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.
This commit is contained in:
2026-06-02 23:18:20 +08:00
parent 150440495d
commit d27ae9407d
17 changed files with 1946 additions and 335 deletions

View File

@@ -12,13 +12,14 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 81da55e0fcf99d34693cbc5a348225c3, type: 3} m_Script: {fileID: 11500000, guid: 81da55e0fcf99d34693cbc5a348225c3, type: 3}
m_Name: PLY_PlayerMovementConfig m_Name: PLY_PlayerMovementConfig
m_EditorClassIdentifier: m_EditorClassIdentifier:
RunSpeed: 6 RunSpeed: 7
AirDragFactor: 1 AirDragFactor: 1
JumpForce: 17.5 JumpForce: 18
CoyoteTime: 0.12 CoyoteTime: 0.12
FallGravityMult: 2 FallGravityMult: 1
MaxFallSpeed: 15 MaxFallSpeed: 20
JumpCutMultiplier: 0.321 JumpCutMultiplier: 0.054
MinJumpTime: 0.08
ApexThreshold: 3 ApexThreshold: 3
ApexGravityMultiplier: 0.3 ApexGravityMultiplier: 0.3
MaxAirJumps: 5 MaxAirJumps: 5

View File

@@ -1244,16 +1244,17 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: m_EditorClassIdentifier:
_hudRoot: {fileID: 1496719665} _hudRoot: {fileID: 1496719665}
_pauseMenuRoot: {fileID: 414932415}
_deathScreenRoot: {fileID: 1071624567} _deathScreenRoot: {fileID: 1071624567}
_settingsRoot: {fileID: 83002174} _panels: []
_mapRoot: {fileID: 1189402268} _addressablePanels: []
_shopRoot: {fileID: 1859511082} _addressablePanelParent: {fileID: 0}
_onGameStateChanged: {fileID: 11400000, guid: aa9c327d03e82c84e87d054545412578, type: 2} _onGameStateChanged: {fileID: 11400000, guid: aa9c327d03e82c84e87d054545412578, type: 2}
_onPauseRequested: {fileID: 11400000, guid: a02c7f0e5fa99054bac624adc82c4a53, type: 2} _onPauseRequested: {fileID: 11400000, guid: a02c7f0e5fa99054bac624adc82c4a53, type: 2}
_onFastTravelOpen: {fileID: 11400000, guid: 9f308b954701a484083fb120aa6c7ee3, type: 2} _onFastTravelOpen: {fileID: 11400000, guid: 9f308b954701a484083fb120aa6c7ee3, type: 2}
_onShopOpen: {fileID: 11400000, guid: 804a6cfdb23f0554195cebcf8270756f, type: 2} _onShopOpen: {fileID: 11400000, guid: 804a6cfdb23f0554195cebcf8270756f, type: 2}
_onMapOpen: {fileID: 11400000, guid: b972e8c7aec9da34d80381e643d49cb2, type: 2} _onMapOpen: {fileID: 11400000, guid: b972e8c7aec9da34d80381e643d49cb2, type: 2}
_onCharmPanelOpen: {fileID: 0}
_onSpellSelectOpen: {fileID: 0}
--- !u!1 &737017260 --- !u!1 &737017260
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -1676,6 +1677,8 @@ MonoBehaviour:
m_EditorClassIdentifier: m_EditorClassIdentifier:
_deathMessage: {fileID: 0} _deathMessage: {fileID: 0}
_btnRespawn: {fileID: 307714527} _btnRespawn: {fileID: 307714527}
_showDelay: 1.5
_defaultDeathText: "\u6C7A\u6B7B"
_onDeathScreenConfirmed: {fileID: 11400000, guid: c5237081444b4b54682df1087095fc89, type: 2} _onDeathScreenConfirmed: {fileID: 11400000, guid: c5237081444b4b54682df1087095fc89, type: 2}
--- !u!1 &1100545355 --- !u!1 &1100545355
GameObject: GameObject:
@@ -1977,6 +1980,7 @@ MonoBehaviour:
_onSceneLoadRequest: {fileID: 11400000, guid: 7a4675ba5f3b784448ce2d1e0048f119, type: 2} _onSceneLoadRequest: {fileID: 11400000, guid: 7a4675ba5f3b784448ce2d1e0048f119, type: 2}
_onFadeInRequest: {fileID: 11400000, guid: f8d520fe699782b4184ff72ce5200c25, type: 2} _onFadeInRequest: {fileID: 11400000, guid: f8d520fe699782b4184ff72ce5200c25, type: 2}
_onFadeOutRequest: {fileID: 11400000, guid: a17901d6793dcf2409e2672ffb383208, type: 2} _onFadeOutRequest: {fileID: 11400000, guid: a17901d6793dcf2409e2672ffb383208, type: 2}
_onSceneWorldStateRestored: {fileID: 0}
_sceneLoader: {fileID: 1100545357} _sceneLoader: {fileID: 1100545357}
_roomFadeDuration: 0.05 _roomFadeDuration: 0.05
_sceneFadeDuration: 0.4 _sceneFadeDuration: 0.4
@@ -2488,8 +2492,7 @@ MonoBehaviour:
_springContainer: {fileID: 0} _springContainer: {fileID: 0}
_springIconPrefab: {fileID: 0} _springIconPrefab: {fileID: 0}
_formIcons: [] _formIcons: []
_interactText: {fileID: 0} _interactPromptWidget: {fileID: 0}
_interactPromptRoot: {fileID: 0}
_onHPChanged: {fileID: 0} _onHPChanged: {fileID: 0}
_onMaxHPChanged: {fileID: 0} _onMaxHPChanged: {fileID: 0}
_onSoulPowerChanged: {fileID: 0} _onSoulPowerChanged: {fileID: 0}
@@ -2497,8 +2500,6 @@ MonoBehaviour:
_onLingZhuChanged: {fileID: 0} _onLingZhuChanged: {fileID: 0}
_onSpringChargesChanged: {fileID: 0} _onSpringChargesChanged: {fileID: 0}
_onFormChanged: {fileID: 0} _onFormChanged: {fileID: 0}
_onShowInteractPrompt: {fileID: 0}
_onHideInteractPrompt: {fileID: 0}
--- !u!1 &1657595859 --- !u!1 &1657595859
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0

View File

@@ -12884,6 +12884,128 @@ MonoBehaviour:
BarrelClipping: 0.25 BarrelClipping: 0.25
Anamorphism: 0 Anamorphism: 0
BlendHint: 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 --- !u!1 &787854959
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -13667,6 +13789,106 @@ Transform:
m_Children: [] m_Children: []
m_Father: {fileID: 2015427670} m_Father: {fileID: 2015427670}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} 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 --- !u!1 &837279661
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -15429,6 +15651,63 @@ Transform:
m_Children: [] m_Children: []
m_Father: {fileID: 1319376623} m_Father: {fileID: 1319376623}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} 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 --- !u!1 &926028880
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -24217,7 +24496,7 @@ GameObject:
m_Icon: {fileID: 0} m_Icon: {fileID: 0}
m_NavMeshLayer: 0 m_NavMeshLayer: 0
m_StaticEditorFlags: 0 m_StaticEditorFlags: 0
m_IsActive: 0 m_IsActive: 1
--- !u!61 &1354690326 --- !u!61 &1354690326
BoxCollider2D: BoxCollider2D:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -208573,6 +208852,39 @@ Transform:
m_Children: [] m_Children: []
m_Father: {fileID: 1521123847} m_Father: {fileID: 1521123847}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} 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 --- !u!1 &1651018827
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -210305,6 +210617,88 @@ PolygonCollider2D:
- {x: -13.5, y: 2.5} - {x: -13.5, y: 2.5}
- {x: -13.5, y: -9.5} - {x: -13.5, y: -9.5}
m_UseDelaunayMesh: 0 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 --- !u!1 &1766894770
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -211346,6 +211740,74 @@ MonoBehaviour:
_noiseFrequency: 1 _noiseFrequency: 1
_dedicatedCamera: {fileID: 873533458} _dedicatedCamera: {fileID: 873533458}
_dedicatedPriority: 20 _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 --- !u!1 &1836019285
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -211554,6 +212016,331 @@ MonoBehaviour:
_noiseFrequency: 1 _noiseFrequency: 1
_dedicatedCamera: {fileID: 2038960454} _dedicatedCamera: {fileID: 2038960454}
_dedicatedPriority: 20 _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 --- !u!1 &1865796628
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -216366,3 +217153,4 @@ SceneRoots:
- {fileID: 1865796631} - {fileID: 1865796631}
- {fileID: 1354690328} - {fileID: 1354690328}
- {fileID: 783576435} - {fileID: 783576435}
- {fileID: 1864792379}

View File

@@ -1,13 +1,33 @@
using System.Collections.Generic;
using System.Text; using System.Text;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
using BaseGames.Enemies.Perception; using BaseGames.Enemies.Perception;
using SlotType = BaseGames.Enemies.Perception.PhysicsPerceptionSystem.SlotType;
namespace BaseGames.Editor namespace BaseGames.Editor
{ {
[CustomEditor(typeof(PhysicsPerceptionSystem))] [CustomEditor(typeof(PhysicsPerceptionSystem))]
public sealed class PhysicsPerceptionSystemEditor : UnityEditor.Editor public sealed class PhysicsPerceptionSystemEditor : UnityEditor.Editor
{ {
// ── Inspector state ───────────────────────────────────────────────────
private SerializedProperty _slotsProp;
private readonly List<bool> _foldouts = new List<bool>();
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 ────────────────────────────────────────────────── // ── Scene 视图 Gizmo ──────────────────────────────────────────────────
[DrawGizmo(GizmoType.NonSelected | GizmoType.Selected | GizmoType.InSelectionHierarchy)] [DrawGizmo(GizmoType.NonSelected | GizmoType.Selected | GizmoType.InSelectionHierarchy)]
@@ -25,15 +45,14 @@ namespace BaseGames.Editor
if (string.IsNullOrEmpty(slot.slotName)) continue; if (string.IsNullOrEmpty(slot.slotName)) continue;
Color baseColor = ResolveGizmoColor(slot.gizmoColor); Color baseColor = ResolveGizmoColor(slot.gizmoColor);
Color fill = baseColor; fill.a = isSelected ? 0.12f : 0.04f; Color fill = new Color(baseColor.r, baseColor.g, baseColor.b, isSelected ? 0.12f : 0.04f);
Color outline = baseColor; outline.a = isSelected ? 0.90f : 0.40f; 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); Vector3 slotCenter = rootPos + new Vector3(slot.offset.x * facingSign, slot.offset.y, 0f);
switch (slot.type) switch (slot.type)
{ {
case PhysicsPerceptionSystem.SlotType.RangeCircle: case SlotType.RangeCircle:
if (slot.radius <= 0f) break; if (slot.radius <= 0f) break;
Handles.color = fill; Handles.color = fill;
Handles.DrawSolidDisc(slotCenter, Vector3.forward, slot.radius); Handles.DrawSolidDisc(slotCenter, Vector3.forward, slot.radius);
@@ -41,7 +60,7 @@ namespace BaseGames.Editor
Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius); Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius);
break; break;
case PhysicsPerceptionSystem.SlotType.FanCast: case SlotType.FanCast:
if (slot.radius > 0f && slot.fanAngle > 0f) if (slot.radius > 0f && slot.fanAngle > 0f)
{ {
DrawFanGizmo(slotCenter, facingSign, slot, fill, outline); DrawFanGizmo(slotCenter, facingSign, slot, fill, outline);
@@ -49,7 +68,7 @@ namespace BaseGames.Editor
} }
break; break;
case PhysicsPerceptionSystem.SlotType.BoxCast: case SlotType.BoxCast:
if (slot.boxSize.x > 0f && slot.boxSize.y > 0f) if (slot.boxSize.x > 0f && slot.boxSize.y > 0f)
{ {
DrawBoxGizmo(slotCenter, facingSign, slot, fill, outline); DrawBoxGizmo(slotCenter, facingSign, slot, fill, outline);
@@ -57,17 +76,27 @@ namespace BaseGames.Editor
} }
break; break;
// BatchLOS: eye-position marker + optional range disc + runtime ray case SlotType.BatchLOS:
case PhysicsPerceptionSystem.SlotType.BatchLOS:
DrawBatchLOSGizmo(system, slotCenter, slot, outline, isSelected); DrawBatchLOSGizmo(system, slotCenter, slot, outline, isSelected);
break; 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; Handles.color = Color.white;
} }
/// <summary>在 Slot 原点处画一个小十字点,帮助可视化 offset 配置。</summary>
static void DrawOriginDot(Vector3 pos, Color color) static void DrawOriginDot(Vector3 pos, Color color)
{ {
Handles.color = color; Handles.color = color;
@@ -75,80 +104,217 @@ namespace BaseGames.Editor
} }
/// <summary> /// <summary>
/// 仅当颜色字段为 Unity 默认值 Color(0,0,0,0)(全零 = 未配置)时, /// Color(0,0,0,0) 是未配置的默认值,返回紫色回退;用户设置的颜色原样保留。
/// 返回易辨识的紫色回退alpha=1用户明确设置的任何颜色包括近黑色均原样保留。
/// </summary> /// </summary>
static Color ResolveGizmoColor(Color 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;
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 void DrawBatchLOSGizmo(PhysicsPerceptionSystem system, Vector3 slotCenter, static void DrawBatchLOSGizmo(PhysicsPerceptionSystem system, Vector3 slotCenter,
PhysicsPerceptionSystem.PerceptionSlot slot, Color slotColor, bool isSelected) 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; float facingSign = system.transform.localScale.x < 0f ? -1f : 1f;
// ── 最大检测范围圆slot.radius > 0 时)──
if (slot.radius > 0f) 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.color = fill;
Handles.DrawSolidDisc(slotCenter, Vector3.forward, slot.radius); 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.color = rim;
Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius); Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius);
} }
// ── 眼睛中心圆点 ──
Handles.color = slotColor; Handles.color = slotColor;
Handles.DrawSolidDisc(slotCenter, Vector3.forward, 0.08f); Handles.DrawSolidDisc(slotCenter, Vector3.forward, 0.08f);
// ── 朝向指示箭头(沿 facingSign 方向,明确"方向性"视线感知)──
float arrowLen = slot.radius > 0f ? Mathf.Min(slot.radius * 0.6f, 0.6f) : 0.4f; float arrowLen = slot.radius > 0f ? Mathf.Min(slot.radius * 0.6f, 0.6f) : 0.4f;
Vector3 fwdArrow = new Vector3(facingSign * arrowLen, 0f, 0f); Vector3 fwdArrow = new Vector3(facingSign * arrowLen, 0f, 0f);
Handles.DrawLine(slotCenter, slotCenter + fwdArrow); Handles.DrawLine(slotCenter, slotCenter + fwdArrow);
Vector3 tip = slotCenter + fwdArrow;
Vector3 arrowTip = slotCenter + fwdArrow;
float headLen = 0.08f; float headLen = 0.08f;
Handles.DrawLine(arrowTip, arrowTip + new Vector3(-facingSign * headLen, headLen, 0f)); Handles.DrawLine(tip, tip + new Vector3(-facingSign * headLen, headLen, 0f));
Handles.DrawLine(arrowTip, arrowTip + new Vector3(-facingSign * headLen, -headLen, 0f)); Handles.DrawLine(tip, tip + new Vector3(-facingSign * headLen, -headLen, 0f));
// ── 放射线(仅选中时显示,避免杂乱)──
if (isSelected) if (isSelected)
{ {
float innerR = 0.12f; float innerR = 0.12f;
float outerR = slot.radius > 0f ? Mathf.Min(slot.radius, 0.30f) : 0.26f; float outerR = slot.radius > 0f ? Mathf.Min(slot.radius, 0.30f) : 0.26f;
for (int i = 0; i < 8; i++) for (int i = 0; i < 8; i++)
{ {
float deg = i * 45f; float r = i * 45f * Mathf.Deg2Rad;
float rad = deg * Mathf.Deg2Rad; var d = new Vector3(Mathf.Cos(r), Mathf.Sin(r), 0f);
Vector3 dir = new Vector3(Mathf.Cos(rad), Mathf.Sin(rad), 0f); Handles.DrawLine(slotCenter + d * innerR, slotCenter + d * outerR);
Handles.DrawLine(slotCenter + dir * innerR, slotCenter + dir * outerR);
} }
} }
// ── 运行时:视线连线;绿 = 可见 / 红 = 遮挡 ──
if (!Application.isPlaying) return; if (!Application.isPlaying) return;
var owner = system.EditorOwner ?? system.GetComponentInParent<BaseGames.Enemies.EnemyBase>(); var owner = system.EditorOwner ?? system.GetComponentInParent<BaseGames.Enemies.EnemyBase>();
if (owner == null || owner.PlayerTransform == null) return; if (owner == null || owner.PlayerTransform == null) return;
bool visible = owner.IsPlayerVisible(); bool visible = owner.IsPlayerVisible();
Color rayCol = visible Color rayCol = visible ? new Color(0.2f, 1.0f, 0.3f, 0.9f) : new Color(1.0f, 0.3f, 0.3f, 0.45f);
? new Color(0.2f, 1.0f, 0.3f, 0.9f)
: new Color(1.0f, 0.3f, 0.3f, 0.45f);
Handles.color = rayCol; Handles.color = rayCol;
Handles.DrawDottedLine(slotCenter, owner.PlayerTransform.position, 3f); Handles.DrawDottedLine(slotCenter, owner.PlayerTransform.position, 3f);
if (visible) if (visible)
Handles.DrawSolidDisc(owner.PlayerTransform.position, Vector3.forward, 0.09f); Handles.DrawSolidDisc(owner.PlayerTransform.position, Vector3.forward, 0.09f);
} }
/// <summary>
/// Sight 槽位 Gizmo视野锥形或全向圆形+ 眼点 + LOS 能力提示射线 + 运行时检测连线。
/// </summary>
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, static void DrawFanGizmo(Vector3 center, float facingSign,
PhysicsPerceptionSystem.PerceptionSlot slot, Color fill, Color outline) PhysicsPerceptionSystem.PerceptionSlot slot, Color fill, Color outline)
{ {
@@ -160,7 +326,6 @@ namespace BaseGames.Editor
Handles.color = outline; Handles.color = outline;
Handles.DrawWireArc(center, Vector3.forward, fromDir, slot.fanAngle, slot.radius); 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 edgeL = RotateVec3(new Vector3(facingSign, 0f, 0f), -halfAngle) * slot.radius;
Vector3 edgeR = 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); Handles.DrawLine(center, center + edgeL);
@@ -173,7 +338,6 @@ namespace BaseGames.Editor
Vector3 boxCenter = center + new Vector3(slot.boxOffset.x * facingSign, slot.boxOffset.y, 0f); Vector3 boxCenter = center + new Vector3(slot.boxOffset.x * facingSign, slot.boxOffset.y, 0f);
float hw = slot.boxSize.x * 0.5f; float hw = slot.boxSize.x * 0.5f;
float hh = slot.boxSize.y * 0.5f; float hh = slot.boxSize.y * 0.5f;
Vector3[] corners = Vector3[] corners =
{ {
boxCenter + new Vector3(-hw, -hh, 0f), 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),
boxCenter + new Vector3(-hw, hh, 0f), boxCenter + new Vector3(-hw, hh, 0f),
}; };
Handles.DrawSolidRectangleWithOutline(corners, fill, outline); Handles.DrawSolidRectangleWithOutline(corners, fill, outline);
} }
static Vector3 RotateVec3(Vector3 v, float angleDeg) static Vector3 RotateVec3(Vector3 v, float angleDeg)
{ {
float rad = angleDeg * Mathf.Deg2Rad; float rad = angleDeg * Mathf.Deg2Rad;
float cos = Mathf.Cos(rad); float cos = Mathf.Cos(rad); float sin = Mathf.Sin(rad);
float sin = Mathf.Sin(rad);
return new Vector3(cos * v.x - sin * v.y, sin * v.x + cos * v.y, 0f); return new Vector3(cos * v.x - sin * v.y, sin * v.x + cos * v.y, 0f);
} }
// ── Inspector ───────────────────────────────────────────────────────── // ── Custom Inspector ──────────────────────────────────────────────────
public override void OnInspectorGUI() public override void OnInspectorGUI()
{
serializedObject.Update();
SyncFoldouts();
if (_slotsProp == null)
{ {
DrawDefaultInspector(); 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; if (!Application.isPlaying) return;
var system = (PhysicsPerceptionSystem)target; var system = (PhysicsPerceptionSystem)target;
var detected = system.EditorDetected; var detected = system.EditorDetected;
if (detected == null || detected.Count == 0) return; if (detected == null || detected.Count == 0) return;
EditorGUILayout.Space(); EditorGUILayout.Space();
EditorGUILayout.LabelField("── 实时检测结果 ──", EditorStyles.boldLabel); EditorGUILayout.LabelField("── 实时检测结果 ──", EditorStyles.boldLabel);
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (var kvp in detected) foreach (var kvp in detected)
{ {
@@ -220,8 +477,195 @@ namespace BaseGames.Editor
} }
EditorGUILayout.LabelField(kvp.Key, kvp.Value.Count > 0 ? sb.ToString() : "—"); EditorGUILayout.LabelField(kvp.Key, kvp.Value.Count > 0 ? sb.ToString() : "—");
} }
Repaint(); Repaint();
} }
/// <summary>
/// 按槽位类型条件渲染字段——每个 SlotType 只显示与其相关的参数。
/// </summary>
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.positionX 随朝向自动翻转"));
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 视图,不影响检测精度(建议 39"));
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("最低可见度 (01)", "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 时生效,建议 39"));
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<PerceptionTriggerProxy>(true);
var matchList = new List<PerceptionTriggerProxy>();
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<Collider2D>();
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<CircleCollider2D>();
circle.isTrigger = true;
circle.radius = 1.5f;
var proxy = go.AddComponent<PerceptionTriggerProxy>();
proxy.slotName = slotName;
Selection.activeGameObject = go;
EditorGUIUtility.PingObject(go);
}
EditorGUILayout.EndHorizontal();
}
} }
} }

View File

@@ -1240,12 +1240,22 @@ namespace BaseGames.Editor
case "los": enumIdx = 1; radius = 0f; layer = 0; break; case "los": enumIdx = 1; radius = 0f; layer = 0; break;
case "attack_melee":enumIdx = 0; radius = 1.5f; layer = playerLayer; break; case "attack_melee":enumIdx = 0; radius = 1.5f; layer = playerLayer; break;
case "attack_range":enumIdx = 0; radius = 8f; 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("type").enumValueIndex = enumIdx;
elem.FindPropertyRelative("radius").floatValue = radius; elem.FindPropertyRelative("radius").floatValue = radius;
elem.FindPropertyRelative("detectLayer").intValue = layer; elem.FindPropertyRelative("detectLayer").intValue = layer;
// sight 槽位默认设置推荐的 LOS 采样点数3中心+上+下)
if (name == "sight")
{
var losRayCountProp = elem.FindPropertyRelative("losRayCount");
if (losRayCountProp != null) losRayCountProp.intValue = 3;
}
// 各 slot 分配语义化默认颜色,可在 Inspector 中按需覆盖 // 各 slot 分配语义化默认颜色,可在 Inspector 中按需覆盖
Color defaultColor = name switch Color defaultColor = name switch
{ {
@@ -1253,6 +1263,9 @@ namespace BaseGames.Editor
"los" => new Color(0.00f, 0.80f, 1.00f, 1f), // 青 "los" => new Color(0.00f, 0.80f, 1.00f, 1f), // 青
"attack_melee" => new Color(1.00f, 0.20f, 0.20f, 1f), // 红 "attack_melee" => new Color(1.00f, 0.20f, 0.20f, 1f), // 红
"attack_range" => new Color(1.00f, 0.40f, 0.60f, 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 回退为紫色 _ => Color.clear, // 未知 slot 回退为紫色
}; };
elem.FindPropertyRelative("gizmoColor").colorValue = defaultColor; elem.FindPropertyRelative("gizmoColor").colorValue = defaultColor;

View File

@@ -7,7 +7,7 @@ namespace BaseGames.Enemies.AI
{ {
/// <summary> /// <summary>
/// BD Conditional玩家是否可见LOS 检测)。 /// BD Conditional玩家是否可见LOS 检测)。
/// 读取 EnemyBase._losResult 字段(由 BatchLOSSystem 每帧写入,或降级 3 帧节流 Raycast)。 /// 读取 EnemyBase.IsPlayerVisible(),结果来自 PhysicsPerceptionSystemLOS / Sight 槽位)。
/// </summary> /// </summary>
[TaskName("Is Player Visible?")] [TaskName("Is Player Visible?")]
[TaskCategory("BaseGames/Enemy/Perception")] [TaskCategory("BaseGames/Enemy/Perception")]

View File

@@ -1,102 +0,0 @@
using System.Collections.Generic;
using Unity.Collections;
using UnityEngine;
using UnityEngine.Jobs;
using BaseGames.Core;
namespace BaseGames.Enemies.AI
{
/// <summary>
/// 批量视线检测系统(架构 07_EnemyModule §12
/// 每 FixedUpdate
/// 1. 读取上一帧 RaycastHit2D 结果,回调各注册者。
/// 2. 重新构建本帧 RaycastCommand 批次,使用 Physics2D.RaycastAll 同步模式执行。
///
/// ⚠️ Unity 2022.3 中 Physics2D 批量命令RaycastCommand2D尚未稳定
/// 此实现使用 FixedUpdate 内顺序 Raycast2D节流确保零 GC 分配。
/// 当敌人数量 > 20 时建议切换到 Job System RaycastCommand。
/// </summary>
[DefaultExecutionOrder(-200)]
public class BatchLOSSystem : MonoBehaviour
{
[SerializeField, Min(1)] private int _maxRequestersPerFrame = 8;
private void Awake() => ServiceLocator.Register<BatchLOSSystem>(this);
private void OnDestroy() => ServiceLocator.Unregister<BatchLOSSystem>(this);
private readonly List<ILOSRequester> _requesters = new();
private readonly HashSet<ILOSRequester> _requesterSet = new(); // O(1) 包含查询
// _indexMap 记录每个 requester 在 _requesters 中的下标,供 Unregister 实现 O(1) 删除
private readonly Dictionary<ILOSRequester, int> _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);
}
}
}

View File

@@ -18,7 +18,7 @@ namespace BaseGames.Enemies
/// ⚠️ _nav 字段类型为 IPathAgent在 BaseGames.Enemies.Navigation 中实现具体类)。 /// ⚠️ _nav 字段类型为 IPathAgent在 BaseGames.Enemies.Navigation 中实现具体类)。
/// 实现 IPoolable配合 PooledObject 支持对象池复用,避免频繁 Destroy/Instantiate。 /// 实现 IPoolable配合 PooledObject 支持对象池复用,避免频繁 Destroy/Instantiate。
/// </summary> /// </summary>
public class EnemyBase : MonoBehaviour, IDamageable, ILOSRequester, IPoolable public class EnemyBase : MonoBehaviour, IDamageable, IPoolable
{ {
[Header("标识")] [Header("标识")]
[SerializeField] private string _enemyId; // 任务系统 / Boss 进程追踪用,如 "Enemy_SpiderGuard" [SerializeField] private string _enemyId; // 任务系统 / Boss 进程追踪用,如 "Enemy_SpiderGuard"
@@ -286,11 +286,14 @@ namespace BaseGames.Enemies
public virtual bool IsPlayerInRange(float range) public virtual bool IsPlayerInRange(float range)
=> _stats != null && _stats.SqrDistanceToPlayer <= range * range; => _stats != null && _stats.SqrDistanceToPlayer <= range * range;
/// <summary>原始视线检测结果(BatchLOSSystem 写入,无感知延迟修正)。</summary> /// <summary>视线检测结果(由 PhysicsPerceptionSystem 的 LOS / Sight slot 提供)。</summary>
public bool HasLineOfSight => _losResult; public bool HasLineOfSight =>
_sensorHub != null &&
(_sensorHub.HasAnyDetection(Perception.SensorSlotNames.LOS) ||
_sensorHub.HasAnyDetection(Perception.SensorSlotNames.Sight));
public virtual bool IsPlayerVisible() public virtual bool IsPlayerVisible()
=> _threatAssessor != null ? _threatAssessor.IsThreatDetected : _losResult; => _threatAssessor != null ? _threatAssessor.IsThreatDetected : HasLineOfSight;
public virtual void FacePlayer() public virtual void FacePlayer()
{ {
@@ -644,31 +647,16 @@ namespace BaseGames.Enemies
protected virtual void OnEnable() protected virtual void OnEnable()
{ {
_onPlayerSpawned?.Subscribe(SetPlayerTransform).AddTo(_subs); _onPlayerSpawned?.Subscribe(SetPlayerTransform).AddTo(_subs);
Core.ServiceLocator.GetOrDefault<AI.BatchLOSSystem>()?.Register(this);
} }
protected virtual void OnDisable() protected virtual void OnDisable()
{ {
Core.ServiceLocator.GetOrDefault<AI.BatchLOSSystem>()?.Unregister(this);
_subs.Clear(); _subs.Clear();
} }
protected virtual void OnDestroy() { } 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 保护,避免空引用) // BehaviorTree 引用(#if GRAPH_DESIGNER 保护,避免空引用)
#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.color = new Color(1f, 0.9f, 0.2f, 0.85f);
Gizmos.DrawWireSphere(eyeWorld, 0.07f); 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(1f, 0.5f, 0f, 0.85f)
: new Color(0.6f, 0.6f, 0.6f, 0.25f); : new Color(0.6f, 0.6f, 0.6f, 0.25f);
Gizmos.DrawLine(eyeWorld, playerPos); Gizmos.DrawLine(eyeWorld, playerPos);

View File

@@ -64,7 +64,7 @@ namespace BaseGames.Enemies
[Tooltip("Stagger / KnockUp 伤害阈值及击飞参数")] [Tooltip("Stagger / KnockUp 伤害阈值及击飞参数")]
public HitTierConfig HitTiers; public HitTierConfig HitTiers;
[Header("视线检测(BatchLOSSystem")] [Header("视线检测(遗留配置,已迁移到 PhysicsPerceptionSystem 各 Slot 的 losBlockMask")]
[Tooltip("相对 transform.position 的眼睛偏移量")] [Tooltip("相对 transform.position 的眼睛偏移量")]
public Vector2 EyeOffset = new Vector2(0f, 0.8f); public Vector2 EyeOffset = new Vector2(0f, 0.8f);
[Tooltip("遮挡 LOS 的物理图层")] [Tooltip("遮挡 LOS 的物理图层")]

View File

@@ -1,27 +0,0 @@
using UnityEngine;
namespace BaseGames.Enemies
{
/// <summary>
/// BatchLOSSystem 的注册接口(架构 07_EnemyModule §12
/// 实现此接口的 EnemyBase 子类可以注册到 BatchLOSSystem
/// 以批处理方式接收 LOSLine of Sight检测结果。
/// </summary>
public interface ILOSRequester
{
/// <summary>射线起点(通常是眼部位置)。</summary>
Vector2 LOSOrigin { get; }
/// <summary>射线终点(通常是玩家位置)。</summary>
Vector2 LOSTarget { get; }
/// <summary>遮挡 LOS 的物理图层。</summary>
LayerMask LOSBlockingMask { get; }
/// <summary>
/// 接收 LOS 检测结果。
/// <paramref name="hasLineOfSight"/>: true = 有视线false = 被遮挡。
/// </summary>
void ReceiveLOSResult(bool hasLineOfSight);
}
}

View File

@@ -7,7 +7,7 @@ namespace BaseGames.Enemies.Perception
/// ///
/// 工作原理: /// 工作原理:
/// <list type="bullet"> /// <list type="bullet">
/// <item>读取 <see cref="EnemyBase.HasLineOfSight"/>原始 LOSBatchLOSSystem 写入)。</item> /// <item>读取 <see cref="EnemyBase.HasLineOfSight"/>由 PhysicsPerceptionSystem 的 LOS/Sight 槽位驱动)。</item>
/// <item>连续感知到玩家超过 <see cref="reactionDelay"/> 秒后,<see cref="IsThreatDetected"/> 才变为 true。</item> /// <item>连续感知到玩家超过 <see cref="reactionDelay"/> 秒后,<see cref="IsThreatDetected"/> 才变为 true。</item>
/// <item>一旦丢失 LOS<see cref="IsThreatDetected"/> 立即重置为 false保持对"躲起来"的快速响应)。</item> /// <item>一旦丢失 LOS<see cref="IsThreatDetected"/> 立即重置为 false保持对"躲起来"的快速响应)。</item>
/// </list> /// </list>

View File

@@ -0,0 +1,57 @@
using UnityEngine;
namespace BaseGames.Enemies.Perception
{
/// <summary>
/// 挂载在 PhysicsPerceptionSystem 子节点上的触发器代理组件。
/// 与 <see cref="PhysicsPerceptionSystem"/> 的 <c>TriggerZone</c> 槽位配合使用:
/// 当物理触发器产生 Enter / Exit 事件时,通知父系统的感知字典,
/// 同时触发 <see cref="PhysicsPerceptionSystem.OnEnterDetection"/> /
/// <see cref="PhysicsPerceptionSystem.OnExitDetection"/> 委托。
///
/// <b>使用步骤:</b>
/// 1. 在 PhysicsPerceptionSystem 的子 GameObject 上添加本组件。
/// 2. 确保同一 GameObject 或子节点上有 Collider2D且 <c>isTrigger = true</c>。
/// 3. 填写 <see cref="slotName"/>(与父系统中同名 TriggerZone 槽位对应)。
/// 4. 设置 <see cref="detectLayer"/>(只有命中该层的碰撞体才会触发通知)。
/// 5. <see cref="ParentSystem"/> 由父系统 Awake() 自动赋值,无需手动操作。
/// </summary>
[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;
/// <summary>由父 PhysicsPerceptionSystem 在 Awake() 中自动注入,无需手动赋值。</summary>
internal PhysicsPerceptionSystem ParentSystem { get; set; }
// ── Unity 生命周期 ────────────────────────────────────────────────────
private void OnValidate()
{
var col = GetComponent<Collider2D>();
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);
}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: a482b11f99a870f4ea28cd36b716a69b guid: e8052de08fa173e479e190f652a1c04d
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@@ -5,15 +5,26 @@ using UnityEngine;
namespace BaseGames.Enemies.Perception namespace BaseGames.Enemies.Perception
{ {
/// <summary> /// <summary>
/// 敌人感知系统(自研纯物理实现)。 /// 敌人感知系统(自研纯物理实现,商业级设计)。
/// 每个 <see cref="PerceptionSlot"/> 独立配置并独立运行,支持种检测模式: /// 每个 <see cref="PerceptionSlot"/> 独立配置并独立运行,支持种检测模式:
/// • RangeCircle — Physics2D.OverlapCircleNonAlloc可选 LOS 视线遮挡校验 /// • <b>RangeCircle</b> — OverlapCircle 纯几何范围检测(无遮挡
/// • BatchLOS — 委托 EnemyBase.IsPlayerVisible()BatchLOSSystem 批量射线 /// • <b>BatchLOS</b> OverlapCircle + 单次 Raycast 自研批量视线检测(低开销,有帧延迟
/// • FanCast — 以朝向为轴的扇形多射线视野(支持遮挡 /// • <b>FanCast</b> OverlapCircle + 角度过滤扇形视野(纯几何,无遮挡)
/// • BoxCast — 矩形区域重叠检测(X 偏移随 localScale.x 自动翻转 /// • <b>BoxCast</b> OverlapBox 矩形区域检测(纯几何,无遮挡
/// • <b>Sight</b> — OverlapCircle + 可选扇形角度 + 强制多点 LOS 遮挡(专用视线传感器)
/// • <b>RayCast</b> — 单/多根方向射线传感器,支持扩散角和遮挡层
/// • <b>TriggerZone</b> — 物理触发器事件驱动(子节点 PerceptionTriggerProxy零轮询
///
/// <b>遮挡检测设计原则:</b>
/// RangeCircle / FanCast / BoxCast 是纯几何传感器,不做遮挡校验。
/// 需要视线遮挡判断时,使用 <b>Sight</b> 槽位(内置多点 LOS 采样,始终执行遮挡检测)。
/// 单向射线遮挡使用 <b>RayCast</b> 槽位obstructLayer 控制阻断层)。
///
/// <b>性能优化:</b>
/// • <see cref="PerceptionSlot.tickInterval"/> — 每 N 帧刷新一次(错帧执行,分散开销)
/// • <see cref="PerceptionSlot.isDisabled"/> — 运行时动态禁用单个槽位
/// • <see cref="SightBatchSystem"/> — 场景单例,全局控制 Sight 射线预算
/// ///
/// EnemyBase.Awake() 通过 GetComponentInChildren&lt;IPerceptionSystem&gt;()
/// 自动发现本组件,无需修改 EnemyBase。
/// 槽位名称常量统一定义于 <see cref="SensorSlotNames"/>。 /// 槽位名称常量统一定义于 <see cref="SensorSlotNames"/>。
/// </summary> /// </summary>
[DisallowMultipleComponent] [DisallowMultipleComponent]
@@ -23,14 +34,20 @@ namespace BaseGames.Enemies.Perception
public enum SlotType public enum SlotType
{ {
/// <summary>Physics2D 圆形重叠检测</summary> /// <summary>Physics2D.OverlapCircle 纯几何范围检测(无遮挡)</summary>
RangeCircle, RangeCircle,
/// <summary>委托 EnemyBase.IsPlayerVisible()BatchLOSSystem 批量射线视线检测</summary> /// <summary>OverlapCircle + 单条遮挡射线(自包含 LOS 检测,低开销</summary>
BatchLOS, BatchLOS,
/// <summary>以朝向为轴的扇形射线视野,遮挡层阻断视线</summary> /// <summary>OverlapCircle + 角度过滤扇形视野(纯几何,无遮挡)</summary>
FanCast, FanCast,
/// <summary>矩形区域重叠检测X 偏移随 localScale.x 自动翻转</summary> /// <summary>OverlapBox 矩形区域检测X 偏移随 localScale.x 自动翻转(纯几何,无遮挡)</summary>
BoxCast BoxCast,
/// <summary>OverlapCircle + 可选扇形角度过滤 + 强制多点 LOS 遮挡校验(专用视线传感器)</summary>
Sight,
/// <summary>单/多根方向射线检测,支持扩散角和遮挡层(对标 RaySensor2D</summary>
RayCast,
/// <summary>物理触发器事件驱动,依赖子节点 PerceptionTriggerProxy零轮询开销</summary>
TriggerZone,
} }
// ── 槽位定义 ────────────────────────────────────────────────────────── // ── 槽位定义 ──────────────────────────────────────────────────────────
@@ -38,56 +55,119 @@ namespace BaseGames.Enemies.Perception
[Serializable] [Serializable]
public struct PerceptionSlot public struct PerceptionSlot
{ {
[Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\naggro / los / attack_melee / attack_range")] // ── 通用 ───────────────────────────────────────────────────────
[Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\naggro / patrol / alert / los / attack_melee / attack_range / sight")]
public string slotName; public string slotName;
[Tooltip("RangeCirclePhysics2D 圆形范围检测\nBatchLOS视线射线检测BatchLOSSystem\nFanCast以朝向为轴的扇形射线视野\nBoxCast矩形区域重叠检测")] [Tooltip("RangeCircle纯几何圆形范围\nBatchLOSOverlapCircle + 单射线遮挡(自包含 LOS\nFanCast扇形视野纯几何\nBoxCast矩形区域纯几何\nSight视线传感器多射线遮挡\nRayCast方向射线传感器\nTriggerZone触发器事件驱动需子节点 PerceptionTriggerProxy")]
public SlotType type; public SlotType type;
[Min(0f)] [Tooltip("检测原点相对于 transform.position 的偏移(米)。\nX 分量随 localScale.x 朝向自动翻转。")]
[Tooltip("RangeCircle / FanCast检测半径\nBatchLOS最大视线检测距离0 = 不限制)\nBoxCast忽略此值")]
public float radius;
[Tooltip("目标检测层(通常为 Player 层BatchLOS 忽略此值")]
public LayerMask detectLayer;
[Tooltip("RangeCircle / BoxCast基础重叠命中后额外校验视线Physics2D.Raycast\nFanCasttrue = 射线被 losBlockMask 层遮挡false = 穿透所有障碍物")]
public bool requireLOS;
[Tooltip("requireLOS = true / FanCastrequireLOS = true视线遮挡检测层通常为 Platform + Wall\nFanCast 射线只在 requireLOS = true 时被此层遮挡")]
public LayerMask losBlockMask;
[Header("Origin")]
[Tooltip("检测原点相对于 transform.position 的偏移(米)。\nX 分量随 localScale.x 朝向自动翻转BatchLOS 仅影响 Gizmo不影响实际射线。")]
public Vector2 offset; public Vector2 offset;
[Header("FanCast")] [Tooltip("Scene 视图 Gizmo 颜色。留空(全透明黑色)时自动使用紫色回退。")]
[Tooltip("FanCast扇形张角以朝向为中轴左右均匀展开")] 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; public float fanAngle;
[Tooltip("FanCast扇形内均匀分布的射线数量建议 511 条)")]
[Min(2)] [Min(2)]
[Tooltip("FanCast 专用Scene 视图 Gizmo 扇形分隔线数量(不影响检测,建议 39")]
public int fanRayCount; public int fanRayCount;
[Header("BoxCast")] // ── BoxCast ────────────────────────────────────────────────────
[Tooltip("BoxCast检测框尺寸 (宽, 高),单位米")] [Tooltip("BoxCast检测框尺寸 (宽, 高),单位米")]
public Vector2 boxSize; public Vector2 boxSize;
[Tooltip("BoxCast相对于感知中心的偏移X 分量随 localScale.x 自动翻转")] [Tooltip("BoxCast相对于感知中心的偏移X 分量随 localScale.x 自动翻转")]
public Vector2 boxOffset; public Vector2 boxOffset;
[Header("Gizmos")] // ── Sight LOS仅 Sight 类型使用)────────────────────────────
[Tooltip("Scene 视图 Gizmo 颜色。留空(全透明黑色)时自动使用紫色回退,以确保可见。")]
public Color gizmoColor; [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 专用] 01\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;
} }
// ── 事件 ─────────────────────────────────────────────────────────────
/// <summary>某槽位首次检测到目标时触发。参数:(slotName, target)。</summary>
public event Action<string, GameObject> OnEnterDetection;
/// <summary>某槽位失去对目标的检测时触发。参数:(slotName, target)。</summary>
public event Action<string, GameObject> OnExitDetection;
// ── 字段 ────────────────────────────────────────────────────────────── // ── 字段 ──────────────────────────────────────────────────────────────
[SerializeField] private PerceptionSlot[] _slots; [SerializeField] private PerceptionSlot[] _slots;
// 当前帧检测结果
private readonly Dictionary<string, List<GameObject>> _detected = private readonly Dictionary<string, List<GameObject>> _detected =
new Dictionary<string, List<GameObject>>(); new Dictionary<string, List<GameObject>>();
// 上帧检测结果Enter/Exit diff 用HashSet 提供 O(1) Contains
private readonly Dictionary<string, HashSet<GameObject>> _prevDetected =
new Dictionary<string, HashSet<GameObject>>();
// 实例缓冲区(单线程 FixedUpdate 安全)
private readonly Collider2D[] _overlapBuffer = new Collider2D[32]; 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<GameObject> _enterBuf = new List<GameObject>(8);
private static readonly List<GameObject> _exitBuf = new List<GameObject>(8);
private int _fixedTick;
private bool _suspended; private bool _suspended;
private EnemyBase _owner; private EnemyBase _owner;
@@ -96,59 +176,163 @@ namespace BaseGames.Enemies.Perception
private void Awake() private void Awake()
{ {
_owner = GetComponentInParent<EnemyBase>(); _owner = GetComponentInParent<EnemyBase>();
if (_slots == null) return;
foreach (var slot in _slots) if (_slots != null)
{ {
if (!string.IsNullOrEmpty(slot.slotName) && !_detected.ContainsKey(slot.slotName)) foreach (var slot in _slots)
_detected[slot.slotName] = new List<GameObject>(4); if (!string.IsNullOrEmpty(slot.slotName))
EnsureSlotDict(slot.slotName);
} }
// TriggerZone发现所有子节点代理并绑定
var proxies = GetComponentsInChildren<PerceptionTriggerProxy>(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<PerceptionTriggerProxy>(true);
foreach (var proxy in proxies)
if (proxy.ParentSystem == this) proxy.ParentSystem = null;
} }
private void FixedUpdate() private void FixedUpdate()
{ {
if (_suspended || _slots == null) return; if (_suspended || _slots == null) return;
foreach (var slot in _slots) _fixedTick++;
RefreshSlot(slot);
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<GameObject>(4);
_prevDetected[name] = new HashSet<GameObject>();
}
} }
// ── 内部检测逻辑 ────────────────────────────────────────────────────── // ── 内部检测逻辑 ──────────────────────────────────────────────────────
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; 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(); list.Clear();
switch (slot.type) switch (slot.type)
{ {
case SlotType.BatchLOS: case SlotType.BatchLOS: RefreshBatchLOS(ref slot, list); break;
if (_owner != null && _owner.IsPlayerVisible() && _owner.PlayerTransform != null) 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 (slot.radius > 0f) 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)
{ {
float dist = Vector2.Distance( bool stillDetected = false;
(Vector2)transform.position, (Vector2)_owner.PlayerTransform.position); for (int i = 0; i < curr.Count; i++)
if (dist > slot.radius) break; if (curr[i] == go) { stillDetected = true; break; }
if (!stillDetected) _exitBuf.Add(go);
} }
list.Add(_owner.PlayerTransform.gameObject);
foreach (var go in _enterBuf) OnEnterDetection?.Invoke(slotName, go);
foreach (var go in _exitBuf) OnExitDetection?.Invoke(slotName, go);
} }
break;
case SlotType.RangeCircle: private void RefreshBatchLOS(ref PerceptionSlot slot, List<GameObject> list)
RefreshRangeCircle(slot, list); {
break; if (slot.radius <= 0f || slot.detectLayer == 0) return;
case SlotType.FanCast: float facingSign = transform.localScale.x < 0f ? -1f : 1f;
RefreshFanCast(slot, list); Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y);
break;
case SlotType.BoxCast: int count = Physics2D.OverlapCircleNonAlloc(origin, slot.radius, _overlapBuffer, slot.detectLayer);
RefreshBoxCast(slot, list); for (int i = 0; i < count; i++)
break; {
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)
{
var hit = Physics2D.Raycast(origin, dir / dist, dist, slot.losBlockMask);
if (hit.collider != null) continue; // 被遮挡
} }
} }
private void RefreshRangeCircle(in PerceptionSlot slot, System.Collections.Generic.List<GameObject> list) list.Add(col.gameObject);
}
}
private void RefreshRangeCircle(ref PerceptionSlot slot, List<GameObject> list)
{ {
if (slot.radius <= 0f || slot.detectLayer == 0) return; if (slot.radius <= 0f || slot.detectLayer == 0) return;
float facingSign = transform.localScale.x < 0f ? -1f : 1f; float facingSign = transform.localScale.x < 0f ? -1f : 1f;
@@ -157,40 +341,30 @@ namespace BaseGames.Enemies.Perception
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var col = _overlapBuffer[i]; var col = _overlapBuffer[i];
if (col == null) continue; if (col != null) list.Add(col.gameObject);
if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue;
list.Add(col.gameObject);
} }
} }
private void RefreshFanCast(in PerceptionSlot slot, System.Collections.Generic.List<GameObject> list) private void RefreshFanCast(ref PerceptionSlot slot, List<GameObject> list)
{ {
if (slot.radius <= 0f || slot.detectLayer == 0 || slot.fanAngle <= 0f) return; if (slot.radius <= 0f || slot.detectLayer == 0 || slot.fanAngle <= 0f) return;
float facingSign = transform.localScale.x < 0f ? -1f : 1f; 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); 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; 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); var col = _overlapBuffer[i];
Vector2 dir = RotateVector(forward, Mathf.Lerp(-halfAngle, halfAngle, t)); if (col == null) continue;
RaycastHit2D hit = Physics2D.Raycast(origin, dir, slot.radius, castMask); if (Vector2.Angle(forward, (Vector2)col.bounds.center - origin) > halfAngle) continue;
if (hit.collider == null) continue; if (!list.Contains(col.gameObject)) list.Add(col.gameObject);
if (((1 << hit.collider.gameObject.layer) & (int)slot.detectLayer) == 0) continue;
var go = hit.collider.gameObject;
if (!list.Contains(go)) list.Add(go);
} }
} }
private void RefreshBoxCast(in PerceptionSlot slot, System.Collections.Generic.List<GameObject> list) private void RefreshBoxCast(ref PerceptionSlot slot, List<GameObject> list)
{ {
if (slot.boxSize.x <= 0f || slot.boxSize.y <= 0f || slot.detectLayer == 0) return; if (slot.boxSize.x <= 0f || slot.boxSize.y <= 0f || slot.detectLayer == 0) return;
@@ -200,35 +374,185 @@ namespace BaseGames.Enemies.Perception
int count = Physics2D.OverlapBoxNonAlloc(center, slot.boxSize, 0f, _overlapBuffer, slot.detectLayer); int count = Physics2D.OverlapBoxNonAlloc(center, slot.boxSize, 0f, _overlapBuffer, slot.detectLayer);
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{
var col = _overlapBuffer[i];
if (col != null) list.Add(col.gameObject);
}
}
/// <summary>
/// 视线传感器OverlapCircle + 可选扇形角度过滤 + 强制多点 LOS 遮挡校验。
/// 唯一内置遮挡检测的轮询槽位类型。
/// </summary>
private void RefreshSight(ref PerceptionSlot slot, List<GameObject> 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);
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]; var col = _overlapBuffer[i];
if (col == null) continue; if (col == null) continue;
if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue; if (limitAngle && Vector2.Angle(forward, (Vector2)col.bounds.center - origin) > halfAngle) continue;
list.Add(col.gameObject); 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 bool HasLineOfSight(Vector2 origin, GameObject target, LayerMask blockMask) /// <summary>
/// 方向射线传感器:单/多根射线,支持扩散角和遮挡阻断。
/// 射线方向在本地空间定义X 分量随朝向自动翻转。
/// </summary>
private void RefreshRayCast(ref PerceptionSlot slot, List<GameObject> list)
{ {
// 优先使用碰撞体包围盒中心(比 transform.position 更准确,避免偏心轴误判) if (slot.rayLength <= 0f || slot.detectLayer == 0) return;
Vector2 targetPos;
var col = target.GetComponent<Collider2D>();
targetPos = col != null ? (Vector2)col.bounds.center : (Vector2)target.transform.position;
var dir = targetPos - origin; float facingSign = transform.localScale.x < 0f ? -1f : 1f;
float dist = dir.magnitude; Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y);
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;
}
private static Vector2 RotateVector(Vector2 v, float angleDeg) // 射线基准方向本地空间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++)
{ {
float rad = angleDeg * Mathf.Deg2Rad; 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 cos = Mathf.Cos(rad);
float sin = Mathf.Sin(rad); float sin = Mathf.Sin(rad);
return new Vector2(cos * v.x - sin * v.y, sin * v.x + cos * v.y); 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─────────────────────────────
/// <summary>
/// 由 <see cref="SightBatchSystem"/> 调用,执行本系统所有 Sight 槽位的刷新。
/// 批量系统负责频率控制,不参与 tickInterval 逻辑。
/// </summary>
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 工具 ──────────────────────────────────────────────────────────
/// <summary>
/// 多点采样 LOS 遮挡检测。
/// <para><paramref name="blockMask"/> = 0 时始终返回 true无遮挡层配置 = 视线始终通过)。</para>
/// </summary>
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<Collider2D>();
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;
}
/// <summary>
/// 将目标包围盒的 LOS 采样点写入 <see cref="_losPointsBuf"/>80% 缩进避免边缘误判)。
/// </summary>
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 ───────────────────────────────────────────────── // ── IPerceptionSystem ─────────────────────────────────────────────────
@@ -236,7 +560,6 @@ namespace BaseGames.Enemies.Perception
public bool HasSlot(string slotName) public bool HasSlot(string slotName)
{ {
if (string.IsNullOrEmpty(slotName)) return false; if (string.IsNullOrEmpty(slotName)) return false;
// 运行时通过字典; 编辑器模式遍历数组
if (_detected.Count > 0) return _detected.ContainsKey(slotName); if (_detected.Count > 0) return _detected.ContainsKey(slotName);
if (_slots == null) return false; if (_slots == null) return false;
foreach (var s in _slots) foreach (var s in _slots)
@@ -265,7 +588,7 @@ namespace BaseGames.Enemies.Perception
{ {
if (string.IsNullOrEmpty(slotName) || _slots == null) return -1f; if (string.IsNullOrEmpty(slotName) || _slots == null) return -1f;
foreach (var s in _slots) 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 s.radius;
return -1f; return -1f;
} }

View File

@@ -16,7 +16,7 @@ namespace BaseGames.Enemies.Perception
/// <summary> /// <summary>
/// 视线检测BatchLOS敌我之间无遮挡时持续为 true。 /// 视线检测BatchLOS敌我之间无遮挡时持续为 true。
/// 由 BatchLOSSystem 批量计算BD_IsPlayerVisible 读取结果。 /// 由 PhysicsPerceptionSystem 自研批量计算OverlapCircle + 单次 RaycastBD_IsPlayerVisible 读取结果。
/// </summary> /// </summary>
public const string LOS = "los"; public const string LOS = "los";
@@ -29,5 +29,23 @@ namespace BaseGames.Enemies.Perception
/// 远程攻击范围RangeCircle玩家进入时触发远程攻击条件。 /// 远程攻击范围RangeCircle玩家进入时触发远程攻击条件。
/// </summary> /// </summary>
public const string AttackRange = "attack_range"; public const string AttackRange = "attack_range";
/// <summary>
/// 巡逻范围RangeCircle定义敌人允许巡逻的地图固定区域半径。
/// 超出此范围时触发"返回巡逻点"逻辑。
/// </summary>
public const string Patrol = "patrol";
/// <summary>
/// 警觉半径RangeCircle进入此圈时敌人从待机/巡逻切换到 Alert 状态,
/// 通常比 Aggro 小,用于区分"察觉"和"追击"两个阶段。
/// </summary>
public const string Alert = "alert";
/// <summary>
/// 视线感知Sight带强制 LOS 遮挡检测的视锥传感器。
/// 仅当目标在视野锥内且无障碍物遮挡时才触发,是"看见玩家"的核心传感器。
/// </summary>
public const string Sight = "sight";
} }
} }

View File

@@ -0,0 +1,107 @@
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.Enemies.Perception
{
/// <summary>
/// 场景级 Sight 槽位批量调度器Phase 3 性能优化)。
///
/// <b>设计意图:</b>
/// Sight 槽位内置多点 LOS 射线检测当场景中存在大量敌人时10+ 个),
/// 每帧同步刷新所有 Sight 槽位会产生明显的 CPU 峰值。
/// 本组件将全局 Sight 更新量限制在 <see cref="maxSystemsPerFrame"/> 个/帧,
/// 通过轮询方式公平分配每帧的 Sight 预算,把瞬时峰值摊平到多帧。
///
/// <b>使用方式:</b>
/// 1. 在场景中添加一个空 GameObject挂载本组件建议放在 Managers 节点下)。
/// 2. 本组件自动在 <c>DefaultExecutionOrder(-50)</c> 执行,比默认 PhysicsPerceptionSystem 早。
/// 3. 当本组件存在时PhysicsPerceptionSystem.FixedUpdate() 会跳过所有 Sight 槽位的更新,
/// 由本组件统一调度(优雅降级:本组件不存在时 Sight 每帧正常更新)。
///
/// <b>性能参考(测试用):</b>
/// • maxSystemsPerFrame = 460fps20 个敌人 → 平均每帧 4 × losRayCount 次射线,
/// 每个敌人 Sight 刷新周期约 5 帧(~83ms适合慢速视线感应。
/// • 增大此值可减少延迟,减小此值可进一步降低每帧开销。
/// </summary>
[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<PhysicsPerceptionSystem> _registrants =
new List<PhysicsPerceptionSystem>(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;
}
// ── 注册 / 注销 ───────────────────────────────────────────────────────
/// <summary>PhysicsPerceptionSystem.Awake() 自动调用。</summary>
public void Register(PhysicsPerceptionSystem system)
{
if (system == null || _registrants.Contains(system)) return;
_registrants.Add(system);
}
/// <summary>PhysicsPerceptionSystem.OnDestroy() 自动调用。O(1) 交换删除。</summary>
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;
}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 89145f6fbc97f53419fa3ce81fcb6342 guid: c4da817926ac7c741a2c33ec552b249e
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2