From d27ae9407df8e3a0dd82d18e47a007927c634483 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Tue, 2 Jun 2026 23:18:20 +0800 Subject: [PATCH] feat: Enhance Physics Perception System with new detection modes and performance optimizations - Updated PhysicsPerceptionSystem to support seven detection modes: RangeCircle, BatchLOS, FanCast, BoxCast, Sight, RayCast, and TriggerZone. - Improved documentation for each detection mode, including performance optimization strategies. - Introduced PerceptionTriggerProxy for event-driven detection in TriggerZone slots. - Added SightBatchSystem to manage Sight slots efficiently, reducing CPU spikes during high enemy counts. - Updated SensorSlotNames to reflect new detection modes and their purposes. - Enhanced internal logic for detecting targets and managing detection events. --- .../Player/PLY_PlayerMovementConfig.asset | 11 +- Assets/_Game/Scenes/Persistent.unity | 17 +- Assets/_Game/Scenes/Testings/TestRoomA.unity | 790 +++++++++++++++++- .../Enemies/PhysicsPerceptionSystemEditor.cs | 554 ++++++++++-- .../Editor/Scene/SceneObjectPlacerTool.cs | 13 + .../Scripts/Enemies/AI/BD_IsPlayerVisible.cs | 2 +- .../Scripts/Enemies/AI/BatchLOSSystem.cs | 102 --- Assets/_Game/Scripts/Enemies/EnemyBase.cs | 30 +- Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs | 2 +- Assets/_Game/Scripts/Enemies/ILOSRequester.cs | 27 - .../Enemies/Perception/EnemyThreatAssessor.cs | 2 +- .../Perception/PerceptionTriggerProxy.cs | 57 ++ .../PerceptionTriggerProxy.cs.meta} | 2 +- .../Perception/PhysicsPerceptionSystem.cs | 543 +++++++++--- .../Enemies/Perception/SensorSlotNames.cs | 20 +- .../Enemies/Perception/SightBatchSystem.cs | 107 +++ .../SightBatchSystem.cs.meta} | 2 +- 17 files changed, 1946 insertions(+), 335 deletions(-) delete mode 100644 Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs delete mode 100644 Assets/_Game/Scripts/Enemies/ILOSRequester.cs create mode 100644 Assets/_Game/Scripts/Enemies/Perception/PerceptionTriggerProxy.cs rename Assets/_Game/Scripts/Enemies/{AI/BatchLOSSystem.cs.meta => Perception/PerceptionTriggerProxy.cs.meta} (83%) create mode 100644 Assets/_Game/Scripts/Enemies/Perception/SightBatchSystem.cs rename Assets/_Game/Scripts/Enemies/{ILOSRequester.cs.meta => Perception/SightBatchSystem.cs.meta} (83%) diff --git a/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset b/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset index 2dd3cde..6e157b6 100644 --- a/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset +++ b/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset @@ -12,13 +12,14 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 81da55e0fcf99d34693cbc5a348225c3, type: 3} m_Name: PLY_PlayerMovementConfig m_EditorClassIdentifier: - RunSpeed: 6 + RunSpeed: 7 AirDragFactor: 1 - JumpForce: 17.5 + JumpForce: 18 CoyoteTime: 0.12 - FallGravityMult: 2 - MaxFallSpeed: 15 - JumpCutMultiplier: 0.321 + FallGravityMult: 1 + MaxFallSpeed: 20 + JumpCutMultiplier: 0.054 + MinJumpTime: 0.08 ApexThreshold: 3 ApexGravityMultiplier: 0.3 MaxAirJumps: 5 diff --git a/Assets/_Game/Scenes/Persistent.unity b/Assets/_Game/Scenes/Persistent.unity index 932a5e7..f2f12d3 100644 --- a/Assets/_Game/Scenes/Persistent.unity +++ b/Assets/_Game/Scenes/Persistent.unity @@ -1244,16 +1244,17 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: _hudRoot: {fileID: 1496719665} - _pauseMenuRoot: {fileID: 414932415} _deathScreenRoot: {fileID: 1071624567} - _settingsRoot: {fileID: 83002174} - _mapRoot: {fileID: 1189402268} - _shopRoot: {fileID: 1859511082} + _panels: [] + _addressablePanels: [] + _addressablePanelParent: {fileID: 0} _onGameStateChanged: {fileID: 11400000, guid: aa9c327d03e82c84e87d054545412578, type: 2} _onPauseRequested: {fileID: 11400000, guid: a02c7f0e5fa99054bac624adc82c4a53, type: 2} _onFastTravelOpen: {fileID: 11400000, guid: 9f308b954701a484083fb120aa6c7ee3, type: 2} _onShopOpen: {fileID: 11400000, guid: 804a6cfdb23f0554195cebcf8270756f, type: 2} _onMapOpen: {fileID: 11400000, guid: b972e8c7aec9da34d80381e643d49cb2, type: 2} + _onCharmPanelOpen: {fileID: 0} + _onSpellSelectOpen: {fileID: 0} --- !u!1 &737017260 GameObject: m_ObjectHideFlags: 0 @@ -1676,6 +1677,8 @@ MonoBehaviour: m_EditorClassIdentifier: _deathMessage: {fileID: 0} _btnRespawn: {fileID: 307714527} + _showDelay: 1.5 + _defaultDeathText: "\u6C7A\u6B7B" _onDeathScreenConfirmed: {fileID: 11400000, guid: c5237081444b4b54682df1087095fc89, type: 2} --- !u!1 &1100545355 GameObject: @@ -1977,6 +1980,7 @@ MonoBehaviour: _onSceneLoadRequest: {fileID: 11400000, guid: 7a4675ba5f3b784448ce2d1e0048f119, type: 2} _onFadeInRequest: {fileID: 11400000, guid: f8d520fe699782b4184ff72ce5200c25, type: 2} _onFadeOutRequest: {fileID: 11400000, guid: a17901d6793dcf2409e2672ffb383208, type: 2} + _onSceneWorldStateRestored: {fileID: 0} _sceneLoader: {fileID: 1100545357} _roomFadeDuration: 0.05 _sceneFadeDuration: 0.4 @@ -2488,8 +2492,7 @@ MonoBehaviour: _springContainer: {fileID: 0} _springIconPrefab: {fileID: 0} _formIcons: [] - _interactText: {fileID: 0} - _interactPromptRoot: {fileID: 0} + _interactPromptWidget: {fileID: 0} _onHPChanged: {fileID: 0} _onMaxHPChanged: {fileID: 0} _onSoulPowerChanged: {fileID: 0} @@ -2497,8 +2500,6 @@ MonoBehaviour: _onLingZhuChanged: {fileID: 0} _onSpringChargesChanged: {fileID: 0} _onFormChanged: {fileID: 0} - _onShowInteractPrompt: {fileID: 0} - _onHideInteractPrompt: {fileID: 0} --- !u!1 &1657595859 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Game/Scenes/Testings/TestRoomA.unity b/Assets/_Game/Scenes/Testings/TestRoomA.unity index 64d88a0..e62a772 100644 --- a/Assets/_Game/Scenes/Testings/TestRoomA.unity +++ b/Assets/_Game/Scenes/Testings/TestRoomA.unity @@ -12884,6 +12884,128 @@ MonoBehaviour: BarrelClipping: 0.25 Anamorphism: 0 BlendHint: 0 +--- !u!1 &785719612 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 785719613} + - component: {fileID: 785719614} + - component: {fileID: 785719615} + - component: {fileID: 785719616} + m_Layer: 0 + m_Name: Visual + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &785719613 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 785719612} + 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: [] + m_Father: {fileID: 1864792379} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!95 &785719614 +Animator: + serializedVersion: 5 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 785719612} + m_Enabled: 1 + m_Avatar: {fileID: 0} + m_Controller: {fileID: 0} + m_CullingMode: 0 + m_UpdateMode: 0 + m_ApplyRootMotion: 0 + m_LinearVelocityBlending: 0 + m_StabilizeFeet: 0 + m_WarningMessage: + m_HasTransformHierarchy: 1 + m_AllowConstantClipSamplingOptimization: 1 + m_KeepAnimatorStateOnDisable: 0 + m_WriteDefaultValuesOnDisable: 0 +--- !u!114 &785719615 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 785719612} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0ad50f81b1d25c441943c37a89ba23f6, type: 3} + m_Name: + m_EditorClassIdentifier: + _Animator: {fileID: 785719614} + _Transitions: {fileID: 0} + _ActionOnDisable: 0 +--- !u!212 &785719616 +SpriteRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 785719612} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 6c1a7b756ba1d4646a405f7f6e0833ad, 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: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_Sprite: {fileID: 7482667652216324306, guid: 311925a002f4447b3a28927169b83ea6, type: 3} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 1, y: 1} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 1 + m_MaskInteraction: 0 + m_SpriteSortPoint: 0 --- !u!1 &787854959 GameObject: m_ObjectHideFlags: 0 @@ -13667,6 +13789,106 @@ Transform: m_Children: [] m_Father: {fileID: 2015427670} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &831117703 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 831117707} + - component: {fileID: 831117706} + - component: {fileID: 831117705} + - component: {fileID: 831117704} + m_Layer: 25 + m_Name: ContactDamageZone + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &831117704 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 831117703} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 6def12af0589a9545b80eb5accf61bb6, type: 3} + m_Name: + m_EditorClassIdentifier: + _repeatInterval: 0.5 +--- !u!114 &831117705 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 831117703} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3} + m_Name: + m_EditorClassIdentifier: + _defaultSource: {fileID: 0} + _hitCooldown: 0.1 + _id: + _rivalHitBoxMask: + serializedVersion: 2 + m_Bits: 0 +--- !u!58 &831117706 +CircleCollider2D: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 831117703} + m_Enabled: 1 + m_Density: 1 + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_ForceSendLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_ForceReceiveLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_ContactCaptureLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_CallbackLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_IsTrigger: 1 + m_UsedByEffector: 0 + m_UsedByComposite: 0 + m_Offset: {x: 0, y: 0} + serializedVersion: 2 + m_Radius: 0.4 +--- !u!4 &831117707 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 831117703} + 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: [] + m_Father: {fileID: 1864792379} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &837279661 GameObject: m_ObjectHideFlags: 0 @@ -15429,6 +15651,63 @@ Transform: m_Children: [] m_Father: {fileID: 1319376623} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &911393355 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 911393356} + - component: {fileID: 911393357} + m_Layer: 0 + m_Name: PlayClipAbility_Alert + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &911393356 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 911393355} + 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: [] + m_Father: {fileID: 1650269713} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &911393357 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 911393355} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a26fca0fa72894a4da1a5a58ee023154, type: 3} + m_Name: + m_EditorClassIdentifier: + _config: {fileID: 11400000, guid: 157dc45e6b444c64ea1a80a5886a8b92, type: 2} + _clip: + _FadeDuration: 0.25 + _Speed: 1 + _Events: + _NormalizedTimes: [] + _Callbacks: [] + _Names: [] + _Clip: {fileID: 0} + _NormalizedStartTime: NaN + references: + version: 2 + RefIds: [] --- !u!1 &926028880 GameObject: m_ObjectHideFlags: 0 @@ -24217,7 +24496,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 0 + m_IsActive: 1 --- !u!61 &1354690326 BoxCollider2D: m_ObjectHideFlags: 0 @@ -208573,6 +208852,39 @@ Transform: m_Children: [] m_Father: {fileID: 1521123847} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1650269712 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1650269713} + m_Layer: 0 + m_Name: Abilities + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1650269713 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1650269712} + 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: 911393356} + - {fileID: 1832569569} + m_Father: {fileID: 1864792379} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1651018827 GameObject: m_ObjectHideFlags: 0 @@ -210305,6 +210617,88 @@ PolygonCollider2D: - {x: -13.5, y: 2.5} - {x: -13.5, y: -9.5} m_UseDelaunayMesh: 0 +--- !u!1 &1758953872 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1758953875} + - component: {fileID: 1758953874} + - component: {fileID: 1758953873} + m_Layer: 27 + m_Name: HurtBox + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1758953873 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1758953872} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d7b7a233d7f70aa4f86b473412b826de, type: 3} + m_Name: + m_EditorClassIdentifier: + _onDamageDealt: {fileID: 0} + _onHitConfirmed: {fileID: 11400000, guid: a67d56f5124e0db4f98f326c74be8091, type: 2} +--- !u!70 &1758953874 +CapsuleCollider2D: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1758953872} + m_Enabled: 1 + m_Density: 1 + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_ForceSendLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_ForceReceiveLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_ContactCaptureLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_CallbackLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_IsTrigger: 1 + m_UsedByEffector: 0 + m_UsedByComposite: 0 + m_Offset: {x: 0, y: 0} + m_Size: {x: 0.55, y: 0.75} + m_Direction: 0 +--- !u!4 &1758953875 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1758953872} + 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: [] + m_Father: {fileID: 1864792379} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1766894770 GameObject: m_ObjectHideFlags: 0 @@ -211346,6 +211740,74 @@ MonoBehaviour: _noiseFrequency: 1 _dedicatedCamera: {fileID: 873533458} _dedicatedPriority: 20 +--- !u!1 &1832569568 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1832569569} + - component: {fileID: 1832569570} + m_Layer: 0 + m_Name: ContactChaseAbility_Chase + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1832569569 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1832569568} + 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: [] + m_Father: {fileID: 1650269713} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1832569570 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1832569568} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7bfd6f44ebdb5bf489ab6703b1ee429b, type: 3} + m_Name: + m_EditorClassIdentifier: + _config: {fileID: 11400000, guid: 0adeaa8a8508fbd40986dbb71cc85acd, type: 2} + _loopClip: + _FadeDuration: 0.25 + _Speed: 1 + _Events: + _NormalizedTimes: [] + _Callbacks: [] + _Names: [] + _Clip: {fileID: 0} + _NormalizedStartTime: NaN + _endClip: + _FadeDuration: 0.25 + _Speed: 1 + _Events: + _NormalizedTimes: [] + _Callbacks: [] + _Names: [] + _Clip: {fileID: 0} + _NormalizedStartTime: NaN + _contactDamage: {fileID: 831117704} + _aggroSlotName: aggro + references: + version: 2 + RefIds: [] --- !u!1 &1836019285 GameObject: m_ObjectHideFlags: 0 @@ -211554,6 +212016,331 @@ MonoBehaviour: _noiseFrequency: 1 _dedicatedCamera: {fileID: 2038960454} _dedicatedPriority: 20 +--- !u!1 &1864792369 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1864792379} + - component: {fileID: 1864792378} + - component: {fileID: 1864792377} + - component: {fileID: 1864792376} + - component: {fileID: 1864792375} + - component: {fileID: 1864792374} + - component: {fileID: 1864792373} + - component: {fileID: 1864792372} + - component: {fileID: 1864792371} + - component: {fileID: 1864792370} + m_Layer: 13 + m_Name: ENM_CaoZhi + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1864792370 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1864792369} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c0026fe36cfaffc4e95698bccd0a8380, type: 3} + m_Name: + m_EditorClassIdentifier: + _slots: + - slotName: aggro + type: 3 + offset: {x: 0, y: 0} + gizmoColor: {r: 0, g: 0, b: 0, a: 0} + isDisabled: 0 + tickInterval: 0 + radius: 4.62 + detectLayer: + serializedVersion: 2 + m_Bits: 512 + fanAngle: 50 + fanRayCount: 5 + boxSize: {x: 4.38, y: 1.86} + boxOffset: {x: 2.14, y: 0} + losBlockMask: + serializedVersion: 2 + m_Bits: 8388608 + losRayCount: 5 + losMinVisibility: 0 + rayDirection: {x: 0, y: 0} + rayLength: 3.9 + raySpread: 78.2 + rayCount: 9 + obstructLayer: + serializedVersion: 2 + m_Bits: 8388608 +--- !u!114 &1864792371 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1864792369} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 44871319d7318de40b9ac21757b69c78, type: 3} + m_Name: + m_EditorClassIdentifier: + _edgeCheckFwdOffset: 0.3 + _edgeCheckDownLen: 0.6 + _groundMask: + serializedVersion: 2 + m_Bits: 4294967295 +--- !u!114 &1864792372 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1864792369} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 77030faff3812a7429edeaca91e9c873, type: 3} + m_Name: + m_EditorClassIdentifier: + movementSpeed: 5 + cornerSpeed: 100 + jumpSpeed: 5 + fallSpeed: 5 + climbSpeed: 5 + enableAgentRotation: 1 + enabledFeatures: -1 +--- !u!114 &1864792373 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1864792369} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3864fd1487d130847b11b82f276d11b6, type: 3} + m_Name: + m_EditorClassIdentifier: + height: 1 + maxSlopeAngle: 180 + autoRepathIntervall: 1 + maximumDistanceToPathStart: 0.7 + linkTraversalCostMultipliers: + - 1 + - 1 + - 1 + - 1 + - 1 + - 1 + allowCloseEnoughPath: 0 + movementSpeed: 5 + cornerSpeed: 100 + jumpSpeed: 5 + fallSpeed: 5 + climbSpeed: 5 + enableDebugMessages: 0 + navTagTraversalCostMultipliers: + - 1 + - 1 + - 1 + - 1 + - 1 + - 1 + status: 0 + navTagMask: -1 +--- !u!114 &1864792374 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1864792369} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 20bd45717dc17a94581eee24814fe60c, type: 3} + m_Name: + m_EditorClassIdentifier: + _config: {fileID: 11400000, guid: 508afd17a0cf2fe47935c78097c3b093, type: 2} + _spriteRenderer: {fileID: 785719616} + _enableTurnAnimation: 0 + _animancer: {fileID: 785719615} + _animConfig: {fileID: 11400000, guid: 06936c5bc3358904cb269abdfa60ed14, type: 2} + _visualRoot: {fileID: 785719613} + _spriteDefaultFacingDir: 1 + _navJumpMaxHeight: 6 + _navJumpMaxDist: 10 + _groundCheckCollider: {fileID: 0} + _groundCheckDist: 0.15 + _groundCheckCount: 3 + _groundMask: + serializedVersion: 2 + m_Bits: 68159744 + _wallCheckDist: 0.2 + _ledgeCheckFwdOffset: 0.1 + _ledgeCheckDownDist: 0.4 + _wallMask: + serializedVersion: 2 + m_Bits: 0 + _dbg_FacingDirection: 0 + _dbg_VelocityX: 0 + _dbg_VelocityY: 0 + _dbg_IsGrounded: 0 + _dbg_IsWallAhead: 0 + _dbg_IsLedgeAhead: 0 + _dbg_IsTurning: 0 + _dbg_NavDriving: 0 + _dbg_Input_MoveDir: 0 + _dbg_Input_MoveSpeed: 0 + _dbg_Input_WantStop: 0 + _dbg_Input_WantFace: 0 + _dbg_Input_FaceTargetPos: {x: 0, y: 0} + _dbg_Input_FaceDir: 0 +--- !u!114 &1864792375 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1864792369} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 48bc7c82cd2c1df4ba7103160db48a11, type: 3} + m_Name: + m_EditorClassIdentifier: + _onDifficultyChanged: {fileID: 11400000, guid: 156874a2ffc17694e91e949abbf97fee, type: 2} +--- !u!114 &1864792376 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1864792369} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 1a2dbfbcc31a4c34cbd3ac893f02e07d, type: 3} + m_Name: + m_EditorClassIdentifier: + _enemyId: + _statsSO: {fileID: 11400000, guid: 508afd17a0cf2fe47935c78097c3b093, type: 2} + _animConfig: {fileID: 11400000, guid: 06936c5bc3358904cb269abdfa60ed14, type: 2} + _stats: {fileID: 1864792375} + _movement: {fileID: 1864792374} + _combat: {fileID: 0} + _animancer: {fileID: 785719615} + _feedback: {fileID: 0} + _hurtBox: {fileID: 1758953873} + _patrolZone: {fileID: 0} + _onEnemyDied: {fileID: 11400000, guid: def849e2c5ec8204eae6b083b02307aa, type: 2} + _onPlayerSpawned: {fileID: 11400000, guid: 7e2c7e614f6627b449a244ab44443adf, type: 2} + _btIdleTickInterval: 0.3 + _btPatrolTickInterval: 0.15 + _btAlertTickInterval: 0.08 + _btChaseTickInterval: 0.05 + _btCombatTickInterval: 0 + _dbg_CurrentState: 0 + _dbg_AiPhase: 0 + _dbg_HasPlayer: 0 + _dbg_LastKnownPos: {x: 0, y: 0} + _dbg_BtTickInterval: 0 + _autoPlayPhaseAnimation: 1 +--- !u!61 &1864792377 +BoxCollider2D: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1864792369} + m_Enabled: 1 + m_Density: 1 + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_ForceSendLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_ForceReceiveLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_ContactCaptureLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_CallbackLayers: + serializedVersion: 2 + m_Bits: 4294967295 + m_IsTrigger: 0 + m_UsedByEffector: 0 + m_UsedByComposite: 0 + m_Offset: {x: 0, y: 0} + m_SpriteTilingProperty: + border: {x: 0, y: 0, z: 0, w: 0} + pivot: {x: 0, y: 0} + oldSize: {x: 0, y: 0} + newSize: {x: 0, y: 0} + adaptiveTilingThreshold: 0 + drawMode: 0 + adaptiveTiling: 0 + m_AutoTiling: 0 + serializedVersion: 2 + m_Size: {x: 0.6, y: 0.8} + m_EdgeRadius: 0 +--- !u!50 &1864792378 +Rigidbody2D: + serializedVersion: 4 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1864792369} + m_BodyType: 0 + m_Simulated: 1 + m_UseFullKinematicContacts: 0 + m_UseAutoMass: 0 + m_Mass: 1 + m_LinearDrag: 0 + m_AngularDrag: 0.05 + m_GravityScale: 2 + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_Interpolate: 0 + m_SleepingMode: 1 + m_CollisionDetection: 1 + m_Constraints: 4 +--- !u!4 &1864792379 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1864792369} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -3.98, y: 8.743559, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 785719613} + - {fileID: 1758953875} + - {fileID: 831117707} + - {fileID: 1650269713} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1865796628 GameObject: m_ObjectHideFlags: 0 @@ -216366,3 +217153,4 @@ SceneRoots: - {fileID: 1865796631} - {fileID: 1354690328} - {fileID: 783576435} + - {fileID: 1864792379} diff --git a/Assets/_Game/Scripts/Editor/Enemies/PhysicsPerceptionSystemEditor.cs b/Assets/_Game/Scripts/Editor/Enemies/PhysicsPerceptionSystemEditor.cs index 4912f56..f92fad8 100644 --- a/Assets/_Game/Scripts/Editor/Enemies/PhysicsPerceptionSystemEditor.cs +++ b/Assets/_Game/Scripts/Editor/Enemies/PhysicsPerceptionSystemEditor.cs @@ -1,13 +1,33 @@ +using System.Collections.Generic; using System.Text; using UnityEditor; using UnityEngine; using BaseGames.Enemies.Perception; +using SlotType = BaseGames.Enemies.Perception.PhysicsPerceptionSystem.SlotType; namespace BaseGames.Editor { [CustomEditor(typeof(PhysicsPerceptionSystem))] public sealed class PhysicsPerceptionSystemEditor : UnityEditor.Editor { + // ── Inspector state ─────────────────────────────────────────────────── + + private SerializedProperty _slotsProp; + private readonly List _foldouts = new List(); + + private void OnEnable() + { + _slotsProp = serializedObject.FindProperty("_slots"); + SyncFoldouts(); + } + + private void SyncFoldouts() + { + if (_slotsProp == null) return; + while (_foldouts.Count < _slotsProp.arraySize) _foldouts.Add(true); + while (_foldouts.Count > _slotsProp.arraySize) _foldouts.RemoveAt(_foldouts.Count - 1); + } + // ── Scene 视图 Gizmo ────────────────────────────────────────────────── [DrawGizmo(GizmoType.NonSelected | GizmoType.Selected | GizmoType.InSelectionHierarchy)] @@ -25,15 +45,14 @@ namespace BaseGames.Editor if (string.IsNullOrEmpty(slot.slotName)) continue; Color baseColor = ResolveGizmoColor(slot.gizmoColor); - Color fill = baseColor; fill.a = isSelected ? 0.12f : 0.04f; - Color outline = baseColor; outline.a = isSelected ? 0.90f : 0.40f; + Color fill = new Color(baseColor.r, baseColor.g, baseColor.b, isSelected ? 0.12f : 0.04f); + Color outline = new Color(baseColor.r, baseColor.g, baseColor.b, isSelected ? 0.90f : 0.40f); - // 每个 Slot 独立检测原点(X 随朝向翻转) Vector3 slotCenter = rootPos + new Vector3(slot.offset.x * facingSign, slot.offset.y, 0f); switch (slot.type) { - case PhysicsPerceptionSystem.SlotType.RangeCircle: + case SlotType.RangeCircle: if (slot.radius <= 0f) break; Handles.color = fill; Handles.DrawSolidDisc(slotCenter, Vector3.forward, slot.radius); @@ -41,7 +60,7 @@ namespace BaseGames.Editor Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius); break; - case PhysicsPerceptionSystem.SlotType.FanCast: + case SlotType.FanCast: if (slot.radius > 0f && slot.fanAngle > 0f) { DrawFanGizmo(slotCenter, facingSign, slot, fill, outline); @@ -49,7 +68,7 @@ namespace BaseGames.Editor } break; - case PhysicsPerceptionSystem.SlotType.BoxCast: + case SlotType.BoxCast: if (slot.boxSize.x > 0f && slot.boxSize.y > 0f) { DrawBoxGizmo(slotCenter, facingSign, slot, fill, outline); @@ -57,17 +76,27 @@ namespace BaseGames.Editor } break; - // BatchLOS: eye-position marker + optional range disc + runtime ray - case PhysicsPerceptionSystem.SlotType.BatchLOS: + case SlotType.BatchLOS: DrawBatchLOSGizmo(system, slotCenter, slot, outline, isSelected); break; + + case SlotType.Sight: + DrawSightGizmo(system, slotCenter, facingSign, slot, fill, outline, isSelected); + break; + + case SlotType.RayCast: + DrawRayCastGizmo(slotCenter, facingSign, slot, outline, isSelected); + break; + + case SlotType.TriggerZone: + DrawTriggerZoneGizmo(slotCenter, slot, outline, isSelected); + break; } } Handles.color = Color.white; } - /// 在 Slot 原点处画一个小十字点,帮助可视化 offset 配置。 static void DrawOriginDot(Vector3 pos, Color color) { Handles.color = color; @@ -75,80 +104,217 @@ namespace BaseGames.Editor } /// - /// 仅当颜色字段为 Unity 默认值 Color(0,0,0,0)(全零 = 未配置)时, - /// 返回易辨识的紫色回退(alpha=1);用户明确设置的任何颜色(包括近黑色)均原样保留。 + /// Color(0,0,0,0) 是未配置的默认值,返回紫色回退;用户设置的颜色原样保留。 /// - static Color ResolveGizmoColor(Color c) - { - bool isDefault = c.r + c.g + c.b + c.a < 0.01f; - return isDefault ? new Color(0.85f, 0.3f, 1.0f, 1.0f) : c; - } + static Color ResolveGizmoColor(Color c) => + c.r + c.g + c.b + c.a < 0.01f ? new Color(0.85f, 0.3f, 1.0f, 1.0f) : c; static void DrawBatchLOSGizmo(PhysicsPerceptionSystem system, Vector3 slotCenter, PhysicsPerceptionSystem.PerceptionSlot slot, Color slotColor, bool isSelected) { - // slotColor 已经过 ResolveGizmoColor 处理,alpha 已由调用方设为 outline alpha。 - - // 所有 gizmo 元素统一画在 slotCenter(由 slot.offset 控制), - // 与 LOSOrigin(实际射线起点)解耦——gizmo 跟 offset 走。 float facingSign = system.transform.localScale.x < 0f ? -1f : 1f; - // ── 最大检测范围圆(slot.radius > 0 时)── if (slot.radius > 0f) { - Color fill = slotColor; fill.a = isSelected ? 0.08f : 0.03f; + Color fill = new Color(slotColor.r, slotColor.g, slotColor.b, isSelected ? 0.08f : 0.03f); Handles.color = fill; Handles.DrawSolidDisc(slotCenter, Vector3.forward, slot.radius); - Color rim = slotColor; rim.a = isSelected ? 0.70f : 0.30f; + Color rim = new Color(slotColor.r, slotColor.g, slotColor.b, isSelected ? 0.70f : 0.30f); Handles.color = rim; Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius); } - // ── 眼睛中心圆点 ── Handles.color = slotColor; Handles.DrawSolidDisc(slotCenter, Vector3.forward, 0.08f); - // ── 朝向指示箭头(沿 facingSign 方向,明确"方向性"视线感知)── - float arrowLen = slot.radius > 0f ? Mathf.Min(slot.radius * 0.6f, 0.6f) : 0.4f; - Vector3 fwdArrow = new Vector3(facingSign * arrowLen, 0f, 0f); + float arrowLen = slot.radius > 0f ? Mathf.Min(slot.radius * 0.6f, 0.6f) : 0.4f; + Vector3 fwdArrow = new Vector3(facingSign * arrowLen, 0f, 0f); Handles.DrawLine(slotCenter, slotCenter + fwdArrow); + Vector3 tip = slotCenter + fwdArrow; + float headLen = 0.08f; + Handles.DrawLine(tip, tip + new Vector3(-facingSign * headLen, headLen, 0f)); + Handles.DrawLine(tip, tip + new Vector3(-facingSign * headLen, -headLen, 0f)); - Vector3 arrowTip = slotCenter + fwdArrow; - float headLen = 0.08f; - Handles.DrawLine(arrowTip, arrowTip + new Vector3(-facingSign * headLen, headLen, 0f)); - Handles.DrawLine(arrowTip, arrowTip + new Vector3(-facingSign * headLen, -headLen, 0f)); - - // ── 放射线(仅选中时显示,避免杂乱)── if (isSelected) { float innerR = 0.12f; float outerR = slot.radius > 0f ? Mathf.Min(slot.radius, 0.30f) : 0.26f; for (int i = 0; i < 8; i++) { - float deg = i * 45f; - float rad = deg * Mathf.Deg2Rad; - Vector3 dir = new Vector3(Mathf.Cos(rad), Mathf.Sin(rad), 0f); - Handles.DrawLine(slotCenter + dir * innerR, slotCenter + dir * outerR); + float r = i * 45f * Mathf.Deg2Rad; + var d = new Vector3(Mathf.Cos(r), Mathf.Sin(r), 0f); + Handles.DrawLine(slotCenter + d * innerR, slotCenter + d * outerR); } } - // ── 运行时:视线连线;绿 = 可见 / 红 = 遮挡 ── if (!Application.isPlaying) return; var owner = system.EditorOwner ?? system.GetComponentInParent(); if (owner == null || owner.PlayerTransform == null) return; bool visible = owner.IsPlayerVisible(); - Color rayCol = visible - ? new Color(0.2f, 1.0f, 0.3f, 0.9f) - : new Color(1.0f, 0.3f, 0.3f, 0.45f); - + Color rayCol = visible ? new Color(0.2f, 1.0f, 0.3f, 0.9f) : new Color(1.0f, 0.3f, 0.3f, 0.45f); Handles.color = rayCol; Handles.DrawDottedLine(slotCenter, owner.PlayerTransform.position, 3f); - if (visible) Handles.DrawSolidDisc(owner.PlayerTransform.position, Vector3.forward, 0.09f); } + /// + /// Sight 槽位 Gizmo:视野锥形(或全向圆形)+ 眼点 + LOS 能力提示射线 + 运行时检测连线。 + /// + static void DrawSightGizmo(PhysicsPerceptionSystem system, Vector3 slotCenter, float facingSign, + PhysicsPerceptionSystem.PerceptionSlot slot, Color fill, Color outline, bool isSelected) + { + if (slot.radius <= 0f) return; + + bool hasCone = slot.fanAngle > 0f && slot.fanAngle < 360f; + + if (hasCone) + { + DrawFanGizmo(slotCenter, facingSign, slot, fill, outline); + } + else + { + Handles.color = fill; + Handles.DrawSolidDisc(slotCenter, Vector3.forward, slot.radius); + Handles.color = outline; + Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius); + } + + // 眼点(视线传感器标志) + Handles.color = outline; + Handles.DrawSolidDisc(slotCenter, Vector3.forward, 0.07f); + + // 选中时:视线射线提示(表明有遮挡检测能力) + if (isSelected) + { + float innerR = 0.10f; + float outerR = Mathf.Min(slot.radius * 0.35f, 0.40f); + if (hasCone) + { + float halfAngle = slot.fanAngle * 0.5f; + int lines = Mathf.Max(3, slot.fanRayCount > 0 ? slot.fanRayCount : 5); + for (int i = 0; i <= lines; i++) + { + float t = (float)i / lines; + float ang = Mathf.Lerp(-halfAngle, halfAngle, t); + var dir = RotateVec3(new Vector3(facingSign, 0f, 0f), ang).normalized; + Handles.DrawDottedLine(slotCenter + dir * innerR, slotCenter + dir * (outerR * 2.5f), 3f); + } + } + else + { + for (int i = 0; i < 8; i++) + { + float r = i * 45f * Mathf.Deg2Rad; + var d = new Vector3(Mathf.Cos(r), Mathf.Sin(r), 0f); + Handles.DrawDottedLine(slotCenter + d * innerR, slotCenter + d * (outerR * 2.5f), 3f); + } + } + + // LOS 图标文本(Scene 视图) + GUIStyle style = new GUIStyle(GUI.skin.label) + { + fontSize = 9, + alignment = TextAnchor.MiddleCenter, + normal = { textColor = new Color(outline.r, outline.g, outline.b, 0.85f) } + }; + Handles.Label(slotCenter + new Vector3(0f, slot.radius + 0.2f, 0f), "LOS", style); + } + + // 运行时:绿线连到检测到的目标 + if (!Application.isPlaying) return; + var detected = system.EditorDetected; + if (detected == null || !detected.TryGetValue(slot.slotName, out var hits) || hits.Count == 0) return; + + Color green = new Color(0.2f, 1.0f, 0.3f, 0.9f); + Handles.color = green; + foreach (var go in hits) + { + if (go == null) continue; + Handles.DrawLine(slotCenter, go.transform.position); + Handles.DrawSolidDisc(go.transform.position, Vector3.forward, 0.09f); + } + } + + static void DrawRayCastGizmo(Vector3 slotCenter, float facingSign, + PhysicsPerceptionSystem.PerceptionSlot slot, Color outline, bool isSelected) + { + if (slot.rayLength <= 0f) return; + + Color lineColor = new Color(outline.r, outline.g, outline.b, isSelected ? 0.90f : 0.50f); + Handles.color = lineColor; + + // Base direction in local space with X flipped for facing + Vector2 baseDir2D = slot.rayDirection.sqrMagnitude < 0.001f + ? Vector2.right + : slot.rayDirection.normalized; + var baseDir = new Vector3(baseDir2D.x * facingSign, baseDir2D.y, 0f).normalized; + + float spread = Mathf.Clamp(slot.raySpread, 0f, 180f); + int count = (spread > 0f && slot.rayCount > 1) ? Mathf.Clamp(slot.rayCount, 2, 9) : 1; + + if (count == 1 || spread <= 0f) + { + Vector3 end = slotCenter + baseDir * slot.rayLength; + Handles.DrawLine(slotCenter, end); + DrawArrowHead(slotCenter, end, lineColor, 0.08f); + } + else + { + float halfSpread = spread * 0.5f; + for (int r = 0; r < count; r++) + { + float t = (float)r / (count - 1); + float ang = Mathf.Lerp(-halfSpread, halfSpread, t); + var dir = RotateVec3(baseDir, ang).normalized; + Vector3 end = slotCenter + dir * slot.rayLength; + Handles.DrawLine(slotCenter, end); + } + // Highlight center ray + Vector3 centerEnd = slotCenter + baseDir * slot.rayLength; + Color bright = new Color(lineColor.r, lineColor.g, lineColor.b, 1f); + DrawArrowHead(slotCenter, centerEnd, bright, 0.08f); + } + + // Origin dot + Handles.color = lineColor; + Handles.DrawSolidDisc(slotCenter, Vector3.forward, 0.05f); + } + + static void DrawArrowHead(Vector3 from, Vector3 tip, Color color, float size) + { + Handles.color = color; + Vector3 dir = (tip - from).normalized; + Handles.DrawLine(tip, tip - dir * size + new Vector3(-dir.y, dir.x, 0f) * size * 0.5f); + Handles.DrawLine(tip, tip - dir * size + new Vector3( dir.y, -dir.x, 0f) * size * 0.5f); + } + + static void DrawTriggerZoneGizmo(Vector3 slotCenter, + PhysicsPerceptionSystem.PerceptionSlot slot, Color outline, bool isSelected) + { + Color dotColor = new Color(outline.r, outline.g, outline.b, isSelected ? 0.90f : 0.55f); + Handles.color = dotColor; + Handles.DrawSolidDisc(slotCenter, Vector3.forward, 0.10f); + + float ring = 0.22f; + Handles.color = new Color(outline.r, outline.g, outline.b, isSelected ? 0.60f : 0.25f); + Handles.DrawWireDisc(slotCenter, Vector3.forward, ring); + Handles.DrawWireDisc(slotCenter, Vector3.forward, ring * 1.55f); + + if (isSelected) + { + GUIStyle style = new GUIStyle(GUI.skin.label) + { + fontSize = 9, + alignment = TextAnchor.MiddleCenter, + normal = { textColor = new Color(outline.r, outline.g, outline.b, 0.85f) } + }; + Handles.Label(slotCenter + new Vector3(0f, ring * 1.55f + 0.15f, 0f), + string.IsNullOrEmpty(slot.slotName) ? "TZ" : $"TZ:{slot.slotName}", style); + } + } + static void DrawFanGizmo(Vector3 center, float facingSign, PhysicsPerceptionSystem.PerceptionSlot slot, Color fill, Color outline) { @@ -160,7 +326,6 @@ namespace BaseGames.Editor Handles.color = outline; Handles.DrawWireArc(center, Vector3.forward, fromDir, slot.fanAngle, slot.radius); - // Edge rays for clarity Vector3 edgeL = RotateVec3(new Vector3(facingSign, 0f, 0f), -halfAngle) * slot.radius; Vector3 edgeR = RotateVec3(new Vector3(facingSign, 0f, 0f), halfAngle) * slot.radius; Handles.DrawLine(center, center + edgeL); @@ -171,9 +336,8 @@ namespace BaseGames.Editor PhysicsPerceptionSystem.PerceptionSlot slot, Color fill, Color outline) { Vector3 boxCenter = center + new Vector3(slot.boxOffset.x * facingSign, slot.boxOffset.y, 0f); - float hw = slot.boxSize.x * 0.5f; - float hh = slot.boxSize.y * 0.5f; - + float hw = slot.boxSize.x * 0.5f; + float hh = slot.boxSize.y * 0.5f; Vector3[] corners = { boxCenter + new Vector3(-hw, -hh, 0f), @@ -181,33 +345,126 @@ namespace BaseGames.Editor boxCenter + new Vector3( hw, hh, 0f), boxCenter + new Vector3(-hw, hh, 0f), }; - Handles.DrawSolidRectangleWithOutline(corners, fill, outline); } static Vector3 RotateVec3(Vector3 v, float angleDeg) { float rad = angleDeg * Mathf.Deg2Rad; - float cos = Mathf.Cos(rad); - float sin = Mathf.Sin(rad); + float cos = Mathf.Cos(rad); float sin = Mathf.Sin(rad); return new Vector3(cos * v.x - sin * v.y, sin * v.x + cos * v.y, 0f); } - // ── Inspector ───────────────────────────────────────────────────────── + // ── Custom Inspector ────────────────────────────────────────────────── public override void OnInspectorGUI() { - DrawDefaultInspector(); + serializedObject.Update(); + SyncFoldouts(); + if (_slotsProp == null) + { + DrawDefaultInspector(); + return; + } + + EditorGUILayout.LabelField("感知槽位", EditorStyles.boldLabel); + EditorGUI.indentLevel++; + + SyncFoldouts(); + + int pendingMoveUp = -1; + int pendingMoveDown = -1; + int pendingDuplicate = -1; + int pendingDelete = -1; + + for (int i = 0; i < _slotsProp.arraySize; i++) + { + var elem = _slotsProp.GetArrayElementAtIndex(i); + var nameProp = elem.FindPropertyRelative("slotName"); + var typeProp = elem.FindPropertyRelative("type"); + + var isDisabledProp = elem.FindPropertyRelative("isDisabled"); + var tickIntervalProp = elem.FindPropertyRelative("tickInterval"); + bool isDisabled = isDisabledProp != null && isDisabledProp.boolValue + && tickIntervalProp != null && tickIntervalProp.intValue > 0; + string disabledTag = isDisabled ? " ⊘" : ""; + string label = string.IsNullOrEmpty(nameProp.stringValue) + ? $"Slot {i}{disabledTag}" + : $"[{i}] {nameProp.stringValue} ({(SlotType)typeProp.enumValueIndex}){disabledTag}"; + + // ── 槽位标题行(折叠 + 操作按钮)────────────────────────── + EditorGUILayout.BeginHorizontal(); + _foldouts[i] = EditorGUILayout.Foldout(_foldouts[i], label, true); + GUILayout.FlexibleSpace(); + using (new EditorGUI.DisabledScope(i == 0)) + if (GUILayout.Button("▲", EditorStyles.miniButton, GUILayout.Width(22))) pendingMoveUp = i; + using (new EditorGUI.DisabledScope(i == _slotsProp.arraySize - 1)) + if (GUILayout.Button("▼", EditorStyles.miniButton, GUILayout.Width(22))) pendingMoveDown = i; + if (GUILayout.Button("⊕", EditorStyles.miniButton, GUILayout.Width(22))) pendingDuplicate = i; + Color _prevSlotBtnColor = GUI.color; + GUI.color = new Color(1f, 0.5f, 0.5f); + if (GUILayout.Button("✕", EditorStyles.miniButton, GUILayout.Width(22))) pendingDelete = i; + GUI.color = _prevSlotBtnColor; + EditorGUILayout.EndHorizontal(); + + if (_foldouts[i]) + { + EditorGUI.indentLevel++; + DrawSlotFields(elem, (SlotType)typeProp.enumValueIndex, nameProp.stringValue); + EditorGUI.indentLevel--; + } + EditorGUILayout.Space(1); + } + + EditorGUI.indentLevel--; + + // ── 底部工具栏:添加新槽位 ────────────────────────────────────── + EditorGUILayout.Space(2); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("⊕ 添加槽位", GUILayout.Width(120))) + { + _slotsProp.InsertArrayElementAtIndex(_slotsProp.arraySize); + _foldouts.Add(true); + } + EditorGUILayout.EndHorizontal(); + EditorGUILayout.Space(4); + + // ── 延迟执行槽位操作(避免在 GUI 循环中修改数组)────────────── + if (pendingDelete >= 0) + { + _slotsProp.DeleteArrayElementAtIndex(pendingDelete); + SyncFoldouts(); + } + else if (pendingDuplicate >= 0) + { + _slotsProp.InsertArrayElementAtIndex(pendingDuplicate); + _foldouts.Insert(pendingDuplicate + 1, true); + } + else if (pendingMoveUp >= 0) + { + _slotsProp.MoveArrayElement(pendingMoveUp, pendingMoveUp - 1); + (_foldouts[pendingMoveUp], _foldouts[pendingMoveUp - 1]) = + (_foldouts[pendingMoveUp - 1], _foldouts[pendingMoveUp]); + } + else if (pendingMoveDown >= 0) + { + _slotsProp.MoveArrayElement(pendingMoveDown, pendingMoveDown + 1); + (_foldouts[pendingMoveDown], _foldouts[pendingMoveDown + 1]) = + (_foldouts[pendingMoveDown + 1], _foldouts[pendingMoveDown]); + } + + serializedObject.ApplyModifiedProperties(); + + // ── 运行时检测结果 ── if (!Application.isPlaying) return; - var system = (PhysicsPerceptionSystem)target; var detected = system.EditorDetected; if (detected == null || detected.Count == 0) return; EditorGUILayout.Space(); EditorGUILayout.LabelField("── 实时检测结果 ──", EditorStyles.boldLabel); - var sb = new StringBuilder(); foreach (var kvp in detected) { @@ -220,8 +477,195 @@ namespace BaseGames.Editor } EditorGUILayout.LabelField(kvp.Key, kvp.Value.Count > 0 ? sb.ToString() : "—"); } - Repaint(); } + + /// + /// 按槽位类型条件渲染字段——每个 SlotType 只显示与其相关的参数。 + /// + private void DrawSlotFields(SerializedProperty elem, SlotType slotType, string slotName) + { + // ── 通用字段(所有类型)──────────────────────────────────────── + + EditorGUILayout.PropertyField(elem.FindPropertyRelative("slotName"), + new GUIContent("槽位名称", "与 SensorSlotNames 常量保持一致")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("type"), + new GUIContent("检测类型")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("offset"), + new GUIContent("原点偏移", "相对于 transform.position,X 随朝向自动翻转")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("gizmoColor"), + new GUIContent("Gizmo 颜色", "全透明黑色 = 自动使用紫色回退")); + + EditorGUILayout.Space(2); + EditorGUILayout.LabelField("─ 运行时控制 ─", EditorStyles.miniLabel); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("isDisabled"), + new GUIContent("禁用", "勾选后停用本槽位(tickInterval > 0 时生效)")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("tickInterval"), + new GUIContent("刷新间隔 (帧)", "0 = 历史兼容(每帧,isDisabled 无效)\n1 = 每帧\n3 = 每 3 帧(Sight 推荐)\n同间隔槽位自动错帧执行")); + + EditorGUILayout.Space(2); + + // ── 按类型显示相关字段 ───────────────────────────────────────── + + switch (slotType) + { + case SlotType.RangeCircle: + EditorGUILayout.PropertyField(elem.FindPropertyRelative("radius"), + new GUIContent("半径 (m)", "OverlapCircle 检测半径")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("detectLayer"), + new GUIContent("检测层")); + break; + + case SlotType.BatchLOS: + EditorGUILayout.PropertyField(elem.FindPropertyRelative("radius"), + new GUIContent("检测半径 (m)", "OverlapCircle 半径,必须 > 0")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("detectLayer"), + new GUIContent("检测层", "目标所在层(通常为 Player 层)")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("losBlockMask"), + new GUIContent("视线遮挡层", "遮挡射线的层(Platform / Wall);留 0 则不做遮挡检测")); + break; + + case SlotType.FanCast: + EditorGUILayout.PropertyField(elem.FindPropertyRelative("radius"), + new GUIContent("半径 (m)", "扇形检测半径")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("detectLayer"), + new GUIContent("检测层")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("fanAngle"), + new GUIContent("扇形角度 (°)", "以朝向为中轴,左右对称展开")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("fanRayCount"), + new GUIContent("Gizmo 分隔线数", "仅影响 Scene 视图,不影响检测精度(建议 3–9)")); + break; + + case SlotType.BoxCast: + EditorGUILayout.PropertyField(elem.FindPropertyRelative("detectLayer"), + new GUIContent("检测层")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("boxSize"), + new GUIContent("矩形尺寸 (m)", "宽 × 高")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("boxOffset"), + new GUIContent("矩形偏移", "相对于原点偏移,X 随朝向翻转")); + break; + + case SlotType.Sight: + EditorGUILayout.PropertyField(elem.FindPropertyRelative("radius"), + new GUIContent("视野半径 (m)", "Sight 传感器检测半径")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("detectLayer"), + new GUIContent("检测层")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("fanAngle"), + new GUIContent("视锥角度 (°)", "0 或 ≥ 360 = 全向 360°;否则以朝向为中轴左右展开")); + EditorGUILayout.Space(2); + EditorGUILayout.LabelField("─ LOS 遮挡设置(Sight 专用)─", EditorStyles.miniLabel); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("losBlockMask"), + new GUIContent("遮挡层", "遮挡物所在层(Platform / Wall / Ground);设为 0 则视线始终通过")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("losRayCount"), + new GUIContent("LOS 采样点数", "1=中心 3=中心+上+下(推荐) 5=中心+上下左右")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("losMinVisibility"), + new GUIContent("最低可见度 (0–1)", "0=任一采样点通过即可 1=全部通过(严格)")); + break; + + case SlotType.RayCast: + EditorGUILayout.PropertyField(elem.FindPropertyRelative("rayDirection"), + new GUIContent("射线方向 (本地空间)", "X 分量随朝向自动翻转;(1,0)=正前方,(0,-1)=正下方")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("rayLength"), + new GUIContent("射线长度 (m)")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("detectLayer"), + new GUIContent("检测层")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("obstructLayer"), + new GUIContent("遮挡层", "射线碰到此层后立即阻断(设为 0 = 射线穿透所有物体)")); + EditorGUILayout.Space(2); + EditorGUILayout.LabelField("─ 扩散(多射线)─", EditorStyles.miniLabel); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("raySpread"), + new GUIContent("扩散角 (°)", "0 = 单根射线;>0 时在此角度范围内均匀分布 N 根射线")); + EditorGUILayout.PropertyField(elem.FindPropertyRelative("rayCount"), + new GUIContent("射线根数", "raySpread > 0 时生效,建议 3–9")); + break; + + case SlotType.TriggerZone: + EditorGUILayout.HelpBox( + "TriggerZone 槽位为事件驱动,零轮询开销。\n" + + "需在此 GameObject 的子节点上挂载 PerceptionTriggerProxy,\n" + + "设置相同 slotName,且 Collider2D.isTrigger = true。", + MessageType.Info); + EditorGUILayout.Space(4); + DrawTriggerZoneManagement(slotName); + break; + } + } + + private void DrawTriggerZoneManagement(string slotName) + { + var system = (PhysicsPerceptionSystem)target; + + EditorGUILayout.LabelField("── TriggerZone 子节点 ──", EditorStyles.miniLabel); + + if (string.IsNullOrWhiteSpace(slotName)) + { + EditorGUILayout.HelpBox("请先填写槽位名称,再管理代理节点。", MessageType.Warning); + return; + } + + // 查找所有匹配的代理节点 + var allProxies = system.GetComponentsInChildren(true); + var matchList = new List(); + foreach (var p in allProxies) + if (p.slotName == slotName) matchList.Add(p); + + if (matchList.Count == 0) + { + EditorGUILayout.HelpBox($"未找到 slotName = \"{slotName}\" 的代理节点。", MessageType.Warning); + } + else + { + foreach (var proxy in matchList) + { + EditorGUILayout.BeginHorizontal(); + using (new EditorGUI.DisabledScope(true)) + EditorGUILayout.ObjectField(proxy, typeof(PerceptionTriggerProxy), true); + + if (GUILayout.Button("选中", EditorStyles.miniButton, GUILayout.Width(44))) + { + Selection.activeGameObject = proxy.gameObject; + EditorGUIUtility.PingObject(proxy.gameObject); + } + + var col = proxy.GetComponent(); + bool badTrigger = col == null || !col.isTrigger; + Color prevC = GUI.color; + GUI.color = badTrigger ? Color.yellow : new Color(0.5f, 1f, 0.5f); + EditorGUILayout.LabelField( + badTrigger ? "⚠ isTrigger" : "✓ Trigger", + GUILayout.Width(76)); + GUI.color = prevC; + + GUI.color = new Color(1f, 0.5f, 0.5f); + if (GUILayout.Button("删除", EditorStyles.miniButton, GUILayout.Width(44))) + Undo.DestroyObjectImmediate(proxy.gameObject); + GUI.color = prevC; + EditorGUILayout.EndHorizontal(); + } + } + + EditorGUILayout.Space(3); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button($"⊕ 创建代理节点 ({slotName})", GUILayout.Width(220))) + { + var go = new GameObject($"TriggerZone_{slotName}"); + Undo.RegisterCreatedObjectUndo(go, "Create TriggerZone Proxy"); + go.transform.SetParent(system.transform, false); + go.transform.localPosition = Vector3.zero; + + // CircleCollider2D must be added BEFORE PerceptionTriggerProxy ([RequireComponent]) + var circle = go.AddComponent(); + circle.isTrigger = true; + circle.radius = 1.5f; + + var proxy = go.AddComponent(); + proxy.slotName = slotName; + + Selection.activeGameObject = go; + EditorGUIUtility.PingObject(go); + } + EditorGUILayout.EndHorizontal(); + } } } diff --git a/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs b/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs index 2d2b2f3..eeeec5d 100644 --- a/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs +++ b/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs @@ -1240,12 +1240,22 @@ namespace BaseGames.Editor case "los": enumIdx = 1; radius = 0f; layer = 0; break; case "attack_melee":enumIdx = 0; radius = 1.5f; layer = playerLayer; break; case "attack_range":enumIdx = 0; radius = 8f; layer = playerLayer; break; + case "patrol": enumIdx = 0; radius = 5f; layer = 0; break; + case "alert": enumIdx = 0; radius = 3f; layer = playerLayer; break; + case "sight": enumIdx = 4; radius = 6f; layer = playerLayer; break; } elem.FindPropertyRelative("type").enumValueIndex = enumIdx; elem.FindPropertyRelative("radius").floatValue = radius; elem.FindPropertyRelative("detectLayer").intValue = layer; + // sight 槽位默认设置推荐的 LOS 采样点数(3:中心+上+下) + if (name == "sight") + { + var losRayCountProp = elem.FindPropertyRelative("losRayCount"); + if (losRayCountProp != null) losRayCountProp.intValue = 3; + } + // 各 slot 分配语义化默认颜色,可在 Inspector 中按需覆盖 Color defaultColor = name switch { @@ -1253,6 +1263,9 @@ namespace BaseGames.Editor "los" => new Color(0.00f, 0.80f, 1.00f, 1f), // 青 "attack_melee" => new Color(1.00f, 0.20f, 0.20f, 1f), // 红 "attack_range" => new Color(1.00f, 0.40f, 0.60f, 1f), // 粉红 + "patrol" => new Color(0.20f, 0.90f, 0.20f, 1f), // 绿 + "alert" => new Color(1.00f, 0.90f, 0.10f, 1f), // 黄 + "sight" => new Color(0.30f, 0.85f, 1.00f, 1f), // 浅蓝(LOS 传感器) _ => Color.clear, // 未知 slot 回退为紫色 }; elem.FindPropertyRelative("gizmoColor").colorValue = defaultColor; diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerVisible.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerVisible.cs index d2ada2e..c773edc 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerVisible.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsPlayerVisible.cs @@ -7,7 +7,7 @@ namespace BaseGames.Enemies.AI { /// /// BD Conditional:玩家是否可见(LOS 检测)。 - /// 读取 EnemyBase._losResult 字段(由 BatchLOSSystem 每帧写入,或降级 3 帧节流 Raycast)。 + /// 读取 EnemyBase.IsPlayerVisible(),结果来自 PhysicsPerceptionSystem(LOS / Sight 槽位)。 /// [TaskName("Is Player Visible?")] [TaskCategory("BaseGames/Enemy/Perception")] diff --git a/Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs b/Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs deleted file mode 100644 index 8c4d253..0000000 --- a/Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Collections.Generic; -using Unity.Collections; -using UnityEngine; -using UnityEngine.Jobs; -using BaseGames.Core; - -namespace BaseGames.Enemies.AI -{ - /// - /// 批量视线检测系统(架构 07_EnemyModule §12)。 - /// 每 FixedUpdate: - /// 1. 读取上一帧 RaycastHit2D 结果,回调各注册者。 - /// 2. 重新构建本帧 RaycastCommand 批次,使用 Physics2D.RaycastAll 同步模式执行。 - /// - /// ⚠️ Unity 2022.3 中 Physics2D 批量命令(RaycastCommand2D)尚未稳定, - /// 此实现使用 FixedUpdate 内顺序 Raycast2D(节流),确保零 GC 分配。 - /// 当敌人数量 > 20 时建议切换到 Job System RaycastCommand。 - /// - [DefaultExecutionOrder(-200)] - public class BatchLOSSystem : MonoBehaviour - { - [SerializeField, Min(1)] private int _maxRequestersPerFrame = 8; - - private void Awake() => ServiceLocator.Register(this); - private void OnDestroy() => ServiceLocator.Unregister(this); - - private readonly List _requesters = new(); - private readonly HashSet _requesterSet = new(); // O(1) 包含查询 - // _indexMap 记录每个 requester 在 _requesters 中的下标,供 Unregister 实现 O(1) 删除 - private readonly Dictionary _indexMap = new(); - private int _currentOffset = 0; - - // ── 注册 ────────────────────────────────────────────────────────── - public void Register(ILOSRequester requester) - { - if (_requesterSet.Add(requester)) - { - _indexMap[requester] = _requesters.Count; - _requesters.Add(requester); - } - } - - public void Unregister(ILOSRequester requester) - { - if (!_requesterSet.Remove(requester)) return; - - int idx = _indexMap[requester]; - int last = _requesters.Count - 1; - - // Swap-and-pop:将末尾元素移动到被删除的位置,避免 O(n) 搬移 - if (idx != last) - { - var moved = _requesters[last]; - _requesters[idx] = moved; - _indexMap[moved] = idx; - } - - _requesters.RemoveAt(last); - _indexMap.Remove(requester); - - // 修正偏移量,防止越界 - if (_currentOffset >= _requesters.Count) _currentOffset = 0; - } - - // ── FixedUpdate ─────────────────────────────────────────────────── - private void FixedUpdate() - { - if (_requesters.Count == 0) return; - - // 每帧轮询部分请求者(均匀分配,避免单帧全量射线) - int count = Mathf.Min(_maxRequestersPerFrame, _requesters.Count); - - for (int i = 0; i < count; i++) - { - int idx = (_currentOffset + i) % _requesters.Count; - var requester = _requesters[idx]; - - bool hasLOS = false; - if (requester != null) - { - Vector2 origin = requester.LOSOrigin; - Vector2 target = requester.LOSTarget; - Vector2 direction = target - origin; - float distance = direction.magnitude; - - if (distance > 0.01f) - { - // direction / distance == direction.normalized,避免重复开方 - var hit = Physics2D.Raycast(origin, direction / distance, distance, requester.LOSBlockingMask); - // 若无遮挡物(hit.collider == null),则视线畅通 - hasLOS = hit.collider == null; - } - - - requester.ReceiveLOSResult(hasLOS); - } - } - - _currentOffset = (_currentOffset + count) % Mathf.Max(1, _requesters.Count); - } - } -} diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs index 152e58d..11f9883 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs @@ -18,7 +18,7 @@ namespace BaseGames.Enemies /// ⚠️ _nav 字段类型为 IPathAgent(在 BaseGames.Enemies.Navigation 中实现具体类)。 /// 实现 IPoolable:配合 PooledObject 支持对象池复用,避免频繁 Destroy/Instantiate。 /// - public class EnemyBase : MonoBehaviour, IDamageable, ILOSRequester, IPoolable + public class EnemyBase : MonoBehaviour, IDamageable, IPoolable { [Header("标识")] [SerializeField] private string _enemyId; // 任务系统 / Boss 进程追踪用,如 "Enemy_SpiderGuard" @@ -286,11 +286,14 @@ namespace BaseGames.Enemies public virtual bool IsPlayerInRange(float range) => _stats != null && _stats.SqrDistanceToPlayer <= range * range; - /// 原始视线检测结果(BatchLOSSystem 写入,无感知延迟修正)。 - public bool HasLineOfSight => _losResult; + /// 视线检测结果(由 PhysicsPerceptionSystem 的 LOS / Sight slot 提供)。 + public bool HasLineOfSight => + _sensorHub != null && + (_sensorHub.HasAnyDetection(Perception.SensorSlotNames.LOS) || + _sensorHub.HasAnyDetection(Perception.SensorSlotNames.Sight)); public virtual bool IsPlayerVisible() - => _threatAssessor != null ? _threatAssessor.IsThreatDetected : _losResult; + => _threatAssessor != null ? _threatAssessor.IsThreatDetected : HasLineOfSight; public virtual void FacePlayer() { @@ -644,31 +647,16 @@ namespace BaseGames.Enemies protected virtual void OnEnable() { _onPlayerSpawned?.Subscribe(SetPlayerTransform).AddTo(_subs); - Core.ServiceLocator.GetOrDefault()?.Register(this); } protected virtual void OnDisable() { - Core.ServiceLocator.GetOrDefault()?.Unregister(this); _subs.Clear(); } protected virtual void OnDestroy() { } - // LOS 缓存(BatchLOSSystem 写入;降级时由 3 帧节流 Raycast 写入) - private bool _losResult; - // ── ILOSRequester ────────────────────────────────────────────────── - public Vector2 LOSOrigin => (Vector2)transform.position + _statsSO.EyeOffset; - public Vector2 LOSTarget => _playerTransform != null - ? (Vector2)_playerTransform.position - : (Vector2)transform.position; - public LayerMask LOSBlockingMask => _statsSO.LOSBlockingMask; - - public void ReceiveLOSResult(bool hasLineOfSight) - { - _losResult = hasLineOfSight; - } // BehaviorTree 引用(#if GRAPH_DESIGNER 保护,避免空引用) #if GRAPH_DESIGNER @@ -831,9 +819,9 @@ namespace BaseGames.Enemies Gizmos.color = new Color(1f, 0.9f, 0.2f, 0.85f); Gizmos.DrawWireSphere(eyeWorld, 0.07f); - if (inRange || _losResult) + if (inRange || HasLineOfSight) { - Gizmos.color = _losResult + Gizmos.color = HasLineOfSight ? new Color(1f, 0.5f, 0f, 0.85f) : new Color(0.6f, 0.6f, 0.6f, 0.25f); Gizmos.DrawLine(eyeWorld, playerPos); diff --git a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs index 079dae1..a695ddd 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs @@ -64,7 +64,7 @@ namespace BaseGames.Enemies [Tooltip("Stagger / KnockUp 伤害阈值及击飞参数")] public HitTierConfig HitTiers; - [Header("视线检测(BatchLOSSystem)")] + [Header("视线检测(遗留配置,已迁移到 PhysicsPerceptionSystem 各 Slot 的 losBlockMask)")] [Tooltip("相对 transform.position 的眼睛偏移量")] public Vector2 EyeOffset = new Vector2(0f, 0.8f); [Tooltip("遮挡 LOS 的物理图层")] diff --git a/Assets/_Game/Scripts/Enemies/ILOSRequester.cs b/Assets/_Game/Scripts/Enemies/ILOSRequester.cs deleted file mode 100644 index b1aa9a5..0000000 --- a/Assets/_Game/Scripts/Enemies/ILOSRequester.cs +++ /dev/null @@ -1,27 +0,0 @@ -using UnityEngine; - -namespace BaseGames.Enemies -{ - /// - /// BatchLOSSystem 的注册接口(架构 07_EnemyModule §12)。 - /// 实现此接口的 EnemyBase 子类可以注册到 BatchLOSSystem, - /// 以批处理方式接收 LOS(Line of Sight)检测结果。 - /// - public interface ILOSRequester - { - /// 射线起点(通常是眼部位置)。 - Vector2 LOSOrigin { get; } - - /// 射线终点(通常是玩家位置)。 - Vector2 LOSTarget { get; } - - /// 遮挡 LOS 的物理图层。 - LayerMask LOSBlockingMask { get; } - - /// - /// 接收 LOS 检测结果。 - /// : true = 有视线,false = 被遮挡。 - /// - void ReceiveLOSResult(bool hasLineOfSight); - } -} diff --git a/Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs b/Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs index b34f9cd..6198a9d 100644 --- a/Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs +++ b/Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs @@ -7,7 +7,7 @@ namespace BaseGames.Enemies.Perception /// /// 工作原理: /// - /// 读取 (原始 LOS,BatchLOSSystem 写入)。 + /// 读取 (由 PhysicsPerceptionSystem 的 LOS/Sight 槽位驱动)。 /// 连续感知到玩家超过 秒后, 才变为 true。 /// 一旦丢失 LOS, 立即重置为 false(保持对"躲起来"的快速响应)。 /// diff --git a/Assets/_Game/Scripts/Enemies/Perception/PerceptionTriggerProxy.cs b/Assets/_Game/Scripts/Enemies/Perception/PerceptionTriggerProxy.cs new file mode 100644 index 0000000..f6394da --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/PerceptionTriggerProxy.cs @@ -0,0 +1,57 @@ +using UnityEngine; + +namespace BaseGames.Enemies.Perception +{ + /// + /// 挂载在 PhysicsPerceptionSystem 子节点上的触发器代理组件。 + /// 与 TriggerZone 槽位配合使用: + /// 当物理触发器产生 Enter / Exit 事件时,通知父系统的感知字典, + /// 同时触发 / + /// 委托。 + /// + /// 使用步骤: + /// 1. 在 PhysicsPerceptionSystem 的子 GameObject 上添加本组件。 + /// 2. 确保同一 GameObject 或子节点上有 Collider2D,且 isTrigger = true。 + /// 3. 填写 (与父系统中同名 TriggerZone 槽位对应)。 + /// 4. 设置 (只有命中该层的碰撞体才会触发通知)。 + /// 5. 由父系统 Awake() 自动赋值,无需手动操作。 + /// + [AddComponentMenu("BaseGames/Enemies/Perception Trigger Proxy")] + [RequireComponent(typeof(Collider2D))] + public sealed class PerceptionTriggerProxy : MonoBehaviour + { + [Tooltip("对应 PhysicsPerceptionSystem 中 TriggerZone 槽位的 slotName")] + public string slotName; + + [Tooltip("目标检测层,只有命中此层的碰撞体才通知父系统")] + public LayerMask detectLayer; + + /// 由父 PhysicsPerceptionSystem 在 Awake() 中自动注入,无需手动赋值。 + internal PhysicsPerceptionSystem ParentSystem { get; set; } + + // ── Unity 生命周期 ──────────────────────────────────────────────────── + + private void OnValidate() + { + var col = GetComponent(); + if (col != null && !col.isTrigger) + Debug.LogWarning( + $"[PerceptionTriggerProxy] '{name}' 上的 Collider2D.isTrigger 未勾选," + + "TriggerZone 槽位将无法工作。", this); + } + + private void OnTriggerEnter2D(Collider2D other) + { + if (ParentSystem == null || string.IsNullOrEmpty(slotName)) return; + if (detectLayer != 0 && ((1 << other.gameObject.layer) & (int)detectLayer) == 0) return; + ParentSystem.OnTriggerZoneEnter(slotName, other.gameObject); + } + + private void OnTriggerExit2D(Collider2D other) + { + if (ParentSystem == null || string.IsNullOrEmpty(slotName)) return; + if (detectLayer != 0 && ((1 << other.gameObject.layer) & (int)detectLayer) == 0) return; + ParentSystem.OnTriggerZoneExit(slotName, other.gameObject); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs.meta b/Assets/_Game/Scripts/Enemies/Perception/PerceptionTriggerProxy.cs.meta similarity index 83% rename from Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs.meta rename to Assets/_Game/Scripts/Enemies/Perception/PerceptionTriggerProxy.cs.meta index d27c75c..f86c13c 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs.meta +++ b/Assets/_Game/Scripts/Enemies/Perception/PerceptionTriggerProxy.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: a482b11f99a870f4ea28cd36b716a69b +guid: e8052de08fa173e479e190f652a1c04d MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs b/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs index 673dc62..138bd59 100644 --- a/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs +++ b/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs @@ -5,15 +5,26 @@ using UnityEngine; namespace BaseGames.Enemies.Perception { /// - /// 敌人感知系统(自研纯物理实现)。 - /// 每个 独立配置并独立运行,支持四种检测模式: - /// • RangeCircle — Physics2D.OverlapCircleNonAlloc(可选 LOS 视线遮挡校验) - /// • BatchLOS — 委托 EnemyBase.IsPlayerVisible()(BatchLOSSystem 批量射线) - /// • FanCast — 以朝向为轴的扇形多射线视野(支持遮挡层) - /// • BoxCast — 矩形区域重叠检测(X 偏移随 localScale.x 自动翻转) + /// 敌人感知系统(自研纯物理实现,商业级设计)。 + /// 每个 独立配置并独立运行,支持七种检测模式: + /// • RangeCircle — OverlapCircle 纯几何范围检测(无遮挡) + /// • BatchLOS — OverlapCircle + 单次 Raycast 自研批量视线检测(低开销,有帧延迟) + /// • FanCast — OverlapCircle + 角度过滤扇形视野(纯几何,无遮挡) + /// • BoxCast — OverlapBox 矩形区域检测(纯几何,无遮挡) + /// • Sight — OverlapCircle + 可选扇形角度 + 强制多点 LOS 遮挡(专用视线传感器) + /// • RayCast — 单/多根方向射线传感器,支持扩散角和遮挡层 + /// • TriggerZone — 物理触发器事件驱动(子节点 PerceptionTriggerProxy,零轮询) + /// + /// 遮挡检测设计原则: + /// RangeCircle / FanCast / BoxCast 是纯几何传感器,不做遮挡校验。 + /// 需要视线遮挡判断时,使用 Sight 槽位(内置多点 LOS 采样,始终执行遮挡检测)。 + /// 单向射线遮挡使用 RayCast 槽位(obstructLayer 控制阻断层)。 + /// + /// 性能优化: + /// • — 每 N 帧刷新一次(错帧执行,分散开销) + /// • — 运行时动态禁用单个槽位 + /// • — 场景单例,全局控制 Sight 射线预算 /// - /// EnemyBase.Awake() 通过 GetComponentInChildren<IPerceptionSystem>() - /// 自动发现本组件,无需修改 EnemyBase。 /// 槽位名称常量统一定义于 。 /// [DisallowMultipleComponent] @@ -23,14 +34,20 @@ namespace BaseGames.Enemies.Perception public enum SlotType { - /// Physics2D 圆形重叠检测 + /// Physics2D.OverlapCircle 纯几何范围检测(无遮挡) RangeCircle, - /// 委托 EnemyBase.IsPlayerVisible()(BatchLOSSystem 批量射线视线检测) + /// OverlapCircle + 单条遮挡射线(自包含 LOS 检测,低开销) BatchLOS, - /// 以朝向为轴的扇形射线视野,遮挡层阻断视线 + /// OverlapCircle + 角度过滤扇形视野(纯几何,无遮挡) FanCast, - /// 矩形区域重叠检测,X 偏移随 localScale.x 自动翻转 - BoxCast + /// OverlapBox 矩形区域检测,X 偏移随 localScale.x 自动翻转(纯几何,无遮挡) + BoxCast, + /// OverlapCircle + 可选扇形角度过滤 + 强制多点 LOS 遮挡校验(专用视线传感器) + Sight, + /// 单/多根方向射线检测,支持扩散角和遮挡层(对标 RaySensor2D) + RayCast, + /// 物理触发器事件驱动,依赖子节点 PerceptionTriggerProxy(零轮询开销) + TriggerZone, } // ── 槽位定义 ────────────────────────────────────────────────────────── @@ -38,56 +55,119 @@ namespace BaseGames.Enemies.Perception [Serializable] public struct PerceptionSlot { - [Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\n(aggro / los / attack_melee / attack_range)")] + // ── 通用 ─────────────────────────────────────────────────────── + + [Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\n(aggro / patrol / alert / los / attack_melee / attack_range / sight)")] public string slotName; - [Tooltip("RangeCircle:Physics2D 圆形范围检测\nBatchLOS:视线射线检测(BatchLOSSystem)\nFanCast:以朝向为轴的扇形射线视野\nBoxCast:矩形区域重叠检测")] + [Tooltip("RangeCircle:纯几何圆形范围\nBatchLOS:OverlapCircle + 单射线遮挡(自包含 LOS)\nFanCast:扇形视野(纯几何)\nBoxCast:矩形区域(纯几何)\nSight:视线传感器(多射线遮挡)\nRayCast:方向射线传感器\nTriggerZone:触发器事件驱动(需子节点 PerceptionTriggerProxy)")] public SlotType type; - [Min(0f)] - [Tooltip("RangeCircle / FanCast:检测半径(米)\nBatchLOS:最大视线检测距离(0 = 不限制)\nBoxCast:忽略此值")] - public float radius; - - [Tooltip("目标检测层(通常为 Player 层);BatchLOS 忽略此值")] - public LayerMask detectLayer; - - [Tooltip("RangeCircle / BoxCast:基础重叠命中后额外校验视线(Physics2D.Raycast)\nFanCast:true = 射线被 losBlockMask 层遮挡;false = 穿透所有障碍物")] - public bool requireLOS; - - [Tooltip("requireLOS = true / FanCast(requireLOS = true):视线遮挡检测层(通常为 Platform + Wall)\nFanCast 射线只在 requireLOS = true 时被此层遮挡")] - public LayerMask losBlockMask; - - [Header("Origin")] - [Tooltip("检测原点相对于 transform.position 的偏移(米)。\nX 分量随 localScale.x 朝向自动翻转;BatchLOS 仅影响 Gizmo,不影响实际射线。")] + [Tooltip("检测原点相对于 transform.position 的偏移(米)。\nX 分量随 localScale.x 朝向自动翻转。")] public Vector2 offset; - [Header("FanCast")] - [Tooltip("FanCast:扇形张角(度),以朝向为中轴左右均匀展开")] + [Tooltip("Scene 视图 Gizmo 颜色。留空(全透明黑色)时自动使用紫色回退。")] + public Color gizmoColor; + + [Tooltip("勾选后禁用本槽位检测(可运行时动态切换)。\n注:tickInterval = 0 时本字段无效(历史兼容模式:始终启用)。")] + public bool isDisabled; + + [Min(0)] + [Tooltip("每 N 个 FixedUpdate 帧刷新一次。\n0 = 历史兼容(每帧刷新,isDisabled 无效)\n1 = 每帧刷新\n3 = 每 3 帧刷新(Sight 推荐)\n多个相同间隔的槽位自动错帧执行。")] + public int tickInterval; + + // ── 范围(RangeCircle / FanCast / BatchLOS / Sight)─────────── + + [Min(0f)] + [Tooltip("RangeCircle / FanCast / Sight:检测半径(米)\nBatchLOS:最大视线距离(0 = 不限)\nBoxCast / RayCast / TriggerZone:忽略")] + public float radius; + + // ── 检测层(RangeCircle / FanCast / BoxCast / Sight / RayCast) + + [Tooltip("目标检测层(通常为 Player 层);BatchLOS / TriggerZone 忽略此值")] + public LayerMask detectLayer; + + // ── FanCast & Sight ──────────────────────────────────────────── + + [Tooltip("FanCast:扇形张角(度),以朝向为中轴左右均匀展开\nSight:视野锥角(度),设为 0 或 ≥ 360 表示全向 360°")] public float fanAngle; - [Tooltip("FanCast:扇形内均匀分布的射线数量(建议 5–11 条)")] [Min(2)] + [Tooltip("FanCast 专用:Scene 视图 Gizmo 扇形分隔线数量(不影响检测,建议 3–9)")] public int fanRayCount; - [Header("BoxCast")] + // ── BoxCast ──────────────────────────────────────────────────── + [Tooltip("BoxCast:检测框尺寸 (宽, 高),单位米")] public Vector2 boxSize; [Tooltip("BoxCast:相对于感知中心的偏移,X 分量随 localScale.x 自动翻转")] public Vector2 boxOffset; - [Header("Gizmos")] - [Tooltip("Scene 视图 Gizmo 颜色。留空(全透明黑色)时自动使用紫色回退,以确保可见。")] - public Color gizmoColor; + // ── Sight LOS(仅 Sight 类型使用)──────────────────────────── + + [Tooltip("[Sight 专用] 视线遮挡检测层(通常为 Platform + Wall + Ground)。\n⚠ 留 0 时视线始终通过(losBlockMask = 0 = 无遮挡层)。")] + public LayerMask losBlockMask; + + [Range(1, 5)] + [Tooltip("[Sight 专用] LOS 多点采样数量:\n1 = 包围盒中心(最快)\n3 = 中心 + 上 + 下(推荐)\n5 = 中心 + 上下左右(最稳健)")] + public int losRayCount; + + [Range(0f, 1f)] + [Tooltip("[Sight 专用] 可见度阈值(0–1):\n0 = 任意 1 条通过即可(宽松)\n0.5 = 50% 通过\n1 = 全部通过(严格)")] + public float losMinVisibility; + + // ── RayCast ─────────────────────────────────────────────────── + + [Tooltip("[RayCast 专用] 射线方向(本地空间),X 分量随朝向自动翻转。\n向量长度不需要为 1,运行时自动归一化。\n示例:(1,0)=正前方 (0,-1)=正下方 (1,-1)=右前下45°")] + public Vector2 rayDirection; + + [Min(0f)] + [Tooltip("[RayCast 专用] 射线长度(米)")] + public float rayLength; + + [Range(0f, 180f)] + [Tooltip("[RayCast 专用] 多射线扩散角(度):\n0 = 单根射线\n> 0 时在此角度范围内均匀分布 rayCount 根射线")] + public float raySpread; + + [Range(1, 9)] + [Tooltip("[RayCast 专用] 射线根数(raySpread > 0 时生效;= 1 时始终单根)")] + public int rayCount; + + [Tooltip("[RayCast 专用] 遮挡层(射线碰到此层后停止,不再检测后续目标);0 = 不检测遮挡,射线穿透所有物体")] + public LayerMask obstructLayer; } + // ── 事件 ───────────────────────────────────────────────────────────── + + /// 某槽位首次检测到目标时触发。参数:(slotName, target)。 + public event Action OnEnterDetection; + + /// 某槽位失去对目标的检测时触发。参数:(slotName, target)。 + public event Action OnExitDetection; + // ── 字段 ────────────────────────────────────────────────────────────── [SerializeField] private PerceptionSlot[] _slots; + // 当前帧检测结果 private readonly Dictionary> _detected = new Dictionary>(); + + // 上帧检测结果(Enter/Exit diff 用,HashSet 提供 O(1) Contains) + private readonly Dictionary> _prevDetected = + new Dictionary>(); + + // 实例缓冲区(单线程 FixedUpdate 安全) private readonly Collider2D[] _overlapBuffer = new Collider2D[32]; + + // 静态共享缓冲区(零 GC,全局单线程安全) + private static readonly Vector2[] _losPointsBuf = new Vector2[5]; + private static readonly RaycastHit2D[] _rayHitBuf = new RaycastHit2D[16]; + private static readonly List _enterBuf = new List(8); + private static readonly List _exitBuf = new List(8); + + private int _fixedTick; private bool _suspended; private EnemyBase _owner; @@ -96,59 +176,163 @@ namespace BaseGames.Enemies.Perception private void Awake() { _owner = GetComponentInParent(); - if (_slots == null) return; - foreach (var slot in _slots) + + if (_slots != null) { - if (!string.IsNullOrEmpty(slot.slotName) && !_detected.ContainsKey(slot.slotName)) - _detected[slot.slotName] = new List(4); + foreach (var slot in _slots) + if (!string.IsNullOrEmpty(slot.slotName)) + EnsureSlotDict(slot.slotName); } + + // TriggerZone:发现所有子节点代理并绑定 + var proxies = GetComponentsInChildren(true); + foreach (var proxy in proxies) + { + if (string.IsNullOrEmpty(proxy.slotName)) continue; + EnsureSlotDict(proxy.slotName); + proxy.ParentSystem = this; + } + + // 注册到 SightBatchSystem(如存在) + if (SightBatchSystem.Instance != null) + SightBatchSystem.Instance.Register(this); + } + + private void OnDestroy() + { + if (SightBatchSystem.Instance != null) + SightBatchSystem.Instance.Unregister(this); + + var proxies = GetComponentsInChildren(true); + foreach (var proxy in proxies) + if (proxy.ParentSystem == this) proxy.ParentSystem = null; } private void FixedUpdate() { if (_suspended || _slots == null) return; - foreach (var slot in _slots) - RefreshSlot(slot); + _fixedTick++; + + bool hasSightBatch = SightBatchSystem.Instance != null; + + for (int i = 0; i < _slots.Length; i++) + { + ref var slot = ref _slots[i]; + if (string.IsNullOrEmpty(slot.slotName)) continue; + + // TriggerZone 完全事件驱动,不轮询 + if (slot.type == SlotType.TriggerZone) continue; + + // Sight 槽由 SightBatchSystem 统一调度,此处跳过 + if (slot.type == SlotType.Sight && hasSightBatch) continue; + + // tickInterval = 0:历史兼容模式(每帧刷新,isDisabled 无效) + if (slot.tickInterval == 0) + { + RefreshSlot(ref slot, i); + continue; + } + + // 新行为:isDisabled + 错帧节流 + if (slot.isDisabled) continue; + if ((_fixedTick + i) % slot.tickInterval != 0) continue; + + RefreshSlot(ref slot, i); + } + } + + private void EnsureSlotDict(string name) + { + if (!_detected.ContainsKey(name)) + { + _detected[name] = new List(4); + _prevDetected[name] = new HashSet(); + } } // ── 内部检测逻辑 ────────────────────────────────────────────────────── - private void RefreshSlot(PerceptionSlot slot) + private void RefreshSlot(ref PerceptionSlot slot, int slotIndex) { - if (string.IsNullOrEmpty(slot.slotName)) return; if (!_detected.TryGetValue(slot.slotName, out var list)) return; + + // Enter/Exit diff:保存上帧结果 + if ((OnEnterDetection != null || OnExitDetection != null) + && _prevDetected.TryGetValue(slot.slotName, out var prev)) + { + prev.Clear(); + foreach (var go in list) prev.Add(go); + } + list.Clear(); switch (slot.type) { - case SlotType.BatchLOS: - if (_owner != null && _owner.IsPlayerVisible() && _owner.PlayerTransform != null) + case SlotType.BatchLOS: RefreshBatchLOS(ref slot, list); break; + case SlotType.RangeCircle: RefreshRangeCircle(ref slot, list); break; + case SlotType.FanCast: RefreshFanCast(ref slot, list); break; + case SlotType.BoxCast: RefreshBoxCast(ref slot, list); break; + case SlotType.Sight: RefreshSight(ref slot, list); break; + case SlotType.RayCast: RefreshRayCast(ref slot, list); break; + } + + FireDiffEvents(slot.slotName); + } + + private void FireDiffEvents(string slotName) + { + if (OnEnterDetection == null && OnExitDetection == null) return; + if (!_prevDetected.TryGetValue(slotName, out var prev)) return; + if (!_detected.TryGetValue(slotName, out var curr)) return; + + _enterBuf.Clear(); + _exitBuf.Clear(); + + foreach (var go in curr) + if (!prev.Contains(go)) _enterBuf.Add(go); + + foreach (var go in prev) + { + bool stillDetected = false; + for (int i = 0; i < curr.Count; i++) + if (curr[i] == go) { stillDetected = true; break; } + if (!stillDetected) _exitBuf.Add(go); + } + + foreach (var go in _enterBuf) OnEnterDetection?.Invoke(slotName, go); + foreach (var go in _exitBuf) OnExitDetection?.Invoke(slotName, go); + } + + private void RefreshBatchLOS(ref PerceptionSlot slot, List list) + { + if (slot.radius <= 0f || slot.detectLayer == 0) return; + + float facingSign = transform.localScale.x < 0f ? -1f : 1f; + Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y); + + int count = Physics2D.OverlapCircleNonAlloc(origin, slot.radius, _overlapBuffer, slot.detectLayer); + for (int i = 0; i < count; i++) + { + var col = _overlapBuffer[i]; + if (col == null) continue; + + if (slot.losBlockMask != 0) + { + Vector2 targetCenter = col.bounds.center; + Vector2 dir = targetCenter - origin; + float dist = dir.magnitude; + if (dist > 0.001f) { - if (slot.radius > 0f) - { - float dist = Vector2.Distance( - (Vector2)transform.position, (Vector2)_owner.PlayerTransform.position); - if (dist > slot.radius) break; - } - list.Add(_owner.PlayerTransform.gameObject); + var hit = Physics2D.Raycast(origin, dir / dist, dist, slot.losBlockMask); + if (hit.collider != null) continue; // 被遮挡 } - break; + } - case SlotType.RangeCircle: - RefreshRangeCircle(slot, list); - break; - - case SlotType.FanCast: - RefreshFanCast(slot, list); - break; - - case SlotType.BoxCast: - RefreshBoxCast(slot, list); - break; + list.Add(col.gameObject); } } - private void RefreshRangeCircle(in PerceptionSlot slot, System.Collections.Generic.List list) + private void RefreshRangeCircle(ref PerceptionSlot slot, List list) { if (slot.radius <= 0f || slot.detectLayer == 0) return; float facingSign = transform.localScale.x < 0f ? -1f : 1f; @@ -157,40 +341,30 @@ namespace BaseGames.Enemies.Perception for (int i = 0; i < count; i++) { var col = _overlapBuffer[i]; - if (col == null) continue; - if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue; - list.Add(col.gameObject); + if (col != null) list.Add(col.gameObject); } } - private void RefreshFanCast(in PerceptionSlot slot, System.Collections.Generic.List list) + private void RefreshFanCast(ref PerceptionSlot slot, List list) { if (slot.radius <= 0f || slot.detectLayer == 0 || slot.fanAngle <= 0f) return; float facingSign = transform.localScale.x < 0f ? -1f : 1f; - var forward = new Vector2(facingSign, 0f); Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y); + var forward = new Vector2(facingSign, 0f); float halfAngle = slot.fanAngle * 0.5f; - int rays = Mathf.Max(2, slot.fanRayCount); - // requireLOS = true:射线被 losBlockMask 遮挡;false:仅检测 detectLayer(穿透障碍物) - int castMask = slot.requireLOS - ? ((int)slot.detectLayer | (int)slot.losBlockMask) - : (int)slot.detectLayer; - for (int r = 0; r < rays; r++) + int count = Physics2D.OverlapCircleNonAlloc(origin, slot.radius, _overlapBuffer, slot.detectLayer); + for (int i = 0; i < count; i++) { - float t = (float)r / (rays - 1); - Vector2 dir = RotateVector(forward, Mathf.Lerp(-halfAngle, halfAngle, t)); - RaycastHit2D hit = Physics2D.Raycast(origin, dir, slot.radius, castMask); - if (hit.collider == null) continue; - if (((1 << hit.collider.gameObject.layer) & (int)slot.detectLayer) == 0) continue; - - var go = hit.collider.gameObject; - if (!list.Contains(go)) list.Add(go); + var col = _overlapBuffer[i]; + if (col == null) continue; + if (Vector2.Angle(forward, (Vector2)col.bounds.center - origin) > halfAngle) continue; + if (!list.Contains(col.gameObject)) list.Add(col.gameObject); } } - private void RefreshBoxCast(in PerceptionSlot slot, System.Collections.Generic.List list) + private void RefreshBoxCast(ref PerceptionSlot slot, List list) { if (slot.boxSize.x <= 0f || slot.boxSize.y <= 0f || slot.detectLayer == 0) return; @@ -202,33 +376,183 @@ namespace BaseGames.Enemies.Perception for (int i = 0; i < count; i++) { var col = _overlapBuffer[i]; - if (col == null) continue; - if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue; - list.Add(col.gameObject); + if (col != null) list.Add(col.gameObject); } } - private bool HasLineOfSight(Vector2 origin, GameObject target, LayerMask blockMask) + /// + /// 视线传感器:OverlapCircle + 可选扇形角度过滤 + 强制多点 LOS 遮挡校验。 + /// 唯一内置遮挡检测的轮询槽位类型。 + /// + private void RefreshSight(ref PerceptionSlot slot, List list) { - // 优先使用碰撞体包围盒中心(比 transform.position 更准确,避免偏心轴误判) - Vector2 targetPos; - var col = target.GetComponent(); - targetPos = col != null ? (Vector2)col.bounds.center : (Vector2)target.transform.position; + if (slot.radius <= 0f || slot.detectLayer == 0) return; - var dir = targetPos - origin; - float dist = dir.magnitude; - if (dist <= 0f) return true; - var hit = Physics2D.Raycast(origin, dir / dist, dist, blockMask); - // 未命中任何障碍,或者第一个命中的就是目标自身(含子物体) - return hit.collider == null || hit.collider.transform.IsChildOf(target.transform) || hit.collider.gameObject == target; + float facingSign = transform.localScale.x < 0f ? -1f : 1f; + Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y); + Vector2 forward = new Vector2(facingSign, 0f); + float halfAngle = slot.fanAngle * 0.5f; + bool limitAngle = slot.fanAngle > 0f && slot.fanAngle < 360f; + + int count = Physics2D.OverlapCircleNonAlloc(origin, slot.radius, _overlapBuffer, slot.detectLayer); + for (int i = 0; i < count; i++) + { + var col = _overlapBuffer[i]; + if (col == null) continue; + if (limitAngle && Vector2.Angle(forward, (Vector2)col.bounds.center - origin) > halfAngle) continue; + if (!HasLineOfSight(origin, col.gameObject, + slot.losBlockMask, Mathf.Max(1, slot.losRayCount), slot.losMinVisibility)) continue; + if (!list.Contains(col.gameObject)) list.Add(col.gameObject); + } } - private static Vector2 RotateVector(Vector2 v, float angleDeg) + /// + /// 方向射线传感器:单/多根射线,支持扩散角和遮挡阻断。 + /// 射线方向在本地空间定义,X 分量随朝向自动翻转。 + /// + private void RefreshRayCast(ref PerceptionSlot slot, List list) { - float rad = angleDeg * Mathf.Deg2Rad; - float cos = Mathf.Cos(rad); - float sin = Mathf.Sin(rad); - return new Vector2(cos * v.x - sin * v.y, sin * v.x + cos * v.y); + if (slot.rayLength <= 0f || slot.detectLayer == 0) return; + + float facingSign = transform.localScale.x < 0f ? -1f : 1f; + Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y); + + // 射线基准方向(本地空间,X 随朝向翻转,fallback = 正前方) + Vector2 baseDir = new Vector2(slot.rayDirection.x * facingSign, slot.rayDirection.y); + if (baseDir.sqrMagnitude < 0.0001f) baseDir = new Vector2(facingSign, 0f); + baseDir.Normalize(); + + int rayCount = Mathf.Max(1, slot.rayCount); + float spread = slot.raySpread; + LayerMask combinedMask = slot.detectLayer | slot.obstructLayer; + + for (int r = 0; r < rayCount; r++) + { + Vector2 dir; + if (rayCount == 1 || spread <= 0f) + { + dir = baseDir; + } + else + { + float t = (float)r / (rayCount - 1); + float angDeg = Mathf.Lerp(-spread * 0.5f, spread * 0.5f, t); + float rad = angDeg * Mathf.Deg2Rad; + float cos = Mathf.Cos(rad); + float sin = Mathf.Sin(rad); + dir = new Vector2(cos * baseDir.x - sin * baseDir.y, + sin * baseDir.x + cos * baseDir.y); + } + + int hits = Physics2D.RaycastNonAlloc(origin, dir, _rayHitBuf, slot.rayLength, combinedMask); + for (int j = 0; j < hits; j++) + { + var hit = _rayHitBuf[j]; + if (hit.collider == null) continue; + int layer = hit.collider.gameObject.layer; + + // 遮挡层:阻断当前射线(不检测后续目标) + if (slot.obstructLayer != 0 && ((1 << layer) & (int)slot.obstructLayer) != 0) break; + + // 检测层:加入结果 + if (((1 << layer) & (int)slot.detectLayer) != 0) + { + var go = hit.collider.gameObject; + if (!list.Contains(go)) list.Add(go); + } + } + } + } + + // ── TriggerZone 回调(由 PerceptionTriggerProxy 调用)──────────────── + + internal void OnTriggerZoneEnter(string slotName, GameObject go) + { + if (!_detected.TryGetValue(slotName, out var list)) return; + if (list.Contains(go)) return; + list.Add(go); + OnEnterDetection?.Invoke(slotName, go); + } + + internal void OnTriggerZoneExit(string slotName, GameObject go) + { + if (!_detected.TryGetValue(slotName, out var list)) return; + if (!list.Remove(go)) return; + OnExitDetection?.Invoke(slotName, go); + } + + // ── SightBatchSystem 调用入口(内部 API)───────────────────────────── + + /// + /// 由 调用,执行本系统所有 Sight 槽位的刷新。 + /// 批量系统负责频率控制,不参与 tickInterval 逻辑。 + /// + internal void ExecuteSightSlots() + { + if (_suspended || _slots == null) return; + for (int i = 0; i < _slots.Length; i++) + { + ref var slot = ref _slots[i]; + if (slot.type != SlotType.Sight) continue; + if (slot.isDisabled && slot.tickInterval > 0) continue; + if (string.IsNullOrEmpty(slot.slotName)) continue; + RefreshSlot(ref slot, i); + } + } + + // ── LOS 工具 ────────────────────────────────────────────────────────── + + /// + /// 多点采样 LOS 遮挡检测。 + /// = 0 时始终返回 true(无遮挡层配置 = 视线始终通过)。 + /// + private static bool HasLineOfSight(Vector2 origin, GameObject target, + LayerMask blockMask, int rayCount = 1, float minVisibility = 0f) + { + if (blockMask == 0) return true; + + var col = target.GetComponent(); + int n = FillLOSPoints(col, target.transform, Mathf.Clamp(rayCount, 1, 5)); + if (n == 0) return true; + + int passed = 0; + for (int i = 0; i < n; i++) + { + var dir = _losPointsBuf[i] - origin; + float dist = dir.magnitude; + if (dist <= 0f) { passed++; continue; } + var hit = Physics2D.Raycast(origin, dir / dist, dist, blockMask); + bool clear = hit.collider == null + || hit.collider.transform.IsChildOf(target.transform) + || hit.collider.gameObject == target; + if (clear) passed++; + } + + float visibility = (float)passed / n; + return minVisibility <= 0f ? passed > 0 : visibility >= minVisibility; + } + + /// + /// 将目标包围盒的 LOS 采样点写入 (80% 缩进避免边缘误判)。 + /// + private static int FillLOSPoints(Collider2D col, Transform fallback, int count) + { + if (col != null) + { + var b = col.bounds; + var center = (Vector2)b.center; + var ext = (Vector2)b.extents * 0.8f; + + _losPointsBuf[0] = center; + if (count >= 2) _losPointsBuf[1] = center + new Vector2(0f, ext.y); + if (count >= 3) _losPointsBuf[2] = center + new Vector2(0f, -ext.y); + if (count >= 4) _losPointsBuf[3] = center + new Vector2( ext.x, 0f); + if (count >= 5) _losPointsBuf[4] = center + new Vector2(-ext.x, 0f); + return count; + } + + _losPointsBuf[0] = (Vector2)fallback.position; + return 1; } // ── IPerceptionSystem ───────────────────────────────────────────────── @@ -236,7 +560,6 @@ namespace BaseGames.Enemies.Perception public bool HasSlot(string slotName) { if (string.IsNullOrEmpty(slotName)) return false; - // 运行时通过字典; 编辑器模式遍历数组 if (_detected.Count > 0) return _detected.ContainsKey(slotName); if (_slots == null) return false; foreach (var s in _slots) @@ -265,7 +588,7 @@ namespace BaseGames.Enemies.Perception { if (string.IsNullOrEmpty(slotName) || _slots == null) return -1f; foreach (var s in _slots) - if (s.slotName == slotName && s.type == SlotType.RangeCircle) + if (s.slotName == slotName && (s.type == SlotType.RangeCircle || s.type == SlotType.Sight)) return s.radius; return -1f; } @@ -291,9 +614,9 @@ namespace BaseGames.Enemies.Perception // ── 编辑器 API(仅 UNITY_EDITOR 访问)──────────────────────────────── #if UNITY_EDITOR - public PerceptionSlot[] EditorSlots => _slots; + public PerceptionSlot[] EditorSlots => _slots; public IReadOnlyDictionary> EditorDetected => _detected; - public EnemyBase EditorOwner => _owner; + public EnemyBase EditorOwner => _owner; #endif } } diff --git a/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs b/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs index 47bb026..d233400 100644 --- a/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs +++ b/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs @@ -16,7 +16,7 @@ namespace BaseGames.Enemies.Perception /// /// 视线检测(BatchLOS):敌我之间无遮挡时持续为 true。 - /// 由 BatchLOSSystem 批量计算,BD_IsPlayerVisible 读取结果。 + /// 由 PhysicsPerceptionSystem 自研批量计算(OverlapCircle + 单次 Raycast),BD_IsPlayerVisible 读取结果。 /// public const string LOS = "los"; @@ -29,5 +29,23 @@ namespace BaseGames.Enemies.Perception /// 远程攻击范围(RangeCircle):玩家进入时触发远程攻击条件。 /// public const string AttackRange = "attack_range"; + + /// + /// 巡逻范围(RangeCircle):定义敌人允许巡逻的地图固定区域半径。 + /// 超出此范围时触发"返回巡逻点"逻辑。 + /// + public const string Patrol = "patrol"; + + /// + /// 警觉半径(RangeCircle):进入此圈时敌人从待机/巡逻切换到 Alert 状态, + /// 通常比 Aggro 小,用于区分"察觉"和"追击"两个阶段。 + /// + public const string Alert = "alert"; + + /// + /// 视线感知(Sight):带强制 LOS 遮挡检测的视锥传感器。 + /// 仅当目标在视野锥内且无障碍物遮挡时才触发,是"看见玩家"的核心传感器。 + /// + public const string Sight = "sight"; } } diff --git a/Assets/_Game/Scripts/Enemies/Perception/SightBatchSystem.cs b/Assets/_Game/Scripts/Enemies/Perception/SightBatchSystem.cs new file mode 100644 index 0000000..c1fec2c --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/SightBatchSystem.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace BaseGames.Enemies.Perception +{ + /// + /// 场景级 Sight 槽位批量调度器(Phase 3 性能优化)。 + /// + /// 设计意图: + /// Sight 槽位内置多点 LOS 射线检测,当场景中存在大量敌人时(10+ 个), + /// 每帧同步刷新所有 Sight 槽位会产生明显的 CPU 峰值。 + /// 本组件将全局 Sight 更新量限制在 个/帧, + /// 通过轮询方式公平分配每帧的 Sight 预算,把瞬时峰值摊平到多帧。 + /// + /// 使用方式: + /// 1. 在场景中添加一个空 GameObject,挂载本组件(建议放在 Managers 节点下)。 + /// 2. 本组件自动在 DefaultExecutionOrder(-50) 执行,比默认 PhysicsPerceptionSystem 早。 + /// 3. 当本组件存在时,PhysicsPerceptionSystem.FixedUpdate() 会跳过所有 Sight 槽位的更新, + /// 由本组件统一调度(优雅降级:本组件不存在时 Sight 每帧正常更新)。 + /// + /// 性能参考(测试用): + /// • maxSystemsPerFrame = 4,60fps,20 个敌人 → 平均每帧 4 × losRayCount 次射线, + /// 每个敌人 Sight 刷新周期约 5 帧(~83ms),适合慢速视线感应。 + /// • 增大此值可减少延迟,减小此值可进一步降低每帧开销。 + /// + [AddComponentMenu("BaseGames/Enemies/Sight Batch System")] + [DefaultExecutionOrder(-50)] + public sealed class SightBatchSystem : MonoBehaviour + { + // ── 单例 ────────────────────────────────────────────────────────────── + + public static SightBatchSystem Instance { get; private set; } + + // ── 配置 ────────────────────────────────────────────────────────────── + + [Min(1)] + [Tooltip("每帧最多更新多少个 PhysicsPerceptionSystem 的 Sight 槽位。\n" + + "建议值:场景敌人数 / 5(使平均刷新延迟 ≈ 5 帧)。\n" + + "值越大 = 延迟越低但 CPU 峰值越高;值越小反之。")] + [SerializeField] private int maxSystemsPerFrame = 4; + + // ── 内部状态 ────────────────────────────────────────────────────────── + + private readonly List _registrants = + new List(32); + + private int _offset; + + // ── 单例生命周期 ────────────────────────────────────────────────────── + + private void Awake() + { + if (Instance != null && Instance != this) + { + Debug.LogWarning("[SightBatchSystem] 场景中存在多个 SightBatchSystem,将销毁多余实例。", this); + Destroy(gameObject); + return; + } + Instance = this; + } + + private void OnDestroy() + { + if (Instance == this) Instance = null; + } + + // ── 注册 / 注销 ─────────────────────────────────────────────────────── + + /// PhysicsPerceptionSystem.Awake() 自动调用。 + public void Register(PhysicsPerceptionSystem system) + { + if (system == null || _registrants.Contains(system)) return; + _registrants.Add(system); + } + + /// PhysicsPerceptionSystem.OnDestroy() 自动调用。O(1) 交换删除。 + public void Unregister(PhysicsPerceptionSystem system) + { + int idx = _registrants.IndexOf(system); + if (idx < 0) return; + int last = _registrants.Count - 1; + _registrants[idx] = _registrants[last]; + _registrants.RemoveAt(last); + + // 防止 _offset 越界 + if (_offset >= _registrants.Count) + _offset = 0; + } + + // ── 调度 ────────────────────────────────────────────────────────────── + + private void FixedUpdate() + { + int total = _registrants.Count; + if (total == 0) return; + + int budget = Mathf.Min(maxSystemsPerFrame, total); + for (int i = 0; i < budget; i++) + { + int idx = (_offset + i) % total; + _registrants[idx].ExecuteSightSlots(); + } + + _offset = (_offset + budget) % total; + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/ILOSRequester.cs.meta b/Assets/_Game/Scripts/Enemies/Perception/SightBatchSystem.cs.meta similarity index 83% rename from Assets/_Game/Scripts/Enemies/ILOSRequester.cs.meta rename to Assets/_Game/Scripts/Enemies/Perception/SightBatchSystem.cs.meta index 22489d5..2bb56fc 100644 --- a/Assets/_Game/Scripts/Enemies/ILOSRequester.cs.meta +++ b/Assets/_Game/Scripts/Enemies/Perception/SightBatchSystem.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 89145f6fbc97f53419fa3ce81fcb6342 +guid: c4da817926ac7c741a2c33ec552b249e MonoImporter: externalObjects: {} serializedVersion: 2