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