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..773b0a3 100644 --- a/Assets/GameMain/DataTables/Entity.txt +++ b/Assets/GameMain/DataTables/Entity.txt @@ -2,12 +2,13 @@ # Id AssetName # int string # 实体编号 策划备注 资源名称 + 11 跟随相机 FollowCamera 1001 测试玩家 Player 101 近战敌人 MeleeEnemy 102 远程敌人 RemoteEnemy - 11 跟随相机 FollowCamera 201 武器小刀 WeaponKnife 202 武器手枪 WeaponHandgun 203 武器斧头 WeaponSlash + 204 武器闪电 WeaponLightning 10001 金币实体 CoinEntity 10002 经验实体 ExpEntity diff --git a/Assets/GameMain/DataTables/Goods.txt b/Assets/GameMain/DataTables/Goods.txt index 67b569f..3a18b18 100644 --- a/Assets/GameMain/DataTables/Goods.txt +++ b/Assets/GameMain/DataTables/Goods.txt @@ -25,3 +25,4 @@ 121 Prop 119 122 Prop 120 123 Weapon 3 + 124 Weapon 4 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..6b69ff2 100644 --- a/Assets/GameMain/DataTables/Weapon.txt +++ b/Assets/GameMain/DataTables/Weapon.txt @@ -5,3 +5,4 @@ 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;HoverHeight:10] [] 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..e12de4c 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} @@ -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/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/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs b/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs index b5d67ad..be60968 100644 --- a/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs @@ -164,6 +164,22 @@ namespace CustomComponent GUILayout.Label($"Battle Time: {enemyManager.ElapsedBattleTime:F1}s / {enemyManager.BattleDuration:F1}s"); GUILayout.Label($"Enemy Count: {enemyManager.CurrentEnemyCount}"); + Simulation.SimulationWorld simulationWorld = GameEntry.SimulationWorld; + if (simulationWorld != null) + { + GUILayout.Space(4f); + GUILayout.Label( + $"Sim Switch: Move={(simulationWorld.UseSimulationMovement ? "On" : "Off")} Job={(simulationWorld.UseJobSimulation ? "On" : "Off")} Burst={(simulationWorld.UseBurstJobs ? "On" : "Off")}"); + GUILayout.Label( + $"Collision Queries: total {simulationWorld.LastCollisionQueryCount} (Projectile {simulationWorld.LastProjectileCollisionQueryCount} / Area {simulationWorld.LastAreaCollisionQueryCount})"); + GUILayout.Label( + $"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")}"); + } + GUILayout.BeginHorizontal(); GUILayout.Label("Rate", GUILayout.Width(52f)); string rateText = GUILayout.TextField(_spawnRateScaleInput.ToString("F2"), GUILayout.Width(60f)); 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..01bba93 100644 --- a/Assets/GameMain/Scripts/DataTable/DRWeapon.cs +++ b/Assets/GameMain/Scripts/DataTable/DRWeapon.cs @@ -109,6 +109,12 @@ namespace DataTable { } + /// + /// 解参数 + /// + /// + /// + /// private Dictionary DeserializeParams(string rawParams) { if (!rawParams.StartsWith('[') || !rawParams.EndsWith(']')) diff --git a/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs b/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs index 559dd3c..e537ce5 100644 --- a/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs +++ b/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs @@ -9,6 +9,11 @@ namespace CustomDebugger 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 Collision_BuildQueries = new("Collision.BuildQueries"); + public static readonly ProfilerMarker Collision_BuildBuckets = new("Collision.BuildBuckets"); + public static readonly ProfilerMarker Collision_QueryCandidates = new("Collision.QueryCandidates"); + public static readonly ProfilerMarker Collision_ResolveProjectile = new("Collision.ResolveProjectile"); + public static readonly ProfilerMarker Collision_ResolveArea = new("Collision.ResolveArea"); public static readonly ProfilerMarker TargetSelection_BuildBuckets = new("TargetSelection.BuildBuckets"); public static readonly ProfilerMarker TargetSelection_QueryNeighbors = new("TargetSelection.QueryNeighbors"); public static readonly ProfilerMarker Movement_Update = new ProfilerMarker("Movement_Update"); diff --git a/Assets/GameMain/Scripts/Definition/Enum/WeaponType.cs b/Assets/GameMain/Scripts/Definition/Enum/WeaponType.cs index 999bf1a..f8c9520 100644 --- a/Assets/GameMain/Scripts/Definition/Enum/WeaponType.cs +++ b/Assets/GameMain/Scripts/Definition/Enum/WeaponType.cs @@ -6,5 +6,6 @@ namespace Definition.Enum WeaponKnife = 1, WeaponHandgun = 2, WeaponSlash = 3, + WeaponLightning = 4, } } diff --git a/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyData.cs b/Assets/GameMain/Scripts/Entity/EntityData/Enemy/EnemyData.cs index 80f2a48..117a255 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.ToLower(), 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..2ac3551 100644 --- a/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponData.cs +++ b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponData.cs @@ -28,14 +28,15 @@ 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.ToLower(), out value); } /// 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..5d5ed5e --- /dev/null +++ b/Assets/GameMain/Scripts/Entity/EntityData/Weapon/WeaponLightningData.cs @@ -0,0 +1,12 @@ +using Definition.Enum; + +namespace Entity.EntityData +{ + public class WeaponLightningData : WeaponData + { + public WeaponLightningData(int entityId, int ownerId, CampType ownerCamp) + : base(entityId, WeaponType.WeaponLightning, ownerId, ownerCamp) + { + } + } +} 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/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..410edd8 --- /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 && simulationWorld.UseJobSimulation; + } + + 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; + } + } + } +} 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/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/WeaponBase.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs index bca06d9..46d2f81 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs @@ -220,6 +220,32 @@ 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.TryEnqueueAreaCollisionQuery(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.TryEnqueueSectorCollisionQuery(Id, ownerEntityId, in center, radius, in direction, + halfAngleDeg, maxTargets); + } + protected void SetTargetSelector(TargetSelectorType selectorType) { TargetSelector = CreateSelector(selectorType); @@ -286,4 +312,3 @@ namespace Entity.Weapon } - diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponKnife/WeaponKnife.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponKnife/WeaponKnife.cs index f2d9d6c..038c5ce 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponKnife/WeaponKnife.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponKnife/WeaponKnife.cs @@ -27,7 +27,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 +116,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 +157,14 @@ namespace Entity.Weapon _sqrRange = _weaponData.AttackRange * _weaponData.AttackRange; _cachedRotation = CachedTransform.rotation; - string hitRadiusRaw = _weaponData.GetParamsString(HitRadiusParamKey); - if (!float.TryParse(hitRadiusRaw, out _hitRadius)) + if (_weaponData.TryGetParam(HitRadiusParamKey, out string hitRadiusRaw)) + { + _hitRadius = Mathf.Max(0.1f, float.Parse(hitRadiusRaw)); + } + else { _hitRadius = _weaponData.AttackRange; } - _hitRadius = Mathf.Max(0.1f, _hitRadius); _hitRadiusSqr = _hitRadius * _hitRadius; _attackEffect = new KnifeRangeAttackEffect(); 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..83de6b9 --- /dev/null +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponLightning/WeaponLightning.cs @@ -0,0 +1,272 @@ +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 const string HitRadiusParamKey = "HitRadius"; + + 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; + + _hitRadius = ReadPositiveParam(HitRadiusParamKey, _weaponData.AttackRange); + _hitRadiusSqr = _hitRadius * _hitRadius; + + 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 float ReadPositiveParam(string paramName, float defaultValue) + { + if (_weaponData.Params != null && + _weaponData.Params.TryGetValue(paramName.ToLower(), out string rawValue) && + float.TryParse(rawValue, out float parsedValue)) + { + return Mathf.Max(0.1f, parsedValue); + } + + return Mathf.Max(0.1f, defaultValue); + } + + 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..3067dc9 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponSlash/WeaponSlash.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponSlash/WeaponSlash.cs @@ -93,12 +93,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 +103,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]; diff --git a/Assets/GameMain/Scripts/Event/Combat.meta b/Assets/GameMain/Scripts/Event/Combat.meta new file mode 100644 index 0000000..04dd2e6 --- /dev/null +++ b/Assets/GameMain/Scripts/Event/Combat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7771e0f6b92ece64395ada5cdea72858 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: 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..b6a4ba2 100644 --- a/Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs +++ b/Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs @@ -91,6 +91,15 @@ namespace Procedure { GameEntry.Entity.HideEntity(entity.Id); } + + var enemyProjectiles = GameEntry.Entity.GetEntityGroup("EnemyProjectile")?.GetAllEntities(); + if (enemyProjectiles != null) + { + foreach (var projectile in enemyProjectiles) + { + GameEntry.Entity.HideEntity(projectile.Id); + } + } } public override void OnDestroy(IFsm procedureOwner) @@ -101,4 +110,4 @@ namespace Procedure #endregion } -} \ 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.EnemyJobs.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs index e08cf6b..98e0044 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs @@ -125,25 +125,44 @@ namespace Simulation private void TickEnemiesJobified(in SimulationTickContext context) { + if (context.DeltaTime <= 0f) + { + PrepareCollisionCandidateChannels(0, 0, 0); + ResetCollisionRuntimeStats(); + ClearAreaCollisionFrameBuffers(); + return; + } + using (CustomProfilerMarker.TickEnemies_BuildInput.Auto()) { SyncSimulationToJobInput(); + int projectileQueryCount = _projectiles.Count; + int areaQueryCount = GetPendingAreaCollisionQueryCount(); + int queryCount = projectileQueryCount + areaQueryCount; + int projectileExpectedCount = projectileQueryCount * Mathf.Max(1, _projectileMaxCandidatesPerQuery); + int areaExpectedCount = EstimatePendingAreaCollisionCandidateCount(); + int expectedCandidateCount = Mathf.Max(16, projectileExpectedCount + areaExpectedCount); + int bucketCapacity = Mathf.Max(256, _enemies.Count * 2 + queryCount); + PrepareCollisionCandidateChannels(queryCount, expectedCandidateCount, bucketCapacity); } using (CustomProfilerMarker.TickEnemies_StateUpdate.Auto()) { ExecuteEnemyMovementJob(in context); + ExecuteProjectileMovementJob(in context); } using (CustomProfilerMarker.TickEnemies_MoveSeparation.Auto()) { ApplyEnemySeparationForJobOutput(in context); + BuildProjectileCollisionCandidates(); } using (CustomProfilerMarker.TickEnemies_WriteBack.Auto()) { - SyncProjectilesToJobOutput(); ApplyJobOutputToSimulation(); + ResolveProjectileCollisionCandidatesMainThread(); + RecycleInactiveProjectiles(); } MarkEnemyTargetSpatialIndexDirty(); @@ -413,6 +432,9 @@ namespace Simulation if (sqrDistance <= float.Epsilon) { + float3 zeroDistanceAxis = GetZeroDistanceSeparationAxis(index, otherIndex); + float directionSign = index < otherIndex ? 1f : -1f; + pushAccumulation += zeroDistanceAxis * (selfRadius * 0.25f * directionSign); continue; } @@ -528,6 +550,18 @@ namespace Simulation return ((long)x << 32) ^ (uint)z; } + private static float3 GetZeroDistanceSeparationAxis(int index, int otherIndex) + { + int lowIndex = math.min(index, otherIndex); + int highIndex = math.max(index, otherIndex); + uint pairHash = (uint)(lowIndex * 73856093) ^ (uint)(highIndex * 19349663); + + float axisX = (pairHash & 1023u) / 511.5f - 1f; + float axisZ = ((pairHash >> 10) & 1023u) / 511.5f - 1f; + float3 axis = new float3(axisX, 0f, axisZ); + return math.normalizesafe(axis, new float3(1f, 0f, 0f)); + } + private static void ExecuteEnemyMovement(int index, NativeArray inputs, NativeArray outputs, float deltaTime, float3 playerPosition) { @@ -587,4 +621,4 @@ namespace Simulation }; } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs index f3344aa..3cf2a39 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs @@ -12,6 +12,7 @@ namespace Simulation 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 +54,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 +81,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.JobDataChannel.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs index 3627bcf..71b0ed3 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Unity.Collections; using Unity.Mathematics; using UnityEngine; @@ -43,7 +44,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; } @@ -54,11 +59,67 @@ 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; } + // Shared broad-phase query payload for CP5 projectile collision and CP6 AOE collision candidates. + private struct CollisionQueryData + { + public int QueryId; + public int SourceType; + public int SourceEntityId; + public int SourceOwnerEntityId; + public float3 Position; + public float Radius; + public int MaxTargets; + public int ShapeType; + public float3 Direction; + public float HalfAngleDeg; + } + + // Shared candidate buffer consumed by the main thread settlement stage. + private struct CollisionCandidateData + { + public int QueryId; + public int SourceType; + public int SourceEntityId; + public int SourceOwnerEntityId; + public int TargetEntityId; + public float SqrDistance; + } + + private struct AreaCollisionRequestData + { + public int SourceEntityId; + public int SourceOwnerEntityId; + public Vector3 Center; + public float Radius; + public int MaxTargets; + public int ShapeType; + public Vector3 Direction; + public float HalfAngleDeg; + } + + private struct AreaCollisionHitEventData + { + public int QueryId; + public int SourceEntityId; + public int SourceOwnerEntityId; + public int TargetEntityId; + public float SqrDistance; + } + + private const int CollisionSourceTypeProjectile = 1; + private const int CollisionSourceTypeArea = 2; + private const int CollisionShapeCircle = 0; + private const int CollisionShapeSector = 1; + private NativeList _enemyJobInputs; private NativeList _enemyJobOutputs; private NativeList _enemyJobSeparationOutputs; @@ -66,7 +127,34 @@ namespace Simulation private NativeList _enemySeparationCurrentPushes; private NativeList _projectileJobInputs; private NativeList _projectileJobOutputs; + private NativeList _collisionQueryInputs; + private NativeList _collisionCandidates; private NativeParallelMultiHashMap _enemySeparationBuckets; + private NativeParallelMultiHashMap _enemyCollisionBuckets; + private readonly List _areaCollisionRequests = new(16); + private readonly List _areaCollisionHitEvents = new(32); + private readonly HashSet _areaCollisionHitDedupKeys = new(); + private int _lastCollisionQueryCount; + private int _lastProjectileCollisionQueryCount; + private int _lastAreaCollisionQueryCount; + private int _lastCollisionCandidateCount; + private int _lastProjectileCollisionCandidateCount; + private int _lastAreaCollisionCandidateCount; + private int _lastResolvedAreaHitCount; + private float _lastCollisionCellSize; + private bool _lastCollisionHasEnemyTargets; + + 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 InitializeJobDataChannels() { @@ -83,7 +171,10 @@ namespace Simulation _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(); } @@ -93,57 +184,94 @@ namespace Simulation { _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; } @@ -167,6 +295,16 @@ namespace Simulation _projectileJobOutputs.Clear(); } + if (_collisionQueryInputs.IsCreated) + { + _collisionQueryInputs.Clear(); + } + + if (_collisionCandidates.IsCreated) + { + _collisionCandidates.Clear(); + } + if (_enemyJobSeparationOutputs.IsCreated) { _enemyJobSeparationOutputs.Clear(); @@ -187,7 +325,29 @@ namespace Simulation _enemySeparationBuckets.Clear(); } + if (_enemyCollisionBuckets.IsCreated) + { + _enemyCollisionBuckets.Clear(); + } + ClearEnemyTargetSpatialIndex(); + _areaCollisionRequests.Clear(); + _areaCollisionHitEvents.Clear(); + _areaCollisionHitDedupKeys.Clear(); + ResetCollisionRuntimeStats(); + } + + private void ResetCollisionRuntimeStats() + { + _lastCollisionQueryCount = 0; + _lastProjectileCollisionQueryCount = 0; + _lastAreaCollisionQueryCount = 0; + _lastCollisionCandidateCount = 0; + _lastProjectileCollisionCandidateCount = 0; + _lastAreaCollisionCandidateCount = 0; + _lastResolvedAreaHitCount = 0; + _lastCollisionCellSize = 0f; + _lastCollisionHasEnemyTargets = false; } private void SyncSimulationToJobInput() @@ -242,6 +402,18 @@ namespace Simulation } } + private void PrepareProjectileJobOutputBuffer(int projectileCount) + { + InitializeJobDataChannels(); + EnsureCapacity(ref _projectileJobOutputs, projectileCount); + _projectileJobOutputs.Clear(); + + if (projectileCount > 0) + { + _projectileJobOutputs.ResizeUninitialized(projectileCount); + } + } + private void SyncProjectilesToJobOutput() { InitializeJobDataChannels(); @@ -254,6 +426,116 @@ namespace Simulation } } + private void CopyProjectileInputToOutput() + { + 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 void PrepareCollisionCandidateChannels(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, + Position = new float3(projectile.Position.x, projectile.Position.y, projectile.Position.z), + Radius = radius, + MaxTargets = math.max(1, maxTargets), + ShapeType = CollisionShapeCircle, + Direction = new float3(0f, 0f, 1f), + HalfAngleDeg = 180f + }); + } + + private void AddAreaCollisionQuery(int queryId, int sourceEntityId, int sourceOwnerEntityId, in Vector3 center, + float radius, int maxTargets, int shapeType, in Vector3 direction, float halfAngleDeg) + { + 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, + 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) + }); + } + + 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 + }); + } + private void PrepareEnemySeparationJobBuffers(int enemyCount, int bucketCapacity) { InitializeJobDataChannels(); @@ -358,7 +640,10 @@ namespace Simulation IsNativeListUsable(_enemySeparationCurrentPushes) && IsNativeListUsable(_projectileJobInputs) && IsNativeListUsable(_projectileJobOutputs) && - IsNativeMultiHashMapUsable(_enemySeparationBuckets); + IsNativeListUsable(_collisionQueryInputs) && + IsNativeListUsable(_collisionCandidates) && + IsNativeMultiHashMapUsable(_enemySeparationBuckets) && + IsNativeMultiHashMapUsable(_enemyCollisionBuckets); } private static void EnsureCapacity(ref NativeList nativeList, int targetCount) where T : unmanaged @@ -472,7 +757,11 @@ namespace Simulation OwnerEntityId = projectile.OwnerEntityId, Position = projectile.Position, Forward = projectile.Forward, + Velocity = projectile.Velocity, Speed = projectile.Speed, + LifeTime = projectile.LifeTime, + Age = projectile.Age, + Active = projectile.Active, RemainingLifetime = projectile.RemainingLifetime, State = projectile.State }; @@ -486,7 +775,11 @@ namespace Simulation OwnerEntityId = projectile.OwnerEntityId, Position = projectile.Position, Forward = projectile.Forward, + Velocity = projectile.Velocity, Speed = projectile.Speed, + LifeTime = projectile.LifeTime, + Age = projectile.Age, + Active = projectile.Active, RemainingLifetime = projectile.RemainingLifetime, State = projectile.State }; @@ -500,7 +793,11 @@ namespace Simulation OwnerEntityId = projectile.OwnerEntityId, Position = projectile.Position, Forward = projectile.Forward, + Velocity = projectile.Velocity, Speed = projectile.Speed, + LifeTime = projectile.LifeTime, + Age = projectile.Age, + Active = projectile.Active, RemainingLifetime = projectile.RemainingLifetime, State = projectile.State }; diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs index 6d12d64..d22b95a 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs @@ -1,18 +1,83 @@ +using CustomEvent; +using Entity; +using Entity.EntityData; +using Entity.Weapon; +using GameFramework.Event; using UnityEngine; namespace Simulation { public partial class SimulationWorld { + [Header("投射物命中表现")] [Tooltip("是否监听投射物命中表现事件。")] [SerializeField] + private bool _projectileHitPresentationEnabled = true; + + [Tooltip("是否播放投射物命中标记。")] [SerializeField] + private bool _projectileHitMarkerEnabled = true; + + [Tooltip("命中标记尺寸。")] [SerializeField] + private float _projectileHitMarkerSize = 0.2f; + + [Tooltip("命中标记在目标上的高度偏移。")] [SerializeField] + private float _projectileHitMarkerYOffset = 1.2f; + + [Tooltip("命中标记持续时间。")] [SerializeField] + private float _projectileHitMarkerDuration = 0.15f; + + [Tooltip("命中标记颜色。")] [SerializeField] + private Color _projectileHitMarkerColor = new(1f, 0f, 0f, 0.95f); + + [Tooltip("是否播放投射物命中特效实体。")] [SerializeField] + private bool _projectileHitEffectEnabled; + + [Tooltip("投射物命中特效实体类型 Id(<=0 表示不启用)。")] [SerializeField] + private int _projectileHitEffectTypeId; + private sealed class Presentation { private readonly SimulationWorld _world; + private HandgunHitMarkerAttackEffect _projectileHitMarkerEffect; + private bool _isProjectileHitEventSubscribed; public Presentation(SimulationWorld world) { _world = world; } + public void OnStart() + { + if (_world == null || !_world._projectileHitPresentationEnabled) + { + return; + } + + var eventComponent = GameEntry.Event; + if (eventComponent == null) + { + return; + } + + eventComponent.Subscribe(ProjectileHitPresentationEventArgs.EventId, OnProjectileHitPresentationEvent); + _isProjectileHitEventSubscribed = true; + } + + public void OnDestroy() + { + if (!_isProjectileHitEventSubscribed) + { + return; + } + + var eventComponent = GameEntry.Event; + if (eventComponent != null) + { + eventComponent.Unsubscribe(ProjectileHitPresentationEventArgs.EventId, + OnProjectileHitPresentationEvent); + } + + _isProjectileHitEventSubscribed = false; + } + public void OnLateUpdate() { if (_world == null || !_world.UseSimulationMovement) @@ -27,9 +92,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 +106,85 @@ namespace Simulation ApplyEnemyPresentation(enemyEntity, enemyData); } + + if (!_world.UseJobSimulation) + { + return; + } + + var projectiles = _world._projectiles; + for (int i = 0; i < projectiles.Count; i++) + { + ProjectileSimData projectileData = projectiles[i]; + if (!projectileData.Active || projectileData.State != ProjectileStateActive) + { + continue; + } + + EntityBase projectileEntity = TryGetEntityById(projectileData.EntityId); + if (projectileEntity == null || !projectileEntity.Available) + { + continue; + } + + ApplyProjectilePresentation(projectileEntity, projectileData); + } + } + + private void OnProjectileHitPresentationEvent(object sender, GameEventArgs e) + { + if (!_isProjectileHitEventSubscribed || _world == null || + e is not ProjectileHitPresentationEventArgs args) + { + return; + } + + if (args.ShowHitMarker && _world._projectileHitMarkerEnabled) + { + PlayHitMarker(args); + } + + if (args.ShowHitEffect && _world._projectileHitEffectEnabled) + { + PlayHitEffect(args); + } + } + + private void PlayHitMarker(ProjectileHitPresentationEventArgs args) + { + EntityBase targetEntity = TryGetEntityById(args.TargetEntityId); + if (targetEntity == null || !targetEntity.Available) + { + return; + } + + _projectileHitMarkerEffect ??= new HandgunHitMarkerAttackEffect( + Mathf.Max(0.01f, _world._projectileHitMarkerSize), + _world._projectileHitMarkerYOffset, + Mathf.Max(0.01f, _world._projectileHitMarkerDuration), + _world._projectileHitMarkerColor); + _projectileHitMarkerEffect.Play(null, args.HitPosition, targetEntity, 0f); + } + + private void PlayHitEffect(ProjectileHitPresentationEventArgs args) + { + int effectTypeId = args.EffectEntityTypeId > 0 ? args.EffectEntityTypeId : _world._projectileHitEffectTypeId; + if (effectTypeId <= 0) + { + return; + } + + var entityComponent = GameEntry.Entity; + if (entityComponent == null) + { + return; + } + + EffectData effectData = new EffectData(entityComponent.GenerateSerialId(), effectTypeId) + { + Position = args.HitPosition + }; + entityComponent.ShowEffect(effectData); } private static void ApplyEnemyPresentation(EnemyBase enemyEntity, in EnemySimData enemyData) @@ -64,6 +208,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/SimulationWorld.ProjectileJobs.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.ProjectileJobs.cs new file mode 100644 index 0000000..0785fa6 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.ProjectileJobs.cs @@ -0,0 +1,1013 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; +using CustomDebugger; +using CustomEvent; +using Definition.DataStruct; +using Definition.Enum; +using Entity; +using Entity.Weapon; +using CustomUtility; +using UnityGameFramework.Runtime; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private const int PlayerEntityId = -1; + + [Header("投射物模拟参数")] [Tooltip("投射物距离玩家超过该水平半径时回收。小于等于 0 表示不启用该回收条件。")] [SerializeField] + private float _projectileMaxDistanceFromPlayer = 120f; + + [Tooltip("投射物与玩家的垂直高度差超过该值时回收。小于等于 0 表示不启用该回收条件。")] [SerializeField] + private float _projectileMaxVerticalOffsetFromPlayer = 30f; + + [Tooltip("投射物 Broad Phase 命中查询半径。")] [SerializeField] + private float _projectileCollisionQueryRadius = 0.35f; + + [Tooltip("每个投射物最多保留的候选目标数量。")] [SerializeField] + private int _projectileMaxCandidatesPerQuery = 1; + + [Tooltip("投射物 Broad Phase 分桶网格尺寸。小于等于 0 时将按查询半径自动推导。")] [SerializeField] + private float _projectileCollisionCellSize = 0f; + + [Header("投射物命中事件派发")] [Tooltip("是否派发投射物命中表现事件。")] [SerializeField] + private bool _dispatchProjectileHitPresentationEvent = true; + + [Tooltip("命中时是否请求命中标记表现。")] [SerializeField] + private bool _dispatchProjectileHitMarkerEvent = true; + + [Tooltip("命中时是否请求特效表现。")] [SerializeField] + private bool _dispatchProjectileHitEffectEvent = true; + + [Tooltip("命中事件建议使用的特效实体类型 Id(<=0 表示不指定,由表现层决定)。")] [SerializeField] + private int _projectileHitPresentationEffectTypeId = 0; + + [BurstCompile] + private struct ProjectileMovementBurstJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + public NativeArray Outputs; + public float DeltaTime; + public float3 PlayerPosition; + public float MaxSqrDistanceFromPlayer; + public float MaxVerticalOffsetFromPlayer; + + public void Execute(int index) + { + ExecuteProjectileMovement(index, Inputs, Outputs, DeltaTime, PlayerPosition, + MaxSqrDistanceFromPlayer, MaxVerticalOffsetFromPlayer); + } + } + + private struct ProjectileMovementJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + public NativeArray Outputs; + public float DeltaTime; + public float3 PlayerPosition; + public float MaxSqrDistanceFromPlayer; + public float MaxVerticalOffsetFromPlayer; + + public void Execute(int index) + { + ExecuteProjectileMovement(index, Inputs, Outputs, DeltaTime, PlayerPosition, + MaxSqrDistanceFromPlayer, MaxVerticalOffsetFromPlayer); + } + } + + public bool TryEnqueueAreaCollisionQuery(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center, + float radius, int maxTargets = 16) + { + return TryEnqueueAreaCollisionQueryInternal(sourceEntityId, sourceOwnerEntityId, in center, radius, + maxTargets, CollisionShapeCircle, Vector3.forward, 180f); + } + + public bool TryEnqueueSectorCollisionQuery(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center, + float radius, in Vector3 direction, float halfAngleDeg, int maxTargets = 16) + { + return TryEnqueueAreaCollisionQueryInternal(sourceEntityId, sourceOwnerEntityId, in center, radius, + maxTargets, CollisionShapeSector, direction, halfAngleDeg); + } + + private bool TryEnqueueAreaCollisionQueryInternal(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center, + float radius, int maxTargets, int shapeType, in Vector3 direction, float halfAngleDeg) + { + if (!_useSimulationMovement || !_useJobSimulation) + { + return false; + } + + if (sourceEntityId == 0 || radius <= 0f || maxTargets <= 0) + { + return false; + } + + int resolvedOwnerEntityId = sourceOwnerEntityId != 0 ? sourceOwnerEntityId : 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, + Center = center, + Radius = Mathf.Max(0.01f, radius), + MaxTargets = Mathf.Max(1, maxTargets), + ShapeType = shapeType, + Direction = normalizedDirection, + HalfAngleDeg = Mathf.Clamp(halfAngleDeg, 0f, 180f) + }); + + return true; + } + + private int GetPendingAreaCollisionQueryCount() + { + return _areaCollisionRequests.Count; + } + + private int EstimatePendingAreaCollisionCandidateCount() + { + int expectedCount = 0; + for (int i = 0; i < _areaCollisionRequests.Count; i++) + { + expectedCount += Mathf.Max(1, _areaCollisionRequests[i].MaxTargets); + } + + return expectedCount; + } + + private void ExecuteProjectileMovementJob(in SimulationTickContext context) + { + int projectileCount = _projectileJobInputs.Length; + PrepareProjectileJobOutputBuffer(projectileCount); + + if (projectileCount == 0) + { + return; + } + + if (context.DeltaTime <= 0f) + { + CopyProjectileInputToOutput(); + return; + } + + 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(); + + JobHandle handle; + if (_useBurstJobs) + { + ProjectileMovementBurstJob burstJob = new ProjectileMovementBurstJob + { + Inputs = inputArray, + Outputs = outputArray, + DeltaTime = context.DeltaTime, + PlayerPosition = playerPosition, + MaxSqrDistanceFromPlayer = maxSqrDistanceFromPlayer, + MaxVerticalOffsetFromPlayer = maxVerticalOffsetFromPlayer + }; + handle = burstJob.Schedule(projectileCount, 64); + } + else + { + ProjectileMovementJob job = new ProjectileMovementJob + { + Inputs = inputArray, + Outputs = outputArray, + DeltaTime = context.DeltaTime, + PlayerPosition = playerPosition, + MaxSqrDistanceFromPlayer = maxSqrDistanceFromPlayer, + MaxVerticalOffsetFromPlayer = maxVerticalOffsetFromPlayer + }; + handle = job.Schedule(projectileCount, 64); + } + + handle.Complete(); + } + + private void BuildProjectileCollisionCandidates() + { + if (!_collisionQueryInputs.IsCreated || !_collisionCandidates.IsCreated || + !_enemyCollisionBuckets.IsCreated) + { + ResetCollisionRuntimeStats(); + ClearAreaCollisionFrameBuffers(); + 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, in request.Center, + request.Radius, request.MaxTargets, request.ShapeType, in request.Direction, request.HalfAngleDeg); + 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) + { + BuildEnemyCollisionBucketsForProjectiles(cellSize); + } + } + + int projectileCandidateCount; + int areaCandidateCount; + using (CustomProfilerMarker.Collision_QueryCandidates.Auto()) + { + QueryProjectileCollisionCandidates(cellSize, hasEnemyTargets, out projectileCandidateCount, + out areaCandidateCount); + } + + _lastProjectileCollisionCandidateCount = projectileCandidateCount; + _lastAreaCollisionCandidateCount = areaCandidateCount; + _lastCollisionCandidateCount = projectileCandidateCount + areaCandidateCount; + } + + private void ResolveProjectileCollisionCandidatesMainThread() + { + if (!_collisionCandidates.IsCreated) + { + _lastResolvedAreaHitCount = 0; + ClearAreaCollisionFrameBuffers(); + return; + } + + _projectileResolvedEntityIds.Clear(); + _areaCollisionHitEvents.Clear(); + _areaCollisionHitDedupKeys.Clear(); + + if (_collisionCandidates.Length == 0) + { + _lastResolvedAreaHitCount = 0; + ClearAreaCollisionFrameBuffers(); + 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 (!TryGetActiveProjectileData(projectileEntityId, out _, out ProjectileSimData projectile)) + { + _projectileResolvedEntityIds.Add(projectileEntityId); + continue; + } + + bool shouldExpireProjectile = true; + bool shouldDispatchPresentation = false; + int damage = 0; + Vector3 hitPosition = projectile.Position; + if (TryGetTargetableEntity(candidate.TargetEntityId, out TargetableObject target)) + { + EntityBase sourceEntity = TryGetEntityById(candidate.SourceEntityId); + EntityBase ownerEntity = TryGetEntityById(candidate.SourceOwnerEntityId); + shouldExpireProjectile = ResolveProjectileHit(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) + { + MarkProjectileExpired(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 = ResolveAreaCollisionHitsMainThread(); + } + + _lastResolvedAreaHitCount = resolvedAreaHitCount; + _projectileResolvedEntityIds.Clear(); + ClearAreaCollisionFrameBuffers(); + } + + private void RecycleInactiveProjectiles() + { + _projectileRecycleEntityIds.Clear(); + for (int i = 0; i < _projectiles.Count; i++) + { + ProjectileSimData projectile = _projectiles[i]; + if (!ShouldRecycleProjectile(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 bool ResolveProjectileHit(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 = CalculateProjectileDamage(sourceImpact.AttackBase, sourceImpact.AttackStat, + targetImpact.DefenseStat, + targetImpact.DodgeStat); + shouldDispatchPresentation = true; + if (damage <= 0) + { + return true; + } + + target.ApplyDamage(attacker ?? sourceEntity ?? ownerEntity, damage); + return true; + } + + 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 int CalculateProjectileDamage(int attack, StatProperty attackStat, StatProperty defenseStat, + StatProperty dodgeStat) + { + if (dodgeStat != null) + { + if (UnityEngine.Random.value < Mathf.Clamp(dodgeStat.Percent, 0f, 0.9f)) + { + return 0; + } + } + + float damage = attack; + if (attackStat != null) + { + damage = (attack + attackStat.Value) * attackStat.Percent; + } + + if (defenseStat != null) + { + damage = (damage - defenseStat.Value) / defenseStat.Percent; + } + + if (damage < 1f) + { + return 1; + } + + return Mathf.CeilToInt(damage); + } + + private static bool TryGetTargetableEntity(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; + } + + private bool TryGetActiveProjectileData(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 MarkProjectileExpired(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; + } + + private int ResolveAreaCollisionHitsMainThread() + { + if (_areaCollisionHitEvents.Count == 0) + { + return 0; + } + + int resolvedHitCount = 0; + for (int i = 0; i < _areaCollisionHitEvents.Count; i++) + { + AreaCollisionHitEventData hitEvent = _areaCollisionHitEvents[i]; + if (!TryGetCollisionQueryById(hitEvent.QueryId, out CollisionQueryData query) || + query.SourceType != CollisionSourceTypeArea) + { + continue; + } + + EntityBase sourceEntity = TryGetEntityById(hitEvent.SourceEntityId); + if (sourceEntity == null || !sourceEntity.Available) + { + continue; + } + + if (!TryGetTargetableEntity(hitEvent.TargetEntityId, out TargetableObject target)) + { + continue; + } + + if (!IsAreaTargetInsidePreciseShape(in query, target)) + { + continue; + } + + AIUtility.PerformCollision(target, sourceEntity); + resolvedHitCount++; + } + + return resolvedHitCount; + } + + private bool TryGetCollisionQueryById(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 static bool IsAreaTargetInsidePreciseShape(in CollisionQueryData query, TargetableObject target) + { + 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); + float radiusSqr = radius * radius; + float sqrDistance = toTarget.sqrMagnitude; + if (sqrDistance > radiusSqr) + { + return false; + } + + 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 void ClearAreaCollisionFrameBuffers() + { + _areaCollisionRequests.Clear(); + _areaCollisionHitEvents.Clear(); + _areaCollisionHitDedupKeys.Clear(); + } + + private void BuildEnemyCollisionBucketsForProjectiles(float cellSize) + { + _enemyCollisionBuckets.Clear(); + for (int i = 0; i < _enemyJobOutputs.Length; i++) + { + EnemyJobOutputData enemy = _enemyJobOutputs[i]; + int cellX = (int)math.floor(enemy.Position.x / cellSize); + int cellZ = (int)math.floor(enemy.Position.z / cellSize); + _enemyCollisionBuckets.Add(SeparationCellKey(cellX, cellZ), i); + } + } + + private void QueryProjectileCollisionCandidates(float cellSize, bool hasEnemyTargets, + out int projectileCandidateCount, out int areaCandidateCount) + { + projectileCandidateCount = 0; + areaCandidateCount = 0; + bool hasPlayerTarget = TryGetPlayerCollisionTarget(out int playerTargetEntityId, out float3 playerPosition); + + for (int i = 0; i < _collisionQueryInputs.Length; i++) + { + CollisionQueryData query = _collisionQueryInputs[i]; + float radiusSqr = query.Radius * query.Radius; + int centerCellX = (int)math.floor(query.Position.x / cellSize); + int centerCellZ = (int)math.floor(query.Position.z / cellSize); + int queryRange = math.max(1, (int)math.ceil(query.Radius / cellSize)); + int selectedCount = 0; + bool reachedLimit = false; + + if (hasPlayerTarget && query.SourceEntityId != playerTargetEntityId && + query.SourceOwnerEntityId != playerTargetEntityId) + { + playerPosition.y = query.Position.y; + float3 playerDelta = playerPosition - query.Position; + float playerSqrDistance = math.lengthsq(playerDelta); + // Log.Info( + // $"playerPos:{playerPosition} - queryPos:{query.Position} = playerSqrDistance:{playerSqrDistance}"); + if (playerSqrDistance <= radiusSqr) + { + AddCollisionCandidate( + query.QueryId, + query.SourceType, + query.SourceEntityId, + query.SourceOwnerEntityId, + playerTargetEntityId, + playerSqrDistance); + if (query.SourceType == CollisionSourceTypeProjectile) + { + projectileCandidateCount++; + } + else if (query.SourceType == CollisionSourceTypeArea) + { + areaCandidateCount++; + } + } + } + + if (!hasEnemyTargets) + { + continue; + } + + for (int dx = -queryRange; dx <= queryRange && !reachedLimit; dx++) + { + for (int dz = -queryRange; dz <= queryRange && !reachedLimit; dz++) + { + long key = SeparationCellKey(centerCellX + dx, centerCellZ + dz); + if (!_enemyCollisionBuckets.TryGetFirstValue(key, out int enemyIndex, + out NativeParallelMultiHashMapIterator iterator)) + { + continue; + } + + do + { + if (enemyIndex < 0 || enemyIndex >= _enemyJobOutputs.Length) + { + continue; + } + + EnemyJobOutputData enemy = _enemyJobOutputs[enemyIndex]; + if (enemy.EntityId == query.SourceOwnerEntityId) + { + continue; + } + + float3 delta = new float3( + enemy.Position.x - query.Position.x, + enemy.Position.y - query.Position.y, + enemy.Position.z - query.Position.z); + float sqrDistance = math.lengthsq(delta); + if (sqrDistance > radiusSqr) + { + continue; + } + + AddCollisionCandidate( + query.QueryId, + query.SourceType, + query.SourceEntityId, + query.SourceOwnerEntityId, + enemy.EntityId, + sqrDistance); + if (query.SourceType == CollisionSourceTypeProjectile) + { + projectileCandidateCount++; + } + else if (query.SourceType == CollisionSourceTypeArea) + { + areaCandidateCount++; + } + selectedCount++; + + if (selectedCount >= query.MaxTargets) + { + reachedLimit = true; + break; + } + } while (_enemyCollisionBuckets.TryGetNextValue(out enemyIndex, ref iterator)); + } + } + } + } + + private static bool TryGetPlayerCollisionTarget(out int playerEntityId, out float3 playerPosition) + { + playerEntityId = PlayerEntityId; + playerPosition = default; + + if (!TryGetTargetableEntity(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; + } + + private static bool ShouldRecycleProjectile(in ProjectileSimData projectile) + { + if (!projectile.Active) + { + return true; + } + + if (projectile.State == ProjectileStateExpired) + { + return true; + } + + return projectile.LifeTime > 0f && projectile.Age >= projectile.LifeTime; + } + + private static 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 = new float3(input.Position.x, input.Position.y, input.Position.z); + float3 forward = new float3(input.Forward.x, input.Forward.y, input.Forward.z); + float3 velocity = new float3(input.Velocity.x, input.Velocity.y, input.Velocity.z); + 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 = new Vector3(nextPosition.x, nextPosition.y, nextPosition.z); + output.Velocity = new Vector3(velocity.x, velocity.y, velocity.z); + 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 = new Vector3(moveForward.x, moveForward.y, moveForward.z); + } + + outputs[index] = output; + } + } +} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.ProjectileJobs.cs.meta b/Assets/GameMain/Scripts/Simulation/SimulationWorld.ProjectileJobs.cs.meta new file mode 100644 index 0000000..87ea5a4 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/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/SimulationWorld.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs index e615094..9c67d05 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs @@ -15,6 +15,8 @@ namespace Simulation private const int EnemyStateIdle = 0; private const int EnemyStateChasing = 1; private const int EnemyStateInAttackRange = 2; + private const int ProjectileStateActive = 0; + private const int ProjectileStateExpired = 1; private struct EnemyTickWorkItem { @@ -49,6 +51,8 @@ namespace Simulation private readonly List _enemies = new List(); private readonly List _projectiles = new List(); private readonly List _pickups = new List(); + private readonly List _projectileRecycleEntityIds = new List(); + private readonly HashSet _projectileResolvedEntityIds = new HashSet(); private readonly List _enemySeparationAgents = new List(); private readonly List _enemyTickWorkItems = new List(); @@ -89,10 +93,12 @@ namespace Simulation private void Start() { _entitySync?.OnStart(); + _presentation?.OnStart(); } private void OnDestroy() { + _presentation?.OnDestroy(); _entitySync?.OnDestroy(); _entitySync = null; _presentation = null; @@ -216,14 +222,14 @@ namespace Simulation return true; } - private void RegisterProjectileLifecycle(EntityBase projectileEntity) + private void RegisterProjectileLifecycle(EntityBase projectileEntity, object userData) { if (projectileEntity == null || projectileEntity.CachedTransform == null) { return; } - UpsertProjectile(CreateProjectileInitialSimData(projectileEntity)); + UpsertProjectile(CreateProjectileInitialSimData(projectileEntity, userData)); } private void UnregisterProjectileLifecycle(int entityId) @@ -313,6 +319,11 @@ namespace Simulation _enemies.Clear(); _projectiles.Clear(); _pickups.Clear(); + _projectileRecycleEntityIds.Clear(); + _projectileResolvedEntityIds.Clear(); + _areaCollisionRequests.Clear(); + _areaCollisionHitEvents.Clear(); + _areaCollisionHitDedupKeys.Clear(); _enemySeparationAgents.Clear(); _enemyTickWorkItems.Clear(); ClearJobDataChannels(); @@ -510,6 +521,10 @@ namespace Simulation speed = movementComponent.Speed; } + float attackRange = enemy != null && enemy.AttackRange > 0f + ? enemy.AttackRange + : DefaultAttackRange; + return new EnemySimData { EntityId = enemy.Id, @@ -517,7 +532,7 @@ namespace Simulation Forward = enemyTransform.forward, Rotation = enemyTransform.rotation, Speed = speed, - AttackRange = 1f, + AttackRange = attackRange, AvoidEnemyOverlap = movementComponent != null && movementComponent.AvoidEnemyOverlap, EnemyBodyRadius = movementComponent != null ? movementComponent.EnemyBodyRadius : 0.45f, SeparationIterations = movementComponent != null ? movementComponent.SeparationIterations : 2, @@ -537,17 +552,52 @@ namespace Simulation }; } - private static ProjectileSimData CreateProjectileInitialSimData(EntityBase projectileEntity) + 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 = 0, + OwnerEntityId = ownerEntityId, Position = projectileEntity.CachedTransform.position, - Forward = projectileEntity.CachedTransform.forward, - Speed = 0f, - RemainingLifetime = 0f, - State = 0 + Forward = forward, + Velocity = velocity, + Speed = speed, + LifeTime = lifeTime, + Age = 0f, + Active = true, + RemainingLifetime = lifeTime, + State = ProjectileStateActive }; } } diff --git a/Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs b/Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs index 3d41060..b7f37d5 100644 --- a/Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs +++ b/Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs @@ -395,6 +395,8 @@ 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); default: return null; } diff --git a/Assets/GameMain/Scripts/Utility/AIUtility.cs b/Assets/GameMain/Scripts/Utility/AIUtility.cs index 04980b0..b9399ea 100644 --- a/Assets/GameMain/Scripts/Utility/AIUtility.cs +++ b/Assets/GameMain/Scripts/Utility/AIUtility.cs @@ -201,6 +201,26 @@ namespace CustomUtility // return; // } + EnemyProjectile enemyProjectile = other as EnemyProjectile; + if (enemyProjectile != null) + { + if (!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) { @@ -220,13 +240,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/Launcher.unity b/Assets/Launcher.unity index 0c01be0..733d6f0 100644 --- a/Assets/Launcher.unity +++ b/Assets/Launcher.unity @@ -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 @@ -1432,11 +1432,25 @@ MonoBehaviour: _enemySeparationCellSize: 0 _enemySeparationPushDamping: 0.5 _enemySeparationMaxStepScale: 0.5 - _enemySeparationZeroDistancePushScale: 1 _enemySeparationUseTangentialInAttackRange: 1 _enemySeparationPushSmoothing: 0.55 - _enemySeparationPushCarry: 1 - _enemySeparationMinPushRetain: 1 + _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 + _projectileMaxDistanceFromPlayer: 120 + _projectileMaxVerticalOffsetFromPlayer: 30 + _projectileCollisionQueryRadius: 0.35 + _projectileMaxCandidatesPerQuery: 1 + _projectileCollisionCellSize: 0 + _dispatchProjectileHitPresentationEvent: 1 + _dispatchProjectileHitMarkerEvent: 1 + _dispatchProjectileHitEffectEvent: 1 + _projectileHitPresentationEffectTypeId: 0 _targetSelectionCellSize: 2 --- !u!1 &1852670052 GameObject: diff --git a/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs index 541b8ea..26172c5 100644 --- a/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs +++ b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs @@ -20,6 +20,21 @@ 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 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}"); @@ -29,6 +44,12 @@ namespace Simulation.Tests.Editor 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); @@ -56,6 +77,39 @@ namespace Simulation.Tests.Editor 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 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 FieldInfo ProjectileMaxDistanceFromPlayerField = + SimulationWorldType?.GetField("_projectileMaxDistanceFromPlayer", NonPublicInstance); + + private static readonly FieldInfo ProjectileMaxVerticalOffsetFromPlayerField = + SimulationWorldType?.GetField("_projectileMaxVerticalOffsetFromPlayer", NonPublicInstance); + private GameObject _worldGameObject; private Component _worldComponent; @@ -65,9 +119,16 @@ 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(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(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(TryGetNearestEnemyEntityIdMethod, "TryGetNearestEnemyEntityId reflection lookup failed."); @@ -75,6 +136,22 @@ namespace Simulation.Tests.Editor Assert.NotNull(SetUseJobSimulationMethod, "SetUseJobSimulation 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(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."); _worldGameObject = new GameObject("SimulationWorldTickTests"); _worldComponent = _worldGameObject.AddComponent(SimulationWorldType); @@ -230,6 +307,209 @@ namespace Simulation.Tests.Editor Assert.That(distance, Is.GreaterThanOrEqualTo(0.5f)); } + [Test] + public void TickProjectiles_MovesAndUpdatesLifetime_WhenJobSimulationEnabled() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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_ResumesFromLatestState_AfterTogglingJobSimulation() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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)); + + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { false }); + InvokeTick(deltaTime: 0.5f, realDeltaTime: 0.5f, playerPosition: Vector3.zero); + object afterJobDisabled = GetProjectileAt(0); + Vector3 positionAfterJobDisabled = (Vector3)GetField(afterJobDisabled, "Position"); + float ageAfterJobDisabled = (float)GetField(afterJobDisabled, "Age"); + Assert.That(positionAfterJobDisabled.x, Is.EqualTo(1f).Within(0.0001f)); + Assert.That(ageAfterJobDisabled, Is.EqualTo(0.5f).Within(0.0001f)); + + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + InvokeTick(deltaTime: 0.5f, realDeltaTime: 0.5f, playerPosition: Vector3.zero); + object afterJobReEnabled = GetProjectileAt(0); + Vector3 positionAfterJobReEnabled = (Vector3)GetField(afterJobReEnabled, "Position"); + float ageAfterJobReEnabled = (float)GetField(afterJobReEnabled, "Age"); + float remainingLifetimeAfterJobReEnabled = (float)GetField(afterJobReEnabled, "RemainingLifetime"); + bool activeAfterJobReEnabled = (bool)GetField(afterJobReEnabled, "Active"); + + Assert.That(positionAfterJobReEnabled.x, Is.EqualTo(2f).Within(0.0001f)); + Assert.That(ageAfterJobReEnabled, Is.EqualTo(1f).Within(0.0001f)); + Assert.That(remainingLifetimeAfterJobReEnabled, Is.EqualTo(4f).Within(0.0001f)); + Assert.IsTrue(activeAfterJobReEnabled); + } + + [Test] + public void EnemyProjectile_TogglesCollider_WhenJobSimulationSwitchesAtRuntime() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { 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); + + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + InvokeEnemyProjectileUpdate(projectileComponent, 0.016f, 0.016f); + Assert.IsFalse(projectileCollider.enabled); + + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { 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() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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_ExpiresAfterCollisionCandidateConsumed_WhenJobSimulationEnabled() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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)); + } + private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange, bool avoidEnemyOverlap = false, float enemyBodyRadius = 0.45f, int separationIterations = 1) { @@ -248,6 +528,24 @@ namespace Simulation.Tests.Editor 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( @@ -265,11 +563,37 @@ namespace Simulation.Tests.Editor 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 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); @@ -293,6 +617,25 @@ 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 int GetCollisionCandidateCount() + { + return (int)CollisionCandidateCountProperty.GetValue(_worldComponent); + } + private static object GetField(object target, string fieldName) { FieldInfo field = target.GetType().GetField(fieldName, PublicInstance); diff --git a/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs index 95b8649..147fe5f 100644 --- a/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs +++ b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs @@ -22,6 +22,21 @@ 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}"); @@ -31,6 +46,12 @@ namespace Simulation.Tests.PlayMode 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); @@ -58,6 +79,39 @@ namespace Simulation.Tests.PlayMode 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 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 FieldInfo ProjectileMaxDistanceFromPlayerField = + SimulationWorldType?.GetField("_projectileMaxDistanceFromPlayer", NonPublicInstance); + + private static readonly FieldInfo ProjectileMaxVerticalOffsetFromPlayerField = + SimulationWorldType?.GetField("_projectileMaxVerticalOffsetFromPlayer", NonPublicInstance); + private GameObject _worldGameObject; private Component _worldComponent; @@ -67,9 +121,16 @@ 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(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(TryGetNearestEnemyEntityIdMethod, "TryGetNearestEnemyEntityId reflection lookup failed."); @@ -77,6 +138,22 @@ namespace Simulation.Tests.PlayMode Assert.NotNull(SetUseJobSimulationMethod, "SetUseJobSimulation 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(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."); _worldGameObject = new GameObject("SimulationWorldPlayModeTests"); _worldComponent = _worldGameObject.AddComponent(SimulationWorldType); @@ -246,6 +323,223 @@ namespace Simulation.Tests.PlayMode yield break; } + [UnityTest] + public IEnumerator TickProjectiles_MovesAndUpdatesLifetime_WhenJobSimulationEnabled() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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_ResumesFromLatestState_AfterTogglingJobSimulation() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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)); + + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { false }); + InvokeTick(deltaTime: 0.5f, realDeltaTime: 0.5f, playerPosition: Vector3.zero); + object afterJobDisabled = GetProjectileAt(0); + Vector3 positionAfterJobDisabled = (Vector3)GetField(afterJobDisabled, "Position"); + float ageAfterJobDisabled = (float)GetField(afterJobDisabled, "Age"); + Assert.That(positionAfterJobDisabled.x, Is.EqualTo(1f).Within(0.0001f)); + Assert.That(ageAfterJobDisabled, Is.EqualTo(0.5f).Within(0.0001f)); + + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + InvokeTick(deltaTime: 0.5f, realDeltaTime: 0.5f, playerPosition: Vector3.zero); + object afterJobReEnabled = GetProjectileAt(0); + Vector3 positionAfterJobReEnabled = (Vector3)GetField(afterJobReEnabled, "Position"); + float ageAfterJobReEnabled = (float)GetField(afterJobReEnabled, "Age"); + float remainingLifetimeAfterJobReEnabled = (float)GetField(afterJobReEnabled, "RemainingLifetime"); + bool activeAfterJobReEnabled = (bool)GetField(afterJobReEnabled, "Active"); + + Assert.That(positionAfterJobReEnabled.x, Is.EqualTo(2f).Within(0.0001f)); + Assert.That(ageAfterJobReEnabled, Is.EqualTo(1f).Within(0.0001f)); + Assert.That(remainingLifetimeAfterJobReEnabled, Is.EqualTo(4f).Within(0.0001f)); + Assert.IsTrue(activeAfterJobReEnabled); + yield break; + } + + [UnityTest] + public IEnumerator EnemyProjectile_TogglesCollider_WhenJobSimulationSwitchesAtRuntime() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { 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); + + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + InvokeEnemyProjectileUpdate(projectileComponent, 0.016f, 0.016f); + Assert.IsFalse(projectileCollider.enabled); + + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { 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() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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_ExpiresAfterCollisionCandidateConsumed_WhenJobSimulationEnabled() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + 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; + } + private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange, bool avoidEnemyOverlap = false, float enemyBodyRadius = 0.45f, int separationIterations = 1) { @@ -264,6 +558,24 @@ namespace Simulation.Tests.PlayMode 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( @@ -281,11 +593,37 @@ namespace Simulation.Tests.PlayMode 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 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); @@ -309,6 +647,25 @@ 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 static object GetField(object target, string fieldName) { FieldInfo field = target.GetType().GetField(fieldName, PublicInstance); diff --git a/docs/TodoList.md b/docs/TodoList.md index e3be624..3c35b95 100644 --- a/docs/TodoList.md +++ b/docs/TodoList.md @@ -144,13 +144,13 @@ - 避免全量最近邻搜索,控制复杂度随敌人数增长的斜率。 - 完成标准:`3k` 敌人下目标选择阶段耗时稳定,且无索引越界/漏目标回归。 -- [ ] Checkpoint 5:投射物批量移动与寿命回收 Job 化 +- [x] Checkpoint 5:投射物批量移动与寿命回收 Job 化 - 投射物数据结构最少包含:`position/velocity/lifeTime/age/active`。 - 迁移投射物移动、越界判定、寿命回收到 Job。 - 回收后保持实体池与索引同步,防止悬空引用。 - 完成标准:连续战斗下投射物数量曲线稳定,无异常积压或提前回收。 -- [ ] Checkpoint 6:AOE/碰撞候选筛选 Job 化(Broad Phase 优先) +- [x] Checkpoint 6:AOE/碰撞候选筛选 Job 化(Broad Phase 优先) - 先 Job 化候选生成(Broad Phase),减少精算对数。 - 精算与伤害结算可先保留主线程,但输入改为候选列表驱动。 - 建立命中事件缓冲区,统一在主线程提交表现层事件。 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..6e9a339 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..ed04d94 100644 Binary files a/数据表/Entity/Weapon.xlsx and b/数据表/Entity/Weapon.xlsx differ diff --git a/数据表/Goods.xlsx b/数据表/Goods.xlsx index 7d8a40f..1740ccc 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