diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..176a458
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto
diff --git a/.gitignore b/.gitignore
index c0740b5..2592865 100644
--- a/.gitignore
+++ b/.gitignore
@@ -75,15 +75,15 @@ crashlytics-build.properties
# Packed Addressables
/[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin*
-# Temporary auto-generated Android Assets
-/[Aa]ssets/[Ss]treamingAssets/aa.meta
-/[Aa]ssets/[Ss]treamingAssets/aa/*
-
+/Assets/StreamingAssets
+/Assets/StreamingAssets.meta
/UI参考
-/AGENTS.md
/bin
/docs/screenshot
*.xmind
/数据表/__pycache__/
+/类吸血鬼项目.md
~$*.xlsx
+Assets/GameMain/Configs/ResourceBuilder.xml
+/.dotnet
diff --git a/Assets/GameFramework/Scripts/Editor/Misc/Type.cs b/Assets/GameFramework/Scripts/Editor/Misc/Type.cs
index 1cb34d1..d75456c 100644
--- a/Assets/GameFramework/Scripts/Editor/Misc/Type.cs
+++ b/Assets/GameFramework/Scripts/Editor/Misc/Type.cs
@@ -22,6 +22,7 @@ namespace UnityGameFramework.Editor
"UnityGameFramework.Runtime",
#endif
"Assembly-CSharp",
+ "VampireLike"
};
private static readonly string[] RuntimeOrEditorAssemblyNames =
@@ -34,6 +35,8 @@ namespace UnityGameFramework.Editor
"UnityGameFramework.Editor",
#endif
"Assembly-CSharp-Editor",
+ "VampireLike",
+ "VampireLike.Editor"
};
///
diff --git a/Assets/GameMain/Configs/ResourceBuilder.xml b/Assets/GameMain/Configs/ResourceBuilder.xml
index a787b58..0298581 100644
--- a/Assets/GameMain/Configs/ResourceBuilder.xml
+++ b/Assets/GameMain/Configs/ResourceBuilder.xml
@@ -2,17 +2,17 @@
- 3
+ 1
33
1
UnityGameFramework.Runtime.DefaultCompressionHelper
- True
+ False
False
- StarForce.Editor.StarForceBuildEventHandler
- D:/Learn/GameLearn/UnityProjects/VampireLike/bin/AssetBundles
+ VampireLike.Editor.VampireLikeBuildEventHandler
+ C:/UnityProjects/VampireLike/bin/AssetBundles
True
- True
- True
+ False
+ False
\ No newline at end of file
diff --git a/Assets/GameMain/Configs/ResourceBuilder.xml.meta b/Assets/GameMain/Configs/ResourceBuilder.xml.meta
index 2a95a18..f61dc84 100644
--- a/Assets/GameMain/Configs/ResourceBuilder.xml.meta
+++ b/Assets/GameMain/Configs/ResourceBuilder.xml.meta
@@ -1,7 +1,5 @@
fileFormatVersion: 2
guid: 5461b0fc87a2ab04fbcfd898d18f6107
-labels:
-- ResourceExclusive
TextScriptImporter:
externalObjects: {}
userData:
diff --git a/Assets/GameMain/Configs/ResourceCollection.xml b/Assets/GameMain/Configs/ResourceCollection.xml
index 5abf749..fc24acd 100644
--- a/Assets/GameMain/Configs/ResourceCollection.xml
+++ b/Assets/GameMain/Configs/ResourceCollection.xml
@@ -17,6 +17,7 @@
+
@@ -42,6 +43,7 @@
+
@@ -83,6 +85,7 @@
+
@@ -94,6 +97,7 @@
+
@@ -133,6 +137,7 @@
+
@@ -157,6 +162,7 @@
+
diff --git a/Assets/GameMain/DataTables/Enemy.txt b/Assets/GameMain/DataTables/Enemy.txt
index 9263fe4..f3668f4 100644
--- a/Assets/GameMain/DataTables/Enemy.txt
+++ b/Assets/GameMain/DataTables/Enemy.txt
@@ -1,5 +1,6 @@
-# 敌人基础属性表
-# Id EntityTypeId MaxHealth HpAddPerLevel Speed CoinDrop ExpDrop DropPercent
-# int int int int float int int float
-# 敌人编号 策划备注 敌人实体编号 最大生命 每关卡增加生命 移动速度 金币掉落 经验掉落 掉落概率
- 1 近战敌人 101 50 50 3 5 1 0.3
+# 敌人基础属性表
+# Id EntityTypeId MaxHealth HpAddPerLevel AttackDamage AttackCooldown AttackRange Speed CoinDrop ExpDrop DropPercent Params
+# int int int int int float float float int int float string
+# 敌人编号 策划备注 敌人实体编号 最大生命 每关卡增加生命 基础伤害 攻击间隔 攻击范围 移动速度 金币掉落 经验掉落 掉落概率 额外参数
+ 1 近战敌人 101 50 50 1 1 1.5 3 5 1 0.3 []
+ 2 远程敌人 102 40 40 1 2 8 2.5 4 2 0.2 []
diff --git a/Assets/GameMain/DataTables/Entity.txt b/Assets/GameMain/DataTables/Entity.txt
index 9ed7bb4..bd1e86b 100644
--- a/Assets/GameMain/DataTables/Entity.txt
+++ b/Assets/GameMain/DataTables/Entity.txt
@@ -2,12 +2,14 @@
# Id AssetName
# int string
# 实体编号 策划备注 资源名称
+ 11 跟随相机 FollowCamera
1001 测试玩家 Player
101 近战敌人 MeleeEnemy
102 远程敌人 RemoteEnemy
- 11 跟随相机 FollowCamera
201 武器小刀 WeaponKnife
202 武器手枪 WeaponHandgun
203 武器斧头 WeaponSlash
+ 204 武器闪电 WeaponLightning
+ 205 武器长枪 WeaponLance
10001 金币实体 CoinEntity
10002 经验实体 ExpEntity
diff --git a/Assets/GameMain/DataTables/Goods.txt b/Assets/GameMain/DataTables/Goods.txt
index 67b569f..7b83641 100644
--- a/Assets/GameMain/DataTables/Goods.txt
+++ b/Assets/GameMain/DataTables/Goods.txt
@@ -1,5 +1,4 @@
-# 商品表
-# Id GoodsType GoodsTypeId
+# Id 列1 GoodsType GoodsTypeId
# int GoodsType int
# 商品编号 策划备注 商品类型 商品对应物品Id
101 道具:药 Prop 101
@@ -25,3 +24,5 @@
121 Prop 119
122 Prop 120
123 Weapon 3
+ 124 Weapon 4
+ 125 Weapon 5
diff --git a/Assets/GameMain/DataTables/Level.txt b/Assets/GameMain/DataTables/Level.txt
index 47c4447..43a1f10 100644
--- a/Assets/GameMain/DataTables/Level.txt
+++ b/Assets/GameMain/DataTables/Level.txt
@@ -2,7 +2,7 @@
# Id EnemyTypes EntityCounts Interval Duration
# int int[] int[] float[] int
# 关卡号 策划备注 敌人类型 每次出怪数量 每次出怪间隔 关卡时间
- 1 第一关 [1] [5] [2] 60
+ 1 第一关 [1,2] [5,2] [4,5] 60
2 第二关 [1] [10] [3] 60
3 第三关 [1] [10] [3] 60
4 第四关 [1] [10] [3] 60
diff --git a/Assets/GameMain/DataTables/Weapon.txt b/Assets/GameMain/DataTables/Weapon.txt
index 00a6658..921e7c9 100644
--- a/Assets/GameMain/DataTables/Weapon.txt
+++ b/Assets/GameMain/DataTables/Weapon.txt
@@ -1,7 +1,8 @@
-# 武器表
-# Id EntityTypeId Title IconAssetName Rarity Price PriceRandomPercent Attack Cooldown AttackRange AttackSoundId Pramas Modifiers
+# Id 列1 EntityTypeId Title IconAssetName Rarity Price PriceRandomPercent Attack Cooldown AttackRange AttackSoundId Pramas Modifiers
# int int string string RarityType int float int float float int string[] StatModifier[]
# 武器编号 策划备注 武器实体编号 武器名 图标资源名 道具品质 武器价格 价格浮动 伤害 冷却 范围 攻击音效编号 额外参数 额外属性
- 1 玩家武器 201 小刀 Almighty_Icon White 120 0.05 100 1.5 5 10000 [hitRadius:2] []
- 2 202 手枪 Almighty_Icon White 130 0.05 120 1 15 10000 [] []
- 3 203 斧头 Almighty_Icon White 100 0.1 150 2 5 10000 [SectorAngle:120] []
+ 1 玩家武器 201 小刀 Almighty_Icon White 120 0.05 100 1.5 5 10000 {"hitRadius":2} []
+ 2 202 手枪 Almighty_Icon White 130 0.05 120 1 15 10000 {} []
+ 3 203 斧头 Almighty_Icon White 100 0.1 150 2 5 10000 {"sectorAngle":120} []
+ 4 204 闪电 Almighty_Icon White 150 0.08 80 3 12 10000 {"hitRadius":3} []
+ 5 205 长枪 Almighty_Icon White 100 0.1 100 1.5 5 10000 {"hitHalfWidth":0.7,"pierceLength":4.5,"hitHeight":0.5,"hitCenterYOffset":0}
diff --git a/Assets/GameMain/Entities/MeleeEnemy.prefab b/Assets/GameMain/Entities/MeleeEnemy.prefab
index 6099332..1d6c23a 100644
--- a/Assets/GameMain/Entities/MeleeEnemy.prefab
+++ b/Assets/GameMain/Entities/MeleeEnemy.prefab
@@ -11,7 +11,6 @@ GameObject:
- component: {fileID: 7683855655592166216}
- component: {fileID: 6418687210998749921}
- component: {fileID: 4710806460657047075}
- - component: {fileID: 8116679074104541426}
- component: {fileID: 1932268889601128120}
- component: {fileID: 557030043145096197}
- component: {fileID: 6353753365317756414}
@@ -87,33 +86,6 @@ MeshRenderer:
m_SortingLayer: 0
m_SortingOrder: 0
m_AdditionalVertexStreams: {fileID: 0}
---- !u!54 &8116679074104541426
-Rigidbody:
- m_ObjectHideFlags: 0
- m_CorrespondingSourceObject: {fileID: 0}
- m_PrefabInstance: {fileID: 0}
- m_PrefabAsset: {fileID: 0}
- m_GameObject: {fileID: 9166462022471897675}
- serializedVersion: 4
- m_Mass: 1
- m_Drag: 0
- m_AngularDrag: 0.05
- m_CenterOfMass: {x: 0, y: 0, z: 0}
- m_InertiaTensor: {x: 1, y: 1, z: 1}
- m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1}
- m_IncludeLayers:
- serializedVersion: 2
- m_Bits: 0
- m_ExcludeLayers:
- serializedVersion: 2
- m_Bits: 5376
- m_ImplicitCom: 1
- m_ImplicitTensor: 1
- m_UseGravity: 0
- m_IsKinematic: 1
- m_Interpolate: 0
- m_Constraints: 0
- m_CollisionDetection: 0
--- !u!136 &1932268889601128120
CapsuleCollider:
m_ObjectHideFlags: 0
@@ -131,7 +103,7 @@ CapsuleCollider:
m_LayerOverridePriority: 0
m_IsTrigger: 1
m_ProvidesContacts: 0
- m_Enabled: 1
+ m_Enabled: 0
serializedVersion: 2
m_Radius: 0.5
m_Height: 2
@@ -154,7 +126,7 @@ MonoBehaviour:
_cachedTransform: {fileID: 7683855655592166216}
_avoidEnemyOverlap: 0
_enemyBodyRadius: 0.45
- _separationIterations: 2
+ _separationIterations: 5
_speedBase: 0
--- !u!114 &6353753365317756414
MonoBehaviour:
diff --git a/Assets/GameMain/Entities/Player.prefab b/Assets/GameMain/Entities/Player.prefab
index bc58f51..8e8b24f 100644
--- a/Assets/GameMain/Entities/Player.prefab
+++ b/Assets/GameMain/Entities/Player.prefab
@@ -150,13 +150,13 @@ Transform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5383497626468778460}
serializedVersion: 2
- m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068}
- m_LocalPosition: {x: 0, y: 15, z: 0}
+ m_LocalRotation: {x: 0.5, y: 0, z: 0, w: 0.8660254}
+ m_LocalPosition: {x: 0, y: 15, z: -10}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 9112716898534404901}
- m_LocalEulerAnglesHint: {x: 90, y: 0, z: 0}
+ m_LocalEulerAnglesHint: {x: 60, y: 0, z: 0}
--- !u!20 &4064848608618185461
Camera:
m_ObjectHideFlags: 0
@@ -189,9 +189,9 @@ Camera:
width: 1
height: 1
near clip plane: 0.3
- far clip plane: 100
+ far clip plane: 200
field of view: 80
- orthographic: 1
+ orthographic: 0
orthographic size: 15
m_Depth: 0
m_CullingMask:
diff --git a/Assets/GameMain/Entities/RemoteEnemy.prefab b/Assets/GameMain/Entities/RemoteEnemy.prefab
index 06f7b4b..4facb8d 100644
--- a/Assets/GameMain/Entities/RemoteEnemy.prefab
+++ b/Assets/GameMain/Entities/RemoteEnemy.prefab
@@ -14,7 +14,7 @@ GameObject:
- component: {fileID: 1932268889601128120}
- component: {fileID: 557030043145096197}
- component: {fileID: 6353753365317756414}
- m_Layer: 7
+ m_Layer: 8
m_Name: RemoteEnemy
m_TagString: Untagged
m_Icon: {fileID: 0}
@@ -103,7 +103,7 @@ CapsuleCollider:
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
- m_Enabled: 1
+ m_Enabled: 0
serializedVersion: 2
m_Radius: 0.5
m_Height: 2
@@ -124,6 +124,9 @@ MonoBehaviour:
_isMoving: 0
_direction: {x: 0, y: 0, z: 0}
_cachedTransform: {fileID: 0}
+ _avoidEnemyOverlap: 0
+ _enemyBodyRadius: 0.45
+ _separationIterations: 2
_speedBase: 0
--- !u!114 &6353753365317756414
MonoBehaviour:
diff --git a/Assets/GameMain/Entities/WeaponLance.prefab b/Assets/GameMain/Entities/WeaponLance.prefab
new file mode 100644
index 0000000..6cf8f57
--- /dev/null
+++ b/Assets/GameMain/Entities/WeaponLance.prefab
@@ -0,0 +1,225 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &6354441506395502586
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 8872382261416578947}
+ - component: {fileID: 1092941560137749238}
+ - component: {fileID: 2293075059394330032}
+ m_Layer: 11
+ m_Name: Cylinder
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &8872382261416578947
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6354441506395502586}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068}
+ m_LocalPosition: {x: 0, y: 0, z: 0.4}
+ m_LocalScale: {x: 0.5, y: 0.3, z: 0.5}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 5097192555115739519}
+ m_LocalEulerAnglesHint: {x: 90, y: 0, z: 0}
+--- !u!33 &1092941560137749238
+MeshFilter:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6354441506395502586}
+ m_Mesh: {fileID: 10206, guid: 0000000000000000e000000000000000, type: 0}
+--- !u!23 &2293075059394330032
+MeshRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6354441506395502586}
+ m_Enabled: 1
+ m_CastShadows: 1
+ m_ReceiveShadows: 1
+ m_DynamicOccludee: 1
+ m_StaticShadowCaster: 0
+ m_MotionVectors: 1
+ m_LightProbeUsage: 1
+ m_ReflectionProbeUsage: 1
+ m_RayTracingMode: 2
+ m_RayTraceProcedural: 0
+ m_RenderingLayerMask: 1
+ m_RendererPriority: 0
+ m_Materials:
+ - {fileID: 2100000, guid: c4f37184fcb9306428d7d002f7dca96d, type: 2}
+ m_StaticBatchInfo:
+ firstSubMesh: 0
+ subMeshCount: 0
+ m_StaticBatchRoot: {fileID: 0}
+ m_ProbeAnchor: {fileID: 0}
+ m_LightProbeVolumeOverride: {fileID: 0}
+ m_ScaleInLightmap: 1
+ m_ReceiveGI: 1
+ m_PreserveUVs: 0
+ m_IgnoreNormalsForChartDetection: 0
+ m_ImportantGI: 0
+ m_StitchLightmapSeams: 1
+ m_SelectedEditorRenderState: 3
+ m_MinimumChartSize: 4
+ m_AutoUVMaxDistance: 0.5
+ m_AutoUVMaxAngle: 89
+ m_LightmapParameters: {fileID: 0}
+ m_SortingLayerID: 0
+ m_SortingLayer: 0
+ m_SortingOrder: 0
+ m_AdditionalVertexStreams: {fileID: 0}
+--- !u!1 &6722279723536450523
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 4238244161129684256}
+ - component: {fileID: 8136283925019532162}
+ - component: {fileID: 452598937405325984}
+ - component: {fileID: 6200741578935482964}
+ m_Layer: 11
+ m_Name: Cylinder 1
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &4238244161129684256
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6722279723536450523}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068}
+ m_LocalPosition: {x: 0, y: 0, z: 0.7}
+ m_LocalScale: {x: 0.2, y: 0.3, z: 0.2}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 5097192555115739519}
+ m_LocalEulerAnglesHint: {x: 90, y: 0, z: 0}
+--- !u!33 &8136283925019532162
+MeshFilter:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6722279723536450523}
+ m_Mesh: {fileID: 10206, guid: 0000000000000000e000000000000000, type: 0}
+--- !u!23 &452598937405325984
+MeshRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6722279723536450523}
+ m_Enabled: 1
+ m_CastShadows: 1
+ m_ReceiveShadows: 1
+ m_DynamicOccludee: 1
+ m_StaticShadowCaster: 0
+ m_MotionVectors: 1
+ m_LightProbeUsage: 1
+ m_ReflectionProbeUsage: 1
+ m_RayTracingMode: 2
+ m_RayTraceProcedural: 0
+ m_RenderingLayerMask: 1
+ m_RendererPriority: 0
+ m_Materials:
+ - {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
+ m_StaticBatchInfo:
+ firstSubMesh: 0
+ subMeshCount: 0
+ m_StaticBatchRoot: {fileID: 0}
+ m_ProbeAnchor: {fileID: 0}
+ m_LightProbeVolumeOverride: {fileID: 0}
+ m_ScaleInLightmap: 1
+ m_ReceiveGI: 1
+ m_PreserveUVs: 0
+ m_IgnoreNormalsForChartDetection: 0
+ m_ImportantGI: 0
+ m_StitchLightmapSeams: 1
+ m_SelectedEditorRenderState: 3
+ m_MinimumChartSize: 4
+ m_AutoUVMaxDistance: 0.5
+ m_AutoUVMaxAngle: 89
+ m_LightmapParameters: {fileID: 0}
+ m_SortingLayerID: 0
+ m_SortingLayer: 0
+ m_SortingOrder: 0
+ m_AdditionalVertexStreams: {fileID: 0}
+--- !u!136 &6200741578935482964
+CapsuleCollider:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6722279723536450523}
+ m_Material: {fileID: 0}
+ m_IncludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_ExcludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_LayerOverridePriority: 0
+ m_IsTrigger: 0
+ m_ProvidesContacts: 0
+ m_Enabled: 1
+ serializedVersion: 2
+ m_Radius: 0.5000001
+ m_Height: 2
+ m_Direction: 1
+ m_Center: {x: 0.000000059604645, y: 0, z: -0.00000008940697}
+--- !u!1 &7825103691467368365
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 5097192555115739519}
+ m_Layer: 11
+ m_Name: WeaponLance
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &5097192555115739519
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 7825103691467368365}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children:
+ - {fileID: 8872382261416578947}
+ - {fileID: 4238244161129684256}
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
diff --git a/Assets/StreamingAssets/GameData.dat.meta b/Assets/GameMain/Entities/WeaponLance.prefab.meta
similarity index 63%
rename from Assets/StreamingAssets/GameData.dat.meta
rename to Assets/GameMain/Entities/WeaponLance.prefab.meta
index 9b7bf66..541f6e3 100644
--- a/Assets/StreamingAssets/GameData.dat.meta
+++ b/Assets/GameMain/Entities/WeaponLance.prefab.meta
@@ -1,6 +1,6 @@
fileFormatVersion: 2
-guid: 9b0d24fd3a44b6f45b3794cdfefd1ac0
-DefaultImporter:
+guid: 11143001bcbdc864b8d8fe2083142e5a
+PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
diff --git a/Assets/GameMain/Entities/WeaponLightning.prefab b/Assets/GameMain/Entities/WeaponLightning.prefab
new file mode 100644
index 0000000..2a841ca
--- /dev/null
+++ b/Assets/GameMain/Entities/WeaponLightning.prefab
@@ -0,0 +1,141 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &3331174537915643484
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 5009597865007941286}
+ - component: {fileID: 2357377495634329419}
+ - component: {fileID: 5508018305229695465}
+ - component: {fileID: 6560911649159431343}
+ m_Layer: 11
+ m_Name: Capsule
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &5009597865007941286
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3331174537915643484}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 0.4, y: 0.5, z: 0.4}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 1074967493716089666}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!33 &2357377495634329419
+MeshFilter:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3331174537915643484}
+ m_Mesh: {fileID: 10208, guid: 0000000000000000e000000000000000, type: 0}
+--- !u!23 &5508018305229695465
+MeshRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3331174537915643484}
+ m_Enabled: 1
+ m_CastShadows: 1
+ m_ReceiveShadows: 1
+ m_DynamicOccludee: 1
+ m_StaticShadowCaster: 0
+ m_MotionVectors: 1
+ m_LightProbeUsage: 1
+ m_ReflectionProbeUsage: 1
+ m_RayTracingMode: 2
+ m_RayTraceProcedural: 0
+ m_RenderingLayerMask: 1
+ m_RendererPriority: 0
+ m_Materials:
+ - {fileID: 2100000, guid: 429ed03405bf8854eab46552b7470ac0, type: 2}
+ m_StaticBatchInfo:
+ firstSubMesh: 0
+ subMeshCount: 0
+ m_StaticBatchRoot: {fileID: 0}
+ m_ProbeAnchor: {fileID: 0}
+ m_LightProbeVolumeOverride: {fileID: 0}
+ m_ScaleInLightmap: 1
+ m_ReceiveGI: 1
+ m_PreserveUVs: 0
+ m_IgnoreNormalsForChartDetection: 0
+ m_ImportantGI: 0
+ m_StitchLightmapSeams: 1
+ m_SelectedEditorRenderState: 3
+ m_MinimumChartSize: 4
+ m_AutoUVMaxDistance: 0.5
+ m_AutoUVMaxAngle: 89
+ m_LightmapParameters: {fileID: 0}
+ m_SortingLayerID: 0
+ m_SortingLayer: 0
+ m_SortingOrder: 0
+ m_AdditionalVertexStreams: {fileID: 0}
+--- !u!136 &6560911649159431343
+CapsuleCollider:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3331174537915643484}
+ m_Material: {fileID: 0}
+ m_IncludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_ExcludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_LayerOverridePriority: 0
+ m_IsTrigger: 0
+ m_ProvidesContacts: 0
+ m_Enabled: 1
+ serializedVersion: 2
+ m_Radius: 0.5
+ m_Height: 2
+ m_Direction: 1
+ m_Center: {x: 0, y: 0, z: 0}
+--- !u!1 &4668848878531932975
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1074967493716089666}
+ m_Layer: 11
+ m_Name: WeaponLightning
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &1074967493716089666
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 4668848878531932975}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children:
+ - {fileID: 5009597865007941286}
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
diff --git a/Assets/GameMain/Entities/WeaponLightning.prefab.meta b/Assets/GameMain/Entities/WeaponLightning.prefab.meta
new file mode 100644
index 0000000..eaa5f35
--- /dev/null
+++ b/Assets/GameMain/Entities/WeaponLightning.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 9d193ac5b4294e0e9ba6e867320944b7
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Assets/GameMain/Fonts/Shaders/TMPro.cginc b/Assets/GameMain/Fonts/Shaders/TMPro.cginc
new file mode 100644
index 0000000..5898130
--- /dev/null
+++ b/Assets/GameMain/Fonts/Shaders/TMPro.cginc
@@ -0,0 +1,84 @@
+float2 UnpackUV(float uv)
+{
+ float2 output;
+ output.x = floor(uv / 4096);
+ output.y = uv - 4096 * output.x;
+
+ return output * 0.001953125;
+}
+
+fixed4 GetColor(half d, fixed4 faceColor, fixed4 outlineColor, half outline, half softness)
+{
+ half faceAlpha = 1-saturate((d - outline * 0.5 + softness * 0.5) / (1.0 + softness));
+ half outlineAlpha = saturate((d + outline * 0.5)) * sqrt(min(1.0, outline));
+
+ faceColor.rgb *= faceColor.a;
+ outlineColor.rgb *= outlineColor.a;
+
+ faceColor = lerp(faceColor, outlineColor, outlineAlpha);
+
+ faceColor *= faceAlpha;
+
+ return faceColor;
+}
+
+float3 GetSurfaceNormal(float4 h, float bias)
+{
+ bool raisedBevel = step(1, fmod(_ShaderFlags, 2));
+
+ h += bias+_BevelOffset;
+
+ float bevelWidth = max(.01, _OutlineWidth+_BevelWidth);
+
+ // Track outline
+ h -= .5;
+ h /= bevelWidth;
+ h = saturate(h+.5);
+
+ if(raisedBevel) h = 1 - abs(h*2.0 - 1.0);
+ h = lerp(h, sin(h*3.141592/2.0), _BevelRoundness);
+ h = min(h, 1.0-_BevelClamp);
+ h *= _Bevel * bevelWidth * _GradientScale * -2.0;
+
+ float3 va = normalize(float3(1.0, 0.0, h.y - h.x));
+ float3 vb = normalize(float3(0.0, -1.0, h.w - h.z));
+
+ return cross(va, vb);
+}
+
+float3 GetSurfaceNormal(float2 uv, float bias, float3 delta)
+{
+ // Read "height field"
+ float4 h = {tex2D(_MainTex, uv - delta.xz).a,
+ tex2D(_MainTex, uv + delta.xz).a,
+ tex2D(_MainTex, uv - delta.zy).a,
+ tex2D(_MainTex, uv + delta.zy).a};
+
+ return GetSurfaceNormal(h, bias);
+}
+
+float3 GetSpecular(float3 n, float3 l)
+{
+ float spec = pow(max(0.0, dot(n, l)), _Reflectivity);
+ return _SpecularColor.rgb * spec * _SpecularPower;
+}
+
+float4 GetGlowColor(float d, float scale)
+{
+ float glow = d - (_GlowOffset*_ScaleRatioB) * 0.5 * scale;
+ float t = lerp(_GlowInner, (_GlowOuter * _ScaleRatioB), step(0.0, glow)) * 0.5 * scale;
+ glow = saturate(abs(glow/(1.0 + t)));
+ glow = 1.0-pow(glow, _GlowPower);
+ glow *= sqrt(min(1.0, t)); // Fade off glow thinner than 1 screen pixel
+ return float4(_GlowColor.rgb, saturate(_GlowColor.a * glow * 2));
+}
+
+float4 BlendARGB(float4 overlying, float4 underlying)
+{
+ overlying.rgb *= overlying.a;
+ underlying.rgb *= underlying.a;
+ float3 blended = overlying.rgb + ((1-overlying.a)*underlying.rgb);
+ float alpha = underlying.a + (1-underlying.a)*overlying.a;
+ return float4(blended, alpha);
+}
+
diff --git a/Assets/StreamingAssets/Resources.dat.meta b/Assets/GameMain/Fonts/Shaders/TMPro.cginc.meta
similarity index 61%
rename from Assets/StreamingAssets/Resources.dat.meta
rename to Assets/GameMain/Fonts/Shaders/TMPro.cginc.meta
index e56784a..f8a43be 100644
--- a/Assets/StreamingAssets/Resources.dat.meta
+++ b/Assets/GameMain/Fonts/Shaders/TMPro.cginc.meta
@@ -1,6 +1,6 @@
fileFormatVersion: 2
-guid: 0372088d74296e44c9eb9185a2d4021e
-DefaultImporter:
+guid: dba6242363c96c44eb6ec1e124d487e4
+ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
diff --git a/Assets/GameMain/Fonts/Shaders/TMPro_Mobile.cginc b/Assets/GameMain/Fonts/Shaders/TMPro_Mobile.cginc
new file mode 100644
index 0000000..5969fec
--- /dev/null
+++ b/Assets/GameMain/Fonts/Shaders/TMPro_Mobile.cginc
@@ -0,0 +1,157 @@
+struct vertex_t {
+ UNITY_VERTEX_INPUT_INSTANCE_ID
+ float4 position : POSITION;
+ float3 normal : NORMAL;
+ float4 color : COLOR;
+ float2 texcoord0 : TEXCOORD0;
+ float2 texcoord1 : TEXCOORD1;
+};
+
+struct pixel_t {
+ UNITY_VERTEX_INPUT_INSTANCE_ID
+ UNITY_VERTEX_OUTPUT_STEREO
+ float4 position : SV_POSITION;
+ float4 faceColor : COLOR;
+ float4 outlineColor : COLOR1;
+ float4 texcoord0 : TEXCOORD0;
+ float4 param : TEXCOORD1; // weight, scaleRatio
+ float2 mask : TEXCOORD2;
+ #if (UNDERLAY_ON || UNDERLAY_INNER)
+ float4 texcoord2 : TEXCOORD3;
+ float4 underlayColor : COLOR2;
+ #endif
+};
+
+float4 SRGBToLinear(float4 rgba) {
+ return float4(lerp(rgba.rgb / 12.92f, pow((rgba.rgb + 0.055f) / 1.055f, 2.4f), step(0.04045f, rgba.rgb)), rgba.a);
+}
+
+pixel_t VertShader(vertex_t input)
+{
+ pixel_t output;
+
+ UNITY_INITIALIZE_OUTPUT(pixel_t, output);
+ UNITY_SETUP_INSTANCE_ID(input);
+ UNITY_TRANSFER_INSTANCE_ID(input, output);
+ UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
+
+ float bold = step(input.texcoord1.y, 0);
+
+ float4 vert = input.position;
+ vert.x += _VertexOffsetX;
+ vert.y += _VertexOffsetY;
+
+ float4 vPosition = UnityObjectToClipPos(vert);
+
+ float weight = lerp(_WeightNormal, _WeightBold, bold) / 4.0;
+ weight = (weight + _FaceDilate) * _ScaleRatioA * 0.5;
+
+ // Generate UV for the Masking Texture
+ float4 clampedRect = clamp(_ClipRect, -2e10, 2e10);
+ float2 maskUV = (vert.xy - clampedRect.xy) / (clampedRect.zw - clampedRect.xy);
+
+ float4 color = input.color;
+ #if (FORCE_LINEAR && !UNITY_COLORSPACE_GAMMA)
+ color = SRGBToLinear(input.color);
+ #endif
+
+ float opacity = color.a;
+ #if (UNDERLAY_ON | UNDERLAY_INNER)
+ opacity = 1.0;
+ #endif
+
+ float4 faceColor = float4(color.rgb, opacity) * _FaceColor;
+ faceColor.rgb *= faceColor.a;
+
+ float4 outlineColor = _OutlineColor;
+ outlineColor.a *= opacity;
+ outlineColor.rgb *= outlineColor.a;
+
+ output.position = vPosition;
+ output.faceColor = faceColor;
+ output.outlineColor = outlineColor;
+ output.texcoord0 = float4(input.texcoord0.xy, maskUV.xy);
+ output.param = float4(0.5 - weight, 1.3333 * _GradientScale * (_Sharpness + 1) / _TextureWidth, _OutlineWidth * _ScaleRatioA * 0.5, 0);
+
+ float2 mask = float2(0, 0);
+ #if UNITY_UI_CLIP_RECT
+ mask = vert.xy * 2 - clampedRect.xy - clampedRect.zw;
+ #endif
+ output.mask = mask;
+
+ #if (UNDERLAY_ON || UNDERLAY_INNER)
+ float4 underlayColor = _UnderlayColor;
+ underlayColor.rgb *= underlayColor.a;
+
+ float x = -(_UnderlayOffsetX * _ScaleRatioC) * _GradientScale / _TextureWidth;
+ float y = -(_UnderlayOffsetY * _ScaleRatioC) * _GradientScale / _TextureHeight;
+
+ output.texcoord2 = float4(input.texcoord0 + float2(x, y), input.color.a, 0);
+ output.underlayColor = underlayColor;
+ #endif
+
+ return output;
+}
+
+float4 PixShader(pixel_t input) : SV_Target
+{
+ UNITY_SETUP_INSTANCE_ID(input);
+
+ float d = tex2D(_MainTex, input.texcoord0.xy).a;
+
+ float2 UV = input.texcoord0.xy;
+ float scale = rsqrt(abs(ddx(UV.x) * ddy(UV.y) - ddy(UV.x) * ddx(UV.y))) * input.param.y;
+
+ #if (UNDERLAY_ON | UNDERLAY_INNER)
+ float layerScale = scale;
+ layerScale /= 1 + ((_UnderlaySoftness * _ScaleRatioC) * layerScale);
+ float layerBias = input.param.x * layerScale - .5 - ((_UnderlayDilate * _ScaleRatioC) * .5 * layerScale);
+ #endif
+
+ scale /= 1 + (_OutlineSoftness * _ScaleRatioA * scale);
+
+ float4 faceColor = input.faceColor * saturate((d - input.param.x) * scale + 0.5);
+
+ #ifdef OUTLINE_ON
+ float4 outlineColor = lerp(input.faceColor, input.outlineColor, sqrt(min(1.0, input.param.z * scale * 2)));
+ faceColor = lerp(outlineColor, input.faceColor, saturate((d - input.param.x - input.param.z) * scale + 0.5));
+ faceColor *= saturate((d - input.param.x + input.param.z) * scale + 0.5);
+ #endif
+
+ #if UNDERLAY_ON
+ d = tex2D(_MainTex, input.texcoord2.xy).a * layerScale;
+ faceColor += float4(_UnderlayColor.rgb * _UnderlayColor.a, _UnderlayColor.a) * saturate(d - layerBias) * (1 - faceColor.a);
+ #endif
+
+ #if UNDERLAY_INNER
+ float bias = input.param.x * scale - 0.5;
+ float sd = saturate(d * scale - bias - input.param.z);
+ d = tex2D(_MainTex, input.texcoord2.xy).a * layerScale;
+ faceColor += float4(_UnderlayColor.rgb * _UnderlayColor.a, _UnderlayColor.a) * (1 - saturate(d - layerBias)) * sd * (1 - faceColor.a);
+ #endif
+
+ #ifdef MASKING
+ float a = abs(_MaskInverse - tex2D(_MaskTex, input.texcoord0.zw).a);
+ float t = a + (1 - _MaskWipeControl) * _MaskEdgeSoftness - _MaskWipeControl;
+ a = saturate(t / _MaskEdgeSoftness);
+ faceColor.rgb = lerp(_MaskEdgeColor.rgb * faceColor.a, faceColor.rgb, a);
+ faceColor *= a;
+ #endif
+
+ // Alternative implementation to UnityGet2DClipping with support for softness
+ #if UNITY_UI_CLIP_RECT
+ float2 maskZW = 0.25 / (0.25 * half2(_MaskSoftnessX, _MaskSoftnessY) + (1 / scale));
+ float2 m = saturate((_ClipRect.zw - _ClipRect.xy - abs(input.mask.xy)) * maskZW);
+ faceColor *= m.x * m.y;
+ #endif
+
+ #if (UNDERLAY_ON | UNDERLAY_INNER)
+ faceColor *= input.texcoord2.z;
+ #endif
+
+ #if UNITY_UI_ALPHACLIP
+ clip(faceColor.a - 0.001);
+ #endif
+
+ return faceColor;
+}
diff --git a/Assets/StreamingAssets/GameFrameworkVersion.dat.meta b/Assets/GameMain/Fonts/Shaders/TMPro_Mobile.cginc.meta
similarity index 61%
rename from Assets/StreamingAssets/GameFrameworkVersion.dat.meta
rename to Assets/GameMain/Fonts/Shaders/TMPro_Mobile.cginc.meta
index a46833a..2a52275 100644
--- a/Assets/StreamingAssets/GameFrameworkVersion.dat.meta
+++ b/Assets/GameMain/Fonts/Shaders/TMPro_Mobile.cginc.meta
@@ -1,6 +1,6 @@
fileFormatVersion: 2
-guid: a7d40db10aa401f4db624fec03b19854
-DefaultImporter:
+guid: d9a5c27d283a4e7429acad6face5485a
+ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
diff --git a/Assets/GameMain/Fonts/Shaders/TMPro_Properties.cginc b/Assets/GameMain/Fonts/Shaders/TMPro_Properties.cginc
new file mode 100644
index 0000000..2e96258
--- /dev/null
+++ b/Assets/GameMain/Fonts/Shaders/TMPro_Properties.cginc
@@ -0,0 +1,85 @@
+// UI Editable properties
+uniform sampler2D _FaceTex; // Alpha : Signed Distance
+uniform float _FaceUVSpeedX;
+uniform float _FaceUVSpeedY;
+uniform fixed4 _FaceColor; // RGBA : Color + Opacity
+uniform float _FaceDilate; // v[ 0, 1]
+uniform float _OutlineSoftness; // v[ 0, 1]
+
+uniform sampler2D _OutlineTex; // RGBA : Color + Opacity
+uniform float _OutlineUVSpeedX;
+uniform float _OutlineUVSpeedY;
+uniform fixed4 _OutlineColor; // RGBA : Color + Opacity
+uniform float _OutlineWidth; // v[ 0, 1]
+
+uniform float _Bevel; // v[ 0, 1]
+uniform float _BevelOffset; // v[-1, 1]
+uniform float _BevelWidth; // v[-1, 1]
+uniform float _BevelClamp; // v[ 0, 1]
+uniform float _BevelRoundness; // v[ 0, 1]
+
+uniform sampler2D _BumpMap; // Normal map
+uniform float _BumpOutline; // v[ 0, 1]
+uniform float _BumpFace; // v[ 0, 1]
+
+uniform samplerCUBE _Cube; // Cube / sphere map
+uniform fixed4 _ReflectFaceColor; // RGB intensity
+uniform fixed4 _ReflectOutlineColor;
+//uniform float _EnvTiltX; // v[-1, 1]
+//uniform float _EnvTiltY; // v[-1, 1]
+uniform float3 _EnvMatrixRotation;
+uniform float4x4 _EnvMatrix;
+
+uniform fixed4 _SpecularColor; // RGB intensity
+uniform float _LightAngle; // v[ 0,Tau]
+uniform float _SpecularPower; // v[ 0, 1]
+uniform float _Reflectivity; // v[ 5, 15]
+uniform float _Diffuse; // v[ 0, 1]
+uniform float _Ambient; // v[ 0, 1]
+
+uniform fixed4 _UnderlayColor; // RGBA : Color + Opacity
+uniform float _UnderlayOffsetX; // v[-1, 1]
+uniform float _UnderlayOffsetY; // v[-1, 1]
+uniform float _UnderlayDilate; // v[-1, 1]
+uniform float _UnderlaySoftness; // v[ 0, 1]
+
+uniform fixed4 _GlowColor; // RGBA : Color + Intesity
+uniform float _GlowOffset; // v[-1, 1]
+uniform float _GlowOuter; // v[ 0, 1]
+uniform float _GlowInner; // v[ 0, 1]
+uniform float _GlowPower; // v[ 1, 1/(1+4*4)]
+
+// API Editable properties
+uniform float _ShaderFlags;
+uniform float _WeightNormal;
+uniform float _WeightBold;
+
+uniform float _ScaleRatioA;
+uniform float _ScaleRatioB;
+uniform float _ScaleRatioC;
+
+uniform float _VertexOffsetX;
+uniform float _VertexOffsetY;
+
+//uniform float _UseClipRect;
+uniform float _MaskID;
+uniform sampler2D _MaskTex;
+uniform float4 _MaskCoord;
+uniform float4 _ClipRect; // bottom left(x,y) : top right(z,w)
+//uniform float _MaskWipeControl;
+//uniform float _MaskEdgeSoftness;
+//uniform fixed4 _MaskEdgeColor;
+//uniform bool _MaskInverse;
+
+uniform float _MaskSoftnessX;
+uniform float _MaskSoftnessY;
+
+// Font Atlas properties
+uniform sampler2D _MainTex;
+uniform float _TextureWidth;
+uniform float _TextureHeight;
+uniform float _GradientScale;
+uniform float _ScaleX;
+uniform float _ScaleY;
+uniform float _PerspectiveFilter;
+uniform float _Sharpness;
diff --git a/Assets/StreamingAssets/SceneSettings.dat.meta b/Assets/GameMain/Fonts/Shaders/TMPro_Properties.cginc.meta
similarity index 61%
rename from Assets/StreamingAssets/SceneSettings.dat.meta
rename to Assets/GameMain/Fonts/Shaders/TMPro_Properties.cginc.meta
index ec19ff6..1bb2217 100644
--- a/Assets/StreamingAssets/SceneSettings.dat.meta
+++ b/Assets/GameMain/Fonts/Shaders/TMPro_Properties.cginc.meta
@@ -1,6 +1,6 @@
fileFormatVersion: 2
-guid: a5e646a0b90b55940be09d61810d429d
-DefaultImporter:
+guid: 8218173e722cac14996fc86da8882fc8
+ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
diff --git a/Assets/GameMain/Fonts/Shaders/TMPro_Surface.cginc b/Assets/GameMain/Fonts/Shaders/TMPro_Surface.cginc
new file mode 100644
index 0000000..622ae87
--- /dev/null
+++ b/Assets/GameMain/Fonts/Shaders/TMPro_Surface.cginc
@@ -0,0 +1,101 @@
+void VertShader(inout appdata_full v, out Input data)
+{
+ v.vertex.x += _VertexOffsetX;
+ v.vertex.y += _VertexOffsetY;
+
+ UNITY_INITIALIZE_OUTPUT(Input, data);
+
+ float bold = step(v.texcoord1.y, 0);
+
+ // Generate normal for backface
+ float3 view = ObjSpaceViewDir(v.vertex);
+ v.normal *= sign(dot(v.normal, view));
+
+#if USE_DERIVATIVE
+ data.param.y = 1;
+#else
+ float4 vert = v.vertex;
+ float4 vPosition = UnityObjectToClipPos(vert);
+ float2 pixelSize = vPosition.w;
+
+ pixelSize /= float2(_ScaleX, _ScaleY) * mul((float2x2)UNITY_MATRIX_P, _ScreenParams.xy);
+ float scale = rsqrt(dot(pixelSize, pixelSize));
+ scale *= abs(v.texcoord1.y) * _GradientScale * (_Sharpness + 1);
+ scale = lerp(scale * (1 - _PerspectiveFilter), scale, abs(dot(UnityObjectToWorldNormal(v.normal.xyz), normalize(WorldSpaceViewDir(vert)))));
+ data.param.y = scale;
+#endif
+
+ data.param.x = (lerp(_WeightNormal, _WeightBold, bold) / 4.0 + _FaceDilate) * _ScaleRatioA * 0.5; //
+
+ v.texcoord1.xy = UnpackUV(v.texcoord1.x);
+ data.viewDirEnv = mul((float3x3)_EnvMatrix, WorldSpaceViewDir(v.vertex));
+}
+
+void PixShader(Input input, inout SurfaceOutput o)
+{
+
+#if USE_DERIVATIVE
+ float2 pixelSize = float2(ddx(input.uv_MainTex.y), ddy(input.uv_MainTex.y));
+ pixelSize *= _TextureWidth * .75;
+ float scale = rsqrt(dot(pixelSize, pixelSize)) * _GradientScale * (_Sharpness + 1);
+#else
+ float scale = input.param.y;
+#endif
+
+ // Signed distance
+ float c = tex2D(_MainTex, input.uv_MainTex).a;
+ float sd = (.5 - c - input.param.x) * scale + .5;
+ float outline = _OutlineWidth*_ScaleRatioA * scale;
+ float softness = _OutlineSoftness*_ScaleRatioA * scale;
+
+ // Color & Alpha
+ float4 faceColor = _FaceColor;
+ float4 outlineColor = _OutlineColor;
+ faceColor *= input.color;
+ outlineColor.a *= input.color.a;
+ faceColor *= tex2D(_FaceTex, float2(input.uv2_FaceTex.x + _FaceUVSpeedX * _Time.y, input.uv2_FaceTex.y + _FaceUVSpeedY * _Time.y));
+ outlineColor *= tex2D(_OutlineTex, float2(input.uv2_OutlineTex.x + _OutlineUVSpeedX * _Time.y, input.uv2_OutlineTex.y + _OutlineUVSpeedY * _Time.y));
+ faceColor = GetColor(sd, faceColor, outlineColor, outline, softness);
+ faceColor.rgb /= max(faceColor.a, 0.0001);
+
+#if BEVEL_ON
+ float3 delta = float3(1.0 / _TextureWidth, 1.0 / _TextureHeight, 0.0);
+
+ float4 smp4x = {tex2D(_MainTex, input.uv_MainTex - delta.xz).a,
+ tex2D(_MainTex, input.uv_MainTex + delta.xz).a,
+ tex2D(_MainTex, input.uv_MainTex - delta.zy).a,
+ tex2D(_MainTex, input.uv_MainTex + delta.zy).a };
+
+ // Face Normal
+ float3 n = GetSurfaceNormal(smp4x, input.param.x);
+
+ // Bumpmap
+ float3 bump = UnpackNormal(tex2D(_BumpMap, input.uv2_FaceTex.xy)).xyz;
+ bump *= lerp(_BumpFace, _BumpOutline, saturate(sd + outline * 0.5));
+ bump = lerp(float3(0, 0, 1), bump, faceColor.a);
+ n = normalize(n - bump);
+
+ // Cubemap reflection
+ fixed4 reflcol = texCUBE(_Cube, reflect(input.viewDirEnv, mul((float3x3)unity_ObjectToWorld, n)));
+ float3 emission = reflcol.rgb * lerp(_ReflectFaceColor.rgb, _ReflectOutlineColor.rgb, saturate(sd + outline * 0.5)) * faceColor.a;
+#else
+ float3 n = float3(0, 0, -1);
+ float3 emission = float3(0, 0, 0);
+#endif
+
+#if GLOW_ON
+ float4 glowColor = GetGlowColor(sd, scale);
+ glowColor.a *= input.color.a;
+ emission += glowColor.rgb*glowColor.a;
+ faceColor = BlendARGB(glowColor, faceColor);
+ faceColor.rgb /= max(faceColor.a, 0.0001);
+#endif
+
+ // Set Standard output structure
+ o.Albedo = faceColor.rgb;
+ o.Normal = -n;
+ o.Emission = emission;
+ o.Specular = lerp(_FaceShininess, _OutlineShininess, saturate(sd + outline * 0.5));
+ o.Gloss = 1;
+ o.Alpha = faceColor.a;
+}
diff --git a/Assets/GameMain/Fonts/Shaders/TMPro_Surface.cginc.meta b/Assets/GameMain/Fonts/Shaders/TMPro_Surface.cginc.meta
new file mode 100644
index 0000000..8f19b55
--- /dev/null
+++ b/Assets/GameMain/Fonts/Shaders/TMPro_Surface.cginc.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 0511409892ffdb7409b52349d8ceceee
+ShaderIncludeImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Components/MovementComponent.cs b/Assets/GameMain/Scripts/Components/MovementComponent.cs
index 2fc3696..3052135 100644
--- a/Assets/GameMain/Scripts/Components/MovementComponent.cs
+++ b/Assets/GameMain/Scripts/Components/MovementComponent.cs
@@ -2,7 +2,6 @@ using System;
using CustomUtility;
using Definition.DataStruct;
using Definition.Enum;
-using Unity.Profiling;
using UnityEngine;
using CustomDebugger;
diff --git a/Assets/GameMain/Scripts/CustomComponent/DamageText/DamageTextComponent.cs b/Assets/GameMain/Scripts/CustomComponent/DamageText/DamageTextComponent.cs
index 86efb80..19782fc 100644
--- a/Assets/GameMain/Scripts/CustomComponent/DamageText/DamageTextComponent.cs
+++ b/Assets/GameMain/Scripts/CustomComponent/DamageText/DamageTextComponent.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using GameFramework.ObjectPool;
using TMPro;
@@ -9,7 +10,7 @@ namespace CustomComponent
{
public class DamageTextComponent : GameFrameworkComponent
{
- [SerializeField] private int _instancePoolCapacity = 32;
+ [SerializeField] private int _instancePoolCapacity = 256;
[SerializeField] private string _poolName = "DamageTextItem";
@@ -43,14 +44,20 @@ namespace CustomComponent
private DamageTextItem CreateDamageTextItem()
{
+ if (_activeDamageTextItems.Count == _instancePoolCapacity)
+ {
+ _instancePoolCapacity = Mathf.Min(_instancePoolCapacity * 2, 1024);
+ _damageTextItemPool.Capacity = _instancePoolCapacity;
+ }
+
DamageTextItemObject itemObject = _damageTextItemPool.Spawn();
if (itemObject != null)
{
return (DamageTextItem)itemObject.Target;
}
-
+
GameObject itemGo = Instantiate(_damageTextItemPrefab, _instanceRoot, false);
-
+
DamageTextItem item = itemGo.GetComponent();
_damageTextItemPool.Register(DamageTextItemObject.Create(item), true);
return item;
@@ -63,5 +70,12 @@ namespace CustomComponent
_activeDamageTextItems.Remove(item);
_damageTextItemPool.Unspawn(item);
}
+
+ private void OnDestroy()
+ {
+ _activeDamageTextItems.Clear();
+ _damageTextItemPool.Release();
+ _damageTextItemPool = null;
+ }
}
}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs b/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs
index b5d67ad..5f18aed 100644
--- a/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs
+++ b/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs
@@ -1,6 +1,8 @@
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using System;
using System.Linq;
+using Components;
+using CustomEvent;
using DataTable;
using Definition.DataStruct;
using Entity;
@@ -19,8 +21,20 @@ namespace CustomComponent
private const float MinSpawnRate = 0.1f;
private const float CornerTapWindow = 0.6f;
private const int RequiredCornerTapCount = 3;
+ private const int DebugHealAmount = 200;
- private Rect _windowRect = new Rect(20f, 60f, 460f, 620f);
+ [Header("Window Content")]
+ [SerializeField] private bool _showBuffSection = true;
+ [SerializeField] private bool _showBattleOverview = true;
+ [SerializeField] private bool _showCollisionStats = true;
+ [SerializeField] private bool _showSpawnControls = true;
+ [SerializeField] private bool _showBattleDurationControls = true;
+ [SerializeField] private bool _showSeparationSolverControls = true;
+ [SerializeField] private bool _showPlayerWeaponControls = true;
+ [SerializeField] private bool _showPlayerHealthControls = true;
+ [SerializeField] private bool _showTips = true;
+
+ private Rect _windowRect = new Rect(20f, 60f, 460f, 800f);
private bool _isPanelVisible;
private int _windowId;
@@ -38,6 +52,7 @@ namespace CustomComponent
private int _cornerTapCount;
private float _lastCornerTapTime = -10f;
+ private bool _lockPlayerHealthToMax;
protected override void Awake()
{
@@ -54,6 +69,10 @@ namespace CustomComponent
}
HandleCornerTapGesture();
+ if (_lockPlayerHealthToMax)
+ {
+ KeepPlayerHealthAtMax();
+ }
}
private void OnGUI()
@@ -77,20 +96,42 @@ namespace CustomComponent
private void DrawWindow(int windowId)
{
- EnsurePropList();
+ if (_showBuffSection)
+ {
+ EnsurePropList();
+ }
GUILayout.BeginVertical();
- DrawBuffSection();
+ bool hasPreviousSection = false;
+ if (_showBuffSection)
+ {
+ DrawBuffSection();
+ hasPreviousSection = true;
+ }
- GUILayout.Space(8f);
- GUILayout.Label(string.Empty, GUI.skin.horizontalSlider);
- GUILayout.Space(8f);
+ if (HasVisibleBattleSection())
+ {
+ if (hasPreviousSection)
+ {
+ GUILayout.Space(8f);
+ GUILayout.Label(string.Empty, GUI.skin.horizontalSlider);
+ GUILayout.Space(8f);
+ }
- DrawBattleSection();
+ DrawBattleSection();
+ hasPreviousSection = true;
+ }
- GUILayout.Space(8f);
- GUILayout.Label("Tips: press `F8` or tap top-left corner 3 times to toggle.", GUILayout.Height(20f));
+ if (_showTips)
+ {
+ if (hasPreviousSection)
+ {
+ GUILayout.Space(8f);
+ }
+
+ GUILayout.Label("Tips: press `F8` or tap top-left corner 3 times to toggle.", GUILayout.Height(20f));
+ }
GUILayout.EndVertical();
GUI.DragWindow(new Rect(0, 0, 10000, 22));
@@ -147,6 +188,7 @@ namespace CustomComponent
ProcedureGame procedure = GameEntry.Procedure.CurrentProcedure as ProcedureGame;
EnemyManagerComponent enemyManager = GameEntry.EnemyManager;
Player player = FindPlayer();
+ HealthComponent playerHealth = player != null ? player.GetComponent() : null;
if (enemyManager == null)
{
@@ -160,86 +202,160 @@ namespace CustomComponent
return;
}
- GUILayout.Label($"Spawn Rate: {enemyManager.SpawnRateScale:F2}");
- GUILayout.Label($"Battle Time: {enemyManager.ElapsedBattleTime:F1}s / {enemyManager.BattleDuration:F1}s");
- GUILayout.Label($"Enemy Count: {enemyManager.CurrentEnemyCount}");
-
- GUILayout.BeginHorizontal();
- GUILayout.Label("Rate", GUILayout.Width(52f));
- string rateText = GUILayout.TextField(_spawnRateScaleInput.ToString("F2"), GUILayout.Width(60f));
- if (float.TryParse(rateText, out float parsedRate))
+ if (_showBattleOverview)
{
- _spawnRateScaleInput = Mathf.Clamp(parsedRate, MinSpawnRate, 50f);
+ GUILayout.Label($"Spawn Rate: {enemyManager.SpawnRateScale:F2}");
+ GUILayout.Label($"Battle Time: {enemyManager.ElapsedBattleTime:F1}s / {enemyManager.BattleDuration:F1}s");
+ GUILayout.Label($"Enemy Count: {enemyManager.CurrentEnemyCount}");
}
- if (GUILayout.Button("Apply", GUILayout.Width(70f)))
+ Simulation.SimulationWorld simulationWorld = GameEntry.SimulationWorld;
+ if (_showCollisionStats && simulationWorld != null)
{
- enemyManager.SetSpawnRateScale(_spawnRateScaleInput);
- }
-
- if (GUILayout.Button("x0.5", GUILayout.Width(60f)))
- {
- _spawnRateScaleInput = Mathf.Max(MinSpawnRate, enemyManager.SpawnRateScale * 0.5f);
- enemyManager.SetSpawnRateScale(_spawnRateScaleInput);
- }
-
- if (GUILayout.Button("x2", GUILayout.Width(60f)))
- {
- _spawnRateScaleInput = enemyManager.SpawnRateScale * 2f;
- enemyManager.SetSpawnRateScale(_spawnRateScaleInput);
- }
-
- GUILayout.EndHorizontal();
-
- GUILayout.BeginHorizontal();
- GUILayout.Label("Add Sec", GUILayout.Width(52f));
- string durationText = GUILayout.TextField(_extendDurationSeconds.ToString("F0"), GUILayout.Width(60f));
- if (float.TryParse(durationText, out float parsedDuration))
- {
- _extendDurationSeconds = Mathf.Clamp(parsedDuration, 1f, 3600f);
- }
-
- if (GUILayout.Button("Extend Battle", GUILayout.Height(24f)))
- {
- if (procedure.CurrentGameState is GameStateBattle gameState)
+ GUILayout.Space(4f);
+ GUILayout.Label(
+ $"Collision Queries: total {simulationWorld.LastCollisionQueryCount} (Projectile {simulationWorld.LastProjectileCollisionQueryCount} / Area {simulationWorld.LastAreaCollisionQueryCount})");
+ GUILayout.Label(
+ $"Collision Candidates: total {simulationWorld.LastCollisionCandidateCount} (Projectile {simulationWorld.LastProjectileCollisionCandidateCount} / Area {simulationWorld.LastAreaCollisionCandidateCount})");
+ GUILayout.Label(
+ $"Area Resolve: hits {simulationWorld.LastResolvedAreaHitCount}");
+ GUILayout.Label(
+ $"Broad Phase: cell {simulationWorld.LastCollisionCellSize:F2}, hasEnemyTargets {(simulationWorld.LastCollisionHasEnemyTargets ? "Yes" : "No")}");
+ if (simulationWorld.LastCollisionCandidateCount != 0)
{
- gameState.AddBattleDuration(_extendDurationSeconds);
+ Log.Info($"LastCollisionCandidateCount:{simulationWorld.LastCollisionCandidateCount}");
+ }
+
+ if (simulationWorld.LastResolvedAreaHitCount != 0)
+ {
+ Log.Info($"LastResolvedAreaHitCount:{simulationWorld.LastResolvedAreaHitCount}");
}
}
- GUILayout.EndHorizontal();
-
- GUILayout.Space(4f);
- GUILayout.Label($"Enemy Separation Solver: {EnemySeparationSolverProvider.CurrentSolverName}");
- GUILayout.BeginHorizontal();
- if (GUILayout.Button("Use Naive O(N^2)", GUILayout.Height(24f)))
+ if (_showSpawnControls)
{
- EnemySeparationSolverProvider.UseNaiveSolver();
+ GUILayout.BeginHorizontal();
+ GUILayout.Label("Rate", GUILayout.Width(52f));
+ string rateText = GUILayout.TextField(_spawnRateScaleInput.ToString("F2"), GUILayout.Width(60f));
+ if (float.TryParse(rateText, out float parsedRate))
+ {
+ _spawnRateScaleInput = Mathf.Clamp(parsedRate, MinSpawnRate, 50f);
+ }
+
+ if (GUILayout.Button("Apply", GUILayout.Width(70f)))
+ {
+ enemyManager.SetSpawnRateScale(_spawnRateScaleInput);
+ }
+
+ if (GUILayout.Button("x0.5", GUILayout.Width(60f)))
+ {
+ _spawnRateScaleInput = Mathf.Max(MinSpawnRate, enemyManager.SpawnRateScale * 0.5f);
+ enemyManager.SetSpawnRateScale(_spawnRateScaleInput);
+ }
+
+ if (GUILayout.Button("x2", GUILayout.Width(60f)))
+ {
+ _spawnRateScaleInput = enemyManager.SpawnRateScale * 2f;
+ enemyManager.SetSpawnRateScale(_spawnRateScaleInput);
+ }
+
+ GUILayout.EndHorizontal();
}
- if (GUILayout.Button("Use Grid Bucket", GUILayout.Height(24f)))
+ if (_showBattleDurationControls)
{
- EnemySeparationSolverProvider.UseGridBucketSolver();
+ GUILayout.BeginHorizontal();
+ GUILayout.Label("Add Sec", GUILayout.Width(52f));
+ string durationText = GUILayout.TextField(_extendDurationSeconds.ToString("F0"), GUILayout.Width(60f));
+ if (float.TryParse(durationText, out float parsedDuration))
+ {
+ _extendDurationSeconds = Mathf.Clamp(parsedDuration, 1f, 3600f);
+ }
+
+ if (GUILayout.Button("Extend Battle", GUILayout.Height(24f)))
+ {
+ if (procedure.CurrentGameState is GameStateBattle gameState)
+ {
+ gameState.AddBattleDuration(_extendDurationSeconds);
+ }
+ }
+
+ GUILayout.EndHorizontal();
}
- GUILayout.EndHorizontal();
-
- GUILayout.Label(
- $"Player Weapon: {(player == null ? "Player not found" : (player.WeaponEnabled ? "Enabled" : "Disabled"))}");
- GUILayout.BeginHorizontal();
- GUI.enabled = player != null;
- if (GUILayout.Button("Disable Weapons", GUILayout.Height(24f)))
+ if (_showSeparationSolverControls)
{
- player.SetWeaponEnabled(false);
+ GUILayout.Space(4f);
+ GUILayout.Label($"Enemy Separation Solver: {EnemySeparationSolverProvider.CurrentSolverName}");
+ GUILayout.BeginHorizontal();
+ if (GUILayout.Button("Use Naive O(N^2)", GUILayout.Height(24f)))
+ {
+ EnemySeparationSolverProvider.UseNaiveSolver();
+ }
+
+ if (GUILayout.Button("Use Grid Bucket", GUILayout.Height(24f)))
+ {
+ EnemySeparationSolverProvider.UseGridBucketSolver();
+ }
+
+ GUILayout.EndHorizontal();
}
- if (GUILayout.Button("Enable Weapons", GUILayout.Height(24f)))
+ if (_showPlayerWeaponControls)
{
- player.SetWeaponEnabled(true);
+ GUILayout.Label(
+ $"Player Weapon: {(player == null ? "Player not found" : (player.WeaponEnabled ? "Enabled" : "Disabled"))}");
+ GUILayout.BeginHorizontal();
+ GUI.enabled = player != null;
+ if (GUILayout.Button("Disable Weapons", GUILayout.Height(24f)))
+ {
+ player.SetWeaponEnabled(false);
+ }
+
+ if (GUILayout.Button("Enable Weapons", GUILayout.Height(24f)))
+ {
+ player.SetWeaponEnabled(true);
+ }
+
+ GUI.enabled = true;
+ GUILayout.EndHorizontal();
}
- GUI.enabled = true;
- GUILayout.EndHorizontal();
+ if (_showPlayerHealthControls)
+ {
+ 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 bool HasVisibleBattleSection()
+ {
+ return _showBattleOverview ||
+ _showCollisionStats ||
+ _showSpawnControls ||
+ _showBattleDurationControls ||
+ _showSeparationSolverControls ||
+ _showPlayerWeaponControls ||
+ _showPlayerHealthControls;
}
private void EnsurePropList(bool force = false)
@@ -299,6 +415,52 @@ namespace CustomComponent
return UnityEngine.Object.FindObjectOfType();
}
+ private void KeepPlayerHealthAtMax()
+ {
+ Player player = FindPlayer();
+ if (player == null) return;
+
+ HealthComponent playerHealth = player.GetComponent();
+ if (playerHealth == null) return;
+
+ RestorePlayerHealthToMax(playerHealth);
+ }
+
+ private static void AddPlayerHealth(HealthComponent playerHealth, int amount)
+ {
+ if (playerHealth == null || amount <= 0) return;
+ if (playerHealth.CurrentHealth <= 0) return;
+
+ int maxHealth = playerHealth.MaxHealth;
+ if (maxHealth <= 0) return;
+
+ int nextHealth = Mathf.Clamp(playerHealth.CurrentHealth + amount, 0, maxHealth);
+ if (nextHealth == playerHealth.CurrentHealth) return;
+
+ playerHealth.CurrentHealth = nextHealth;
+ PublishPlayerHealthChanged(playerHealth);
+ }
+
+ private static void RestorePlayerHealthToMax(HealthComponent playerHealth)
+ {
+ if (playerHealth == null) return;
+ if (playerHealth.CurrentHealth <= 0) return;
+
+ int maxHealth = playerHealth.MaxHealth;
+ if (maxHealth <= 0 || playerHealth.CurrentHealth >= maxHealth) return;
+
+ playerHealth.CurrentHealth = maxHealth;
+ PublishPlayerHealthChanged(playerHealth);
+ }
+
+ private static void PublishPlayerHealthChanged(HealthComponent playerHealth)
+ {
+ if (playerHealth == null || GameEntry.Event == null) return;
+
+ GameEntry.Event.Fire(null,
+ PlayerHealthChangeEventArgs.Create(0, playerHealth.CurrentHealth, playerHealth.MaxHealth));
+ }
+
private static void AddSelectedBuffToPlayer(DRProp prop, int count)
{
Player player = FindPlayer();
diff --git a/Assets/GameMain/Scripts/CustomComponent/EnemyManager/EnemyManagerComponent.cs b/Assets/GameMain/Scripts/CustomComponent/EnemyManager/EnemyManagerComponent.cs
index f954cf8..02bf272 100644
--- a/Assets/GameMain/Scripts/CustomComponent/EnemyManager/EnemyManagerComponent.cs
+++ b/Assets/GameMain/Scripts/CustomComponent/EnemyManager/EnemyManagerComponent.cs
@@ -20,6 +20,7 @@ namespace CustomComponent
private EntityComponent _entity;
private List _enemies;
+ private Dictionary _enemyById;
public List Enemies => _enemies;
@@ -58,6 +59,7 @@ namespace CustomComponent
{
_entity = GameEntry.Entity;
_enemies = new List();
+ _enemyById = new Dictionary();
GameEntry.Event.Subscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess);
GameEntry.Event.Subscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete);
@@ -65,10 +67,14 @@ namespace CustomComponent
private void OnDestroy()
{
- GameEntry.Event.Unsubscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess);
- GameEntry.Event.Unsubscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete);
+ if (GameEntry.Event != null)
+ {
+ GameEntry.Event.Unsubscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess);
+ GameEntry.Event.Unsubscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete);
+ }
_enemies = null;
+ _enemyById = null;
_entity = null;
}
@@ -157,6 +163,25 @@ namespace CustomComponent
}
_enemies.Clear();
+ _enemyById?.Clear();
+ }
+
+ public bool TryGetEnemy(int entityId, out EntityBase enemy)
+ {
+ enemy = null;
+ if (_enemyById == null || !_enemyById.TryGetValue(entityId, out EntityBase cachedEnemy))
+ {
+ return false;
+ }
+
+ if (cachedEnemy == null || !cachedEnemy.Available)
+ {
+ _enemyById.Remove(entityId);
+ return false;
+ }
+
+ enemy = cachedEnemy;
+ return true;
}
public void SetSpawnRateScale(float scale)
@@ -218,6 +243,7 @@ namespace CustomComponent
enemy.SetTarget(_player);
RemoveEnemyFromCache(enemy.Id);
_enemies.Add(enemy);
+ _enemyById[enemy.Id] = enemy;
}
if (ne.EntityLogicType == typeof(Player))
@@ -245,11 +271,21 @@ namespace CustomComponent
private void RemoveEnemyFromCache(int entityId)
{
+ if (_enemyById != null)
+ {
+ _enemyById.Remove(entityId);
+ }
+
for (int i = _enemies.Count - 1; i >= 0; i--)
{
EntityBase cachedEnemy = _enemies[i];
if (cachedEnemy == null || cachedEnemy.Id == entityId)
{
+ if (cachedEnemy != null && _enemyById != null)
+ {
+ _enemyById.Remove(cachedEnemy.Id);
+ }
+
_enemies.RemoveAt(i);
}
}
diff --git a/Assets/GameMain/Scripts/DataTable/DREnemy.cs b/Assets/GameMain/Scripts/DataTable/DREnemy.cs
index fb7b690..9b363d5 100644
--- a/Assets/GameMain/Scripts/DataTable/DREnemy.cs
+++ b/Assets/GameMain/Scripts/DataTable/DREnemy.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Generic;
using UnityGameFramework.Runtime;
namespace DataTable
@@ -5,23 +7,31 @@ namespace DataTable
public class DREnemy : DataRowBase
{
private int m_id;
-
+
public override int Id => m_id;
-
+
public int EntityTypeId { get; private set; }
-
+
public int MaxHealth { get; private set; }
-
+
public int HpAddPerLevel { get; private set; }
-
+
+ public int AttackDamage { get; private set; }
+
+ public float AttackCooldown { get; private set; }
+
+ public float AttackRange { get; private set; }
+
public float Speed { get; private set; }
-
+
public int DropCoin { get; private set; }
-
+
public int DropExp { get; private set; }
-
+
public float DropPercent { get; private set; }
+ public Dictionary Params { get; private set; }
+
public override bool ParseDataRow(string dataRowString, object userData)
{
string[] columnStrings = dataRowString.Split(DataTableExtension.DataSplitSeparators);
@@ -33,12 +43,47 @@ namespace DataTable
EntityTypeId = int.Parse(columnStrings[index++]);
MaxHealth = int.Parse(columnStrings[index++]);
HpAddPerLevel = int.Parse(columnStrings[index++]);
+ AttackDamage = int.Parse(columnStrings[index++]);
+ AttackCooldown = float.Parse(columnStrings[index++]);
+ AttackRange = float.Parse(columnStrings[index++]);
Speed = float.Parse(columnStrings[index++]);
DropCoin = int.Parse(columnStrings[index++]);
DropExp = int.Parse(columnStrings[index++]);
DropPercent = float.Parse(columnStrings[index++]);
+ Params = DeserializeParams(columnStrings[index++]);
return true;
}
+
+ ///
+ /// 解参数
+ ///
+ ///
+ ///
+ ///
+ private Dictionary DeserializeParams(string rawParams)
+ {
+ if (!rawParams.StartsWith('[') || !rawParams.EndsWith(']'))
+ {
+ throw new ArgumentException("Input must be enclosed in square brackets.");
+ }
+
+ var dict = new Dictionary();
+
+ if (string.IsNullOrEmpty(rawParams)) return dict;
+
+ string[] items = rawParams.Substring(1, rawParams.Length - 2).Split(";");
+ foreach (var item in items)
+ {
+ string entry = item.Trim();
+ if (string.IsNullOrEmpty(entry)) continue;
+
+ string[] pair = entry.Split(':', StringSplitOptions.RemoveEmptyEntries);
+ if (pair.Length != 2) continue;
+ dict.Add(pair[0].ToLower(), pair[1]);
+ }
+
+ return dict;
+ }
}
-}
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/DataTable/DRWeapon.cs b/Assets/GameMain/Scripts/DataTable/DRWeapon.cs
index f323ca4..b2346c1 100644
--- a/Assets/GameMain/Scripts/DataTable/DRWeapon.cs
+++ b/Assets/GameMain/Scripts/DataTable/DRWeapon.cs
@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
using Definition.DataStruct;
using Definition.Enum;
using GameFramework;
using CustomUtility;
+using Newtonsoft.Json.Linq;
using UnityEngine;
using UnityGameFramework.Runtime;
@@ -74,6 +74,11 @@ namespace DataTable
///
public Dictionary Pramas { get; private set; }
+ ///
+ /// 获取武器额外参数 Json。
+ ///
+ public string ParamsJson { get; private set; }
+
///
/// 获取武器额外属性。
///
@@ -97,7 +102,8 @@ namespace DataTable
Cooldown = float.Parse(columnStrings[index++]);
AttackRange = float.Parse(columnStrings[index++]);
AttackSoundId = int.Parse(columnStrings[index++]);
- Pramas = DeserializeParams(columnStrings[index++]);
+ ParamsJson = columnStrings[index++];
+ Pramas = DeserializeParams(ParamsJson);
Modifiers = Utility.Json.ToObject(columnStrings[index++]);
GeneratePropertyArray();
@@ -109,29 +115,43 @@ namespace DataTable
{
}
+ ///
+ /// 解参数
+ ///
+ ///
+ ///
private Dictionary DeserializeParams(string rawParams)
{
- if (!rawParams.StartsWith('[') || !rawParams.EndsWith(']'))
+ var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (string.IsNullOrWhiteSpace(rawParams))
{
- throw new ArgumentException("Input must be enclosed in square brackets.");
+ return dict;
}
-
- var dict = new Dictionary();
-
- if (string.IsNullOrEmpty(rawParams)) return dict;
- string[] items = rawParams.Substring(1, rawParams.Length - 2).Split(";");
- foreach (var item in items)
+ try
{
- string entry = item.Trim();
- if (string.IsNullOrEmpty(entry)) continue;
+ JObject paramObject = Utility.Json.ToObject(rawParams);
+ if (paramObject == null)
+ {
+ return dict;
+ }
- string[] pair = entry.Split(':' , StringSplitOptions.RemoveEmptyEntries);
- if (pair.Length != 2) continue;
- dict.Add(pair[0].ToLower(), pair[1]);
+ foreach (var pair in paramObject)
+ {
+ if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value == null)
+ {
+ continue;
+ }
+
+ dict[pair.Key] = pair.Value.ToString();
+ }
+ }
+ catch (Exception exception)
+ {
+ Log.Warning("Failed to parse weapon params json '{0}'. Error: {1}", rawParams, exception.Message);
}
return dict;
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs b/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs
index a9445ca..fe2c13e 100644
--- a/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs
+++ b/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs
@@ -4,13 +4,26 @@ namespace CustomDebugger
{
public static class CustomProfilerMarker
{
- public static readonly ProfilerMarker TickEnemies = new ProfilerMarker("TickEnemies");
- public static readonly ProfilerMarker TickEnemies_BuildInput = new ProfilerMarker("TickEnemies.BuildInput");
- public static readonly ProfilerMarker TickEnemies_MoveSeparation = new ProfilerMarker("TickEnemies.MoveSeparation");
- public static readonly ProfilerMarker TickEnemies_StateUpdate = new ProfilerMarker("TickEnemies.StateUpdate");
- public static readonly ProfilerMarker TickEnemies_WriteBack = new ProfilerMarker("TickEnemies.WriteBack");
- public static readonly ProfilerMarker Movement_Update = new ProfilerMarker("Movement_Update");
+ public static readonly ProfilerMarker TickEnemies = new("TickEnemies");
+ public static readonly ProfilerMarker TickEnemies_BuildInput = new("TickEnemies.BuildInput");
+ public static readonly ProfilerMarker TickEnemies_StateUpdate = new("TickEnemies.StateUpdate");
+ public static readonly ProfilerMarker TickEnemies_Schedule = new("TickEnemies.Schedule");
+ public static readonly ProfilerMarker TickEnemies_Complete = new("TickEnemies.Complete");
+ public static readonly ProfilerMarker TickEnemies_MainThreadCommit = new("TickEnemies.MainThreadCommit");
+ public static readonly ProfilerMarker TickEnemies_WriteBack = new("TickEnemies.WriteBack");
+
+ public static readonly ProfilerMarker Collision = new("Collision");
+ public static readonly ProfilerMarker Collision_BuildQueries = new("Collision.BuildQueries");
+ public static readonly ProfilerMarker Collision_BuildBuckets = new("Collision.BuildBuckets");
+ public static readonly ProfilerMarker Collision_QueryCandidates = new("Collision.QueryCandidates");
+ public static readonly ProfilerMarker Collision_ResolveProjectile = new("Collision.ResolveProjectile");
+ public static readonly ProfilerMarker Collision_ResolveArea = new("Collision.ResolveArea");
+
+ public static readonly ProfilerMarker TargetSelection_BuildBuckets = new("TargetSelection.BuildBuckets");
+ public static readonly ProfilerMarker TargetSelection_QueryNeighbors = new("TargetSelection.QueryNeighbors");
+
+ public static readonly ProfilerMarker Movement_Update = new("Movement_Update");
public static readonly ProfilerMarker ShopUI_Update = new("UGF.ShopUI.Update");
public static readonly ProfilerMarker Inventory_Refresh = new("UGF.Inventory.Refresh");
- }
-}
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Definition/Enum/WeaponType.cs b/Assets/GameMain/Scripts/Definition/Enum/WeaponType.cs
index 999bf1a..cddd437 100644
--- a/Assets/GameMain/Scripts/Definition/Enum/WeaponType.cs
+++ b/Assets/GameMain/Scripts/Definition/Enum/WeaponType.cs
@@ -6,5 +6,7 @@ namespace Definition.Enum
WeaponKnife = 1,
WeaponHandgun = 2,
WeaponSlash = 3,
+ WeaponLightning = 4,
+ WeaponLance = 5,
}
}
diff --git a/Assets/GameMain/Scripts/Editor/VampireLike.Editor.asmdef b/Assets/GameMain/Scripts/Editor/VampireLike.Editor.asmdef
new file mode 100644
index 0000000..fd99ead
--- /dev/null
+++ b/Assets/GameMain/Scripts/Editor/VampireLike.Editor.asmdef
@@ -0,0 +1,20 @@
+{
+ "name": "VampireLike.Editor",
+ "rootNamespace": "",
+ "references": [
+ "VampireLike",
+ "UnityGameFramework.Runtime",
+ "UnityGameFramework.Editor"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Assets/GameMain/Scripts/Editor/VampireLike.Editor.asmdef.meta b/Assets/GameMain/Scripts/Editor/VampireLike.Editor.asmdef.meta
new file mode 100644
index 0000000..a44978d
--- /dev/null
+++ b/Assets/GameMain/Scripts/Editor/VampireLike.Editor.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 602d791ab1251f74ca2470c53bf382a3
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Editor/StarForceBuildEventHandler.cs b/Assets/GameMain/Scripts/Editor/VampireLikeBuildEventHandler.cs
similarity index 91%
rename from Assets/GameMain/Scripts/Editor/StarForceBuildEventHandler.cs
rename to Assets/GameMain/Scripts/Editor/VampireLikeBuildEventHandler.cs
index c33c208..1405571 100644
--- a/Assets/GameMain/Scripts/Editor/StarForceBuildEventHandler.cs
+++ b/Assets/GameMain/Scripts/Editor/VampireLikeBuildEventHandler.cs
@@ -1,19 +1,12 @@
-//------------------------------------------------------------
-// Game Framework
-// Copyright © 2013-2021 Jiang Yin. All rights reserved.
-// Homepage: https://gameframework.cn/
-// Feedback: mailto:ellan@gameframework.cn
-//------------------------------------------------------------
-
-using GameFramework;
+using GameFramework;
using System.IO;
using UnityEditor;
using UnityEngine;
using UnityGameFramework.Editor.ResourceTools;
-namespace StarForce.Editor
+namespace VampireLike.Editor
{
- public sealed class StarForceBuildEventHandler : IBuildEventHandler
+ public sealed class VampireLikeBuildEventHandler : IBuildEventHandler
{
public bool ContinueOnFailure
{
diff --git a/Assets/GameMain/Scripts/Editor/StarForceBuildEventHandler.cs.meta b/Assets/GameMain/Scripts/Editor/VampireLikeBuildEventHandler.cs.meta
similarity index 84%
rename from Assets/GameMain/Scripts/Editor/StarForceBuildEventHandler.cs.meta
rename to Assets/GameMain/Scripts/Editor/VampireLikeBuildEventHandler.cs.meta
index fec01da..640f501 100644
--- a/Assets/GameMain/Scripts/Editor/StarForceBuildEventHandler.cs.meta
+++ b/Assets/GameMain/Scripts/Editor/VampireLikeBuildEventHandler.cs.meta
@@ -1,8 +1,7 @@
fileFormatVersion: 2
guid: 64311189c3f9ae140b59a31db9831950
-timeCreated: 1528026151
-licenseType: Pro
MonoImporter:
+ externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyData.cs b/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyData.cs
index 80f2a48..490eb8c 100644
--- a/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyData.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyData.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using DataTable;
using Definition.Enum;
using UnityEngine;
@@ -8,17 +9,7 @@ namespace Entity.EntityData
[Serializable]
public class EnemyData : TargetableObjectData
{
- [SerializeField] private EnemyType _enemyType;
-
- [SerializeField] private int _entityTypeId;
-
- [SerializeField] private float _speedBase = 0;
-
- [SerializeField] private int _dropCoin = 0;
-
- [SerializeField] private int _dropExp = 0;
-
- [SerializeField] private float _dropPercent = 0;
+ [SerializeField] private DREnemy _drEnemy;
public EnemyData(int entityId, EnemyType enemyType, int level) : base(
entityId, (int)enemyType, CampType.Enemy)
@@ -29,30 +20,46 @@ namespace Entity.EntityData
{
throw new Exception($"Enemy data table row is missing, EnemyType='{enemyType}'.");
}
+ else
+ {
+ _drEnemy = enemyRow;
+ }
int effectiveLevel = Mathf.Max(1, level);
-
- _enemyType = enemyType;
- _entityTypeId = enemyRow.EntityTypeId;
MaxHealthBase = enemyRow.MaxHealth + enemyRow.HpAddPerLevel * (effectiveLevel - 1);
- _speedBase = enemyRow.Speed;
- _dropCoin = enemyRow.DropCoin;
- _dropExp = enemyRow.DropExp;
- _dropPercent = enemyRow.DropPercent;
}
- public EnemyType EnemyType => _enemyType;
-
- public int EntityTypeId => _entityTypeId;
+ public EnemyType EnemyType => (EnemyType)_drEnemy.Id;
+
+ public int EntityTypeId => _drEnemy.EntityTypeId;
public override int MaxHealthBase { get; }
- public float SpeedBase => _speedBase;
+ public int AttackDamage => _drEnemy.AttackDamage;
- public int DropCoin => _dropCoin;
+ public float AttackCooldown => _drEnemy.AttackCooldown;
- public int DropExp => _dropExp;
+ public float AttackRange => _drEnemy.AttackRange;
- public float DropPercent => _dropPercent;
+ public float SpeedBase => _drEnemy.Speed;
+
+ public int DropCoin => _drEnemy.DropCoin;
+
+ public int DropExp => _drEnemy.DropExp;
+
+ public float DropPercent => _drEnemy.DropPercent;
+
+ public IReadOnlyDictionary Params => _drEnemy.Params;
+
+ public bool TryGetParam(string key, out string value)
+ {
+ value = null;
+ if (string.IsNullOrEmpty(key) || _drEnemy?.Params == null)
+ {
+ return false;
+ }
+
+ return _drEnemy.Params.TryGetValue(key, out value);
+ }
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyProjectileData.cs b/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyProjectileData.cs
new file mode 100644
index 0000000..317ba53
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyProjectileData.cs
@@ -0,0 +1,29 @@
+using System;
+using Definition.Enum;
+using UnityEngine;
+
+namespace Entity.EntityData
+{
+ [Serializable]
+ public class EnemyProjectileData : EntityDataBase
+ {
+ public EnemyProjectileData(int entityId, int ownerEntityId, CampType ownerCamp, int attackDamage,
+ float speed, float lifeTime, Vector3 direction)
+ : base(entityId, 0)
+ {
+ OwnerEntityId = ownerEntityId;
+ OwnerCamp = ownerCamp;
+ AttackDamage = attackDamage;
+ Speed = speed;
+ LifeTime = lifeTime;
+ Direction = direction;
+ }
+
+ public int OwnerEntityId { get; }
+ public CampType OwnerCamp { get; }
+ public int AttackDamage { get; }
+ public float Speed { get; }
+ public float LifeTime { get; }
+ public Vector3 Direction { get; }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyProjectileData.cs.meta b/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyProjectileData.cs.meta
new file mode 100644
index 0000000..1db6206
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyProjectileData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 52b38f83ab6c4029803d40c189db47c7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponData.cs b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponData.cs
index d9238e8..4483584 100644
--- a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponData.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponData.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using DataTable;
using Definition.DataStruct;
using Definition.Enum;
+using GameFramework;
namespace Entity.EntityData
{
@@ -28,14 +29,35 @@ namespace Entity.EntityData
public WeaponType WeaponType => (WeaponType)_drWeapon.Id;
- public string GetParamsString(string paramsName)
+ public bool TryGetParam(string key, out string value)
{
- if (!Params.TryGetValue(paramsName.ToLower(), out var value))
+ value = null;
+ if (string.IsNullOrEmpty(key) || Params == null)
{
- throw new Exception($"Parameter '{paramsName}' not found.");
+ return false;
}
- return value;
+ return Params.TryGetValue(key, out value);
+ }
+
+ protected TParams ParseParams() where TParams : new()
+ {
+ if (string.IsNullOrWhiteSpace(_drWeapon.ParamsJson))
+ {
+ return new TParams();
+ }
+
+ try
+ {
+ TParams parsed = Utility.Json.ToObject(_drWeapon.ParamsJson);
+ return parsed ?? new TParams();
+ }
+ catch (Exception exception)
+ {
+ throw new Exception(
+ $"Failed to parse weapon params, WeaponType='{WeaponType}', Json='{_drWeapon.ParamsJson}'.",
+ exception);
+ }
}
///
@@ -79,9 +101,11 @@ namespace Entity.EntityData
///
public Dictionary Params => _drWeapon.Pramas;
+ public string ParamsJson => _drWeapon.ParamsJson;
+
///
/// 额外属性。
///
public StatModifier[] Modifiers => _drWeapon.Modifiers;
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponHandgunData.cs b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponHandgunData.cs
index b9b1914..c47c6ee 100644
--- a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponHandgunData.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponHandgunData.cs
@@ -1,12 +1,21 @@
+using System;
using Definition.Enum;
namespace Entity.EntityData
{
+ [Serializable]
+ public sealed class WeaponHandgunParamsData
+ {
+ }
+
public class WeaponHandgunData : WeaponData
{
+ public WeaponHandgunParamsData ParamsData { get; }
+
public WeaponHandgunData(int entityId, int ownerId, CampType ownerCamp)
: base(entityId, WeaponType.WeaponHandgun, ownerId, ownerCamp)
{
+ ParamsData = ParseParams();
}
}
}
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponKnifeData.cs b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponKnifeData.cs
index 2bbe261..4fcf4bf 100644
--- a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponKnifeData.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponKnifeData.cs
@@ -1,12 +1,22 @@
+using System;
using Definition.Enum;
namespace Entity.EntityData
{
+ [Serializable]
+ public sealed class WeaponKnifeParamsData
+ {
+ public float HitRadius { get; set; }
+ }
+
public class WeaponKnifeData : WeaponData
{
+ public WeaponKnifeParamsData ParamsData { get; }
+
public WeaponKnifeData(int entityId, int ownerId, CampType ownerCamp) : base(entityId, WeaponType.WeaponKnife,
ownerId, ownerCamp)
{
+ ParamsData = ParseParams();
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLanceData.cs b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLanceData.cs
new file mode 100644
index 0000000..b3183c1
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLanceData.cs
@@ -0,0 +1,71 @@
+using System;
+using Definition.Enum;
+
+namespace Entity.EntityData
+{
+ [Serializable]
+ public sealed class WeaponLanceParamsData
+ {
+ ///
+ /// 横向半宽,表示前戳矩形判定的一半宽度。
+ ///
+ public float HitHalfWidth { get; set; }
+
+ ///
+ /// 旧字段兼容,未配置 HitHalfWidth 时回退使用。
+ ///
+ public float HitRadius { get; set; }
+
+ ///
+ /// 前戳判定盒体的总高度。
+ ///
+ public float HitHeight { get; set; }
+
+ ///
+ /// 判定盒体中心相对战斗平面的高度偏移。
+ ///
+ public float HitCenterYOffset { get; set; }
+
+ ///
+ /// 前刺距离,同时驱动武器位移和命中长度。
+ ///
+ public float PierceLength { get; set; }
+
+ ///
+ /// 旧字段兼容,未配置 PierceLength 时回退使用。
+ ///
+ public float ThrustDistance { get; set; }
+
+ ///
+ /// 判定起点相对武器当前位置的前置偏移。
+ ///
+ public float ForwardOffset { get; set; }
+
+ ///
+ /// 追踪目标时的转向速度。
+ ///
+ public float RotateSpeed { get; set; }
+
+ ///
+ /// 向前突刺阶段耗时。
+ ///
+ public float AttackDuration { get; set; }
+
+ ///
+ /// 收枪返回阶段耗时。
+ ///
+ public float ReturnDuration { get; set; }
+ }
+
+ [Serializable]
+ public class WeaponLanceData : WeaponData
+ {
+ public WeaponLanceParamsData ParamsData { get; }
+
+ public WeaponLanceData(int entityId, int ownerId, CampType ownerCamp)
+ : base(entityId, WeaponType.WeaponLance, ownerId, ownerCamp)
+ {
+ ParamsData = ParseParams();
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLanceData.cs.meta b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLanceData.cs.meta
new file mode 100644
index 0000000..f16bacc
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLanceData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4d1a821e8ee1a9a4b912b70b0a1616eb
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLightningData.cs b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLightningData.cs
new file mode 100644
index 0000000..abe5a03
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLightningData.cs
@@ -0,0 +1,23 @@
+using System;
+using Definition.Enum;
+
+namespace Entity.EntityData
+{
+ [Serializable]
+ public sealed class WeaponLightningParamsData
+ {
+ public float HitRadius { get; set; }
+ public float HoverHeight { get; set; }
+ }
+
+ public class WeaponLightningData : WeaponData
+ {
+ public WeaponLightningParamsData ParamsData { get; }
+
+ public WeaponLightningData(int entityId, int ownerId, CampType ownerCamp)
+ : base(entityId, WeaponType.WeaponLightning, ownerId, ownerCamp)
+ {
+ ParamsData = ParseParams();
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLightningData.cs.meta b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLightningData.cs.meta
new file mode 100644
index 0000000..77f60c8
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLightningData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 25b0006918fd46959c7f6b8ec1bbc8ab
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponSlashData.cs b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponSlashData.cs
index 99a7381..f500788 100644
--- a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponSlashData.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponSlashData.cs
@@ -1,12 +1,22 @@
+using System;
using Definition.Enum;
namespace Entity.EntityData
{
+ [Serializable]
+ public sealed class WeaponSlashParamsData
+ {
+ public float SectorAngle { get; set; }
+ }
+
public class WeaponSlashData : WeaponData
{
+ public WeaponSlashParamsData ParamsData { get; }
+
public WeaponSlashData(int entityId, int ownerId, CampType ownerCamp)
: base(entityId, WeaponType.WeaponSlash, ownerId, ownerCamp)
{
+ ParamsData = ParseParams();
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyBase.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyBase.cs
index f7dadeb..d10f4d4 100644
--- a/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyBase.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyBase.cs
@@ -7,6 +7,7 @@ public abstract class EnemyBase : TargetableObject
protected Transform _target;
public abstract override ImpactData GetImpactData();
+ public virtual float AttackRange => 1f;
public virtual void SetTarget(Transform target) => _target = target;
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs
new file mode 100644
index 0000000..5d19702
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs
@@ -0,0 +1,140 @@
+using Definition.DataStruct;
+using Definition.Enum;
+using Entity.EntityData;
+using UnityEngine;
+using UnityGameFramework.Runtime;
+
+namespace Entity
+{
+ public class EnemyProjectile : EntityBase
+ {
+ private EnemyProjectileData _projectileData;
+ private Vector3 _direction = Vector3.forward;
+ private float _elapsedTime;
+ private bool _isActive;
+ private bool _isSimulationDriven;
+ private ImpactData _impactData;
+ private Collider[] _cachedColliders;
+
+ public bool IsActive => _isActive;
+ public ImpactData GetImpactData() => _impactData;
+
+ protected override void OnShow(object userData)
+ {
+ base.OnShow(userData);
+
+ _projectileData = userData as EnemyProjectileData;
+ if (_projectileData == null)
+ {
+ Log.Error("Enemy projectile data is invalid.");
+ _isActive = false;
+ GameEntry.Entity.HideEntity(this);
+ return;
+ }
+
+ _isActive = true;
+ _elapsedTime = 0f;
+ _impactData = new ImpactData(_projectileData.OwnerCamp, _projectileData.AttackDamage);
+
+ _direction = _projectileData.Direction;
+ _direction.y = 0f;
+ if (_direction.sqrMagnitude <= Mathf.Epsilon)
+ {
+ _direction = CachedTransform.forward;
+ _direction.y = 0f;
+ }
+
+ if (_direction.sqrMagnitude <= Mathf.Epsilon)
+ {
+ _direction = Vector3.forward;
+ }
+ else
+ {
+ _direction.Normalize();
+ }
+
+ CachedTransform.rotation = Quaternion.LookRotation(_direction, Vector3.up);
+
+ if (_projectileData.OwnerCamp == CampType.Player)
+ {
+ gameObject.layer = LayerMask.NameToLayer("PlayerWeapon");
+ }
+ else if (_projectileData.OwnerCamp == CampType.Enemy)
+ {
+ gameObject.layer = LayerMask.NameToLayer("EnemyWeapon");
+ }
+
+ _isSimulationDriven = IsDrivenBySimulationWorld();
+ SetColliderEnabled(!_isSimulationDriven);
+ }
+
+ protected override void OnUpdate(float elapseSeconds, float realElapseSeconds)
+ {
+ base.OnUpdate(elapseSeconds, realElapseSeconds);
+
+ if (!_isActive || _projectileData == null) return;
+
+ bool isSimulationDriven = IsDrivenBySimulationWorld();
+ if (isSimulationDriven != _isSimulationDriven)
+ {
+ _isSimulationDriven = isSimulationDriven;
+ SetColliderEnabled(!_isSimulationDriven);
+ }
+
+ if (_isSimulationDriven) return;
+
+ if (_projectileData.Speed > 0f)
+ {
+ CachedTransform.position += _direction * (_projectileData.Speed * elapseSeconds);
+ }
+
+ _elapsedTime += elapseSeconds;
+ if (_projectileData.LifeTime > 0f && _elapsedTime >= _projectileData.LifeTime)
+ {
+ Expire();
+ }
+ }
+
+ protected override void OnHide(bool isShutdown, object userData)
+ {
+ _isActive = false;
+ _projectileData = null;
+ _elapsedTime = 0f;
+ _isSimulationDriven = false;
+ _impactData = default;
+ _direction = Vector3.forward;
+
+ base.OnHide(isShutdown, userData);
+ }
+
+ public void Expire()
+ {
+ if (!_isActive) return;
+ _isActive = false;
+ GameEntry.Entity.HideEntity(this);
+ }
+
+ private static bool IsDrivenBySimulationWorld()
+ {
+ var simulationWorld = GameEntry.SimulationWorld;
+ return simulationWorld != null && simulationWorld.UseSimulationMovement;
+ }
+
+ private void SetColliderEnabled(bool enabled)
+ {
+ _cachedColliders ??= GetComponentsInChildren(true);
+ if (_cachedColliders == null) return;
+
+ for (int i = 0; i < _cachedColliders.Length; i++)
+ {
+ Collider collider = _cachedColliders[i];
+ if (collider == null)
+ {
+ continue;
+ }
+
+ collider.enabled = enabled;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs.meta
new file mode 100644
index 0000000..2d7fd14
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 22624f81b9364c8681b32d993f5e618f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/MeleeEnemy.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/MeleeEnemy.cs
index 948599f..33f1902 100644
--- a/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/MeleeEnemy.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/MeleeEnemy.cs
@@ -1,7 +1,8 @@
using Components;
+using CustomUtility;
using Definition.DataStruct;
+using Definition.Enum;
using Entity.EntityData;
-using Entity.Weapon;
using UnityEngine;
using UnityGameFramework.Runtime;
@@ -9,17 +10,33 @@ namespace Entity
{
public class MeleeEnemy : EnemyBase
{
+ private enum AttackStateType
+ {
+ Idle,
+ Check_OutRange,
+ Check_InRange,
+ Attack
+ }
+
private MovementComponent _movementComponent;
+
private float _attackRange = 1f;
- private float _attackRangeSquared;
+ private float _attackCooldown = 1f;
+ private int _attackDamage = 1;
+
+ private float _sqrAttackRange;
+ private float _currAttackTimer;
+
+ private AttackStateType _attackState = AttackStateType.Idle;
private EnemyData _meleeEnemyData;
- private WeaponBase _weapon;
+ private TargetableObject _targetableTarget;
protected override TargetableObjectData _targetableObjectData => _meleeEnemyData;
+ public override float AttackRange => _attackRange;
public override ImpactData GetImpactData()
{
- return new ImpactData(_meleeEnemyData.Camp, 0);
+ return new ImpactData(_meleeEnemyData.Camp, _attackDamage);
}
#region FSM
@@ -35,51 +52,42 @@ namespace Entity
protected override void OnShow(object userData)
{
base.OnShow(userData);
-
+
if (userData is EnemyData enemyData)
{
_meleeEnemyData = enemyData;
_healthComponent.OnInit(enemyData.MaxHealthBase);
_movementComponent.OnInit(_meleeEnemyData.SpeedBase, this.CachedTransform, null, true);
_movementComponent.SetMove(true);
- _attackRangeSquared = _attackRange * _attackRange;
+
+ _attackRange = Mathf.Max(0.1f, _meleeEnemyData.AttackRange);
+ _attackCooldown = Mathf.Max(0.01f, _meleeEnemyData.AttackCooldown);
+ _attackDamage = Mathf.Max(1, _meleeEnemyData.AttackDamage);
+ _sqrAttackRange = _attackRange * _attackRange;
+
+ _currAttackTimer = 0f;
+ _attackState = AttackStateType.Idle;
+ _targetableTarget = null;
+
this.CachedTransform.position = enemyData.Position;
}
else
{
Log.Error($"Invalid data type. Data type: {userData?.GetType()}");
}
-
}
protected override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
+ base.OnUpdate(elapseSeconds, realElapseSeconds);
+
+ UpdateAttackState(elapseSeconds);
+
if (IsSimulationMovementEnabled())
{
return;
}
- base.OnUpdate(elapseSeconds, realElapseSeconds);
-
- if (_target == null)
- {
- _movementComponent.SetMove(false);
- _movementComponent.OnUpdate(elapseSeconds, realElapseSeconds);
- return;
- }
-
- float distanceSquared = (this.CachedTransform.position - _target.position).sqrMagnitude;
- if (distanceSquared < _attackRangeSquared)
- {
- // 攻击
- _movementComponent.SetMove(false);
- }
- else
- {
- _movementComponent.SetMove(true);
- _movementComponent.SetDirection(GetTargetDirection());
- }
-
_movementComponent.OnUpdate(elapseSeconds, realElapseSeconds);
}
@@ -102,7 +110,7 @@ namespace Entity
};
GameEntry.Entity.ShowExp(data);
}
-
+
base.OnDead(attacker);
}
@@ -110,12 +118,143 @@ namespace Entity
{
_movementComponent.OnReset();
_healthComponent.OnReset();
+ _targetableTarget = null;
+ _currAttackTimer = 0f;
+ _attackState = AttackStateType.Idle;
base.OnHide(isShutdown, userData);
}
#endregion
+ public override void SetTarget(Transform target)
+ {
+ base.SetTarget(target);
+ _targetableTarget = target != null ? target.GetComponent() : null;
+ }
+
+ private void UpdateAttackState(float elapseSeconds)
+ {
+ _currAttackTimer += elapseSeconds;
+
+ switch (_attackState)
+ {
+ case AttackStateType.Idle:
+ SetMove(false);
+ if (HasValidTarget())
+ {
+ TransitionTo(AttackStateType.Check_OutRange);
+ }
+
+ break;
+ case AttackStateType.Check_OutRange:
+ if (!HasValidTarget())
+ {
+ TransitionTo(AttackStateType.Idle);
+ return;
+ }
+
+ if (IsTargetInRange())
+ {
+ TransitionTo(AttackStateType.Check_InRange);
+ return;
+ }
+
+ SetMove(true);
+ _movementComponent.SetDirection(GetTargetDirection());
+ break;
+ case AttackStateType.Check_InRange:
+ if (!HasValidTarget())
+ {
+ TransitionTo(AttackStateType.Idle);
+ return;
+ }
+
+ SetMove(false);
+ if (!IsTargetInRange())
+ {
+ TransitionTo(AttackStateType.Check_OutRange);
+ return;
+ }
+
+ if (_currAttackTimer >= _attackCooldown)
+ {
+ TransitionTo(AttackStateType.Attack);
+ }
+
+ break;
+ }
+ }
+
+ private void TransitionTo(AttackStateType newState)
+ {
+ _attackState = newState;
+
+ if (_attackState == AttackStateType.Check_InRange)
+ {
+ SetMove(false);
+ }
+
+ if (_attackState == AttackStateType.Check_InRange && _currAttackTimer >= _attackCooldown)
+ {
+ TransitionTo(AttackStateType.Attack);
+ return;
+ }
+
+ if (_attackState == AttackStateType.Attack)
+ {
+ SetMove(false);
+ ExecuteAttack();
+ _currAttackTimer = 0f;
+ TransitionTo(AttackStateType.Check_InRange);
+ }
+ }
+
+ private bool HasValidTarget()
+ {
+ if (_target == null) return false;
+
+ if (_targetableTarget == null || _targetableTarget.CachedTransform != _target)
+ {
+ _targetableTarget = _target.GetComponent();
+ }
+
+ return _targetableTarget != null && _targetableTarget.Available && !_targetableTarget.IsDead;
+ }
+
+ private bool IsTargetInRange()
+ {
+ if (_target == null) return false;
+
+ Vector3 delta = _target.position - this.CachedTransform.position;
+ delta.y = 0f;
+ return delta.sqrMagnitude <= _sqrAttackRange;
+ }
+
+ private void ExecuteAttack()
+ {
+ if (!HasValidTarget() || !IsTargetInRange()) return;
+
+ ImpactData targetImpactData = _targetableTarget.GetImpactData();
+ ImpactData selfImpactData = GetImpactData();
+ if (AIUtility.GetRelation(selfImpactData.Camp, targetImpactData.Camp) == RelationType.Friendly)
+ {
+ return;
+ }
+
+ int damage = AIUtility.CalcDamageHP(_attackDamage, null, targetImpactData.DefenseStat,
+ targetImpactData.DodgeStat);
+ _targetableTarget.ApplyDamage(this, damage);
+ }
+
+ private void SetMove(bool value)
+ {
+ if (_movementComponent != null)
+ {
+ _movementComponent.SetMove(value);
+ }
+ }
+
private Vector3 GetTargetDirection()
{
if (_target == null)
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/RemoteEnemy.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/RemoteEnemy.cs
index f0fdf07..16051ac 100644
--- a/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/RemoteEnemy.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/RemoteEnemy.cs
@@ -1,4 +1,6 @@
using Components;
+using CustomUtility;
+using Definition;
using Definition.DataStruct;
using Entity.EntityData;
using UnityEngine;
@@ -8,17 +10,39 @@ namespace Entity
{
public class RemoteEnemy : EnemyBase
{
- private float _speed;
+ private const string EnemyProjectileGroupName = "Bullet";
+ private const float EnemyProjectileGroupAutoReleaseInterval = 60f;
+ private const int EnemyProjectileGroupCapacity = 64;
+ private const float EnemyProjectileGroupExpireTime = 60f;
+ private const int EnemyProjectileGroupPriority = 0;
+
+ private const string ProjectileSpeedParamKey = "ProjectileSpeed";
+ private const string ProjectileLifeTimeParamKey = "ProjectileLifeTime";
+ private const string ProjectileSpawnForwardOffsetParamKey = "ProjectileSpawnForwardOffset";
+ private const string ProjectileSpawnHeightOffsetParamKey = "ProjectileSpawnHeightOffset";
+ private const string ProjectileAssetNameParamKey = "ProjectileAssetName";
+
private MovementComponent _movementComponent;
private float _attackRange = 1f;
private float _attackRangeSquared;
+ private float _attackCooldown = 1f;
+ private int _attackDamage = 1;
+ private float _currAttackTimer;
+
+ [SerializeField] private float _projectileSpeed = 12f;
+ [SerializeField] private float _projectileLifeTime = 3f;
+ [SerializeField] private float _projectileSpawnForwardOffset = 0.7f;
+ [SerializeField] private float _projectileSpawnHeightOffset = 0.6f;
+ [SerializeField] private string _projectileAssetName = "BulletHandgun";
+
private EnemyData _remoteEnemyData;
protected override TargetableObjectData _targetableObjectData => _remoteEnemyData;
+ public override float AttackRange => _attackRange;
public override ImpactData GetImpactData()
{
- return new ImpactData(_remoteEnemyData.Camp, 0);
+ return new ImpactData(_remoteEnemyData.Camp, _attackDamage);
}
protected override void OnInit(object userData)
@@ -39,7 +63,21 @@ namespace Entity
_healthComponent.OnInit(enemyData.MaxHealthBase);
_movementComponent.OnInit(_remoteEnemyData.SpeedBase, this.CachedTransform, null, true);
_movementComponent.SetMove(true);
+
+ _attackRange = Mathf.Max(0.1f, _remoteEnemyData.AttackRange);
_attackRangeSquared = _attackRange * _attackRange;
+ _attackCooldown = Mathf.Max(0.01f, _remoteEnemyData.AttackCooldown);
+ _attackDamage = Mathf.Max(1, _remoteEnemyData.AttackDamage);
+
+ _projectileSpeed = ReadPositiveParam(ProjectileSpeedParamKey, _projectileSpeed);
+ _projectileLifeTime = ReadPositiveParam(ProjectileLifeTimeParamKey, _projectileLifeTime);
+ _projectileSpawnForwardOffset = ReadPositiveParam(ProjectileSpawnForwardOffsetParamKey,
+ _projectileSpawnForwardOffset);
+ _projectileSpawnHeightOffset = ReadPositiveParam(ProjectileSpawnHeightOffsetParamKey,
+ _projectileSpawnHeightOffset);
+ _projectileAssetName = ReadStringParam(ProjectileAssetNameParamKey, _projectileAssetName);
+
+ _currAttackTimer = 0f;
this.CachedTransform.position = enemyData.Position;
}
else
@@ -50,25 +88,28 @@ namespace Entity
protected override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
- if (IsSimulationMovementEnabled())
- {
- return;
- }
-
base.OnUpdate(elapseSeconds, realElapseSeconds);
+ _currAttackTimer += elapseSeconds;
+
if (_target == null)
{
_movementComponent.SetMove(false);
- _movementComponent.OnUpdate(elapseSeconds, realElapseSeconds);
+ if (!IsSimulationMovementEnabled())
+ {
+ _movementComponent.OnUpdate(elapseSeconds, realElapseSeconds);
+ }
+
return;
}
- float distanceSquared = (this.CachedTransform.position - _target.position).sqrMagnitude;
- if (distanceSquared < _attackRangeSquared)
+ Vector3 toTarget = _target.position - this.CachedTransform.position;
+ toTarget.y = 0f;
+ float distanceSquared = toTarget.sqrMagnitude;
+ if (distanceSquared <= _attackRangeSquared)
{
- // 攻击
_movementComponent.SetMove(false);
+ TryFireProjectile();
}
else
{
@@ -76,17 +117,115 @@ namespace Entity
_movementComponent.SetDirection(GetTargetDirection());
}
- _movementComponent.OnUpdate(elapseSeconds, realElapseSeconds);
+ if (!IsSimulationMovementEnabled())
+ {
+ _movementComponent.OnUpdate(elapseSeconds, realElapseSeconds);
+ }
}
protected override void OnHide(bool isShutdown, object userData)
{
_movementComponent.OnReset();
_healthComponent.OnReset();
+ _currAttackTimer = 0f;
base.OnHide(isShutdown, userData);
}
+ private void TryFireProjectile()
+ {
+ if (_currAttackTimer < _attackCooldown || _target == null) return;
+ if (!EnsureEnemyProjectileGroup()) return;
+
+ Vector3 spawnPosition = this.CachedTransform.position +
+ this.CachedTransform.forward * _projectileSpawnForwardOffset +
+ Vector3.up * _projectileSpawnHeightOffset;
+ Vector3 direction = _target.position - spawnPosition;
+ direction.y = 0f;
+ if (direction.sqrMagnitude <= Mathf.Epsilon)
+ {
+ direction = this.CachedTransform.forward;
+ direction.y = 0f;
+ }
+
+ if (direction.sqrMagnitude <= Mathf.Epsilon)
+ {
+ direction = Vector3.forward;
+ }
+ else
+ {
+ direction.Normalize();
+ }
+
+ int projectileEntityId = GameEntry.Entity.GenerateSerialId();
+ var projectileData = new EnemyProjectileData(projectileEntityId, Id, _remoteEnemyData.Camp,
+ _attackDamage, _projectileSpeed, _projectileLifeTime, direction)
+ {
+ Position = spawnPosition,
+ Rotation = Quaternion.LookRotation(direction, Vector3.up)
+ };
+
+ GameEntry.Entity.ShowEntity(
+ entityId: projectileEntityId,
+ entityLogicType: typeof(EnemyProjectile),
+ entityAssetName: AssetUtility.GetEntityAsset(_projectileAssetName),
+ entityGroupName: EnemyProjectileGroupName,
+ priority: Constant.AssetPriority.BulletAsset,
+ userData: projectileData);
+
+ _currAttackTimer = 0f;
+ }
+
+ private static bool EnsureEnemyProjectileGroup()
+ {
+ var entityComponent = GameEntry.Entity;
+ if (entityComponent == null) return false;
+
+ if (entityComponent.HasEntityGroup(EnemyProjectileGroupName))
+ {
+ return true;
+ }
+
+ bool addResult = entityComponent.AddEntityGroup(
+ EnemyProjectileGroupName,
+ EnemyProjectileGroupAutoReleaseInterval,
+ EnemyProjectileGroupCapacity,
+ EnemyProjectileGroupExpireTime,
+ EnemyProjectileGroupPriority);
+
+ if (!addResult)
+ {
+ Log.Warning("Can not create entity group '{0}'.", EnemyProjectileGroupName);
+ return false;
+ }
+
+ return true;
+ }
+
+ private float ReadPositiveParam(string paramName, float defaultValue)
+ {
+ if (_remoteEnemyData != null &&
+ _remoteEnemyData.TryGetParam(paramName, out string rawValue) &&
+ float.TryParse(rawValue, out float parsedValue))
+ {
+ return Mathf.Max(0.01f, parsedValue);
+ }
+
+ return Mathf.Max(0.01f, defaultValue);
+ }
+
+ private string ReadStringParam(string paramName, string defaultValue)
+ {
+ if (_remoteEnemyData != null &&
+ _remoteEnemyData.TryGetParam(paramName, out string rawValue) &&
+ !string.IsNullOrWhiteSpace(rawValue))
+ {
+ return rawValue;
+ }
+
+ return defaultValue;
+ }
+
private Vector3 GetTargetDirection()
{
if (_target == null)
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/TargetableObject.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/TargetableObject.cs
index f98c7d2..83d71b3 100644
--- a/Assets/GameMain/Scripts/Entity/EntityLogic/TargetableObject.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/TargetableObject.cs
@@ -61,8 +61,8 @@ namespace Entity
private void OnTriggerEnter(Collider other)
{
- EntityBase entity = other.gameObject.GetComponent();
- if (entity == null)
+ EntityBase entity = other.GetComponentInParent();
+ if (entity == null || entity == this)
{
return;
}
@@ -70,7 +70,7 @@ namespace Entity
if (entity is TargetableObject && entity.Id < Id)
{
// 碰撞事件由 Id 大的一方处理
- // 在这里规定所有的 Enemy 的 Id 均大于 0
+ // 在这里约定 Enemy 的 Id 为非负数(通常从 0 开始)
// 而其他的 Entity (Player, Weapon, Bullet) 的 Id 均小于 0
return;
}
@@ -78,4 +78,4 @@ namespace Entity
AIUtility.PerformCollision(this, entity);
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerAttackEffect.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerAttackEffect.cs
index dd8e814..22787f8 100644
--- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerAttackEffect.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerAttackEffect.cs
@@ -1,13 +1,23 @@
+using GameFramework.ObjectPool;
using UnityEngine;
namespace Entity.Weapon
{
public sealed class HandgunHitMarkerAttackEffect : IWeaponAttackEffect
{
+ private const string PoolName = "Weapon.HandgunHitMarker";
+ private const float PoolAutoReleaseInterval = 60f;
+ private const int PoolCapacity = 128;
+ private const float PoolExpireTime = 120f;
+ private const int PoolPriority = 0;
+
+ private static IObjectPool s_Pool;
+
private readonly float _size;
private readonly float _yOffset;
private readonly float _duration;
private readonly Color _color;
+ private Material _sharedMaterial;
public HandgunHitMarkerAttackEffect(float size, float yOffset, float duration, Color color)
{
@@ -20,35 +30,95 @@ namespace Entity.Weapon
public void Play(WeaponBase weapon, Vector3 position, EntityBase target, float radius)
{
if (target == null) return;
+ if (!TrySpawnMarker(out HandgunHitMarkerPooledInstance markerInstance))
+ {
+ return;
+ }
- GameObject marker = GameObject.CreatePrimitive(PrimitiveType.Sphere);
- marker.name = "HandgunHitMarker";
+ Transform targetTransform = target.CachedTransform;
+ Vector3 worldPosition = targetTransform != null ? targetTransform.position : position;
+ markerInstance.transform.SetParent(null, false);
+ markerInstance.transform.position = worldPosition + Vector3.up * _yOffset;
+ markerInstance.transform.localScale = Vector3.one * Mathf.Max(0.01f, _size);
+ markerInstance.ApplyMaterial(GetSharedMaterial());
+ markerInstance.Activate(Mathf.Max(0.01f, _duration), s_Pool);
+ }
- Collider collider = marker.GetComponent();
+ private bool TrySpawnMarker(out HandgunHitMarkerPooledInstance markerInstance)
+ {
+ markerInstance = null;
+ IObjectPool pool = EnsurePool();
+ if (pool == null)
+ {
+ return false;
+ }
+
+ HandgunHitMarkerPoolObject pooledObject = pool.Spawn();
+ if (pooledObject != null)
+ {
+ markerInstance = pooledObject.Target as HandgunHitMarkerPooledInstance;
+ if (markerInstance != null)
+ {
+ return true;
+ }
+ }
+
+ GameObject markerGameObject = GameObject.CreatePrimitive(PrimitiveType.Sphere);
+ markerGameObject.name = "HandgunHitMarker";
+ Collider collider = markerGameObject.GetComponent();
if (collider != null)
{
Object.Destroy(collider);
}
- marker.transform.SetParent(target.CachedTransform, false);
- marker.transform.localPosition = new Vector3(0f, _yOffset, 0f);
- marker.transform.localScale = Vector3.one * Mathf.Max(0.01f, _size);
+ markerInstance = markerGameObject.AddComponent();
+ markerGameObject.SetActive(false);
+ pool.Register(HandgunHitMarkerPoolObject.Create(markerInstance), true);
+ return true;
+ }
- Renderer renderer = marker.GetComponent();
- if (renderer != null)
+ private static IObjectPool EnsurePool()
+ {
+ var poolComponent = GameEntry.ObjectPool;
+ if (poolComponent == null)
{
- Shader shader = Shader.Find("Sprites/Default");
- if (shader == null)
- {
- shader = Shader.Find("Unlit/Color");
- }
-
- Material material = new Material(shader);
- material.color = _color;
- renderer.material = material;
+ return null;
}
- Object.Destroy(marker, Mathf.Max(0.01f, _duration));
+ if (s_Pool != null && poolComponent.HasObjectPool(PoolName))
+ {
+ return s_Pool;
+ }
+
+ s_Pool = poolComponent.HasObjectPool(PoolName)
+ ? poolComponent.GetObjectPool(PoolName)
+ : poolComponent.CreateSingleSpawnObjectPool(
+ PoolName,
+ PoolAutoReleaseInterval,
+ PoolCapacity,
+ PoolExpireTime,
+ PoolPriority);
+ return s_Pool;
+ }
+
+ private Material GetSharedMaterial()
+ {
+ if (_sharedMaterial != null)
+ {
+ return _sharedMaterial;
+ }
+
+ Shader shader = Shader.Find("Sprites/Default");
+ if (shader == null)
+ {
+ shader = Shader.Find("Unlit/Color");
+ }
+
+ _sharedMaterial = new Material(shader)
+ {
+ color = _color
+ };
+ return _sharedMaterial;
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs
new file mode 100644
index 0000000..57efc88
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs
@@ -0,0 +1,27 @@
+using GameFramework;
+using GameFramework.ObjectPool;
+using UnityEngine;
+
+namespace Entity.Weapon
+{
+ public sealed class HandgunHitMarkerPoolObject : ObjectBase
+ {
+ public static HandgunHitMarkerPoolObject Create(object target)
+ {
+ HandgunHitMarkerPoolObject pooledObject = ReferencePool.Acquire();
+ pooledObject.Initialize(target);
+ return pooledObject;
+ }
+
+ protected override void Release(bool isShutdown)
+ {
+ HandgunHitMarkerPooledInstance markerInstance = Target as HandgunHitMarkerPooledInstance;
+ if (markerInstance == null)
+ {
+ return;
+ }
+
+ Object.Destroy(markerInstance.gameObject);
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs.meta
new file mode 100644
index 0000000..3dd2f9b
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 966d34a18f5d00c49bddea3c7c5ec13d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs
new file mode 100644
index 0000000..accc3a3
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs
@@ -0,0 +1,78 @@
+using GameFramework.ObjectPool;
+using UnityEngine;
+
+namespace Entity.Weapon
+{
+ public sealed class HandgunHitMarkerPooledInstance : MonoBehaviour
+ {
+ private Renderer _renderer;
+ private IObjectPool _ownerPool;
+ private float _expireTime;
+ private bool _isActive;
+
+ private void Awake()
+ {
+ _renderer = GetComponent();
+ }
+
+ private void Update()
+ {
+ if (!_isActive)
+ {
+ return;
+ }
+
+ if (Time.time < _expireTime)
+ {
+ return;
+ }
+
+ ReturnToPool();
+ }
+
+ public void ApplyMaterial(Material material)
+ {
+ if (_renderer == null)
+ {
+ _renderer = GetComponent();
+ }
+
+ if (_renderer == null)
+ {
+ return;
+ }
+
+ _renderer.sharedMaterial = material;
+ }
+
+ public void Activate(float duration, IObjectPool pool)
+ {
+ _ownerPool = pool;
+ _expireTime = Time.time + Mathf.Max(0.01f, duration);
+ _isActive = true;
+ gameObject.SetActive(true);
+ }
+
+ private void OnDisable()
+ {
+ _isActive = false;
+ _ownerPool = null;
+ }
+
+ private void ReturnToPool()
+ {
+ _isActive = false;
+ IObjectPool pool = _ownerPool;
+ _ownerPool = null;
+ transform.SetParent(null, false);
+
+ if (pool == null)
+ {
+ gameObject.SetActive(false);
+ return;
+ }
+
+ pool.Unspawn(this);
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs.meta
new file mode 100644
index 0000000..328f200
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7ff8a67466d8f0643845c41fd5952f50
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LanceThrustAttackEffect.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LanceThrustAttackEffect.cs
new file mode 100644
index 0000000..b358215
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LanceThrustAttackEffect.cs
@@ -0,0 +1,82 @@
+using UnityEngine;
+
+namespace Entity.Weapon
+{
+ public sealed class LanceThrustAttackEffect : IWeaponAttackEffect
+ {
+ private readonly float _duration = 0.18f;
+ private readonly float _yOffset = 0.06f;
+ private readonly float _lineWidth = 0.05f;
+ private readonly Color _color = new(0.2f, 0.95f, 0.7f, 0.92f);
+
+ public LanceThrustAttackEffect()
+ {
+ }
+
+ public LanceThrustAttackEffect(float duration, float yOffset, float lineWidth, Color color)
+ {
+ _duration = duration;
+ _yOffset = yOffset;
+ _lineWidth = lineWidth;
+ _color = color;
+ }
+
+ public void Play(WeaponBase weapon, Vector3 position, EntityBase target, float radius)
+ {
+ if (weapon is not WeaponLance lance) return;
+
+ Vector3 forward = lance.StrikeDirection;
+ forward.y = 0f;
+ if (forward.sqrMagnitude <= Mathf.Epsilon)
+ {
+ forward = Vector3.forward;
+ }
+
+ forward.Normalize();
+ Vector3 right = Vector3.Cross(Vector3.up, forward);
+ float halfWidth = Mathf.Max(0.1f, lance.HitHalfWidth);
+ float halfLength = Mathf.Max(0.1f, lance.PierceLength * 0.5f);
+ Vector3 center = position + Vector3.up * _yOffset;
+
+ Vector3 frontCenter = center + forward * halfLength;
+ Vector3 backCenter = center - forward * halfLength;
+
+ Vector3[] corners =
+ {
+ frontCenter - right * halfWidth,
+ frontCenter + right * halfWidth,
+ backCenter + right * halfWidth,
+ backCenter - right * halfWidth,
+ };
+
+ GameObject indicator = new GameObject("LanceThrustIndicator");
+ indicator.transform.position = center;
+
+ LineRenderer line = indicator.AddComponent();
+ line.loop = true;
+ line.useWorldSpace = true;
+ line.positionCount = corners.Length;
+ line.startWidth = _lineWidth;
+ line.endWidth = _lineWidth;
+ line.startColor = _color;
+ line.endColor = _color;
+
+ Shader shader = Shader.Find("Sprites/Default");
+ if (shader == null)
+ {
+ shader = Shader.Find("Unlit/Color");
+ }
+
+ Material material = new Material(shader);
+ material.color = _color;
+ line.material = material;
+
+ for (int i = 0; i < corners.Length; i++)
+ {
+ line.SetPosition(i, corners[i]);
+ }
+
+ Object.Destroy(indicator, Mathf.Max(0.01f, _duration));
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LanceThrustAttackEffect.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LanceThrustAttackEffect.cs.meta
new file mode 100644
index 0000000..ebbae42
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LanceThrustAttackEffect.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8c77c590c7233e544a9295ed41ff8827
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LightningStrikeAttackEffect.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LightningStrikeAttackEffect.cs
new file mode 100644
index 0000000..f877122
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LightningStrikeAttackEffect.cs
@@ -0,0 +1,81 @@
+using UnityEngine;
+
+namespace Entity.Weapon
+{
+ public sealed class LightningStrikeAttackEffect : IWeaponAttackEffect
+ {
+ private readonly float _duration = 0.22f;
+ private readonly float _yOffset = 0.2f;
+ private readonly float _ringLineWidth = 0.045f;
+ private readonly float _boltLineWidth = 0.08f;
+ private readonly int _ringSegments = 32;
+ private readonly float _boltHeight = 3f;
+ private readonly Color _ringColor = new(0.35f, 0.82f, 1f, 0.92f);
+ private readonly Color _boltColor = new(0.85f, 0.96f, 1f, 0.97f);
+
+ public void Play(WeaponBase weapon, Vector3 position, EntityBase target, float radius)
+ {
+ float safeRadius = Mathf.Max(0.1f, radius);
+ Vector3 center = new Vector3(position.x, position.y + _yOffset, position.z);
+
+ GameObject root = new GameObject("LightningStrikeEffect");
+ root.transform.position = center;
+
+ Shader shader = Shader.Find("Sprites/Default");
+ if (shader == null)
+ {
+ shader = Shader.Find("Unlit/Color");
+ }
+
+ Material ringMaterial = new Material(shader);
+ ringMaterial.color = _ringColor;
+
+ LineRenderer ring = root.AddComponent();
+ ring.loop = true;
+ ring.useWorldSpace = true;
+ ring.positionCount = _ringSegments;
+ ring.startWidth = _ringLineWidth;
+ ring.endWidth = _ringLineWidth;
+ ring.startColor = _ringColor;
+ ring.endColor = _ringColor;
+ ring.material = ringMaterial;
+
+ float step = Mathf.PI * 2f / _ringSegments;
+ for (int i = 0; i < _ringSegments; i++)
+ {
+ float angle = i * step;
+ Vector3 offset = new Vector3(Mathf.Cos(angle) * safeRadius, 0f, Mathf.Sin(angle) * safeRadius);
+ ring.SetPosition(i, center + offset);
+ }
+
+ GameObject bolt = new GameObject("Bolt");
+ bolt.transform.SetParent(root.transform, false);
+
+ Material boltMaterial = new Material(shader);
+ boltMaterial.color = _boltColor;
+
+ LineRenderer boltLine = bolt.AddComponent();
+ boltLine.loop = false;
+ boltLine.useWorldSpace = true;
+ boltLine.positionCount = 4;
+ boltLine.startWidth = _boltLineWidth;
+ boltLine.endWidth = _boltLineWidth * 0.55f;
+ boltLine.startColor = _boltColor;
+ boltLine.endColor = _boltColor;
+ boltLine.material = boltMaterial;
+
+ Vector3 top = center + Vector3.up * _boltHeight;
+ Vector3 middleA = center + Vector3.up * (_boltHeight * 0.66f) +
+ new Vector3(Random.Range(-0.18f, 0.18f), 0f, Random.Range(-0.18f, 0.18f));
+ Vector3 middleB = center + Vector3.up * (_boltHeight * 0.3f) +
+ new Vector3(Random.Range(-0.12f, 0.12f), 0f, Random.Range(-0.12f, 0.12f));
+
+ boltLine.SetPosition(0, top);
+ boltLine.SetPosition(1, middleA);
+ boltLine.SetPosition(2, middleB);
+ boltLine.SetPosition(3, center);
+
+ Object.Destroy(root, Mathf.Max(0.01f, _duration));
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LightningStrikeAttackEffect.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LightningStrikeAttackEffect.cs.meta
new file mode 100644
index 0000000..294871b
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/LightningStrikeAttackEffect.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 81cbdf8961ad419c91b989d56ca782d0
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs
index a5bd9aa..1d72447 100644
--- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using CustomUtility;
+using CustomComponent;
namespace Entity.Weapon
{
@@ -12,6 +13,11 @@ namespace Entity.Weapon
return null;
}
+ if (TrySelectFromSpatialIndex(weapon, maxSqrRange, out EntityBase indexedTarget))
+ {
+ return indexedTarget;
+ }
+
EntityBase target = null;
float minSqrMagnitude = maxSqrRange > 0f ? maxSqrRange : float.MaxValue;
@@ -28,5 +34,35 @@ namespace Entity.Weapon
return target;
}
+
+ private static bool TrySelectFromSpatialIndex(WeaponBase weapon, float maxSqrRange, out EntityBase target)
+ {
+ target = null;
+ if (weapon == null || maxSqrRange <= 0f || weapon.CachedTransform == null)
+ {
+ return false;
+ }
+
+ var simulationWorld = GameEntry.SimulationWorld;
+ if (simulationWorld == null || !simulationWorld.UseSimulationMovement)
+ {
+ return false;
+ }
+
+ if (!simulationWorld.TryGetNearestEnemyEntityId(weapon.CachedTransform.position, maxSqrRange,
+ out int entityId))
+ {
+ return false;
+ }
+
+ EnemyManagerComponent enemyManager = GameEntry.EnemyManager;
+ if (enemyManager == null || !enemyManager.TryGetEnemy(entityId, out EntityBase enemy))
+ {
+ return false;
+ }
+
+ target = enemy;
+ return true;
+ }
}
-}
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs
index bca06d9..0f67bd6 100644
--- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs
@@ -37,7 +37,7 @@ namespace Entity.Weapon
protected ITargetSelector TargetSelector { get; set; }
protected Dictionary _states;
-
+
protected WeaponStateBase _currentState;
protected EntityBase _target;
@@ -50,7 +50,7 @@ namespace Entity.Weapon
private StatComponent _attackStatComponent;
private System.Action _attackStatCallback;
-
+
private static readonly List s_EmptyCandidates = new();
#region Lifecycle
@@ -220,6 +220,46 @@ namespace Entity.Weapon
return AIUtility.GetSqrMagnitudeXZ(this, target) < sqrRange;
}
+ protected bool TryQueueAreaCollisionQuery(in Vector3 center, float radius, int maxTargets = 16)
+ {
+ var simulationWorld = GameEntry.SimulationWorld;
+ if (simulationWorld == null)
+ {
+ return false;
+ }
+
+ int ownerEntityId = WeaponData != null ? WeaponData.OwnerId : Id;
+ return simulationWorld.TryRequestAreaCollision(Id, ownerEntityId, in center, radius, maxTargets);
+ }
+
+ protected bool TryQueueSectorCollisionQuery(in Vector3 center, float radius, in Vector3 direction,
+ float halfAngleDeg, int maxTargets = 16)
+ {
+ var simulationWorld = GameEntry.SimulationWorld;
+ if (simulationWorld == null)
+ {
+ return false;
+ }
+
+ int ownerEntityId = WeaponData != null ? WeaponData.OwnerId : Id;
+ return simulationWorld.TryRequestSectorCollision(Id, ownerEntityId, in center, radius, in direction,
+ halfAngleDeg, maxTargets);
+ }
+
+ protected bool TryQueueRectangleCollisionQuery(in Vector3 center, float halfWidth, float halfLength,
+ in Vector3 direction, int maxTargets = 16)
+ {
+ var simulationWorld = GameEntry.SimulationWorld;
+ if (simulationWorld == null)
+ {
+ return false;
+ }
+
+ int ownerEntityId = WeaponData != null ? WeaponData.OwnerId : Id;
+ return simulationWorld.TryRequestRectangleCollision(Id, ownerEntityId, in center, halfWidth, halfLength,
+ in direction, maxTargets);
+ }
+
protected void SetTargetSelector(TargetSelectorType selectorType)
{
TargetSelector = CreateSelector(selectorType);
@@ -278,12 +318,4 @@ namespace Entity.Weapon
public abstract void OnLeave();
public override string ToString() => State.ToString();
}
-
-
-
-
-
}
-
-
-
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponHandgun/WeaponHandgun.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponHandgun/WeaponHandgun.cs
index cae096f..22bd47d 100644
--- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponHandgun/WeaponHandgun.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponHandgun/WeaponHandgun.cs
@@ -46,22 +46,15 @@ namespace Entity.Weapon
{
FaceTargetImmediately();
- Vector3 fireOrigin = CachedTransform.TransformPoint(_fireOriginOffset);
- Vector3 fireDirection = CachedTransform.forward;
- float maxDistance = Mathf.Max(0.1f, _weaponData.AttackRange);
-
- if (Physics.Raycast(fireOrigin, fireDirection, out RaycastHit hit, maxDistance, _hitMask,
- QueryTriggerInteraction.Collide))
+ if (!TryResolveAttackTarget(out TargetableObject targetable, out Vector3 hitPosition))
{
- TargetableObject targetable = hit.collider.GetComponentInParent();
- if (targetable != null && targetable.Available && !targetable.IsDead)
- {
- _attackEffect?.Play(this, hit.point, targetable, 0f);
- _isAttacking = true;
- AIUtility.PerformCollision(targetable, this);
- _isAttacking = false;
- }
+ return;
}
+
+ _attackEffect?.Play(this, hitPosition, targetable, 0f);
+ _isAttacking = true;
+ AIUtility.PerformCollision(targetable, this);
+ _isAttacking = false;
}
protected override void Check()
@@ -95,6 +88,40 @@ namespace Entity.Weapon
CachedTransform.rotation = Quaternion.LookRotation(directionToTarget.normalized, Vector3.up);
}
+ private bool TryResolveAttackTarget(out TargetableObject targetable, out Vector3 hitPosition)
+ {
+ targetable = _target as TargetableObject;
+ hitPosition = CachedTransform.position;
+ if (targetable == null || !targetable.Available || targetable.IsDead)
+ {
+ return false;
+ }
+
+ Transform targetTransform = targetable.CachedTransform;
+ if (targetTransform == null)
+ {
+ return false;
+ }
+
+ hitPosition = targetTransform.position;
+
+ Vector3 fireOrigin = CachedTransform.TransformPoint(_fireOriginOffset);
+ Vector3 directionToTarget = targetTransform.position - fireOrigin;
+ float maxDistance = Mathf.Max(0.1f, _weaponData.AttackRange);
+ if (directionToTarget.sqrMagnitude > Mathf.Epsilon &&
+ Physics.Raycast(fireOrigin, directionToTarget.normalized, out RaycastHit hit, maxDistance, _hitMask,
+ QueryTriggerInteraction.Collide))
+ {
+ TargetableObject raycastTarget = hit.collider.GetComponentInParent();
+ if (raycastTarget == targetable)
+ {
+ hitPosition = hit.point;
+ }
+ }
+
+ return true;
+ }
+
#region Lifecycle
protected override bool OnWeaponShow(object userData)
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponKnife/WeaponKnife.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponKnife/WeaponKnife.cs
index f2d9d6c..6458605 100644
--- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponKnife/WeaponKnife.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponKnife/WeaponKnife.cs
@@ -11,8 +11,6 @@ namespace Entity.Weapon
{
public partial class WeaponKnife : WeaponBase
{
- private const string HitRadiusParamKey = "HitRadius";
-
private WeaponKnifeData _weaponData;
private Quaternion _cachedRotation;
@@ -27,7 +25,7 @@ namespace Entity.Weapon
[SerializeField] private LayerMask _hitMask = ~0;
[SerializeField] private int _maxHitColliders = 32;
-
+
private IWeaponAttackEffect _attackEffect;
private Collider[] _hitResults;
private readonly HashSet _hitEntityIds = new();
@@ -116,7 +114,15 @@ namespace Entity.Weapon
private void ApplyGroundAreaDamage()
{
- if (_hitRadius <= 0f || _hitResults == null || _hitResults.Length == 0) return;
+ if (_hitRadius <= 0f) return;
+
+ if (TryQueueAreaCollisionQuery(_attackCenter, _hitRadius, Mathf.Max(1, _maxHitColliders)))
+ {
+ _hitEntityIds.Clear();
+ return;
+ }
+
+ if (_hitResults == null || _hitResults.Length == 0) return;
int hitCount = Physics.OverlapSphereNonAlloc(_attackCenter, _hitRadius, _hitResults, _hitMask,
QueryTriggerInteraction.Collide);
@@ -149,12 +155,8 @@ namespace Entity.Weapon
_sqrRange = _weaponData.AttackRange * _weaponData.AttackRange;
_cachedRotation = CachedTransform.rotation;
- string hitRadiusRaw = _weaponData.GetParamsString(HitRadiusParamKey);
- if (!float.TryParse(hitRadiusRaw, out _hitRadius))
- {
- _hitRadius = _weaponData.AttackRange;
- }
- _hitRadius = Mathf.Max(0.1f, _hitRadius);
+ float configuredHitRadius = _weaponData.ParamsData != null ? _weaponData.ParamsData.HitRadius : 0f;
+ _hitRadius = configuredHitRadius > 0f ? Mathf.Max(0.1f, configuredHitRadius) : _weaponData.AttackRange;
_hitRadiusSqr = _hitRadius * _hitRadius;
_attackEffect = new KnifeRangeAttackEffect();
diff --git a/Assets/StreamingAssets/UI.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance.meta
similarity index 77%
rename from Assets/StreamingAssets/UI.meta
rename to Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance.meta
index 1992eb8..4d75326 100644
--- a/Assets/StreamingAssets/UI.meta
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 1b6ba43d50137a44a9cac13aee7c79b4
+guid: aee5d5037e73e894cac11712e321b930
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.AttackState.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.AttackState.cs
new file mode 100644
index 0000000..394b447
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.AttackState.cs
@@ -0,0 +1,31 @@
+namespace Entity.Weapon
+{
+ public partial class WeaponLance
+ {
+ private class AttackState : WeaponStateBase
+ {
+ private WeaponLance _weapon;
+
+ public override WeaponStateType State => WeaponStateType.Attack;
+ public override void OnInit(WeaponBase weapon) => _weapon = weapon as WeaponLance;
+
+ public override void OnEnter()
+ {
+ _weapon._currAttackTimer = 0f;
+ _weapon.Attack();
+ }
+
+ public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
+ {
+ if (!_weapon._isAttacking)
+ {
+ _weapon.TransitionTo(WeaponStateType.Check_InRange);
+ }
+ }
+
+ public override void OnLeave()
+ {
+ }
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.AttackState.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.AttackState.cs.meta
new file mode 100644
index 0000000..2ad423b
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.AttackState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d9991ee189b733b4e9d40395357f5ef2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckInRangeState.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckInRangeState.cs
new file mode 100644
index 0000000..2d1d3d2
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckInRangeState.cs
@@ -0,0 +1,49 @@
+namespace Entity.Weapon
+{
+ public partial class WeaponLance
+ {
+ private class CheckInRangeState : WeaponStateBase
+ {
+ private WeaponLance _weapon;
+
+ public override WeaponStateType State => WeaponStateType.Check_InRange;
+ public override void OnInit(WeaponBase weapon) => _weapon = weapon as WeaponLance;
+
+ public override void OnEnter()
+ {
+ if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
+ {
+ _weapon.TransitionTo(WeaponStateType.Attack);
+ }
+ }
+
+ public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
+ {
+ _weapon.Check();
+ _weapon.RotateToTarget(elapseSeconds);
+ _weapon._currAttackTimer += elapseSeconds;
+
+ if (_weapon._target == null || !_weapon._target.Available)
+ {
+ _weapon.TransitionTo(WeaponStateType.Idle);
+ return;
+ }
+
+ if (!_weapon.IsInRange(_weapon._target, _weapon._sqrRange))
+ {
+ _weapon.TransitionTo(WeaponStateType.Check_OutRange);
+ return;
+ }
+
+ if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
+ {
+ _weapon.TransitionTo(WeaponStateType.Attack);
+ }
+ }
+
+ public override void OnLeave()
+ {
+ }
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckInRangeState.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckInRangeState.cs.meta
new file mode 100644
index 0000000..3e6f575
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckInRangeState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 01edc8bb0ff46a14d8028e03af932b52
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckOutRangeState.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckOutRangeState.cs
new file mode 100644
index 0000000..93bf972
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckOutRangeState.cs
@@ -0,0 +1,39 @@
+namespace Entity.Weapon
+{
+ public partial class WeaponLance
+ {
+ private class CheckOutRangeState : WeaponStateBase
+ {
+ private WeaponLance _weapon;
+
+ public override WeaponStateType State => WeaponStateType.Check_OutRange;
+ public override void OnInit(WeaponBase weapon) => _weapon = weapon as WeaponLance;
+
+ public override void OnEnter()
+ {
+ }
+
+ public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
+ {
+ _weapon.Check();
+ _weapon.RotateToTarget(elapseSeconds);
+ _weapon._currAttackTimer += elapseSeconds;
+
+ if (_weapon._target == null || !_weapon._target.Available)
+ {
+ _weapon.TransitionTo(WeaponStateType.Idle);
+ return;
+ }
+
+ if (_weapon.IsInRange(_weapon._target, _weapon._sqrRange))
+ {
+ _weapon.TransitionTo(WeaponStateType.Check_InRange);
+ }
+ }
+
+ public override void OnLeave()
+ {
+ }
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckOutRangeState.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckOutRangeState.cs.meta
new file mode 100644
index 0000000..416d391
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.CheckOutRangeState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8009ad2c332a956438e3357ed5e30eb6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.IdleState.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.IdleState.cs
new file mode 100644
index 0000000..f0ab6f0
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.IdleState.cs
@@ -0,0 +1,33 @@
+namespace Entity.Weapon
+{
+ public partial class WeaponLance
+ {
+ public class IdleState : WeaponStateBase
+ {
+ private WeaponLance _weapon;
+
+ public override WeaponStateType State => WeaponStateType.Idle;
+ public override void OnInit(WeaponBase weapon) => _weapon = weapon as WeaponLance;
+
+ public override void OnEnter()
+ {
+ }
+
+ public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
+ {
+ _weapon.Check();
+ _weapon.RotateToOrigin(elapseSeconds);
+ _weapon._currAttackTimer += elapseSeconds;
+
+ if (_weapon._target != null && _weapon._target.Available)
+ {
+ _weapon.TransitionTo(WeaponStateType.Check_OutRange);
+ }
+ }
+
+ public override void OnLeave()
+ {
+ }
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.IdleState.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.IdleState.cs.meta
new file mode 100644
index 0000000..4ad5fa9
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.IdleState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4c9e98f5381e579469dcdac7bf3e1e25
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.cs
new file mode 100644
index 0000000..ddf076f
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.cs
@@ -0,0 +1,359 @@
+using System.Collections.Generic;
+using Components;
+using CustomUtility;
+using Definition.DataStruct;
+using Definition.Enum;
+using DG.Tweening;
+using Entity.EntityData;
+using UnityEngine;
+using UnityGameFramework.Runtime;
+
+namespace Entity.Weapon
+{
+ public partial class WeaponLance : WeaponBase
+ {
+ // 长枪专用数据,包含强类型 ParamsData。
+ [SerializeField] private WeaponLanceData _weaponData;
+
+ private Quaternion _cachedRotation;
+ // 朝向目标时的旋转速度,可被 ParamsData.RotateSpeed 覆盖。
+ [SerializeField] private float _rotateSpeed = 5f;
+
+ private Sequence _attackSequence;
+ private Transform _attackParent;
+
+ // 前刺动画耗时。
+ [SerializeField] private float _attackDuration = 0.12f;
+ // 收枪返回耗时。
+ [SerializeField] private float _returnDuration = 0.18f;
+
+ [SerializeField] private LayerMask _hitMask = ~0;
+ [SerializeField] private int _maxHitColliders = 32;
+
+ private IWeaponAttackEffect _attackEffect;
+ private Collider[] _hitResults;
+ private readonly HashSet _hitEntityIds = new();
+
+ // 前戳矩形判定的横向半宽。
+ private float _hitHalfWidth;
+ // 前戳矩形判定盒体的总高度。
+ private float _hitHeight;
+ // 盒体中心相对战斗平面的高度偏移。
+ private float _hitCenterYOffset;
+ // 从判定起点向前延伸的有效刺击长度。
+ private float _pierceLength;
+ // 判定起点相对武器当前位置的前移量。
+ private float _forwardOffset;
+ // 本次攻击锁定的前戳方向,避免受位移动画中的武器位置影响。
+ private Vector3 _strikeDirection = Vector3.forward;
+ // 本次攻击锁定的矩形判定中心。
+ private Vector3 _strikeCenter;
+
+ public override ImpactData GetImpactData()
+ {
+ return new ImpactData(_weaponData.OwnerCamp, _weaponData.Attack, AttackStat);
+ }
+
+ protected override void BuildStates()
+ {
+ RegisterState(new IdleState());
+ RegisterState(new CheckInRangeState());
+ RegisterState(new CheckOutRangeState());
+ RegisterState(new AttackState());
+ }
+
+ protected override void Attack()
+ {
+ StopAttackTween(false);
+ FaceTargetImmediately();
+ CacheStrikeSnapshot();
+
+ _isAttacking = true;
+ _attackParent = CachedTransform.parent;
+ CachedTransform.SetParent(null);
+
+ Vector3 targetPos = CachedTransform.position + _strikeDirection * _pierceLength;
+ _attackSequence = DOTween.Sequence();
+ _attackSequence.Append(CachedTransform.DOMove(targetPos, _attackDuration).SetEase(Ease.OutQuad));
+ _attackSequence.AppendCallback(() =>
+ {
+ _attackEffect?.Play(this, _strikeCenter, _target, _hitHalfWidth);
+ ApplyPierceDamage();
+
+ if (_attackParent != null)
+ {
+ CachedTransform.SetParent(_attackParent);
+ }
+ });
+ _attackSequence.Append(CachedTransform.DOLocalMove(Vector3.zero, _returnDuration).SetEase(Ease.InQuad));
+ _attackSequence.AppendCallback(() =>
+ {
+ _isAttacking = false;
+ _attackSequence = null;
+ _attackParent = null;
+ });
+ }
+
+ protected override void Check()
+ {
+ _target = SelectTarget(_sqrRange);
+ }
+
+ private void RotateToTarget(float elapseSeconds)
+ {
+ if (_target == null || !_target.Available) return;
+
+ Vector3 directionToTarget = _target.CachedTransform.position - CachedTransform.position;
+ directionToTarget.y = 0f;
+ if (directionToTarget.sqrMagnitude <= Mathf.Epsilon) return;
+
+ Quaternion targetRotation = Quaternion.LookRotation(directionToTarget.normalized, Vector3.up);
+ CachedTransform.rotation =
+ Quaternion.Slerp(CachedTransform.rotation, targetRotation, _rotateSpeed * elapseSeconds);
+ }
+
+ private void RotateToOrigin(float elapseSeconds)
+ {
+ CachedTransform.rotation =
+ Quaternion.Slerp(CachedTransform.rotation, _cachedRotation, _rotateSpeed * elapseSeconds);
+ }
+
+ private void FaceTargetImmediately()
+ {
+ if (_target == null || !_target.Available) return;
+
+ Vector3 directionToTarget = _target.CachedTransform.position - CachedTransform.position;
+ directionToTarget.y = 0f;
+ if (directionToTarget.sqrMagnitude <= Mathf.Epsilon) return;
+
+ CachedTransform.rotation = Quaternion.LookRotation(directionToTarget.normalized, Vector3.up);
+ }
+
+ private void CacheStrikeSnapshot()
+ {
+ _strikeDirection = ResolvePlanarForward();
+ Vector3 strikeStart = CachedTransform.position;
+ strikeStart.y = ResolveStrikePlaneY();
+ strikeStart += _strikeDirection * _forwardOffset;
+ _strikeCenter = strikeStart + _strikeDirection * (_pierceLength * 0.5f);
+ _strikeCenter.y += _hitCenterYOffset;
+ }
+
+ private Vector3 ResolvePlanarForward()
+ {
+ Vector3 forward = CachedTransform.forward;
+ forward.y = 0f;
+ if (forward.sqrMagnitude <= Mathf.Epsilon)
+ {
+ forward = Vector3.forward;
+ }
+
+ forward.Normalize();
+ return forward;
+ }
+
+ private void ApplyPierceDamage()
+ {
+ if (_hitHalfWidth <= 0f || _pierceLength <= 0f) return;
+
+ Vector3 strikeDirection = _strikeDirection;
+ if (strikeDirection.sqrMagnitude <= Mathf.Epsilon) return;
+
+ Vector3 broadPhaseCenter = _strikeCenter;
+ Quaternion broadPhaseRotation = Quaternion.LookRotation(strikeDirection, Vector3.up);
+
+ if (TryQueueRectangleCollisionQuery(broadPhaseCenter, _hitHalfWidth, _pierceLength * 0.5f,
+ strikeDirection, Mathf.Max(1, _maxHitColliders)))
+ {
+ _hitEntityIds.Clear();
+ return;
+ }
+
+ int capacity = Mathf.Max(1, _maxHitColliders);
+ if (_hitResults == null || _hitResults.Length != capacity)
+ {
+ _hitResults = new Collider[capacity];
+ }
+
+ Vector3 halfExtents = new(_hitHalfWidth, _hitHeight * 0.5f, _pierceLength * 0.5f);
+ int hitCount = Physics.OverlapBoxNonAlloc(broadPhaseCenter, halfExtents, _hitResults, broadPhaseRotation,
+ _hitMask, QueryTriggerInteraction.Collide);
+ _hitEntityIds.Clear();
+ for (int i = 0; i < hitCount; i++)
+ {
+ Collider collider = _hitResults[i];
+ if (collider == null) continue;
+
+ TargetableObject targetable = collider.GetComponentInParent();
+ if (targetable == null || !targetable.Available || targetable.IsDead) continue;
+ if (!_hitEntityIds.Add(targetable.Id)) continue;
+
+ if (!IsTargetInsidePierce(targetable, broadPhaseCenter, strikeDirection)) continue;
+
+ AIUtility.PerformCollision(targetable, this);
+ }
+ }
+
+ private bool IsTargetInsidePierce(TargetableObject targetable, Vector3 strikeCenter, Vector3 strikeDirection)
+ {
+ Vector3 delta = targetable.CachedTransform.position - strikeCenter;
+ delta.y = 0f;
+
+ Vector3 right = Vector3.Cross(Vector3.up, strikeDirection);
+ float forwardDistance = Vector3.Dot(delta, strikeDirection);
+ float lateralDistance = Vector3.Dot(delta, right);
+ float targetRadius = ResolveTargetCollisionRadius(targetable);
+
+ return Mathf.Abs(forwardDistance) <= _pierceLength * 0.5f + targetRadius &&
+ Mathf.Abs(lateralDistance) <= _hitHalfWidth + targetRadius;
+ }
+
+ private static float ResolveTargetCollisionRadius(TargetableObject targetable)
+ {
+ if (targetable == null)
+ {
+ return 0f;
+ }
+
+ MovementComponent movementComponent = targetable.GetComponent();
+ return movementComponent != null ? Mathf.Max(0f, movementComponent.EnemyBodyRadius) : 0f;
+ }
+
+ private float ResolveStrikePlaneY()
+ {
+ if (_target != null && _target.Available)
+ {
+ return _target.CachedTransform.position.y;
+ }
+
+ if (_attackParent != null)
+ {
+ return _attackParent.position.y;
+ }
+
+ if (CachedTransform.parent != null)
+ {
+ return CachedTransform.parent.position.y;
+ }
+
+ return CachedTransform.position.y;
+ }
+
+ protected override bool OnWeaponShow(object userData)
+ {
+ _weaponData = RequireWeaponData(userData);
+ if (_weaponData == null) return false;
+ WeaponData = _weaponData;
+
+ _currAttackTimer = 0f;
+ _sqrRange = _weaponData.AttackRange * _weaponData.AttackRange;
+ _cachedRotation = CachedTransform.rotation;
+
+ WeaponLanceParamsData paramsData = _weaponData.ParamsData;
+ float configuredHalfWidth = paramsData != null && paramsData.HitHalfWidth > 0f
+ ? paramsData.HitHalfWidth
+ : paramsData != null && paramsData.HitRadius > 0f
+ ? paramsData.HitRadius
+ : 0.45f;
+ _hitHalfWidth = Mathf.Max(0.1f, configuredHalfWidth);
+ _hitHeight = paramsData != null && paramsData.HitHeight > 0f
+ ? Mathf.Max(0.2f, paramsData.HitHeight)
+ : 4f;
+ _hitCenterYOffset = paramsData != null ? paramsData.HitCenterYOffset : 0f;
+ _pierceLength = paramsData != null && paramsData.PierceLength > 0f
+ ? paramsData.PierceLength
+ : paramsData != null && paramsData.ThrustDistance > 0f
+ ? paramsData.ThrustDistance
+ : _weaponData.AttackRange;
+ _forwardOffset = paramsData != null && paramsData.ForwardOffset > 0f
+ ? paramsData.ForwardOffset
+ : 0f;
+
+ if (paramsData != null && paramsData.RotateSpeed > 0f)
+ {
+ _rotateSpeed = paramsData.RotateSpeed;
+ }
+
+ if (paramsData != null && paramsData.AttackDuration > 0f)
+ {
+ _attackDuration = paramsData.AttackDuration;
+ }
+
+ if (paramsData != null && paramsData.ReturnDuration > 0f)
+ {
+ _returnDuration = paramsData.ReturnDuration;
+ }
+
+ int colliderCapacity = Mathf.Max(1, _maxHitColliders);
+ if (_hitResults == null || _hitResults.Length != colliderCapacity)
+ {
+ _hitResults = new Collider[colliderCapacity];
+ }
+
+ _attackEffect = new LanceThrustAttackEffect();
+
+ if (_weaponData.OwnerCamp == CampType.Player)
+ {
+ gameObject.layer = LayerMask.NameToLayer("PlayerWeapon");
+ _hitMask = LayerMask.GetMask("Enemy");
+ }
+ else if (_weaponData.OwnerCamp == CampType.Enemy)
+ {
+ gameObject.layer = LayerMask.NameToLayer("EnemyWeapon");
+ _hitMask = LayerMask.GetMask("Player");
+ }
+
+ return true;
+ }
+
+ protected override void OnWeaponHide(object userData)
+ {
+ StopAttackTween(true);
+ _attackEffect = null;
+ }
+
+ protected override void OnWeaponAttach(EntityLogic parentEntity, Transform parentTransform, object userData)
+ {
+ BindAttackStatFromOwner(parentEntity);
+ }
+
+ protected override void OnWeaponDetach(EntityLogic parentEntity, object userData)
+ {
+ StopAttackTween(true);
+ ReleaseAttackStatSubscription();
+ }
+
+ protected override void OnEnabledChanged(bool enabled)
+ {
+ if (!enabled)
+ {
+ StopAttackTween(true);
+ }
+ }
+
+ private void StopAttackTween(bool resetTransform)
+ {
+ if (_attackSequence != null)
+ {
+ _attackSequence.Kill();
+ _attackSequence = null;
+ }
+
+ _isAttacking = false;
+
+ if (resetTransform && _attackParent != null)
+ {
+ CachedTransform.SetParent(_attackParent);
+ CachedTransform.localPosition = Vector3.zero;
+ CachedTransform.rotation = _cachedRotation;
+ }
+
+ _attackParent = null;
+ _hitEntityIds.Clear();
+ }
+
+ public float HitHalfWidth => _hitHalfWidth;
+ public float PierceLength => _pierceLength;
+ public Vector3 StrikeDirection => _strikeDirection;
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.cs.meta
new file mode 100644
index 0000000..b54effa
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLance/WeaponLance.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f90612be7695ee549b39473c9b706122
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning.meta
new file mode 100644
index 0000000..1587695
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 5fded69986744ded97edb5f2ac06304f
+timeCreated: 1771578597
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.AttackState.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.AttackState.cs
new file mode 100644
index 0000000..4541751
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.AttackState.cs
@@ -0,0 +1,31 @@
+namespace Entity.Weapon
+{
+ public partial class WeaponLightning
+ {
+ private class AttackState : WeaponStateBase
+ {
+ private WeaponLightning _weapon;
+
+ public override WeaponStateType State => WeaponStateType.Attack;
+ public override void OnInit(WeaponBase weapon) => _weapon = weapon as WeaponLightning;
+
+ public override void OnEnter()
+ {
+ _weapon._currAttackTimer = 0f;
+ _weapon.Attack();
+ }
+
+ public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
+ {
+ if (!_weapon._isAttacking)
+ {
+ _weapon.TransitionTo(WeaponStateType.Check_InRange);
+ }
+ }
+
+ public override void OnLeave()
+ {
+ }
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.AttackState.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.AttackState.cs.meta
new file mode 100644
index 0000000..36aa280
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.AttackState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 25854832a7d3416dacab67bcfef2a2fa
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckInRangeState.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckInRangeState.cs
new file mode 100644
index 0000000..c89e293
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckInRangeState.cs
@@ -0,0 +1,49 @@
+namespace Entity.Weapon
+{
+ public partial class WeaponLightning
+ {
+ private class CheckInRangeState : WeaponStateBase
+ {
+ private WeaponLightning _weapon;
+
+ public override WeaponStateType State => WeaponStateType.Check_InRange;
+ public override void OnInit(WeaponBase weapon) => _weapon = weapon as WeaponLightning;
+
+ public override void OnEnter()
+ {
+ if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
+ {
+ _weapon.TransitionTo(WeaponStateType.Attack);
+ }
+ }
+
+ public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
+ {
+ _weapon.Check();
+ _weapon.RotateToTarget(elapseSeconds);
+ _weapon._currAttackTimer += elapseSeconds;
+
+ if (_weapon._target == null || !_weapon._target.Available)
+ {
+ _weapon.TransitionTo(WeaponStateType.Idle);
+ return;
+ }
+
+ if (!_weapon.IsInRange(_weapon._target, _weapon._sqrRange))
+ {
+ _weapon.TransitionTo(WeaponStateType.Check_OutRange);
+ return;
+ }
+
+ if (_weapon._currAttackTimer >= _weapon._weaponData.Cooldown)
+ {
+ _weapon.TransitionTo(WeaponStateType.Attack);
+ }
+ }
+
+ public override void OnLeave()
+ {
+ }
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckInRangeState.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckInRangeState.cs.meta
new file mode 100644
index 0000000..46164eb
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckInRangeState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 25404ad172434ba38609e797b6d96919
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckOutRangeState.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckOutRangeState.cs
new file mode 100644
index 0000000..9160ebd
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckOutRangeState.cs
@@ -0,0 +1,39 @@
+namespace Entity.Weapon
+{
+ public partial class WeaponLightning
+ {
+ private class CheckOutRangeState : WeaponStateBase
+ {
+ private WeaponLightning _weapon;
+
+ public override WeaponStateType State => WeaponStateType.Check_OutRange;
+ public override void OnInit(WeaponBase weapon) => _weapon = weapon as WeaponLightning;
+
+ public override void OnEnter()
+ {
+ }
+
+ public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
+ {
+ _weapon.Check();
+ _weapon.RotateToTarget(elapseSeconds);
+ _weapon._currAttackTimer += elapseSeconds;
+
+ if (_weapon._target == null || !_weapon._target.Available)
+ {
+ _weapon.TransitionTo(WeaponStateType.Idle);
+ return;
+ }
+
+ if (_weapon.IsInRange(_weapon._target, _weapon._sqrRange))
+ {
+ _weapon.TransitionTo(WeaponStateType.Check_InRange);
+ }
+ }
+
+ public override void OnLeave()
+ {
+ }
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckOutRangeState.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckOutRangeState.cs.meta
new file mode 100644
index 0000000..e9c3d49
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.CheckOutRangeState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f3aa479abb79491d83e8de76806faf7a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.IdleState.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.IdleState.cs
new file mode 100644
index 0000000..1fea7a6
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.IdleState.cs
@@ -0,0 +1,33 @@
+namespace Entity.Weapon
+{
+ public partial class WeaponLightning
+ {
+ private class IdleState : WeaponStateBase
+ {
+ private WeaponLightning _weapon;
+
+ public override WeaponStateType State => WeaponStateType.Idle;
+ public override void OnInit(WeaponBase weapon) => _weapon = weapon as WeaponLightning;
+
+ public override void OnEnter()
+ {
+ }
+
+ public override void OnUpdate(float elapseSeconds, float realElapseSeconds)
+ {
+ _weapon.Check();
+ _weapon.RotateToOrigin(elapseSeconds);
+ _weapon._currAttackTimer += elapseSeconds;
+
+ if (_weapon._target != null && _weapon._target.Available)
+ {
+ _weapon.TransitionTo(WeaponStateType.Check_OutRange);
+ }
+ }
+
+ public override void OnLeave()
+ {
+ }
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.IdleState.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.IdleState.cs.meta
new file mode 100644
index 0000000..70343bd
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.IdleState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3464037c29774c3ea326dc93fe33551e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.cs
new file mode 100644
index 0000000..746a343
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.cs
@@ -0,0 +1,264 @@
+using System.Collections.Generic;
+using CustomUtility;
+using Definition.DataStruct;
+using Definition.Enum;
+using DG.Tweening;
+using Entity.EntityData;
+using UnityEngine;
+using UnityGameFramework.Runtime;
+
+namespace Entity.Weapon
+{
+ public partial class WeaponLightning : WeaponBase
+ {
+ private WeaponLightningData _weaponData;
+
+ private Quaternion _cachedRotation;
+ [SerializeField] private float _rotateSpeed = 5f;
+
+ [SerializeField] private float _takeOffDuration = 0.3f;
+ [SerializeField] private float _flyToTargetDuration = 0.2f;
+ [SerializeField] private float _strikeDuration = 0.1f;
+ [SerializeField] private float _returnDuration = 0.22f;
+
+ [SerializeField] private float _hoverHeight = 15f;
+
+ [SerializeField] private LayerMask _hitMask = ~0;
+ [SerializeField] private int _maxHitColliders = 32;
+
+ private Sequence _attackSequence;
+ private Transform _attackParent;
+ private Vector3 _lockedStrikePoint;
+
+ private Collider[] _hitResults;
+ private readonly HashSet _hitEntityIds = new();
+
+ private float _hitRadius;
+ private float _hitRadiusSqr;
+
+ private IWeaponAttackEffect _attackEffect;
+
+ public override ImpactData GetImpactData()
+ {
+ return new ImpactData(_weaponData.OwnerCamp, _weaponData.Attack, AttackStat);
+ }
+
+ protected override void BuildStates()
+ {
+ RegisterState(new IdleState());
+ RegisterState(new CheckOutRangeState());
+ RegisterState(new CheckInRangeState());
+ RegisterState(new AttackState());
+ }
+
+ protected override void Attack()
+ {
+ StopAttackTween(false);
+ FaceTargetImmediately();
+
+ _isAttacking = true;
+ _lockedStrikePoint = ResolveStrikePoint();
+ _attackParent = CachedTransform.parent;
+ CachedTransform.SetParent(null);
+
+ Vector3 takeOffPosition = CachedTransform.position;
+ Vector3 hoverPosition = _lockedStrikePoint + Vector3.up * Mathf.Max(0.1f, _hoverHeight);
+
+ _attackSequence = DOTween.Sequence();
+ _attackSequence.Append(CachedTransform.DOMove(takeOffPosition, _takeOffDuration).SetEase(Ease.OutQuad));
+ _attackSequence.Append(CachedTransform.DOMove(hoverPosition, _flyToTargetDuration).SetEase(Ease.OutSine));
+ _attackSequence.AppendCallback(() => CachedTransform.LookAt(_lockedStrikePoint));
+ _attackSequence.Append(CachedTransform.DOMove(_lockedStrikePoint, _strikeDuration).SetEase(Ease.InQuad));
+ _attackSequence.AppendCallback(() =>
+ {
+ _attackEffect?.Play(this, _lockedStrikePoint, _target, _hitRadius);
+ ApplyGroundAreaDamage();
+
+ if (_attackParent != null)
+ {
+ CachedTransform.SetParent(_attackParent);
+ }
+ });
+ _attackSequence.Append(CachedTransform.DOLocalMove(Vector3.zero, _returnDuration).SetEase(Ease.OutSine));
+ _attackSequence.AppendCallback(() =>
+ {
+ _isAttacking = false;
+ _attackSequence = null;
+ _attackParent = null;
+ });
+ }
+
+ protected override void Check()
+ {
+ _target = SelectTarget(_sqrRange);
+ }
+
+ private Vector3 ResolveStrikePoint()
+ {
+ if (_target != null && _target.Available)
+ {
+ return _target.CachedTransform.position;
+ }
+
+ return CachedTransform.position;
+ }
+
+ private void ApplyGroundAreaDamage()
+ {
+ if (_hitRadius <= 0f) return;
+
+ if (TryQueueAreaCollisionQuery(_lockedStrikePoint, _hitRadius, Mathf.Max(1, _maxHitColliders)))
+ {
+ _hitEntityIds.Clear();
+ return;
+ }
+
+ if (_hitResults == null || _hitResults.Length == 0) return;
+
+ int hitCount = Physics.OverlapSphereNonAlloc(_lockedStrikePoint, _hitRadius, _hitResults, _hitMask,
+ QueryTriggerInteraction.Collide);
+
+ _hitEntityIds.Clear();
+ for (int i = 0; i < hitCount; i++)
+ {
+ Collider collider = _hitResults[i];
+ if (collider == null) continue;
+
+ TargetableObject targetable = collider.GetComponentInParent();
+ if (targetable == null || !targetable.Available || targetable.IsDead) continue;
+ if (!_hitEntityIds.Add(targetable.Id)) continue;
+
+ Vector3 delta = targetable.CachedTransform.position - _lockedStrikePoint;
+ delta.y = 0f;
+ if (delta.sqrMagnitude > _hitRadiusSqr) continue;
+
+ AIUtility.PerformCollision(targetable, this);
+ }
+ }
+
+ private void RotateToTarget(float elapseSeconds)
+ {
+ if (_target == null || !_target.Available) return;
+
+ Vector3 directionToTarget = _target.CachedTransform.position - CachedTransform.position;
+ directionToTarget.y = 0f;
+ if (directionToTarget.sqrMagnitude <= Mathf.Epsilon) return;
+
+ Quaternion targetRotation = Quaternion.LookRotation(directionToTarget.normalized, Vector3.up);
+ CachedTransform.rotation =
+ Quaternion.Slerp(CachedTransform.rotation, targetRotation, _rotateSpeed * elapseSeconds);
+ }
+
+ private void RotateToOrigin(float elapseSeconds)
+ {
+ CachedTransform.rotation =
+ Quaternion.Slerp(CachedTransform.rotation, _cachedRotation, _rotateSpeed * elapseSeconds);
+ }
+
+ private void FaceTargetImmediately()
+ {
+ if (_target == null || !_target.Available) return;
+
+ Vector3 directionToTarget = _target.CachedTransform.position - CachedTransform.position;
+ directionToTarget.y = 0f;
+ if (directionToTarget.sqrMagnitude <= Mathf.Epsilon) return;
+
+ CachedTransform.rotation = Quaternion.LookRotation(directionToTarget.normalized, Vector3.up);
+ }
+
+ protected override bool OnWeaponShow(object userData)
+ {
+ _weaponData = RequireWeaponData(userData);
+ if (_weaponData == null) return false;
+ WeaponData = _weaponData;
+
+ _currAttackTimer = 0f;
+ _sqrRange = _weaponData.AttackRange * _weaponData.AttackRange;
+ _cachedRotation = CachedTransform.rotation;
+
+ float configuredHitRadius = _weaponData.ParamsData != null ? _weaponData.ParamsData.HitRadius : 0f;
+ _hitRadius = configuredHitRadius > 0f ? Mathf.Max(0.1f, configuredHitRadius) : _weaponData.AttackRange;
+ _hitRadiusSqr = _hitRadius * _hitRadius;
+ float configuredHoverHeight = _weaponData.ParamsData != null ? _weaponData.ParamsData.HoverHeight : 0f;
+ if (configuredHoverHeight > 0f)
+ {
+ _hoverHeight = Mathf.Max(0.1f, configuredHoverHeight);
+ }
+
+ int colliderCapacity = Mathf.Max(1, _maxHitColliders);
+ if (_hitResults == null || _hitResults.Length != colliderCapacity)
+ {
+ _hitResults = new Collider[colliderCapacity];
+ }
+
+ _attackEffect = new LightningStrikeAttackEffect();
+
+ if (_weaponData.OwnerCamp == CampType.Player)
+ {
+ gameObject.layer = LayerMask.NameToLayer("PlayerWeapon");
+ _hitMask = LayerMask.GetMask("Enemy");
+ }
+ else if (_weaponData.OwnerCamp == CampType.Enemy)
+ {
+ gameObject.layer = LayerMask.NameToLayer("EnemyWeapon");
+ _hitMask = LayerMask.GetMask("Player");
+ }
+
+ return true;
+ }
+
+ protected override void OnWeaponHide(object userData)
+ {
+ StopAttackTween(true);
+ _attackEffect = null;
+ }
+
+ protected override void OnWeaponAttach(EntityLogic parentEntity, Transform parentTransform, object userData)
+ {
+ BindAttackStatFromOwner(parentEntity);
+ }
+
+ protected override void OnWeaponDetach(EntityLogic parentEntity, object userData)
+ {
+ StopAttackTween(true);
+ ReleaseAttackStatSubscription();
+ }
+
+ protected override void OnEnabledChanged(bool enabled)
+ {
+ if (!enabled)
+ {
+ StopAttackTween(true);
+ }
+ }
+
+ private void StopAttackTween(bool resetTransform)
+ {
+ if (_attackSequence != null)
+ {
+ _attackSequence.Kill();
+ _attackSequence = null;
+ }
+
+ _isAttacking = false;
+
+ if (resetTransform)
+ {
+ if (_attackParent != null)
+ {
+ CachedTransform.SetParent(_attackParent);
+ CachedTransform.localPosition = Vector3.zero;
+ }
+ else if (CachedTransform.parent != null)
+ {
+ CachedTransform.localPosition = Vector3.zero;
+ }
+
+ CachedTransform.rotation = _cachedRotation;
+ }
+
+ _attackParent = null;
+ _hitEntityIds.Clear();
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.cs.meta
new file mode 100644
index 0000000..82f8d6d
--- /dev/null
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a8315d9e60c4434ebde9b23c853f27d0
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponSlash/WeaponSlash.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponSlash/WeaponSlash.cs
index 522d029..53c0d35 100644
--- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponSlash/WeaponSlash.cs
+++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponSlash/WeaponSlash.cs
@@ -13,8 +13,6 @@ namespace Entity.Weapon
{
#region Property
- private const string SectorAngleParamKey = "SectorAngle";
-
private WeaponSlashData _weaponData;
private Quaternion _cachedRotation;
@@ -93,12 +91,8 @@ namespace Entity.Weapon
private void ApplySectorDamage()
{
- if (_attackRadius <= 0f || _hitResults == null || _hitResults.Length == 0) return;
+ if (_attackRadius <= 0f) return;
- int hitCount = Physics.OverlapSphereNonAlloc(_attackCenter, _attackRadius, _hitResults, _hitMask,
- QueryTriggerInteraction.Collide);
-
- _hitEntityIds.Clear();
Vector3 forward = CachedTransform.forward;
forward.y = 0f;
if (forward.sqrMagnitude <= Mathf.Epsilon)
@@ -107,8 +101,20 @@ namespace Entity.Weapon
}
forward.Normalize();
-
float halfAngle = _sectorAngle * 0.5f;
+ if (TryQueueSectorCollisionQuery(_attackCenter, _attackRadius, in forward, halfAngle,
+ Mathf.Max(1, _maxHitColliders)))
+ {
+ _hitEntityIds.Clear();
+ return;
+ }
+
+ if (_hitResults == null || _hitResults.Length == 0) return;
+
+ int hitCount = Physics.OverlapSphereNonAlloc(_attackCenter, _attackRadius, _hitResults, _hitMask,
+ QueryTriggerInteraction.Collide);
+
+ _hitEntityIds.Clear();
for (int i = 0; i < hitCount; i++)
{
Collider collider = _hitResults[i];
@@ -176,15 +182,8 @@ namespace Entity.Weapon
_attackRadius = Mathf.Max(0.1f, _weaponData.AttackRange);
_attackRadiusSqr = _attackRadius * _attackRadius;
- _sectorAngle = 90f;
- if (_weaponData.Params != null &&
- _weaponData.Params.TryGetValue(SectorAngleParamKey.ToLower(), out string rawAngle))
- {
- if (float.TryParse(rawAngle, out float parsedAngle))
- {
- _sectorAngle = Mathf.Clamp(parsedAngle, 1f, 360f);
- }
- }
+ float configuredSectorAngle = _weaponData.ParamsData != null ? _weaponData.ParamsData.SectorAngle : 0f;
+ _sectorAngle = configuredSectorAngle > 0f ? Mathf.Clamp(configuredSectorAngle, 1f, 360f) : 90f;
int capacity = Mathf.Max(1, _maxHitColliders);
if (_hitResults == null || _hitResults.Length != capacity)
diff --git a/Assets/StreamingAssets/UI/UISprites.meta b/Assets/GameMain/Scripts/Event/Combat.meta
similarity index 77%
rename from Assets/StreamingAssets/UI/UISprites.meta
rename to Assets/GameMain/Scripts/Event/Combat.meta
index 50f2378..04dd2e6 100644
--- a/Assets/StreamingAssets/UI/UISprites.meta
+++ b/Assets/GameMain/Scripts/Event/Combat.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: aa252b664de661c40bee3ddc89dfc7e0
+guid: 7771e0f6b92ece64395ada5cdea72858
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/Assets/GameMain/Scripts/Event/Combat/ProjectileHitPresentationEventArgs.cs b/Assets/GameMain/Scripts/Event/Combat/ProjectileHitPresentationEventArgs.cs
new file mode 100644
index 0000000..fdda881
--- /dev/null
+++ b/Assets/GameMain/Scripts/Event/Combat/ProjectileHitPresentationEventArgs.cs
@@ -0,0 +1,53 @@
+using GameFramework;
+using GameFramework.Event;
+using UnityEngine;
+
+namespace CustomEvent
+{
+ public sealed class ProjectileHitPresentationEventArgs : GameEventArgs
+ {
+ public static readonly int EventId = typeof(ProjectileHitPresentationEventArgs).GetHashCode();
+
+ public override int Id => EventId;
+
+ public int ProjectileEntityId { get; private set; }
+ public int SourceEntityId { get; private set; }
+ public int SourceOwnerEntityId { get; private set; }
+ public int TargetEntityId { get; private set; }
+ public int Damage { get; private set; }
+ public Vector3 HitPosition { get; private set; }
+ public bool ShowHitMarker { get; private set; }
+ public bool ShowHitEffect { get; private set; }
+ public int EffectEntityTypeId { get; private set; }
+
+ public static ProjectileHitPresentationEventArgs Create(int projectileEntityId, int sourceEntityId,
+ int sourceOwnerEntityId, int targetEntityId, int damage, in Vector3 hitPosition, bool showHitMarker,
+ bool showHitEffect, int effectEntityTypeId)
+ {
+ ProjectileHitPresentationEventArgs args = ReferencePool.Acquire();
+ args.ProjectileEntityId = projectileEntityId;
+ args.SourceEntityId = sourceEntityId;
+ args.SourceOwnerEntityId = sourceOwnerEntityId;
+ args.TargetEntityId = targetEntityId;
+ args.Damage = damage;
+ args.HitPosition = hitPosition;
+ args.ShowHitMarker = showHitMarker;
+ args.ShowHitEffect = showHitEffect;
+ args.EffectEntityTypeId = effectEntityTypeId;
+ return args;
+ }
+
+ public override void Clear()
+ {
+ ProjectileEntityId = 0;
+ SourceEntityId = 0;
+ SourceOwnerEntityId = 0;
+ TargetEntityId = 0;
+ Damage = 0;
+ HitPosition = Vector3.zero;
+ ShowHitMarker = false;
+ ShowHitEffect = false;
+ EffectEntityTypeId = 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Event/Combat/ProjectileHitPresentationEventArgs.cs.meta b/Assets/GameMain/Scripts/Event/Combat/ProjectileHitPresentationEventArgs.cs.meta
new file mode 100644
index 0000000..0f7860e
--- /dev/null
+++ b/Assets/GameMain/Scripts/Event/Combat/ProjectileHitPresentationEventArgs.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d02876e26d8342f7af024c697e451ea4
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs b/Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs
index e7785b3..2e3f13b 100644
--- a/Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs
+++ b/Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs
@@ -91,6 +91,10 @@ namespace Procedure
{
GameEntry.Entity.HideEntity(entity.Id);
}
+
+ HideEntityGroup("Bullet");
+ HideEntityGroup("Projectile");
+ HideEntityGroup("EnemyProjectile");
}
public override void OnDestroy(IFsm procedureOwner)
@@ -99,6 +103,21 @@ namespace Procedure
_procedureGame = null;
}
+ private static void HideEntityGroup(string groupName)
+ {
+ var entityGroup = GameEntry.Entity.GetEntityGroup(groupName);
+ var entities = entityGroup?.GetAllEntities();
+ if (entities == null)
+ {
+ return;
+ }
+
+ foreach (var entity in entities)
+ {
+ GameEntry.Entity.HideEntity(entity.Id);
+ }
+ }
+
#endregion
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs b/Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs
index bcb12aa..12efc78 100644
--- a/Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs
+++ b/Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs
@@ -35,6 +35,7 @@ namespace Procedure
public Player Player;
public GameStateBase CurrentGameState => _gameStates[_currentGameState];
+ public GameStateType CurrentGameStateType => _currentGameState;
private void InitGameState()
{
@@ -87,7 +88,7 @@ namespace Procedure
base.OnEnter(procedureOwner);
_procedureOwner = procedureOwner;
- GameEntry.SimulationWorld?.Clear();
+ GameEntry.SimulationWorld?.ClearSimulationState();
GameEntry.Event.Subscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormSuccess);
GameEntry.Event.Subscribe(ShowEntitySuccessEventArgs.EventId, ShowEntitySuccess);
@@ -135,7 +136,7 @@ namespace Procedure
Player = null;
_procedureOwner = null;
- GameEntry.SimulationWorld?.Clear();
+ GameEntry.SimulationWorld?.ClearSimulationState();
GameEntry.Event.Unsubscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormSuccess);
GameEntry.Event.Unsubscribe(ShowEntitySuccessEventArgs.EventId, ShowEntitySuccess);
@@ -169,4 +170,4 @@ namespace Procedure
#endregion
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel.meta b/Assets/GameMain/Scripts/Simulation/DataChannel.meta
new file mode 100644
index 0000000..db64f25
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 5f28ff313e954720a04246191b7f9ddd
+timeCreated: 1771901064
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.CollisionTransient.cs b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.CollisionTransient.cs
new file mode 100644
index 0000000..a18242b
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.CollisionTransient.cs
@@ -0,0 +1,133 @@
+using Unity.Mathematics;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ #region Collision Transient
+
+ public int CollisionCandidateCount => _collisionCandidates.IsCreated ? _collisionCandidates.Length : 0;
+ public int PendingAreaCollisionRequestCount => _areaCollisionRequests.Count;
+ public int LastCollisionQueryCount => _lastCollisionQueryCount;
+ public int LastProjectileCollisionQueryCount => _lastProjectileCollisionQueryCount;
+ public int LastAreaCollisionQueryCount => _lastAreaCollisionQueryCount;
+ public int LastCollisionCandidateCount => _lastCollisionCandidateCount;
+ public int LastProjectileCollisionCandidateCount => _lastProjectileCollisionCandidateCount;
+ public int LastAreaCollisionCandidateCount => _lastAreaCollisionCandidateCount;
+ public int LastResolvedAreaHitCount => _lastResolvedAreaHitCount;
+ public float LastCollisionCellSize => _lastCollisionCellSize;
+ public bool LastCollisionHasEnemyTargets => _lastCollisionHasEnemyTargets;
+
+ private void ResetCollisionRuntimeStats()
+ {
+ _lastCollisionQueryCount = 0;
+ _lastProjectileCollisionQueryCount = 0;
+ _lastAreaCollisionQueryCount = 0;
+ _lastCollisionCandidateCount = 0;
+ _lastProjectileCollisionCandidateCount = 0;
+ _lastAreaCollisionCandidateCount = 0;
+ _lastResolvedAreaHitCount = 0;
+ _lastCollisionCellSize = 0f;
+ _lastCollisionHasEnemyTargets = false;
+ }
+
+ private void PrepareCollisionQueryAndCandidateChannels(int queryCount, int expectedCandidateCount,
+ int bucketCapacity)
+ {
+ InitializeJobDataChannels();
+ EnsureCapacity(ref _collisionQueryInputs, queryCount);
+ EnsureCapacity(ref _collisionCandidates, expectedCandidateCount);
+ EnsureCapacity(ref _enemyCollisionBuckets, bucketCapacity);
+
+ _collisionQueryInputs.Clear();
+ _collisionCandidates.Clear();
+ _enemyCollisionBuckets.Clear();
+ }
+
+ private void AddProjectileCollisionQuery(int queryId, in ProjectileJobOutputData projectile, float radius,
+ int maxTargets = 1)
+ {
+ if (!_collisionQueryInputs.IsCreated || radius <= 0f)
+ {
+ return;
+ }
+
+ _collisionQueryInputs.Add(new CollisionQueryData
+ {
+ QueryId = queryId,
+ SourceType = CollisionSourceTypeProjectile,
+ SourceEntityId = projectile.EntityId,
+ SourceOwnerEntityId = projectile.OwnerEntityId,
+ SourceWasActiveAtQueryTime = true,
+ Position = projectile.Position,
+ Radius = radius,
+ MaxTargets = math.max(1, maxTargets),
+ ShapeType = CollisionShapeCircle,
+ Direction = new float3(0f, 0f, 1f),
+ HalfAngleDeg = 180f,
+ HalfWidth = 0f,
+ HalfLength = 0f
+ });
+ }
+
+ private void AddAreaCollisionQuery(int queryId, int sourceEntityId, int sourceOwnerEntityId,
+ bool sourceWasActiveAtQueryTime, in Vector3 center, float radius, int maxTargets, int shapeType,
+ in Vector3 direction, float halfAngleDeg, float halfWidth, float halfLength)
+ {
+ if (!_collisionQueryInputs.IsCreated || radius <= 0f)
+ {
+ return;
+ }
+
+ Vector3 normalizedDirection = direction;
+ normalizedDirection.y = 0f;
+ if (normalizedDirection.sqrMagnitude <= Mathf.Epsilon)
+ {
+ normalizedDirection = Vector3.forward;
+ }
+ else
+ {
+ normalizedDirection.Normalize();
+ }
+
+ _collisionQueryInputs.Add(new CollisionQueryData
+ {
+ QueryId = queryId,
+ SourceType = CollisionSourceTypeArea,
+ SourceEntityId = sourceEntityId,
+ SourceOwnerEntityId = sourceOwnerEntityId,
+ SourceWasActiveAtQueryTime = sourceWasActiveAtQueryTime,
+ Position = new float3(center.x, center.y, center.z),
+ Radius = radius,
+ MaxTargets = math.max(1, maxTargets),
+ ShapeType = shapeType,
+ Direction = new float3(normalizedDirection.x, normalizedDirection.y, normalizedDirection.z),
+ HalfAngleDeg = Mathf.Clamp(halfAngleDeg, 0f, 180f),
+ HalfWidth = Mathf.Max(0f, halfWidth),
+ HalfLength = Mathf.Max(0f, halfLength)
+ });
+ }
+
+ private void AddCollisionCandidate(int queryId, int sourceType, int sourceEntityId, int sourceOwnerEntityId,
+ int targetEntityId, float sqrDistance)
+ {
+ if (!_collisionCandidates.IsCreated)
+ {
+ return;
+ }
+
+ _collisionCandidates.Add(new CollisionCandidateData
+ {
+ QueryId = queryId,
+ SourceType = sourceType,
+ SourceEntityId = sourceEntityId,
+ SourceOwnerEntityId = sourceOwnerEntityId,
+ TargetEntityId = targetEntityId,
+ SqrDistance = sqrDistance
+ });
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.CollisionTransient.cs.meta b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.CollisionTransient.cs.meta
new file mode 100644
index 0000000..1702dee
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.CollisionTransient.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4b0eaf024db24511988ba3bde25359e7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.EnemySeparationTemporal.cs b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.EnemySeparationTemporal.cs
new file mode 100644
index 0000000..b899e6f
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.EnemySeparationTemporal.cs
@@ -0,0 +1,91 @@
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ #region Enemy Separation Temporal
+
+ private void PrepareEnemySeparationJobBuffers(int enemyCount, int bucketCapacity)
+ {
+ InitializeJobDataChannels();
+ EnsureCapacity(ref _enemyJobSeparationOutputs, enemyCount);
+ _enemyJobSeparationOutputs.Clear();
+ if (enemyCount > 0)
+ {
+ _enemyJobSeparationOutputs.ResizeUninitialized(enemyCount);
+ }
+
+ EnsureCapacity(ref _enemySeparationBuckets, bucketCapacity);
+ _enemySeparationBuckets.Clear();
+
+ EnsureCapacity(ref _enemySeparationPreviousPushes, enemyCount);
+ EnsureCapacity(ref _enemySeparationCurrentPushes, enemyCount);
+
+ if (_enemySeparationPreviousPushes.Length < enemyCount)
+ {
+ int oldLength = _enemySeparationPreviousPushes.Length;
+ _enemySeparationPreviousPushes.ResizeUninitialized(enemyCount);
+ for (int i = oldLength; i < enemyCount; i++)
+ {
+ _enemySeparationPreviousPushes[i] = float2.zero;
+ }
+ }
+ else if (_enemySeparationPreviousPushes.Length > enemyCount)
+ {
+ _enemySeparationPreviousPushes.ResizeUninitialized(enemyCount);
+ }
+
+ _enemySeparationCurrentPushes.Clear();
+ if (enemyCount > 0)
+ {
+ _enemySeparationCurrentPushes.ResizeUninitialized(enemyCount);
+ }
+ }
+
+ private void CommitEnemySeparationTemporalBuffers(int enemyCount)
+ {
+ if (!_enemySeparationPreviousPushes.IsCreated || !_enemySeparationCurrentPushes.IsCreated)
+ {
+ return;
+ }
+
+ int copyCount = math.min(enemyCount,
+ math.min(_enemySeparationPreviousPushes.Length, _enemySeparationCurrentPushes.Length));
+ for (int i = 0; i < copyCount; i++)
+ {
+ _enemySeparationPreviousPushes[i] = _enemySeparationCurrentPushes[i];
+ }
+ }
+
+ private void OnEnemyAddedToSeparationTemporalBuffers()
+ {
+ if (_enemySeparationPreviousPushes.IsCreated)
+ {
+ _enemySeparationPreviousPushes.Add(float2.zero);
+ }
+
+ if (_enemySeparationCurrentPushes.IsCreated)
+ {
+ _enemySeparationCurrentPushes.Add(float2.zero);
+ }
+ }
+
+ private void OnEnemyRemovedFromSeparationTemporalBuffers(int removedIndex)
+ {
+ if (_enemySeparationPreviousPushes.IsCreated && removedIndex >= 0 &&
+ removedIndex < _enemySeparationPreviousPushes.Length)
+ {
+ _enemySeparationPreviousPushes.RemoveAtSwapBack(removedIndex);
+ }
+
+ if (_enemySeparationCurrentPushes.IsCreated && removedIndex >= 0 &&
+ removedIndex < _enemySeparationCurrentPushes.Length)
+ {
+ _enemySeparationCurrentPushes.RemoveAtSwapBack(removedIndex);
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.EnemySeparationTemporal.cs.meta b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.EnemySeparationTemporal.cs.meta
new file mode 100644
index 0000000..e0fbe41
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.EnemySeparationTemporal.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b2c8675358384b21925e6f660ce61a79
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs
new file mode 100644
index 0000000..5dd7ac6
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+using Unity.Collections;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ // Shared channel constants plus compatibility fields still reflected by tests.
+ private const int CollisionSourceTypeProjectile = 1;
+ private const int CollisionSourceTypeArea = 2;
+ private const int CollisionShapeCircle = 0;
+ private const int CollisionShapeSector = 1;
+ private const int CollisionShapeRectangle = 2;
+
+ // Kept as top-level fields because current regression tests reflect them directly.
+ private NativeList _collisionQueryInputs;
+ private readonly List _areaCollisionRequests = new(16);
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs.meta b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs.meta
new file mode 100644
index 0000000..0922578
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 20926de0e6e14c7b818779593f1f87bc
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataConversion.cs b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataConversion.cs
new file mode 100644
index 0000000..bd8e0af
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataConversion.cs
@@ -0,0 +1,151 @@
+using Unity.Mathematics;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ #region Job Data Conversion
+
+ private void SyncSimulationStateToJobInputs()
+ {
+ InitializeJobDataChannels();
+ EnsureCapacity(ref _enemyJobInputs, _enemies.Count);
+ EnsureCapacity(ref _projectileJobInputs, _projectiles.Count);
+
+ _enemyJobInputs.Clear();
+ _projectileJobInputs.Clear();
+
+ foreach (EnemySimData data in _enemies)
+ {
+ _enemyJobInputs.Add(ConvertToEnemyJobInput(data));
+ }
+
+ foreach (ProjectileSimData data in _projectiles)
+ {
+ _projectileJobInputs.Add(ConvertToProjectileJobInput(data));
+ }
+ }
+
+ private void PrepareEnemyJobOutputBuffer(int enemyCount)
+ {
+ InitializeJobDataChannels();
+ EnsureCapacity(ref _enemyJobOutputs, enemyCount);
+ _enemyJobOutputs.Clear();
+
+ if (enemyCount > 0)
+ {
+ _enemyJobOutputs.ResizeUninitialized(enemyCount);
+ }
+ }
+
+ private void PrepareProjectileJobOutputBuffer(int projectileCount)
+ {
+ InitializeJobDataChannels();
+ EnsureCapacity(ref _projectileJobOutputs, projectileCount);
+ _projectileJobOutputs.Clear();
+
+ if (projectileCount > 0)
+ {
+ _projectileJobOutputs.ResizeUninitialized(projectileCount);
+ }
+ }
+
+ private void CopyProjectileInputsToOutputs()
+ {
+ for (int i = 0; i < _projectileJobInputs.Length; i++)
+ {
+ ProjectileJobInputData input = _projectileJobInputs[i];
+ _projectileJobOutputs[i] = 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
+ };
+ }
+ }
+
+ private static EnemyJobInputData ConvertToEnemyJobInput(in EnemySimData enemy)
+ {
+ return new EnemyJobInputData
+ {
+ EntityId = enemy.EntityId,
+ Position = new float3(enemy.Position.x, enemy.Position.y, enemy.Position.z),
+ Forward = new float3(enemy.Forward.x, enemy.Forward.y, enemy.Forward.z),
+ Rotation = new quaternion(enemy.Rotation.x, enemy.Rotation.y, enemy.Rotation.z, enemy.Rotation.w),
+ Speed = enemy.Speed,
+ AttackRange = enemy.AttackRange,
+ AvoidEnemyOverlap = enemy.AvoidEnemyOverlap,
+ EnemyBodyRadius = enemy.EnemyBodyRadius,
+ SeparationIterations = enemy.SeparationIterations,
+ TargetType = enemy.TargetType,
+ State = enemy.State
+ };
+ }
+
+ private static EnemySimData ConvertToEnemySimData(in EnemyJobOutputData enemy)
+ {
+ return new EnemySimData
+ {
+ EntityId = enemy.EntityId,
+ Position = new Vector3(enemy.Position.x, enemy.Position.y, enemy.Position.z),
+ Forward = new Vector3(enemy.Forward.x, enemy.Forward.y, enemy.Forward.z),
+ Rotation = new Quaternion(enemy.Rotation.value.x, enemy.Rotation.value.y, enemy.Rotation.value.z,
+ enemy.Rotation.value.w),
+ Speed = enemy.Speed,
+ AttackRange = enemy.AttackRange,
+ AvoidEnemyOverlap = enemy.AvoidEnemyOverlap,
+ EnemyBodyRadius = enemy.EnemyBodyRadius,
+ SeparationIterations = enemy.SeparationIterations,
+ TargetType = enemy.TargetType,
+ State = enemy.State
+ };
+ }
+
+ private static ProjectileJobInputData ConvertToProjectileJobInput(in ProjectileSimData projectile)
+ {
+ return new ProjectileJobInputData
+ {
+ EntityId = projectile.EntityId,
+ OwnerEntityId = projectile.OwnerEntityId,
+ Position = new float3(projectile.Position.x, projectile.Position.y, projectile.Position.z),
+ Forward = new float3(projectile.Forward.x, projectile.Forward.y, projectile.Forward.z),
+ Velocity = new float3(projectile.Velocity.x, projectile.Velocity.y, projectile.Velocity.z),
+ Speed = projectile.Speed,
+ LifeTime = projectile.LifeTime,
+ Age = projectile.Age,
+ Active = projectile.Active,
+ RemainingLifetime = projectile.RemainingLifetime,
+ State = projectile.State
+ };
+ }
+
+ private static ProjectileSimData ConvertToProjectileSimData(in ProjectileJobOutputData projectile)
+ {
+ return new ProjectileSimData
+ {
+ EntityId = projectile.EntityId,
+ OwnerEntityId = projectile.OwnerEntityId,
+ Position = new Vector3(projectile.Position.x, projectile.Position.y, projectile.Position.z),
+ Forward = new Vector3(projectile.Forward.x, projectile.Forward.y, projectile.Forward.z),
+ Velocity = new Vector3(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
+ };
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataConversion.cs.meta b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataConversion.cs.meta
new file mode 100644
index 0000000..a2d85e0
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataConversion.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 57a3f582d9c04803908205e0cd4b5b75
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataLifecycle.cs b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataLifecycle.cs
new file mode 100644
index 0000000..de6dca8
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataLifecycle.cs
@@ -0,0 +1,271 @@
+using System;
+using Unity.Collections;
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ #region Job Data Lifecycle
+
+ private void InitializeJobDataChannels()
+ {
+ if (AreJobDataChannelsUsable())
+ {
+ return;
+ }
+
+ DisposeJobDataChannels();
+ _enemyJobInputs = new NativeList(64, Allocator.Persistent);
+ _enemyJobOutputs = new NativeList(64, Allocator.Persistent);
+ _enemyJobSeparationOutputs = new NativeList(64, Allocator.Persistent);
+ _enemySeparationPreviousPushes = new NativeList(64, Allocator.Persistent);
+ _enemySeparationCurrentPushes = new NativeList(64, Allocator.Persistent);
+ _projectileJobInputs = new NativeList(64, Allocator.Persistent);
+ _projectileJobOutputs = new NativeList(64, Allocator.Persistent);
+ _collisionQueryInputs = new NativeList(64, Allocator.Persistent);
+ _collisionCandidates = new NativeList(128, Allocator.Persistent);
+ _enemySeparationBuckets = new NativeParallelMultiHashMap(256, Allocator.Persistent);
+ _enemyCollisionBuckets = new NativeParallelMultiHashMap(256, Allocator.Persistent);
+ InitializeEnemyTargetSpatialIndex();
+ }
+
+ private void DisposeJobDataChannels()
+ {
+ if (_enemyJobInputs.IsCreated)
+ {
+ _enemyJobInputs.Dispose();
+ }
+
+ _enemyJobInputs = default;
+
+ if (_enemyJobOutputs.IsCreated)
+ {
+ _enemyJobOutputs.Dispose();
+ }
+
+ _enemyJobOutputs = default;
+
+ if (_enemyJobSeparationOutputs.IsCreated)
+ {
+ _enemyJobSeparationOutputs.Dispose();
+ }
+
+ _enemyJobSeparationOutputs = default;
+
+ if (_enemySeparationPreviousPushes.IsCreated)
+ {
+ _enemySeparationPreviousPushes.Dispose();
+ }
+
+ _enemySeparationPreviousPushes = default;
+
+ if (_enemySeparationCurrentPushes.IsCreated)
+ {
+ _enemySeparationCurrentPushes.Dispose();
+ }
+
+ _enemySeparationCurrentPushes = default;
+
+ if (_projectileJobInputs.IsCreated)
+ {
+ _projectileJobInputs.Dispose();
+ }
+
+ _projectileJobInputs = default;
+
+ if (_projectileJobOutputs.IsCreated)
+ {
+ _projectileJobOutputs.Dispose();
+ }
+
+ _projectileJobOutputs = default;
+
+ if (_collisionQueryInputs.IsCreated)
+ {
+ _collisionQueryInputs.Dispose();
+ }
+
+ _collisionQueryInputs = default;
+
+ if (_collisionCandidates.IsCreated)
+ {
+ _collisionCandidates.Dispose();
+ }
+
+ _collisionCandidates = default;
+
+ if (_enemySeparationBuckets.IsCreated)
+ {
+ _enemySeparationBuckets.Dispose();
+ }
+
+ _enemySeparationBuckets = default;
+
+ if (_enemyCollisionBuckets.IsCreated)
+ {
+ _enemyCollisionBuckets.Dispose();
+ }
+
+ _enemyCollisionBuckets = default;
+
+ DisposeEnemyTargetSpatialIndex();
+ _areaCollisionRequests.Clear();
+ _areaCollisionHitEvents.Clear();
+ _areaCollisionHitDedupKeys.Clear();
+ ResetCollisionRuntimeStats();
+ }
+
+ private void ClearJobDataChannels()
+ {
+ if (!AreJobDataChannelsUsable())
+ {
+ _areaCollisionRequests.Clear();
+ _areaCollisionHitEvents.Clear();
+ _areaCollisionHitDedupKeys.Clear();
+ ResetCollisionRuntimeStats();
+ return;
+ }
+
+ if (_enemyJobInputs.IsCreated)
+ {
+ _enemyJobInputs.Clear();
+ }
+
+ if (_enemyJobOutputs.IsCreated)
+ {
+ _enemyJobOutputs.Clear();
+ }
+
+ if (_projectileJobInputs.IsCreated)
+ {
+ _projectileJobInputs.Clear();
+ }
+
+ if (_projectileJobOutputs.IsCreated)
+ {
+ _projectileJobOutputs.Clear();
+ }
+
+ if (_collisionQueryInputs.IsCreated)
+ {
+ _collisionQueryInputs.Clear();
+ }
+
+ if (_collisionCandidates.IsCreated)
+ {
+ _collisionCandidates.Clear();
+ }
+
+ if (_enemyJobSeparationOutputs.IsCreated)
+ {
+ _enemyJobSeparationOutputs.Clear();
+ }
+
+ if (_enemySeparationPreviousPushes.IsCreated)
+ {
+ _enemySeparationPreviousPushes.Clear();
+ }
+
+ if (_enemySeparationCurrentPushes.IsCreated)
+ {
+ _enemySeparationCurrentPushes.Clear();
+ }
+
+ if (_enemySeparationBuckets.IsCreated)
+ {
+ _enemySeparationBuckets.Clear();
+ }
+
+ if (_enemyCollisionBuckets.IsCreated)
+ {
+ _enemyCollisionBuckets.Clear();
+ }
+
+ ClearEnemyTargetSpatialIndex();
+ _areaCollisionRequests.Clear();
+ _areaCollisionHitEvents.Clear();
+ _areaCollisionHitDedupKeys.Clear();
+ ResetCollisionRuntimeStats();
+ }
+
+ private bool AreJobDataChannelsUsable()
+ {
+ return IsNativeListUsable(_enemyJobInputs) &&
+ IsNativeListUsable(_enemyJobOutputs) &&
+ IsNativeListUsable(_enemyJobSeparationOutputs) &&
+ IsNativeListUsable(_enemySeparationPreviousPushes) &&
+ IsNativeListUsable(_enemySeparationCurrentPushes) &&
+ IsNativeListUsable(_projectileJobInputs) &&
+ IsNativeListUsable(_projectileJobOutputs) &&
+ IsNativeListUsable(_collisionQueryInputs) &&
+ IsNativeListUsable(_collisionCandidates) &&
+ IsNativeMultiHashMapUsable(_enemySeparationBuckets) &&
+ IsNativeMultiHashMapUsable(_enemyCollisionBuckets);
+ }
+
+ private static void EnsureCapacity(ref NativeList nativeList, int targetCount) where T : unmanaged
+ {
+ if (!nativeList.IsCreated || targetCount <= 0)
+ {
+ return;
+ }
+
+ if (nativeList.Capacity < targetCount)
+ {
+ nativeList.Capacity = targetCount;
+ }
+ }
+
+ private static void EnsureCapacity(ref NativeParallelMultiHashMap hashMap, int targetCount)
+ {
+ if (!hashMap.IsCreated || targetCount <= 0)
+ {
+ return;
+ }
+
+ if (hashMap.Capacity < targetCount)
+ {
+ hashMap.Capacity = targetCount;
+ }
+ }
+
+ private static bool IsNativeListUsable(NativeList nativeList) where T : unmanaged
+ {
+ if (!nativeList.IsCreated)
+ {
+ return false;
+ }
+
+ try
+ {
+ _ = nativeList.Length;
+ return true;
+ }
+ catch (ObjectDisposedException)
+ {
+ return false;
+ }
+ }
+
+ private static bool IsNativeMultiHashMapUsable(NativeParallelMultiHashMap hashMap)
+ {
+ if (!hashMap.IsCreated)
+ {
+ return false;
+ }
+
+ try
+ {
+ _ = hashMap.Count();
+ return true;
+ }
+ catch (ObjectDisposedException)
+ {
+ return false;
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataLifecycle.cs.meta b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataLifecycle.cs.meta
new file mode 100644
index 0000000..5a67a38
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataLifecycle.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b2e46cc42cdd45f5badd02bd4635ba97
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobOutputCommit.cs b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobOutputCommit.cs
new file mode 100644
index 0000000..16bef98
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobOutputCommit.cs
@@ -0,0 +1,40 @@
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ #region Job Output Commit
+
+ private void ApplyJobOutputsToSimulationState()
+ {
+ int enemyCount = Mathf.Min(_enemies.Count, _enemyJobOutputs.Length);
+ bool hasEnemyPositionChanged = false;
+ for (int i = 0; i < enemyCount; i++)
+ {
+ EnemyJobOutputData output = _enemyJobOutputs[i];
+ if (!hasEnemyPositionChanged)
+ {
+ Vector3 currentPosition = _enemies[i].Position;
+ hasEnemyPositionChanged = currentPosition.x != output.Position.x ||
+ currentPosition.z != output.Position.z;
+ }
+
+ _enemies[i] = ConvertToEnemySimData(output);
+ }
+
+ if (hasEnemyPositionChanged)
+ {
+ MarkEnemyTargetSpatialIndexDirty();
+ }
+
+ int projectileCount = Mathf.Min(_projectiles.Count, _projectileJobOutputs.Length);
+ for (int i = 0; i < projectileCount; i++)
+ {
+ _projectiles[i] = ConvertToProjectileSimData(_projectileJobOutputs[i]);
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobOutputCommit.cs.meta b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobOutputCommit.cs.meta
new file mode 100644
index 0000000..96ddf4d
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobOutputCommit.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 59ae2375305641a6a36d1bea0bf1abc2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct.meta b/Assets/GameMain/Scripts/Simulation/JobStruct.meta
new file mode 100644
index 0000000..5880af5
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 3f549ca9decb4f38933329abacbf78ac
+timeCreated: 1771901120
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs
new file mode 100644
index 0000000..9176817
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs
@@ -0,0 +1,14 @@
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ private struct AreaCollisionHitEventData
+ {
+ public int QueryId;
+ public int SourceEntityId;
+ public int SourceOwnerEntityId;
+ public int TargetEntityId;
+ public float SqrDistance;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs.meta
new file mode 100644
index 0000000..87603fc
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 5140d989cba042e6bcb217893491273d
+timeCreated: 1771901785
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs
new file mode 100644
index 0000000..0e51176
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs
@@ -0,0 +1,22 @@
+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;
+ public float HalfWidth;
+ public float HalfLength;
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs.meta
new file mode 100644
index 0000000..e36da08
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: e32f4e0724314c70882f5ed043a4dca4
+timeCreated: 1771901749
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs
new file mode 100644
index 0000000..74dad2c
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs
@@ -0,0 +1,48 @@
+using Unity.Burst;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ [BurstCompile]
+ private struct BuildEnemySeparationBucketsBurstJob : IJobParallelFor
+ {
+ [ReadOnly] public NativeArray Inputs;
+ public NativeParallelMultiHashMap.ParallelWriter Buckets;
+ public float CellSize;
+
+ public void Execute(int index)
+ {
+ BuildEnemySeparationBucket(
+ index,
+ Inputs,
+ Buckets,
+ CellSize
+ );
+ }
+ }
+
+ private static void BuildEnemySeparationBucket(
+ int index,
+ NativeArray inputs,
+ NativeParallelMultiHashMap.ParallelWriter buckets,
+ float cellSize
+ )
+ {
+ EnemyJobOutputData output = inputs[index];
+ if (!output.AvoidEnemyOverlap)
+ {
+ return;
+ }
+
+ float3 position = output.Position;
+ position.y = 0f;
+ int cellX = (int)math.floor(position.x / cellSize);
+ int cellZ = (int)math.floor(position.z / cellSize);
+ buckets.Add(SeparationCellKey(cellX, cellZ), index);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs.meta
new file mode 100644
index 0000000..896729f
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: e833e91677bb49a4a458c9c8b90099e9
+timeCreated: 1771901500
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs
new file mode 100644
index 0000000..6747975
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs
@@ -0,0 +1,15 @@
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ private struct CollisionCandidateData
+ {
+ public int QueryId;
+ public int SourceType;
+ public int SourceEntityId;
+ public int SourceOwnerEntityId;
+ public int TargetEntityId;
+ public float SqrDistance;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs.meta
new file mode 100644
index 0000000..4bed197
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 453c14964a4b4aed89c0abd7b25cd26c
+timeCreated: 1771901722
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs
new file mode 100644
index 0000000..75d6cd9
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs
@@ -0,0 +1,24 @@
+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;
+ public float HalfWidth;
+ public float HalfLength;
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs.meta
new file mode 100644
index 0000000..2ace39d
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 58abe0d4fd7a49acb716fbd26e0fb19f
+timeCreated: 1771901691
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs
new file mode 100644
index 0000000..5d72ecc
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs
@@ -0,0 +1,22 @@
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ private struct EnemyJobInputData
+ {
+ public int EntityId;
+ public float3 Position;
+ public float3 Forward;
+ public quaternion Rotation;
+ public float Speed;
+ public float AttackRange;
+ public bool AvoidEnemyOverlap;
+ public float EnemyBodyRadius;
+ public int SeparationIterations;
+ public int TargetType;
+ public int State;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs.meta
new file mode 100644
index 0000000..ce66843
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 171936b8a6b84a8fbacc7651ce473b98
+timeCreated: 1771901146
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs
new file mode 100644
index 0000000..e7b7a22
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs
@@ -0,0 +1,22 @@
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ private struct EnemyJobOutputData
+ {
+ public int EntityId;
+ public float3 Position;
+ public float3 Forward;
+ public quaternion Rotation;
+ public float Speed;
+ public float AttackRange;
+ public bool AvoidEnemyOverlap;
+ public float EnemyBodyRadius;
+ public int SeparationIterations;
+ public int TargetType;
+ public int State;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs.meta
new file mode 100644
index 0000000..d12a3e8
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 34ef6fbf123f45ca88d8e7ab8311d543
+timeCreated: 1771901204
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs
new file mode 100644
index 0000000..51f9eb8
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs
@@ -0,0 +1,93 @@
+using Unity.Burst;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ [BurstCompile]
+ private struct EnemyMovementBurstJob : IJobParallelFor
+ {
+ [ReadOnly] public NativeArray Inputs;
+ public NativeArray Outputs;
+ public float DeltaTime;
+ public float3 PlayerPosition;
+
+ public void Execute(int index)
+ {
+ ExecuteEnemyMovement(
+ index,
+ Inputs,
+ Outputs,
+ DeltaTime,
+ PlayerPosition
+ );
+ }
+ }
+
+ private static void ExecuteEnemyMovement(
+ int index,
+ NativeArray inputs,
+ NativeArray outputs,
+ float deltaTime,
+ float3 playerPosition
+ )
+ {
+ EnemyJobInputData input = inputs[index];
+ float attackRange = input.AttackRange > 0f ? input.AttackRange : DefaultAttackRange;
+ float attackRangeSqr = attackRange * attackRange;
+
+ float3 currentPosition = input.Position;
+ float3 horizontalPosition = new float3(currentPosition.x, 0f, currentPosition.z);
+ float3 toPlayer = playerPosition - horizontalPosition;
+ float sqrDistance = math.lengthsq(toPlayer);
+ bool isInAttackRange = sqrDistance <= attackRangeSqr;
+ bool canChase = !isInAttackRange && input.Speed > 0f && sqrDistance > float.Epsilon;
+
+ float3 forward = input.Forward;
+ float3 desiredPosition = currentPosition;
+ quaternion rotation = input.Rotation;
+
+ if (canChase)
+ {
+ forward = math.normalizesafe(toPlayer, forward);
+ desiredPosition = currentPosition + forward * input.Speed * deltaTime;
+ if (math.lengthsq(forward) > float.Epsilon)
+ {
+ rotation = quaternion.LookRotationSafe(forward, math.up());
+ }
+ }
+
+ int nextState;
+ if (isInAttackRange)
+ {
+ nextState = EnemyStateInAttackRange;
+ }
+ else if (canChase)
+ {
+ nextState = EnemyStateChasing;
+ }
+ else
+ {
+ nextState = EnemyStateIdle;
+ }
+
+ outputs[index] = new EnemyJobOutputData
+ {
+ EntityId = input.EntityId,
+ Position = desiredPosition,
+ Forward = forward,
+ Rotation = rotation,
+ Speed = input.Speed,
+ AttackRange = attackRange,
+ AvoidEnemyOverlap = input.AvoidEnemyOverlap,
+ EnemyBodyRadius = input.EnemyBodyRadius,
+ SeparationIterations = input.SeparationIterations,
+ TargetType = input.TargetType,
+ State = nextState
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs.meta
new file mode 100644
index 0000000..dbd2ee4
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: abd5519779ed41d4947280eec326ffb1
+timeCreated: 1771901472
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs
new file mode 100644
index 0000000..f3457b1
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs
@@ -0,0 +1,245 @@
+using Unity.Burst;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ [BurstCompile]
+ private struct EnemySeparationBurstJob : IJobParallelFor
+ {
+ [ReadOnly] public NativeArray Inputs;
+ [ReadOnly] public NativeParallelMultiHashMap Buckets;
+ [ReadOnly] public NativeArray PreviousPushes;
+ public NativeArray Outputs;
+ public NativeArray CurrentPushes;
+ public float CellSize;
+ public float MaxRadius;
+ public float3 PlayerPosition;
+ public float PushDamping;
+ public float MaxStepScale;
+ public bool UseTangentialInAttackRange;
+ public float PushSmoothing;
+
+ public void Execute(int index)
+ {
+ ExecuteEnemySeparation(
+ index,
+ Inputs,
+ Buckets,
+ Outputs,
+ CellSize,
+ MaxRadius,
+ PlayerPosition,
+ PushDamping,
+ MaxStepScale,
+ UseTangentialInAttackRange,
+ PreviousPushes,
+ CurrentPushes,
+ PushSmoothing
+ );
+ }
+ }
+
+ private static void ExecuteEnemySeparation(
+ int index,
+ NativeArray inputs,
+ NativeParallelMultiHashMap buckets,
+ NativeArray outputs,
+ float cellSize,
+ float maxRadius,
+ float3 playerPosition,
+ float pushDamping,
+ float maxStepScale,
+ bool useTangentialInAttackRange,
+ NativeArray previousPushes,
+ NativeArray currentPushes,
+ float pushSmoothing
+ )
+ {
+ currentPushes[index] = float2.zero;
+ EnemyJobOutputData self = inputs[index];
+ if (!self.AvoidEnemyOverlap)
+ {
+ outputs[index] = self;
+ return;
+ }
+
+ float3 candidate = self.Position;
+ candidate.y = 0f;
+ float3 original = candidate;
+ float3 fallback =
+ math.normalizesafe(new float3(self.Forward.x, 0f, self.Forward.z), new float3(1f, 0f, 0f));
+ float selfRadius = self.EnemyBodyRadius > 0f ? self.EnemyBodyRadius : 0.45f;
+ int iterations = self.SeparationIterations > 0 ? self.SeparationIterations : 1;
+ int queryRange = math.max(1, (int)math.ceil((selfRadius + maxRadius) / cellSize));
+
+ for (int iter = 0; iter < iterations; iter++)
+ {
+ int cellX = (int)math.floor(candidate.x / cellSize);
+ int cellZ = (int)math.floor(candidate.z / cellSize);
+ float3 pushAccumulation = float3.zero;
+
+ for (int dx = -queryRange; dx <= queryRange; dx++)
+ {
+ for (int dz = -queryRange; dz <= queryRange; dz++)
+ {
+ long key = SeparationCellKey(cellX + dx, cellZ + dz);
+ if (!buckets.TryGetFirstValue(key, out int otherIndex,
+ out NativeParallelMultiHashMapIterator iterator))
+ {
+ continue;
+ }
+
+ do
+ {
+ if (otherIndex == index)
+ {
+ continue;
+ }
+
+ EnemyJobOutputData other = inputs[otherIndex];
+ if (!other.AvoidEnemyOverlap)
+ {
+ continue;
+ }
+
+ float otherRadius = other.EnemyBodyRadius > 0f ? other.EnemyBodyRadius : 0.45f;
+ float minDistance = selfRadius + otherRadius;
+ float minDistanceSqr = minDistance * minDistance;
+
+ float3 otherPosition = other.Position;
+ otherPosition.y = 0f;
+ float3 toSelf = candidate - otherPosition;
+ float sqrDistance = math.lengthsq(toSelf);
+
+ if (sqrDistance <= float.Epsilon)
+ {
+ float3 zeroDistanceAxis = GetZeroDistanceSeparationAxis(index, otherIndex);
+ float directionSign = index < otherIndex ? 1f : -1f;
+ pushAccumulation += zeroDistanceAxis * (selfRadius * 0.25f * directionSign);
+ continue;
+ }
+
+ if (sqrDistance >= minDistanceSqr)
+ {
+ continue;
+ }
+
+ float distance = math.sqrt(sqrDistance);
+ float penetration = minDistance - distance;
+ pushAccumulation += (toSelf / distance) * penetration;
+ } while (buckets.TryGetNextValue(out otherIndex, ref iterator));
+ }
+ }
+
+ if (math.lengthsq(pushAccumulation) <= float.Epsilon)
+ {
+ continue;
+ }
+
+ float3 resolvedPush = pushAccumulation * pushDamping;
+
+ float maxStep = selfRadius * maxStepScale;
+ float pushLength = math.length(resolvedPush);
+ if (pushLength > maxStep && pushLength > float.Epsilon)
+ {
+ resolvedPush = resolvedPush / pushLength * maxStep;
+ }
+
+ candidate += resolvedPush;
+ }
+
+ float3 framePush = candidate - original;
+ float2 previousPush2 = previousPushes[index];
+ float3 previousPush = new float3(previousPush2.x, 0f, previousPush2.y);
+ float3 smoothedPush = SmoothSeparationPush(framePush, previousPush, pushSmoothing);
+
+ if (useTangentialInAttackRange && self.State == EnemyStateInAttackRange)
+ {
+ smoothedPush = ProjectToTangential(smoothedPush, playerPosition, original);
+ }
+
+ float maxTotalStep = selfRadius * maxStepScale * iterations;
+ float smoothedLength = math.length(smoothedPush);
+ if (smoothedLength > maxTotalStep && smoothedLength > float.Epsilon)
+ {
+ smoothedPush = smoothedPush / smoothedLength * maxTotalStep;
+ }
+
+ float3 finalPosition = original + smoothedPush;
+ currentPushes[index] = new float2(smoothedPush.x, smoothedPush.z);
+ self.Position = new float3(finalPosition.x, self.Position.y, finalPosition.z);
+ if (math.lengthsq(smoothedPush) > float.Epsilon)
+ {
+ self.Forward = new float3(fallback.x, self.Forward.y, fallback.z);
+ }
+
+ outputs[index] = self;
+ }
+
+ private static float3 SmoothSeparationPush(float3 framePush, float3 previousPush, float pushSmoothing)
+ {
+ float frameLengthSqr = math.lengthsq(framePush);
+ float previousLengthSqr = math.lengthsq(previousPush);
+
+ if (frameLengthSqr <= float.Epsilon)
+ {
+ return float3.zero;
+ }
+
+ if (previousLengthSqr <= float.Epsilon || pushSmoothing <= 0f)
+ {
+ return framePush;
+ }
+
+ float frameLength = math.sqrt(frameLengthSqr);
+ float previousLength = math.sqrt(previousLengthSqr);
+ float3 frameDirection = framePush / frameLength;
+ float3 previousDirection = previousPush / previousLength;
+ float directionAlignment = math.dot(frameDirection, previousDirection);
+
+ if (directionAlignment >= 0.35f)
+ {
+ return framePush;
+ }
+
+ float directionalFactor = math.saturate((0.35f - directionAlignment) / 1.35f);
+ float smoothingStrength = pushSmoothing * directionalFactor;
+ return math.lerp(framePush, previousPush, smoothingStrength);
+ }
+
+ private static float3 ProjectToTangential(float3 push, float3 playerPosition, float3 currentPosition)
+ {
+ if (math.lengthsq(push) <= float.Epsilon)
+ {
+ return push;
+ }
+
+ float3 toPlayer = playerPosition - currentPosition;
+ float toPlayerSqr = math.lengthsq(toPlayer);
+ if (toPlayerSqr <= float.Epsilon)
+ {
+ return push;
+ }
+
+ float3 radialDirection = toPlayer / math.sqrt(toPlayerSqr);
+ float radialOffset = math.dot(push, radialDirection);
+ return push - radialDirection * radialOffset;
+ }
+
+ private static float3 GetZeroDistanceSeparationAxis(int index, int otherIndex)
+ {
+ int lowIndex = math.min(index, otherIndex);
+ int highIndex = math.max(index, otherIndex);
+ uint pairHash = (uint)(lowIndex * 73856093) ^ (uint)(highIndex * 19349663);
+
+ float axisX = (pairHash & 1023u) / 511.5f - 1f;
+ float axisZ = ((pairHash >> 10) & 1023u) / 511.5f - 1f;
+ float3 axis = new float3(axisX, 0f, axisZ);
+ return math.normalizesafe(axis, new float3(1f, 0f, 0f));
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs.meta
new file mode 100644
index 0000000..5142e9a
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 94a77477c3a8410cb58b5028dd5d2c63
+timeCreated: 1771901543
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs
new file mode 100644
index 0000000..bd9fe86
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs
@@ -0,0 +1,22 @@
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ private struct ProjectileJobInputData
+ {
+ public int EntityId;
+ public int OwnerEntityId;
+ public float3 Position;
+ public float3 Forward;
+ public float3 Velocity;
+ public float Speed;
+ public float LifeTime;
+ public float Age;
+ public bool Active;
+ public float RemainingLifetime;
+ public int State;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs.meta
new file mode 100644
index 0000000..5613784
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 334ca3869d7940058256ba5419573637
+timeCreated: 1771901235
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs
new file mode 100644
index 0000000..a1cd081
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs
@@ -0,0 +1,22 @@
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ private struct ProjectileJobOutputData
+ {
+ public int EntityId;
+ public int OwnerEntityId;
+ public float3 Position;
+ public float3 Forward;
+ public float3 Velocity;
+ public float Speed;
+ public float LifeTime;
+ public float Age;
+ public bool Active;
+ public float RemainingLifetime;
+ public int State;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs.meta
new file mode 100644
index 0000000..62908ee
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: b5c4a144914548379d9991d8ef061da7
+timeCreated: 1771901266
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs
new file mode 100644
index 0000000..d27c6e5
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs
@@ -0,0 +1,118 @@
+using Unity.Burst;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ [BurstCompile]
+ private struct ProjectileMovementBurstJob : IJobParallelFor
+ {
+ [ReadOnly] public NativeArray Inputs;
+ public NativeArray Outputs;
+ public float DeltaTime;
+ public float3 PlayerPosition;
+ public float MaxSqrDistanceFromPlayer;
+ public float MaxVerticalOffsetFromPlayer;
+
+ public void Execute(int index)
+ {
+ ExecuteProjectileMovement(
+ index,
+ Inputs,
+ Outputs,
+ DeltaTime,
+ PlayerPosition,
+ MaxSqrDistanceFromPlayer,
+ MaxVerticalOffsetFromPlayer);
+ }
+ }
+
+ private static void ExecuteProjectileMovement(
+ int index,
+ NativeArray inputs,
+ NativeArray outputs,
+ float deltaTime,
+ float3 playerPosition,
+ float maxSqrDistanceFromPlayer,
+ float maxVerticalOffsetFromPlayer)
+ {
+ ProjectileJobInputData input = inputs[index];
+ ProjectileJobOutputData output = new ProjectileJobOutputData
+ {
+ EntityId = input.EntityId,
+ OwnerEntityId = input.OwnerEntityId,
+ Position = input.Position,
+ Forward = input.Forward,
+ Velocity = input.Velocity,
+ Speed = input.Speed,
+ LifeTime = input.LifeTime,
+ Age = input.Age,
+ Active = input.Active,
+ RemainingLifetime = input.RemainingLifetime,
+ State = input.State
+ };
+
+ if (!input.Active)
+ {
+ output.State = ProjectileStateExpired;
+ outputs[index] = output;
+ return;
+ }
+
+ float3 position = input.Position;
+ float3 forward = input.Forward;
+ float3 velocity = input.Velocity;
+ if (math.lengthsq(velocity) <= float.Epsilon && input.Speed > 0f)
+ {
+ float3 moveDirection = math.normalizesafe(forward, new float3(0f, 0f, 1f));
+ velocity = moveDirection * input.Speed;
+ }
+
+ float3 nextPosition = position + velocity * deltaTime;
+ float nextAge = math.max(0f, input.Age + deltaTime);
+ float nextRemainingLifetime = input.RemainingLifetime;
+ bool shouldExpire = false;
+
+ if (input.LifeTime > 0f)
+ {
+ nextRemainingLifetime = math.max(0f, input.LifeTime - nextAge);
+ shouldExpire = nextAge >= input.LifeTime;
+ }
+ else if (input.RemainingLifetime > 0f)
+ {
+ nextRemainingLifetime = math.max(0f, input.RemainingLifetime - deltaTime);
+ shouldExpire = nextRemainingLifetime <= float.Epsilon;
+ }
+
+ if (!shouldExpire && maxSqrDistanceFromPlayer > 0f)
+ {
+ float3 horizontalDelta =
+ new float3(nextPosition.x - playerPosition.x, 0f, nextPosition.z - playerPosition.z);
+ shouldExpire = math.lengthsq(horizontalDelta) > maxSqrDistanceFromPlayer;
+ }
+
+ if (!shouldExpire && maxVerticalOffsetFromPlayer > 0f)
+ {
+ shouldExpire = math.abs(nextPosition.y - playerPosition.y) > maxVerticalOffsetFromPlayer;
+ }
+
+ output.Position = nextPosition;
+ output.Velocity = velocity;
+ output.Age = nextAge;
+ output.RemainingLifetime = nextRemainingLifetime;
+ output.Active = !shouldExpire;
+ output.State = shouldExpire ? ProjectileStateExpired : ProjectileStateActive;
+
+ if (math.lengthsq(velocity) > float.Epsilon)
+ {
+ float3 moveForward = math.normalizesafe(velocity, forward);
+ output.Forward = moveForward;
+ }
+
+ outputs[index] = output;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs.meta
new file mode 100644
index 0000000..88c2bb9
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 49001e849c1b4afdaa37d851b9b76866
+timeCreated: 1771901884
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs
new file mode 100644
index 0000000..b654f41
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs
@@ -0,0 +1,132 @@
+using Unity.Burst;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ [BurstCompile]
+ private struct QueryCollisionCandidatesBurstJob : IJobParallelFor
+ {
+ [ReadOnly] public NativeArray Queries;
+ [ReadOnly] public NativeParallelMultiHashMap EnemyBuckets;
+ [ReadOnly] public NativeArray EnemyOutputs;
+ public NativeList.ParallelWriter Candidates;
+ public bool HasEnemyTargets;
+ public bool HasPlayerTarget;
+ public int PlayerTargetEntityId;
+ public float3 PlayerPosition;
+ public float CellSize;
+
+ public void Execute(int index)
+ {
+ CollisionQueryData query = Queries[index];
+ 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;
+ if (query.SourceType == CollisionSourceTypeArea)
+ {
+ playerPosition.y = query.Position.y;
+ }
+ float3 playerDelta = playerPosition - query.Position;
+ float playerSqrDistance = math.lengthsq(playerDelta);
+ float playerRadiusSqr = query.Radius * query.Radius;
+ if (playerSqrDistance <= playerRadiusSqr)
+ {
+ Candidates.AddNoResize(new CollisionCandidateData
+ {
+ QueryId = query.QueryId,
+ SourceType = query.SourceType,
+ SourceEntityId = query.SourceEntityId,
+ SourceOwnerEntityId = query.SourceOwnerEntityId,
+ TargetEntityId = PlayerTargetEntityId,
+ SqrDistance = playerSqrDistance
+ });
+
+ selectedCount++;
+ if (selectedCount >= query.MaxTargets)
+ {
+ reachedLimit = true;
+ }
+ }
+ }
+
+ if (!HasEnemyTargets || reachedLimit)
+ {
+ return;
+ }
+
+ for (int dx = -queryRange; dx <= queryRange && !reachedLimit; dx++)
+ {
+ for (int dz = -queryRange; dz <= queryRange && !reachedLimit; dz++)
+ {
+ long key = SeparationCellKey(centerCellX + dx, centerCellZ + dz);
+ if (!EnemyBuckets.TryGetFirstValue(key, out int enemyIndex,
+ out NativeParallelMultiHashMapIterator iterator))
+ {
+ continue;
+ }
+
+ do
+ {
+ if (enemyIndex < 0 || enemyIndex >= EnemyOutputs.Length)
+ {
+ continue;
+ }
+
+ EnemyJobOutputData enemy = EnemyOutputs[enemyIndex];
+ if (enemy.EntityId == query.SourceOwnerEntityId)
+ {
+ continue;
+ }
+
+ float deltaY = query.SourceType == CollisionSourceTypeArea
+ ? 0f
+ : enemy.Position.y - query.Position.y;
+ float3 delta = new float3(
+ enemy.Position.x - query.Position.x,
+ deltaY,
+ enemy.Position.z - query.Position.z);
+ float sqrDistance = math.lengthsq(delta);
+ float targetRadius = query.SourceType == CollisionSourceTypeArea
+ ? math.max(0f, enemy.EnemyBodyRadius)
+ : 0f;
+ float effectiveRadius = query.Radius + targetRadius;
+ if (sqrDistance > effectiveRadius * effectiveRadius)
+ {
+ 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));
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs.meta
new file mode 100644
index 0000000..aea157e
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 81aa43bb35f547c48b867eafa17a3857
+timeCreated: 1771914779
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs.meta b/Assets/GameMain/Scripts/Simulation/Jobs.meta
new file mode 100644
index 0000000..f0ecbfb
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: c66a8c10fffb404c9f4819adffea45fd
+timeCreated: 1771900893
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionBroadPhase.cs b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionBroadPhase.cs
new file mode 100644
index 0000000..9803c85
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionBroadPhase.cs
@@ -0,0 +1,242 @@
+using CustomDebugger;
+using Entity;
+using Unity.Burst;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ #region Collision Broad Phase
+
+ private void PrepareCollisionCandidatesForFrame()
+ {
+ _collisionCandidateQueryScheduled = false;
+
+ if (!_collisionQueryInputs.IsCreated || !_collisionCandidates.IsCreated ||
+ !_enemyCollisionBuckets.IsCreated)
+ {
+ ResetCollisionRuntimeStats();
+ ClearAreaCollisionTransientBuffers();
+ return;
+ }
+
+ int projectileCount = _projectileJobOutputs.Length;
+ int areaQueryCount = _areaCollisionRequests.Count;
+ if (projectileCount == 0 && areaQueryCount == 0)
+ {
+ ResetCollisionRuntimeStats();
+ return;
+ }
+
+ float queryRadius = Mathf.Max(0.01f, _projectileCollisionQueryRadius);
+ int maxCandidatesPerQuery = Mathf.Max(1, _projectileMaxCandidatesPerQuery);
+ float maxQueryRadius = queryRadius;
+ int queryId = 0;
+ int projectileQueryCount = 0;
+ int builtAreaQueryCount = 0;
+ using (CustomProfilerMarker.Collision_BuildQueries.Auto())
+ {
+ for (int i = 0; i < projectileCount; i++)
+ {
+ ProjectileJobOutputData projectile = _projectileJobOutputs[i];
+ if (!projectile.Active || projectile.State != ProjectileStateActive)
+ {
+ continue;
+ }
+
+ AddProjectileCollisionQuery(queryId, in projectile, queryRadius, maxCandidatesPerQuery);
+ queryId++;
+ projectileQueryCount++;
+ }
+
+ for (int i = 0; i < areaQueryCount; i++)
+ {
+ AreaCollisionRequestData request = _areaCollisionRequests[i];
+ AddAreaCollisionQuery(queryId, request.SourceEntityId, request.SourceOwnerEntityId,
+ request.SourceWasActiveAtQueryTime, in request.Center, request.Radius, request.MaxTargets,
+ request.ShapeType, in request.Direction, request.HalfAngleDeg, request.HalfWidth,
+ request.HalfLength);
+ queryId++;
+ builtAreaQueryCount++;
+ if (request.Radius > maxQueryRadius)
+ {
+ maxQueryRadius = request.Radius;
+ }
+ }
+ }
+
+ _lastProjectileCollisionQueryCount = projectileQueryCount;
+ _lastAreaCollisionQueryCount = builtAreaQueryCount;
+ _lastCollisionQueryCount = projectileQueryCount + builtAreaQueryCount;
+ _lastResolvedAreaHitCount = 0;
+
+ if (_collisionQueryInputs.Length == 0)
+ {
+ _lastCollisionCandidateCount = 0;
+ _lastProjectileCollisionCandidateCount = 0;
+ _lastAreaCollisionCandidateCount = 0;
+ _lastCollisionCellSize = 0f;
+ _lastCollisionHasEnemyTargets = _enemyJobOutputs.Length > 0;
+ return;
+ }
+
+ float autoCellSize = maxQueryRadius * 2f;
+ float configuredCellSize = _projectileCollisionCellSize > 0f ? _projectileCollisionCellSize : autoCellSize;
+ float cellSize = Mathf.Max(0.1f, configuredCellSize);
+ bool hasEnemyTargets = _enemyJobOutputs.Length > 0;
+ _lastCollisionCellSize = cellSize;
+ _lastCollisionHasEnemyTargets = hasEnemyTargets;
+
+ using (CustomProfilerMarker.Collision_BuildBuckets.Auto())
+ {
+ if (hasEnemyTargets)
+ {
+ BuildEnemyCollisionBuckets(cellSize);
+ }
+ }
+
+ using (CustomProfilerMarker.Collision_QueryCandidates.Auto())
+ {
+ _collisionCandidateQueryScheduled = ScheduleCollisionCandidateQueryJob(cellSize, hasEnemyTargets,
+ out _collisionCandidateQueryHandle);
+ }
+ }
+
+ private void CompleteCollisionCandidatesForFrame()
+ {
+ if (_collisionCandidateQueryScheduled)
+ {
+ _collisionCandidateQueryHandle.Complete();
+ _collisionCandidateQueryScheduled = false;
+ }
+
+ CountCollisionCandidatesBySourceType(out int projectileCandidateCount, out int areaCandidateCount);
+ _lastProjectileCollisionCandidateCount = projectileCandidateCount;
+ _lastAreaCollisionCandidateCount = areaCandidateCount;
+ _lastCollisionCandidateCount = projectileCandidateCount + areaCandidateCount;
+ }
+
+ private void BuildEnemyCollisionBuckets(float cellSize)
+ {
+ _enemyCollisionBuckets.Clear();
+ int enemyCount = _enemyJobOutputs.Length;
+ if (enemyCount <= 0)
+ {
+ return;
+ }
+
+ BuildCollisionBucketsBurstJob job = new BuildCollisionBucketsBurstJob
+ {
+ EnemyOutputs = _enemyJobOutputs.AsArray(),
+ Buckets = _enemyCollisionBuckets.AsParallelWriter(),
+ CellSize = cellSize
+ };
+
+ using (CustomProfilerMarker.TickEnemies_Complete.Auto())
+ {
+ JobHandle handle = job.Schedule(enemyCount, 64);
+ handle.Complete();
+ }
+ }
+
+ [BurstCompile]
+ private struct BuildCollisionBucketsBurstJob : IJobParallelFor
+ {
+ [ReadOnly] public NativeArray EnemyOutputs;
+ public NativeParallelMultiHashMap.ParallelWriter Buckets;
+ public float CellSize;
+
+ public void Execute(int index)
+ {
+ EnemyJobOutputData enemy = EnemyOutputs[index];
+ int cellX = (int)math.floor(enemy.Position.x / CellSize);
+ int cellZ = (int)math.floor(enemy.Position.z / CellSize);
+ Buckets.Add(SeparationCellKey(cellX, cellZ), index);
+ }
+ }
+
+ private bool ScheduleCollisionCandidateQueryJob(float cellSize, bool hasEnemyTargets, out JobHandle handle)
+ {
+ handle = default;
+ if (!_collisionQueryInputs.IsCreated || !_collisionCandidates.IsCreated)
+ {
+ return false;
+ }
+
+ _collisionCandidates.Clear();
+ int queryCount = _collisionQueryInputs.Length;
+ if (queryCount == 0)
+ {
+ return false;
+ }
+
+ bool hasPlayerTarget = TryGetPlayerCollisionTarget(out int playerTargetEntityId, out float3 playerPosition);
+ QueryCollisionCandidatesBurstJob job = new QueryCollisionCandidatesBurstJob
+ {
+ Queries = _collisionQueryInputs.AsArray(),
+ EnemyBuckets = _enemyCollisionBuckets,
+ EnemyOutputs = _enemyJobOutputs.AsArray(),
+ Candidates = _collisionCandidates.AsParallelWriter(),
+ HasEnemyTargets = hasEnemyTargets,
+ HasPlayerTarget = hasPlayerTarget,
+ PlayerTargetEntityId = playerTargetEntityId,
+ PlayerPosition = playerPosition,
+ CellSize = cellSize
+ };
+
+ handle = job.Schedule(queryCount, 64);
+ return true;
+ }
+
+ private void CountCollisionCandidatesBySourceType(out int projectileCandidateCount, out int areaCandidateCount)
+ {
+ projectileCandidateCount = 0;
+ areaCandidateCount = 0;
+
+ if (!_collisionCandidates.IsCreated)
+ {
+ return;
+ }
+
+ for (int i = 0; i < _collisionCandidates.Length; i++)
+ {
+ CollisionCandidateData candidate = _collisionCandidates[i];
+ if (candidate.SourceType == CollisionSourceTypeProjectile)
+ {
+ projectileCandidateCount++;
+ }
+ else if (candidate.SourceType == CollisionSourceTypeArea)
+ {
+ areaCandidateCount++;
+ }
+ }
+ }
+
+ private static bool TryGetPlayerCollisionTarget(out int playerEntityId, out float3 playerPosition)
+ {
+ playerEntityId = PlayerEntityId;
+ playerPosition = default;
+
+ if (!TryGetAliveTargetableEntity(playerEntityId, out TargetableObject playerTarget))
+ {
+ return false;
+ }
+
+ Transform playerTransform = playerTarget.CachedTransform;
+ if (playerTransform == null)
+ {
+ return false;
+ }
+
+ Vector3 position = playerTransform.position;
+ playerPosition = new float3(position.x, position.y, position.z);
+ return true;
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionBroadPhase.cs.meta b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionBroadPhase.cs.meta
new file mode 100644
index 0000000..e7d2eb5
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionBroadPhase.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ac6aca660ff944e69ea8f5e06f0609cf
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs
new file mode 100644
index 0000000..e7db405
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs
@@ -0,0 +1,10 @@
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ // Shared collision pipeline configuration and runtime state.
+ // Request buffering, broad-phase scheduling, resolve, and presentation
+ // dispatch live in dedicated partial files under Jobs/.
+ private const int PlayerEntityId = -1;
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs.meta b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs.meta
new file mode 100644
index 0000000..be1c4d6
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 87bc5c5ec75920d46a65881e949bd2a5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPresentation.cs b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPresentation.cs
new file mode 100644
index 0000000..5f36e50
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPresentation.cs
@@ -0,0 +1,115 @@
+using CustomEvent;
+using Definition.DataStruct;
+using Entity;
+using Entity.Weapon;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ #region Collision Presentation
+
+ private void DispatchProjectileHitPresentationEvent(int projectileEntityId, int sourceEntityId,
+ int sourceOwnerEntityId, int targetEntityId, int damage, in Vector3 hitPosition)
+ {
+ if (!_dispatchProjectileHitPresentationEvent)
+ {
+ return;
+ }
+
+ var eventComponent = GameEntry.Event;
+ if (eventComponent == null)
+ {
+ return;
+ }
+
+ eventComponent.Fire(this, ProjectileHitPresentationEventArgs.Create(
+ projectileEntityId,
+ sourceEntityId,
+ sourceOwnerEntityId,
+ targetEntityId,
+ damage,
+ hitPosition,
+ _dispatchProjectileHitMarkerEvent,
+ _dispatchProjectileHitEffectEvent,
+ _projectileHitPresentationEffectTypeId));
+ }
+
+ private static bool TryResolveImpactSource(EntityBase sourceEntity, EntityBase ownerEntity,
+ out EntityBase attacker, out ImpactData impactData)
+ {
+ if (TryResolveImpactFromEntity(sourceEntity, out impactData))
+ {
+ attacker = sourceEntity;
+ return true;
+ }
+
+ if (TryResolveImpactFromEntity(ownerEntity, out impactData))
+ {
+ attacker = ownerEntity;
+ return true;
+ }
+
+ attacker = null;
+ impactData = default;
+ return false;
+ }
+
+ private static bool TryResolveImpactFromEntity(EntityBase entity, out ImpactData impactData)
+ {
+ if (entity is WeaponBase weapon)
+ {
+ impactData = weapon.GetImpactData();
+ return true;
+ }
+
+ if (entity is EnemyProjectile enemyProjectile)
+ {
+ impactData = enemyProjectile.GetImpactData();
+ return true;
+ }
+
+ if (entity is TargetableObject targetableObject)
+ {
+ impactData = targetableObject.GetImpactData();
+ return true;
+ }
+
+ impactData = default;
+ return false;
+ }
+
+ private static bool TryGetAliveTargetableEntity(int entityId, out TargetableObject target)
+ {
+ target = null;
+
+ var enemyManager = GameEntry.EnemyManager;
+ if (enemyManager != null && enemyManager.TryGetEnemy(entityId, out EntityBase enemyEntity))
+ {
+ if (enemyEntity is TargetableObject enemyTarget && enemyTarget.Available && !enemyTarget.IsDead)
+ {
+ target = enemyTarget;
+ return true;
+ }
+ }
+
+ EntityBase entity = TryGetEntityById(entityId);
+ if (entity is TargetableObject targetable && targetable.Available && !targetable.IsDead)
+ {
+ target = targetable;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static EntityBase TryGetEntityById(int entityId)
+ {
+ var entityComponent = GameEntry.Entity;
+ return entityComponent != null ? entityComponent.GetGameEntity(entityId) : null;
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPresentation.cs.meta b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPresentation.cs.meta
new file mode 100644
index 0000000..27d23f0
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPresentation.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 882102e53dea40a2b5b596e1b41bbed6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionRequests.cs b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionRequests.cs
new file mode 100644
index 0000000..4b790c6
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionRequests.cs
@@ -0,0 +1,126 @@
+using Entity;
+using Entity.Weapon;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ #region Collision Requests
+
+ public bool TryRequestAreaCollision(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center,
+ float radius, int maxTargets = 16)
+ {
+ return TryRequestAreaCollisionInternal(sourceEntityId, sourceOwnerEntityId, in center, radius,
+ maxTargets, CollisionShapeCircle, Vector3.forward, 180f, 0f, 0f);
+ }
+
+ public bool TryRequestSectorCollision(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center,
+ float radius, in Vector3 direction, float halfAngleDeg, int maxTargets = 16)
+ {
+ return TryRequestAreaCollisionInternal(sourceEntityId, sourceOwnerEntityId, in center, radius,
+ maxTargets, CollisionShapeSector, direction, halfAngleDeg, 0f, 0f);
+ }
+
+ public bool TryRequestRectangleCollision(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center,
+ float halfWidth, float halfLength, in Vector3 direction, int maxTargets = 16)
+ {
+ float safeHalfWidth = Mathf.Max(0.01f, halfWidth);
+ float safeHalfLength = Mathf.Max(0.01f, halfLength);
+ float boundingRadius = Mathf.Sqrt(safeHalfWidth * safeHalfWidth + safeHalfLength * safeHalfLength);
+ return TryRequestAreaCollisionInternal(sourceEntityId, sourceOwnerEntityId, in center, boundingRadius,
+ maxTargets, CollisionShapeRectangle, direction, 0f, safeHalfWidth, safeHalfLength);
+ }
+
+ private bool TryRequestAreaCollisionInternal(int sourceEntityId, int sourceOwnerEntityId,
+ in Vector3 center, float radius, int maxTargets, int shapeType, in Vector3 direction, float halfAngleDeg,
+ float halfWidth, float halfLength)
+ {
+ if (!_useSimulationMovement)
+ {
+ return false;
+ }
+
+ if (sourceEntityId == 0 || radius <= 0f || maxTargets <= 0)
+ {
+ return false;
+ }
+
+ int resolvedOwnerEntityId = sourceOwnerEntityId != 0 ? sourceOwnerEntityId : sourceEntityId;
+ bool sourceWasActiveAtQueryTime = WasCollisionSourceActiveAtQueryTime(sourceEntityId);
+ Vector3 normalizedDirection = direction;
+ normalizedDirection.y = 0f;
+ if (normalizedDirection.sqrMagnitude <= Mathf.Epsilon)
+ {
+ normalizedDirection = Vector3.forward;
+ }
+ else
+ {
+ normalizedDirection.Normalize();
+ }
+
+ _areaCollisionRequests.Add(new AreaCollisionRequestData
+ {
+ SourceEntityId = sourceEntityId,
+ SourceOwnerEntityId = resolvedOwnerEntityId,
+ SourceWasActiveAtQueryTime = sourceWasActiveAtQueryTime,
+ Center = center,
+ Radius = Mathf.Max(0.01f, radius),
+ MaxTargets = Mathf.Max(1, maxTargets),
+ ShapeType = shapeType,
+ Direction = normalizedDirection,
+ HalfAngleDeg = Mathf.Clamp(halfAngleDeg, 0f, 180f),
+ HalfWidth = Mathf.Max(0f, halfWidth),
+ HalfLength = Mathf.Max(0f, halfLength)
+ });
+
+ return true;
+ }
+
+ private int GetPendingAreaCollisionRequestCount()
+ {
+ return _areaCollisionRequests.Count;
+ }
+
+ private int EstimatePendingAreaCollisionCandidateCountFromRequests()
+ {
+ int expectedCount = 0;
+ for (int i = 0; i < _areaCollisionRequests.Count; i++)
+ {
+ expectedCount += Mathf.Max(1, _areaCollisionRequests[i].MaxTargets);
+ }
+
+ return expectedCount;
+ }
+
+ private void ClearAreaCollisionTransientBuffers()
+ {
+ _areaCollisionRequests.Clear();
+ _areaCollisionHitEvents.Clear();
+ _areaCollisionHitDedupKeys.Clear();
+ }
+
+ private static bool WasCollisionSourceActiveAtQueryTime(int sourceEntityId)
+ {
+ EntityBase sourceEntity = TryGetEntityById(sourceEntityId);
+ if (sourceEntity == null || !sourceEntity.Available)
+ {
+ return false;
+ }
+
+ if (sourceEntity is WeaponBase weapon)
+ {
+ return weapon.IsAttacking;
+ }
+
+ if (sourceEntity is EnemyProjectile projectile)
+ {
+ return projectile.IsActive;
+ }
+
+ return true;
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionRequests.cs.meta b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionRequests.cs.meta
new file mode 100644
index 0000000..d8a8a70
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionRequests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 56086ce3981e4ed5b95fdc54f7db85a3
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionResolve.cs b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionResolve.cs
new file mode 100644
index 0000000..065d9e8
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionResolve.cs
@@ -0,0 +1,364 @@
+using Components;
+using CustomDebugger;
+using CustomUtility;
+using Definition.DataStruct;
+using Definition.Enum;
+using Entity;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ #region Collision Resolve
+
+ private void ResolveCollisionCandidatesOnMainThread()
+ {
+ if (!_collisionCandidates.IsCreated)
+ {
+ _lastResolvedAreaHitCount = 0;
+ ClearAreaCollisionTransientBuffers();
+ return;
+ }
+
+ _projectileResolvedEntityIds.Clear();
+ _areaCollisionHitEvents.Clear();
+ _areaCollisionHitDedupKeys.Clear();
+
+ if (_collisionCandidates.Length == 0)
+ {
+ _lastResolvedAreaHitCount = 0;
+ ClearAreaCollisionTransientBuffers();
+ return;
+ }
+
+ using (CustomProfilerMarker.Collision_ResolveProjectile.Auto())
+ {
+ for (int i = 0; i < _collisionCandidates.Length; i++)
+ {
+ CollisionCandidateData candidate = _collisionCandidates[i];
+ if (candidate.SourceType == CollisionSourceTypeProjectile)
+ {
+ int projectileEntityId = candidate.SourceEntityId;
+ if (_projectileResolvedEntityIds.Contains(projectileEntityId))
+ {
+ continue;
+ }
+
+ if (!TryGetActiveProjectileSimData(projectileEntityId, out _, out ProjectileSimData projectile))
+ {
+ _projectileResolvedEntityIds.Add(projectileEntityId);
+ continue;
+ }
+
+ bool shouldExpireProjectile = true;
+ bool shouldDispatchPresentation = false;
+ int damage = 0;
+ Vector3 hitPosition = projectile.Position;
+ if (TryGetAliveTargetableEntity(candidate.TargetEntityId, out TargetableObject target))
+ {
+ EntityBase sourceEntity = TryGetEntityById(candidate.SourceEntityId);
+ EntityBase ownerEntity = TryGetEntityById(candidate.SourceOwnerEntityId);
+ shouldExpireProjectile = ResolveProjectileHitAgainstTarget(target, sourceEntity,
+ ownerEntity,
+ in projectile,
+ out damage, out hitPosition, out shouldDispatchPresentation);
+ }
+
+ if (shouldDispatchPresentation)
+ {
+ DispatchProjectileHitPresentationEvent(projectileEntityId, candidate.SourceEntityId,
+ candidate.SourceOwnerEntityId, candidate.TargetEntityId, damage, in hitPosition);
+ }
+
+ if (shouldExpireProjectile)
+ {
+ MarkProjectileAsExpired(projectileEntityId);
+ _projectileResolvedEntityIds.Add(projectileEntityId);
+ }
+
+ continue;
+ }
+
+ if (candidate.SourceType == CollisionSourceTypeArea)
+ {
+ long dedupKey = (((long)candidate.QueryId) << 32) ^ (uint)candidate.TargetEntityId;
+ if (!_areaCollisionHitDedupKeys.Add(dedupKey))
+ {
+ continue;
+ }
+
+ _areaCollisionHitEvents.Add(new AreaCollisionHitEventData
+ {
+ QueryId = candidate.QueryId,
+ SourceEntityId = candidate.SourceEntityId,
+ SourceOwnerEntityId = candidate.SourceOwnerEntityId,
+ TargetEntityId = candidate.TargetEntityId,
+ SqrDistance = candidate.SqrDistance
+ });
+ }
+ }
+ }
+
+ int resolvedAreaHitCount;
+ using (CustomProfilerMarker.Collision_ResolveArea.Auto())
+ {
+ resolvedAreaHitCount = ResolveAreaCollisionHitsOnMainThread();
+ }
+
+ _lastResolvedAreaHitCount = resolvedAreaHitCount;
+ _projectileResolvedEntityIds.Clear();
+ ClearAreaCollisionTransientBuffers();
+ }
+
+ private bool ResolveProjectileHitAgainstTarget(TargetableObject target, EntityBase sourceEntity,
+ EntityBase ownerEntity,
+ in ProjectileSimData projectile, out int damage, out Vector3 hitPosition,
+ out bool shouldDispatchPresentation)
+ {
+ damage = 0;
+ hitPosition = projectile.Position;
+ shouldDispatchPresentation = false;
+
+ if (target == null || !target.Available || target.IsDead)
+ {
+ return true;
+ }
+
+ if (target.CachedTransform != null)
+ {
+ hitPosition = target.CachedTransform.position;
+ }
+
+ if (!TryResolveImpactSource(sourceEntity, ownerEntity, out EntityBase attacker,
+ out ImpactData sourceImpact))
+ {
+ shouldDispatchPresentation = true;
+ return true;
+ }
+
+ ImpactData targetImpact = target.GetImpactData();
+ if (AIUtility.GetRelation(targetImpact.Camp, sourceImpact.Camp) == RelationType.Friendly)
+ {
+ return false;
+ }
+
+ damage = AIUtility.CalcDamageHP(sourceImpact.AttackBase, sourceImpact.AttackStat,
+ targetImpact.DefenseStat,
+ targetImpact.DodgeStat);
+ shouldDispatchPresentation = true;
+ if (damage <= 0)
+ {
+ return true;
+ }
+
+ target.ApplyDamage(attacker ?? sourceEntity ?? ownerEntity, damage);
+ return true;
+ }
+
+ private int ResolveAreaCollisionHitsOnMainThread()
+ {
+ if (_areaCollisionHitEvents.Count == 0)
+ {
+ return 0;
+ }
+
+ int resolvedHitCount = 0;
+ for (int i = 0; i < _areaCollisionHitEvents.Count; i++)
+ {
+ AreaCollisionHitEventData hitEvent = _areaCollisionHitEvents[i];
+ if (!TryGetCollisionQueryByQueryId(hitEvent.QueryId, out CollisionQueryData query) ||
+ query.SourceType != CollisionSourceTypeArea)
+ {
+ continue;
+ }
+
+ if (!query.SourceWasActiveAtQueryTime)
+ {
+ continue;
+ }
+
+ EntityBase sourceEntity = TryGetEntityById(hitEvent.SourceEntityId);
+ if (sourceEntity == null || !sourceEntity.Available)
+ {
+ continue;
+ }
+
+ if (!TryGetAliveTargetableEntity(hitEvent.TargetEntityId, out TargetableObject target))
+ {
+ continue;
+ }
+
+ float targetRadius = ResolveAreaTargetRadius(target);
+ if (!IsAreaTargetInsidePreciseShape(in query, target, targetRadius))
+ {
+ continue;
+ }
+
+ AIUtility.PerformCollision(target, sourceEntity, true);
+ resolvedHitCount++;
+ }
+
+ return resolvedHitCount;
+ }
+
+ private bool TryGetCollisionQueryByQueryId(int queryId, out CollisionQueryData query)
+ {
+ query = default;
+ if (!_collisionQueryInputs.IsCreated || queryId < 0 || queryId >= _collisionQueryInputs.Length)
+ {
+ return false;
+ }
+
+ CollisionQueryData direct = _collisionQueryInputs[queryId];
+ if (direct.QueryId == queryId)
+ {
+ query = direct;
+ return true;
+ }
+
+ for (int i = 0; i < _collisionQueryInputs.Length; i++)
+ {
+ CollisionQueryData candidate = _collisionQueryInputs[i];
+ if (candidate.QueryId != queryId)
+ {
+ continue;
+ }
+
+ query = candidate;
+ return true;
+ }
+
+ return false;
+ }
+
+ private float ResolveAreaTargetRadius(TargetableObject target)
+ {
+ if (target == null)
+ {
+ return 0f;
+ }
+
+ if (target is EnemyBase && TryGetEnemyData(target.Id, out EnemySimData enemyData))
+ {
+ return Mathf.Max(0f, enemyData.EnemyBodyRadius);
+ }
+
+ MovementComponent movementComponent = target.GetComponent();
+ return movementComponent != null ? Mathf.Max(0f, movementComponent.EnemyBodyRadius) : 0f;
+ }
+
+ private static bool IsAreaTargetInsidePreciseShape(in CollisionQueryData query, TargetableObject target,
+ float targetRadius)
+ {
+ if (target == null || target.CachedTransform == null)
+ {
+ return false;
+ }
+
+ Vector3 center = new Vector3(query.Position.x, query.Position.y, query.Position.z);
+ Vector3 toTarget = target.CachedTransform.position - center;
+ toTarget.y = 0f;
+
+ float radius = Mathf.Max(0.01f, query.Radius + Mathf.Max(0f, targetRadius));
+ float radiusSqr = radius * radius;
+ float sqrDistance = toTarget.sqrMagnitude;
+ if (sqrDistance > radiusSqr)
+ {
+ return false;
+ }
+
+ if (query.ShapeType == CollisionShapeRectangle)
+ {
+ Vector3 forwardRect = new Vector3(query.Direction.x, query.Direction.y, query.Direction.z);
+ forwardRect.y = 0f;
+ if (forwardRect.sqrMagnitude <= Mathf.Epsilon)
+ {
+ forwardRect = Vector3.forward;
+ }
+ else
+ {
+ forwardRect.Normalize();
+ }
+
+ Vector3 rightRect = Vector3.Cross(Vector3.up, forwardRect);
+ float halfWidth = Mathf.Max(0.01f, query.HalfWidth + Mathf.Max(0f, targetRadius));
+ float halfLength = Mathf.Max(0.01f, query.HalfLength + Mathf.Max(0f, targetRadius));
+ float forwardDistance = Vector3.Dot(toTarget, forwardRect);
+ float lateralDistance = Vector3.Dot(toTarget, rightRect);
+ return Mathf.Abs(forwardDistance) <= halfLength && Mathf.Abs(lateralDistance) <= halfWidth;
+ }
+
+ if (query.ShapeType != CollisionShapeSector)
+ {
+ return true;
+ }
+
+ if (sqrDistance <= Mathf.Epsilon)
+ {
+ return true;
+ }
+
+ Vector3 forward = new Vector3(query.Direction.x, query.Direction.y, query.Direction.z);
+ forward.y = 0f;
+ if (forward.sqrMagnitude <= Mathf.Epsilon)
+ {
+ forward = Vector3.forward;
+ }
+ else
+ {
+ forward.Normalize();
+ }
+
+ float halfAngle = Mathf.Clamp(query.HalfAngleDeg, 0f, 180f);
+ float angle = Vector3.Angle(forward, toTarget.normalized);
+ return angle <= halfAngle;
+ }
+
+ private bool TryGetActiveProjectileSimData(int projectileEntityId, out int simulationIndex,
+ out ProjectileSimData projectile)
+ {
+ simulationIndex = -1;
+ projectile = default;
+
+ if (!ProjectileBinding.TryGetSimulationIndex(projectileEntityId, out int foundIndex) || foundIndex < 0 ||
+ foundIndex >= _projectiles.Count)
+ {
+ return false;
+ }
+
+ ProjectileSimData data = _projectiles[foundIndex];
+ if (!data.Active || data.State != ProjectileStateActive)
+ {
+ return false;
+ }
+
+ simulationIndex = foundIndex;
+ projectile = data;
+ return true;
+ }
+
+ private bool MarkProjectileAsExpired(int projectileEntityId)
+ {
+ if (!ProjectileBinding.TryGetSimulationIndex(projectileEntityId, out int simulationIndex) ||
+ simulationIndex < 0 || simulationIndex >= _projectiles.Count)
+ {
+ return false;
+ }
+
+ ProjectileSimData projectile = _projectiles[simulationIndex];
+ projectile.Active = false;
+ projectile.State = ProjectileStateExpired;
+ projectile.RemainingLifetime = 0f;
+ if (projectile.LifeTime > 0f && projectile.Age < projectile.LifeTime)
+ {
+ projectile.Age = projectile.LifeTime;
+ }
+
+ _projectiles[simulationIndex] = projectile;
+ return true;
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionResolve.cs.meta b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionResolve.cs.meta
new file mode 100644
index 0000000..25015d0
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionResolve.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5261fa73dd9c4bfc91fcba0d5ca1bcb1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs
new file mode 100644
index 0000000..3d447f5
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs
@@ -0,0 +1,280 @@
+using CustomDebugger;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ // Orchestrates per-tick simulation pipeline and enemy movement/separation jobs.
+ [Header("敌人互斥参数")]
+ [Tooltip("敌人互斥分桶使用的网格尺寸。小于等于 0 时,将根据敌人体积半径自动计算。")]
+ [SerializeField]
+ private float _enemySeparationCellSize = 0f;
+
+ [Tooltip("每次迭代对互斥推力累积值的阻尼系数。数值越大,分离速度越快。")]
+ [SerializeField]
+ private float _enemySeparationPushDamping = 0.75f;
+
+ [Tooltip("每次迭代允许的最大互斥位移步长(按敌人体积半径倍率计算)。")]
+ [SerializeField]
+ private float _enemySeparationMaxStepScale = 1f;
+
+ [Tooltip("敌人进入攻击范围后,互斥位移是否保持为相对玩家方向的切向分量(避免被径向推离玩家)。")]
+ [SerializeField]
+ private bool _enemySeparationUseTangentialInAttackRange = true;
+
+ [Tooltip("互斥推力方向突变时的时间平滑系数。越大越稳定,但响应越慢。")]
+ [SerializeField]
+ private float _enemySeparationPushSmoothing = 0.55f;
+
+ private void TickSimulationPipeline(in SimulationTickContext context)
+ {
+ // 1. 早退分支:deltaTime <= 0 时只清理碰撞通道和统计,然后返回。
+ if (context.DeltaTime <= 0f)
+ {
+ PrepareCollisionQueryAndCandidateChannels(0, 0, 0);
+ ResetCollisionRuntimeStats();
+ ClearAreaCollisionTransientBuffers();
+ return;
+ }
+
+ JobHandle enemyMovementHandle = default;
+ JobHandle projectileMovementHandle = default;
+ JobHandle enemySeparationHandle = default;
+ bool hasEnemySeparationJob = false;
+ bool hasEnemySeparationCandidates = false;
+ int enemySeparationCount = 0;
+ float enemySeparationMaxRadius = 0.45f;
+
+ // 2. BuildInput 阶段:
+ // 把 _enemies/_projectiles 同步到 Native 输入,准备输出缓冲;
+ // 统计是否需要敌人分离 Job;
+ // 预估并准备碰撞查询/候选缓冲。
+ using (CustomProfilerMarker.TickEnemies_BuildInput.Auto())
+ {
+ SyncSimulationStateToJobInputs();
+ int enemyCount = _enemyJobInputs.Length;
+ int projectileCount = _projectileJobInputs.Length;
+ PrepareEnemyJobOutputBuffer(enemyCount);
+ PrepareProjectileJobOutputBuffer(projectileCount);
+
+ enemySeparationCount = enemyCount;
+ for (int i = 0; i < enemyCount; i++)
+ {
+ EnemyJobInputData input = _enemyJobInputs[i];
+ if (!input.AvoidEnemyOverlap)
+ {
+ continue;
+ }
+
+ hasEnemySeparationCandidates = true;
+ float radius = input.EnemyBodyRadius > 0f ? input.EnemyBodyRadius : 0.45f;
+ if (radius > enemySeparationMaxRadius)
+ {
+ enemySeparationMaxRadius = radius;
+ }
+ }
+
+ if (hasEnemySeparationCandidates)
+ {
+ int separationBucketCapacity = Mathf.Max(128, enemyCount * 2);
+ PrepareEnemySeparationJobBuffers(enemyCount, separationBucketCapacity);
+ }
+
+ int projectileQueryCount = _projectiles.Count;
+ int areaQueryCount = GetPendingAreaCollisionRequestCount();
+ int queryCount = projectileQueryCount + areaQueryCount;
+ int projectileExpectedCount = projectileQueryCount * Mathf.Max(1, _projectileMaxCandidatesPerQuery);
+ int areaExpectedCount = EstimatePendingAreaCollisionCandidateCountFromRequests();
+ int expectedCandidateCount = Mathf.Max(16, projectileExpectedCount + areaExpectedCount);
+ int bucketCapacity = Mathf.Max(256, _enemies.Count * 2 + queryCount);
+ PrepareCollisionQueryAndCandidateChannels(queryCount, expectedCandidateCount, bucketCapacity);
+ }
+
+ // 3. StateUpdate 阶段(调度两个移动 Job)
+ using (CustomProfilerMarker.TickEnemies_StateUpdate.Auto())
+ {
+ enemyMovementHandle = ExecuteEnemyMovementJob(in context);
+ projectileMovementHandle = ExecuteProjectileMovementJob(in context);
+ }
+
+ // 4. Schedule 阶段(可选分离 Job)
+ // 如果有需要分离的敌人,调度:
+ // BuildEnemySeparationBucketsBurstJob(依赖 EnemyMovement)
+ // EnemySeparationBurstJob(依赖分桶 Job)
+ // 然后把“敌人链路 handle”和“投射物移动 handle”合并。
+ JobHandle simulationHandle;
+ using (CustomProfilerMarker.TickEnemies_Schedule.Auto())
+ {
+ hasEnemySeparationJob = TryScheduleEnemySeparationFromJobOutput(
+ in context,
+ enemyMovementHandle,
+ hasEnemySeparationCandidates,
+ enemySeparationCount,
+ enemySeparationMaxRadius,
+ out enemySeparationHandle);
+ JobHandle enemyHandle = hasEnemySeparationJob ? enemySeparationHandle : enemyMovementHandle;
+ simulationHandle = JobHandle.CombineDependencies(enemyHandle, projectileMovementHandle);
+ }
+
+ // 5. Complete 阶段:等待上述 Job 全部完成。
+ using (CustomProfilerMarker.TickEnemies_Complete.Auto())
+ {
+ simulationHandle.Complete();
+ }
+
+ // 6. 主线程后处理阶段:
+ // - 把分离结果覆盖回敌人输出(如果有分离 Job)
+ // - 构建碰撞候选(投射物查询 + area 请求查询 + 网格筛选)
+ using (CustomProfilerMarker.TickEnemies_MainThreadCommit.Auto())
+ {
+ if (hasEnemySeparationJob)
+ {
+ CommitEnemySeparationFromJobOutput(enemySeparationCount);
+ }
+ }
+
+ using (CustomProfilerMarker.Collision.Auto())
+ {
+ PrepareCollisionCandidatesForFrame();
+ CompleteCollisionCandidatesForFrame();
+ }
+
+ // 7. MainThreadCommit 阶段
+ // - ApplyJobOutputsToSimulationState(写回 _enemies/_projectiles)
+ // - ResolveCollisionCandidatesOnMainThread(命中结算、事件、范围碰撞)
+ // - RecycleInactiveAndExpiredProjectiles(隐藏并移除失效投射物)
+ using (CustomProfilerMarker.TickEnemies_WriteBack.Auto())
+ {
+ using (CustomProfilerMarker.TickEnemies_MainThreadCommit.Auto())
+ {
+ ApplyJobOutputsToSimulationState();
+ ResolveCollisionCandidatesOnMainThread();
+ RecycleInactiveAndExpiredProjectiles();
+ }
+ }
+ }
+
+ private JobHandle ExecuteEnemyMovementJob(in SimulationTickContext context)
+ {
+ int enemyCount = _enemyJobInputs.Length;
+ if (enemyCount == 0)
+ {
+ return default;
+ }
+
+ if (context.DeltaTime <= 0f)
+ {
+ CopyEnemyInputsToOutputs();
+ return default;
+ }
+
+ float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z);
+ NativeArray inputArray = _enemyJobInputs.AsArray();
+ NativeArray outputArray = _enemyJobOutputs.AsArray();
+
+ EnemyMovementBurstJob burstJob = new EnemyMovementBurstJob
+ {
+ Inputs = inputArray,
+ Outputs = outputArray,
+ DeltaTime = context.DeltaTime,
+ PlayerPosition = playerPosition
+ };
+ return burstJob.Schedule(enemyCount, 64);
+ }
+
+ private void CopyEnemyInputsToOutputs()
+ {
+ for (int i = 0; i < _enemyJobInputs.Length; i++)
+ {
+ EnemyJobInputData input = _enemyJobInputs[i];
+ _enemyJobOutputs[i] = new EnemyJobOutputData
+ {
+ EntityId = input.EntityId,
+ Position = input.Position,
+ Forward = input.Forward,
+ Rotation = input.Rotation,
+ Speed = input.Speed,
+ AttackRange = input.AttackRange,
+ AvoidEnemyOverlap = input.AvoidEnemyOverlap,
+ EnemyBodyRadius = input.EnemyBodyRadius,
+ SeparationIterations = input.SeparationIterations,
+ TargetType = input.TargetType,
+ State = input.State
+ };
+ }
+ }
+
+ private bool TryScheduleEnemySeparationFromJobOutput(in SimulationTickContext context, JobHandle dependency,
+ bool hasSeparationCandidates, int enemyCount, float maxRadius, out JobHandle separationHandle)
+ {
+ separationHandle = dependency;
+ if (enemyCount <= 0 || !hasSeparationCandidates)
+ {
+ return false;
+ }
+
+ float autoCellSize = maxRadius * 2f;
+ float configuredCellSize = _enemySeparationCellSize > 0f ? _enemySeparationCellSize : autoCellSize;
+ float cellSize = Mathf.Max(0.1f, configuredCellSize);
+ float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z);
+ float pushDamping = Mathf.Clamp(_enemySeparationPushDamping, 0f, 2f);
+ float maxStepScale = Mathf.Max(0.1f, _enemySeparationMaxStepScale);
+ bool useTangentialInAttackRange = _enemySeparationUseTangentialInAttackRange;
+ float pushSmoothing = Mathf.Clamp01(_enemySeparationPushSmoothing);
+
+ NativeArray inputArray = _enemyJobOutputs.AsArray();
+ NativeArray separatedOutputArray = _enemyJobSeparationOutputs.AsArray();
+ NativeArray previousPushes = _enemySeparationPreviousPushes.AsArray();
+ NativeArray currentPushes = _enemySeparationCurrentPushes.AsArray();
+
+
+ BuildEnemySeparationBucketsBurstJob buildJob = new BuildEnemySeparationBucketsBurstJob
+ {
+ Inputs = inputArray,
+ Buckets = _enemySeparationBuckets.AsParallelWriter(),
+ CellSize = cellSize
+ };
+ JobHandle buildHandle = buildJob.Schedule(enemyCount, 64, dependency);
+ EnemySeparationBurstJob separationJob = new EnemySeparationBurstJob
+ {
+ Inputs = inputArray,
+ Buckets = _enemySeparationBuckets,
+ PreviousPushes = previousPushes,
+ Outputs = separatedOutputArray,
+ CurrentPushes = currentPushes,
+ CellSize = cellSize,
+ MaxRadius = maxRadius,
+ PlayerPosition = playerPosition,
+ PushDamping = pushDamping,
+ MaxStepScale = maxStepScale,
+ UseTangentialInAttackRange = useTangentialInAttackRange,
+ PushSmoothing = pushSmoothing
+ };
+ separationHandle = separationJob.Schedule(enemyCount, 64, buildHandle);
+ return true;
+ }
+
+ private void CommitEnemySeparationFromJobOutput(int enemyCount)
+ {
+ if (enemyCount <= 0)
+ {
+ return;
+ }
+
+ CommitEnemySeparationTemporalBuffers(enemyCount);
+ for (int i = 0; i < enemyCount; i++)
+ {
+ _enemyJobOutputs[i] = _enemyJobSeparationOutputs[i];
+ }
+ }
+
+ private static long SeparationCellKey(int x, int z)
+ {
+ return ((long)x << 32) ^ (uint)z;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs.meta b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs.meta
new file mode 100644
index 0000000..86287bc
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 99b885bb5cfe48e7bd9dba74fe683149
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs
new file mode 100644
index 0000000..c56b8f7
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs
@@ -0,0 +1,111 @@
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ [Header("Projectile Simulation")]
+ [Tooltip("Recycle projectile when horizontal distance to player exceeds this range. <=0 disables this rule.")]
+ [SerializeField]
+ private float _projectileMaxDistanceFromPlayer = 120f;
+
+ [Tooltip("Recycle projectile when vertical offset to player exceeds this range. <=0 disables this rule.")]
+ [SerializeField]
+ private float _projectileMaxVerticalOffsetFromPlayer = 30f;
+
+ #region Projectile Movement Job
+
+ private JobHandle ExecuteProjectileMovementJob(in SimulationTickContext context)
+ {
+ int projectileCount = _projectileJobInputs.Length;
+ if (projectileCount == 0)
+ {
+ return default;
+ }
+
+ if (context.DeltaTime <= 0f)
+ {
+ CopyProjectileInputsToOutputs();
+ return default;
+ }
+
+ float maxDistance = Mathf.Max(0f, _projectileMaxDistanceFromPlayer);
+ float maxSqrDistanceFromPlayer = maxDistance > 0f ? maxDistance * maxDistance : -1f;
+ float maxVerticalOffsetFromPlayer = Mathf.Max(0f, _projectileMaxVerticalOffsetFromPlayer);
+ float3 playerPosition =
+ new float3(context.PlayerPosition.x, context.PlayerPosition.y, context.PlayerPosition.z);
+ NativeArray inputArray = _projectileJobInputs.AsArray();
+ NativeArray outputArray = _projectileJobOutputs.AsArray();
+
+
+ ProjectileMovementBurstJob burstJob = new ProjectileMovementBurstJob
+ {
+ Inputs = inputArray,
+ Outputs = outputArray,
+ DeltaTime = context.DeltaTime,
+ PlayerPosition = playerPosition,
+ MaxSqrDistanceFromPlayer = maxSqrDistanceFromPlayer,
+ MaxVerticalOffsetFromPlayer = maxVerticalOffsetFromPlayer
+ };
+ return burstJob.Schedule(projectileCount, 64);
+ }
+
+ #endregion
+
+ #region Projectile Cleanup
+
+ private void RecycleInactiveAndExpiredProjectiles()
+ {
+ _projectileRecycleEntityIds.Clear();
+ for (int i = 0; i < _projectiles.Count; i++)
+ {
+ ProjectileSimData projectile = _projectiles[i];
+ if (!ShouldRecycleProjectileSimData(projectile))
+ {
+ continue;
+ }
+
+ _projectileRecycleEntityIds.Add(projectile.EntityId);
+ }
+
+ if (_projectileRecycleEntityIds.Count == 0)
+ {
+ return;
+ }
+
+ var entityComponent = GameEntry.Entity;
+ for (int i = 0; i < _projectileRecycleEntityIds.Count; i++)
+ {
+ int entityId = _projectileRecycleEntityIds[i];
+ if (entityComponent != null)
+ {
+ entityComponent.HideEntity(entityId);
+ }
+
+ RemoveProjectileByEntityId(entityId);
+ }
+
+ _projectileRecycleEntityIds.Clear();
+ }
+
+ private static bool ShouldRecycleProjectileSimData(in ProjectileSimData projectile)
+ {
+ if (!projectile.Active)
+ {
+ return true;
+ }
+
+ if (projectile.State == ProjectileStateExpired)
+ {
+ return true;
+ }
+
+ return projectile.LifeTime > 0f && projectile.Age >= projectile.LifeTime;
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs.meta b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs.meta
new file mode 100644
index 0000000..87ea5a4
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bcb6513f18404795a654500d3d2f8ef9
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/Presentation.meta b/Assets/GameMain/Scripts/Simulation/Presentation.meta
new file mode 100644
index 0000000..4ef6cfc
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Presentation.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 4bf448696a8641268abb660b16def6f5
+timeCreated: 1771911417
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs
new file mode 100644
index 0000000..4c681ff
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs
@@ -0,0 +1,135 @@
+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)
+ {
+ // Projectile hit markers were reusing the handgun marker effect and were
+ // visually indistinguishable from handgun lock/hit feedback.
+ }
+
+ 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);
+ }
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs.meta b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs.meta
new file mode 100644
index 0000000..0364ca5
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 99cec505913140a2b4bdf1498b28c044
+timeCreated: 1771911517
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.Presentation.cs b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.Presentation.cs
new file mode 100644
index 0000000..34b4914
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.Presentation.cs
@@ -0,0 +1,9 @@
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs.meta b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.Presentation.cs.meta
similarity index 100%
rename from Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs.meta
rename to Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.Presentation.cs.meta
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs
similarity index 52%
rename from Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs
rename to Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs
index 6d12d64..5217514 100644
--- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs
+++ b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs
@@ -1,14 +1,15 @@
+using Entity;
using UnityEngine;
namespace Simulation
{
- public partial class SimulationWorld
+ public sealed partial class SimulationWorld
{
- private sealed class Presentation
+ private sealed class TransformSync
{
private readonly SimulationWorld _world;
- public Presentation(SimulationWorld world)
+ public TransformSync(SimulationWorld world)
{
_world = world;
}
@@ -27,9 +28,9 @@ namespace Simulation
}
var enemies = enemyManager.Enemies;
- for (int i = 0; i < enemies.Count; i++)
+ foreach (var enemy in enemies)
{
- if (enemies[i] is not EnemyBase enemyEntity || !enemyEntity.Available)
+ if (enemy is not EnemyBase enemyEntity || !enemyEntity.Available)
{
continue;
}
@@ -41,6 +42,24 @@ namespace Simulation
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)
@@ -64,6 +83,25 @@ namespace Simulation
enemyTransform.forward = forward.normalized;
}
}
+
+ private static void ApplyProjectilePresentation(EntityBase projectileEntity,
+ in ProjectileSimData projectileData)
+ {
+ Transform projectileTransform = projectileEntity.CachedTransform;
+ if (projectileTransform == null)
+ {
+ return;
+ }
+
+ projectileTransform.position = projectileData.Position;
+ Vector3 forward = projectileData.Forward;
+ if (forward.sqrMagnitude <= float.Epsilon)
+ {
+ return;
+ }
+
+ projectileTransform.rotation = Quaternion.LookRotation(forward.normalized, Vector3.up);
+ }
}
}
}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs.meta b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs.meta
new file mode 100644
index 0000000..e3bb324
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 21da1bcb02d247358738e9b562af5512
+timeCreated: 1771911431
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/SimData/ProjectileSimData.cs b/Assets/GameMain/Scripts/Simulation/SimData/ProjectileSimData.cs
index fa23db6..a7433f8 100644
--- a/Assets/GameMain/Scripts/Simulation/SimData/ProjectileSimData.cs
+++ b/Assets/GameMain/Scripts/Simulation/SimData/ProjectileSimData.cs
@@ -8,7 +8,11 @@ namespace Simulation
public int OwnerEntityId;
public Vector3 Position;
public Vector3 Forward;
+ public Vector3 Velocity;
public float Speed;
+ public float LifeTime;
+ public float Age;
+ public bool Active;
public float RemainingLifetime;
public int State;
}
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs
index f3344aa..d8a3290 100644
--- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs
+++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs
@@ -4,14 +4,16 @@ using UnityGameFramework.Runtime;
namespace Simulation
{
- public partial class SimulationWorld
+ public sealed partial class SimulationWorld
{
+ // Bridges entity show/hide events into simulation state registration.
public sealed class EntitySync
{
private const string EnemyGroupName = "Enemy";
private const string DropGroupName = "Drop";
private const string BulletGroupName = "Bullet";
private const string ProjectileGroupName = "Projectile";
+ private const string EnemyProjectileGroupName = "EnemyProjectile";
private readonly SimulationWorld _world;
@@ -53,10 +55,11 @@ namespace Simulation
return;
}
- if ((groupName == BulletGroupName || groupName == ProjectileGroupName) &&
+ if ((groupName == BulletGroupName || groupName == ProjectileGroupName ||
+ groupName == EnemyProjectileGroupName) &&
args.Entity.Logic is EntityBase projectileEntity)
{
- _world.RegisterProjectileLifecycle(projectileEntity);
+ _world.RegisterProjectileLifecycle(projectileEntity, args.UserData);
}
}
@@ -79,7 +82,8 @@ namespace Simulation
return;
}
- if (groupName == BulletGroupName || groupName == ProjectileGroupName)
+ if (groupName == BulletGroupName || groupName == ProjectileGroupName ||
+ groupName == EnemyProjectileGroupName)
{
_world.UnregisterProjectileLifecycle(args.EntityId);
}
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntityToSimData.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntityToSimData.cs
new file mode 100644
index 0000000..cbf3e8d
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntityToSimData.cs
@@ -0,0 +1,109 @@
+using Components;
+using Entity;
+using Entity.EntityData;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ #region Entity To Sim Data
+
+ private static EnemySimData CreateEnemyInitialSimData(EnemyBase enemy, EnemyData enemyData)
+ {
+ Transform enemyTransform = enemy.CachedTransform;
+ MovementComponent movementComponent = enemy.GetComponent();
+
+ float speed = 0f;
+ if (enemyData != null)
+ {
+ speed = enemyData.SpeedBase;
+ }
+ else if (movementComponent != null)
+ {
+ speed = movementComponent.Speed;
+ }
+
+ float attackRange = enemy.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 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
+ };
+ }
+
+ private static PickupSimData CreatePickupInitialSimData(EntityBase pickupEntity)
+ {
+ return new PickupSimData
+ {
+ EntityId = pickupEntity.Id,
+ Position = pickupEntity.CachedTransform.position,
+ PickupRadius = 0.35f,
+ State = 0
+ };
+ }
+
+ #endregion
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntityToSimData.cs.meta b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntityToSimData.cs.meta
new file mode 100644
index 0000000..0df076e
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntityToSimData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bdedabee0342441e9551c802dd66693a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.RuntimeModules.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.RuntimeModules.cs
new file mode 100644
index 0000000..00fb351
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.RuntimeModules.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ [SerializeField] private CollisionPipelineSettings _collisionPipelineSettings = new();
+ [SerializeField] private TargetSelectionSettings _targetSelectionSettings = new();
+
+ private readonly SimulationStateStore _simulationState = new();
+ private readonly JobDataRuntimeState _jobDataRuntime = new();
+ private readonly CollisionPipelineRuntimeState _collisionPipelineRuntime = new();
+ private readonly TargetSelectionRuntimeState _targetSelectionRuntime = new();
+
+ private List _enemies => _simulationState.Enemies;
+ private List _projectiles => _simulationState.Projectiles;
+ private List _pickups => _simulationState.Pickups;
+ private List _projectileRecycleEntityIds => _simulationState.ProjectileRecycleEntityIds;
+ private HashSet _projectileResolvedEntityIds => _collisionPipelineRuntime.ProjectileResolvedEntityIds;
+
+ private EntityBinding EnemyBinding => _simulationState.EnemyBinding;
+ private EntityBinding ProjectileBinding => _simulationState.ProjectileBinding;
+ private EntityBinding PickupBinding => _simulationState.PickupBinding;
+
+ private ref NativeList _enemyJobInputs => ref _jobDataRuntime.EnemyJobInputs;
+ private ref NativeList _enemyJobOutputs => ref _jobDataRuntime.EnemyJobOutputs;
+ private ref NativeList _enemyJobSeparationOutputs => ref _jobDataRuntime.EnemyJobSeparationOutputs;
+ private ref NativeList _enemySeparationPreviousPushes => ref _jobDataRuntime.EnemySeparationPreviousPushes;
+ private ref NativeList _enemySeparationCurrentPushes => ref _jobDataRuntime.EnemySeparationCurrentPushes;
+ private ref NativeList _projectileJobInputs => ref _jobDataRuntime.ProjectileJobInputs;
+ private ref NativeList _projectileJobOutputs => ref _jobDataRuntime.ProjectileJobOutputs;
+ private ref NativeList _collisionCandidates => ref _jobDataRuntime.CollisionCandidates;
+ private ref NativeParallelMultiHashMap _enemySeparationBuckets => ref _jobDataRuntime.EnemySeparationBuckets;
+ private ref NativeParallelMultiHashMap _enemyCollisionBuckets => ref _jobDataRuntime.EnemyCollisionBuckets;
+ private List _areaCollisionHitEvents => _jobDataRuntime.AreaCollisionHitEvents;
+ private HashSet _areaCollisionHitDedupKeys => _jobDataRuntime.AreaCollisionHitDedupKeys;
+ private ref int _lastCollisionQueryCount => ref _jobDataRuntime.LastCollisionQueryCount;
+ private ref int _lastProjectileCollisionQueryCount => ref _jobDataRuntime.LastProjectileCollisionQueryCount;
+ private ref int _lastAreaCollisionQueryCount => ref _jobDataRuntime.LastAreaCollisionQueryCount;
+ private ref int _lastCollisionCandidateCount => ref _jobDataRuntime.LastCollisionCandidateCount;
+ private ref int _lastProjectileCollisionCandidateCount => ref _jobDataRuntime.LastProjectileCollisionCandidateCount;
+ private ref int _lastAreaCollisionCandidateCount => ref _jobDataRuntime.LastAreaCollisionCandidateCount;
+ private ref int _lastResolvedAreaHitCount => ref _jobDataRuntime.LastResolvedAreaHitCount;
+ private ref float _lastCollisionCellSize => ref _jobDataRuntime.LastCollisionCellSize;
+ private ref bool _lastCollisionHasEnemyTargets => ref _jobDataRuntime.LastCollisionHasEnemyTargets;
+
+ private ref JobHandle _collisionCandidateQueryHandle => ref _collisionPipelineRuntime.CollisionCandidateQueryHandle;
+ private ref bool _collisionCandidateQueryScheduled => ref _collisionPipelineRuntime.CollisionCandidateQueryScheduled;
+
+ private ref NativeParallelMultiHashMap _enemyTargetBuckets => ref _targetSelectionRuntime.EnemyTargetBuckets;
+ private ref bool _enemyTargetBucketsDirty => ref _targetSelectionRuntime.EnemyTargetBucketsDirty;
+
+ private float _projectileCollisionQueryRadius => _collisionPipelineSettings.ProjectileCollisionQueryRadius;
+ private int _projectileMaxCandidatesPerQuery => _collisionPipelineSettings.ProjectileMaxCandidatesPerQuery;
+ private float _projectileCollisionCellSize => _collisionPipelineSettings.ProjectileCollisionCellSize;
+ private bool _dispatchProjectileHitPresentationEvent => _collisionPipelineSettings.DispatchProjectileHitPresentationEvent;
+ private bool _dispatchProjectileHitMarkerEvent => _collisionPipelineSettings.DispatchProjectileHitMarkerEvent;
+ private bool _dispatchProjectileHitEffectEvent => _collisionPipelineSettings.DispatchProjectileHitEffectEvent;
+ private int _projectileHitPresentationEffectTypeId => _collisionPipelineSettings.ProjectileHitPresentationEffectTypeId;
+ private float _targetSelectionCellSize => _targetSelectionSettings.CellSize;
+
+ [Serializable]
+ private sealed class CollisionPipelineSettings
+ {
+ [Header("Projectile Collision Query")]
+ [Tooltip("Projectile broad-phase collision query radius.")]
+ public float ProjectileCollisionQueryRadius = 0.35f;
+
+ [Tooltip("Maximum retained candidates per projectile query.")]
+ public int ProjectileMaxCandidatesPerQuery = 1;
+
+ [Tooltip("Broad-phase bucket cell size. <=0 derives from query radius.")]
+ public float ProjectileCollisionCellSize = 0f;
+
+ [Header("Projectile Hit Event Dispatch")]
+ [Tooltip("Dispatch projectile hit presentation event.")]
+ public bool DispatchProjectileHitPresentationEvent = true;
+
+ [Tooltip("Request hit marker when projectile hits.")]
+ public bool DispatchProjectileHitMarkerEvent = true;
+
+ [Tooltip("Request hit effect when projectile hits.")]
+ public bool DispatchProjectileHitEffectEvent = true;
+
+ [Tooltip("Default hit effect entity type id in presentation event. 0 means not specified.")]
+ public int ProjectileHitPresentationEffectTypeId;
+ }
+
+ [Serializable]
+ private sealed class TargetSelectionSettings
+ {
+ [Header("Target Selection")]
+ [Tooltip("Spatial hash cell size for nearest-enemy queries.")]
+ public float CellSize = 2f;
+ }
+
+ private sealed class SimulationStateStore
+ {
+ public readonly List Enemies = new();
+ public readonly List Projectiles = new();
+ public readonly List Pickups = new();
+ public readonly List ProjectileRecycleEntityIds = new();
+ public readonly EntityBinding EnemyBinding = new();
+ public readonly EntityBinding ProjectileBinding = new();
+ public readonly EntityBinding PickupBinding = new();
+ }
+
+ private sealed class JobDataRuntimeState
+ {
+ public NativeList EnemyJobInputs;
+ public NativeList EnemyJobOutputs;
+ public NativeList EnemyJobSeparationOutputs;
+ public NativeList EnemySeparationPreviousPushes;
+ public NativeList EnemySeparationCurrentPushes;
+ public NativeList ProjectileJobInputs;
+ public NativeList ProjectileJobOutputs;
+ public NativeList CollisionCandidates;
+ public NativeParallelMultiHashMap EnemySeparationBuckets;
+ public NativeParallelMultiHashMap EnemyCollisionBuckets;
+ public readonly List AreaCollisionHitEvents = new(32);
+ public readonly HashSet AreaCollisionHitDedupKeys = new();
+ public int LastCollisionQueryCount;
+ public int LastProjectileCollisionQueryCount;
+ public int LastAreaCollisionQueryCount;
+ public int LastCollisionCandidateCount;
+ public int LastProjectileCollisionCandidateCount;
+ public int LastAreaCollisionCandidateCount;
+ public int LastResolvedAreaHitCount;
+ public float LastCollisionCellSize;
+ public bool LastCollisionHasEnemyTargets;
+ }
+
+ private sealed class CollisionPipelineRuntimeState
+ {
+ public JobHandle CollisionCandidateQueryHandle;
+ public bool CollisionCandidateQueryScheduled;
+ public readonly HashSet ProjectileResolvedEntityIds = new();
+ }
+
+ private sealed class TargetSelectionRuntimeState
+ {
+ public NativeParallelMultiHashMap EnemyTargetBuckets;
+ public bool EnemyTargetBucketsDirty = true;
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.RuntimeModules.cs.meta b/Assets/GameMain/Scripts/Simulation/SimulationWorld.RuntimeModules.cs.meta
new file mode 100644
index 0000000..2809300
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.RuntimeModules.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e562a5a506b1424bb1a9eff97c8c469e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs
new file mode 100644
index 0000000..dd4f0a1
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs
@@ -0,0 +1,222 @@
+using Entity;
+using Entity.EntityData;
+
+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;
+ }
+
+ #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);
+ }
+
+ #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);
+ }
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs.meta b/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs.meta
new file mode 100644
index 0000000..2aadcb6
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9a3f6ab748524f8f8ab6655e294889f1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs
new file mode 100644
index 0000000..60af9b3
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs
@@ -0,0 +1,158 @@
+using CustomDebugger;
+using Unity.Collections;
+using UnityEngine;
+
+namespace Simulation
+{
+ public sealed partial class SimulationWorld
+ {
+ public bool TryGetNearestEnemyEntityId(Vector3 origin, float maxSqrRange, out int enemyEntityId)
+ {
+ enemyEntityId = 0;
+ if (maxSqrRange <= 0f || _enemies.Count == 0)
+ {
+ return false;
+ }
+
+ if (!_useSimulationMovement)
+ {
+ return false;
+ }
+
+ BuildEnemyTargetSpatialIndexIfNeeded();
+
+ float cellSize = GetTargetSelectionCellSize();
+ int centerCellX = ToCell(origin.x, cellSize);
+ int centerCellZ = ToCell(origin.z, cellSize);
+ float range = Mathf.Sqrt(maxSqrRange);
+ int queryRange = Mathf.Max(1, Mathf.CeilToInt(range / cellSize));
+
+ float minSqrDistance = maxSqrRange;
+ bool found = false;
+
+ using (CustomProfilerMarker.TargetSelection_QueryNeighbors.Auto())
+ {
+ for (int dx = -queryRange; dx <= queryRange; dx++)
+ {
+ for (int dz = -queryRange; dz <= queryRange; dz++)
+ {
+ long key = CellKey(centerCellX + dx, centerCellZ + dz);
+ if (!_enemyTargetBuckets.TryGetFirstValue(key, out int enemyIndex,
+ out NativeParallelMultiHashMapIterator iterator))
+ {
+ continue;
+ }
+
+ do
+ {
+ if (enemyIndex < 0 || enemyIndex >= _enemies.Count)
+ {
+ continue;
+ }
+
+ EnemySimData enemy = _enemies[enemyIndex];
+ Vector3 delta = enemy.Position - origin;
+ delta.y = 0f;
+ float sqrDistance = delta.sqrMagnitude;
+ if (sqrDistance >= minSqrDistance)
+ {
+ continue;
+ }
+
+ minSqrDistance = sqrDistance;
+ enemyEntityId = enemy.EntityId;
+ found = true;
+ } while (_enemyTargetBuckets.TryGetNextValue(out enemyIndex, ref iterator));
+ }
+ }
+ }
+
+ return found;
+ }
+
+ private void InitializeEnemyTargetSpatialIndex()
+ {
+ if (_enemyTargetBuckets.IsCreated)
+ {
+ return;
+ }
+
+ _enemyTargetBuckets = new NativeParallelMultiHashMap(256, Allocator.Persistent);
+ _enemyTargetBucketsDirty = true;
+ }
+
+ private void DisposeEnemyTargetSpatialIndex()
+ {
+ if (_enemyTargetBuckets.IsCreated)
+ {
+ _enemyTargetBuckets.Dispose();
+ }
+
+ _enemyTargetBuckets = default;
+ _enemyTargetBucketsDirty = true;
+ }
+
+ private void ClearEnemyTargetSpatialIndex()
+ {
+ if (_enemyTargetBuckets.IsCreated)
+ {
+ _enemyTargetBuckets.Clear();
+ }
+
+ _enemyTargetBucketsDirty = true;
+ }
+
+ private void MarkEnemyTargetSpatialIndexDirty()
+ {
+ _enemyTargetBucketsDirty = true;
+ }
+
+ private void BuildEnemyTargetSpatialIndexIfNeeded()
+ {
+ InitializeEnemyTargetSpatialIndex();
+
+ if (!_enemyTargetBucketsDirty)
+ {
+ return;
+ }
+
+ using (CustomProfilerMarker.TargetSelection_BuildBuckets.Auto())
+ {
+ int enemyCount = _enemies.Count;
+ int desiredCapacity = Mathf.Max(256, enemyCount * 2 + 1);
+ if (_enemyTargetBuckets.Capacity < desiredCapacity)
+ {
+ _enemyTargetBuckets.Capacity = desiredCapacity;
+ }
+
+ _enemyTargetBuckets.Clear();
+ float cellSize = GetTargetSelectionCellSize();
+
+ for (int i = 0; i < enemyCount; i++)
+ {
+ EnemySimData enemy = _enemies[i];
+ int cellX = ToCell(enemy.Position.x, cellSize);
+ int cellZ = ToCell(enemy.Position.z, cellSize);
+ _enemyTargetBuckets.Add(CellKey(cellX, cellZ), i);
+ }
+ }
+
+ _enemyTargetBucketsDirty = false;
+ }
+
+ private float GetTargetSelectionCellSize()
+ {
+ return Mathf.Max(0.1f, _targetSelectionCellSize);
+ }
+
+ private static int ToCell(float value, float cellSize)
+ {
+ return Mathf.FloorToInt(value / cellSize);
+ }
+
+ private static long CellKey(int x, int z)
+ {
+ return ((long)x << 32) ^ (uint)z;
+ }
+ }
+}
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs.meta b/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs.meta
new file mode 100644
index 0000000..22df1e5
--- /dev/null
+++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b327174958354540a46e8685f2272cb0
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs
index c263698..a200ee1 100644
--- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs
+++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs
@@ -1,9 +1,5 @@
using System.Collections.Generic;
-using Components;
using CustomDebugger;
-using CustomUtility;
-using Entity;
-using Entity.EntityData;
using UnityEngine;
using UnityGameFramework.Runtime;
@@ -11,252 +7,63 @@ namespace Simulation
{
public sealed partial class SimulationWorld : GameFrameworkComponent
{
+ // Partial layout:
+ // - SimulationWorld.cs: 核心状态、常量和 Unity 生命周期入口点。
+ // - SimulationWorld.RuntimeModules.cs: 运行时域对象、配置和状态代理。
+ // - SimulationWorld.SimEntityState.cs: 模拟状态的增删改查和生命周期注册。
+ // - SimulationWorld.EntityToSimData.cs: Unity 实体到 sim data 的初始化适配。
+ // - SimulationWorld.EntitySync.cs: GameFramework 实体 show/hide 事件桥。
+ // - SimulationWorld.TargetSelectionSpatialIndex.cs: 最近敌空间索引查询。
+ // - Presentation/SimulationWorld.TransformSync.cs: late-update transform 同步桥。
+ // - Presentation/SimulationWorld.HitPresentation.cs: 投射物命中事件表现桥。
+ // - DataChannel/SimulationWorld.JobDataChannel.cs: Job 通道共享字段、常量和运行时状态。
+ // - DataChannel/SimulationWorld.JobDataLifecycle.cs: Native 通道初始化、清理和 clear。
+ // - DataChannel/SimulationWorld.JobDataConversion.cs: sim/job 数据转换与输入输出缓冲准备。
+ // - DataChannel/SimulationWorld.CollisionTransient.cs: 碰撞临时通道和运行时统计。
+ // - DataChannel/SimulationWorld.EnemySeparationTemporal.cs: 敌人分离的帧间临时状态。
+ // - DataChannel/SimulationWorld.JobOutputCommit.cs: Job 输出回写主容器。
+ // - Jobs/SimulationWorld.EnemyJobs.cs: 模拟通道 编排 + 敌人移动/分离 顺序执行
+ // - Jobs/SimulationWorld.ProjectileJobs.cs: 投射物移动与回收
+ // - Jobs/SimulationWorld.CollisionPipeline.cs: 碰撞管线共享配置和状态
+ // - Jobs/SimulationWorld.CollisionRequests.cs: area/sector 请求缓冲
+ // - Jobs/SimulationWorld.CollisionBroadPhase.cs: broad-phase 候选构建和 Job 调度
+ // - Jobs/SimulationWorld.CollisionResolve.cs: 主线程命中结算与 area settle
+ // - Jobs/SimulationWorld.CollisionPresentation.cs: 命中表现事件和实体/impact 解析
+ // - JobStruct/*.cs: burst job 内核和面向 job 的数据结构
private const float DefaultAttackRange = 1f;
private const int EnemyStateIdle = 0;
private const int EnemyStateChasing = 1;
private const int EnemyStateInAttackRange = 2;
-
- 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;
- }
+ private const int ProjectileStateActive = 0;
+ private const int ProjectileStateExpired = 1;
- [SerializeField] private bool _useSimulationMovement;
+ [Header("模拟世界全局设置")] [Tooltip("是否启用世界模拟")] [SerializeField]
+ private bool _useSimulationMovement = true;
private EntitySync _entitySync;
- private Presentation _presentation;
-
- private readonly List _enemies = new List();
- private readonly List _projectiles = new List();
- private readonly List _pickups = new List();
- private readonly List _enemySeparationAgents = new List();
- private readonly List _enemyTickWorkItems = new List();
-
- private EntityBinding EnemyBinding { get; } = new EntityBinding();
- private EntityBinding ProjectileBinding { get; } = new EntityBinding();
- private EntityBinding PickupBinding { get; } = new EntityBinding();
+ private TransformSync _transformSync;
+ private HitPresentation _hitPresentation;
public IReadOnlyList Enemies => _enemies;
public IReadOnlyList Projectiles => _projectiles;
public IReadOnlyList Pickups => _pickups;
public bool UseSimulationMovement => _useSimulationMovement;
- public void SetUseSimulationMovement(bool enabled)
- {
- _useSimulationMovement = enabled;
- }
+ #region Lifecycle
protected override void Awake()
{
base.Awake();
_entitySync = new EntitySync(this);
- _presentation = new Presentation(this);
+ _transformSync = new TransformSync(this);
+ _hitPresentation = new HitPresentation(this);
+ InitializeJobDataChannels();
}
private void Start()
{
_entitySync?.OnStart();
- }
-
- private void OnDestroy()
- {
- _entitySync?.OnDestroy();
- _entitySync = null;
- _presentation = null;
- }
-
- private void LateUpdate()
- {
- _presentation?.OnLateUpdate();
- }
-
- private int AddEnemy(in EnemySimData simData)
- {
- int simulationIndex = _enemies.Count;
- _enemies.Add(simData);
- EnemyBinding.Bind(simData.EntityId, simulationIndex);
- return simulationIndex;
- }
-
- private int UpsertEnemy(in EnemySimData simData)
- {
- if (!EnemyBinding.TryGetSimulationIndex(simData.EntityId, out int simulationIndex))
- {
- return AddEnemy(simData);
- }
-
- _enemies[simulationIndex] = simData;
- return simulationIndex;
- }
-
- private bool RemoveEnemyByEntityId(int entityId)
- {
- if (!EnemyBinding.TryGetSimulationIndex(entityId, out int simulationIndex))
- {
- return false;
- }
-
- int lastIndex = _enemies.Count - 1;
- if (simulationIndex != lastIndex)
- {
- EnemySimData movedData = _enemies[lastIndex];
- _enemies[simulationIndex] = movedData;
- EnemyBinding.RemapIndex(movedData.EntityId, simulationIndex);
- }
-
- _enemies.RemoveAt(lastIndex);
- EnemyBinding.UnbindByEntityId(entityId);
- 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)
- {
- if (projectileEntity == null || projectileEntity.CachedTransform == null)
- {
- return;
- }
-
- UpsertProjectile(CreateProjectileInitialSimData(projectileEntity));
- }
-
- private void UnregisterProjectileLifecycle(int entityId)
- {
- RemoveProjectileByEntityId(entityId);
- }
-
- private int AddPickup(in PickupSimData simData)
- {
- int simulationIndex = _pickups.Count;
- _pickups.Add(simData);
- PickupBinding.Bind(simData.EntityId, simulationIndex);
- return simulationIndex;
- }
-
- private int UpsertPickup(in PickupSimData simData)
- {
- if (!PickupBinding.TryGetSimulationIndex(simData.EntityId, out int simulationIndex))
- {
- return AddPickup(simData);
- }
-
- _pickups[simulationIndex] = simData;
- return simulationIndex;
- }
-
- private bool RemovePickupByEntityId(int entityId)
- {
- if (!PickupBinding.TryGetSimulationIndex(entityId, out int simulationIndex))
- {
- return false;
- }
-
- int lastIndex = _pickups.Count - 1;
- if (simulationIndex != lastIndex)
- {
- PickupSimData movedData = _pickups[lastIndex];
- _pickups[simulationIndex] = movedData;
- PickupBinding.RemapIndex(movedData.EntityId, simulationIndex);
- }
-
- _pickups.RemoveAt(lastIndex);
- PickupBinding.UnbindByEntityId(entityId);
- return true;
- }
-
- private void RegisterPickupLifecycle(EntityBase pickupEntity)
- {
- if (pickupEntity == null || pickupEntity.CachedTransform == null)
- {
- return;
- }
-
- UpsertPickup(CreatePickupInitialSimData(pickupEntity));
- }
-
- private void UnregisterPickupLifecycle(int entityId)
- {
- RemovePickupByEntityId(entityId);
+ _hitPresentation?.OnStart();
}
public void Tick(in SimulationTickContext context)
@@ -268,241 +75,25 @@ namespace Simulation
using (CustomProfilerMarker.TickEnemies.Auto())
{
- TickEnemies(in context);
+ TickSimulationPipeline(in context);
}
}
- public void Clear()
+ private void OnDestroy()
{
- _enemies.Clear();
- _projectiles.Clear();
- _pickups.Clear();
- _enemySeparationAgents.Clear();
- _enemyTickWorkItems.Clear();
-
- EnemyBinding.Clear();
- ProjectileBinding.Clear();
- PickupBinding.Clear();
+ _hitPresentation?.OnDestroy();
+ _entitySync?.OnDestroy();
+ _entitySync = null;
+ _transformSync = null;
+ _hitPresentation = null;
+ DisposeJobDataChannels();
}
- private void TickEnemies(in SimulationTickContext context)
+ private void LateUpdate()
{
- if (_enemies.Count == 0 || context.DeltaTime <= 0f)
- {
- return;
- }
-
- Vector3 playerPosition = context.PlayerPosition;
- playerPosition.y = 0f;
-
- using (CustomProfilerMarker.TickEnemies_BuildInput.Auto())
- {
- BuildEnemyTickInput(in playerPosition);
- }
-
- using (CustomProfilerMarker.TickEnemies_MoveSeparation.Auto())
- {
- MoveAndSeparateEnemies(context.DeltaTime);
- }
-
- using (CustomProfilerMarker.TickEnemies_StateUpdate.Auto())
- {
- UpdateEnemyStates();
- }
-
- using (CustomProfilerMarker.TickEnemies_WriteBack.Auto())
- {
- WriteBackEnemyTickResults();
- }
+ _transformSync?.OnLateUpdate();
}
- private void BuildEnemyTickInput(in Vector3 playerPosition)
- {
- _enemyTickWorkItems.Clear();
- _enemySeparationAgents.Clear();
-
- for (int i = 0; i < _enemies.Count; i++)
- {
- EnemySimData enemy = _enemies[i];
-
- Vector3 currentPosition = enemy.Position;
- Vector3 horizontalPosition = currentPosition;
- horizontalPosition.y = 0f;
- Vector3 toPlayer = playerPosition - horizontalPosition;
- float sqrDistance = toPlayer.sqrMagnitude;
-
- float attackRange = enemy.AttackRange > 0f ? enemy.AttackRange : DefaultAttackRange;
- float attackRangeSqr = attackRange * attackRange;
- bool isInAttackRange = sqrDistance <= attackRangeSqr;
- bool canChase = !isInAttackRange && enemy.Speed > 0f && sqrDistance > float.Epsilon;
-
- EnemyTickWorkItem workItem = new EnemyTickWorkItem
- {
- EntityId = enemy.EntityId,
- CurrentPosition = currentPosition,
- DesiredPosition = currentPosition,
- ToPlayer = toPlayer,
- Forward = enemy.Forward,
- Rotation = enemy.Rotation,
- SqrDistanceToPlayer = sqrDistance,
- AttackRangeSqr = attackRangeSqr,
- Speed = enemy.Speed,
- SeparationIterations = enemy.SeparationIterations > 0 ? enemy.SeparationIterations : 1,
- AvoidEnemyOverlap = enemy.AvoidEnemyOverlap,
- CanChase = canChase,
- HasRotationUpdate = false,
- NextState = EnemyStateIdle
- };
- _enemyTickWorkItems.Add(workItem);
-
- if (!enemy.AvoidEnemyOverlap) continue;
-
- _enemySeparationAgents.Add(new EnemySeparationAgent
- {
- AgentId = enemy.EntityId,
- Position = horizontalPosition,
- Radius = enemy.EnemyBodyRadius > 0f ? enemy.EnemyBodyRadius : 0.45f
- });
- }
- }
-
- private void MoveAndSeparateEnemies(float deltaTime)
- {
- EnemySeparationSolverProvider.SetSimulationAgents(_enemySeparationAgents);
-
- for (int i = 0; i < _enemyTickWorkItems.Count; i++)
- {
- EnemyTickWorkItem workItem = _enemyTickWorkItems[i];
- if (!workItem.CanChase)
- {
- _enemyTickWorkItems[i] = workItem;
- continue;
- }
-
- Vector3 forward = workItem.ToPlayer.normalized;
- Vector3 desiredPosition = workItem.CurrentPosition + forward * workItem.Speed * deltaTime;
-
- if (workItem.AvoidEnemyOverlap)
- {
- desiredPosition = EnemySeparationSolverProvider.ResolveSimulation(
- workItem.EntityId,
- desiredPosition,
- forward,
- workItem.SeparationIterations);
- }
-
- workItem.Forward = forward;
- workItem.DesiredPosition = desiredPosition;
-
- if (forward.sqrMagnitude > float.Epsilon)
- {
- workItem.Rotation = Quaternion.LookRotation(forward, Vector3.up);
- workItem.HasRotationUpdate = true;
- }
-
- _enemyTickWorkItems[i] = workItem;
- }
- }
-
- private void UpdateEnemyStates()
- {
- for (int i = 0; i < _enemyTickWorkItems.Count; i++)
- {
- EnemyTickWorkItem workItem = _enemyTickWorkItems[i];
-
- if (workItem.SqrDistanceToPlayer <= workItem.AttackRangeSqr)
- {
- workItem.NextState = EnemyStateInAttackRange;
- }
- else if (workItem.CanChase)
- {
- workItem.NextState = EnemyStateChasing;
- }
- else
- {
- workItem.NextState = EnemyStateIdle;
- }
-
- _enemyTickWorkItems[i] = workItem;
- }
- }
-
- private void WriteBackEnemyTickResults()
- {
- 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;
- }
- }
-
- enemy.State = workItem.NextState;
- _enemies[i] = enemy;
- }
- }
-
- private static EnemySimData CreateEnemyInitialSimData(EnemyBase enemy, EnemyData enemyData)
- {
- Transform enemyTransform = enemy.CachedTransform;
- MovementComponent movementComponent = enemy.GetComponent();
-
- float speed = 0f;
- if (enemyData != null)
- {
- speed = enemyData.SpeedBase;
- }
- else if (movementComponent != null)
- {
- speed = movementComponent.Speed;
- }
-
- return new EnemySimData
- {
- EntityId = enemy.Id,
- Position = enemyTransform.position,
- Forward = enemyTransform.forward,
- Rotation = enemyTransform.rotation,
- Speed = speed,
- AttackRange = 1f,
- 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)
- {
- return new ProjectileSimData
- {
- EntityId = projectileEntity.Id,
- OwnerEntityId = 0,
- Position = projectileEntity.CachedTransform.position,
- Forward = projectileEntity.CachedTransform.forward,
- Speed = 0f,
- RemainingLifetime = 0f,
- State = 0
- };
- }
+ #endregion
}
}
diff --git a/Assets/GameMain/Scripts/UI/GameScene/Controller/DisplayItemInfoFormController.cs b/Assets/GameMain/Scripts/UI/GameScene/Controller/DisplayItemInfoFormController.cs
index 5f0161f..47019ee 100644
--- a/Assets/GameMain/Scripts/UI/GameScene/Controller/DisplayItemInfoFormController.cs
+++ b/Assets/GameMain/Scripts/UI/GameScene/Controller/DisplayItemInfoFormController.cs
@@ -1,6 +1,7 @@
using CustomEvent;
using Definition.Enum;
using GameFramework.Event;
+using UnityEngine;
using UnityGameFramework.Runtime;
namespace UI
@@ -38,6 +39,7 @@ namespace UI
{
if (rawData == null)
{
+ Log.Error("DisplayItemInfoFormController.BuildContext() rawData is null.");
return null;
}
@@ -92,25 +94,65 @@ namespace UI
}
}
+ private bool IsCurrentFormSender(object sender)
+ {
+ if (sender is DisplayItemInfoForm displayItemInfoForm)
+ {
+ return displayItemInfoForm == Form;
+ }
+
+ if (sender is Component component && Form != null)
+ {
+ return component.transform.IsChildOf(Form.transform);
+ }
+
+ return false;
+ }
+
#region Event Handlers
private void DisplayItemInfoLock(object sender, GameEventArgs e)
{
- if (!(e is DisplayItemInfoLockEventArgs)) return;
+ if (!(e is DisplayItemInfoLockEventArgs))
+ {
+ return;
+ }
+
+ if (Context == null)
+ {
+ Log.Error("DisplayItemInfoFormController.DisplayItemInfoLock() Context is null.");
+ return;
+ }
+
+ if (Form == null)
+ {
+ Log.Error("DisplayItemInfoFormController.DisplayItemInfoLock() Form is null.");
+ return;
+ }
_locked = true;
}
private void DisplayItemInfoHide(object sender, GameEventArgs e)
{
- if (!(e is DisplayItemInfoHideEventArgs args)) return;
-
- if (!args.Force && _locked && sender is not DisplayItemInfoForm) return;
+ if (!(e is DisplayItemInfoHideEventArgs args))
+ {
+ return;
+ }
+
+ if (args.Force)
+ {
+ GameEntry.UIRouter.CloseUI(UIFormType.DisplayItemInfoForm);
+ _locked = false;
+ return;
+ }
+ if (_locked && !args.Force) return;
+
GameEntry.UIRouter.CloseUI(UIFormType.DisplayItemInfoForm);
_locked = false;
}
#endregion
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/Scripts/UI/GameScene/Controller/LevelUpFormController.cs b/Assets/GameMain/Scripts/UI/GameScene/Controller/LevelUpFormController.cs
index dc6fda8..495a029 100644
--- a/Assets/GameMain/Scripts/UI/GameScene/Controller/LevelUpFormController.cs
+++ b/Assets/GameMain/Scripts/UI/GameScene/Controller/LevelUpFormController.cs
@@ -32,8 +32,15 @@ namespace UI
private static LevelUpFormContext BuildContext(LevelUpFormRawData rawData)
{
- if (rawData == null || rawData.Rewards == null)
+ if (rawData == null)
{
+ Log.Error("LevelUpFormController.BuildContext() rawData is null.");
+ return null;
+ }
+
+ if (rawData.Rewards == null)
+ {
+ Log.Error("LevelUpFormController.BuildContext() rewards are null.");
return null;
}
@@ -143,7 +150,7 @@ namespace UI
private void OnRefresh(object sender, GameEventArgs e)
{
- if (!(sender is LevelUpForm))
+ if ((LevelUpForm)sender != Form)
{
return;
}
@@ -158,6 +165,11 @@ namespace UI
private void OnLevelUpPropSelected(object sender, GameEventArgs e)
{
+ if ((LevelUpForm)sender != Form)
+ {
+ return;
+ }
+
if (!(e is LevelUpPropSelectedEventArgs args))
{
return;
diff --git a/Assets/GameMain/Scripts/UI/GameScene/Controller/ShopFormController.cs b/Assets/GameMain/Scripts/UI/GameScene/Controller/ShopFormController.cs
index f305048..97924b3 100644
--- a/Assets/GameMain/Scripts/UI/GameScene/Controller/ShopFormController.cs
+++ b/Assets/GameMain/Scripts/UI/GameScene/Controller/ShopFormController.cs
@@ -2,7 +2,6 @@ using System.Collections.Generic;
using CustomEvent;
using Definition.DataStruct;
using Definition.Enum;
-using Entity;
using CustomUtility;
using Entity.Weapon;
using GameFramework.Event;
@@ -47,6 +46,7 @@ namespace UI
{
if (rawData == null)
{
+ Log.Error("ShopFormController.BuildContext() rawData is null.");
return null;
}
@@ -171,8 +171,15 @@ namespace UI
private static void AppendDisplayItemContext(DisplayListAreaContext listContext, DisplayItemContext newItem)
{
- if (listContext == null || newItem == null)
+ if (listContext == null)
{
+ Log.Error("ShopFormController.AppendDisplayItemContext() listContext is null.");
+ return;
+ }
+
+ if (newItem == null)
+ {
+ Log.Warning("ShopFormController.AppendDisplayItemContext() newItem is null.");
return;
}
@@ -192,6 +199,12 @@ namespace UI
#region UI Methods
+ public override void CloseUI()
+ {
+ base.CloseUI();
+ GameEntry.Event.Fire(this, DisplayItemInfoHideEventArgs.Create(true));
+ }
+
public int? OpenUI(ShopFormRawData rawData)
{
ShopFormContext context = BuildContext(rawData);
@@ -243,8 +256,15 @@ namespace UI
private void RefreshGoodsItems(ShopRefreshResult result)
{
- if (Context == null || result == null)
+ if (result == null)
{
+ Log.Error("ShopFormController.RefreshGoodsItems() result is null.");
+ return;
+ }
+
+ if (Context == null)
+ {
+ Log.Error("ShopFormController.RefreshGoodsItems() Context is null.");
return;
}
@@ -253,6 +273,7 @@ namespace UI
if (Form == null)
{
+ Log.Error("ShopFormController.RefreshGoodsItems() Form is null.");
return;
}
@@ -262,8 +283,15 @@ namespace UI
private void ApplyGoodsPurchased(ShopPurchaseResult result)
{
- if (Context == null || result == null)
+ if (result == null)
{
+ Log.Error("ShopFormController.ApplyGoodsPurchased() result is null.");
+ return;
+ }
+
+ if (Context == null)
+ {
+ Log.Error("ShopFormController.ApplyGoodsPurchased() Context is null.");
return;
}
@@ -287,6 +315,104 @@ namespace UI
Form?.ApplyGoodsPurchased(result.GoodsIndex, result.DisplayItem);
}
+ private bool IsCurrentFormEventSender(object sender)
+ {
+ if (sender is ShopForm shopForm)
+ {
+ return shopForm == Form;
+ }
+
+ if (sender is Component component && Form != null)
+ {
+ return component.transform.IsChildOf(Form.transform);
+ }
+
+ return false;
+ }
+
+ private bool TryGetWeaponInfoRawData(int index, Vector3 targetPos, out DisplayItemInfoFormRawData rawData)
+ {
+ rawData = null;
+
+ if (_rawData?.WeaponItems == null)
+ {
+ Log.Error("ShopFormController.TryGetWeaponInfoRawData() WeaponItems is null.");
+ return false;
+ }
+
+ if (Context == null)
+ {
+ Log.Error("ShopFormController.TryGetWeaponInfoRawData() Context is null.");
+ return false;
+ }
+
+ if (index < 0 || index >= _rawData.WeaponItems.Count)
+ {
+ Log.Error($"ShopFormController.TryGetWeaponInfoRawData() invalid weapon index: {index}.");
+ return false;
+ }
+
+ WeaponBase weapon = _rawData.WeaponItems[index];
+ if (weapon?.WeaponData == null)
+ {
+ Log.Error($"ShopFormController.TryGetWeaponInfoRawData() weapon data is null at index {index}.");
+ return false;
+ }
+
+ var weaponData = weapon.WeaponData;
+ rawData = new DisplayItemInfoFormRawData
+ {
+ TargetPos = targetPos,
+ Index = index,
+ IconAssetName = weaponData.IconAssetName,
+ Title = weaponData.Title,
+ Rarity = weaponData.Rarity,
+ TypeText = "武器",
+ Description = ItemDescUtility.CreateWeaponDescription(weaponData),
+ Price = Mathf.FloorToInt(weaponData.Price * Context.WeaponRecycleRate),
+ IsWeapon = true
+ };
+ return true;
+ }
+
+ private bool TryGetPropInfoRawData(int index, Vector3 targetPos, out DisplayItemInfoFormRawData rawData)
+ {
+ rawData = null;
+
+ if (_rawData?.PropItems == null)
+ {
+ Log.Error("ShopFormController.TryGetPropInfoRawData() PropItems is null.");
+ return false;
+ }
+
+ if (index < 0 || index >= _rawData.PropItems.Count)
+ {
+ Log.Error($"ShopFormController.TryGetPropInfoRawData() invalid prop index: {index}.");
+ return false;
+ }
+
+ PropItem propItem = _rawData.PropItems[index];
+ if (propItem == null)
+ {
+ Log.Error($"ShopFormController.TryGetPropInfoRawData() prop item is null at index {index}.");
+ return false;
+ }
+
+ rawData = new DisplayItemInfoFormRawData
+ {
+ TargetPos = targetPos,
+ Index = index,
+ IconAssetName = propItem.IconAssetName,
+ Title = propItem.Title,
+ Rarity = propItem.Rarity,
+ TypeText = "道具",
+ Description = ItemDescUtility.CreatePropDescription(propItem),
+ Price = 0,
+ IsWeapon = false
+ };
+ return true;
+ }
+
#endregion
#region Event Handlers
@@ -350,32 +476,30 @@ namespace UI
private void DisplayItemShow(object sender, GameEventArgs e)
{
- if (!(e is DisplayItemShowEventArgs args) || _rawData == null) return;
-
- DisplayItemInfoFormRawData rawData = new();
- rawData.TargetPos = args.TargetPos;
- rawData.Index = args.Index;
- if (args.IsWeapon)
+ if (!(e is DisplayItemShowEventArgs args))
{
- var weaponData = _rawData.WeaponItems[args.Index].WeaponData;
- rawData.IconAssetName = weaponData.IconAssetName;
- rawData.Title = weaponData.Title;
- rawData.Rarity = weaponData.Rarity;
- rawData.TypeText = "武器";
- rawData.Description = ItemDescUtility.CreateWeaponDescription(weaponData);
- rawData.Price = Mathf.FloorToInt(weaponData.Price * Context.WeaponRecycleRate);
- rawData.IsWeapon = true;
+ return;
}
- else
+
+ if (!IsCurrentFormEventSender(sender))
{
- var propItem = _rawData.PropItems[args.Index];
- rawData.IconAssetName = propItem.IconAssetName;
- rawData.Title = propItem.Title;
- rawData.Rarity = propItem.Rarity;
- rawData.TypeText = "道具";
- rawData.Description = ItemDescUtility.CreatePropDescription(propItem);
- rawData.Price = 0;
- rawData.IsWeapon = false;
+ return;
+ }
+
+ if (_rawData == null)
+ {
+ Log.Error("ShopFormController.DisplayItemShow() _rawData is null.");
+ return;
+ }
+
+ DisplayItemInfoFormRawData rawData;
+ bool success = args.IsWeapon
+ ? TryGetWeaponInfoRawData(args.Index, args.TargetPos, out rawData)
+ : TryGetPropInfoRawData(args.Index, args.TargetPos, out rawData);
+
+ if (!success)
+ {
+ return;
}
GameEntry.UIRouter.OpenUI(UIFormType.DisplayItemInfoForm, rawData);
@@ -383,10 +507,19 @@ namespace UI
private void WeaponRecycle(object sender, GameEventArgs e)
{
- if (!(e is ShopWeaponRecycleEventArgs args)) return;
+ if (!(e is ShopWeaponRecycleEventArgs args))
+ {
+ return;
+ }
+
+ if (sender is not DisplayItemInfoForm)
+ {
+ return;
+ }
if (_useCase == null || Context == null)
{
+ Log.Error("ShopFormController.WeaponRecycle() controller state is invalid.");
return;
}
@@ -408,4 +541,4 @@ namespace UI
#endregion
}
-}
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs b/Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs
index 3d41060..32d6ac8 100644
--- a/Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs
+++ b/Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs
@@ -395,6 +395,10 @@ namespace UI
return new WeaponHandgunData(entityId, ownerId, ownerCamp);
case WeaponType.WeaponSlash:
return new WeaponSlashData(entityId, ownerId, ownerCamp);
+ case WeaponType.WeaponLightning:
+ return new WeaponLightningData(entityId, ownerId, ownerCamp);
+ case WeaponType.WeaponLance:
+ return new WeaponLanceData(entityId, ownerId, ownerCamp);
default:
return null;
}
diff --git a/Assets/GameMain/Scripts/UI/GameScene/View/DisplayItemInfoForm.cs b/Assets/GameMain/Scripts/UI/GameScene/View/DisplayItemInfoForm.cs
index 0e49b07..0b15368 100644
--- a/Assets/GameMain/Scripts/UI/GameScene/View/DisplayItemInfoForm.cs
+++ b/Assets/GameMain/Scripts/UI/GameScene/View/DisplayItemInfoForm.cs
@@ -199,7 +199,7 @@ namespace UI
public void OnCancelButtonClick()
{
- GameEntry.Event.Fire(this, DisplayItemInfoHideEventArgs.Create());
+ GameEntry.Event.Fire(this, DisplayItemInfoHideEventArgs.Create(true));
}
}
}
diff --git a/Assets/GameMain/Scripts/UI/MenuScene/Controller/SelectRoleFormController.cs b/Assets/GameMain/Scripts/UI/MenuScene/Controller/SelectRoleFormController.cs
index c31e86f..8c55efe 100644
--- a/Assets/GameMain/Scripts/UI/MenuScene/Controller/SelectRoleFormController.cs
+++ b/Assets/GameMain/Scripts/UI/MenuScene/Controller/SelectRoleFormController.cs
@@ -1,6 +1,7 @@
using CustomEvent;
using Definition.Enum;
using GameFramework.Event;
+using UnityEngine;
using UnityGameFramework.Runtime;
namespace UI
@@ -34,6 +35,7 @@ namespace UI
{
if (rawData == null)
{
+ Log.Error("SelectRoleFormController.BuildContext() rawData is null.");
return null;
}
@@ -107,17 +109,35 @@ namespace UI
public void UpdateShowRole(RolePropertyAreaContext rolePropertyAreaContext)
{
- if (Context != null)
+ if (Context == null)
{
- Context.RolePropertyAreaContext = rolePropertyAreaContext;
+ Log.Error("SelectRoleFormController.UpdateShowRole() Context is null.");
+ return;
}
+ Context.RolePropertyAreaContext = rolePropertyAreaContext;
+
Form?.UpdateShowRole(rolePropertyAreaContext);
}
+ private bool IsCurrentFormEventSender(object sender)
+ {
+ if (sender is SelectRoleForm selectRoleForm)
+ {
+ return selectRoleForm == Form;
+ }
+
+ if (sender is Component component && Form != null)
+ {
+ return component.transform.IsChildOf(Form.transform);
+ }
+
+ return false;
+ }
+
private void OnMenuSelectRoleReturn(object sender, GameEventArgs e)
{
- if (!(sender is SelectRoleForm) || !(e is MenuSelectRoleReturnEventArgs))
+ if ((SelectRoleForm)sender != Form || !(e is MenuSelectRoleReturnEventArgs))
{
return;
}
@@ -132,10 +152,22 @@ namespace UI
return;
}
- SelectRoleFormRawData rawData = _useCase != null ? _useCase.SelectRole(args.RoleId) : null;
+ if (!IsCurrentFormEventSender(sender))
+ {
+ return;
+ }
+
+ if (_useCase == null)
+ {
+ Log.Error("SelectRoleFormController.OnMenuSelectRoleSelected() useCase is null.");
+ return;
+ }
+
+ SelectRoleFormRawData rawData = _useCase.SelectRole(args.RoleId);
SelectRoleFormContext context = BuildContext(rawData);
if (context == null)
{
+ Log.Error("SelectRoleFormController.OnMenuSelectRoleSelected() context build failed.");
return;
}
@@ -150,7 +182,18 @@ namespace UI
return;
}
- _useCase?.ConfirmSelectedRole();
+ if (!IsCurrentFormEventSender(sender))
+ {
+ return;
+ }
+
+ if (_useCase == null)
+ {
+ Log.Error("SelectRoleFormController.OnMenuSelectRoleConfirm() useCase is null.");
+ return;
+ }
+
+ _useCase.ConfirmSelectedRole();
}
}
-}
+}
\ No newline at end of file
diff --git a/Assets/GameMain/Scripts/Utility/AIUtility.cs b/Assets/GameMain/Scripts/Utility/AIUtility.cs
index 04980b0..c118872 100644
--- a/Assets/GameMain/Scripts/Utility/AIUtility.cs
+++ b/Assets/GameMain/Scripts/Utility/AIUtility.cs
@@ -159,6 +159,11 @@ namespace CustomUtility
}
public static void PerformCollision(TargetableObject entity, EntityBase other)
+ {
+ PerformCollision(entity, other, false);
+ }
+
+ public static void PerformCollision(TargetableObject entity, EntityBase other, bool ignoreRuntimeState)
{
if (entity == null || other == null)
{
@@ -201,10 +206,30 @@ namespace CustomUtility
// return;
// }
+ EnemyProjectile enemyProjectile = other as EnemyProjectile;
+ if (enemyProjectile != null)
+ {
+ if (!ignoreRuntimeState && !enemyProjectile.IsActive) return;
+
+ ImpactData entityImpactData = entity.GetImpactData();
+ ImpactData projectileImpactData = enemyProjectile.GetImpactData();
+ if (GetRelation(entityImpactData.Camp, projectileImpactData.Camp) == RelationType.Friendly)
+ {
+ return;
+ }
+
+ int entityDamageHP = CalcDamageHP(projectileImpactData.AttackBase, projectileImpactData.AttackStat,
+ entityImpactData.DefenseStat, entityImpactData.DodgeStat);
+
+ entity.ApplyDamage(enemyProjectile, entityDamageHP);
+ enemyProjectile.Expire();
+ return;
+ }
+
WeaponBase weapon = other as WeaponBase;
if (weapon != null)
{
- if (!weapon.IsAttacking) return;
+ if (!ignoreRuntimeState && !weapon.IsAttacking) return;
ImpactData entityImpactData = entity.GetImpactData();
ImpactData weaponImpactData = weapon.GetImpactData();
if (GetRelation(entityImpactData.Camp, weaponImpactData.Camp) == RelationType.Friendly)
@@ -220,13 +245,13 @@ namespace CustomUtility
}
}
- private static int CalcDamageHP(int attack, StatProperty attackStat, StatProperty defenseStat,
+ public static int CalcDamageHP(int attack, StatProperty attackStat, StatProperty defenseStat,
StatProperty dodgeStat)
{
// 1. 处理闪避(闪避率取值 (0, 0.9),不允许拉满闪避)
if (dodgeStat != null)
{
- if (Random.value < Mathf.Clamp(dodgeStat.Percent, 0, 0.9f)) return 0;
+ if (Random.value < Mathf.Clamp(dodgeStat.Value, 0, 0.9f)) return 0;
}
// 2. 处理攻击加成 最终伤害 = (基础伤害 + 伤害提升固定值) * 伤害提升率
diff --git a/Assets/GameMain/Scripts/Utility/ItemDescUtility.cs b/Assets/GameMain/Scripts/Utility/ItemDescUtility.cs
index 313a308..3a416ea 100644
--- a/Assets/GameMain/Scripts/Utility/ItemDescUtility.cs
+++ b/Assets/GameMain/Scripts/Utility/ItemDescUtility.cs
@@ -4,16 +4,26 @@ using DataTable;
using Definition.DataStruct;
using Entity.EntityData;
using Entity.Weapon;
+using System;
using UnityGameFramework.Runtime;
namespace CustomUtility
{
public static class ItemDescUtility
{
- private static readonly Dictionary _paramsDict = new()
+ private static readonly Dictionary _paramsDict = new(StringComparer.OrdinalIgnoreCase)
{
- {"hitradius", "伤害范围"},
- {"sectorangle", "攻击角度"}
+ {"hitHalfWidth", "横向半宽"},
+ {"hitRadius", "攻击半宽"},
+ {"hitHeight", "判定高度"},
+ {"hitCenterYOffset", "判定高度偏移"},
+ {"sectorAngle", "攻击角度"},
+ {"pierceLength", "前戳距离"},
+ {"thrustDistance", "前戳距离(旧)"},
+ {"forwardOffset", "前置偏移"},
+ {"rotateSpeed", "转向速度"},
+ {"attackDuration", "突刺时长"},
+ {"returnDuration", "收枪时长"}
};
public static string CreatePropDescription(StatModifier[] modifiers)
@@ -89,6 +99,11 @@ namespace CustomUtility
sb.Append(modifiersDesc);
}
+ if (@params == null || @params.Count == 0)
+ {
+ return sb.ToString();
+ }
+
foreach (var kvp in @params)
{
if (!_paramsDict.TryGetValue(kvp.Key, out string value))
@@ -102,4 +117,4 @@ namespace CustomUtility
return sb.ToString();
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/GameMain/VampireLike.asmdef b/Assets/GameMain/VampireLike.asmdef
new file mode 100644
index 0000000..ed42c89
--- /dev/null
+++ b/Assets/GameMain/VampireLike.asmdef
@@ -0,0 +1,23 @@
+{
+ "name": "VampireLike",
+ "rootNamespace": "",
+ "references": [
+ "GUID:363c5eb08ff8e6a439b85e37b8c20d96",
+ "GUID:a2d8a19598eca814496b089021d08d60",
+ "GUID:75469ad4d38634e559750d17036d5f7c",
+ "GUID:d8b63aba1907145bea998dd612889d6b",
+ "GUID:6055be8ebefd69e48b49212b09b47b2f",
+ "GUID:e0cd26848372d4e5c891c569017e11f1",
+ "GUID:2665a8d13d1b3f18800f46e256720795",
+ "GUID:fca0f81bc71f1944887dd65f134c54a0"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
\ No newline at end of file
diff --git a/Assets/GameMain/VampireLike.asmdef.meta b/Assets/GameMain/VampireLike.asmdef.meta
new file mode 100644
index 0000000..5147440
--- /dev/null
+++ b/Assets/GameMain/VampireLike.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 47a82ffa13c291447ab895cd0bc251cd
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Launcher.unity b/Assets/Launcher.unity
index 06b4865..d435872 100644
--- a/Assets/Launcher.unity
+++ b/Assets/Launcher.unity
@@ -390,7 +390,7 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 11405216, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_AvailableProcedureTypeNames.Array.size
- value: 12
+ value: 13
objectReference: {fileID: 0}
- target: {fileID: 11405216, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_AvailableProcedureTypeNames.Array.data[0]
@@ -430,15 +430,15 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 11405216, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_AvailableProcedureTypeNames.Array.data[9]
- value: Procedure.ProcedureUpdateResources
+ value: Procedure.ProcedureStressTest
objectReference: {fileID: 0}
- target: {fileID: 11405216, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_AvailableProcedureTypeNames.Array.data[10]
- value: Procedure.ProcedureUpdateVersion
+ value: Procedure.ProcedureUpdateResources
objectReference: {fileID: 0}
- target: {fileID: 11405216, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_AvailableProcedureTypeNames.Array.data[11]
- value: Procedure.ProcedureVerifyResources
+ value: Procedure.ProcedureUpdateVersion
objectReference: {fileID: 0}
- target: {fileID: 11405216, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_AvailableProcedureTypeNames.Array.data[12]
@@ -607,7 +607,7 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 11494652, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_EntityGroups.Array.data[0].m_InstanceCapacity
- value: 16
+ value: 10
objectReference: {fileID: 0}
- target: {fileID: 11494652, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_EntityGroups.Array.data[1].m_InstanceCapacity
@@ -623,7 +623,7 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 11494652, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_EntityGroups.Array.data[4].m_InstanceCapacity
- value: 2
+ value: 1
objectReference: {fileID: 0}
- target: {fileID: 11494652, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_EntityGroups.Array.data[5].m_InstanceCapacity
@@ -719,7 +719,7 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 11499388, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_FrameRate
- value: 60
+ value: 240
objectReference: {fileID: 0}
- target: {fileID: 11499388, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3}
propertyPath: m_EditorLanguage
@@ -848,6 +848,15 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 1d8ada5157a04921a6e543a040e57960, type: 3}
m_Name:
m_EditorClassIdentifier:
+ _showBuffSection: 0
+ _showBattleOverview: 0
+ _showCollisionStats: 0
+ _showSpawnControls: 1
+ _showBattleDurationControls: 1
+ _showSeparationSolverControls: 0
+ _showPlayerWeaponControls: 1
+ _showPlayerHealthControls: 1
+ _showTips: 0
--- !u!1 &513208572
GameObject:
m_ObjectHideFlags: 0
@@ -1426,7 +1435,32 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 8a558ebbc9cb4d94946ac9f4f27914d8, type: 3}
m_Name:
m_EditorClassIdentifier:
+ _enemySeparationCellSize: 0
+ _enemySeparationPushDamping: 0.5
+ _enemySeparationMaxStepScale: 0.5
+ _enemySeparationUseTangentialInAttackRange: 1
+ _enemySeparationPushSmoothing: 0.55
+ _projectileMaxDistanceFromPlayer: 120
+ _projectileMaxVerticalOffsetFromPlayer: 30
+ _projectileHitPresentationEnabled: 1
+ _projectileHitMarkerEnabled: 1
+ _projectileHitMarkerSize: 0.2
+ _projectileHitMarkerYOffset: 1.2
+ _projectileHitMarkerDuration: 0.15
+ _projectileHitMarkerColor: {r: 1, g: 0, b: 0, a: 0.95}
+ _projectileHitEffectEnabled: 0
+ _projectileHitEffectTypeId: 0
_useSimulationMovement: 1
+ _collisionPipelineSettings:
+ ProjectileCollisionQueryRadius: 0.35
+ ProjectileMaxCandidatesPerQuery: 1
+ ProjectileCollisionCellSize: 0
+ DispatchProjectileHitPresentationEvent: 1
+ DispatchProjectileHitMarkerEvent: 1
+ DispatchProjectileHitEffectEvent: 1
+ ProjectileHitPresentationEffectTypeId: 0
+ _targetSelectionSettings:
+ CellSize: 2
--- !u!1 &1852670052
GameObject:
m_ObjectHideFlags: 0
diff --git a/Assets/Plugins/Demigiant/DOTween/Modules/DOTween.Modules.asmdef b/Assets/Plugins/Demigiant/DOTween/Modules/DOTween.Modules.asmdef
new file mode 100644
index 0000000..42ef5ab
--- /dev/null
+++ b/Assets/Plugins/Demigiant/DOTween/Modules/DOTween.Modules.asmdef
@@ -0,0 +1,3 @@
+{
+ "name": "DOTween.Modules"
+}
diff --git a/Assets/Plugins/Demigiant/DOTween/Modules/DOTween.Modules.asmdef.meta b/Assets/Plugins/Demigiant/DOTween/Modules/DOTween.Modules.asmdef.meta
new file mode 100644
index 0000000..d8ee93d
--- /dev/null
+++ b/Assets/Plugins/Demigiant/DOTween/Modules/DOTween.Modules.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: fca0f81bc71f1944887dd65f134c54a0
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Resources/DOTweenSettings.asset b/Assets/Resources/DOTweenSettings.asset
index 62ebbaf..88f2719 100644
--- a/Assets/Resources/DOTweenSettings.asset
+++ b/Assets/Resources/DOTweenSettings.asset
@@ -24,7 +24,7 @@ MonoBehaviour:
showUnityEditorReport: 0
logBehaviour: 0
drawGizmos: 1
- defaultRecyclable: 0
+ defaultRecyclable: 1
defaultAutoPlay: 3
defaultUpdateType: 0
defaultTimeScaleIndependent: 0
@@ -49,6 +49,6 @@ MonoBehaviour:
deAudioEnabled: 0
deUnityExtendedEnabled: 0
epoOutlineEnabled: 0
- createASMDEF: 0
+ createASMDEF: 1
showPlayingTweens: 0
showPausedTweens: 0
diff --git a/Assets/StreamingAssets.meta b/Assets/StreamingAssets.meta
deleted file mode 100644
index 619bede..0000000
--- a/Assets/StreamingAssets.meta
+++ /dev/null
@@ -1,9 +0,0 @@
-fileFormatVersion: 2
-guid: fa53b8776f6a8ce4598fe035cb4356b8
-folderAsset: yes
-timeCreated: 1528026174
-licenseType: Pro
-DefaultImporter:
- userData:
- assetBundleName:
- assetBundleVariant:
diff --git a/Assets/StreamingAssets/GameData.dat b/Assets/StreamingAssets/GameData.dat
deleted file mode 100644
index 0589cae..0000000
Binary files a/Assets/StreamingAssets/GameData.dat and /dev/null differ
diff --git a/Assets/StreamingAssets/GameFrameworkVersion.dat b/Assets/StreamingAssets/GameFrameworkVersion.dat
deleted file mode 100644
index bb98210..0000000
Binary files a/Assets/StreamingAssets/GameFrameworkVersion.dat and /dev/null differ
diff --git a/Assets/StreamingAssets/Resources.dat b/Assets/StreamingAssets/Resources.dat
deleted file mode 100644
index 3cc12ac..0000000
Binary files a/Assets/StreamingAssets/Resources.dat and /dev/null differ
diff --git a/Assets/StreamingAssets/SceneSettings.dat b/Assets/StreamingAssets/SceneSettings.dat
deleted file mode 100644
index ff11765..0000000
Binary files a/Assets/StreamingAssets/SceneSettings.dat and /dev/null differ
diff --git a/Assets/StreamingAssets/UI.dat b/Assets/StreamingAssets/UI.dat
deleted file mode 100644
index 746fbb9..0000000
Binary files a/Assets/StreamingAssets/UI.dat and /dev/null differ
diff --git a/Assets/StreamingAssets/UI.dat.meta b/Assets/StreamingAssets/UI.dat.meta
deleted file mode 100644
index 2f07d4c..0000000
--- a/Assets/StreamingAssets/UI.dat.meta
+++ /dev/null
@@ -1,7 +0,0 @@
-fileFormatVersion: 2
-guid: ff394cf6a35932247b0649240bbbd5fc
-DefaultImporter:
- externalObjects: {}
- userData:
- assetBundleName:
- assetBundleVariant:
diff --git a/Assets/StreamingAssets/UI/UIItems.dat b/Assets/StreamingAssets/UI/UIItems.dat
deleted file mode 100644
index 78fb5d7..0000000
Binary files a/Assets/StreamingAssets/UI/UIItems.dat and /dev/null differ
diff --git a/Assets/StreamingAssets/UI/UIItems.dat.meta b/Assets/StreamingAssets/UI/UIItems.dat.meta
deleted file mode 100644
index eb7fb00..0000000
--- a/Assets/StreamingAssets/UI/UIItems.dat.meta
+++ /dev/null
@@ -1,7 +0,0 @@
-fileFormatVersion: 2
-guid: b692b20b915b5384bb238bbe1e86f6c8
-DefaultImporter:
- externalObjects: {}
- userData:
- assetBundleName:
- assetBundleVariant:
diff --git a/Assets/StreamingAssets/UI/UISprites/SelectRoleFormRT.dat b/Assets/StreamingAssets/UI/UISprites/SelectRoleFormRT.dat
deleted file mode 100644
index c0a6382..0000000
Binary files a/Assets/StreamingAssets/UI/UISprites/SelectRoleFormRT.dat and /dev/null differ
diff --git a/Assets/StreamingAssets/UI/UISprites/SelectRoleFormRT.dat.meta b/Assets/StreamingAssets/UI/UISprites/SelectRoleFormRT.dat.meta
deleted file mode 100644
index ebd9ad7..0000000
--- a/Assets/StreamingAssets/UI/UISprites/SelectRoleFormRT.dat.meta
+++ /dev/null
@@ -1,7 +0,0 @@
-fileFormatVersion: 2
-guid: ccdf2a9817ba952488f12dadb5e278c6
-DefaultImporter:
- externalObjects: {}
- userData:
- assetBundleName:
- assetBundleVariant:
diff --git a/Assets/StreamingAssets/URPAssets.dat b/Assets/StreamingAssets/URPAssets.dat
deleted file mode 100644
index d3ee1df..0000000
Binary files a/Assets/StreamingAssets/URPAssets.dat and /dev/null differ
diff --git a/Assets/StreamingAssets/URPAssets.dat.meta b/Assets/StreamingAssets/URPAssets.dat.meta
deleted file mode 100644
index 5b89c19..0000000
--- a/Assets/StreamingAssets/URPAssets.dat.meta
+++ /dev/null
@@ -1,7 +0,0 @@
-fileFormatVersion: 2
-guid: 74423cdaf48700645bc031c80e5b3fc7
-DefaultImporter:
- externalObjects: {}
- userData:
- assetBundleName:
- assetBundleVariant:
diff --git a/Assets/Tests/Simulation/EditMode/Simulation.EditModeTests.asmdef b/Assets/Tests/Simulation/EditMode/Simulation.EditModeTests.asmdef
index 36ab93f..eeb62c7 100644
--- a/Assets/Tests/Simulation/EditMode/Simulation.EditModeTests.asmdef
+++ b/Assets/Tests/Simulation/EditMode/Simulation.EditModeTests.asmdef
@@ -1,11 +1,26 @@
{
"name": "Simulation.EditModeTests",
- "references": [],
- "optionalUnityReferences": [
- "TestAssemblies"
+ "rootNamespace": "",
+ "references": [
+ "UnityGameFramework.Runtime",
+ "UnityEngine.TestRunner",
+ "UnityEditor.TestRunner",
+ "VampireLike"
],
"includePlatforms": [
"Editor"
],
- "excludePlatforms": []
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": true,
+ "precompiledReferences": [
+ "nunit.framework.dll",
+ "GameFramework.dll"
+ ],
+ "autoReferenced": false,
+ "defineConstraints": [
+ "UNITY_INCLUDE_TESTS"
+ ],
+ "versionDefines": [],
+ "noEngineReferences": false
}
diff --git a/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs
index 444d9be..547a405 100644
--- a/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs
+++ b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs
@@ -1,15 +1,23 @@
+using System;
+using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
+using Procedure;
+using GameFramework.Fsm;
+using GameFramework.Procedure;
+using Object = UnityEngine.Object;
namespace Simulation.Tests.Editor
{
public class SimulationWorldTickTests
{
- private const string GameAssemblyName = "Assembly-CSharp";
+ private const string GameAssemblyName = "VampireLike";
+ private const string RuntimeAssemblyName = "UnityGameFramework.Runtime";
private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static;
private const BindingFlags PublicInstance = BindingFlags.Public | BindingFlags.Instance;
private const BindingFlags NonPublicInstance = BindingFlags.NonPublic | BindingFlags.Instance;
+ private const BindingFlags NonPublicStatic = BindingFlags.NonPublic | BindingFlags.Static;
private static readonly System.Type SimulationWorldType =
System.Type.GetType($"Simulation.SimulationWorld, {GameAssemblyName}");
@@ -20,23 +28,92 @@ namespace Simulation.Tests.Editor
private static readonly System.Type EnemySimDataType =
System.Type.GetType($"Simulation.EnemySimData, {GameAssemblyName}");
+ private static readonly System.Type ProjectileSimDataType =
+ System.Type.GetType($"Simulation.ProjectileSimData, {GameAssemblyName}");
+
+ private static readonly System.Type PickupSimDataType =
+ System.Type.GetType($"Simulation.PickupSimData, {GameAssemblyName}");
+
+ private static readonly System.Type EnemyProjectileType =
+ System.Type.GetType($"Entity.EnemyProjectile, {GameAssemblyName}");
+
+ private static readonly System.Type EnemyProjectileDataType =
+ System.Type.GetType($"Entity.EntityData.EnemyProjectileData, {GameAssemblyName}");
+
+ private static readonly System.Type CampTypeType =
+ System.Type.GetType($"Definition.Enum.CampType, {GameAssemblyName}");
+
+ private static readonly System.Type GameEntryType =
+ System.Type.GetType($"GameEntry, {GameAssemblyName}");
+
private static readonly System.Type EnemySeparationSolverProviderType =
System.Type.GetType($"CustomUtility.EnemySeparationSolverProvider, {GameAssemblyName}");
+ private static readonly System.Type EnemyManagerComponentType =
+ System.Type.GetType($"CustomComponent.EnemyManagerComponent, {GameAssemblyName}");
+
+ private static readonly System.Type PlayerType =
+ System.Type.GetType($"Entity.Player, {GameAssemblyName}");
+
+ private static readonly System.Type EntityBaseType =
+ System.Type.GetType($"Entity.EntityBase, {GameAssemblyName}");
+
+ private static readonly System.Type HealthComponentType =
+ System.Type.GetType($"Components.HealthComponent, {GameAssemblyName}");
+
+ private static readonly System.Type EntityLogicType =
+ System.Type.GetType($"UnityGameFramework.Runtime.EntityLogic, {RuntimeAssemblyName}");
+
+ private static readonly System.Type ProcedureComponentType =
+ System.Type.GetType($"UnityGameFramework.Runtime.ProcedureComponent, {RuntimeAssemblyName}");
+
+ private static readonly System.Type ProcedureGameType =
+ System.Type.GetType($"Procedure.ProcedureGame, {GameAssemblyName}");
+
+ private static readonly System.Type GameStateTypeType =
+ System.Type.GetType($"Procedure.GameStateType, {GameAssemblyName}");
+
+ private static readonly System.Type ProcedureManagerType =
+ System.Type.GetType("GameFramework.Procedure.ProcedureManager, GameFramework");
+
+ private static readonly System.Type ProcedureManagerInterfaceType =
+ System.Type.GetType("GameFramework.Procedure.IProcedureManager, GameFramework");
+
+ private static readonly System.Type FsmOpenGenericType =
+ System.Type.GetType("GameFramework.Fsm.Fsm`1, GameFramework");
+
private static readonly MethodInfo UpsertEnemyMethod =
SimulationWorldType?.GetMethod("UpsertEnemy", NonPublicInstance);
private static readonly MethodInfo RemoveEnemyByEntityIdMethod =
SimulationWorldType?.GetMethod("RemoveEnemyByEntityId", NonPublicInstance);
+ private static readonly MethodInfo UpsertProjectileMethod =
+ SimulationWorldType?.GetMethod("UpsertProjectile", NonPublicInstance);
+
+ private static readonly MethodInfo RemoveProjectileByEntityIdMethod =
+ SimulationWorldType?.GetMethod("RemoveProjectileByEntityId", NonPublicInstance);
+
+ private static readonly MethodInfo UpsertPickupMethod =
+ SimulationWorldType?.GetMethod("UpsertPickup", NonPublicInstance);
+
+ private static readonly MethodInfo RemovePickupByEntityIdMethod =
+ SimulationWorldType?.GetMethod("RemovePickupByEntityId", NonPublicInstance);
+
private static readonly MethodInfo TryGetEnemyDataMethod =
SimulationWorldType?.GetMethod("TryGetEnemyData", NonPublicInstance);
private static readonly MethodInfo TickMethod =
SimulationWorldType?.GetMethod("Tick", PublicInstance);
- private static readonly MethodInfo SetUseSimulationMovementMethod =
- SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance);
+ private static readonly MethodInfo TryGetNearestEnemyEntityIdMethod =
+ SimulationWorldType?.GetMethod("TryGetNearestEnemyEntityId", PublicInstance);
+
+ private static readonly MethodInfo TryRequestAreaCollisionMethod =
+ SimulationWorldType?.GetMethod("TryRequestAreaCollision", PublicInstance);
+
+ private static readonly MethodInfo ClearSimulationStateMethod =
+ SimulationWorldType?.GetMethod("ClearSimulationState", PublicInstance);
private static readonly MethodInfo UseGridBucketSolverMethod =
EnemySeparationSolverProviderType?.GetMethod("UseGridBucketSolver", PublicStatic);
@@ -44,12 +121,87 @@ namespace Simulation.Tests.Editor
private static readonly FieldInfo EntitySyncField =
SimulationWorldType?.GetField("_entitySync", NonPublicInstance);
- private static readonly FieldInfo PresentationField =
- SimulationWorldType?.GetField("_presentation", NonPublicInstance);
+ private static readonly FieldInfo TransformSyncField =
+ SimulationWorldType?.GetField("_transformSync", NonPublicInstance);
+
+ private static readonly FieldInfo HitPresentationField =
+ SimulationWorldType?.GetField("_hitPresentation", NonPublicInstance);
+
+ private static readonly FieldInfo UseSimulationMovementField =
+ SimulationWorldType?.GetField("_useSimulationMovement", NonPublicInstance);
private static readonly PropertyInfo EnemiesProperty =
SimulationWorldType?.GetProperty("Enemies", PublicInstance);
+ private static readonly PropertyInfo ProjectilesProperty =
+ SimulationWorldType?.GetProperty("Projectiles", PublicInstance);
+
+ private static readonly PropertyInfo PickupsProperty =
+ SimulationWorldType?.GetProperty("Pickups", PublicInstance);
+
+ private static readonly PropertyInfo CollisionCandidateCountProperty =
+ SimulationWorldType?.GetProperty("CollisionCandidateCount", PublicInstance);
+
+ private static readonly PropertyInfo UseSimulationMovementProperty =
+ SimulationWorldType?.GetProperty("UseSimulationMovement", PublicInstance);
+
+ private static readonly PropertyInfo LastResolvedAreaHitCountProperty =
+ SimulationWorldType?.GetProperty("LastResolvedAreaHitCount", PublicInstance);
+
+ private static readonly FieldInfo CollisionQueryInputsField =
+ SimulationWorldType?.GetField("_collisionQueryInputs", NonPublicInstance);
+
+ private static readonly FieldInfo AreaCollisionRequestsField =
+ SimulationWorldType?.GetField("_areaCollisionRequests", NonPublicInstance);
+
+ private static readonly MethodInfo EnemyProjectileOnUpdateMethod =
+ EnemyProjectileType?.GetMethod("OnUpdate", NonPublicInstance);
+
+ private static readonly FieldInfo EnemyProjectileDataField =
+ EnemyProjectileType?.GetField("_projectileData", NonPublicInstance);
+
+ private static readonly FieldInfo EnemyProjectileIsActiveField =
+ EnemyProjectileType?.GetField("_isActive", NonPublicInstance);
+
+ private static readonly FieldInfo EnemyProjectileIsSimulationDrivenField =
+ EnemyProjectileType?.GetField("_isSimulationDriven", NonPublicInstance);
+
+ private static readonly PropertyInfo GameEntrySimulationWorldProperty =
+ GameEntryType?.GetProperty("SimulationWorld", PublicStatic);
+
+ private static readonly MethodInfo GameEntryGetSimulationWorldMethod =
+ GameEntrySimulationWorldProperty?.GetGetMethod(true);
+
+ private static readonly MethodInfo GameEntrySetSimulationWorldMethod =
+ GameEntrySimulationWorldProperty?.GetSetMethod(true);
+
+ private static readonly PropertyInfo GameEntryEnemyManagerProperty =
+ GameEntryType?.GetProperty("EnemyManager", PublicStatic);
+
+ private static readonly MethodInfo GameEntryGetEnemyManagerMethod =
+ GameEntryEnemyManagerProperty?.GetGetMethod(true);
+
+ private static readonly MethodInfo GameEntrySetEnemyManagerMethod =
+ GameEntryEnemyManagerProperty?.GetSetMethod(true);
+
+ private static readonly PropertyInfo GameEntryProcedureProperty =
+ GameEntryType?.GetProperty("Procedure", PublicStatic);
+
+ private static readonly MethodInfo GameEntryGetProcedureMethod =
+ GameEntryProcedureProperty?.GetGetMethod(true);
+
+ private static readonly MethodInfo GameEntrySetProcedureMethod =
+ GameEntryProcedureProperty?.GetSetMethod(true);
+
+ private static readonly MethodInfo HealthComponentOnInitMethod =
+ HealthComponentType?.GetMethod("OnInit", PublicInstance);
+
+ private static readonly FieldInfo ProjectileMaxDistanceFromPlayerField =
+ SimulationWorldType?.GetField("_projectileMaxDistanceFromPlayer", NonPublicInstance);
+
+ private static readonly FieldInfo ProjectileMaxVerticalOffsetFromPlayerField =
+ SimulationWorldType?.GetField("_projectileMaxVerticalOffsetFromPlayer", NonPublicInstance);
+
private GameObject _worldGameObject;
private Component _worldComponent;
@@ -59,18 +211,70 @@ namespace Simulation.Tests.Editor
Assert.NotNull(SimulationWorldType, "SimulationWorld type lookup failed.");
Assert.NotNull(SimulationTickContextType, "SimulationTickContext type lookup failed.");
Assert.NotNull(EnemySimDataType, "EnemySimData type lookup failed.");
+ Assert.NotNull(ProjectileSimDataType, "ProjectileSimData type lookup failed.");
+ Assert.NotNull(PickupSimDataType, "PickupSimData type lookup failed.");
+ Assert.NotNull(EnemyProjectileType, "EnemyProjectile type lookup failed.");
+ Assert.NotNull(EnemyProjectileDataType, "EnemyProjectileData type lookup failed.");
+ Assert.NotNull(CampTypeType, "CampType type lookup failed.");
+ Assert.NotNull(GameEntryType, "GameEntry type lookup failed.");
Assert.NotNull(EnemySeparationSolverProviderType, "EnemySeparationSolverProvider type lookup failed.");
+ Assert.NotNull(EnemyManagerComponentType, "EnemyManagerComponent type lookup failed.");
+ Assert.NotNull(PlayerType, "Player type lookup failed.");
+ Assert.NotNull(EntityBaseType, "EntityBase type lookup failed.");
+ Assert.NotNull(HealthComponentType, "HealthComponent type lookup failed.");
+ Assert.NotNull(EntityLogicType, "EntityLogic type lookup failed.");
+ Assert.NotNull(ProcedureComponentType, "ProcedureComponent type lookup failed.");
+ Assert.NotNull(ProcedureGameType, "ProcedureGame type lookup failed.");
+ Assert.NotNull(GameStateTypeType, "GameStateType type lookup failed.");
+ Assert.NotNull(ProcedureManagerType, "ProcedureManager type lookup failed.");
+ Assert.NotNull(ProcedureManagerInterfaceType, "IProcedureManager type lookup failed.");
+ Assert.NotNull(FsmOpenGenericType, "Fsm`1 type lookup failed.");
Assert.NotNull(UpsertEnemyMethod, "UpsertEnemy reflection lookup failed.");
Assert.NotNull(RemoveEnemyByEntityIdMethod, "RemoveEnemyByEntityId reflection lookup failed.");
+ Assert.NotNull(UpsertProjectileMethod, "UpsertProjectile reflection lookup failed.");
+ Assert.NotNull(RemoveProjectileByEntityIdMethod, "RemoveProjectileByEntityId reflection lookup failed.");
+ Assert.NotNull(UpsertPickupMethod, "UpsertPickup reflection lookup failed.");
+ Assert.NotNull(RemovePickupByEntityIdMethod, "RemovePickupByEntityId reflection lookup failed.");
Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed.");
Assert.NotNull(TickMethod, "Tick reflection lookup failed.");
- Assert.NotNull(SetUseSimulationMovementMethod, "SetUseSimulationMovement reflection lookup failed.");
+ Assert.NotNull(TryGetNearestEnemyEntityIdMethod, "TryGetNearestEnemyEntityId reflection lookup failed.");
+ Assert.NotNull(TryRequestAreaCollisionMethod, "TryRequestAreaCollision reflection lookup failed.");
+ Assert.NotNull(ClearSimulationStateMethod, "ClearSimulationState reflection lookup failed.");
Assert.NotNull(UseGridBucketSolverMethod, "UseGridBucketSolver reflection lookup failed.");
Assert.NotNull(EnemiesProperty, "Enemies property reflection lookup failed.");
+ Assert.NotNull(ProjectilesProperty, "Projectiles property reflection lookup failed.");
+ Assert.NotNull(PickupsProperty, "Pickups property reflection lookup failed.");
+ Assert.NotNull(CollisionCandidateCountProperty, "CollisionCandidateCount property reflection lookup failed.");
+ Assert.NotNull(UseSimulationMovementProperty, "UseSimulationMovement property reflection lookup failed.");
+ Assert.NotNull(UseSimulationMovementField, "_useSimulationMovement field reflection lookup failed.");
+ Assert.NotNull(LastResolvedAreaHitCountProperty, "LastResolvedAreaHitCount property reflection lookup failed.");
+ Assert.NotNull(CollisionQueryInputsField, "Collision query inputs field reflection lookup failed.");
+ Assert.NotNull(AreaCollisionRequestsField, "Area collision requests field reflection lookup failed.");
+ Assert.NotNull(ProjectileMaxDistanceFromPlayerField,
+ "Projectile max distance field reflection lookup failed.");
+ Assert.NotNull(ProjectileMaxVerticalOffsetFromPlayerField,
+ "Projectile max vertical offset field reflection lookup failed.");
+ Assert.NotNull(EnemyProjectileOnUpdateMethod, "EnemyProjectile.OnUpdate reflection lookup failed.");
+ Assert.NotNull(EnemyProjectileDataField, "EnemyProjectile _projectileData reflection lookup failed.");
+ Assert.NotNull(EnemyProjectileIsActiveField, "EnemyProjectile _isActive reflection lookup failed.");
+ Assert.NotNull(EnemyProjectileIsSimulationDrivenField,
+ "EnemyProjectile _isSimulationDriven reflection lookup failed.");
+ Assert.NotNull(GameEntrySimulationWorldProperty, "GameEntry.SimulationWorld property lookup failed.");
+ Assert.NotNull(GameEntryGetSimulationWorldMethod,
+ "GameEntry.SimulationWorld getter reflection lookup failed.");
+ Assert.NotNull(GameEntrySetSimulationWorldMethod,
+ "GameEntry.SimulationWorld setter reflection lookup failed.");
+ Assert.NotNull(GameEntryEnemyManagerProperty, "GameEntry.EnemyManager property lookup failed.");
+ Assert.NotNull(GameEntryGetEnemyManagerMethod, "GameEntry.EnemyManager getter reflection lookup failed.");
+ Assert.NotNull(GameEntrySetEnemyManagerMethod, "GameEntry.EnemyManager setter reflection lookup failed.");
+ Assert.NotNull(GameEntryProcedureProperty, "GameEntry.Procedure property lookup failed.");
+ Assert.NotNull(GameEntryGetProcedureMethod, "GameEntry.Procedure getter reflection lookup failed.");
+ Assert.NotNull(GameEntrySetProcedureMethod, "GameEntry.Procedure setter reflection lookup failed.");
+ Assert.NotNull(HealthComponentOnInitMethod, "HealthComponent.OnInit reflection lookup failed.");
_worldGameObject = new GameObject("SimulationWorldTickTests");
_worldComponent = _worldGameObject.AddComponent(SimulationWorldType);
- SetUseSimulationMovementMethod.Invoke(_worldComponent, new object[] { true });
+ SetUseSimulationMovement(true);
UseGridBucketSolverMethod.Invoke(null, new object[] { 1f });
}
@@ -80,7 +284,8 @@ namespace Simulation.Tests.Editor
if (_worldComponent != null)
{
EntitySyncField?.SetValue(_worldComponent, null);
- PresentationField?.SetValue(_worldComponent, null);
+ TransformSyncField?.SetValue(_worldComponent, null);
+ HitPresentationField?.SetValue(_worldComponent, null);
}
if (_worldGameObject != null)
@@ -143,7 +348,463 @@ namespace Simulation.Tests.Editor
Assert.That((int)GetField(GetEnemyAt(1), "EntityId"), Is.EqualTo(2003));
}
- private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange)
+ [Test]
+ public void TickEnemies_ChasesPlayer_WhenJobSimulationChannelEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 1101, position: Vector3.zero, speed: 2f, attackRange: 1f));
+
+ InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: new Vector3(10f, 0f, 0f));
+
+ object enemy = GetEnemyAt(0);
+ Assert.That((int)GetField(enemy, "State"), Is.EqualTo(1));
+ Vector3 position = (Vector3)GetField(enemy, "Position");
+ Vector3 forward = (Vector3)GetField(enemy, "Forward");
+ Assert.That(position.x, Is.EqualTo(2f).Within(0.0001f));
+ Assert.That(position.z, Is.EqualTo(0f).Within(0.0001f));
+ Assert.That(forward.x, Is.EqualTo(1f).Within(0.0001f));
+ }
+
+ [Test]
+ public void TickEnemies_MatchesOutput_AfterClearSimulationState()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 1151, position: Vector3.zero, speed: 2f, attackRange: 1f));
+ InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: new Vector3(10f, 0f, 0f));
+
+ object nonBurstEnemy = GetEnemyAt(0);
+ int nonBurstState = (int)GetField(nonBurstEnemy, "State");
+ Vector3 nonBurstPosition = (Vector3)GetField(nonBurstEnemy, "Position");
+ Vector3 nonBurstForward = (Vector3)GetField(nonBurstEnemy, "Forward");
+
+ ClearSimulationStateMethod.Invoke(_worldComponent, null);
+
+ SetUseSimulationMovement(true);
+ UpsertEnemy(CreateEnemy(entityId: 1151, position: Vector3.zero, speed: 2f, attackRange: 1f));
+ InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: new Vector3(10f, 0f, 0f));
+
+ object burstEnemy = GetEnemyAt(0);
+ int burstState = (int)GetField(burstEnemy, "State");
+ Vector3 burstPosition = (Vector3)GetField(burstEnemy, "Position");
+ Vector3 burstForward = (Vector3)GetField(burstEnemy, "Forward");
+
+ Assert.That(burstState, Is.EqualTo(nonBurstState));
+ Assert.That((burstPosition - nonBurstPosition).sqrMagnitude, Is.LessThanOrEqualTo(1e-8f));
+ Assert.That((burstForward - nonBurstForward).sqrMagnitude, Is.LessThanOrEqualTo(1e-8f));
+ }
+
+ [Test]
+ public void TryGetNearestEnemyEntityId_SelectsNearestBucketCandidate_WhenJobSimulationEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 1201, position: new Vector3(1f, 0f, 0f), speed: 0f, attackRange: 1f));
+ UpsertEnemy(CreateEnemy(entityId: 1202, position: new Vector3(6f, 0f, 0f), speed: 0f, attackRange: 1f));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ object[] parameters = { Vector3.zero, 100f, 0 };
+ bool found = (bool)TryGetNearestEnemyEntityIdMethod.Invoke(_worldComponent, parameters);
+ int nearestEntityId = (int)parameters[2];
+
+ Assert.IsTrue(found);
+ Assert.That(nearestEntityId, Is.EqualTo(1201));
+ }
+
+ [Test]
+ public void TickEnemies_SeparatesOverlappedEnemies_WhenJobSimulationEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 1301, position: new Vector3(0f, 0f, 0f), speed: 1f, attackRange: 0.1f,
+ avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 2));
+ UpsertEnemy(CreateEnemy(entityId: 1302, position: new Vector3(0.1f, 0f, 0f), speed: 1f, attackRange: 0.1f,
+ avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 2));
+
+ InvokeTick(deltaTime: 0.1f, realDeltaTime: 0.1f, playerPosition: new Vector3(10f, 0f, 0f));
+
+ object enemyA = GetEnemyAt(0);
+ object enemyB = GetEnemyAt(1);
+ Vector3 posA = (Vector3)GetField(enemyA, "Position");
+ Vector3 posB = (Vector3)GetField(enemyB, "Position");
+ posA.y = 0f;
+ posB.y = 0f;
+ float distance = Vector3.Distance(posA, posB);
+ Assert.That(distance, Is.GreaterThanOrEqualTo(0.89f));
+ }
+
+ [Test]
+ public void TickEnemies_SeparatesOverlappedEnemies_WhenPlayerIsStaticAndInRange()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 1311, position: new Vector3(0f, 0f, 0f), speed: 1f, attackRange: 10f,
+ avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 3));
+ UpsertEnemy(CreateEnemy(entityId: 1312, position: new Vector3(0.05f, 0f, 0f), speed: 1f, attackRange: 10f,
+ avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 3));
+
+ InvokeTick(deltaTime: 0.1f, realDeltaTime: 0.1f, playerPosition: Vector3.zero);
+
+ object enemyA = GetEnemyAt(0);
+ object enemyB = GetEnemyAt(1);
+ Assert.That((int)GetField(enemyA, "State"), Is.EqualTo(2));
+ Assert.That((int)GetField(enemyB, "State"), Is.EqualTo(2));
+
+ Vector3 posA = (Vector3)GetField(enemyA, "Position");
+ Vector3 posB = (Vector3)GetField(enemyB, "Position");
+ posA.y = 0f;
+ posB.y = 0f;
+ float distance = Vector3.Distance(posA, posB);
+ Assert.That(distance, Is.GreaterThanOrEqualTo(0.5f));
+ }
+
+ [Test]
+ public void TickProjectiles_MovesAndUpdatesLifetime_WhenJobSimulationEnabled()
+ {
+ UpsertProjectile(CreateProjectile(entityId: 5101, position: Vector3.zero, forward: Vector3.right,
+ velocity: new Vector3(2f, 0f, 0f), speed: 0f, lifeTime: 2f, age: 0f, active: true,
+ remainingLifetime: 2f, state: 0));
+
+ InvokeTick(deltaTime: 0.5f, realDeltaTime: 0.5f, playerPosition: Vector3.zero);
+
+ Assert.That(GetProjectilesCount(), Is.EqualTo(1));
+ object projectile = GetProjectileAt(0);
+ Vector3 position = (Vector3)GetField(projectile, "Position");
+ float age = (float)GetField(projectile, "Age");
+ float remainingLifetime = (float)GetField(projectile, "RemainingLifetime");
+ bool active = (bool)GetField(projectile, "Active");
+
+ Assert.That(position.x, Is.EqualTo(1f).Within(0.0001f));
+ Assert.That(age, Is.EqualTo(0.5f).Within(0.0001f));
+ Assert.That(remainingLifetime, Is.EqualTo(1.5f).Within(0.0001f));
+ Assert.IsTrue(active);
+ }
+
+ [Test]
+ public void TickProjectiles_ContinuesFromLatestState_AcrossConsecutiveTicks()
+ {
+ UpsertProjectile(CreateProjectile(entityId: 5110, position: Vector3.zero, forward: Vector3.right,
+ velocity: new Vector3(2f, 0f, 0f), speed: 0f, lifeTime: 5f, age: 0f, active: true,
+ remainingLifetime: 5f, state: 0));
+
+ InvokeTick(deltaTime: 0.5f, realDeltaTime: 0.5f, playerPosition: Vector3.zero);
+ object afterJobEnabled = GetProjectileAt(0);
+ Vector3 positionAfterJobEnabled = (Vector3)GetField(afterJobEnabled, "Position");
+ float ageAfterJobEnabled = (float)GetField(afterJobEnabled, "Age");
+ Assert.That(positionAfterJobEnabled.x, Is.EqualTo(1f).Within(0.0001f));
+ Assert.That(ageAfterJobEnabled, Is.EqualTo(0.5f).Within(0.0001f));
+
+ InvokeTick(deltaTime: 0.5f, realDeltaTime: 0.5f, playerPosition: Vector3.zero);
+ object afterSecondTick = GetProjectileAt(0);
+ Vector3 positionAfterSecondTick = (Vector3)GetField(afterSecondTick, "Position");
+ float ageAfterSecondTick = (float)GetField(afterSecondTick, "Age");
+ float remainingLifetimeAfterSecondTick = (float)GetField(afterSecondTick, "RemainingLifetime");
+ bool activeAfterSecondTick = (bool)GetField(afterSecondTick, "Active");
+
+ Assert.That(positionAfterSecondTick.x, Is.EqualTo(2f).Within(0.0001f));
+ Assert.That(ageAfterSecondTick, Is.EqualTo(1f).Within(0.0001f));
+ Assert.That(remainingLifetimeAfterSecondTick, Is.EqualTo(4f).Within(0.0001f));
+ Assert.IsTrue(activeAfterSecondTick);
+ }
+
+ [Test]
+ public void EnemyProjectile_TogglesCollider_WhenSimulationMovementSwitchesAtRuntime()
+ {
+ SetUseSimulationMovement(false);
+
+ GameObject projectileObject = new GameObject("EnemyProjectileColliderToggleEditMode");
+ try
+ {
+ Component projectileComponent = projectileObject.AddComponent(EnemyProjectileType);
+ Collider projectileCollider = projectileObject.AddComponent();
+ projectileCollider.enabled = true;
+
+ object previousSimulationWorld = GetGameEntrySimulationWorld();
+ SetGameEntrySimulationWorld(_worldComponent);
+ try
+ {
+ object neutralCamp = System.Enum.Parse(CampTypeType, "Neutral");
+ object projectileData = System.Activator.CreateInstance(
+ EnemyProjectileDataType,
+ BindingFlags.Public | BindingFlags.Instance,
+ null,
+ new object[] { 7001, 1, neutralCamp, 1, 0f, 10f, Vector3.forward },
+ null);
+
+ EnemyProjectileDataField.SetValue(projectileComponent, projectileData);
+ EnemyProjectileIsActiveField.SetValue(projectileComponent, true);
+ EnemyProjectileIsSimulationDrivenField.SetValue(projectileComponent, false);
+
+ InvokeEnemyProjectileUpdate(projectileComponent, 0.016f, 0.016f);
+ Assert.IsTrue(projectileCollider.enabled);
+
+ SetUseSimulationMovement(true);
+ InvokeEnemyProjectileUpdate(projectileComponent, 0.016f, 0.016f);
+ Assert.IsFalse(projectileCollider.enabled);
+
+ SetUseSimulationMovement(false);
+ InvokeEnemyProjectileUpdate(projectileComponent, 0.016f, 0.016f);
+ Assert.IsTrue(projectileCollider.enabled);
+ }
+ finally
+ {
+ SetGameEntrySimulationWorld(previousSimulationWorld);
+ }
+ }
+ finally
+ {
+ Object.DestroyImmediate(projectileObject);
+ }
+ }
+
+ [Test]
+ public void RemoveProjectileByEntityId_RemapIndex_ForMovedProjectile()
+ {
+ UpsertProjectile(CreateProjectile(entityId: 5105, position: new Vector3(0f, 0f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 3f, age: 0f, active: true,
+ remainingLifetime: 3f, state: 0));
+ UpsertProjectile(CreateProjectile(entityId: 5106, position: new Vector3(1f, 0f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 3f, age: 0f, active: true,
+ remainingLifetime: 3f, state: 0));
+ UpsertProjectile(CreateProjectile(entityId: 5107, position: new Vector3(2f, 0f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 3f, age: 0f, active: true,
+ remainingLifetime: 3f, state: 0));
+
+ bool removed = RemoveProjectileByEntityId(5106);
+ bool removedMoved = RemoveProjectileByEntityId(5107);
+
+ Assert.IsTrue(removed);
+ Assert.That(GetProjectilesCount(), Is.EqualTo(1));
+ Assert.IsTrue(removedMoved);
+ Assert.That((int)GetField(GetProjectileAt(0), "EntityId"), Is.EqualTo(5105));
+ }
+
+ [Test]
+ public void TickProjectiles_RecyclesWhenExceedingPlayerDistance_WhenJobSimulationEnabled()
+ {
+ ProjectileMaxDistanceFromPlayerField.SetValue(_worldComponent, 5f);
+ ProjectileMaxVerticalOffsetFromPlayerField.SetValue(_worldComponent, 1000f);
+ UpsertProjectile(CreateProjectile(entityId: 5108, position: new Vector3(6f, 0f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 3f, age: 0f, active: true,
+ remainingLifetime: 3f, state: 0));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ Assert.That(GetProjectilesCount(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public void TickProjectiles_RecyclesWhenExceedingVerticalOffset_WhenJobSimulationEnabled()
+ {
+ ProjectileMaxDistanceFromPlayerField.SetValue(_worldComponent, 0f);
+ ProjectileMaxVerticalOffsetFromPlayerField.SetValue(_worldComponent, 1f);
+ UpsertProjectile(CreateProjectile(entityId: 5109, position: new Vector3(0f, 2f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 3f, age: 0f, active: true,
+ remainingLifetime: 3f, state: 0));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ Assert.That(GetProjectilesCount(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public void TickProjectiles_RecyclesExpiredProjectile_WhenJobSimulationEnabled()
+ {
+ UpsertProjectile(CreateProjectile(entityId: 5102, position: Vector3.zero, forward: Vector3.forward,
+ velocity: Vector3.zero, speed: 0f, lifeTime: 1f, age: 0.95f, active: true, remainingLifetime: 0.05f,
+ state: 0));
+
+ InvokeTick(deltaTime: 0.1f, realDeltaTime: 0.1f, playerPosition: Vector3.zero);
+
+ Assert.That(GetProjectilesCount(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public void TickProjectiles_BuildsCollisionCandidatesAgainstEnemies_WhenJobSimulationEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 5201, position: Vector3.zero, speed: 0f, attackRange: 1f));
+ UpsertProjectile(CreateProjectile(entityId: 5202, position: Vector3.zero, forward: Vector3.forward,
+ velocity: Vector3.zero, speed: 0f, lifeTime: 2f, age: 0f, active: true, remainingLifetime: 2f,
+ state: 0));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ Assert.That(GetCollisionCandidateCount(), Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void TickProjectiles_BuildsCollisionCandidates_WithLatestEnemyMovement_WhenJobSimulationEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 5211, position: new Vector3(2f, 0f, 0f), speed: 1f, attackRange: 0.1f));
+ UpsertProjectile(CreateProjectile(entityId: 5212, position: new Vector3(1f, 0f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 2f, age: 0f, active: true,
+ remainingLifetime: 2f, state: 0));
+
+ InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: Vector3.zero);
+
+ Assert.That(GetCollisionCandidateCount(), Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void TickProjectiles_ExpiresAfterCollisionCandidateConsumed_WhenJobSimulationEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 5203, position: Vector3.zero, speed: 0f, attackRange: 1f));
+ UpsertProjectile(CreateProjectile(entityId: 5204, position: Vector3.zero, forward: Vector3.forward,
+ velocity: Vector3.zero, speed: 0f, lifeTime: 10f, age: 0f, active: true, remainingLifetime: 10f,
+ state: 0));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ Assert.That(GetProjectilesCount(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public void TickProjectiles_LimitsCandidatesToMaxTargets_IncludingPlayerCandidate()
+ {
+ object previousEnemyManager = GetGameEntryEnemyManager();
+ GameObject enemyManagerObject = new GameObject("EnemyManagerMaxTargetsEditMode");
+ GameObject playerObject = new GameObject("PlayerTargetMaxTargetsEditMode");
+ try
+ {
+ Component enemyManager = enemyManagerObject.AddComponent(EnemyManagerComponentType);
+ Component player = playerObject.AddComponent(PlayerType);
+ Component healthComponent = playerObject.AddComponent(HealthComponentType);
+ HealthComponentOnInitMethod.Invoke(healthComponent, new object[] { 100, null });
+ SetPrivateField(player, "_healthComponent", healthComponent);
+ SetPrivateField(player, "m_CachedTransform", playerObject.transform);
+ SetPrivateField(player, "m_Available", true);
+
+ object enemyById = Activator.CreateInstance(typeof(Dictionary<,>).MakeGenericType(typeof(int), EntityBaseType));
+ enemyById.GetType().GetMethod("Add")?.Invoke(enemyById, new object[] { -1, player });
+ object enemies = Activator.CreateInstance(typeof(List<>).MakeGenericType(EntityBaseType));
+ enemies.GetType().GetMethod("Add")?.Invoke(enemies, new object[] { player });
+ SetPrivateField(enemyManager, "_enemyById", enemyById);
+ SetPrivateField(enemyManager, "_enemies", enemies);
+ SetGameEntryEnemyManager(enemyManager);
+
+ UpsertEnemy(CreateEnemy(entityId: 5221, position: Vector3.zero, speed: 0f, attackRange: 1f));
+ UpsertProjectile(CreateProjectile(entityId: 5222, position: Vector3.zero, forward: Vector3.forward,
+ velocity: Vector3.zero, speed: 0f, lifeTime: 1f, age: 0f, active: true, remainingLifetime: 1f,
+ state: 0));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ Assert.That(GetCollisionCandidateCount(), Is.EqualTo(1));
+ }
+ finally
+ {
+ SetGameEntryEnemyManager(previousEnemyManager);
+ Object.DestroyImmediate(enemyManagerObject);
+ Object.DestroyImmediate(playerObject);
+ }
+ }
+
+ [Test]
+ public void TryRequestAreaCollision_ReturnsFalse_WhenSimulationMovementDisabled()
+ {
+ SetUseSimulationMovement(false);
+ object[] requestArgs = { 5230, 5230, Vector3.zero, 1f, 1 };
+ bool requestResult = (bool)TryRequestAreaCollisionMethod.Invoke(_worldComponent, requestArgs);
+
+ Assert.IsFalse(requestResult);
+ Assert.IsFalse((bool)UseSimulationMovementProperty.GetValue(_worldComponent));
+ }
+
+ [Test]
+ public void EnqueueAreaQuery_CapturesInactiveSourceSnapshot_WhenSourceEntityUnavailable()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 5231, position: Vector3.zero, speed: 0f, attackRange: 1f));
+
+ object[] enqueueArgs = { 99999, 99999, Vector3.zero, 1f, 1 };
+ bool enqueueResult = (bool)TryRequestAreaCollisionMethod.Invoke(_worldComponent, enqueueArgs);
+ Assert.IsTrue(enqueueResult);
+
+ object areaCollisionRequests = AreaCollisionRequestsField.GetValue(_worldComponent);
+ Assert.NotNull(areaCollisionRequests);
+ PropertyInfo requestCountProperty = areaCollisionRequests.GetType().GetProperty("Count", PublicInstance);
+ int requestCount = (int)requestCountProperty.GetValue(areaCollisionRequests);
+ Assert.That(requestCount, Is.GreaterThan(0));
+
+ PropertyInfo requestItemProperty = areaCollisionRequests.GetType().GetProperty("Item", PublicInstance);
+ object firstRequest = requestItemProperty.GetValue(areaCollisionRequests, new object[] { 0 });
+ FieldInfo requestSnapshotField =
+ firstRequest.GetType().GetField("SourceWasActiveAtQueryTime", PublicInstance);
+ Assert.NotNull(requestSnapshotField);
+ bool requestSnapshot = (bool)requestSnapshotField.GetValue(firstRequest);
+ Assert.IsFalse(requestSnapshot);
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+ Assert.That(GetLastResolvedAreaHitCount(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public void ProcedureGame_TransitionsBattleToLevelUpShopAndBackToBattle()
+ {
+ var procedureGame = (ProcedureGame)Activator.CreateInstance(ProcedureGameType);
+ GameObject playerObject = new GameObject("ProcedureGameTransitionPlayer");
+ try
+ {
+ var player = playerObject.AddComponent(PlayerType);
+ Assert.NotNull(player);
+ PlayerType.GetProperty("PendingLevelPoints", PublicInstance)?.SetValue(player, 1);
+ ProcedureGameType.GetField("Player", PublicInstance)?.SetValue(procedureGame, player);
+
+ var battleState = new TrackingGameState(GameStateType.Battle);
+ var levelUpState = new TrackingGameState(GameStateType.LevelUp);
+ var shopState = new TrackingGameState(GameStateType.Shop);
+ var gameStates = new Dictionary
+ {
+ { GameStateType.Battle, battleState },
+ { GameStateType.LevelUp, levelUpState },
+ { GameStateType.Shop, shopState },
+ };
+
+ SetPrivateField(procedureGame, "_gameStates", gameStates);
+ SetPrivateField(procedureGame, "_currentGameState", GameStateType.Battle);
+ SetPrivateField(procedureGame, "_procedureOwner", null);
+
+ procedureGame.BattleToShopOrLevelUp();
+ Assert.That(procedureGame.CurrentLevel, Is.EqualTo(2));
+ Assert.That(procedureGame.CurrentGameStateType, Is.EqualTo(GameStateType.LevelUp));
+ Assert.That(battleState.LeaveCount, Is.EqualTo(1));
+ Assert.That(levelUpState.EnterCount, Is.EqualTo(1));
+
+ PlayerType.GetProperty("PendingLevelPoints", PublicInstance)?.SetValue(player, 0);
+ procedureGame.LevelUpToShop();
+ Assert.That(procedureGame.CurrentGameStateType, Is.EqualTo(GameStateType.Shop));
+ Assert.That(levelUpState.LeaveCount, Is.EqualTo(1));
+ Assert.That(shopState.EnterCount, Is.EqualTo(1));
+
+ procedureGame.ShopToBattle();
+ Assert.That(procedureGame.CurrentGameStateType, Is.EqualTo(GameStateType.Battle));
+ Assert.That(shopState.LeaveCount, Is.EqualTo(1));
+ Assert.That(battleState.EnterCount, Is.EqualTo(1));
+ }
+ finally
+ {
+ Object.DestroyImmediate(playerObject);
+ }
+ }
+
+ [Test]
+ public void PickupLifecycle_UpsertAndRemove_KeepsBindingsConsistent()
+ {
+ UpsertPickup(CreatePickup(entityId: 6101, position: new Vector3(1f, 0f, 0f), pickupRadius: 0.35f, state: 0));
+ UpsertPickup(CreatePickup(entityId: 6102, position: new Vector3(2f, 0f, 0f), pickupRadius: 0.35f, state: 0));
+ UpsertPickup(CreatePickup(entityId: 6103, position: new Vector3(3f, 0f, 0f), pickupRadius: 0.35f, state: 0));
+
+ Assert.That(GetPickupsCount(), Is.EqualTo(3));
+
+ UpsertPickup(CreatePickup(entityId: 6101, position: new Vector3(10f, 0f, 0f), pickupRadius: 0.5f, state: 1));
+ Assert.That(GetPickupsCount(), Is.EqualTo(3));
+ object updatedPickup = GetPickupAt(0);
+ Assert.That((int)GetField(updatedPickup, "EntityId"), Is.EqualTo(6101));
+ Assert.That(((Vector3)GetField(updatedPickup, "Position")).x, Is.EqualTo(10f).Within(0.0001f));
+ Assert.That((float)GetField(updatedPickup, "PickupRadius"), Is.EqualTo(0.5f).Within(0.0001f));
+
+ bool removedMiddle = RemovePickupByEntityId(6102);
+ bool removedMoved = RemovePickupByEntityId(6103);
+
+ Assert.IsTrue(removedMiddle);
+ Assert.That(GetPickupsCount(), Is.EqualTo(1));
+ Assert.IsTrue(removedMoved);
+ Assert.That((int)GetField(GetPickupAt(0), "EntityId"), Is.EqualTo(6101));
+ }
+
+ private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange,
+ bool avoidEnemyOverlap = false, float enemyBodyRadius = 0.45f, int separationIterations = 1)
{
object enemy = System.Activator.CreateInstance(EnemySimDataType);
SetField(ref enemy, "EntityId", entityId);
@@ -152,14 +813,42 @@ namespace Simulation.Tests.Editor
SetField(ref enemy, "Rotation", Quaternion.identity);
SetField(ref enemy, "Speed", speed);
SetField(ref enemy, "AttackRange", attackRange);
- SetField(ref enemy, "AvoidEnemyOverlap", false);
- SetField(ref enemy, "EnemyBodyRadius", 0.45f);
- SetField(ref enemy, "SeparationIterations", 1);
+ SetField(ref enemy, "AvoidEnemyOverlap", avoidEnemyOverlap);
+ SetField(ref enemy, "EnemyBodyRadius", enemyBodyRadius);
+ SetField(ref enemy, "SeparationIterations", separationIterations);
SetField(ref enemy, "TargetType", 0);
SetField(ref enemy, "State", 0);
return enemy;
}
+ private object CreateProjectile(int entityId, Vector3 position, Vector3 forward, Vector3 velocity, float speed,
+ float lifeTime, float age, bool active, float remainingLifetime, int state)
+ {
+ object projectile = System.Activator.CreateInstance(ProjectileSimDataType);
+ SetField(ref projectile, "EntityId", entityId);
+ SetField(ref projectile, "OwnerEntityId", 0);
+ SetField(ref projectile, "Position", position);
+ SetField(ref projectile, "Forward", forward);
+ SetField(ref projectile, "Velocity", velocity);
+ SetField(ref projectile, "Speed", speed);
+ SetField(ref projectile, "LifeTime", lifeTime);
+ SetField(ref projectile, "Age", age);
+ SetField(ref projectile, "Active", active);
+ SetField(ref projectile, "RemainingLifetime", remainingLifetime);
+ SetField(ref projectile, "State", state);
+ return projectile;
+ }
+
+ private object CreatePickup(int entityId, Vector3 position, float pickupRadius, int state)
+ {
+ object pickup = Activator.CreateInstance(PickupSimDataType);
+ SetField(ref pickup, "EntityId", entityId);
+ SetField(ref pickup, "Position", position);
+ SetField(ref pickup, "PickupRadius", pickupRadius);
+ SetField(ref pickup, "State", state);
+ return pickup;
+ }
+
private void InvokeTick(float deltaTime, float realDeltaTime, Vector3 playerPosition)
{
object tickContext = System.Activator.CreateInstance(
@@ -172,16 +861,77 @@ namespace Simulation.Tests.Editor
TickMethod.Invoke(_worldComponent, new[] { tickContext });
}
+ private void SetUseSimulationMovement(bool enabled)
+ {
+ UseSimulationMovementField.SetValue(_worldComponent, enabled);
+ }
+
private void UpsertEnemy(object enemy)
{
UpsertEnemyMethod.Invoke(_worldComponent, new[] { enemy });
}
+ private void UpsertProjectile(object projectile)
+ {
+ UpsertProjectileMethod.Invoke(_worldComponent, new[] { projectile });
+ }
+
+ private void UpsertPickup(object pickup)
+ {
+ UpsertPickupMethod.Invoke(_worldComponent, new[] { pickup });
+ }
+
private bool RemoveEnemyByEntityId(int entityId)
{
return (bool)RemoveEnemyByEntityIdMethod.Invoke(_worldComponent, new object[] { entityId });
}
+ private bool RemoveProjectileByEntityId(int entityId)
+ {
+ return (bool)RemoveProjectileByEntityIdMethod.Invoke(_worldComponent, new object[] { entityId });
+ }
+
+ private bool RemovePickupByEntityId(int entityId)
+ {
+ return (bool)RemovePickupByEntityIdMethod.Invoke(_worldComponent, new object[] { entityId });
+ }
+
+ private static object GetGameEntrySimulationWorld()
+ {
+ return GameEntryGetSimulationWorldMethod.Invoke(null, null);
+ }
+
+ private static void SetGameEntrySimulationWorld(object simulationWorld)
+ {
+ GameEntrySetSimulationWorldMethod.Invoke(null, new[] { simulationWorld });
+ }
+
+ private static object GetGameEntryEnemyManager()
+ {
+ return GameEntryGetEnemyManagerMethod.Invoke(null, null);
+ }
+
+ private static void SetGameEntryEnemyManager(object enemyManager)
+ {
+ GameEntrySetEnemyManagerMethod.Invoke(null, new[] { enemyManager });
+ }
+
+ private static object GetGameEntryProcedure()
+ {
+ return GameEntryGetProcedureMethod.Invoke(null, null);
+ }
+
+ private static void SetGameEntryProcedure(object procedureComponent)
+ {
+ GameEntrySetProcedureMethod.Invoke(null, new[] { procedureComponent });
+ }
+
+ private static void InvokeEnemyProjectileUpdate(Component projectileComponent, float elapseSeconds,
+ float realElapseSeconds)
+ {
+ EnemyProjectileOnUpdateMethod.Invoke(projectileComponent, new object[] { elapseSeconds, realElapseSeconds });
+ }
+
private bool TryGetEnemyData(int entityId, out object enemyData)
{
object boxedDefault = System.Activator.CreateInstance(EnemySimDataType);
@@ -205,6 +955,44 @@ namespace Simulation.Tests.Editor
return (int)countProperty.GetValue(enemies);
}
+ private object GetProjectileAt(int index)
+ {
+ object projectiles = ProjectilesProperty.GetValue(_worldComponent);
+ PropertyInfo itemProperty = projectiles.GetType().GetProperty("Item", PublicInstance);
+ return itemProperty.GetValue(projectiles, new object[] { index });
+ }
+
+ private int GetProjectilesCount()
+ {
+ object projectiles = ProjectilesProperty.GetValue(_worldComponent);
+ PropertyInfo countProperty = projectiles.GetType().GetProperty("Count", PublicInstance);
+ return (int)countProperty.GetValue(projectiles);
+ }
+
+ private object GetPickupAt(int index)
+ {
+ object pickups = PickupsProperty.GetValue(_worldComponent);
+ PropertyInfo itemProperty = pickups.GetType().GetProperty("Item", PublicInstance);
+ return itemProperty.GetValue(pickups, new object[] { index });
+ }
+
+ private int GetPickupsCount()
+ {
+ object pickups = PickupsProperty.GetValue(_worldComponent);
+ PropertyInfo countProperty = pickups.GetType().GetProperty("Count", PublicInstance);
+ return (int)countProperty.GetValue(pickups);
+ }
+
+ private int GetCollisionCandidateCount()
+ {
+ return (int)CollisionCandidateCountProperty.GetValue(_worldComponent);
+ }
+
+ private int GetLastResolvedAreaHitCount()
+ {
+ return (int)LastResolvedAreaHitCountProperty.GetValue(_worldComponent);
+ }
+
private static object GetField(object target, string fieldName)
{
FieldInfo field = target.GetType().GetField(fieldName, PublicInstance);
@@ -216,5 +1004,60 @@ namespace Simulation.Tests.Editor
FieldInfo field = target.GetType().GetField(fieldName, PublicInstance);
field.SetValue(target, value);
}
+
+ private static void SetPrivateField(object target, string fieldName, object value)
+ {
+ Type type = target.GetType();
+ while (type != null)
+ {
+ FieldInfo field = type.GetField(fieldName, NonPublicInstance);
+ if (field != null)
+ {
+ field.SetValue(target, value);
+ return;
+ }
+
+ type = type.BaseType;
+ }
+
+ Assert.Fail($"Field '{fieldName}' was not found on type '{target.GetType().FullName}'.");
+ }
+
+ private sealed class TrackingGameState : GameStateBase
+ {
+ public TrackingGameState(GameStateType gameStateType)
+ {
+ GameStateType = gameStateType;
+ }
+
+ public override GameStateType GameStateType { get; }
+
+ public int EnterCount { get; private set; }
+
+ public int LeaveCount { get; private set; }
+
+ public override void OnInit(ProcedureGame master)
+ {
+ }
+
+ public override void OnEnter(IFsm procedureOwner)
+ {
+ EnterCount++;
+ }
+
+ public override void OnUpdate(IFsm procedureOwner, float elapseSeconds,
+ float realElapseSeconds)
+ {
+ }
+
+ public override void OnLeave(IFsm procedureOwner)
+ {
+ LeaveCount++;
+ }
+
+ public override void OnDestroy(IFsm procedureOwner)
+ {
+ }
+ }
}
}
diff --git a/Assets/Tests/Simulation/PlayMode/Simulation.PlayModeTests.asmdef b/Assets/Tests/Simulation/PlayMode/Simulation.PlayModeTests.asmdef
index d55509a..9079655 100644
--- a/Assets/Tests/Simulation/PlayMode/Simulation.PlayModeTests.asmdef
+++ b/Assets/Tests/Simulation/PlayMode/Simulation.PlayModeTests.asmdef
@@ -1,9 +1,24 @@
{
"name": "Simulation.PlayModeTests",
- "references": [],
- "optionalUnityReferences": [
- "TestAssemblies"
+ "rootNamespace": "",
+ "references": [
+ "UnityGameFramework.Runtime",
+ "UnityEngine.TestRunner",
+ "UnityEditor.TestRunner",
+ "VampireLike"
],
"includePlatforms": [],
- "excludePlatforms": []
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": true,
+ "precompiledReferences": [
+ "nunit.framework.dll",
+ "GameFramework.dll"
+ ],
+ "autoReferenced": false,
+ "defineConstraints": [
+ "UNITY_INCLUDE_TESTS"
+ ],
+ "versionDefines": [],
+ "noEngineReferences": false
}
diff --git a/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs
index 7aeb397..1405bc5 100644
--- a/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs
+++ b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs
@@ -1,14 +1,18 @@
+using System;
using System.Collections;
+using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
+using Object = UnityEngine.Object;
namespace Simulation.Tests.PlayMode
{
public class SimulationWorldPlayModeTests
{
- private const string GameAssemblyName = "Assembly-CSharp";
+ private const string GameAssemblyName = "VampireLike";
+ private const string RuntimeAssemblyName = "UnityGameFramework.Runtime";
private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static;
private const BindingFlags PublicInstance = BindingFlags.Public | BindingFlags.Instance;
private const BindingFlags NonPublicInstance = BindingFlags.NonPublic | BindingFlags.Instance;
@@ -22,23 +26,80 @@ namespace Simulation.Tests.PlayMode
private static readonly System.Type EnemySimDataType =
System.Type.GetType($"Simulation.EnemySimData, {GameAssemblyName}");
+ private static readonly System.Type ProjectileSimDataType =
+ System.Type.GetType($"Simulation.ProjectileSimData, {GameAssemblyName}");
+
+ private static readonly System.Type EnemyProjectileType =
+ System.Type.GetType($"Entity.EnemyProjectile, {GameAssemblyName}");
+
+ private static readonly System.Type EnemyProjectileDataType =
+ System.Type.GetType($"Entity.EntityData.EnemyProjectileData, {GameAssemblyName}");
+
+ private static readonly System.Type CampTypeType =
+ System.Type.GetType($"Definition.Enum.CampType, {GameAssemblyName}");
+
+ private static readonly System.Type GameEntryType =
+ System.Type.GetType($"GameEntry, {GameAssemblyName}");
+
private static readonly System.Type EnemySeparationSolverProviderType =
System.Type.GetType($"CustomUtility.EnemySeparationSolverProvider, {GameAssemblyName}");
+ private static readonly System.Type EnemyManagerComponentType =
+ System.Type.GetType($"CustomComponent.EnemyManagerComponent, {GameAssemblyName}");
+
+ private static readonly System.Type PlayerType =
+ System.Type.GetType($"Entity.Player, {GameAssemblyName}");
+
+ private static readonly System.Type EntityBaseType =
+ System.Type.GetType($"Entity.EntityBase, {GameAssemblyName}");
+
+ private static readonly System.Type HealthComponentType =
+ System.Type.GetType($"Components.HealthComponent, {GameAssemblyName}");
+
+ private static readonly System.Type ProcedureComponentType =
+ System.Type.GetType($"UnityGameFramework.Runtime.ProcedureComponent, {RuntimeAssemblyName}");
+
+ private static readonly System.Type ProcedureGameType =
+ System.Type.GetType($"Procedure.ProcedureGame, {GameAssemblyName}");
+
+ private static readonly System.Type GameStateTypeType =
+ System.Type.GetType($"Procedure.GameStateType, {GameAssemblyName}");
+
+ private static readonly System.Type ProcedureManagerType =
+ System.Type.GetType("GameFramework.Procedure.ProcedureManager, GameFramework");
+
+ private static readonly System.Type ProcedureManagerInterfaceType =
+ System.Type.GetType("GameFramework.Procedure.IProcedureManager, GameFramework");
+
+ private static readonly System.Type FsmOpenGenericType =
+ System.Type.GetType("GameFramework.Fsm.Fsm`1, GameFramework");
+
private static readonly MethodInfo UpsertEnemyMethod =
SimulationWorldType?.GetMethod("UpsertEnemy", NonPublicInstance);
private static readonly MethodInfo RemoveEnemyByEntityIdMethod =
SimulationWorldType?.GetMethod("RemoveEnemyByEntityId", NonPublicInstance);
+ private static readonly MethodInfo UpsertProjectileMethod =
+ SimulationWorldType?.GetMethod("UpsertProjectile", NonPublicInstance);
+
+ private static readonly MethodInfo RemoveProjectileByEntityIdMethod =
+ SimulationWorldType?.GetMethod("RemoveProjectileByEntityId", NonPublicInstance);
+
private static readonly MethodInfo TryGetEnemyDataMethod =
SimulationWorldType?.GetMethod("TryGetEnemyData", NonPublicInstance);
private static readonly MethodInfo TickMethod =
SimulationWorldType?.GetMethod("Tick", PublicInstance);
- private static readonly MethodInfo SetUseSimulationMovementMethod =
- SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance);
+ private static readonly MethodInfo TryGetNearestEnemyEntityIdMethod =
+ SimulationWorldType?.GetMethod("TryGetNearestEnemyEntityId", PublicInstance);
+
+ private static readonly MethodInfo TryRequestAreaCollisionMethod =
+ SimulationWorldType?.GetMethod("TryRequestAreaCollision", PublicInstance);
+
+ private static readonly MethodInfo ClearSimulationStateMethod =
+ SimulationWorldType?.GetMethod("ClearSimulationState", PublicInstance);
private static readonly MethodInfo UseGridBucketSolverMethod =
EnemySeparationSolverProviderType?.GetMethod("UseGridBucketSolver", PublicStatic);
@@ -46,12 +107,84 @@ namespace Simulation.Tests.PlayMode
private static readonly FieldInfo EntitySyncField =
SimulationWorldType?.GetField("_entitySync", NonPublicInstance);
- private static readonly FieldInfo PresentationField =
- SimulationWorldType?.GetField("_presentation", NonPublicInstance);
+ private static readonly FieldInfo TransformSyncField =
+ SimulationWorldType?.GetField("_transformSync", NonPublicInstance);
+
+ private static readonly FieldInfo HitPresentationField =
+ SimulationWorldType?.GetField("_hitPresentation", NonPublicInstance);
+
+ private static readonly FieldInfo UseSimulationMovementField =
+ SimulationWorldType?.GetField("_useSimulationMovement", NonPublicInstance);
private static readonly PropertyInfo EnemiesProperty =
SimulationWorldType?.GetProperty("Enemies", PublicInstance);
+ private static readonly PropertyInfo ProjectilesProperty =
+ SimulationWorldType?.GetProperty("Projectiles", PublicInstance);
+
+ private static readonly PropertyInfo CollisionCandidateCountProperty =
+ SimulationWorldType?.GetProperty("CollisionCandidateCount", PublicInstance);
+
+ private static readonly PropertyInfo UseSimulationMovementProperty =
+ SimulationWorldType?.GetProperty("UseSimulationMovement", PublicInstance);
+
+ private static readonly PropertyInfo LastResolvedAreaHitCountProperty =
+ SimulationWorldType?.GetProperty("LastResolvedAreaHitCount", PublicInstance);
+
+ private static readonly FieldInfo CollisionQueryInputsField =
+ SimulationWorldType?.GetField("_collisionQueryInputs", NonPublicInstance);
+
+ private static readonly FieldInfo AreaCollisionRequestsField =
+ SimulationWorldType?.GetField("_areaCollisionRequests", NonPublicInstance);
+
+ private static readonly MethodInfo EnemyProjectileOnUpdateMethod =
+ EnemyProjectileType?.GetMethod("OnUpdate", NonPublicInstance);
+
+ private static readonly FieldInfo EnemyProjectileDataField =
+ EnemyProjectileType?.GetField("_projectileData", NonPublicInstance);
+
+ private static readonly FieldInfo EnemyProjectileIsActiveField =
+ EnemyProjectileType?.GetField("_isActive", NonPublicInstance);
+
+ private static readonly FieldInfo EnemyProjectileIsSimulationDrivenField =
+ EnemyProjectileType?.GetField("_isSimulationDriven", NonPublicInstance);
+
+ private static readonly PropertyInfo GameEntrySimulationWorldProperty =
+ GameEntryType?.GetProperty("SimulationWorld", PublicStatic);
+
+ private static readonly MethodInfo GameEntryGetSimulationWorldMethod =
+ GameEntrySimulationWorldProperty?.GetGetMethod(true);
+
+ private static readonly MethodInfo GameEntrySetSimulationWorldMethod =
+ GameEntrySimulationWorldProperty?.GetSetMethod(true);
+
+ private static readonly PropertyInfo GameEntryEnemyManagerProperty =
+ GameEntryType?.GetProperty("EnemyManager", PublicStatic);
+
+ private static readonly MethodInfo GameEntryGetEnemyManagerMethod =
+ GameEntryEnemyManagerProperty?.GetGetMethod(true);
+
+ private static readonly MethodInfo GameEntrySetEnemyManagerMethod =
+ GameEntryEnemyManagerProperty?.GetSetMethod(true);
+
+ private static readonly PropertyInfo GameEntryProcedureProperty =
+ GameEntryType?.GetProperty("Procedure", PublicStatic);
+
+ private static readonly MethodInfo GameEntryGetProcedureMethod =
+ GameEntryProcedureProperty?.GetGetMethod(true);
+
+ private static readonly MethodInfo GameEntrySetProcedureMethod =
+ GameEntryProcedureProperty?.GetSetMethod(true);
+
+ private static readonly MethodInfo HealthComponentOnInitMethod =
+ HealthComponentType?.GetMethod("OnInit", PublicInstance);
+
+ private static readonly FieldInfo ProjectileMaxDistanceFromPlayerField =
+ SimulationWorldType?.GetField("_projectileMaxDistanceFromPlayer", NonPublicInstance);
+
+ private static readonly FieldInfo ProjectileMaxVerticalOffsetFromPlayerField =
+ SimulationWorldType?.GetField("_projectileMaxVerticalOffsetFromPlayer", NonPublicInstance);
+
private GameObject _worldGameObject;
private Component _worldComponent;
@@ -61,23 +194,71 @@ namespace Simulation.Tests.PlayMode
Assert.NotNull(SimulationWorldType, "SimulationWorld type lookup failed.");
Assert.NotNull(SimulationTickContextType, "SimulationTickContext type lookup failed.");
Assert.NotNull(EnemySimDataType, "EnemySimData type lookup failed.");
+ Assert.NotNull(ProjectileSimDataType, "ProjectileSimData type lookup failed.");
+ Assert.NotNull(EnemyProjectileType, "EnemyProjectile type lookup failed.");
+ Assert.NotNull(EnemyProjectileDataType, "EnemyProjectileData type lookup failed.");
+ Assert.NotNull(CampTypeType, "CampType type lookup failed.");
+ Assert.NotNull(GameEntryType, "GameEntry type lookup failed.");
Assert.NotNull(EnemySeparationSolverProviderType, "EnemySeparationSolverProvider type lookup failed.");
+ Assert.NotNull(EnemyManagerComponentType, "EnemyManagerComponent type lookup failed.");
+ Assert.NotNull(PlayerType, "Player type lookup failed.");
+ Assert.NotNull(EntityBaseType, "EntityBase type lookup failed.");
+ Assert.NotNull(HealthComponentType, "HealthComponent type lookup failed.");
+ Assert.NotNull(ProcedureComponentType, "ProcedureComponent type lookup failed.");
+ Assert.NotNull(ProcedureGameType, "ProcedureGame type lookup failed.");
+ Assert.NotNull(GameStateTypeType, "GameStateType type lookup failed.");
+ Assert.NotNull(ProcedureManagerType, "ProcedureManager type lookup failed.");
+ Assert.NotNull(ProcedureManagerInterfaceType, "IProcedureManager type lookup failed.");
+ Assert.NotNull(FsmOpenGenericType, "Fsm`1 type lookup failed.");
Assert.NotNull(UpsertEnemyMethod, "UpsertEnemy reflection lookup failed.");
Assert.NotNull(RemoveEnemyByEntityIdMethod, "RemoveEnemyByEntityId reflection lookup failed.");
+ Assert.NotNull(UpsertProjectileMethod, "UpsertProjectile reflection lookup failed.");
+ Assert.NotNull(RemoveProjectileByEntityIdMethod, "RemoveProjectileByEntityId reflection lookup failed.");
Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed.");
Assert.NotNull(TickMethod, "Tick reflection lookup failed.");
- Assert.NotNull(SetUseSimulationMovementMethod, "SetUseSimulationMovement reflection lookup failed.");
+ Assert.NotNull(TryGetNearestEnemyEntityIdMethod, "TryGetNearestEnemyEntityId reflection lookup failed.");
+ Assert.NotNull(TryRequestAreaCollisionMethod, "TryRequestAreaCollision reflection lookup failed.");
+ Assert.NotNull(ClearSimulationStateMethod, "ClearSimulationState reflection lookup failed.");
Assert.NotNull(UseGridBucketSolverMethod, "UseGridBucketSolver reflection lookup failed.");
Assert.NotNull(EnemiesProperty, "Enemies property reflection lookup failed.");
+ Assert.NotNull(ProjectilesProperty, "Projectiles property reflection lookup failed.");
+ Assert.NotNull(CollisionCandidateCountProperty, "CollisionCandidateCount property reflection lookup failed.");
+ Assert.NotNull(UseSimulationMovementProperty, "UseSimulationMovement property reflection lookup failed.");
+ Assert.NotNull(UseSimulationMovementField, "_useSimulationMovement field reflection lookup failed.");
+ Assert.NotNull(LastResolvedAreaHitCountProperty, "LastResolvedAreaHitCount property reflection lookup failed.");
+ Assert.NotNull(CollisionQueryInputsField, "Collision query inputs field reflection lookup failed.");
+ Assert.NotNull(AreaCollisionRequestsField, "Area collision requests field reflection lookup failed.");
+ Assert.NotNull(ProjectileMaxDistanceFromPlayerField,
+ "Projectile max distance field reflection lookup failed.");
+ Assert.NotNull(ProjectileMaxVerticalOffsetFromPlayerField,
+ "Projectile max vertical offset field reflection lookup failed.");
+ Assert.NotNull(EnemyProjectileOnUpdateMethod, "EnemyProjectile.OnUpdate reflection lookup failed.");
+ Assert.NotNull(EnemyProjectileDataField, "EnemyProjectile _projectileData reflection lookup failed.");
+ Assert.NotNull(EnemyProjectileIsActiveField, "EnemyProjectile _isActive reflection lookup failed.");
+ Assert.NotNull(EnemyProjectileIsSimulationDrivenField,
+ "EnemyProjectile _isSimulationDriven reflection lookup failed.");
+ Assert.NotNull(GameEntrySimulationWorldProperty, "GameEntry.SimulationWorld property lookup failed.");
+ Assert.NotNull(GameEntryGetSimulationWorldMethod,
+ "GameEntry.SimulationWorld getter reflection lookup failed.");
+ Assert.NotNull(GameEntrySetSimulationWorldMethod,
+ "GameEntry.SimulationWorld setter reflection lookup failed.");
+ Assert.NotNull(GameEntryEnemyManagerProperty, "GameEntry.EnemyManager property lookup failed.");
+ Assert.NotNull(GameEntryGetEnemyManagerMethod, "GameEntry.EnemyManager getter reflection lookup failed.");
+ Assert.NotNull(GameEntrySetEnemyManagerMethod, "GameEntry.EnemyManager setter reflection lookup failed.");
+ Assert.NotNull(GameEntryProcedureProperty, "GameEntry.Procedure property lookup failed.");
+ Assert.NotNull(GameEntryGetProcedureMethod, "GameEntry.Procedure getter reflection lookup failed.");
+ Assert.NotNull(GameEntrySetProcedureMethod, "GameEntry.Procedure setter reflection lookup failed.");
+ Assert.NotNull(HealthComponentOnInitMethod, "HealthComponent.OnInit reflection lookup failed.");
_worldGameObject = new GameObject("SimulationWorldPlayModeTests");
_worldComponent = _worldGameObject.AddComponent(SimulationWorldType);
// Isolate PlayMode regression to simulation behavior only.
EntitySyncField?.SetValue(_worldComponent, null);
- PresentationField?.SetValue(_worldComponent, null);
+ TransformSyncField?.SetValue(_worldComponent, null);
+ HitPresentationField?.SetValue(_worldComponent, null);
- SetUseSimulationMovementMethod.Invoke(_worldComponent, new object[] { true });
+ SetUseSimulationMovement(true);
UseGridBucketSolverMethod.Invoke(null, new object[] { 1f });
yield return null;
}
@@ -88,7 +269,8 @@ namespace Simulation.Tests.PlayMode
if (_worldComponent != null)
{
EntitySyncField?.SetValue(_worldComponent, null);
- PresentationField?.SetValue(_worldComponent, null);
+ TransformSyncField?.SetValue(_worldComponent, null);
+ HitPresentationField?.SetValue(_worldComponent, null);
}
if (_worldGameObject != null)
@@ -155,7 +337,414 @@ namespace Simulation.Tests.PlayMode
yield break;
}
- private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange)
+ [UnityTest]
+ public IEnumerator TickEnemies_ChasesPlayer_WhenJobSimulationChannelEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 3201, position: Vector3.zero, speed: 2f, attackRange: 1f));
+
+ InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: new Vector3(10f, 0f, 0f));
+
+ object enemy = GetEnemyAt(0);
+ Assert.That((int)GetField(enemy, "State"), Is.EqualTo(1));
+ Vector3 position = (Vector3)GetField(enemy, "Position");
+ Vector3 forward = (Vector3)GetField(enemy, "Forward");
+ Assert.That(position.x, Is.EqualTo(2f).Within(0.0001f));
+ Assert.That(position.z, Is.EqualTo(0f).Within(0.0001f));
+ Assert.That(forward.x, Is.EqualTo(1f).Within(0.0001f));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickEnemies_MatchesOutput_AfterClearSimulationState()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 3251, position: Vector3.zero, speed: 2f, attackRange: 1f));
+ InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: new Vector3(10f, 0f, 0f));
+
+ object nonBurstEnemy = GetEnemyAt(0);
+ int nonBurstState = (int)GetField(nonBurstEnemy, "State");
+ Vector3 nonBurstPosition = (Vector3)GetField(nonBurstEnemy, "Position");
+ Vector3 nonBurstForward = (Vector3)GetField(nonBurstEnemy, "Forward");
+
+ ClearSimulationStateMethod.Invoke(_worldComponent, null);
+
+ SetUseSimulationMovement(true);
+ UpsertEnemy(CreateEnemy(entityId: 3251, position: Vector3.zero, speed: 2f, attackRange: 1f));
+ InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: new Vector3(10f, 0f, 0f));
+
+ object burstEnemy = GetEnemyAt(0);
+ int burstState = (int)GetField(burstEnemy, "State");
+ Vector3 burstPosition = (Vector3)GetField(burstEnemy, "Position");
+ Vector3 burstForward = (Vector3)GetField(burstEnemy, "Forward");
+
+ Assert.That(burstState, Is.EqualTo(nonBurstState));
+ Assert.That((burstPosition - nonBurstPosition).sqrMagnitude, Is.LessThanOrEqualTo(1e-8f));
+ Assert.That((burstForward - nonBurstForward).sqrMagnitude, Is.LessThanOrEqualTo(1e-8f));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TryGetNearestEnemyEntityId_SelectsNearestBucketCandidate_WhenJobSimulationEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 3301, position: new Vector3(1f, 0f, 0f), speed: 0f, attackRange: 1f));
+ UpsertEnemy(CreateEnemy(entityId: 3302, position: new Vector3(6f, 0f, 0f), speed: 0f, attackRange: 1f));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ object[] parameters = { Vector3.zero, 100f, 0 };
+ bool found = (bool)TryGetNearestEnemyEntityIdMethod.Invoke(_worldComponent, parameters);
+ int nearestEntityId = (int)parameters[2];
+
+ Assert.IsTrue(found);
+ Assert.That(nearestEntityId, Is.EqualTo(3301));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickEnemies_SeparatesOverlappedEnemies_WhenJobSimulationEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 3401, position: new Vector3(0f, 0f, 0f), speed: 1f, attackRange: 0.1f,
+ avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 2));
+ UpsertEnemy(CreateEnemy(entityId: 3402, position: new Vector3(0.1f, 0f, 0f), speed: 1f, attackRange: 0.1f,
+ avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 2));
+
+ InvokeTick(deltaTime: 0.1f, realDeltaTime: 0.1f, playerPosition: new Vector3(10f, 0f, 0f));
+
+ object enemyA = GetEnemyAt(0);
+ object enemyB = GetEnemyAt(1);
+ Vector3 posA = (Vector3)GetField(enemyA, "Position");
+ Vector3 posB = (Vector3)GetField(enemyB, "Position");
+ posA.y = 0f;
+ posB.y = 0f;
+ float distance = Vector3.Distance(posA, posB);
+ Assert.That(distance, Is.GreaterThanOrEqualTo(0.89f));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickEnemies_SeparatesOverlappedEnemies_WhenPlayerIsStaticAndInRange()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 3411, position: new Vector3(0f, 0f, 0f), speed: 1f, attackRange: 10f,
+ avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 3));
+ UpsertEnemy(CreateEnemy(entityId: 3412, position: new Vector3(0.05f, 0f, 0f), speed: 1f, attackRange: 10f,
+ avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 3));
+
+ InvokeTick(deltaTime: 0.1f, realDeltaTime: 0.1f, playerPosition: Vector3.zero);
+
+ object enemyA = GetEnemyAt(0);
+ object enemyB = GetEnemyAt(1);
+ Assert.That((int)GetField(enemyA, "State"), Is.EqualTo(2));
+ Assert.That((int)GetField(enemyB, "State"), Is.EqualTo(2));
+
+ Vector3 posA = (Vector3)GetField(enemyA, "Position");
+ Vector3 posB = (Vector3)GetField(enemyB, "Position");
+ posA.y = 0f;
+ posB.y = 0f;
+ float distance = Vector3.Distance(posA, posB);
+ Assert.That(distance, Is.GreaterThanOrEqualTo(0.5f));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickProjectiles_MovesAndUpdatesLifetime_WhenJobSimulationEnabled()
+ {
+ UpsertProjectile(CreateProjectile(entityId: 5401, position: Vector3.zero, forward: Vector3.right,
+ velocity: new Vector3(2f, 0f, 0f), speed: 0f, lifeTime: 2f, age: 0f, active: true,
+ remainingLifetime: 2f, state: 0));
+
+ InvokeTick(deltaTime: 0.5f, realDeltaTime: 0.5f, playerPosition: Vector3.zero);
+
+ Assert.That(GetProjectilesCount(), Is.EqualTo(1));
+ object projectile = GetProjectileAt(0);
+ Vector3 position = (Vector3)GetField(projectile, "Position");
+ float age = (float)GetField(projectile, "Age");
+ float remainingLifetime = (float)GetField(projectile, "RemainingLifetime");
+ bool active = (bool)GetField(projectile, "Active");
+
+ Assert.That(position.x, Is.EqualTo(1f).Within(0.0001f));
+ Assert.That(age, Is.EqualTo(0.5f).Within(0.0001f));
+ Assert.That(remainingLifetime, Is.EqualTo(1.5f).Within(0.0001f));
+ Assert.IsTrue(active);
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickProjectiles_ContinuesFromLatestState_AcrossConsecutiveTicks()
+ {
+ UpsertProjectile(CreateProjectile(entityId: 5410, position: Vector3.zero, forward: Vector3.right,
+ velocity: new Vector3(2f, 0f, 0f), speed: 0f, lifeTime: 5f, age: 0f, active: true,
+ remainingLifetime: 5f, state: 0));
+
+ InvokeTick(deltaTime: 0.5f, realDeltaTime: 0.5f, playerPosition: Vector3.zero);
+ object afterJobEnabled = GetProjectileAt(0);
+ Vector3 positionAfterJobEnabled = (Vector3)GetField(afterJobEnabled, "Position");
+ float ageAfterJobEnabled = (float)GetField(afterJobEnabled, "Age");
+ Assert.That(positionAfterJobEnabled.x, Is.EqualTo(1f).Within(0.0001f));
+ Assert.That(ageAfterJobEnabled, Is.EqualTo(0.5f).Within(0.0001f));
+
+ InvokeTick(deltaTime: 0.5f, realDeltaTime: 0.5f, playerPosition: Vector3.zero);
+ object afterSecondTick = GetProjectileAt(0);
+ Vector3 positionAfterSecondTick = (Vector3)GetField(afterSecondTick, "Position");
+ float ageAfterSecondTick = (float)GetField(afterSecondTick, "Age");
+ float remainingLifetimeAfterSecondTick = (float)GetField(afterSecondTick, "RemainingLifetime");
+ bool activeAfterSecondTick = (bool)GetField(afterSecondTick, "Active");
+
+ Assert.That(positionAfterSecondTick.x, Is.EqualTo(2f).Within(0.0001f));
+ Assert.That(ageAfterSecondTick, Is.EqualTo(1f).Within(0.0001f));
+ Assert.That(remainingLifetimeAfterSecondTick, Is.EqualTo(4f).Within(0.0001f));
+ Assert.IsTrue(activeAfterSecondTick);
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator EnemyProjectile_TogglesCollider_WhenSimulationMovementSwitchesAtRuntime()
+ {
+ SetUseSimulationMovement(false);
+
+ GameObject projectileObject = new GameObject("EnemyProjectileColliderTogglePlayMode");
+ Component projectileComponent = null;
+ try
+ {
+ projectileComponent = projectileObject.AddComponent(EnemyProjectileType);
+ Collider projectileCollider = projectileObject.AddComponent();
+ projectileCollider.enabled = true;
+
+ object previousSimulationWorld = GetGameEntrySimulationWorld();
+ SetGameEntrySimulationWorld(_worldComponent);
+ try
+ {
+ object neutralCamp = System.Enum.Parse(CampTypeType, "Neutral");
+ object projectileData = System.Activator.CreateInstance(
+ EnemyProjectileDataType,
+ BindingFlags.Public | BindingFlags.Instance,
+ null,
+ new object[] { 8001, 1, neutralCamp, 1, 0f, 10f, Vector3.forward },
+ null);
+
+ EnemyProjectileDataField.SetValue(projectileComponent, projectileData);
+ EnemyProjectileIsActiveField.SetValue(projectileComponent, true);
+ EnemyProjectileIsSimulationDrivenField.SetValue(projectileComponent, false);
+
+ InvokeEnemyProjectileUpdate(projectileComponent, 0.016f, 0.016f);
+ Assert.IsTrue(projectileCollider.enabled);
+
+ SetUseSimulationMovement(true);
+ InvokeEnemyProjectileUpdate(projectileComponent, 0.016f, 0.016f);
+ Assert.IsFalse(projectileCollider.enabled);
+
+ SetUseSimulationMovement(false);
+ InvokeEnemyProjectileUpdate(projectileComponent, 0.016f, 0.016f);
+ Assert.IsTrue(projectileCollider.enabled);
+ }
+ finally
+ {
+ SetGameEntrySimulationWorld(previousSimulationWorld);
+ }
+ }
+ finally
+ {
+ if (projectileObject != null)
+ {
+ Object.Destroy(projectileObject);
+ }
+ }
+
+ yield return null;
+ }
+
+ [UnityTest]
+ public IEnumerator RemoveProjectileByEntityId_RemapIndex_ForMovedProjectile()
+ {
+ UpsertProjectile(CreateProjectile(entityId: 5405, position: new Vector3(0f, 0f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 3f, age: 0f, active: true,
+ remainingLifetime: 3f, state: 0));
+ UpsertProjectile(CreateProjectile(entityId: 5406, position: new Vector3(1f, 0f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 3f, age: 0f, active: true,
+ remainingLifetime: 3f, state: 0));
+ UpsertProjectile(CreateProjectile(entityId: 5407, position: new Vector3(2f, 0f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 3f, age: 0f, active: true,
+ remainingLifetime: 3f, state: 0));
+
+ bool removed = RemoveProjectileByEntityId(5406);
+ bool removedMoved = RemoveProjectileByEntityId(5407);
+
+ Assert.IsTrue(removed);
+ Assert.That(GetProjectilesCount(), Is.EqualTo(1));
+ Assert.IsTrue(removedMoved);
+ Assert.That((int)GetField(GetProjectileAt(0), "EntityId"), Is.EqualTo(5405));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickProjectiles_RecyclesWhenExceedingPlayerDistance_WhenJobSimulationEnabled()
+ {
+ ProjectileMaxDistanceFromPlayerField.SetValue(_worldComponent, 5f);
+ ProjectileMaxVerticalOffsetFromPlayerField.SetValue(_worldComponent, 1000f);
+ UpsertProjectile(CreateProjectile(entityId: 5408, position: new Vector3(6f, 0f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 3f, age: 0f, active: true,
+ remainingLifetime: 3f, state: 0));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ Assert.That(GetProjectilesCount(), Is.EqualTo(0));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickProjectiles_RecyclesWhenExceedingVerticalOffset_WhenJobSimulationEnabled()
+ {
+ ProjectileMaxDistanceFromPlayerField.SetValue(_worldComponent, 0f);
+ ProjectileMaxVerticalOffsetFromPlayerField.SetValue(_worldComponent, 1f);
+ UpsertProjectile(CreateProjectile(entityId: 5409, position: new Vector3(0f, 2f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 3f, age: 0f, active: true,
+ remainingLifetime: 3f, state: 0));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ Assert.That(GetProjectilesCount(), Is.EqualTo(0));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickProjectiles_RecyclesExpiredProjectile_WhenJobSimulationEnabled()
+ {
+ UpsertProjectile(CreateProjectile(entityId: 5402, position: Vector3.zero, forward: Vector3.forward,
+ velocity: Vector3.zero, speed: 0f, lifeTime: 1f, age: 0.95f, active: true, remainingLifetime: 0.05f,
+ state: 0));
+
+ InvokeTick(deltaTime: 0.1f, realDeltaTime: 0.1f, playerPosition: Vector3.zero);
+
+ Assert.That(GetProjectilesCount(), Is.EqualTo(0));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickProjectiles_BuildsCollisionCandidatesAgainstEnemies_WhenJobSimulationEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 5501, position: Vector3.zero, speed: 0f, attackRange: 1f));
+ UpsertProjectile(CreateProjectile(entityId: 5502, position: Vector3.zero, forward: Vector3.forward,
+ velocity: Vector3.zero, speed: 0f, lifeTime: 2f, age: 0f, active: true, remainingLifetime: 2f,
+ state: 0));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ Assert.That(GetCollisionCandidateCount(), Is.GreaterThan(0));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickProjectiles_BuildsCollisionCandidates_WithLatestEnemyMovement_WhenJobSimulationEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 5511, position: new Vector3(2f, 0f, 0f), speed: 1f, attackRange: 0.1f));
+ UpsertProjectile(CreateProjectile(entityId: 5512, position: new Vector3(1f, 0f, 0f),
+ forward: Vector3.forward, velocity: Vector3.zero, speed: 0f, lifeTime: 2f, age: 0f, active: true,
+ remainingLifetime: 2f, state: 0));
+
+ InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: Vector3.zero);
+
+ Assert.That(GetCollisionCandidateCount(), Is.GreaterThan(0));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickProjectiles_ExpiresAfterCollisionCandidateConsumed_WhenJobSimulationEnabled()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 5503, position: Vector3.zero, speed: 0f, attackRange: 1f));
+ UpsertProjectile(CreateProjectile(entityId: 5504, position: Vector3.zero, forward: Vector3.forward,
+ velocity: Vector3.zero, speed: 0f, lifeTime: 10f, age: 0f, active: true, remainingLifetime: 10f,
+ state: 0));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ Assert.That(GetProjectilesCount(), Is.EqualTo(0));
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator TickProjectiles_LimitsCandidatesToMaxTargets_IncludingPlayerCandidate()
+ {
+ object previousEnemyManager = GetGameEntryEnemyManager();
+ GameObject enemyManagerObject = new GameObject("EnemyManagerMaxTargetsPlayMode");
+ GameObject playerObject = new GameObject("PlayerTargetMaxTargetsPlayMode");
+ try
+ {
+ Component enemyManager = enemyManagerObject.AddComponent(EnemyManagerComponentType);
+ Component player = playerObject.AddComponent(PlayerType);
+ Component healthComponent = playerObject.AddComponent(HealthComponentType);
+ HealthComponentOnInitMethod.Invoke(healthComponent, new object[] { 100, null });
+ SetPrivateField(player, "_healthComponent", healthComponent);
+ SetPrivateField(player, "m_CachedTransform", playerObject.transform);
+ SetPrivateField(player, "m_Available", true);
+
+ object enemyById = Activator.CreateInstance(typeof(Dictionary<,>).MakeGenericType(typeof(int), EntityBaseType));
+ enemyById.GetType().GetMethod("Add")?.Invoke(enemyById, new object[] { -1, player });
+ object enemies = Activator.CreateInstance(typeof(List<>).MakeGenericType(EntityBaseType));
+ enemies.GetType().GetMethod("Add")?.Invoke(enemies, new object[] { player });
+ SetPrivateField(enemyManager, "_enemyById", enemyById);
+ SetPrivateField(enemyManager, "_enemies", enemies);
+ SetGameEntryEnemyManager(enemyManager);
+
+ UpsertEnemy(CreateEnemy(entityId: 5521, position: Vector3.zero, speed: 0f, attackRange: 1f));
+ UpsertProjectile(CreateProjectile(entityId: 5522, position: Vector3.zero, forward: Vector3.forward,
+ velocity: Vector3.zero, speed: 0f, lifeTime: 1f, age: 0f, active: true, remainingLifetime: 1f,
+ state: 0));
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+
+ Assert.That(GetCollisionCandidateCount(), Is.EqualTo(1));
+ }
+ finally
+ {
+ SetGameEntryEnemyManager(previousEnemyManager);
+ Object.Destroy(enemyManagerObject);
+ Object.Destroy(playerObject);
+ }
+
+ yield return null;
+ }
+
+ [UnityTest]
+ public IEnumerator TryRequestAreaCollision_ReturnsFalse_WhenSimulationMovementDisabled()
+ {
+ SetUseSimulationMovement(false);
+ object[] requestArgs = { 5530, 5530, Vector3.zero, 1f, 1 };
+ bool requestResult = (bool)TryRequestAreaCollisionMethod.Invoke(_worldComponent, requestArgs);
+
+ Assert.IsFalse(requestResult);
+ Assert.IsFalse((bool)UseSimulationMovementProperty.GetValue(_worldComponent));
+
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator EnqueueAreaQuery_CapturesInactiveSourceSnapshot_WhenSourceEntityUnavailable()
+ {
+ UpsertEnemy(CreateEnemy(entityId: 5531, position: Vector3.zero, speed: 0f, attackRange: 1f));
+
+ object[] enqueueArgs = { 99999, 99999, Vector3.zero, 1f, 1 };
+ bool enqueueResult = (bool)TryRequestAreaCollisionMethod.Invoke(_worldComponent, enqueueArgs);
+ Assert.IsTrue(enqueueResult);
+
+ object areaCollisionRequests = AreaCollisionRequestsField.GetValue(_worldComponent);
+ Assert.NotNull(areaCollisionRequests);
+ PropertyInfo requestCountProperty = areaCollisionRequests.GetType().GetProperty("Count", PublicInstance);
+ int requestCount = (int)requestCountProperty.GetValue(areaCollisionRequests);
+ Assert.That(requestCount, Is.GreaterThan(0));
+
+ PropertyInfo requestItemProperty = areaCollisionRequests.GetType().GetProperty("Item", PublicInstance);
+ object firstRequest = requestItemProperty.GetValue(areaCollisionRequests, new object[] { 0 });
+ FieldInfo requestSnapshotField =
+ firstRequest.GetType().GetField("SourceWasActiveAtQueryTime", PublicInstance);
+ Assert.NotNull(requestSnapshotField);
+ bool requestSnapshot = (bool)requestSnapshotField.GetValue(firstRequest);
+ Assert.IsFalse(requestSnapshot);
+
+ InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero);
+ Assert.That(GetLastResolvedAreaHitCount(), Is.EqualTo(0));
+ yield break;
+ }
+
+ private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange,
+ bool avoidEnemyOverlap = false, float enemyBodyRadius = 0.45f, int separationIterations = 1)
{
object enemy = System.Activator.CreateInstance(EnemySimDataType);
SetField(ref enemy, "EntityId", entityId);
@@ -164,14 +753,32 @@ namespace Simulation.Tests.PlayMode
SetField(ref enemy, "Rotation", Quaternion.identity);
SetField(ref enemy, "Speed", speed);
SetField(ref enemy, "AttackRange", attackRange);
- SetField(ref enemy, "AvoidEnemyOverlap", false);
- SetField(ref enemy, "EnemyBodyRadius", 0.45f);
- SetField(ref enemy, "SeparationIterations", 1);
+ SetField(ref enemy, "AvoidEnemyOverlap", avoidEnemyOverlap);
+ SetField(ref enemy, "EnemyBodyRadius", enemyBodyRadius);
+ SetField(ref enemy, "SeparationIterations", separationIterations);
SetField(ref enemy, "TargetType", 0);
SetField(ref enemy, "State", 0);
return enemy;
}
+ private object CreateProjectile(int entityId, Vector3 position, Vector3 forward, Vector3 velocity, float speed,
+ float lifeTime, float age, bool active, float remainingLifetime, int state)
+ {
+ object projectile = System.Activator.CreateInstance(ProjectileSimDataType);
+ SetField(ref projectile, "EntityId", entityId);
+ SetField(ref projectile, "OwnerEntityId", 0);
+ SetField(ref projectile, "Position", position);
+ SetField(ref projectile, "Forward", forward);
+ SetField(ref projectile, "Velocity", velocity);
+ SetField(ref projectile, "Speed", speed);
+ SetField(ref projectile, "LifeTime", lifeTime);
+ SetField(ref projectile, "Age", age);
+ SetField(ref projectile, "Active", active);
+ SetField(ref projectile, "RemainingLifetime", remainingLifetime);
+ SetField(ref projectile, "State", state);
+ return projectile;
+ }
+
private void InvokeTick(float deltaTime, float realDeltaTime, Vector3 playerPosition)
{
object tickContext = System.Activator.CreateInstance(
@@ -184,16 +791,67 @@ namespace Simulation.Tests.PlayMode
TickMethod.Invoke(_worldComponent, new[] { tickContext });
}
+ private void SetUseSimulationMovement(bool enabled)
+ {
+ UseSimulationMovementField.SetValue(_worldComponent, enabled);
+ }
+
private void UpsertEnemy(object enemy)
{
UpsertEnemyMethod.Invoke(_worldComponent, new[] { enemy });
}
+ private void UpsertProjectile(object projectile)
+ {
+ UpsertProjectileMethod.Invoke(_worldComponent, new[] { projectile });
+ }
+
private bool RemoveEnemyByEntityId(int entityId)
{
return (bool)RemoveEnemyByEntityIdMethod.Invoke(_worldComponent, new object[] { entityId });
}
+ private bool RemoveProjectileByEntityId(int entityId)
+ {
+ return (bool)RemoveProjectileByEntityIdMethod.Invoke(_worldComponent, new object[] { entityId });
+ }
+
+ private static object GetGameEntrySimulationWorld()
+ {
+ return GameEntryGetSimulationWorldMethod.Invoke(null, null);
+ }
+
+ private static void SetGameEntrySimulationWorld(object simulationWorld)
+ {
+ GameEntrySetSimulationWorldMethod.Invoke(null, new[] { simulationWorld });
+ }
+
+ private static object GetGameEntryEnemyManager()
+ {
+ return GameEntryGetEnemyManagerMethod.Invoke(null, null);
+ }
+
+ private static void SetGameEntryEnemyManager(object enemyManager)
+ {
+ GameEntrySetEnemyManagerMethod.Invoke(null, new[] { enemyManager });
+ }
+
+ private static object GetGameEntryProcedure()
+ {
+ return GameEntryGetProcedureMethod.Invoke(null, null);
+ }
+
+ private static void SetGameEntryProcedure(object procedureComponent)
+ {
+ GameEntrySetProcedureMethod.Invoke(null, new[] { procedureComponent });
+ }
+
+ private static void InvokeEnemyProjectileUpdate(Component projectileComponent, float elapseSeconds,
+ float realElapseSeconds)
+ {
+ EnemyProjectileOnUpdateMethod.Invoke(projectileComponent, new object[] { elapseSeconds, realElapseSeconds });
+ }
+
private bool TryGetEnemyData(int entityId, out object enemyData)
{
object boxedDefault = System.Activator.CreateInstance(EnemySimDataType);
@@ -217,6 +875,30 @@ namespace Simulation.Tests.PlayMode
return (int)countProperty.GetValue(enemies);
}
+ private object GetProjectileAt(int index)
+ {
+ object projectiles = ProjectilesProperty.GetValue(_worldComponent);
+ PropertyInfo itemProperty = projectiles.GetType().GetProperty("Item", PublicInstance);
+ return itemProperty.GetValue(projectiles, new object[] { index });
+ }
+
+ private int GetProjectilesCount()
+ {
+ object projectiles = ProjectilesProperty.GetValue(_worldComponent);
+ PropertyInfo countProperty = projectiles.GetType().GetProperty("Count", PublicInstance);
+ return (int)countProperty.GetValue(projectiles);
+ }
+
+ private int GetCollisionCandidateCount()
+ {
+ return (int)CollisionCandidateCountProperty.GetValue(_worldComponent);
+ }
+
+ private int GetLastResolvedAreaHitCount()
+ {
+ return (int)LastResolvedAreaHitCountProperty.GetValue(_worldComponent);
+ }
+
private static object GetField(object target, string fieldName)
{
FieldInfo field = target.GetType().GetField(fieldName, PublicInstance);
@@ -228,5 +910,23 @@ namespace Simulation.Tests.PlayMode
FieldInfo field = target.GetType().GetField(fieldName, PublicInstance);
field.SetValue(target, value);
}
+
+ private static void SetPrivateField(object target, string fieldName, object value)
+ {
+ Type type = target.GetType();
+ while (type != null)
+ {
+ FieldInfo field = type.GetField(fieldName, NonPublicInstance);
+ if (field != null)
+ {
+ field.SetValue(target, value);
+ return;
+ }
+
+ type = type.BaseType;
+ }
+
+ Assert.Fail($"Field '{fieldName}' was not found on type '{target.GetType().FullName}'.");
+ }
}
}
diff --git a/Packages/manifest.json b/Packages/manifest.json
index d54123a..b6ab6e0 100644
--- a/Packages/manifest.json
+++ b/Packages/manifest.json
@@ -1,14 +1,17 @@
{
"dependencies": {
+ "com.unity.burst": "1.8.28",
"com.unity.collab-proxy": "2.11.2",
+ "com.unity.collections": "2.5.7",
"com.unity.ide.rider": "3.0.39",
"com.unity.ide.visualstudio": "2.0.22",
"com.unity.ide.vscode": "1.2.5",
"com.unity.inputsystem": "1.14.2",
+ "com.unity.mathematics": "1.3.2",
"com.unity.nuget.newtonsoft-json": "3.2.2",
"com.unity.render-pipelines.universal": "14.0.12",
"com.unity.test-framework": "1.1.33",
- "com.unity.textmeshpro": "3.0.7",
+ "com.unity.textmeshpro": "3.0.9",
"com.unity.timeline": "1.7.7",
"com.unity.ugui": "1.0.0",
"com.unity.visualscripting": "1.9.4",
diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json
index 9f838b9..49c5036 100644
--- a/Packages/packages-lock.json
+++ b/Packages/packages-lock.json
@@ -1,8 +1,8 @@
{
"dependencies": {
"com.unity.burst": {
- "version": "1.8.21",
- "depth": 1,
+ "version": "1.8.28",
+ "depth": 0,
"source": "registry",
"dependencies": {
"com.unity.mathematics": "1.2.1",
@@ -17,9 +17,22 @@
"dependencies": {},
"url": "https://packages.unity.cn"
},
+ "com.unity.collections": {
+ "version": "2.5.7",
+ "depth": 0,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.burst": "1.8.19",
+ "com.unity.mathematics": "1.3.2",
+ "com.unity.test-framework": "1.4.6",
+ "com.unity.nuget.mono-cecil": "1.11.5",
+ "com.unity.test-framework.performance": "3.0.3"
+ },
+ "url": "https://packages.unity.cn"
+ },
"com.unity.ext.nunit": {
- "version": "1.0.6",
- "depth": 1,
+ "version": "2.0.3",
+ "depth": 2,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.cn"
@@ -59,7 +72,14 @@
"url": "https://packages.unity.cn"
},
"com.unity.mathematics": {
- "version": "1.2.6",
+ "version": "1.3.2",
+ "depth": 0,
+ "source": "registry",
+ "dependencies": {},
+ "url": "https://packages.unity.cn"
+ },
+ "com.unity.nuget.mono-cecil": {
+ "version": "1.11.5",
"depth": 1,
"source": "registry",
"dependencies": {},
@@ -120,18 +140,28 @@
}
},
"com.unity.test-framework": {
- "version": "1.1.33",
- "depth": 0,
+ "version": "1.4.6",
+ "depth": 1,
"source": "registry",
"dependencies": {
- "com.unity.ext.nunit": "1.0.6",
+ "com.unity.ext.nunit": "2.0.3",
"com.unity.modules.imgui": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0"
},
"url": "https://packages.unity.cn"
},
+ "com.unity.test-framework.performance": {
+ "version": "3.0.3",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.test-framework": "1.1.31",
+ "com.unity.modules.jsonserialize": "1.0.0"
+ },
+ "url": "https://packages.unity.cn"
+ },
"com.unity.textmeshpro": {
- "version": "3.0.7",
+ "version": "3.0.9",
"depth": 0,
"source": "registry",
"dependencies": {
diff --git a/docs/CodeX-TODO.md b/docs/CodeX-TODO.md
new file mode 100644
index 0000000..46b8f95
--- /dev/null
+++ b/docs/CodeX-TODO.md
@@ -0,0 +1,467 @@
+# CodeX TODO
+
+## SimulationWorld 路线收敛
+
+### 当前结论
+
+- 当前 `SimulationWorld` 唯一还成体系的执行路径,就是现有 Burst Job 管线。
+- 这条管线已经覆盖:
+ - 敌人移动:`EnemyMovementBurstJob`
+ - 敌人分离:`BuildEnemySeparationBucketsBurstJob` + `EnemySeparationBurstJob`
+ - 投射物移动:`ProjectileMovementBurstJob`
+ - 碰撞 broad-phase:`BuildCollisionBucketsBurstJob` + `QueryCollisionCandidatesBurstJob`
+- 与之并存的旧路径仍然散落在实体和组件里:
+ - `MovementComponent.OnUpdate()` 仍在直接推进位移
+ - `EnemySeparationSolverProvider` 仍在负责旧互斥逻辑
+ - `EnemyBase` / `MeleeEnemy` / `RemoteEnemy` / `EnemyProjectile` / `NearestTargetSelector` 仍在用 `UseSimulationMovement` 做双路径分支
+- 文档中的 `UseJobSimulation` / `UseBurstJobs` 当前只有说明,没有代码实现。
+- 因此当前要做的不是“抛弃 Burst 路线”,而是反过来:
+ - 保留 `SimulationWorld` 的 Burst 逻辑作为唯一执行路径
+ - 删除所有旧组件驱动/旧 fallback 路径
+ - 把组件层收敛成 `SimulationWorld` 的输入、注册和表现壳层
+
+### 目标定义
+
+- `SimulationWorld` 成为唯一的运行时仿真执行入口。
+- 实体自身不再计算“下一帧位置”,而是只向 `SimulationWorld` 提供输入和配置。
+- `MovementComponent` 不再直接改 `Transform`,只负责:
+ - 保存移动开关/方向/速度等输入态
+ - 向 `SimulationWorld` 注册或同步这些输入
+- 实体表现层只消费 `SimulationWorld` 的输出结果,不再自己推进位移或互斥。
+- 删除所有“Simulation / 非 Simulation”双路径分支,避免行为在两套逻辑里分叉。
+
+### 必改项
+
+#### 1. Tick 主入口收敛
+
+- 文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.cs`
+- 处理:
+ - 保留 `TickSimulationPipeline(in SimulationTickContext context)` 作为唯一执行主入口
+ - 删除“开关关闭后直接跳过 SimulationWorld”的旧语义
+ - 重定义 `UseSimulationMovement`:
+ - 要么删除
+ - 要么仅保留为全局停机开关,而不是双路径路由开关
+- 备注:
+ - `GameStateBattle.OnUpdate` 目前已经把 `SimulationWorld.Tick(...)` 放在战斗主循环里,这个方向是对的
+
+#### 2. 保留并固化 Burst/Job 敌人执行面
+
+- 文件:`Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs`
+- 处理:
+ - 保留 Burst Job 调度,明确这就是敌人的唯一移动/互斥路径
+ - 把实体侧“追击/停下/朝向目标”的输入全部规范成写入 sim state,而不是各实体自己推动
+ - 明确 `_enemies` 是敌人移动和互斥的唯一状态源
+
+#### 3. 保留并固化 Burst/Job 投射物执行面
+
+- 文件:`Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs`
+- 处理:
+ - 保留投射物移动 Job,作为唯一的投射物位置推进路径
+ - 删除 `EnemyProjectile.OnUpdate` 中的自驱动移动和寿命推进逻辑
+ - `EnemyProjectile` 只负责 show/hide、碰撞体开关、表现同步
+
+#### 4. 保留并固化 Burst/Job 碰撞 broad-phase
+
+- 文件:`Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionBroadPhase.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs`
+- 处理:
+ - 保留现有碰撞分桶和候选查询 Job
+ - 把 area/sector/projectile 命中统一视为 `SimulationWorld` 能力,不再给实体侧保留另一套 fallback 查询逻辑
+ - 确认武器系统全部通过 `SimulationWorld` 提供的查询/结算能力工作
+
+#### 5. Native 通道与 Job 数据层保留,但去掉兼容性残留
+
+- 文件:`Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataLifecycle.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataConversion.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobOutputCommit.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/JobStruct/*`
+- 处理:
+ - 保留 Native 通道和 sim/job 转换层
+ - 清理“只为旧测试和旧双路径兼容保留”的字段与注释
+ - 把 job output 回写 `_enemies/_projectiles` 明确为正式架构,而不是过渡方案
+- 备注:
+ - 当前 `_collisionQueryInputs` 和 `_areaCollisionRequests` 还被测试直接反射,后续需要改测试而不是继续迁就反射
+
+#### 6. Presentation 回写链路收敛
+
+- 文件:`Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs`
+- 处理:
+ - 保留 `TransformSync` 从 `_enemies/_projectiles` 回写实体表现
+ - 删除实体自身 `OnUpdate` 中对位置和朝向的直接推进
+ - 明确表现层只消费 sim 输出,不再写回 sim 逻辑状态
+
+#### 7. 生命周期注册与数据容器职责重定义
+
+- 文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.EntityToSimData.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs`
+- 文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.RuntimeModules.cs`
+- 处理:
+ - 明确 `SimulationWorld` 持有 `_enemies/_projectiles/_pickups` 作为正式运行时状态
+ - 补“组件输入 -> sim state”同步接口,替代实体自己推进逻辑
+ - 将注册/反注册、初始数据灌入、索引维护继续收拢在 `SimulationWorld`
+
+#### 8. 战斗入口与 GameEntry 依赖固化
+
+- 文件:`Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs`
+- 文件:`Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs`
+- 文件:`Assets/GameMain/Scripts/Base/GameEntry.Custom.cs`
+- 处理:
+ - 保持 `SimulationWorld` 是战斗主循环核心组件
+ - `ClearSimulationState()` 继续保留,但要确认只清 sim 状态,不引入双路径 reset 语义
+ - `GameEntry` 自动挂载 `SimulationWorld` 是合理的,应继续保留
+
+### 需要一起改的外围依赖
+
+#### 9. MovementComponent 收敛为输入/注册层
+
+- 文件:`Assets/GameMain/Scripts/Components/MovementComponent.cs`
+- 文件:`Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs`
+- 处理:
+ - 删除 `MovementComponent.OnUpdate()` 中直接改 `Transform.position` 的逻辑
+ - 删除对 `EnemySeparationSolverProvider.Resolve/Register/Unregister` 的运行时依赖
+ - `MovementComponent` 仅保留:
+ - 速度/方向/移动开关
+ - 互斥半径/迭代参数
+ - 向 `SimulationWorld` 注册、反注册、同步输入
+- 备注:
+ - 这部分是你刚说的核心:位置计算归 `SimulationWorld`,`MovementComponent` 只负责注册和输入
+
+#### 10. 实体侧双路径和自驱动移动清理
+
+- 文件:`Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyBase.cs`
+- 文件:`Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/MeleeEnemy.cs`
+- 文件:`Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/RemoteEnemy.cs`
+- 文件:`Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs`
+- 文件:`Assets/GameMain/Scripts/Entity/EntityLogic/Player.cs`
+- 文件:`Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs`
+- 处理:
+ - 删除 `UseSimulationMovement` 分支判断
+ - 删除 `MeleeEnemy` / `RemoteEnemy` 中对 `_movementComponent.OnUpdate()` 的调用
+ - 删除 `Player` 中通过 `MovementComponent.OnUpdate()` 推进玩家位置的逻辑,改为只提交输入方向
+ - 删除 `EnemyProjectile` 中“Simulation 驱动 or 自驱动”双模式
+ - `NearestTargetSelector` 直接依赖 `SimulationWorld` 空间索引,不再 fallback 到遍历分支
+
+#### 11. Debug 面板与旧互斥调试项清理
+
+- 文件:`Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs`
+- 处理:
+ - 保留 `SimulationWorld` 的碰撞和 broad-phase 指标
+ - 删除 `EnemySeparationSolverProvider` 的旧 solver 切换 UI 和相关文案
+ - 面板上不再出现“双路径”或“旧 solver”调试入口
+
+#### 12. 测试整体重建
+
+- 文件:`Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs`
+- 文件:`Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs`
+- 处理:
+- 当前测试强依赖:
+ - `_useSimulationMovement`
+ - `_collisionQueryInputs`
+ - `_areaCollisionRequests`
+ - Job 通道与碰撞候选计数
+ - 一旦抛弃现有 Burst/Job 路线,这批测试大概率需要整体重写
+- 建议:
+ - 不要继续维护“反射私有字段 + 验证 Native 通道”的测试模式
+ - 改为验证外部可观察行为:
+ - 敌人移动
+ - 投射物生命周期
+ - 范围命中结果
+ - 实体 hide/remove 生命周期
+
+#### 13. 文档同步
+
+- 文件:`docs/P1.5 Simulation-Supplement.md`
+- 文件:`docs/P2 Job System + Burst 落地.md`
+- 文件:`docs/TodoList.md`
+- 处理:
+ - 标明当前代码已不再保留 P1.5 的 `SimulationWorld` 执行路径
+ - 删除或修正文档里关于 `SetUseSimulationMovement(bool)`、`UseJobSimulation`、`UseBurstJobs` 的失真描述
+
+### 推荐实施顺序
+
+1. 先确认唯一执行路径
+- `SimulationWorld` Burst 管线作为唯一运行时执行路径
+- 实体和组件只保留输入、注册、表现职责
+
+2. 再处理战斗入口
+- 先改 `GameStateBattle` / `GameEntry` / `ProcedureGame`
+- 让运行时明确依赖当前 Burst 管线,不再保留双路径语义
+
+3. 再清理旧组件驱动路径
+- 先收敛 `MovementComponent`
+- 再删敌人/玩家/投射物实体里的自驱动移动
+- 再删旧 fallback 查询和旧互斥 solver
+
+4. 最后重建测试和文档
+- 先让行为稳定
+- 再补新的回归测试和文档
+
+### 当前建议
+
+- 不建议试图“恢复 P1.5 开关”。
+ - 当前代码里并没有一条完整可切换的 P1.5 `SimulationWorld` 路径,恢复开关只会制造更多伪分支。
+- 更合理的做法是直接承认现状:
+ - 保留 Burst 化 `SimulationWorld` 作为唯一执行路径
+ - 删掉所有组件自驱动和 fallback 分支
+- 下一步应按上面的收敛顺序推进,不要再尝试维护“双路径可切换”。
+
+## Weapon 现状梳理
+
+### 1. 数据层结构
+
+- `Assets/GameMain/DataTables/Weapon.txt`
+ - 武器基础数据表。
+ - 当前 `Params` 列已经切换为标准 JSON 对象。
+ - 约束:
+ - 空参数统一写 `{}`
+ - 不再兼容 `[]`
+ - key 名应与对应 `ParamsData` 属性名一致
+
+- `Assets/GameMain/Scripts/DataTable/DRWeapon.cs`
+ - 负责解析武器表基础字段。
+ - 保留两份参数视图:
+ - `ParamsJson`
+ - 原始 JSON 字符串,供 `WeaponData` 强类型反序列化使用。
+ - `Pramas`
+ - 由 `ParamsJson` 转成的 `Dictionary`。
+ - 仅用于描述/UI 等弱类型读取场景。
+
+- `Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponData.cs`
+ - 保留所有武器共用字段:
+ - `Attack`
+ - `Cooldown`
+ - `AttackRange`
+ - `Price`
+ - `Rarity`
+ - `Modifiers`
+ - `ParamsJson`
+ - `Params`
+ - 提供 `ParseParams()` 作为武器子类的强类型参数解析入口。
+
+- `Assets/GameMain/Scripts/Entity/EntityData/Weapon/*`
+ - 每个具体武器子类持有自己的 `ParamsData`:
+ - `WeaponKnifeData -> WeaponKnifeParamsData`
+ - `WeaponHandgunData -> WeaponHandgunParamsData`
+ - `WeaponSlashData -> WeaponSlashParamsData`
+ - `WeaponLightningData -> WeaponLightningParamsData`
+
+### 2. 逻辑层结构
+
+- `Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs`
+ - 武器统一基类。
+ - 负责:
+ - 生命周期
+ - 状态机切换
+ - 选敌
+ - Simulation area/sector query 接入
+ - 绑定玩家攻击属性
+
+- 当前已有四种武器实现模板:
+ - `WeaponKnife`
+ - 近身前刺 + 圆形范围命中
+ - `WeaponHandgun`
+ - 单次 Raycast 瞬发命中
+ - `WeaponSlash`
+ - 扇形范围命中
+ - `WeaponLightning`
+ - 锁定目标点 + 落点范围打击
+
+- 参数读取方式
+ - 已改为在具体武器中直接读取对应 `ParamsData`
+ - 不再在武器逻辑中手动解析字符串参数
+
+### 3. 当前已接通的参数
+
+- `WeaponKnifeParamsData`
+ - `HitRadius`
+
+- `WeaponHandgunParamsData`
+ - 暂无字段,待扩展
+
+- `WeaponSlashParamsData`
+ - `SectorAngle`
+
+- `WeaponLightningParamsData`
+ - `HitRadius`
+ - `HoverHeight`
+
+### 4. 当前结构的优点
+
+- 公共字段仍集中在 `WeaponData`,不会把通用逻辑拆散
+- 武器专属参数已经强类型化,初始化更稳定
+- 数据表可读性比旧的 KV 字符串格式更高
+- 新武器扩展时,可以复用:
+ - 表
+ - `DRWeapon`
+ - `WeaponData`
+ - `WeaponBase`
+ - AttackEffect
+ - Simulation area/sector query
+
+### 5. 当前结构的限制
+
+- 仍然不是“纯配置驱动行为”
+ - 行为差异主要还在具体武器类里
+- `Handgun` 参数化程度还不够
+ - 目前还不适合直接高效派生霰弹枪、狙击枪、 burst 枪
+- `Pramas` 命名拼写仍保留旧名字
+ - 目前为了兼容存量调用,暂不改
+
+## Weapon 扩展计划
+
+### P0: 稳定当前数据链路
+
+- 检查 `Weapon.txt` 中全部行:
+ - `Params` 必须都是 JSON 对象
+ - 空参数统一为 `{}`
+- 检查后续新增字段时:
+ - 数据表 key 与 `ParamsData` 属性名保持一致
+- 补一轮基础验证:
+ - 商店描述
+ - 玩家初始武器生成
+ - 商店购买武器生成
+
+### P1: 先把 Handgun 参数化做完整
+
+目标:
+- 把 `WeaponHandgun` 做成远程枪械母版,而不是只有一把“手枪”
+
+建议新增参数:
+- `PelletCount`
+- `SpreadAngle`
+- `PenetrationCount`
+- `FireOriginOffsetX`
+- `FireOriginOffsetY`
+- `FireOriginOffsetZ`
+- `HitMarkerSize`
+- `HitMarkerYOffset`
+- `HitMarkerDuration`
+
+落地后可直接派生:
+- 手枪
+- 霰弹枪
+- 狙击枪
+- 三连发手炮
+
+### P2: 优先做低成本高收益的新武器
+
+优先顺序建议:
+
+1. 长枪 / 刺剑
+- 基于 `WeaponKnife`
+- 重点调:
+ - 前刺距离
+ - 命中半径
+ - 冷却
+
+2. 大剑 / 半月斩
+- 基于 `WeaponSlash`
+- 重点调:
+ - `SectorAngle`
+ - 攻击范围
+ - 动画时长
+
+3. 战锤 / 震地锤
+- 基于 `WeaponLightning` 或 `WeaponKnife`
+- 重点调:
+ - 落点半径
+ - 前摇
+ - 低频高伤
+
+4. 霰弹枪
+- 基于参数化后的 `WeaponHandgun`
+- 重点调:
+ - 散射
+ - 多 pellet
+ - 近距离爆发
+
+5. 狙击枪
+- 基于参数化后的 `WeaponHandgun`
+- 重点调:
+ - 单发高伤
+ - 超远射程
+ - 慢冷却
+
+6. 陨石杖 / 圣光柱
+- 基于 `WeaponLightning`
+- 重点调:
+ - `HoverHeight`
+ - 爆炸半径
+ - 冷却
+
+### P3: 中成本扩展
+
+1. 链式闪电
+- 在首目标命中后,继续寻找附近目标
+- 需要新增:
+ - 连锁次数
+ - 连锁半径
+ - 每跳衰减
+
+2. 穿透弹 / 火球
+- 复用现有 projectile/simulation 基础
+- 需要明确:
+ - 穿透次数
+ - 命中后是否爆炸
+
+3. 地雷 / 陷阱
+- 本质是延时触发 area hit
+- 需要新增:
+ - 布置后触发时机
+ - 持续时间
+ - 触发半径
+
+4. 回旋镖
+- 需要双阶段投射物状态
+- 成本高于普通枪械/范围武器
+
+### P4: 暂缓项
+
+以下方向暂不建议优先投入:
+
+- 持续激光
+- 喷火器
+- 环绕飞剑
+- 常驻法球
+- 冰冻/中毒/击退等状态驱动武器流派
+
+原因:
+- 当前武器框架核心仍是“单次攻击结算”
+- 持续伤害与异常状态还没有形成统一挂点
+
+## 新武器接入步骤模板
+
+1. 在 `Weapon.txt` 新增一行
+- 配好基础字段
+- `Params` 写 JSON 对象
+
+2. 新增 `WeaponType`
+- 在 `Assets/GameMain/Scripts/Definition/Enum/WeaponType.cs`
+
+3. 新增武器数据子类
+- 新建 `WeaponXXXData`
+- 新建 `WeaponXXXParamsData`
+- 在构造里调用 `ParseParams()`
+
+4. 新增武器逻辑类
+- 继承 `WeaponBase`
+- 接入状态机
+- 读取 `ParamsData`
+
+5. 接入生成入口
+- 玩家初始武器
+- 商店购买武器
+- 其他掉落/奖励入口
+
+6. 验证点
+- 武器生成正确
+- 参数生效正确
+- 描述文本正确
+- Simulation 模式和非 Simulation 模式都能命中
diff --git a/docs/P2 Job System + Burst 落地.md b/docs/P2 Job System + Burst 落地.md
new file mode 100644
index 0000000..19ddd10
--- /dev/null
+++ b/docs/P2 Job System + Burst 落地.md
@@ -0,0 +1,179 @@
+# P2 Job System + Burst 落地(结项与验收)
+
+## 1. 文档目的
+本文件用于对齐 `docs/TodoList.md` 的 P2 Checkpoint 9,作为 P2 结项与 P3 输入基线。
+
+目标:
+- 固化压测口径(0.5k/1k/1.5k/2k)
+- 给出回归验证结论
+- 给出开关/回滚策略
+- 给出最终验收判定(通过/不通过)
+
+## 2. 验收标准(对齐 TodoList)
+来源:`docs/TodoList.md` 第 171~179 行。
+
+- 在 `2k` 敌人规模下,CPU Main Thread 明显下降(目标 `>= 30%`)。
+- Profiler 中战斗帧 `GC Alloc` 接近 `0`(持续帧)。
+
+## 3. 测试设备与环境
+- 设备:iQOO Neo8
+- CPU:第一代骁龙 8+
+- 内存:12 GB
+- 系统:OriginOS 6(Android 16)
+- Profiler 口径:以 CPU `ms` 为主,`fps` 仅作辅助(Android 端存在 60fps 上限)
+- Profiler 配置:`Call Stacks = Off`
+
+## 4. P2 开关与回滚策略
+
+### 4.1 运行开关
+- `UseSimulationMovement`
+- `UseJobSimulation`
+- `UseBurstJobs`
+
+### 4.2 生效时机约束
+- `UseSimulationMovement` / `UseJobSimulation`:战斗内不支持热切换,需在 Battle 外修改后生效。
+- `UseBurstJobs`:可切换,但建议仅用于战斗外 A/B。
+
+### 4.3 回滚策略(建议)
+1. 切回非 Job 路径:`UseJobSimulation = false`
+2. 若仍异常,切回旧移动:`UseSimulationMovement = false`
+3. 保留 `UseBurstJobs` 仅在 Job 路径 A/B 对照
+
+## 5. 回归验证(Checkpoint 9)
+
+| 用例 | 目标 | 状态 | 证据 |
+|------------------------------------------|--------------|----|----|
+| 10 分钟连续战斗 | 无异常日志、流程稳定 | 待补 | 待补 |
+| `Battle -> LevelUp -> Shop -> Battle` 循环 | 状态切换稳定、无卡死 | 通过 | `Logs/editmode-test-results.xml` |
+| 掉落拾取链路 | 掉落生成/吸附/回收正常 | 通过 | `Logs/editmode-test-results.xml` |
+
+建议附证据:
+- `Logs/playmode-tests.log`
+- 关键流程录屏/截图
+- 回归脚本或人工步骤说明
+
+### 5.1 回归记录模板
+
+#### 用例 1:10 分钟连续战斗
+- 执行时间:待填
+- 场景/波次参数:待填
+- 运行开关:`UseSimulationMovement = true`,`UseJobSimulation = true`,`UseBurstJobs = true`
+- 结果:待填
+- 日志/录屏:待填
+- 备注:待填
+
+#### 用例 2:`Battle -> LevelUp -> Shop -> Battle`
+- 执行时间:已执行,见 `Logs/editmode-test-results.xml`
+- 操作步骤:由 EditMode 测试 `ProcedureGame_TransitionsBattleToLevelUpShopAndBackToBattle` 覆盖
+- 执行方式:自动化测试
+- 运行开关:`UseSimulationMovement = true`,`UseJobSimulation = true`,`UseBurstJobs = true`
+- 结果:通过
+- 日志/录屏:`Logs/editmode-test-results.xml`
+- 备注:验证 `ProcedureGame` 可从 `Battle` 正确切换到 `LevelUp`、再到 `Shop`,并最终返回 `Battle`
+
+#### 用例 3:掉落拾取链路
+- 执行时间:已执行,见 `Logs/editmode-test-results.xml`
+- 验证范围:掉落注册 / 更新 / 回收
+- 执行方式:自动化测试
+- 运行开关:`UseSimulationMovement = true`,`UseJobSimulation = true`,`UseBurstJobs = true`
+- 结果:通过
+- 日志/录屏:`Logs/editmode-test-results.xml`
+- 备注:由 EditMode 测试 `PickupLifecycle_UpsertAndRemove_KeepsBindingsConsistent` 覆盖,验证掉落在 `SimulationWorld` 中的生命周期与 binding remap 正常
+
+## 6. 压测口径与数据
+
+### 6.1 标准口径(必须覆盖)
+- 敌人规模:`0.5k / 1k / 1.5k / 2k`
+- 指标:
+ - 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% |
+
+### 6.3 当前口径覆盖情况
+- 已覆盖敌人规模:`0.5k / 1k / 1.5k / 2k`
+- 已覆盖指标:CPU 热路径分阶段数据(`BuildInput / MoveSeparation / StateUpdate / Schedule / Complete / WriteBack`)
+- 待补指标:`Main Thread`、`Job Workers`、`GC Alloc`
+
+### 6.4 待补验收数据模板
+
+#### Main Thread / Job Workers / GC Alloc(P1.5 vs P2)
+| 敌人数量 | P1.5 Main Thread | P2 Main Thread | Main Thread 降幅 | P1.5 Job Workers | P2 Job Workers | P1.5 GC Alloc | P2 GC Alloc |
+|------|------------------:|---------------:|-----------------:|-----------------:|---------------:|--------------:|------------:|
+| `500` | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
+| `1000` | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
+| `1500` | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
+| `2000` | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
+
+#### 关键采样说明
+- 采样平台:Android 真机(与 P1.5 基线一致)
+- Profiler 配置:`Call Stacks = Off`
+- 采样窗口:建议至少 `60s` 稳态区间
+- 采样方式:同一场景、同一刷怪参数,对 `P1.5` 与 `P2` 分别采样
+- 结论口径:以 `2k` 作为最高压力场景进行最终验收
+
+#### 6.4.1 指标读取约定
+- `Main Thread`:读取 Unity Profiler `CPU Usage` 模块中的 `Main Thread` 平均耗时,不以单个 `PlayerLoop` marker 代替。
+- `Job Workers`:作为辅助指标,记录稳定窗口内 `Job Worker` / `Worker Thread` 的忙碌情况。若线程分布零散,可填写平均观察值、典型区间,或在表中填“见 Profiler 截图”并附截图证据。
+- `GC Alloc`:读取持续帧 `GC Alloc`,优先记录稳定窗口内的典型值或平均值,目标为接近 `0 B/frame`。
+- `Main Thread 降幅`:以 `((P1.5 Main Thread - P2 Main Thread) / P1.5 Main Thread) * 100%` 计算。
+
+#### 6.4.2 采样建议
+- `Main Thread` 与 `GC Alloc` 是 P2 验收的硬指标,优先保证这两项完整、可复现。
+- `Job Workers` 主要用于证明主要计算已迁移到 Worker Threads,不要求过度追求逐线程精确求和。
+- 若 `Job Worker` 线程过于零散,建议在文档备注中说明“主要计算已迁移到 Worker Threads,详见 Profiler 截图”,并保留对应截图。
+
+## 7. 验收判定
+
+| 验收项 | 标准 | 当前状态 | 判定 |
+|--------------------|----------|----------|-----|
+| Main Thread 降幅(2k) | `>= 30%` | `P2 TickEnemies` 相比 `P1.5` 降低 `56.4%` | 通过 |
+| 持续帧 GC Alloc | 接近 0 | 缺失 GC 数据 | 不通过 |
+| 回归用例证据 | 三项用例可复现并留档 | 已完成 2/3,剩余 10 分钟连续战斗待补 | 不通过 |
+
+**当前结论:P2 Checkpoint 9 尚未完成。**
+
+可确认部分:
+- P2 在 `0.5k~2k` 规模的热路径 CPU 优化已显著成立。
+- `2k` 作为最高压力场景时,CPU 主线程降幅目标已满足。
+- 当前阻塞项仅剩 `GC Alloc` 验证与 `10 分钟连续战斗` 手测证据补齐。
+
+## 8. 下一步补齐动作(建议)
+1. 按同一 `2k` 场景补采 `Main Thread / Job Workers / GC Alloc` 三项,并写入 6.3。
+2. 完成第 5 节剩余的 `10 分钟连续战斗` 回归,并补齐日志、录屏或步骤说明。
+3. 补齐后将第 7 节判定更新为“通过”,再在 `TodoList.md` 把 P2 Checkpoint 9 勾选。
+
+### 8.1 完成后回写清单
+- 将 5.0 三个回归用例的“状态”统一改为“通过”或“不通过”。
+- 将 6.4 的 `Main Thread / Job Workers / GC Alloc` 实测数据填写完整。
+- 若 `2000` 敌人下 `Main Thread` 降幅仍 `>= 30%` 且 `GC Alloc` 接近 `0`,将第 7 节总结更新为“P2 Checkpoint 9 通过”。
+- 同步将 `docs/TodoList.md` 的 `Checkpoint 9` 由 `[ ]` 改为 `[x]`。
+
+## 9. 测试命令(复用)
+- PlayMode:
+ - `Unity -batchmode -nographics -projectPath . -runTests -testPlatform PlayMode -testResults Logs/playmode-test-results.xml -logFile Logs/playmode-tests.log`
+- EditMode:
+ - `Unity -batchmode -nographics -projectPath . -runTests -testPlatform EditMode -testResults Logs/editmode-test-results.xml -logFile Logs/editmode-tests.log`
diff --git a/docs/TodoList.md b/docs/TodoList.md
index cf39345..7a6a302 100644
--- a/docs/TodoList.md
+++ b/docs/TodoList.md
@@ -10,7 +10,7 @@
## 1. P0 基线修正与性能基准
- [x] 建立性能基准场景(建议复用 `Game.unity` + 压测参数):
- - 指标:`1k / 2k / 3k` 敌人时的 FPS、CPU Main Thread、GC Alloc、Draw Calls。
+ - 指标:`0.5k / 1k / 1.5k / 2k` 敌人时的 FPS、CPU Main Thread、GC Alloc、Draw Calls。
- 输出:一份基线表格(开发机配置 + Unity Profiler 截图)。
- [x] 修正当前高风险逻辑问题(避免后续优化建立在不稳定行为上):
- `ProcedureGame.OnEnter()` 与 `_hudInitialized` 逻辑中有重复初始化状态机风险(`InitGameState()` 被调用两次)。
@@ -62,7 +62,7 @@
- [x] Checkpoint 7:P1 阶段回归与性能记录
- 回归用例:战斗 10 分钟、`Battle -> LevelUp -> Shop -> Battle` 循环、掉落吸附与拾取。
- - Profiling 对比:记录 1k/2k/3k 敌人下 Main Thread、GC Alloc、敌人更新耗时。
+ - Profiling 对比:记录 `0.5k / 1k / 1.5k / 2k` 敌人下 Main Thread、GC Alloc、敌人更新耗时。
- 输出文档:`P1 Simulation 分层设计 + 回滚开关说明 + 对比数据`。
- 完成标准:核心流程稳定,无新增 Error/Exception;可一键回滚到旧更新路径。
@@ -74,7 +74,7 @@
- 目标:将 `TickEnemies GC` 从当前 `27~108 KB` 降到 `< 5 KB / frame`。
- 重点文件:`Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`。
- 处理方式:桶容器与临时列表复用(包含 bucket list 复用池),避免每帧重建集合。
- - 完成标准:`2000` 敌人压测下 `TickEnemies GC` 稳定 `< 5 KB / frame`。
+ - 完成标准:`2k` 敌人压测下 `TickEnemies GC` 稳定 `< 5 KB / frame`。
- [x] Checkpoint 2:解耦 Simulation 核心与 `Transform` 运行时依赖
- 目标:`SimulationWorld.TickEnemies` 不直接读取或写入 `Transform`。
@@ -111,23 +111,72 @@
- Simulation 层与表现层边界清晰,可无缝衔接 P2 Job/Burst 改造。
## 3. P2 Job System + Burst 落地(核心性能阶段)
-- [ ] 引入并锁定依赖版本(Unity 2022.3 对应):
- - `com.unity.collections`
- - `com.unity.jobs`
- - `com.unity.burst`
- - `com.unity.mathematics`
-- [ ] 第一批 Job 化模块(优先级从高到低):
- 1. 敌人移动与朝向更新(`IJobParallelFor`)。
- 2. 目标选择加速(空间哈希/网格分桶,减少全量最近邻搜索)。
- 3. 投射物批量移动与寿命回收。
- 4. AOE/碰撞候选筛选(先 broad phase,后精算)。
-- [ ] Burst 编译策略:
- - 热路径 Job 全部 `[BurstCompile]`。
- - 禁止在 Job 内使用托管分配、虚调用、LINQ。
-- [ ] 主线程仅做:输入采样、状态切换、UI同步、实体显隐。
+- [x] Checkpoint 1:依赖锁定与运行开关落地
+ - 在 `Packages/manifest.json` 锁定并确认版本:
+ - `com.unity.collections`
+ - `com.unity.jobs`(已废弃并并入 `com.unity.collections`,Unity 2022.3 不再单独锁定包)
+ - `com.unity.burst`
+ - `com.unity.mathematics`
+ - 增加 P2 运行开关(建议):
+ - `UseJobSimulation`
+ - `UseBurstJobs`
+ - 约束:默认可一键回退到 P1.5 路径,避免全量切换导致定位困难。
+ - 完成标准:Editor/Development Build 均可编译运行;关闭开关时行为与 P1.5 一致。
+
+- [x] Checkpoint 2:Simulation 与 Job 数据通道打通(仅建通道,不改行为)
+ - 为敌人/投射物建立 Job 输入输出结构(纯数据,不含 `Transform`/托管引用)。
+ - 建立 `SimulationWorld -> NativeContainer -> SimulationWorld` 的拷贝与回写流程。
+ - 统一生命周期:`Allocator.Persistent` 分配、集中 `Dispose`,避免泄漏。
+ - 完成标准:战斗循环可稳定运行,且该通道持续帧无新增 GC Alloc 热点。
+
+- [x] Checkpoint 3:敌人移动与朝向 Job 化(第一优先)
+ - 将敌人移动、朝向更新迁移至 `IJobParallelFor`。
+ - 输入最少包含:`position/forward/speed/targetPosition/deltaTime/state`。
+ - 输出最少包含:`nextPosition/nextForward/isMoving`。
+ - 保留 A/B 路径:可切换 Job 与旧逻辑对比。
+ - 完成标准:开启 Job 后敌人追踪行为视觉一致;`TickEnemies` 主线程耗时明显下降。
+
+- [x] Checkpoint 4:目标选择加速(空间哈希/网格分桶)
+ - 建立敌人/目标的空间索引容器(建议 `NativeParallelMultiHashMap` 或等价结构)。
+ - 拆分为两个阶段:
+ - 构建分桶(Build Buckets)
+ - 邻域候选查询(Query Neighbors)
+ - 避免全量最近邻搜索,控制复杂度随敌人数增长的斜率。
+ - 完成标准:`2k` 敌人下目标选择阶段耗时稳定,且无索引越界/漏目标回归。
+
+- [x] Checkpoint 5:投射物批量移动与寿命回收 Job 化
+ - 投射物数据结构最少包含:`position/velocity/lifeTime/age/active`。
+ - 迁移投射物移动、越界判定、寿命回收到 Job。
+ - 回收后保持实体池与索引同步,防止悬空引用。
+ - 完成标准:连续战斗下投射物数量曲线稳定,无异常积压或提前回收。
+
+- [x] Checkpoint 6:AOE/碰撞候选筛选 Job 化(Broad Phase 优先)
+ - 先 Job 化候选生成(Broad Phase),减少精算对数。
+ - 精算与伤害结算可先保留主线程,但输入改为候选列表驱动。
+ - 建立命中事件缓冲区,统一在主线程提交表现层事件。
+ - 完成标准:命中结果与现有逻辑一致,候选数量与耗时显著下降。
+
+- [x] Checkpoint 7:Burst 策略落地与热路径约束
+ - 热路径 Job 全部添加 `[BurstCompile]`,并在 Burst Inspector 确认已生效。
+ - 清理 Job 内不兼容写法:托管分配、虚调用、LINQ、异常路径热调用。
+ - 数学计算统一迁移到 `Unity.Mathematics`。
+ - 完成标准:核心 Job 均由 Burst 编译,且无安全检查错误/降级回 Mono 的关键路径。
+
+- [x] Checkpoint 8:主线程职责收口与调度稳定
+ - 明确主线程只做:输入采样、状态切换、UI 同步、实体显隐、最终写回。
+ - 统一 `Schedule -> Dependency Combine -> Complete` 位置,防止隐式同步抖动。
+ - 清理战斗帧中不必要的主线程循环(尤其逐实体逻辑)。
+ - 完成标准:Profiler 可见主要计算在 Worker Threads;Main Thread 峰值更平滑。
+
+- [ ] Checkpoint 9:P2 回归、压测与结项文档
+ - 回归用例:10 分钟战斗、`Battle -> LevelUp -> Shop -> Battle` 循环、掉落拾取链路。
+ - 压测口径:`0.5k / 1k / 1.5k / 2k` 敌人,记录 Main Thread、Job Workers、GC Alloc、关键 Marker。
+ - 输出文档:`P2 Job/Burst 改造说明 + 开关/回滚策略 + 前后对比数据`。
+ - 完成标准:结论可复现,可作为 P3 GPU Instancing 的输入基线。
+ - 当前状态:`P2 TickEnemies` 在 `2k` 规模下相对 `P1.5` 已降至 `9.44 ms`(约 `-56.4%`),CPU 目标已满足;仍需补齐 `GC Alloc` 与三项回归证据后再勾选。
**验收标准**
-- 在 3k 敌人规模下,CPU Main Thread 明显下降(目标 >= 30%)。
+- 在 2k 敌人规模下,CPU Main Thread 明显下降(目标 >= 30%)。
- Profiler 中战斗帧 GC Alloc 接近 0(持续帧)。
## 4. P3 GPU Instancing 渲染管线(与 Job 并行推进)
@@ -191,3 +240,6 @@
- [ ] Profiling 对比(改造前后同场景同参数)。
- [ ] 风险与回滚说明(特别是热更新与渲染链路)。
+## 测试命令
+- PlayMode: `& "C:\UnityProjects\Unity Editor\2022.3.62f3c1\Editor\Unity.exe" -batchmode -nographics -projectPath . -runTests -testPlatform PlayMode -testResults Logs/playmode-test-results.xml -logFile Logs/playmode-tests.log`
+- EditMode: `& "C:\UnityProjects\Unity Editor\2022.3.62f3c1\Editor\Unity.exe" -batchmode -nographics -projectPath . -runTests -testPlatform EditMode -testResults Logs/editmode-test-results.xml -logFile Logs/editmode-tests.log`
diff --git a/docs/UI-5层架构设计规范.md b/docs/UI-5层架构设计规范.md
new file mode 100644
index 0000000..bc88819
--- /dev/null
+++ b/docs/UI-5层架构设计规范.md
@@ -0,0 +1,192 @@
+# UI 五层架构设计规范(RawData / Controller / View / Context / UseCase)
+
+## 1. 适用范围
+
+- 适用目录:`Assets/GameMain/Scripts/UI/*`
+- 重点对象:采用五层拆分的 UI 模块(`MenuScene`、`GameScene`、`General` 下的分层 UI)
+- 本文不展开 Unity GameFramework 底层实现细节,仅约束项目内 UI 代码组织与协作方式
+
+## 2. 架构总览
+
+UI 模块采用“输入数据 -> 业务编排 -> 展示数据 -> 渲染表现”的分层方式,核心链路如下:
+
+1. 外部流程(Procedure/GameState)创建并绑定 UseCase
+2. 通过 `GameEntry.UIRouter` 打开指定 UI
+3. Controller 从 UseCase 取 RawData,并转换为 Context
+4. View 使用 Context 渲染
+5. View 通过事件回传交互,Controller 处理后驱动 UseCase 更新,再刷新 View
+
+简化关系图:
+
+```text
+Procedure/GameState
+ -> UIRouter
+ -> Controller <-> UseCase
+ -> Context -> View
+View --(CustomEvent)--> Controller
+```
+
+## 3. 五层职责定义
+
+### 3.1 RawData 层
+
+职责:承载“业务原始数据”,作为 UseCase 到 Controller 的传输模型。
+
+约束:
+
+- 命名:`XXXFormRawData`
+- 只描述数据,不包含 UI 渲染行为
+- 可保留领域对象或数据表对象(例如 `DRLevelUpReward`、`WeaponBase`)
+- 不依赖具体 View 组件
+
+参考:
+
+- `Assets/GameMain/Scripts/UI/GameScene/RawData/ShopFormRawData.cs`
+- `Assets/GameMain/Scripts/UI/GameScene/RawData/LevelUpFormRawData.cs`
+- `Assets/GameMain/Scripts/UI/MenuScene/RawData/SelectRoleFormRawData.cs`
+
+### 3.2 UseCase 层
+
+职责:封装 UI 对应业务用例,负责业务规则、状态推进、数据生成。
+
+约束:
+
+- 实现 `IUIUseCase`
+- 命名:`XXXFormUseCase`
+- 对外提供 `CreateInitialModel / TryRefresh / Select / Confirm` 等语义化方法
+- 返回 RawData(或结果对象),不直接操作具体 View
+
+参考:
+
+- `Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs`
+- `Assets/GameMain/Scripts/UI/GameScene/UseCase/LevelUpFormUseCase.cs`
+- `Assets/GameMain/Scripts/UI/MenuScene/UseCase/SelectRoleFormUseCase.cs`
+
+### 3.3 Controller 层
+
+职责:UI 编排层,连接 UseCase 与 View,管理 UI 生命周期、事件订阅、数据转换。
+
+约束:
+
+- 继承 `UIFormControllerCommonBase`
+- 命名:`XXXFormController`
+- 通过 `BindUseCase(IUIUseCase)` 注入用例并做类型校验
+- `OpenUI(object userData = null)` 支持:`Context`、`RawData`、`null`
+- 负责 RawData -> Context 的转换(常见 `BuildContext`)
+- 在 `SubscribeCustomEvents / UnsubscribeCustomEvents` 成对管理事件
+- 可做局部刷新(避免整窗重建)
+
+参考:
+
+- `Assets/GameMain/Scripts/UI/GameScene/Controller/ShopFormController.cs`
+- `Assets/GameMain/Scripts/UI/GameScene/Controller/LevelUpFormController.cs`
+- `Assets/GameMain/Scripts/UI/MenuScene/Controller/SelectRoleFormController.cs`
+
+### 3.4 Context 层
+
+职责:承载“可直接驱动 UI 展示”的上下文数据。
+
+约束:
+
+- 继承 `UIContext`
+- 命名:`XXXFormContext` 或 `XXXItemContext`
+- 字段以展示友好为目标(标题、描述、图标、稀有度、列表等)
+- 允许组合子 Context(例如列表区 + 条目)
+
+参考:
+
+- `Assets/GameMain/Scripts/UI/GameScene/Context/ShopFormContext.cs`
+- `Assets/GameMain/Scripts/UI/GameScene/Context/DisplayListAreaContext.cs`
+- `Assets/GameMain/Scripts/UI/MenuScene/Context/SelectRoleFormContext.cs`
+
+### 3.5 View 层
+
+职责:纯表现层,负责控件绑定、显示刷新、交互事件抛出。
+
+约束:
+
+- Form 类继承 `UGuiForm`,子组件通常继承 `MonoBehaviour`
+- 命名:`XXXForm` / `XXXItem` / `XXXArea`
+- 提供 `RefreshUI(Context)`、`OnInit(Context)`、`OnReset()` 等渲染入口
+- 用户交互通过 `GameEntry.Event.Fire(...)` 通知 Controller
+- 不承载业务规则(计算、流程推进、数据筛选应在 UseCase)
+
+参考:
+
+- `Assets/GameMain/Scripts/UI/GameScene/View/ShopForm.cs`
+- `Assets/GameMain/Scripts/UI/GameScene/View/DisplayListArea.cs`
+- `Assets/GameMain/Scripts/UI/MenuScene/View/SelectRoleForm.cs`
+
+## 4. 标准交互流程
+
+### 4.1 初始化与绑定
+
+1. Procedure/GameState 创建 UseCase
+2. 调用 `GameEntry.UIRouter.BindUIUseCase(UIFormType.X, useCase)`
+
+示例:
+
+- `Assets/GameMain/Scripts/Procedure/Game/GameStateShop.cs`
+- `Assets/GameMain/Scripts/Procedure/Game/GameStateLevelUp.cs`
+- `Assets/GameMain/Scripts/Procedure/ProcedureStartMenu.cs`
+
+### 4.2 打开 UI
+
+1. 调用 `GameEntry.UIRouter.OpenUI(UIFormType.X)`
+2. Controller 从 UseCase 取 RawData(或接收外部 RawData/Context)
+3. Controller 构建 Context 后打开/刷新 Form
+4. View 在 `OnOpen` 中校验 Context 类型并执行 `RefreshUI`
+
+### 4.3 用户交互到刷新
+
+1. View 触发事件(如购买、刷新、选择)
+2. Controller 监听事件并调用 UseCase
+3. UseCase 返回新数据或操作结果
+4. Controller 更新 Context 并刷新全部或局部 UI
+
+### 4.4 关闭 UI
+
+1. 调用 `GameEntry.UIRouter.CloseUI(UIFormType.X)`
+2. Controller 解除事件订阅并关闭窗体
+3. View `OnClose` 清理本地状态
+
+## 5. 目录与命名规范
+
+- 目录:`UI//RawData|UseCase|Controller|Context|View`
+- 五层同名前缀保持一致:`ShopForm*`、`LevelUpForm*`、`SelectRoleForm*`
+- 子组件上下文命名:`RoleItemContext`、`DisplayItemContext`、`LevelUpRewardItemContext`
+- 新增 UI Form 时优先建立完整五层;仅纯静态展示可降级为 View-only
+
+## 6. 依赖方向约束
+
+允许依赖:
+
+- `UseCase -> RawData / 领域对象`
+- `Controller -> UseCase + RawData + Context + View + Event`
+- `View -> Context + Event`
+
+禁止依赖:
+
+- `View -> UseCase`
+- `View -> 领域状态修改`
+- `Context/RawData -> View`
+
+## 7. 新增一个五层 UI 的落地步骤
+
+1. 在目标场景目录创建 `RawData / UseCase / Context / Controller / View` 对应类型
+2. 在 UseCase 中实现模型创建与交互方法
+3. 在 Controller 中实现 `BindUseCase`、`OpenUI`、`BuildContext`、事件订阅
+4. 在 View 中实现 `RefreshUI` 和交互事件抛出
+5. 在对应 Procedure/GameState 里完成 UseCase 绑定与 Open/Close 调用
+6. 自测三条主链路:首次打开、交互刷新、关闭重开
+
+## 8. 项目当前实践说明
+
+- `ShopForm`、`LevelUpForm`、`SelectRoleForm` 是当前五层模式的主要样板
+- `DialogForm` 也有 Controller/Context/RawData,但 UseCase 为可选
+- `HudForm`、`StartMenuForm` 当前为轻用例场景,可不强制 UseCase
+- `SettingForm`、`AboutForm` 属于历史直连型 UI,不属于五层完整样板
+
+---
+
+如后续需要统一重构,建议优先把历史直连型 UI(如 `SettingForm`)迁移到五层模板,以降低 UI 逻辑耦合度。
diff --git a/skills/simulation-development/SKILL.md b/skills/simulation-development/SKILL.md
index 1a143a3..bcce81f 100644
--- a/skills/simulation-development/SKILL.md
+++ b/skills/simulation-development/SKILL.md
@@ -1,89 +1,103 @@
----
+---
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, review, refactor, and extend VampireLike SimulationWorld architecture. Use when working on SimulationWorld data ownership, entity lifecycle sync, tick pipeline orchestration, Job/Burst data channels, projectile or area collision settlement, target-selection indexing, presentation write-back, or Simulation regression tests and architecture documentation that must preserve core invariants.
---
# Simulation Development
-## Quick Start
-
-1. Read baseline design doc: `./references/SimulationDevelopmentSkill.md`.
-2. Read latest measured baseline: `../../docs/P1.5 Simulation-Supplement.md`.
-3. Confirm your change scope is one or more of:
- - `SimData` contracts (`EnemySimData`, `ProjectileSimData`, `PickupSimData`)
- - lifecycle sync (`SimulationWorld.EntitySync`)
- - per-frame simulation (`SimulationWorld.Tick`, `TickEnemies`)
- - presentation write-back (`SimulationWorld.Presentation`)
- - enemy separation solver integration (`EnemySeparationSolverProvider`)
-4. Keep rollback path available through `UseSimulationMovement`.
+1. Read `./references/SimulationDevelopmentSkill.md` first.
+2. Treat current code as the source of truth when the reference and implementation diverge.
+3. Load only the source files needed for the task from the map below.
+4. Keep architecture changes and behavior changes explicit; do not hide them inside unrelated edits.
## Source Map
-- Simulation core: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.cs`
-- Lifecycle sync: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs`
-- Presentation sync: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs`
+- Core entry: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.cs`
+- Sim state lifecycle: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs`
+- Entity lifecycle bridge: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs`
+- Job data channel: `../../Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs`
+- Enemy pipeline: `../../Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs`
+- Projectile pipeline: `../../Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs`
+- Collision pipeline: `../../Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs`
+- Target selection index: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs`
+- Transform write-back: `../../Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs`
+- Hit presentation bridge: `../../Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs`
- Tick context: `../../Assets/GameMain/Scripts/Simulation/SimulationTickContext.cs`
-- Index binding: `../../Assets/GameMain/Scripts/Simulation/EntityBinding.cs`
-- Battle entry: `../../Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs`
-- Global component init: `../../Assets/GameMain/Scripts/Base/GameEntry.Custom.cs`
-- Enemy old path gate:
- - `../../Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/MeleeEnemy.cs`
- - `../../Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/RemoteEnemy.cs`
-- Separation solver:
- - `../../Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs`
- - `../../Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs`
- - `../../Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`
-- P1.5 baseline doc:
- - `../../docs/P1.5 Simulation-Supplement.md`
+- Entity index binding: `../../Assets/GameMain/Scripts/Simulation/EntityBinding.cs`
+- Battle update entry: `../../Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs`
+- Procedure-level cleanup: `../../Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs`
+- Damage and collision utility: `../../Assets/GameMain/Scripts/Utility/AIUtility.cs`
+- Regression tests:
+ - `../../Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs`
+ - `../../Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs`
+
+## Workflow
+
+1. Classify the change before editing:
+ - simulation state contract
+ - entity lifecycle mapping
+ - tick pipeline stage
+ - collision or area query semantics
+ - presentation write-back
+ - test or architecture doc maintenance
+2. Preserve the main boundaries:
+ - `Tick` remains the only simulation logic entry
+ - lifecycle registration and removal remain centralized
+ - logic does not write `Transform`
+ - damage, event dispatch, entity hiding, and recycle stay on the main thread
+3. Extend data first when behavior depends on new state:
+ - update `SimData`
+ - update job input/output structs
+ - update conversion and initialization paths
+4. Reuse an existing pipeline stage before adding a new one.
+5. Update `./references/SimulationDevelopmentSkill.md` when module boundaries, invariants, or execution flow change.
+6. Add or adjust Simulation tests for every behavior change.
## Non-Negotiable Invariants
-- Maintain `EntityId <-> SimulationIndex` consistency.
-- Use swap-back removal (`move last -> remove last -> remap index`).
-- Keep lifecycle registration/removal inside `EntitySync` event flow; do not double-write containers from gameplay code.
-- Keep logic/presentation boundary:
- - Simulation computes logical outputs.
- - Presentation writes back `Transform`.
-- Keep A/B rollback path:
- - `UseSimulationMovement == false` must preserve old behavior path.
-- Avoid new managed allocations in Tick hot paths.
+- Keep `_enemies`, `_projectiles`, and `_pickups` as the persistent source of truth.
+- Keep `EntityBinding` consistent with container indices.
+- Use swap-back removal with remap before unbind.
+- Drive container add/remove only through lifecycle sync and sim state helpers.
+- Keep target-selection buckets and collision buckets as rebuildable caches, not persistent business state.
+- Keep area query snapshot semantics intact.
+- Avoid managed allocations and LINQ in hot paths.
-## Change Recipes
+## Change Guidance
-### Add or Change SimData Fields
+### Extend Simulation State
-1. Update target struct in `Simulation/SimData/`.
-2. Populate default/initial values in `EntitySync` create methods.
-3. Apply runtime updates in `Tick` phase.
-4. Consume outputs in `Presentation` only if visual write-back is needed.
-5. Ensure backward compatibility when `UseSimulationMovement` is off.
+1. Add fields to the relevant sim data and job structs.
+2. Populate defaults in the lifecycle registration path.
+3. Flow the data through the execution stage that owns it.
+4. Consume presentation-only values in Presentation code, not in simulation jobs.
-### Extend Enemy Tick Behavior
+### Extend Lifecycle Mapping
-1. Keep deterministic stage order (`BuildInput -> Move/Separation -> StateUpdate -> WriteBack`).
-2. Preserve state semantics and avoid direct UI/event side effects in Tick.
-3. Keep `ProfilerMarker` coverage for each stage.
-4. Keep Tick hot path data-driven (no direct `Transform` read/write).
+1. Add the entity group mapping in `SimulationWorld.EntitySync.cs`.
+2. Register and unregister through dedicated sim state helpers.
+3. Preserve clear ownership over which container the entity enters.
-### Implement Projectile/Pickup Tick (from placeholder to real behavior)
+### Extend Tick Pipeline
-1. Keep lifecycle path unchanged (`EntitySync` handles add/remove).
-2. Add dedicated tick methods in `SimulationWorld` for each data type.
-3. Keep outputs in data containers; write visuals in presentation phase.
-4. Ensure removal path and binding remap rules are identical to enemy path.
+1. Place logic inside the smallest existing stage that fits.
+2. Keep job work data-oriented and side-effect free.
+3. Apply outputs back to sim state before any presentation write-back.
-### Refactor Toward Job/Burst
+### Extend Collision Behavior
-1. Prioritize `Move/Separation` stage parallelization.
-2. Keep `ProfilerMarker` and stage boundaries stable for P1.5/P2 comparison.
-3. Leave transform write-back to presentation-only stage.
-4. Keep managed allocations and virtual dispatch out of core loops.
+1. Separate broad-phase candidate generation from final settlement.
+2. Preserve dedup and snapshot behavior on the main thread.
+3. Route gameplay effects through the existing main-thread settlement path.
-## Validation Checklist
+### Extend Presentation
-- `UseSimulationMovement = false` and `true` both run correctly.
-- No duplicate registration or stale index after entity hide/destroy.
-- Battle loop remains stable (`Battle -> LevelUp -> Shop -> Battle`).
-- No new per-frame GC spikes in `TickEnemies`.
-- 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.
+1. Read simulation output only after logic settlement is complete.
+2. Do not mutate simulation state from presentation code.
+
+## Validation
+
+- Verify index stability after removal paths.
+- Verify clear/reset paths leave no stale bindings or transient buffers.
+- Verify behavior under the relevant Simulation tests.
+- Verify the reference doc still matches the code after architectural edits.
diff --git a/skills/simulation-development/agents/openai.yaml b/skills/simulation-development/agents/openai.yaml
index f35a135..286be52 100644
--- a/skills/simulation-development/agents/openai.yaml
+++ b/skills/simulation-development/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
display_name: "Simulation Development"
- short_description: "Maintain and extend VampireLike Simulation architecture"
- default_prompt: "Use $simulation-development to implement and validate a Simulation layer change with rollback safety."
+ short_description: "Extend VampireLike SimulationWorld safely"
+ default_prompt: "Use $simulation-development to implement, review, or extend a SimulationWorld change while preserving architecture invariants."
diff --git a/skills/simulation-development/references/SimulationDevelopmentSkill.md b/skills/simulation-development/references/SimulationDevelopmentSkill.md
index 87fb71c..623ce68 100644
--- a/skills/simulation-development/references/SimulationDevelopmentSkill.md
+++ b/skills/simulation-development/references/SimulationDevelopmentSkill.md
@@ -1,157 +1,311 @@
-# Simulation Development Skill(VampireLike)
+# SimulationWorld Architecture Specification
-## 目标
-本文件是 `Simulation` 分层的开发规范与速查手册。
-后续调整敌人移动、补齐投射物/掉落物逻辑、推进 Job/Burst 改造时,优先按本文档执行,避免反复通读全部代码。
+## 文档定位
+本文件是 `SimulationWorld` 的架构规范与扩展开发约束。
-## 当前架构总览(P1.5 已落地)
-- Simulation 主目录:`Assets/GameMain/Scripts/Simulation/`
-- 核心组件:`SimulationWorld`(`GameFrameworkComponent`)
-- 数据容器:
- - `List _enemies`
- - `List _projectiles`
- - `List _pickups`
-- Tick 临时缓冲:
- - `List _enemyTickWorkItems`
- - `List _enemySeparationAgents`
-- 索引绑定:`EntityBinding`(`EntityId <-> SimulationIndex` 双向映射)
-- 生命周期同步:`SimulationWorld.EntitySync`(监听实体 Show/Hide 事件)
-- 表现层回写:`SimulationWorld.Presentation`(`LateUpdate` 写回 `Transform`)
-- Tick 上下文:`SimulationTickContext`(`DeltaTime`、`RealDeltaTime`、`PlayerPosition`)
+用途分为两部分:
+- 作为当前 `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 容器与实体生命周期一致:
+## 适用范围
+- `Assets/GameMain/Scripts/Simulation/*`
+- `Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs`
+- `Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs`
+- `Assets/GameMain/Scripts/Utility/AIUtility.cs`
+- `Assets/Tests/Simulation/EditMode/*`
+- `Assets/Tests/Simulation/PlayMode/*`
-| 事件 | 组名 | 行为 |
-|---|---|---|
-| `ShowEntitySuccessEventArgs` | `Enemy` | `RegisterEnemyLifecycle` + `UpsertEnemy` |
-| `HideEntityCompleteEventArgs` | `Enemy` | `UnregisterEnemyLifecycle` + `RemoveEnemyByEntityId` |
-| `ShowEntitySuccessEventArgs` | `Drop` | `RegisterPickupLifecycle` + `UpsertPickup` |
-| `HideEntityCompleteEventArgs` | `Drop` | `UnregisterPickupLifecycle` + `RemovePickupByEntityId` |
-| `ShowEntitySuccessEventArgs` | `Bullet` / `Projectile` | `RegisterProjectileLifecycle` + `UpsertProjectile` |
-| `HideEntityCompleteEventArgs` | `Bullet` / `Projectile` | `UnregisterProjectileLifecycle` + `RemoveProjectileByEntityId` |
+## 架构目标
+- 将战斗中的敌人、投射物、掉落物运行时状态收口到统一仿真容器。
+- 将热路径逻辑与 Unity 表现层解耦,避免在仿真阶段直接读写 `Transform`。
+- 为 Job/Burst 提供稳定的数据通道、生命周期管理和主线程结算收口点。
+- 保持 `SimulationWorld` 对外是单一战斗调度入口,而不是分散的业务入口集合。
+- 保证扩展新仿真对象或新碰撞规则时,能够沿着既有管线接入,而不是旁路修改。
-关键规则:
-- 删除容器元素统一使用“末尾覆盖 + `RemoveAt(lastIndex)` + `EntityBinding.RemapIndex`”。
-- `Upsert` 语义:`EntityId` 已存在则覆盖,不存在则追加。
+## 非目标
+- 不负责完整战斗规则定义。伤害公式、碰撞业务语义仍由 `AIUtility` 和实体逻辑承担。
+- 不负责实体创建策略。实体创建与隐藏仍由外部流程和 Entity 系统负责。
+- 不追求全局 ECS 化。本模块仍以 `SimulationWorld + partial + Native 容器` 为中心组织。
+- 不在 Job 中直接驱动表现层、事件系统或 Unity 对象生命周期。
-## EnemySimData 合约(当前实现)
-文件:`Assets/GameMain/Scripts/Simulation/SimData/EnemySimData.cs`
+## 外部依赖与系统边界
+`SimulationWorld` 处于战斗流程中层,位于 `GameStateBattle` 和具体实体逻辑之间。
-- `EntityId`:实体唯一标识
-- `Position / Forward / Rotation`:逻辑输出与表现层写回字段
-- `Speed`:来自 `EnemyData.SpeedBase`
-- `AttackRange`:当前固定初始化为 `1f`
-- `AvoidEnemyOverlap / EnemyBodyRadius / SeparationIterations`:从 `MovementComponent` 读取
-- `TargetType / State`:状态扩展预留
+上游依赖:
+- `GameStateBattle.OnUpdate` 驱动每帧 `Tick`。
+- `GameEntry.Event` 提供实体显示/隐藏事件,用于同步仿真容器生命周期。
+- `GameEntry.Entity` 提供实体查询、隐藏和表现事件消费。
-当前状态值(`SimulationWorld` 常量):
-- `0`:Idle
-- `1`:Chasing
-- `2`:InAttackRange
+下游协作:
+- `AIUtility` 负责伤害与碰撞业务结算。
+- Enemy/Projectile/Drop 实体提供初始化所需运行时数据。
+- Presentation 子模块负责把仿真结果写回表现层。
-## TickEnemies 当前算法(P1.5 分阶段)
-文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.cs`
+边界要求:
+- 外部业务代码不得直接增删 `_enemies`、`_projectiles`、`_pickups`。
+- 外部业务代码不得绕过 `SimulationWorld` 直接维护仿真索引。
+- `SimulationWorld` 不直接拥有实体生成权,只消费实体生命周期事件。
-`TickEnemies` 入口保持纯数据热路径,不直接读写 `Transform`,按四阶段执行:
-1. `BuildInput`
- - 计算到玩家平面距离
- - 产出 `EnemyTickWorkItem`
- - 生成分离输入 `EnemySeparationAgent`
-2. `Move/Separation`
- - 计算追踪位移与朝向
- - 通过 `EnemySeparationSolverProvider.ResolveSimulation(...)` 做互斥求解
-3. `StateUpdate`
- - 按距离与可追逐状态更新 `Idle/Chasing/InAttackRange`
-4. `WriteBack`
- - 回写 `EnemySimData`(`Position/Forward/Rotation/State`)
+## 模块结构
+`SimulationWorld` 使用 `partial` 拆分,职责按以下边界划分:
-## 互斥求解器双通道(Legacy + Simulation)
-文件:
-- `Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs`
-- `Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs`
-- `Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`
+- `SimulationWorld.cs`
+ - 核心组件入口、主状态容器、基础依赖、Unity 生命周期入口。
+- `SimulationWorld.SimEntityState.cs`
+ - 敌人、投射物、掉落物的仿真态创建、更新、删除和清空。
+- `SimulationWorld.EntitySync.cs`
+ - 监听实体 show/hide 事件,将实体生命周期映射到仿真容器。
+- `DataChannel/SimulationWorld.JobDataChannel.cs`
+ - Native 容器持有、初始化、清理、容量准备、仿真数据到 Job 数据的转换。
+- `Jobs/SimulationWorld.EnemyJobs.cs`
+ - 每帧仿真主编排、敌人移动与互斥分离 Job 调度。
+- `Jobs/SimulationWorld.ProjectileJobs.cs`
+ - 投射物移动、寿命处理、越界回收。
+- `Jobs/SimulationWorld.CollisionPipeline.cs`
+ - 投射物与区域碰撞查询构建、候选筛选、主线程命中结算。
+- `SimulationWorld.TargetSelectionSpatialIndex.cs`
+ - 敌人目标选择空间索引。
+- `Presentation/SimulationWorld.TransformSync.cs`
+ - `LateUpdate` 表现写回。
+- `Presentation/SimulationWorld.HitPresentation.cs`
+ - 命中事件的表现消费桥。
-说明:
-- `SetSimulationAgents/ResolveSimulation`:供 Simulation 纯数据路径调用。
-- `Register/Unregister/Resolve(Transform, ...)`:保留旧路径兼容与回滚能力。
-- `GridBucketEnemySeparationSolver` 已加入桶列表复用池(`_bucketListPool`)以降低 GC。
+## 核心数据所有权
+### 主容器
+- `_enemies`
+- `_projectiles`
+- `_pickups`
-## 表现层回写(Presentation)规则
-文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs`
+这些容器是真实仿真态所有者。Job 输入输出缓冲只是当前帧的镜像通道,不是持久源数据。
-- 仅在 `UseSimulationMovement == true` 时执行
-- 遍历 `EnemyManager.Enemies`,按 `EntityId` 查找 `EnemySimData`
-- 写回顺序:
- - 始终写回 `position`
- - 优先使用 `rotation`
- - 若 `rotation` 无效,则回退到 `forward`
+### 绑定关系
+- `EntityBinding` 维护 `EntityId <-> SimulationIndex` 双向映射。
+- 容器删除使用 `swap-back`。
+- 发生尾元素覆盖时,必须同步 `RemapIndex`。
+- 删除完成后再 `Unbind`,避免索引悬挂。
-## 与旧移动系统的关系(重要)
-- `MeleeEnemy` / `RemoteEnemy` 在 `OnUpdate` 开头门控:
- - 开启 Simulation:直接 `return`
- - 关闭 Simulation:走旧 `MovementComponent`
-- 回滚能力来自同一构建内的 `UseSimulationMovement` A/B 开关。
+### Native 容器
+- Job 通道一律使用 `Allocator.Persistent`。
+- 生命周期由 `InitializeJobDataChannels` / `DisposeJobDataChannels` 集中管理。
+- 帧间复用时使用 `Clear`,不允许用临时重建替代正常复用。
+- Job 数据与主容器数据之间的转换必须集中在 `JobDataChannel` 侧完成。
-## Projectile / Pickup 现状
-- `ProjectileSimData`、`PickupSimData` 已具备容器、绑定与生命周期同步通道
-- 当前仍未接入独立 Tick 行为,仅完成“创建/回收/索引同步”占位目标
+## 生命周期模型
+### 实体进入仿真
+统一由 `EntitySync` 监听实体显示事件后触发:
+- Enemy group -> `RegisterEnemyLifecycle`
+- Drop group -> `RegisterPickupLifecycle`
+- Bullet / Projectile / EnemyProjectile group -> `RegisterProjectileLifecycle`
-## P1.5 实测基线(P2 输入)
-基线文档:`docs/P1.5 Simulation-Supplement.md`
+### 实体退出仿真
+统一由 `EntitySync` 监听实体隐藏事件后触发:
+- Enemy -> `UnregisterEnemyLifecycle`
+- Drop -> `UnregisterPickupLifecycle`
+- Projectile 相关 group -> `UnregisterProjectileLifecycle`
-关键结论:
-- `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 上限影响
+### 清场
+`ClearSimulationState` 负责:
+- 清空主容器
+- 清空投射物回收与结算缓存
+- 清空区域碰撞请求与命中缓存
+- 清空 Job 通道
+- 清空全部 `EntityBinding`
-## 自动化回归(P1.5 已补)
-目录:`Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs`
+## 运行时执行链路
+### 帧级入口
+1. `GameStateBattle.OnUpdate`
+2. `_enemyManager.OnUpdate(...)`
+3. `SimulationWorld.Tick(...)`
+4. `SimulationWorld.LateUpdate()`
-覆盖点:
-- 敌人追踪玩家
-- 进入攻击距离后停止移动
-- 实体移除后的索引 remap 稳定性
+### Tick 总流程
+`SimulationWorld.Tick` 是战斗仿真的唯一主入口。
-## 后续扩展规范(必须遵守)
-1. 先扩数据,再扩行为
-先在 `SimData` 增字段,再改 `EntitySync` 初始化与 `Tick` 逻辑,最后改表现层消费。
+约束:
+- 当 `UseSimulationMovement == false` 时,直接返回。
+- `Tick` 只负责逻辑仿真与结算,不直接写 `Transform`。
-2. 保留 A/B 路径
-任何迁移都必须可在同一构建内通过开关回退到旧路径。
+### 每帧仿真管线
+当前实现的标准顺序为:
+1. Early Return
+ - `DeltaTime <= 0` 时只清理碰撞临时通道和统计。
+2. BuildInput
+ - 将 `_enemies` / `_projectiles` 同步为 Job 输入。
+ - 准备敌人输出、投射物输出、碰撞查询缓冲。
+3. StateUpdate
+ - 调度敌人移动 Job。
+ - 调度投射物移动 Job。
+4. Schedule
+ - 按需调度敌人互斥分离 Job。
+ - 合并敌人与投射物 Job 依赖。
+5. Complete
+ - 等待本帧仿真 Job 完成。
+6. Collision
+ - 构建碰撞查询。
+ - 构建敌人碰撞桶。
+ - 生成候选并统计。
+7. WriteBack
+ - 把输出写回主容器。
+ - 在主线程结算碰撞与伤害。
+ - 回收失效投射物。
-3. 生命周期只走 EntitySync
-禁止在敌人业务代码中手动改写 Simulation 容器,避免双写导致索引错乱。
+### LateUpdate
+`LateUpdate` 只做表现写回,不做逻辑判定:
+- 敌人位置/朝向写回
+- 投射物位置/朝向写回
-4. 维持“逻辑输出 / 表现消费”边界
-Simulation 只产出逻辑结果,不直接触发 UI、特效、音频事件。
+## 线程模型与边界
+### Job/Burst 允许做的事
+- 读取 Job 输入缓冲
+- 写入 Job 输出缓冲
+- 写入 NativeHashMap / NativeList 等碰撞与分桶数据
+- 执行纯数据计算
-5. 删除策略统一用 swap-back
-所有 Simulation 容器删除都必须 remap 索引,严禁 `RemoveAt(i)` 直接删中间项。
+### Job/Burst 禁止做的事
+- 读写 `Transform`
+- 操作 GameObject / Entity 生命周期
+- 调用事件系统
+- 直接调用 `AIUtility.PerformCollision`
+- 进行托管分配、LINQ、装箱
-6. 热路径禁用托管分配
-`TickEnemies`、互斥求解、阶段化循环里禁止 LINQ/临时集合扩张。
+### 主线程必须做的事
+- 应用输出到仿真主容器
+- 命中结算与伤害计算
+- 投射物失效回收
+- 命中表现事件派发
+- `LateUpdate` 表现写回
-## P2 前的已知技术债
-- `AttackRange` 目前固定值 `1f`,尚未由配置化数值驱动
-- `EnemySimData.TargetType/State` 语义仍偏轻量,未形成完整状态机合约
-- Projectile/Pickup 尚未迁移真实 Tick 行为
+## 子系统约束
+### 敌人仿真
+固定接入点:
+- BuildInput
+- Movement
+- Separation
+- WriteBack
-## 提交前检查清单
-- 是否保持了 `UseSimulationMovement` 关闭时行为不变
-- 是否保持了 `EntityId <-> SimulationIndex` 一致性(含移除 remap)
-- 是否避免在 Tick 热路径引入新 GC
-- 是否将新字段接入了 `EntitySync -> Tick -> Presentation` 全链路
-- 是否补充了最小回归验证(至少 Battle 循环、敌人移除、索引稳定性)
-- 是否同步更新本 Skill 文档与 `docs/P1.5 Simulation-Supplement.md`
+约束:
+- 敌人状态必须以 `EnemySimData` 为中心流动。
+- 互斥与移动结果必须先写入输出缓冲,再统一提交。
+- 与目标选择相关的空间索引脏标记必须在主容器变更时维护。
+
+### 投射物仿真
+固定接入点:
+- BuildInput
+- Movement
+- Collision Query
+- Resolve
+- Recycle
+
+约束:
+- 投射物生命周期状态必须由 `ProjectileSimData.Active` 和 `State` 共同表达。
+- 投射物实际隐藏与移除只能在主线程回收阶段完成。
+
+### 碰撞管线
+职责:
+- 构建投射物查询和区域查询
+- 生成 broad-phase 候选
+- 在主线程做最终业务结算
+
+约束:
+- Broad-phase 只能筛候选,不能替代最终命中判定。
+- Area Query 必须保留 `SourceWasActiveAtQueryTime` 快照语义。
+- 候选去重与区域命中去重只能在主线程收口。
+
+### 目标选择空间索引
+职责:
+- 提供按位置查询最近敌人的能力。
+
+约束:
+- 仅在仿真启用时对外提供结果。
+- 敌人主容器变更后必须标记脏。
+- 索引是缓存,不是源数据;源数据仍是 `_enemies`。
+
+### Presentation
+职责:
+- 将仿真层结果写回表现层。
+- 消费命中表现事件。
+
+约束:
+- Presentation 只消费仿真结果,不反向修改仿真逻辑状态。
+- 任何新增表现都应接在 `Presentation` 子模块,而不是接回 Job 或业务结算热路径。
+
+## 不可破坏的不变量
+### 生命周期单入口
+仿真容器的增删必须只经过 `EntitySync` 和 `SimEntityState`。
+
+### 数据单一事实来源
+主容器是持续态事实来源,Job 缓冲只是帧级副本。
+
+### 逻辑与表现分离
+逻辑阶段不写 `Transform`,表现阶段不做业务结算。
+
+### 索引一致性
+任何 `swap-back` 删除都必须同步 remap,否则视为架构级错误。
+
+### 主线程结算收口
+伤害、事件派发、实体隐藏和回收必须回到主线程。
+
+### 空间索引与碰撞桶是缓存
+它们可以重建,不可被外部业务当作持久数据依赖。
+
+## 扩展开发流程
+### Step 0:判定接入位置
+先判断新需求属于:
+- 新仿真态字段
+- 新执行阶段逻辑
+- 新碰撞查询类型
+- 新表现桥接
+
+不要一开始就直接改 `Tick` 主流程。
+
+### Step 1:扩状态
+先补 `SimData`、必要的 Job 输入输出结构和转换逻辑。
+
+### Step 2:接生命周期
+如果是新实体类型,先定义 show/hide 到仿真态的映射,再进入执行阶段。
+
+### Step 3:接执行管线
+优先复用已有阶段;只有确实无法收纳时才新增阶段,并补可观测的 profiler 标记。
+
+### Step 4:接主线程结算
+需要业务判定、伤害、事件派发、实体隐藏时,一律回主线程收口。
+
+### Step 5:接表现
+视觉写回、命中反馈、临时特效都放在 `Presentation` 侧。
+
+### Step 6:补测试
+至少覆盖:
+- 正常行为
+- 空容器和边界条件
+- 删除后的索引稳定性
+- 碰撞去重或快照语义
+- 与旧路径一致的关键行为
+
+### Step 7:更新文档
+修改模块边界、数据契约、不变量或执行阶段时,必须同步更新本文件。
+
+## 回归关注点
+- `ClearSimulationState` 是否把主容器、缓存和 binding 一并清干净。
+- 删除路径是否保持 `swap-back + remap` 一致。
+- Job Native 容器是否有泄漏或容量管理回退。
+- 是否在热路径引入托管分配。
+- 是否让表现逻辑重新侵入仿真逻辑。
+- 是否破坏 Area Query 快照语义。
+- 是否破坏碰撞候选与命中去重。
+
+## 测试建议
+至少保留并持续扩展以下类型的测试:
+- Tick 行为正确性
+- 主线程与 Job 管线一致性
+- 最近敌查询正确性
+- 投射物候选上限与玩家候选覆盖
+- Area Query 快照语义
+- 清场和 Battle 循环稳定性
+
+## 维护原则
+如果未来需要继续扩展为多模式仿真开关、更多 Job 管线层级或新的仿真对象类型,应优先维护以下三点:
+- `Tick` 仍然只有一个主入口
+- 仿真态生命周期仍然只有一个注册/反注册入口
+- 主线程结算与表现写回边界不被打穿
diff --git a/skills/weapon-development/SKILL.md b/skills/weapon-development/SKILL.md
index 4ec3047..d0bdc7b 100644
--- a/skills/weapon-development/SKILL.md
+++ b/skills/weapon-development/SKILL.md
@@ -13,20 +13,30 @@ description: Develop and extend the VampireLike weapon system. Use when creating
- state flow (`Idle`, `Check_OutRange`, `Check_InRange`, `Attack`)
- target selector (`ITargetSelector`, `TargetSelectorType`)
- effect layer (`IWeaponAttackEffect`)
- - data contract (`DRWeapon`, `WeaponData`)
-3. Keep behavior compatibility with current gameplay loop and UI/event chain.
+ - data contract (`DRWeapon`, `WeaponData`, `ParamsData`)
+3. Keep behavior compatibility with current gameplay loop, shop flow, inventory flow, and UI/event chain.
## Source Map
-- Weapon base: `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs`
+- Weapon base:
+ - `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs`
- Existing weapons:
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponKnife/`
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponHandgun/`
- - `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponSlash.cs`
+ - `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponSlash/`
+ - `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/`
- Selectors:
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/`
+- Attack effects:
+ - `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/`
- Weapon data:
- `../../Assets/GameMain/Scripts/Entity/EntityData/Weapon/`
+- Weapon table:
+ - `../../Assets/GameMain/DataTables/Weapon.txt`
+- Data row:
+ - `../../Assets/GameMain/Scripts/DataTable/DRWeapon.cs`
+- Shop integration:
+ - `../../Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs`
- Entity show flow:
- `../../Assets/GameMain/Scripts/Entity/EntityExtension.cs`
@@ -36,7 +46,8 @@ description: Develop and extend the VampireLike weapon system. Use when creating
- Keep state transitions explicit and non-blocking.
- Keep cooldown accumulation valid even when no target is found.
- Keep attack logic and visual effect logic decoupled.
-- Use safe parsing (`TryParse` + defaults) for runtime weapon parameters.
+- Parse weapon-specific parameters into strong-typed `ParamsData` at data initialization time.
+- Treat `Weapon.txt` `Params` as a JSON object column; empty params must use `{}`.
- Preserve compatibility with shop/inventory/UI refresh flow.
## Change Recipes
@@ -44,17 +55,18 @@ description: Develop and extend the VampireLike weapon system. Use when creating
### Add a New Weapon
1. Extend `WeaponType` without reordering existing enum values.
-2. Add `WeaponXxxData : WeaponData` for strong-typed fields.
-3. Add `WeaponXxx : WeaponBase` and implement only weapon-specific behavior.
-4. Build state files under `Weapon/WeaponXxx/` with partial class layout.
-5. Register display/data mapping path so `ShowWeapon` can instantiate correctly.
+2. Add `WeaponXxxData : WeaponData` and `WeaponXxxParamsData` for weapon-specific parameters.
+3. Parse `ParamsJson` through `ParseParams()` inside the weapon data constructor.
+4. Add `WeaponXxx : WeaponBase` and implement only weapon-specific behavior.
+5. Build state files under `Weapon/WeaponXxx/` with partial class layout.
+6. Register display/data mapping path so `ShowWeapon` and shop purchase can instantiate correctly.
### Add or Update a Target Selector
1. Implement `ITargetSelector`.
2. Update `TargetSelectorType`.
3. Register creation in `WeaponBase.CreateSelector`.
-4. Validate target semantics with current-health rules where applicable.
+4. Validate target semantics with current-health/runtime-health rules where applicable.
### Add or Update Attack Effect
@@ -62,11 +74,19 @@ description: Develop and extend the VampireLike weapon system. Use when creating
2. Trigger effect from weapon attack state only.
3. Keep damage resolution outside effect code.
+### Add or Update Weapon Parameters
+
+1. Update `Weapon.txt` `Params` JSON object.
+2. Add or update the corresponding `WeaponXxxParamsData` fields.
+3. Keep key names aligned with `ParamsData` property names.
+4. Read values from `ParamsData` in weapon initialization, not by manual string parsing in runtime logic.
+
## Validation Checklist
- Weapon can be shown, attached, and updated without exceptions.
- State machine does not stall across target loss/reacquire.
- Cooldown and range checks match design expectation.
- Damage path and effect path remain decoupled.
+- `ParamsData` matches table content and default fallback behavior.
- UI/shop/inventory interactions stay stable after the change.
-- Update `./references/WeaponDevelopmentSkill.md` if contracts changed.
+- Update `./references/WeaponDevelopmentSkill.md` if contracts or recommended patterns changed.
diff --git a/skills/weapon-development/references/WeaponDevelopmentSkill.md b/skills/weapon-development/references/WeaponDevelopmentSkill.md
index 875a989..b8f75c1 100644
--- a/skills/weapon-development/references/WeaponDevelopmentSkill.md
+++ b/skills/weapon-development/references/WeaponDevelopmentSkill.md
@@ -1,16 +1,17 @@
-# Weapon Development Skill(VampireLike)
+# Weapon Development Skill(VampireLike)
## 目标
本文件是 `Entity.Weapon` 体系的开发规范与速查手册。
-后续新增武器或扩展机制时,优先按本文档执行,避免重复通读历史上下文。
+后续新增武器、扩展参数、调整状态机或联动商店/背包时,优先按本文档执行,避免重复通读历史上下文。
## 当前架构总览
- 武器运行时入口:`WeaponBase`(`Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs`)
-- 武器具体实现:`WeaponKnife`、`WeaponHandgun`、`WeaponSlash`
+- 武器具体实现:`WeaponKnife`、`WeaponHandgun`、`WeaponSlash`、`WeaponLightning`
- 目标选择策略:`ITargetSelector` + `TargetSelectorType`
- 攻击可视化:`IWeaponAttackEffect`
- 数据入口:`DRWeapon` -> `WeaponData`(及其子类)
- 实体生成:`EntityExtension.ShowWeapon`
+- 商店接入:`ShopFormUseCase.CreateWeaponData`
## WeaponBase 统一职责(已上收)
`WeaponBase` 负责以下通用逻辑,子类不要重复实现:
@@ -20,8 +21,34 @@
- 启用门控:`OnUpdate` 中统一 `if (!_isEnabled) return`
- 目标选择入口:`SelectTarget`、`SetTargetSelector`、`CreateSelector`
- 距离判定:`IsInRange`(基于 XZ 平面距离)
+- Simulation 命中请求:`TryQueueAreaCollisionQuery`、`TryQueueSectorCollisionQuery`
- 玩家攻击属性订阅:`BindAttackStatFromOwner` / `ReleaseAttackStatSubscription`
+约束:
+- 不要在子类里重复实现 `WeaponBase` 已有能力。
+- 行为差异优先收敛在:`BuildStates`、`Check`、`Attack`、少量专属辅助方法。
+
+## 当前武器模板分类
+### 1. `WeaponKnife`
+- 模式:近身前刺 + 圆形范围命中
+- 典型参数:`HitRadius`
+- 适合派生:长枪、刺剑、短矛、震地锤近身版
+
+### 2. `WeaponHandgun`
+- 模式:单次 `Raycast` 瞬发命中
+- 当前参数化程度较低,仍适合作为远程枪械母版继续扩展
+- 适合派生:手枪、霰弹枪、狙击枪、三连发枪
+
+### 3. `WeaponSlash`
+- 模式:扇形范围命中
+- 典型参数:`SectorAngle`
+- 适合派生:大剑、斧头、半月斩、横扫类武器
+
+### 4. `WeaponLightning`
+- 模式:锁定目标点 + 落点范围打击
+- 典型参数:`HitRadius`、`HoverHeight`
+- 适合派生:闪电、陨石杖、圣光柱、空袭类武器
+
## 状态机约定
统一状态枚举:
- `Idle`
@@ -39,12 +66,14 @@
关键规则:
- 即使没有目标,也允许蓄力(计时器持续走)。
- 一旦进入 `Check_InRange` 且冷却已满,应立即触发攻击。
+- 攻击过程中若需要多帧动画,使用 `_isAttacking` 控制状态退出时机。
## 3D 场景下的距离/朝向原则
- 射程与目标筛选:优先使用 XZ 平面距离(忽略 Y),调用 `AIUtility.GetSqrMagnitudeXZ`。
- 视觉朝向与弹道:可按武器设计决定是否使用完整 3D 向量。
- `WeaponHandgun`:允许俯仰瞄准,逻辑上用射线命中对象判定伤害。
- 近战地面范围类(Knife/Slash):伤害检测建议投影到地面(XZ)再判定。
+ - 落点类(Lightning):锁点可取目标当前位置,但范围判定仍建议按地面距离收口。
## 目标选择策略规范
接口:`ITargetSelector.SelectTarget(WeaponBase weapon, IEnumerable candidates, float maxSqrRange)`
@@ -55,14 +84,15 @@
- `LowestHealthTargetSelector`
语义约定:
-- `HighestHealth` / `LowestHealth` 必须按“当前血量”筛选。
-- 当前实现读取 `HealthComponent.CurrentHealth`。
+- `NearestTargetSelector` 在 Simulation 启用时优先走空间索引。
+- `HighestHealth` / `LowestHealth` 当前基于运行时生命值选择目标。
+- 若新增新策略,必须明确“按当前值”还是“按最大值”筛选,避免语义漂移。
扩展策略步骤:
1. 新建 selector 类并实现 `ITargetSelector`。
2. 更新 `TargetSelectorType` 枚举。
3. 在 `WeaponBase.CreateSelector` 中注册。
-4. 武器在 `OnWeaponShow` 或构造阶段选择策略。
+4. 武器在 `OnWeaponShow` 或初始化阶段选择策略。
## 攻击可视化效果规范
接口:`IWeaponAttackEffect.Play(WeaponBase weapon, Vector3 position, EntityBase target, float radius)`
@@ -71,25 +101,68 @@
- 可视化逻辑与伤害逻辑解耦。
- 武器类只负责触发 `Play`,不把可视化细节塞回武器核心逻辑。
- 当前阶段允许临时对象创建;后续若有性能压力再统一对象池化。
+- 命中特效、范围预警、扇形描边都属于 effect 层,不属于伤害层。
## 数据层规范(DRWeapon / WeaponData)
-`DRWeapon` 提供通用字段:
-- `Attack`、`Cooldown`、`AttackRange`、`AttackSoundId`
-- `Pramas`(字典,Key 建议统一转小写)
+### `DRWeapon`
+提供通用字段:
+- `Attack`
+- `Cooldown`
+- `AttackRange`
+- `AttackSoundId`
+- `ParamsJson`
+- `Pramas`
- `Modifiers`
约定:
-- 参数解析尽量在数据层/初始化阶段完成。
-- 武器逻辑层读取 `WeaponData` 的强类型结果,不要散落 `Parse`。
-- 解析时必须容错:优先 `TryParse` + 默认值,避免运行时异常。
+- `Params` 列现在使用标准 JSON 对象。
+- 空参数统一写 `{}`。
+- 不再兼容 `[]`。
+- `Pramas` 保留为描述/UI 的兼容字典视图,不再作为武器逻辑主读取入口。
+
+### `WeaponData`
+提供公共武器字段与通用解析入口:
+- 公共战斗字段:`Attack`、`Cooldown`、`AttackRange`
+- 公共展示字段:`Title`、`IconAssetName`、`Rarity`、`Price`
+- 参数入口:`ParamsJson`、`Params`
+- 通用强类型解析:`ParseParams()`
+
+约定:
+- 参数解析优先在数据层完成。
+- 武器逻辑层读取强类型 `ParamsData`,不要散落字符串 `Parse`。
+- 只有描述/UI 等弱类型场景才继续读取 `Params` 字典。
+
+### 具体武器数据子类
+每种武器数据子类都应持有自己的 `ParamsData`:
+- `WeaponKnifeData -> WeaponKnifeParamsData`
+- `WeaponHandgunData -> WeaponHandgunParamsData`
+- `WeaponSlashData -> WeaponSlashParamsData`
+- `WeaponLightningData -> WeaponLightningParamsData`
+
+当前已接通字段:
+- `WeaponKnifeParamsData`
+ - `HitRadius`
+- `WeaponHandgunParamsData`
+ - 暂无字段
+- `WeaponSlashParamsData`
+ - `SectorAngle`
+- `WeaponLightningParamsData`
+ - `HitRadius`
+ - `HoverHeight`
+
+JSON 约束:
+- key 名应与 `ParamsData` 属性名一致。
+- 统一使用 JSON 对象,不要再使用自定义 KV 串。
+- 不建议在 `ParamsJson` 中使用制表符和跨行内容,避免 txt 表分列出错。
## 新增武器标准流程
1. 定义枚举
- - 更新 `WeaponType`(保持递增值,避免重排已有值)。
+ - 更新 `WeaponType`,保持递增值,避免重排已有值。
2. 建立数据类
- 新建 `WeaponXxxData : WeaponData`。
- - 如果有独有参数,提供强类型字段或统一初始化逻辑。
+ - 新建 `WeaponXxxParamsData`。
+ - 在构造阶段调用 `ParseParams()`,把参数初始化为强类型字段。
3. 建立行为类
- 新建 `WeaponXxx : WeaponBase`。
@@ -106,29 +179,84 @@
5. 建立可视化(可选)
- 新建 `WeaponXxxAttackEffect : IWeaponAttackEffect`,在武器中组合调用。
-6. 若为远程武器,建立子弹实体
- - `BulletXxx : Bullet`
- - `BulletXxxData : BulletData`
- - 通过武器赋予伤害/阵营参数。
- - 自动销毁应走对象池回收。
-
-7. 接入实体展示与数据表
+6. 接入实体展示与数据表
- 确保 `DRWeapon` / `DREntity` 配置齐全。
- `EntityExtension.ShowWeapon` 可正确映射到 `Entity.Weapon.WeaponXxx`。
+ - 商店/背包创建入口能正确构造 `WeaponXxxData`。
-8. 联动系统
+7. 联动系统
- 背包、商店购买/出售、UI 展示、事件流刷新。
+8. 验证点
+ - 武器能正确生成、附着、更新。
+ - `ParamsData` 与数据表一致。
+ - 描述文本仍正确展示。
+ - Simulation 模式和非 Simulation 模式都能命中。
+
+## 扩展优先级建议
+### 第一批:低成本高收益
+- 长枪 / 刺剑
+ - 基于 `WeaponKnife`
+- 大剑 / 半月斩
+ - 基于 `WeaponSlash`
+- 战锤 / 震地锤
+ - 基于 `WeaponLightning` 或 `WeaponKnife`
+- 霰弹枪
+ - 基于参数化后的 `WeaponHandgun`
+- 狙击枪
+ - 基于参数化后的 `WeaponHandgun`
+- 陨石杖 / 圣光柱
+ - 基于 `WeaponLightning`
+
+### 第二批:中成本扩展
+- 链式闪电
+- 穿透弹 / 火球
+- 地雷 / 陷阱
+- 回旋镖
+
+### 暂缓项
+- 持续激光
+- 喷火器
+- 环绕飞剑
+- 常驻法球
+- 状态异常驱动的复杂武器流派
+
+原因:
+- 当前武器主流程仍是单次攻击结算。
+- 持续伤害、异常状态和常驻 orbit 行为尚未形成统一挂点。
+
+## `WeaponHandgun` 后续建议
+当前 `WeaponHandgun` 参数化仍偏弱,建议作为下一阶段母版优先扩展。
+
+建议新增字段:
+- `PelletCount`
+- `SpreadAngle`
+- `PenetrationCount`
+- `FireOriginOffsetX`
+- `FireOriginOffsetY`
+- `FireOriginOffsetZ`
+- `HitMarkerSize`
+- `HitMarkerYOffset`
+- `HitMarkerDuration`
+
+完成后可快速派生:
+- 手枪
+- 霰弹枪
+- 狙击枪
+- 三连发枪
+
## 代码检查清单(提交前)
- - 是否重复实现了 `WeaponBase` 已有通用逻辑。
- - 状态流转是否会卡死或漏转场。
- - 冷却计时是否在无目标时仍正常累积。
- - 目标失效(null/Unavailable/死亡)是否安全处理。
- - 命中检测是否符合武器设计(地面范围/射线/扇形)。
- - 数据参数是否全部容错解析。
- - 可视化是否与伤害逻辑解耦。
- - 是否正确订阅/解绑攻击属性(或复用基类方法)。
+- 是否重复实现了 `WeaponBase` 已有通用逻辑。
+- 状态流转是否会卡死或漏转场。
+- 冷却计时是否在无目标时仍正常累积。
+- 目标失效(null/Unavailable/死亡)是否安全处理。
+- 命中检测是否符合武器设计(地面范围/射线/扇形/落点)。
+- 数据参数是否全部收敛到 `ParamsData`。
+- 可视化是否与伤害逻辑解耦。
+- 是否正确订阅/解绑攻击属性(或复用基类方法)。
+- 商店、背包、武器描述是否仍保持兼容。
## 已知注意点
- - 目录中存在 `Pramas` 命名拼写历史包袱,保持兼容即可。
- - 文档中的“规范”优先级高于历史实现;历史实现若偏离,按本规范逐步收敛。
+- 目录中存在 `Pramas` 命名拼写历史包袱,当前保持兼容即可。
+- 文档中的“规范”优先级高于历史实现;历史实现若偏离,按本规范逐步收敛。
+- 当前更推荐“公共 `WeaponData` + 专属 `ParamsData`”结构,不建议把每种武器做成一套完全独立的数据总线。
diff --git a/数据表/Entity/Enemy.xlsx b/数据表/Entity/Enemy.xlsx
index 7f2d4e0..f50e4c5 100644
Binary files a/数据表/Entity/Enemy.xlsx and b/数据表/Entity/Enemy.xlsx differ
diff --git a/数据表/Entity/Entity.xlsx b/数据表/Entity/Entity.xlsx
index 36a894e..12ab48e 100644
Binary files a/数据表/Entity/Entity.xlsx and b/数据表/Entity/Entity.xlsx differ
diff --git a/数据表/Entity/Weapon.xlsx b/数据表/Entity/Weapon.xlsx
index 8982b48..28a267a 100644
Binary files a/数据表/Entity/Weapon.xlsx and b/数据表/Entity/Weapon.xlsx differ
diff --git a/数据表/Goods.xlsx b/数据表/Goods.xlsx
index 7d8a40f..ccdf210 100644
Binary files a/数据表/Goods.xlsx and b/数据表/Goods.xlsx differ
diff --git a/数据表/Level.xlsx b/数据表/Level.xlsx
index 742b78e..1b68adb 100644
Binary files a/数据表/Level.xlsx and b/数据表/Level.xlsx differ
diff --git a/数据表/convert.py b/数据表/convert.py
index 99ce634..d606043 100644
--- a/数据表/convert.py
+++ b/数据表/convert.py
@@ -1,6 +1,9 @@
import pandas as pd
import os
-import csv
+def format_cell(value):
+ if value is None:
+ return ''
+ return str(value)
def convert_excel_to_txt(folder_path='.'):
# 计数器,用于最后汇总
@@ -28,24 +31,15 @@ def convert_excel_to_txt(folder_path='.'):
try:
# 读取 Excel
- df = pd.read_excel(file_path, header=None)
-
- # 预处理:将 NaN 替换为空字符串,否则导出会变成 "nan"
- df = df.fillna('')
-
- # 导出设置:
- # 1. sep='\t' : 使用制表符分隔
- # 2. quoting=csv.QUOTE_NONE : 不使用引号包裹字段,也不会把 " 变成 ""
- # 3. escapechar='\\' : 如果单元格内恰好有 Tab 键,会用反斜杠转义,防止数据列错位
- df.to_csv(
- output_file,
- sep='\t',
- index=False,
- header=False,
- encoding='utf-8',
- quoting=csv.QUOTE_NONE,
- escapechar='\\'
+ df = pd.read_excel(
+ file_path,
+ header=None,
+ keep_default_na=False,
)
+
+ with open(output_file, 'w', encoding='utf-8', newline='') as f:
+ for row in df.itertuples(index=False, name=None):
+ f.write('\t'.join(format_cell(cell) for cell in row) + '\n')
print(f"成功转换 -> {output_file}")
count += 1