diff --git a/Assets/_Game/Art/Characters/Enemies/E001/Flip.anim b/Assets/_Game/Art/Characters/Enemies/E001/Flip.anim index b1655a3..7e9443f 100644 --- a/Assets/_Game/Art/Characters/Enemies/E001/Flip.anim +++ b/Assets/_Game/Art/Characters/Enemies/E001/Flip.anim @@ -61,7 +61,7 @@ AnimationClip: m_Level: 0 m_CycleOffset: 0 m_HasAdditiveReferencePose: 0 - m_LoopTime: 1 + m_LoopTime: 0 m_LoopBlend: 0 m_LoopBlendOrientation: 0 m_LoopBlendPositionY: 0 diff --git a/Assets/_Game/Scenes/Testings/TestRoomA.unity b/Assets/_Game/Scenes/Testings/TestRoomA.unity index 331dcdd..64d88a0 100644 --- a/Assets/_Game/Scenes/Testings/TestRoomA.unity +++ b/Assets/_Game/Scenes/Testings/TestRoomA.unity @@ -7766,75 +7766,6 @@ Transform: - {fileID: 2120252236} m_Father: {fileID: 495476726} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &435966522 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 435966523} - - component: {fileID: 435966524} - 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 &435966523 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 435966522} - 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: 1340413309} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!114 &435966524 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 435966522} - 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: 1347552443} - _sensorHub: {fileID: 1584752068} - _aggroSlotName: aggro - references: - version: 2 - RefIds: [] --- !u!1 &442873750 GameObject: m_ObjectHideFlags: 0 @@ -10679,63 +10610,6 @@ Transform: m_Children: [] m_Father: {fileID: 52454202} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &637286414 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 637286415} - - component: {fileID: 637286416} - 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 &637286415 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 637286414} - 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: 1340413309} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!114 &637286416 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 637286414} - 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 &645364058 GameObject: m_ObjectHideFlags: 0 @@ -23507,39 +23381,6 @@ MonoBehaviour: _noiseFrequency: 1 _dedicatedCamera: {fileID: 2064386419} _dedicatedPriority: 20 ---- !u!1 &1340413308 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1340413309} - m_Layer: 0 - m_Name: Abilities - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!4 &1340413309 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1340413308} - 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: 637286415} - - {fileID: 435966523} - m_Father: {fileID: 1584752080} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1341564411 GameObject: m_ObjectHideFlags: 0 @@ -23919,106 +23760,6 @@ MonoBehaviour: _noiseFrequency: 1 _dedicatedCamera: {fileID: 1901040639} _dedicatedPriority: 20 ---- !u!1 &1347552442 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1347552446} - - component: {fileID: 1347552445} - - component: {fileID: 1347552444} - - component: {fileID: 1347552443} - m_Layer: 25 - m_Name: ContactDamageZone - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &1347552443 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1347552442} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 6def12af0589a9545b80eb5accf61bb6, type: 3} - m_Name: - m_EditorClassIdentifier: - _repeatInterval: 0.5 ---- !u!114 &1347552444 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1347552442} - 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 &1347552445 -CircleCollider2D: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1347552442} - 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 &1347552446 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1347552442} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0.409, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 1584752080} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1347749803 GameObject: m_ObjectHideFlags: 0 @@ -26704,88 +26445,6 @@ Transform: - {fileID: 212756508} m_Father: {fileID: 1295644710} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1432871575 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1432871578} - - component: {fileID: 1432871577} - - component: {fileID: 1432871576} - m_Layer: 27 - m_Name: HurtBox - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &1432871576 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1432871575} - 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 &1432871577 -CapsuleCollider2D: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1432871575} - 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 &1432871578 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1432871575} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0.373, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 1584752080} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1434802717 GameObject: m_ObjectHideFlags: 0 @@ -207623,433 +207282,6 @@ Transform: m_Children: [] m_Father: {fileID: 1411393553} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1584752067 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1584752080} - - component: {fileID: 1584752079} - - component: {fileID: 1584752078} - - component: {fileID: 1584752077} - - component: {fileID: 1584752076} - - component: {fileID: 1584752075} - - component: {fileID: 1584752074} - - component: {fileID: 1584752073} - - component: {fileID: 1584752072} - - component: {fileID: 1584752071} - - component: {fileID: 1584752070} - - component: {fileID: 1584752069} - - component: {fileID: 1584752068} - - component: {fileID: 1584752081} - 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 &1584752068 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 58cb3ac0e49c151429cad39d3e164a3d, type: 3} - m_Name: - m_EditorClassIdentifier: - _slots: - - slotName: aggro - sensor: {fileID: 0} - - slotName: wall_ahead - sensor: {fileID: 0} - - slotName: ledge - sensor: {fileID: 0} ---- !u!114 &1584752069 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - 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: 256 ---- !u!114 &1584752070 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - 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 &1584752071 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 3864fd1487d130847b11b82f276d11b6, type: 3} - m_Name: - m_EditorClassIdentifier: - height: 0.80000114 - maxSlopeAngle: 13.9 - 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 &1584752072 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - 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: 1584752075} - _enableTurnAnimation: 0 - _animancer: {fileID: 1584752076} - _animConfig: {fileID: 11400000, guid: 06936c5bc3358904cb269abdfa60ed14, type: 2} - _navJumpMaxHeight: 6 - _navJumpMaxDist: 10 - _groundCheckDist: 0.35 - _groundMask: - serializedVersion: 2 - m_Bits: 0 - _dbg_FacingDirection: 0 - _dbg_VelocityX: 0 - _dbg_VelocityY: 0 - _dbg_IsGrounded: 0 - _dbg_IsTurning: 0 ---- !u!114 &1584752073 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - 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 &1584752074 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - 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: 1584752073} - _movement: {fileID: 1584752072} - _combat: {fileID: 0} - _animancer: {fileID: 1584752076} - _feedback: {fileID: 0} - _hurtBox: {fileID: 1432871576} - _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!212 &1584752075 -SpriteRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - 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!114 &1584752076 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 0ad50f81b1d25c441943c37a89ba23f6, type: 3} - m_Name: - m_EditorClassIdentifier: - _Animator: {fileID: 1584752077} - _Transitions: {fileID: 0} - _ActionOnDisable: 0 ---- !u!95 &1584752077 -Animator: - serializedVersion: 5 - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - 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!70 &1584752078 -CapsuleCollider2D: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - 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.5} - m_Size: {x: 1, y: 1} - m_Direction: 0 ---- !u!50 &1584752079 -Rigidbody2D: - serializedVersion: 4 - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - 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 &1584752080 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: -5.2, y: 8.1, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 1432871578} - - {fileID: 1347552446} - - {fileID: 1340413309} - m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!114 &1584752081 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1584752067} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 0cdaa3305fa954c45a80c9662aa6f425, type: 3} - m_Name: - m_EditorClassIdentifier: - m_GraphName: Behavior Tree - m_Index: 0 - m_Data: - m_TaskData: - - m_ObjectType: Opsive.BehaviorDesigner.Runtime.Tasks.Actions.StackedAction - m_ValueHashes: - m_LongValueHashes: 30b77c28f48d1b2966cb3cf98c4bd96480f6501333363a66622a4da7e5588b5847627e5c3afba34af262c60c48f0bccad0ed2caea3f34ac163eb2caea3f34ac1a8724a1098fa102319cb31fdb78bb19fc8fad5b29007ef3a396f2e031315f24fe4a06bb554569d8946fff10c773614c9 - m_ValuePositions: 000000000200000004000000060000000a0000003100000035000000390000003d0000004100000045000000460000004a0000004a000000 - m_Values: 0000ffffffff010000004261736547616d65732e456e656d6965732e41492e42445f506174726f6c576179706f696e7473020000000000000001000000000000009a99993e010000000000000000 - m_UnityObjects: - - {fileID: 5705765} - - {fileID: 1398696825} - m_Version: 3.4 - m_EventTaskData: - - m_ObjectType: Opsive.BehaviorDesigner.Runtime.Tasks.Events.Start - m_ValueHashes: - m_LongValueHashes: 59405171878141b1 - m_ValuePositions: 00000000 - m_Values: 0000 - m_UnityObjects: [] - m_Version: 3.4 - m_SharedVariableData: [] - m_DisabledEventNodesData: [] - m_DisabledLogicNodesData: [] - m_UniqueID: -1404223673 - m_LogicNodePropertiesData: - - m_ObjectType: Opsive.GraphDesigner.Runtime.LogicNodeProperties - m_ValueHashes: - m_LongValueHashes: bc124df8ef5e104cf36ca30dee0de9958dd19827f48d1b29bd8814239a1bb7642f6406e2580d1e294f869cc9196b0c27aea3bc2e18d5b803a034c7c2b541f015557e981535906112a98ff48b9e8a66b37f542abda0a249c23a7f01b1f88d1a7b - m_ValuePositions: 00000000240000002c000000300000003000000030000000400000004100000045000000450000004700000049000000 - m_Values: 37383637303330652d633337332d346433612d623831662d643362383132353230343464008001440000de4300000043000000000000000000000000000000000000000000ffffffff00 - m_UnityObjects: [] - m_Version: 3.4 - m_EventNodePropertiesData: - - m_ObjectType: Opsive.GraphDesigner.Runtime.NodeProperties - m_ValueHashes: - m_LongValueHashes: bc124df8ef5e104cf36ca30dee0de9958dd19827f48d1b29bd8814239a1bb7642f6406e2580d1e294f869cc9196b0c27aea3bc2e18d5b803 - m_ValuePositions: 00000000240000002c00000030000000300000003000000040000000 - m_Values: 63663532303038392d646165382d343566632d393539372d3833613734336635316638300000044400007e430000dc420000000000000000000000000000000000 - m_UnityObjects: [] - m_Version: 3.4 - m_GroupPropertiesData: [] - m_SharedVariableGroupsData: [] - m_StartWhenEnabled: 1 - m_PauseWhenDisabled: 0 - m_UpdateMode: 0 - m_EvaluationType: 0 - m_MaxEvaluationCount: 1 - m_Subtree: {fileID: 0} --- !u!1 &1584945498 GameObject: m_ObjectHideFlags: 0 @@ -217134,4 +216366,3 @@ SceneRoots: - {fileID: 1865796631} - {fileID: 1354690328} - {fileID: 783576435} - - {fileID: 1584752080} diff --git a/Assets/_Game/Scripts/Combat/DamageInfo.cs b/Assets/_Game/Scripts/Combat/DamageInfo.cs index 60f8900..730bc8f 100644 --- a/Assets/_Game/Scripts/Combat/DamageInfo.cs +++ b/Assets/_Game/Scripts/Combat/DamageInfo.cs @@ -28,6 +28,12 @@ namespace BaseGames.Combat public string SourceId; public string SkillId; /// + /// HitBox 激活实例 ID(由 HitBox.Activate() 自动生成并写入,0 = 无追踪路径)。 + /// HurtBoxOwnerGuard 用此字段做同激活去重,防止多 HurtBox 节点被同一次攻击重复扣血。 + /// [NonSerialized]:每次激活动态生成,不需要序列化。 + /// + [System.NonSerialized] public uint HitActivationId; + /// /// 攻击来源投射物(仅当攻击方是 Projectile 时非 null)。 /// 用于弹反成功时调用 ReflectAsPlayerProjectile() 翻转阵营。 /// [NonSerialized]:MonoBehaviour 引用不参与 Unity 资产序列化。 diff --git a/Assets/_Game/Scripts/Combat/HitBox.cs b/Assets/_Game/Scripts/Combat/HitBox.cs index ffa5026..659eb5c 100644 --- a/Assets/_Game/Scripts/Combat/HitBox.cs +++ b/Assets/_Game/Scripts/Combat/HitBox.cs @@ -8,8 +8,10 @@ namespace BaseGames.Combat /// 攻击判定盒。挂载在武器 Prefab 或技能 HitBox Prefab 的子节点上。 /// 直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。 /// Collider2D 需设 IsTrigger = true,Layer = PlayerHitBox 或 EnemyHitBox。 + /// + /// 多碰撞体模式:在子节点上挂载 HitBoxColliderProxy + Collider2D 即可组合任意形状。 + /// HitBox 本身可不带 Collider2D(仅代理子节点)或同时拥有直属 Collider2D。 /// - [RequireComponent(typeof(Collider2D))] public class HitBox : MonoBehaviour { [SerializeField] private DamageSourceSO _defaultSource; @@ -33,7 +35,16 @@ namespace BaseGames.Combat private Rigidbody2D _ownerRigidbody; private bool _isActive; private IClashService _clashService; - private Collider2D _collider; + + // 直属碰撞体(本 GameObject 上) + private Collider2D[] _directColliders = System.Array.Empty(); + // 子节点代理碰撞体 + private HitBoxColliderProxy[] _proxies = System.Array.Empty(); + + // 激活 ID:每次 Activate() 递增,写入 DamageInfo.HitActivationId, + // HurtBoxOwnerGuard 据此防止多 HurtBox 节点被同一次攻击重复扣血。 + private static uint _nextActivationId = 1; + private uint _currentActivationId; /// HitBox 当前是否激活(供 ClashResolver 查询)。 public bool IsActive => _isActive; @@ -58,12 +69,13 @@ namespace BaseGames.Combat /// public void Activate(DamageSourceSO source = null, Transform attacker = null) { - _currentSource = source ?? _defaultSource; - _attackerTransform = attacker ?? transform; - _isActive = true; - _collider.enabled = true; - // 缓存宿主 Rigidbody2D(沿父层级向上查找) - _ownerRigidbody = _attackerTransform.GetComponentInParent(); + _currentActivationId = _nextActivationId++; + _currentSource = source ?? _defaultSource; + _attackerTransform = attacker ?? transform; + _isActive = true; + _ownerRigidbody = _attackerTransform.GetComponentInParent(); + foreach (var col in _directColliders) col.enabled = true; + foreach (var proxy in _proxies) proxy.SetEnabled(true); // 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标) _hitThisActivation.Clear(); _hitCooldownTimers.Clear(); @@ -71,8 +83,9 @@ namespace BaseGames.Combat public void Deactivate() { - _isActive = false; - _collider.enabled = false; + _isActive = false; + foreach (var col in _directColliders) col.enabled = false; + foreach (var proxy in _proxies) proxy.SetEnabled(false); _hitThisActivation.Clear(); _hitCooldownTimers.Clear(); } @@ -85,12 +98,18 @@ namespace BaseGames.Combat private void Awake() { - // 确保 Collider2D 是 Trigger - _collider = GetComponent(); - if (!_collider.isTrigger) - Debug.LogWarning($"[HitBox] {name}: Collider2D.isTrigger 应为 true。", this); - // 初始状态关闭碰撞体,防止未激活时产生物理检测 - _collider.enabled = false; + // 收集本节点上所有直属 Collider2D,并验证 isTrigger + _directColliders = GetComponents(); + foreach (var col in _directColliders) + { + if (!col.isTrigger) + Debug.LogWarning($"[HitBox] {name}: Collider2D ({col.GetType().Name}) isTrigger 应为 true。", this); + col.enabled = false; + } + // 注册所有子代 HitBoxColliderProxy(子节点多形状模式) + _proxies = GetComponentsInChildren(true); + foreach (var proxy in _proxies) + proxy.Init(this); // 缓存 IClashService:OnTriggerEnter2D 为物理热路径,避免每次调用 Dictionary 查找 _clashService = ServiceLocator.GetOrDefault(); // 缓存宿主投射物(仅 Projectile GameObject 上挂载的 HitBox 非 null) @@ -100,20 +119,18 @@ namespace BaseGames.Combat private void OnDisable() { _isActive = false; - if (_collider != null) _collider.enabled = false; + foreach (var col in _directColliders) if (col != null) col.enabled = false; + foreach (var proxy in _proxies) proxy.SetEnabled(false); _hitThisActivation.Clear(); _hitCooldownTimers.Clear(); } - private void OnTriggerExit2D(Collider2D other) - { - // 目标离开判定区域时清除其冷却记录,防止持续激活的 HitBox(环境危险等) - // 因有效目标持续流动而无限积累已离场对象。 - // 注意:_hitThisActivation 刻意保留,确保同一激活期内不重复命中。 - _hitCooldownTimers.Remove(other); - } + private void OnTriggerEnter2D(Collider2D other) => HandleTriggerEnter(other, null); + private void OnTriggerExit2D(Collider2D other) => HandleTriggerExit(other); - private void OnTriggerEnter2D(Collider2D other) { + /// 代理入口:由 HitBoxColliderProxy 或本节点 OnTriggerEnter2D 转发。 + internal void HandleTriggerEnter(Collider2D other, Collider2D sourceCollider) + { if (!_isActive) return; if (_currentSource == null) { @@ -136,6 +153,7 @@ namespace BaseGames.Combat _attackerTransform.position, _attackerTransform.gameObject.layer, _ownerProjectile); + info.HitActivationId = _currentActivationId; // ① 拼刀检测:当前 HitBox 携带 CanClash 标记,且碰到对立阵营的 HitBox 层 int otherLayer = other.gameObject.layer; @@ -153,9 +171,12 @@ namespace BaseGames.Combat // ② 命中 HurtBox if (other.TryGetComponent(out var hurtBox)) { - // 用 HitBox 自身碰撞盒中心在 HurtBox 表面上的最近点作为受击位置。 - // 对大体积/长条形受击体(如地刺),此点远比 HurtBox 节点中心更准确。 - Vector3 hitPoint = other.ClosestPoint(_collider.bounds.center); + // hitPoint:优先使用触发命中的碰撞体中心在 HurtBox 表面的最近点; + // 无 sourceCollider(直属碰撞体)时回退到 HitBox 节点坐标。 + Vector2 hitOrigin = sourceCollider != null + ? (Vector2)sourceCollider.bounds.center + : (Vector2)transform.position; + Vector3 hitPoint = other.ClosestPoint(hitOrigin); hurtBox.ReceiveDamage(info, hitPoint); OnHitConfirmed?.Invoke(info); return; @@ -171,6 +192,15 @@ namespace BaseGames.Combat // ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)── private readonly Dictionary _hitCooldownTimers = new(8); + /// 代理出口:由 HitBoxColliderProxy 或本节点 OnTriggerExit2D 转发。 + internal void HandleTriggerExit(Collider2D other) + { + // 目标离开判定区域时清除其冷却记录,防止持续激活的 HitBox(环境危险等) + // 因有效目标持续流动而无限积累已离场对象。 + // 注意:_hitThisActivation 刻意保留,确保同一激活期内不重复命中。 + _hitCooldownTimers.Remove(other); + } + private bool CheckCooldown(Collider2D other) { float now = Time.time; @@ -183,11 +213,17 @@ namespace BaseGames.Combat #if UNITY_EDITOR private void OnDrawGizmos() { - var col = GetComponent(); - if (col == null) return; Color fill = _isActive ? new Color(1f, 0.15f, 0.15f, 0.25f) : new Color(1f, 0.15f, 0.15f, 0.05f); Color outline = _isActive ? new Color(1f, 0.15f, 0.15f, 0.90f) : new Color(1f, 0.15f, 0.15f, 0.25f); - DrawCollider2DFilled(col, fill, outline); + // 直属碰撞体 + foreach (var col in GetComponents()) + DrawCollider2DFilled(col, fill, outline); + // 子代代理碰撞体 + foreach (var proxy in GetComponentsInChildren(true)) + { + var proxyCol = proxy.GetComponent(); + if (proxyCol != null) DrawCollider2DFilled(proxyCol, fill, outline); + } } // ── Gizmo 辅助(填充 + 轮廓,不依赖外部工具类)────────────────────────── diff --git a/Assets/_Game/Scripts/Combat/HitBoxColliderProxy.cs b/Assets/_Game/Scripts/Combat/HitBoxColliderProxy.cs new file mode 100644 index 0000000..66bb072 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/HitBoxColliderProxy.cs @@ -0,0 +1,41 @@ +using UnityEngine; + +namespace BaseGames.Combat +{ + /// + /// 子碰撞体代理。挂载在 HitBox 子节点上,将 Trigger 事件转发给父级 HitBox, + /// 实现"单一 HitBox 组件 + 多个异形 Collider2D"的组合判定盒。 + /// + /// 配置说明(子节点多形状模式): + /// [AttackNode] ← HitBox 组件(本身可不带 Collider2D) + /// ├── [Shape_Box] ← BoxCollider2D + HitBoxColliderProxy + /// └── [Shape_Circle] ← CircleCollider2D + HitBoxColliderProxy + /// + /// ⚠️ 子节点 Collider2D 须设 IsTrigger = true,Layer 与父 HitBox 一致。 + /// ⚠️ 无需手动调用 Init();HitBox.Awake() 自动完成注册。 + /// + [RequireComponent(typeof(Collider2D))] + public sealed class HitBoxColliderProxy : MonoBehaviour + { + private HitBox _owner; + private Collider2D _col; + + /// 由父 HitBox.Awake() 调用,完成双向注册。 + internal void Init(HitBox owner) + { + _owner = owner; + _col = GetComponent(); + if (!_col.isTrigger) + Debug.LogWarning($"[HitBoxColliderProxy] {name}: Collider2D.isTrigger 应为 true。", this); + _col.enabled = false; + } + + internal void SetEnabled(bool value) + { + if (_col != null) _col.enabled = value; + } + + private void OnTriggerEnter2D(Collider2D other) => _owner?.HandleTriggerEnter(other, _col); + private void OnTriggerExit2D(Collider2D other) => _owner?.HandleTriggerExit(other); + } +} diff --git a/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs.meta b/Assets/_Game/Scripts/Combat/HitBoxColliderProxy.cs.meta similarity index 83% rename from Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs.meta rename to Assets/_Game/Scripts/Combat/HitBoxColliderProxy.cs.meta index fc47010..4f801a8 100644 --- a/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs.meta +++ b/Assets/_Game/Scripts/Combat/HitBoxColliderProxy.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 58cb3ac0e49c151429cad39d3e164a3d +guid: e11b931e351246344aec20aa35489592 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/_Game/Scripts/Combat/HurtBox.cs b/Assets/_Game/Scripts/Combat/HurtBox.cs index d6e6b68..6255ff9 100644 --- a/Assets/_Game/Scripts/Combat/HurtBox.cs +++ b/Assets/_Game/Scripts/Combat/HurtBox.cs @@ -20,6 +20,8 @@ namespace BaseGames.Combat private bool _isHurtBoxInvincible; private bool _isActive = true; + // 所有者级去重保护:防止同一角色的多个 HurtBox 子节点在同一次 HitBox 激活中被重复伤害 + private HurtBoxOwnerGuard _ownerGuard; // ── 事件频道 ────────────────────────────────────────────────────────── [SerializeField] private DamageInfoEventChannelSO _onDamageDealt; @@ -41,10 +43,14 @@ namespace BaseGames.Combat #endif private void Awake() { - _owner = GetComponentInParent(); + _owner = GetComponentInParent(); _statusEffectable = GetComponentInParent(); if (_owner == null) Debug.LogWarning($"[HurtBox] {name}: 父节点中未找到 IDamageable 实现。", this); + // 在角色根节点查找或自动创建 HurtBoxOwnerGuard(多 HurtBox 共享所有者时只有一个 Guard) + _ownerGuard = transform.root.GetComponent(); + if (_ownerGuard == null && _owner != null) + _ownerGuard = transform.root.gameObject.AddComponent(); } /// @@ -56,6 +62,8 @@ namespace BaseGames.Combat { Vector3 resolvedHitPoint = hitPoint ?? transform.position; if (!_isActive || _owner == null) return; + // 所有者级去重:同一 HitBox 激活期内多个 HurtBox 子节点只处理首次命中(共享 HP) + if (_ownerGuard != null && !_ownerGuard.TryRegisterHit(info.HitActivationId)) return; // 1. 无敌帧检查 if ((_owner.IsInvincible || _isHurtBoxInvincible) diff --git a/Assets/_Game/Scripts/Combat/HurtBoxOwnerGuard.cs b/Assets/_Game/Scripts/Combat/HurtBoxOwnerGuard.cs new file mode 100644 index 0000000..a83d68e --- /dev/null +++ b/Assets/_Game/Scripts/Combat/HurtBoxOwnerGuard.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace BaseGames.Combat +{ + /// + /// HurtBox 所有者保护组件。防止同一角色身上的多个 HurtBox 在同一次 HitBox 激活中被重复伤害。 + /// + /// 工作原理:每次 HitBox 激活携带唯一 ActivationId(由 HitBox.Activate() 生成递增值)。 + /// HurtBox.ReceiveDamage 调用 TryRegisterHit(id) 时,同一 id 只有首次调用返回 true; + /// 后续同 id 调用(来自同一角色的其他 HurtBox 子节点)返回 false 并跳过伤害流水线, + /// 从而保证"多个 HurtBox 共享同一 HP 池时,一次攻击只扣一次血"。 + /// + /// 生命周期:由 HurtBox.Awake() 在角色根节点自动添加(如不存在则创建)。无需手动挂载。 + /// 逐帧通过 frameCount 差异懒清空处理集,零 GC 开销。 + /// + [AddComponentMenu("")] // 隐藏菜单:由 HurtBox 自动管理 + public sealed class HurtBoxOwnerGuard : MonoBehaviour + { + private readonly HashSet _processedIds = new(4); + private int _lastClearFrame = -1; + + /// + /// 尝试注册一次命中。 + /// + /// HitBox 激活 ID(0 = 无追踪路径,始终允许通过)。 + /// true = 首次注册,应继续处理伤害;false = 同 id 已被处理,跳过。 + public bool TryRegisterHit(uint activationId) + { + // activationId == 0:LethalTrap / BodyContactDamage 等旁路路径,不做去重 + if (activationId == 0) return true; + + EnsureClearedThisFrame(); + return _processedIds.Add(activationId); + } + + private void EnsureClearedThisFrame() + { + int frame = Time.frameCount; + if (frame == _lastClearFrame) return; + _processedIds.Clear(); + _lastClearFrame = frame; + } + } +} diff --git a/Assets/_Game/Scripts/Combat/HurtBoxOwnerGuard.cs.meta b/Assets/_Game/Scripts/Combat/HurtBoxOwnerGuard.cs.meta new file mode 100644 index 0000000..ed6e3e1 --- /dev/null +++ b/Assets/_Game/Scripts/Combat/HurtBoxOwnerGuard.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d9d0dceef43f3534c9bcc84af2134c53 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs b/Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs index 4e9fe2d..b587da8 100644 --- a/Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs +++ b/Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using UnityEditor; -using UnityEditor.UIElements; + using UnityEngine; using UnityEngine.UIElements; using BaseGames.Boss; @@ -15,6 +15,8 @@ using BaseGames.Parry; using BaseGames.Player; using BaseGames.Player.States; using BaseGames.Skills; +using BaseGames.Editor.Combat; +using BaseGames.Editor.Skills; namespace BaseGames.Editor { @@ -59,6 +61,7 @@ namespace BaseGames.Editor // 小怪类型选择 — 具体敌人类型 private int _enemyTypeIndex = 0; + private SceneObjectPlacerTool.EnemyBodyColliderType _enemyBodyCollider = SceneObjectPlacerTool.EnemyBodyColliderType.Box; private static readonly (string id, string name)[] EnemyTypes = { ("E001", "草蛭"), @@ -195,6 +198,8 @@ namespace BaseGames.Editor var jumpGroup = MakeActionGroup(); jumpGroup.Add(MakeJumpButton("Data Hub(武器/技能/形态)", DataHubWindow.Open)); + jumpGroup.Add(MakeJumpButton("武器 HitBox 向导", WeaponHitBoxWizard.Open)); + jumpGroup.Add(MakeJumpButton("技能 HitBox 向导", SkillHitBoxWizard.Open)); jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu)); root.Add(jumpGroup); @@ -248,8 +253,9 @@ namespace BaseGames.Editor root.Add(MakeSectionHeader("▶ 专项编辑器")); var jumpGroup = MakeActionGroup(); - jumpGroup.Add(MakeJumpButton("Data Hub(敌人数据)", DataHubWindow.Open)); - jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu)); + jumpGroup.Add(MakeJumpButton("Data Hub(敌人数据)", DataHubWindow.Open)); + jumpGroup.Add(MakeJumpButton("武器 HitBox 向导", WeaponHitBoxWizard.Open)); + jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu)); root.Add(jumpGroup); return root; @@ -286,11 +292,16 @@ namespace BaseGames.Editor container.Add(MakeSeparator()); container.Add(MakeSectionHeader("▶ 场景搭建")); - container.Add(MakeHelpBox("在当前活动场景中放置完整组件树并自动绑定已有 SO。")); + container.Add(MakeHelpBox("在当前活动场景中放置完整组件树(含 Visual 子节点对齐 Collider)并自动绑定已有 SO。")); + + var colliderField = new UnityEngine.UIElements.EnumField("主体碰撞器类型", _enemyBodyCollider); + colliderField.RegisterValueChangedCallback(evt => _enemyBodyCollider = (SceneObjectPlacerTool.EnemyBodyColliderType)evt.newValue); + container.Add(colliderField); var sceneGroup = MakeActionGroup(); string sceneLabel = $"放置 {id} {name} 到场景"; - sceneGroup.Add(MakeSceneButton(sceneLabel, () => PlaceSpecificEnemy(id))); + sceneGroup.Add(MakeSceneButton(sceneLabel, () => PlaceSpecificEnemy(id, _enemyBodyCollider))); + sceneGroup.Add(MakeSceneButton("迁移选中对象 Visual 节点", MigrateSelectedEnemyVisualNode)); container.Add(sceneGroup); } @@ -330,15 +341,17 @@ namespace BaseGames.Editor var sceneGroup = MakeActionGroup(); sceneGroup.Add(MakeSceneButton("放置嘲风到场景并绑定 SO", SceneObjectPlacerTool.PlaceChaoFeng)); + sceneGroup.Add(MakeSceneButton("迁移选中对象 Visual 节点", MigrateSelectedEnemyVisualNode)); root.Add(sceneGroup); root.Add(MakeSeparator()); root.Add(MakeSectionHeader("▶ 专项编辑器")); var jumpGroup = MakeActionGroup(); - jumpGroup.Add(MakeJumpButton("Boss 技能序列查看器", BossSkillSequenceWindow.OpenWindow)); - jumpGroup.Add(MakeJumpButton("Data Hub(Boss技能)", DataHubWindow.Open)); - jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu)); + jumpGroup.Add(MakeJumpButton("Boss 技能序列查看器", BossSkillSequenceWindow.OpenWindow)); + jumpGroup.Add(MakeJumpButton("武器 HitBox 向导", WeaponHitBoxWizard.Open)); + jumpGroup.Add(MakeJumpButton("Data Hub(Boss技能)", DataHubWindow.Open)); + jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu)); root.Add(jumpGroup); return root; @@ -603,23 +616,45 @@ namespace BaseGames.Editor $"全部 {id} SO 已创建(已存在的跳过)。\n请放置到场景后检查组件绑定。", "确定"); } - private static void PlaceSpecificEnemy(string id) + private static void PlaceSpecificEnemy(string id, SceneObjectPlacerTool.EnemyBodyColliderType bodyCollider) { switch (id) { - case "E001": SceneObjectPlacerTool.PlaceE001_CaoZhi(); break; - case "E002": SceneObjectPlacerTool.PlaceE002_HuangZhi(); break; - case "E003": SceneObjectPlacerTool.PlaceE003_YouZhi_Enemy(); break; - case "E004": SceneObjectPlacerTool.PlaceE004_ZhiMu_Enemy(); break; - case "E005": SceneObjectPlacerTool.PlaceE005_FeiZhi_Enemy(); break; - case "E006": SceneObjectPlacerTool.PlaceE006_Huan(); break; + case "E001": SceneObjectPlacerTool.PlaceE001_CaoZhi(bodyCollider); break; + case "E002": SceneObjectPlacerTool.PlaceE002_HuangZhi(bodyCollider); break; + case "E003": SceneObjectPlacerTool.PlaceE003_YouZhi_Enemy(bodyCollider); break; + case "E004": SceneObjectPlacerTool.PlaceE004_ZhiMu_Enemy(bodyCollider); break; + case "E005": SceneObjectPlacerTool.PlaceE005_FeiZhi_Enemy(bodyCollider); break; + case "E006": SceneObjectPlacerTool.PlaceE006_Huan(bodyCollider); break; default: Debug.LogError($"[CharacterWizardWindow] 未注册的敌人 id '{id}',请在 SceneObjectPlacerTool 中实现对应 PlaceE...() 方法并注册。"); - SceneObjectPlacerTool.PlaceEnemy(); + SceneObjectPlacerTool.PlaceEnemy(bodyCollider); break; } } + private static void MigrateSelectedEnemyVisualNode() + { + var targets = Selection.gameObjects; + if (targets == null || targets.Length == 0) + { + EditorUtility.DisplayDialog("迁移 Visual 节点", + "请先在 Hierarchy 中选中一个或多个 Enemy 对象。", "确定"); + return; + } + int count = 0; + foreach (var go in targets) + { + var movement = go.GetComponent(); + if (movement != null) { movement.SetupVisualNode(); count++; } + } + if (count == 0) + EditorUtility.DisplayDialog("迁移 Visual 节点", + "选中的对象中没有找到 EnemyMovement 组件。", "确定"); + else + Debug.Log($"[CharacterWizardWindow] 已对 {count} 个对象完成 Visual 节点迁移。"); + } + // ── SO 资产工厂:嘲风 Boss ───────────────────────────────────────────── private static void CreateChaoFengStatsSO() diff --git a/Assets/_Game/Scripts/Editor/Combat/HitBoxEditor.cs b/Assets/_Game/Scripts/Editor/Combat/HitBoxEditor.cs new file mode 100644 index 0000000..9167545 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Combat/HitBoxEditor.cs @@ -0,0 +1,115 @@ +#if UNITY_EDITOR +using UnityEditor; +using UnityEngine; +using BaseGames.Combat; + +namespace BaseGames.Editor.Combat +{ + /// + /// HitBox 自定义 Inspector。 + /// 在"多形状碰撞体"面板中提供快捷按钮,一键创建带 HitBoxColliderProxy 的子形状节点。 + /// + /// 生成子节点结构: + /// [HitBoxParent] ← HitBox 组件(可无 Collider2D) + /// ├── [Shape_Box] ← BoxCollider2D(isTrigger) + HitBoxColliderProxy + /// ├── [Shape_Circle] ← CircleCollider2D(isTrigger) + HitBoxColliderProxy + /// ├── [Shape_Capsule] ← CapsuleCollider2D(isTrigger) + HitBoxColliderProxy + /// └── [Shape_Polygon] ← PolygonCollider2D(isTrigger) + HitBoxColliderProxy + /// + [CustomEditor(typeof(HitBox))] + public class HitBoxEditor : UnityEditor.Editor + { + public override void OnInspectorGUI() + { + DrawDefaultInspector(); + + EditorGUILayout.Space(6); + EditorGUILayout.LabelField("── 多形状碰撞体 ──", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "在子节点添加 HitBoxColliderProxy + Collider2D 以组合多形状判定盒。\n" + + "子节点 Layer 自动继承本节点;Collider2D.IsTrigger 自动设为 true。", + MessageType.Info); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("+ Box")) + AddShapeChild((HitBox)target, ShapeKind.Box); + if (GUILayout.Button("+ Circle")) + AddShapeChild((HitBox)target, ShapeKind.Circle); + if (GUILayout.Button("+ Capsule")) + AddShapeChild((HitBox)target, ShapeKind.Capsule); + if (GUILayout.Button("+ Polygon")) + AddShapeChild((HitBox)target, ShapeKind.Polygon); + EditorGUILayout.EndHorizontal(); + + if (Application.isPlaying) + { + EditorGUILayout.Space(2); + int proxyCount = ((HitBox)target).GetComponentsInChildren(true).Length; + EditorGUILayout.LabelField($"子代 HitBoxColliderProxy 数:{proxyCount}", EditorStyles.miniLabel); + Repaint(); + } + } + + // ── 内部工具 ────────────────────────────────────────────────────────── + + private enum ShapeKind { Box, Circle, Capsule, Polygon } + + private static void AddShapeChild(HitBox hitBox, ShapeKind kind) + { + var child = new GameObject($"[Shape_{kind}]"); + Undo.RegisterCreatedObjectUndo(child, $"Add HitBox {kind} Shape"); + child.transform.SetParent(hitBox.transform, false); + child.layer = hitBox.gameObject.layer; + + Collider2D col = kind switch + { + ShapeKind.Box => CreateBox(child), + ShapeKind.Circle => CreateCircle(child), + ShapeKind.Capsule => CreateCapsule(child), + ShapeKind.Polygon => CreatePolygon(child), + _ => CreateBox(child), + }; + col.isTrigger = true; + + Undo.AddComponent(child); + + Selection.activeGameObject = child; + EditorGUIUtility.PingObject(child); + } + + private static BoxCollider2D CreateBox(GameObject go) + { + var c = Undo.AddComponent(go); + c.size = new Vector2(1f, 0.5f); + return c; + } + + private static CircleCollider2D CreateCircle(GameObject go) + { + var c = Undo.AddComponent(go); + c.radius = 0.4f; + return c; + } + + private static CapsuleCollider2D CreateCapsule(GameObject go) + { + var c = Undo.AddComponent(go); + c.size = new Vector2(0.5f, 1f); + return c; + } + + private static PolygonCollider2D CreatePolygon(GameObject go) + { + var c = Undo.AddComponent(go); + c.SetPath(0, new Vector2[] + { + new( 0f, 0.3f), + new( 0.5f, 0f ), + new( 0f, -0.3f), + new(-0.5f, 0f ), + }); + return c; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Editor/Combat/HitBoxEditor.cs.meta b/Assets/_Game/Scripts/Editor/Combat/HitBoxEditor.cs.meta new file mode 100644 index 0000000..2332859 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Combat/HitBoxEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 414d2528016107148929f4d65d5507e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Combat/HurtBoxEditor.cs b/Assets/_Game/Scripts/Editor/Combat/HurtBoxEditor.cs index d3fa618..abd7976 100644 --- a/Assets/_Game/Scripts/Editor/Combat/HurtBoxEditor.cs +++ b/Assets/_Game/Scripts/Editor/Combat/HurtBoxEditor.cs @@ -6,9 +6,14 @@ using BaseGames.Combat; namespace BaseGames.Editor { /// - /// HurtBox 运行时注入状态可视化面板。 - /// 通过 HurtBox 上的 Editor* 属性读取注入状态,以颜色区分是否注入成功。 - /// 绿色 = 注入完成;橙色 = 未注入(该能力静默不生效);灰色 = 非 PlayMode。 + /// HurtBox 自定义 Inspector。 + /// ① 提供"多形状受击区域"快捷按钮:一键创建带 HurtBox 的子形状节点(共享同一 HP 池)。 + /// ② 运行时注入状态可视化面板:绿色 = 注入完成;橙色 = 未注入;灰色 = 非 PlayMode。 + /// + /// 生成子节点结构: + /// [HurtBoxParent] ← HurtBox + 任意 Collider2D(主受击区) + /// ├── [HurtShape_Box] ← BoxCollider2D(isTrigger) + HurtBox(共享 HP) + /// └── [HurtShape_Circle] ← CircleCollider2D(isTrigger) + HurtBox(共享 HP) /// [CustomEditor(typeof(HurtBox))] public class HurtBoxEditor : UnityEditor.Editor @@ -27,6 +32,24 @@ namespace BaseGames.Editor { DrawDefaultInspector(); + // ── 多形状受击区域 ────────────────────────────────────────────── + EditorGUILayout.Space(6); + EditorGUILayout.LabelField("── 多形状受击区域 ──", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "在子节点添加 HurtBox + Collider2D 以组合多形状受击区域,各子节点共享同一 HP 池。\n" + + "子节点 Layer 自动继承本节点;HurtBoxOwnerGuard 防止同一次攻击重复扣血。", + MessageType.Info); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("+ Box")) + AddShapeChild((HurtBox)target, ShapeKind.Box); + if (GUILayout.Button("+ Circle")) + AddShapeChild((HurtBox)target, ShapeKind.Circle); + if (GUILayout.Button("+ Capsule")) + AddShapeChild((HurtBox)target, ShapeKind.Capsule); + EditorGUILayout.EndHorizontal(); + + // ── 运行时注入状态 ────────────────────────────────────────────── EditorGUILayout.Space(4); EditorGUILayout.LabelField("── 运行时注入状态 ──", EditorStyles.boldLabel); @@ -59,6 +82,34 @@ namespace BaseGames.Editor // 持续刷新(避免只显示初始状态) if (Application.isPlaying) Repaint(); } + + // ── 子形状创建工具 ───────────────────────────────────────────────────── + + private enum ShapeKind { Box, Circle, Capsule } + + private static void AddShapeChild(HurtBox hurtBox, ShapeKind kind) + { + var child = new GameObject($"[HurtShape_{kind}]"); + Undo.RegisterCreatedObjectUndo(child, $"Add HurtBox {kind} Shape"); + child.transform.SetParent(hurtBox.transform, false); + child.layer = hurtBox.gameObject.layer; + + // 先加 Collider2D 以满足 HurtBox 的 [RequireComponent(typeof(Collider2D))], + // 再 AddComponent() 则不会重复创建 Collider2D。 + Collider2D col = kind switch + { + ShapeKind.Box => Undo.AddComponent(child), + ShapeKind.Circle => Undo.AddComponent(child), + ShapeKind.Capsule => Undo.AddComponent(child), + _ => Undo.AddComponent(child), + }; + col.isTrigger = true; + + Undo.AddComponent(child); + + Selection.activeGameObject = child; + EditorGUIUtility.PingObject(child); + } } } #endif diff --git a/Assets/_Game/Scripts/Editor/Enemies/PhysicsPerceptionSystemEditor.cs b/Assets/_Game/Scripts/Editor/Enemies/PhysicsPerceptionSystemEditor.cs new file mode 100644 index 0000000..4912f56 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Enemies/PhysicsPerceptionSystemEditor.cs @@ -0,0 +1,227 @@ +using System.Text; +using UnityEditor; +using UnityEngine; +using BaseGames.Enemies.Perception; + +namespace BaseGames.Editor +{ + [CustomEditor(typeof(PhysicsPerceptionSystem))] + public sealed class PhysicsPerceptionSystemEditor : UnityEditor.Editor + { + // ── Scene 视图 Gizmo ────────────────────────────────────────────────── + + [DrawGizmo(GizmoType.NonSelected | GizmoType.Selected | GizmoType.InSelectionHierarchy)] + static void DrawGizmos(PhysicsPerceptionSystem system, GizmoType gizmoType) + { + var slots = system.EditorSlots; + if (slots == null) return; + + bool isSelected = (gizmoType & (GizmoType.Selected | GizmoType.InSelectionHierarchy)) != 0; + Vector3 rootPos = system.transform.position; + float facingSign = system.transform.localScale.x < 0f ? -1f : 1f; + + foreach (var slot in slots) + { + if (string.IsNullOrEmpty(slot.slotName)) continue; + + Color baseColor = ResolveGizmoColor(slot.gizmoColor); + Color fill = baseColor; fill.a = isSelected ? 0.12f : 0.04f; + Color outline = baseColor; outline.a = isSelected ? 0.90f : 0.40f; + + // 每个 Slot 独立检测原点(X 随朝向翻转) + Vector3 slotCenter = rootPos + new Vector3(slot.offset.x * facingSign, slot.offset.y, 0f); + + switch (slot.type) + { + case PhysicsPerceptionSystem.SlotType.RangeCircle: + if (slot.radius <= 0f) break; + Handles.color = fill; + Handles.DrawSolidDisc(slotCenter, Vector3.forward, slot.radius); + Handles.color = outline; + Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius); + break; + + case PhysicsPerceptionSystem.SlotType.FanCast: + if (slot.radius > 0f && slot.fanAngle > 0f) + { + DrawFanGizmo(slotCenter, facingSign, slot, fill, outline); + if (isSelected) DrawOriginDot(slotCenter, outline); + } + break; + + case PhysicsPerceptionSystem.SlotType.BoxCast: + if (slot.boxSize.x > 0f && slot.boxSize.y > 0f) + { + DrawBoxGizmo(slotCenter, facingSign, slot, fill, outline); + if (isSelected) DrawOriginDot(slotCenter, outline); + } + break; + + // BatchLOS: eye-position marker + optional range disc + runtime ray + case PhysicsPerceptionSystem.SlotType.BatchLOS: + DrawBatchLOSGizmo(system, slotCenter, slot, outline, isSelected); + break; + } + } + + Handles.color = Color.white; + } + + /// 在 Slot 原点处画一个小十字点,帮助可视化 offset 配置。 + static void DrawOriginDot(Vector3 pos, Color color) + { + Handles.color = color; + Handles.DrawSolidDisc(pos, Vector3.forward, 0.04f); + } + + /// + /// 仅当颜色字段为 Unity 默认值 Color(0,0,0,0)(全零 = 未配置)时, + /// 返回易辨识的紫色回退(alpha=1);用户明确设置的任何颜色(包括近黑色)均原样保留。 + /// + static Color ResolveGizmoColor(Color c) + { + bool isDefault = c.r + c.g + c.b + c.a < 0.01f; + return isDefault ? new Color(0.85f, 0.3f, 1.0f, 1.0f) : c; + } + + static void DrawBatchLOSGizmo(PhysicsPerceptionSystem system, Vector3 slotCenter, + PhysicsPerceptionSystem.PerceptionSlot slot, Color slotColor, bool isSelected) + { + // slotColor 已经过 ResolveGizmoColor 处理,alpha 已由调用方设为 outline alpha。 + + // 所有 gizmo 元素统一画在 slotCenter(由 slot.offset 控制), + // 与 LOSOrigin(实际射线起点)解耦——gizmo 跟 offset 走。 + float facingSign = system.transform.localScale.x < 0f ? -1f : 1f; + + // ── 最大检测范围圆(slot.radius > 0 时)── + if (slot.radius > 0f) + { + Color fill = slotColor; fill.a = isSelected ? 0.08f : 0.03f; + Handles.color = fill; + Handles.DrawSolidDisc(slotCenter, Vector3.forward, slot.radius); + Color rim = slotColor; rim.a = isSelected ? 0.70f : 0.30f; + Handles.color = rim; + Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius); + } + + // ── 眼睛中心圆点 ── + Handles.color = slotColor; + Handles.DrawSolidDisc(slotCenter, Vector3.forward, 0.08f); + + // ── 朝向指示箭头(沿 facingSign 方向,明确"方向性"视线感知)── + float arrowLen = slot.radius > 0f ? Mathf.Min(slot.radius * 0.6f, 0.6f) : 0.4f; + Vector3 fwdArrow = new Vector3(facingSign * arrowLen, 0f, 0f); + Handles.DrawLine(slotCenter, slotCenter + fwdArrow); + + Vector3 arrowTip = slotCenter + fwdArrow; + float headLen = 0.08f; + Handles.DrawLine(arrowTip, arrowTip + new Vector3(-facingSign * headLen, headLen, 0f)); + Handles.DrawLine(arrowTip, arrowTip + new Vector3(-facingSign * headLen, -headLen, 0f)); + + // ── 放射线(仅选中时显示,避免杂乱)── + if (isSelected) + { + float innerR = 0.12f; + float outerR = slot.radius > 0f ? Mathf.Min(slot.radius, 0.30f) : 0.26f; + for (int i = 0; i < 8; i++) + { + float deg = i * 45f; + float rad = deg * Mathf.Deg2Rad; + Vector3 dir = new Vector3(Mathf.Cos(rad), Mathf.Sin(rad), 0f); + Handles.DrawLine(slotCenter + dir * innerR, slotCenter + dir * outerR); + } + } + + // ── 运行时:视线连线;绿 = 可见 / 红 = 遮挡 ── + if (!Application.isPlaying) return; + var owner = system.EditorOwner ?? system.GetComponentInParent(); + if (owner == null || owner.PlayerTransform == null) return; + + bool visible = owner.IsPlayerVisible(); + Color rayCol = visible + ? new Color(0.2f, 1.0f, 0.3f, 0.9f) + : new Color(1.0f, 0.3f, 0.3f, 0.45f); + + Handles.color = rayCol; + Handles.DrawDottedLine(slotCenter, owner.PlayerTransform.position, 3f); + + if (visible) + Handles.DrawSolidDisc(owner.PlayerTransform.position, Vector3.forward, 0.09f); + } + + static void DrawFanGizmo(Vector3 center, float facingSign, + PhysicsPerceptionSystem.PerceptionSlot slot, Color fill, Color outline) + { + float halfAngle = slot.fanAngle * 0.5f; + Vector3 fromDir = RotateVec3(new Vector3(facingSign, 0f, 0f), -halfAngle); + + Handles.color = fill; + Handles.DrawSolidArc(center, Vector3.forward, fromDir, slot.fanAngle, slot.radius); + Handles.color = outline; + Handles.DrawWireArc(center, Vector3.forward, fromDir, slot.fanAngle, slot.radius); + + // Edge rays for clarity + Vector3 edgeL = RotateVec3(new Vector3(facingSign, 0f, 0f), -halfAngle) * slot.radius; + Vector3 edgeR = RotateVec3(new Vector3(facingSign, 0f, 0f), halfAngle) * slot.radius; + Handles.DrawLine(center, center + edgeL); + Handles.DrawLine(center, center + edgeR); + } + + static void DrawBoxGizmo(Vector3 center, float facingSign, + PhysicsPerceptionSystem.PerceptionSlot slot, Color fill, Color outline) + { + Vector3 boxCenter = center + new Vector3(slot.boxOffset.x * facingSign, slot.boxOffset.y, 0f); + float hw = slot.boxSize.x * 0.5f; + float hh = slot.boxSize.y * 0.5f; + + Vector3[] corners = + { + 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); + } + + static Vector3 RotateVec3(Vector3 v, float angleDeg) + { + float rad = angleDeg * Mathf.Deg2Rad; + float cos = Mathf.Cos(rad); + float sin = Mathf.Sin(rad); + return new Vector3(cos * v.x - sin * v.y, sin * v.x + cos * v.y, 0f); + } + + // ── Inspector ───────────────────────────────────────────────────────── + + public override void OnInspectorGUI() + { + DrawDefaultInspector(); + + if (!Application.isPlaying) return; + + var system = (PhysicsPerceptionSystem)target; + var detected = system.EditorDetected; + if (detected == null || detected.Count == 0) return; + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("── 实时检测结果 ──", EditorStyles.boldLabel); + + var sb = new StringBuilder(); + foreach (var kvp in detected) + { + sb.Clear(); + if (kvp.Value.Count > 0) + { + sb.Append("✓"); + foreach (var go in kvp.Value) + sb.Append(' ').Append(go != null ? go.name : "null"); + } + EditorGUILayout.LabelField(kvp.Key, kvp.Value.Count > 0 ? sb.ToString() : "—"); + } + + Repaint(); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Enemies/PhysicsPerceptionSystemEditor.cs.meta b/Assets/_Game/Scripts/Editor/Enemies/PhysicsPerceptionSystemEditor.cs.meta new file mode 100644 index 0000000..c57228d --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Enemies/PhysicsPerceptionSystemEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2cd6fa1109af6d04fafb6c1556eea81d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs b/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs index 64becfa..2d2b2f3 100644 --- a/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs +++ b/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Reflection; using Animancer; using BaseGames.Boss; @@ -36,6 +36,9 @@ namespace BaseGames.Editor /// public static class SceneObjectPlacerTool { + // ── 碰撞器类型 ──────────────────────────────────────────────────────── + public enum EnemyBodyColliderType { Box, Capsule, Circle } + // ══ 菜单入口 ══════════════════════════════════════════════════════════ [MenuItem("BaseGames/Scene/Place/Player", priority = 100)] @@ -246,7 +249,9 @@ namespace BaseGames.Editor } [MenuItem("BaseGames/Scene/Place/Enemy (Basic)", priority = 110)] - public static void PlaceEnemy() + public static void PlaceEnemy() => PlaceEnemy(EnemyBodyColliderType.Box); + + public static void PlaceEnemy(EnemyBodyColliderType bodyCollider) { var report = new List(); int undoGroup = Undo.GetCurrentGroup(); @@ -264,10 +269,12 @@ namespace BaseGames.Editor rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; - GetOrAddComponent(go); - GetOrAddComponent(go); - AnimancerComponent animancer = GetOrAddComponent(go); - SpriteRenderer sr = SetupSpriteRenderer(go); + Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.7f, 0.9f)); + Transform visual = GetOrCreateChild(go.transform, "Visual"); + visual.localPosition = (Vector3)(Vector2)body.offset; + GetOrAddComponent(visual.gameObject); + AnimancerComponent animancer = GetOrAddComponent(visual.gameObject); + SpriteRenderer sr = SetupSpriteRenderer(visual.gameObject); EnemyBase enemyBase = GetOrAddComponent(go); EnemyStats enemyStats = GetOrAddComponent(go); @@ -275,7 +282,7 @@ namespace BaseGames.Editor GetOrAddComponent(go); GetOrAddComponent(go); GetOrAddComponent(go); // required by EnemyNavAgent [RequireComponent] - GetOrAddComponent(go); + PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); // HurtBox child Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); @@ -306,6 +313,7 @@ namespace BaseGames.Editor AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed"); // Wire EnemyMovement + AssignReference(movement, "_visualRoot", visual, report); AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_spriteRenderer", sr, report); AssignLayerMask(movement, "_groundMask", @@ -319,6 +327,7 @@ namespace BaseGames.Editor else report.Add("未找到 DamageSourceSO,HitBox_Body._defaultSource 未绑定。请创建 CMB_DS_EnemyBody.asset。"); + SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "los" }, report); report.Add("★ 指定 EnemyBase._statsSO、_animConfig 资产(按所创建的敌人类型命名)。"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定对应 .asset。"); @@ -329,7 +338,9 @@ namespace BaseGames.Editor } [MenuItem("BaseGames/Scene/Place/Boss Enemy", priority = 115)] - public static void PlaceBossEnemy() + public static void PlaceBossEnemy() => PlaceBossEnemy(EnemyBodyColliderType.Box); + + public static void PlaceBossEnemy(EnemyBodyColliderType bodyCollider) { var report = new List(); @@ -345,12 +356,13 @@ namespace BaseGames.Editor rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; rb.interpolation = RigidbodyInterpolation2D.Interpolate; - GetOrAddComponent(go); + CreateBodyCollider(go, bodyCollider, new Vector2(1.5f, 2.5f)); GetOrAddComponent(go); SetupSpriteRenderer(go); BossBase bossBase = GetOrAddComponent(go); EnemyStats bossStats = GetOrAddComponent(go); + PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); // HurtBox child Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); @@ -388,6 +400,7 @@ namespace BaseGames.Editor AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt"); AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed"); + SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report); report.Add("填写 _bossId。"); report.Add("挂载 BossSkillSequencer 组件并指定技能序列 SO;行为树、NavAgent 需手工添加。"); report.Add("多阶段 Boss 可在此 GameObject 上继续 AddComponent 阶段切换控制器。"); @@ -399,7 +412,9 @@ namespace BaseGames.Editor // ══ 具体敌人快速放置 ════════════════════════════════════════════════════ [MenuItem("BaseGames/Scene/Place/Enemy E001 (草蛭)", priority = 111)] - public static void PlaceE001_CaoZhi() + public static void PlaceE001_CaoZhi() => PlaceE001_CaoZhi(EnemyBodyColliderType.Box); + + public static void PlaceE001_CaoZhi(EnemyBodyColliderType bodyCollider) { var report = new List(); int undoGroup = Undo.GetCurrentGroup(); @@ -416,12 +431,12 @@ namespace BaseGames.Editor rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; - CapsuleCollider2D body = GetOrAddComponent(go); - body.size = new Vector2(0.6f, 0.8f); - - GetOrAddComponent(go); - AnimancerComponent animancer = GetOrAddComponent(go); - SpriteRenderer sr1 = SetupSpriteRenderer(go); + Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.6f, 0.8f)); + Transform visual = GetOrCreateChild(go.transform, "Visual"); + visual.localPosition = (Vector3)(Vector2)body.offset; + GetOrAddComponent(visual.gameObject); + AnimancerComponent animancer = GetOrAddComponent(visual.gameObject); + SpriteRenderer sr1 = SetupSpriteRenderer(visual.gameObject); EnemyBase enemyBase = GetOrAddComponent(go); EnemyStats enemyStats = GetOrAddComponent(go); @@ -429,7 +444,7 @@ namespace BaseGames.Editor GetOrAddComponent(go); GetOrAddComponent(go); GetOrAddComponent(go); // required by EnemyNavAgent [RequireComponent] - EnemySensorHub sensorHub = GetOrAddComponent(go); + PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); @@ -469,6 +484,7 @@ namespace BaseGames.Editor AssignAsset(movement, "_config", report, false, "ENM_E001_Stats"); AssignAsset(movement, "_animConfig", report, false, "ENM_E001_AnimConfig"); + AssignReference(movement, "_visualRoot", visual, report); AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_spriteRenderer", sr1, report); AssignLayerMask(movement, "_groundMask", @@ -478,13 +494,11 @@ namespace BaseGames.Editor AssignAsset(alertAbility, "_config", report, false, "ABL_E001_Alert"); AssignAsset(chaseAbility, "_config", report, false, "ABL_E001_Chase"); AssignReference(chaseAbility, "_contactDamage", bodyContact, report); - AssignReference(chaseAbility, "_sensorHub", sensorHub, report); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); if (dmgSrc != null) AssignReference(contactHitBox, "_defaultSource", dmgSrc, report); - SetupSensorHubSlotNames(sensorHub, new[] { "aggro", "wall_ahead", "ledge" }, report); - report.Add("★ 在 EnemySensorHub Inspector 中绑定 Sensor 子节点(aggro/wall_ahead/ledge)。"); + SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "los" }, report); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E001_CaoZhi.asset。"); Undo.CollapseUndoOperations(undoGroup); @@ -494,7 +508,9 @@ namespace BaseGames.Editor } [MenuItem("BaseGames/Scene/Place/Enemy E002 (簧蛭)", priority = 112)] - public static void PlaceE002_HuangZhi() + public static void PlaceE002_HuangZhi() => PlaceE002_HuangZhi(EnemyBodyColliderType.Box); + + public static void PlaceE002_HuangZhi(EnemyBodyColliderType bodyCollider) { var report = new List(); int undoGroup = Undo.GetCurrentGroup(); @@ -509,18 +525,24 @@ namespace BaseGames.Editor rb.bodyType = RigidbodyType2D.Kinematic; rb.gravityScale = 0f; - CapsuleCollider2D body = GetOrAddComponent(go); - body.size = new Vector2(0.5f, 0.7f); + Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.5f, 0.7f)); - GetOrAddComponent(go); - AnimancerComponent animancer = GetOrAddComponent(go); - SetupSpriteRenderer(go); + // Visual 子节点:挂载精灵 / 动画(EnemyMovement 翻转时操作此节点) + Transform visual = GetOrCreateChild(go.transform, "Visual"); + visual.localPosition = (Vector3)(Vector2)body.offset; + GetOrAddComponent(visual.gameObject); + AnimancerComponent animancer = GetOrAddComponent(visual.gameObject); + SpriteRenderer sr = SetupSpriteRenderer(visual.gameObject); EnemyBase enemyBase = GetOrAddComponent(go); EnemyStats enemyStats = GetOrAddComponent(go); - EnemySensorHub sensorHub = GetOrAddComponent(go); + EnemyMovement movement = GetOrAddComponent(go); + GetOrAddComponent(go); + GetOrAddComponent(go); + GetOrAddComponent(go); // required by EnemyNavAgent [RequireComponent] + PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); - // HurtBox(初始禁用,附着天花板时不受伤) + // HurtBox(初始禁用,悬挂阶段无法被攻击) Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); CapsuleCollider2D hurtCap = GetOrAddComponent(hurtBoxT.gameObject); @@ -529,24 +551,35 @@ namespace BaseGames.Editor HurtBox hurtBox = GetOrAddComponent(hurtBoxT.gameObject); hurtBoxT.gameObject.SetActive(false); - // AttackHitBox(下坠发动时由能力启用) - Transform atkT = GetOrCreateChild(go.transform, "AttackHitBox"); - SetLayer(atkT.gameObject, "EnemyHitBox", report); - BoxCollider2D atkCol = GetOrAddComponent(atkT.gameObject); - atkCol.isTrigger = true; - atkCol.size = new Vector2(0.5f, 0.5f); - HitBox atkHitBox = GetOrAddComponent(atkT.gameObject); - atkT.gameObject.SetActive(false); + // LandingHitBox(落地瞬间 AoE,由 CeilingDropAbility 激活) + Transform landingHitBoxT = GetOrCreateChild(go.transform, "LandingHitBox"); + SetLayer(landingHitBoxT.gameObject, "EnemyHitBox", report); + BoxCollider2D landingCol = GetOrAddComponent(landingHitBoxT.gameObject); + landingCol.isTrigger = true; + landingCol.size = new Vector2(0.8f, 0.3f); + HitBox landingHitBox = GetOrAddComponent(landingHitBoxT.gameObject); + landingHitBoxT.gameObject.SetActive(false); + + // ContactDamageZone(地面巡逻时造成接触伤害,落地后由行为树启用) + Transform contactT = GetOrCreateChild(go.transform, "ContactDamageZone"); + SetLayer(contactT.gameObject, "EnemyHitBox", report); + CircleCollider2D contactCol = GetOrAddComponent(contactT.gameObject); + contactCol.isTrigger = true; + contactCol.radius = 0.35f; + HitBox contactHitBox = GetOrAddComponent(contactT.gameObject); + GetOrAddComponent(contactT.gameObject); + contactT.gameObject.SetActive(false); Transform abilitiesT = GetOrCreateChild(go.transform, "Abilities"); - Transform strikeT = GetOrCreateChild(abilitiesT, "CeilingHangStrikeAbility"); - CeilingHangStrikeAbility strikeAbility = GetOrAddComponent(strikeT.gameObject); + Transform dropT = GetOrCreateChild(abilitiesT, "CeilingDropAbility"); + CeilingDropAbility dropAbility = GetOrAddComponent(dropT.gameObject); // SOs — assign first so OnValidate doesn't warn during wiring - AssignAsset(enemyBase, "_statsSO", report, false, "ENM_E002_Stats"); - AssignAsset(enemyBase, "_animConfig", report, false, "ENM_E002_AnimConfig"); + AssignAsset(enemyBase, "_statsSO", report, false, "ENM_E002_Stats"); + AssignAsset(enemyBase, "_animConfig", report, false, "ENM_E002_AnimConfig"); AssignReference(enemyBase, "_stats", enemyStats, report); + AssignReference(enemyBase, "_movement", movement, report); AssignReference(enemyBase, "_animancer", animancer, report); AssignReference(enemyBase, "_hurtBox", hurtBox, report); AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied"); @@ -555,16 +588,32 @@ namespace BaseGames.Editor AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt"); AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed"); - AssignAsset(strikeAbility, "_config", report, false, "ABL_E002_Strike"); - AssignReference(strikeAbility, "_attackHitBox", atkHitBox, report); - AssignReference(strikeAbility, "_hurtBox", hurtBox, report); + AssignAsset(movement, "_config", report, false, "ENM_E002_Stats"); + AssignAsset(movement, "_animConfig", report, false, "ENM_E002_AnimConfig"); + AssignReference(movement, "_visualRoot", visual, report); + AssignReference(movement, "_animancer", animancer, report); + AssignReference(movement, "_spriteRenderer", sr, report); + AssignLayerMask(movement, "_groundMask", + new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" }, + report); + + AssignReference(dropAbility, "_landingHitBox", landingHitBox, report); + AssignLayerMask(dropAbility, "_groundMask", + new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" }, + report); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); - if (dmgSrc != null) AssignReference(atkHitBox, "_defaultSource", dmgSrc, report); + if (dmgSrc != null) + { + AssignReference(landingHitBox, "_defaultSource", dmgSrc, report); + AssignReference(contactHitBox, "_defaultSource", dmgSrc, report); + } - SetupSensorHubSlotNames(sensorHub, new[] { "attack_range" }, report); + SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_range" }, report); report.Add("★ 将此对象放置于天花板,调整位置使 CapsuleCollider 正好贴合天花板底面。"); + report.Add("★ HurtBox / ContactDamageZone 初始禁用;落地后由行为树激活。"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E002_HuangZhi.asset。"); + report.Add("★ BD 树逻辑建议:Idle(悬挂)→ IsSensorDetecting(aggro) → UseAbility(CeilingDrop) → IsGrounded → Patrol(Pace)。"); Undo.CollapseUndoOperations(undoGroup); Selection.activeGameObject = go; @@ -573,7 +622,9 @@ namespace BaseGames.Editor } [MenuItem("BaseGames/Scene/Place/Enemy E003 (幼蛭)", priority = 113)] - public static void PlaceE003_YouZhi_Enemy() + public static void PlaceE003_YouZhi_Enemy() => PlaceE003_YouZhi_Enemy(EnemyBodyColliderType.Box); + + public static void PlaceE003_YouZhi_Enemy(EnemyBodyColliderType bodyCollider) { var report = new List(); int undoGroup = Undo.GetCurrentGroup(); @@ -588,12 +639,12 @@ namespace BaseGames.Editor rb.bodyType = RigidbodyType2D.Kinematic; rb.gravityScale = 0f; - CapsuleCollider2D body = GetOrAddComponent(go); - body.size = new Vector2(0.5f, 0.6f); - - GetOrAddComponent(go); - AnimancerComponent animancer = GetOrAddComponent(go); - SpriteRenderer sr3 = SetupSpriteRenderer(go); + Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.5f, 0.6f)); + Transform visual = GetOrCreateChild(go.transform, "Visual"); + visual.localPosition = (Vector3)(Vector2)body.offset; + GetOrAddComponent(visual.gameObject); + AnimancerComponent animancer = GetOrAddComponent(visual.gameObject); + SpriteRenderer sr3 = SetupSpriteRenderer(visual.gameObject); E003_YouZhi enemyBase = GetOrAddComponent(go); EnemyStats enemyStats = GetOrAddComponent(go); @@ -601,7 +652,7 @@ namespace BaseGames.Editor GetOrAddComponent(go); GetOrAddComponent(go); GetOrAddComponent(go); // required by EnemyNavAgent [RequireComponent] - EnemySensorHub sensorHub = GetOrAddComponent(go); + PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); @@ -638,6 +689,7 @@ namespace BaseGames.Editor AssignAsset(movement, "_config", report, false, "ENM_E003_Stats"); AssignAsset(movement, "_animConfig", report, false, "ENM_E003_AnimConfig"); + AssignReference(movement, "_visualRoot", visual, report); AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_spriteRenderer", sr3, report); AssignLayerMask(movement, "_groundMask", @@ -650,7 +702,7 @@ namespace BaseGames.Editor Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); if (dmgSrc != null) AssignReference(contactHitBox, "_defaultSource", dmgSrc, report); - SetupSensorHubSlotNames(sensorHub, new[] { "aggro" }, report); + SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "los" }, report); report.Add("★ 将此对象放置于天花板下方,E003_YouZhi 会在 OnSpawn/ActivateFromCeiling 时执行下坠。"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E003_YouZhi.asset。"); @@ -661,7 +713,9 @@ namespace BaseGames.Editor } [MenuItem("BaseGames/Scene/Place/Enemy E004 (蛭母)", priority = 114)] - public static void PlaceE004_ZhiMu_Enemy() + public static void PlaceE004_ZhiMu_Enemy() => PlaceE004_ZhiMu_Enemy(EnemyBodyColliderType.Box); + + public static void PlaceE004_ZhiMu_Enemy(EnemyBodyColliderType bodyCollider) { var report = new List(); int undoGroup = Undo.GetCurrentGroup(); @@ -678,12 +732,12 @@ namespace BaseGames.Editor rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; - CapsuleCollider2D body = GetOrAddComponent(go); - body.size = new Vector2(0.8f, 1.2f); - - GetOrAddComponent(go); - AnimancerComponent animancer = GetOrAddComponent(go); - SpriteRenderer sr4 = SetupSpriteRenderer(go); + Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.8f, 1.2f)); + Transform visual = GetOrCreateChild(go.transform, "Visual"); + visual.localPosition = (Vector3)(Vector2)body.offset; + GetOrAddComponent(visual.gameObject); + AnimancerComponent animancer = GetOrAddComponent(visual.gameObject); + SpriteRenderer sr4 = SetupSpriteRenderer(visual.gameObject); E004_ZhiMu enemyBase = GetOrAddComponent(go); EnemyStats enemyStats = GetOrAddComponent(go); @@ -692,7 +746,7 @@ namespace BaseGames.Editor GetOrAddComponent(go); GetOrAddComponent(go); GetOrAddComponent(go); // required by EnemyNavAgent [RequireComponent] - EnemySensorHub sensorHub = GetOrAddComponent(go); + PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); @@ -756,6 +810,7 @@ namespace BaseGames.Editor AssignAsset(movement, "_config", report, false, "ENM_E004_Stats"); AssignAsset(movement, "_animConfig", report, false, "ENM_E004_AnimConfig"); + AssignReference(movement, "_visualRoot", visual, report); AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_spriteRenderer", sr4, report); AssignLayerMask(movement, "_groundMask", @@ -772,7 +827,6 @@ namespace BaseGames.Editor AssignReference(slamAbl, "_hitBox", slamHitBox, report); AssignReference(acidAbl, "_muzzle", acidMuzzleT, report); AssignReference(chargeAbl, "_chargeHitBox", chargeHitBox, report); - AssignReference(chaseAbl, "_sensorHub", sensorHub, report); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); if (dmgSrc != null) @@ -782,7 +836,7 @@ namespace BaseGames.Editor AssignReference(chargeHitBox, "_defaultSource", dmgSrc, report); } - SetupSensorHubSlotNames(sensorHub, new[] { "aggro", "los" }, report); + SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E004_ZhiMu.asset。"); Undo.CollapseUndoOperations(undoGroup); @@ -792,7 +846,9 @@ namespace BaseGames.Editor } [MenuItem("BaseGames/Scene/Place/Enemy E005 (肥蛭)", priority = 115)] - public static void PlaceE005_FeiZhi_Enemy() + public static void PlaceE005_FeiZhi_Enemy() => PlaceE005_FeiZhi_Enemy(EnemyBodyColliderType.Box); + + public static void PlaceE005_FeiZhi_Enemy(EnemyBodyColliderType bodyCollider) { var report = new List(); int undoGroup = Undo.GetCurrentGroup(); @@ -809,12 +865,12 @@ namespace BaseGames.Editor rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; - CapsuleCollider2D body = GetOrAddComponent(go); - body.size = new Vector2(0.9f, 1.0f); - - GetOrAddComponent(go); - AnimancerComponent animancer = GetOrAddComponent(go); - SpriteRenderer sr5 = SetupSpriteRenderer(go); + Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.9f, 1.0f)); + Transform visual = GetOrCreateChild(go.transform, "Visual"); + visual.localPosition = (Vector3)(Vector2)body.offset; + GetOrAddComponent(visual.gameObject); + AnimancerComponent animancer = GetOrAddComponent(visual.gameObject); + SpriteRenderer sr5 = SetupSpriteRenderer(visual.gameObject); E005_FeiZhi enemyBase = GetOrAddComponent(go); EnemyStats enemyStats = GetOrAddComponent(go); @@ -822,7 +878,7 @@ namespace BaseGames.Editor GetOrAddComponent(go); GetOrAddComponent(go); GetOrAddComponent(go); // required by EnemyNavAgent [RequireComponent] - EnemySensorHub sensorHub = GetOrAddComponent(go); + PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); @@ -863,6 +919,7 @@ namespace BaseGames.Editor AssignAsset(movement, "_config", report, false, "ENM_E005_Stats"); AssignAsset(movement, "_animConfig", report, false, "ENM_E005_AnimConfig"); + AssignReference(movement, "_visualRoot", visual, report); AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_spriteRenderer", sr5, report); AssignLayerMask(movement, "_groundMask", @@ -878,7 +935,7 @@ namespace BaseGames.Editor Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); if (dmgSrc != null) AssignReference(biteHitBox, "_defaultSource", dmgSrc, report); - SetupSensorHubSlotNames(sensorHub, new[] { "aggro" }, report); + SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report); report.Add("★ 在 E005_FeiZhi._deathPreClip 上添加 AnimationEvent 调用 SpawnProjectile(\"spawn_e003\")。"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E005_FeiZhi.asset。"); @@ -889,7 +946,9 @@ namespace BaseGames.Editor } [MenuItem("BaseGames/Scene/Place/Enemy E006 (讙)", priority = 116)] - public static void PlaceE006_Huan() + public static void PlaceE006_Huan() => PlaceE006_Huan(EnemyBodyColliderType.Box); + + public static void PlaceE006_Huan(EnemyBodyColliderType bodyCollider) { var report = new List(); int undoGroup = Undo.GetCurrentGroup(); @@ -906,12 +965,12 @@ namespace BaseGames.Editor rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; - CapsuleCollider2D body = GetOrAddComponent(go); - body.size = new Vector2(0.7f, 1.0f); - - GetOrAddComponent(go); - AnimancerComponent animancer = GetOrAddComponent(go); - SpriteRenderer sr6 = SetupSpriteRenderer(go); + Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.7f, 1.0f)); + Transform visual = GetOrCreateChild(go.transform, "Visual"); + visual.localPosition = (Vector3)(Vector2)body.offset; + GetOrAddComponent(visual.gameObject); + AnimancerComponent animancer = GetOrAddComponent(visual.gameObject); + SpriteRenderer sr6 = SetupSpriteRenderer(visual.gameObject); EnemyBase enemyBase = GetOrAddComponent(go); EnemyStats enemyStats = GetOrAddComponent(go); @@ -919,7 +978,7 @@ namespace BaseGames.Editor GetOrAddComponent(go); GetOrAddComponent(go); GetOrAddComponent(go); // required by EnemyNavAgent [RequireComponent] - EnemySensorHub sensorHub = GetOrAddComponent(go); + PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); @@ -966,6 +1025,7 @@ namespace BaseGames.Editor AssignAsset(movement, "_config", report, false, "ENM_E006_Stats"); AssignAsset(movement, "_animConfig", report, false, "ENM_E006_AnimConfig"); + AssignReference(movement, "_visualRoot", visual, report); AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_spriteRenderer", sr6, report); AssignLayerMask(movement, "_groundMask", @@ -977,7 +1037,6 @@ namespace BaseGames.Editor AssignReference(leapAbl, "_landingHitBox", landHitBox, report); AssignReference(chaseAbl, "_contactDamage", bodyContact, report); - AssignReference(chaseAbl, "_sensorHub", sensorHub, report); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); if (dmgSrc != null) @@ -986,7 +1045,7 @@ namespace BaseGames.Editor AssignReference(landHitBox, "_defaultSource", dmgSrc, report); } - SetupSensorHubSlotNames(sensorHub, new[] { "aggro", "wall_ahead", "ledge" }, report); + SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "los" }, report); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E006_Huan.asset。"); Undo.CollapseUndoOperations(undoGroup); @@ -996,7 +1055,9 @@ namespace BaseGames.Editor } [MenuItem("BaseGames/Scene/Place/Boss 嘲风 (ChaoFeng)", priority = 117)] - public static void PlaceChaoFeng() + public static void PlaceChaoFeng() => PlaceChaoFeng(EnemyBodyColliderType.Box); + + public static void PlaceChaoFeng(EnemyBodyColliderType bodyCollider) { var report = new List(); int undoGroup = Undo.GetCurrentGroup(); @@ -1014,12 +1075,12 @@ namespace BaseGames.Editor rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; rb.interpolation = RigidbodyInterpolation2D.Interpolate; - CapsuleCollider2D body = GetOrAddComponent(go); - body.size = new Vector2(1.2f, 2.0f); - - GetOrAddComponent(go); - AnimancerComponent animancer = GetOrAddComponent(go); - SpriteRenderer srBoss = SetupSpriteRenderer(go); + Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(1.2f, 2.0f)); + Transform visual = GetOrCreateChild(go.transform, "Visual"); + visual.localPosition = (Vector3)(Vector2)body.offset; + GetOrAddComponent(visual.gameObject); + AnimancerComponent animancer = GetOrAddComponent(visual.gameObject); + SpriteRenderer srBoss = SetupSpriteRenderer(visual.gameObject); ChaoFengBoss bossBase = GetOrAddComponent(go); EnemyStats bossStats = GetOrAddComponent(go); @@ -1031,7 +1092,7 @@ namespace BaseGames.Editor BossSkillExecutor skillExec = GetOrAddComponent(go); ChaoFengFloatController floatCtrl = GetOrAddComponent(go); ChaoFengKnockdownCounter knockdown = GetOrAddComponent(go); - EnemySensorHub sensorHub = GetOrAddComponent(go); + PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); // HurtBox Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); @@ -1078,6 +1139,7 @@ namespace BaseGames.Editor AssignAsset(movement, "_config", report, false, "ENM_ChaoFeng_Stats"); AssignAsset(movement, "_animConfig", report, false, "ENM_ChaoFeng_AnimConfig"); + AssignReference(movement, "_visualRoot", visual, report); AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_spriteRenderer", srBoss, report); AssignLayerMask(movement, "_groundMask", @@ -1103,7 +1165,7 @@ namespace BaseGames.Editor if (hb != null) AssignReference(hb, "_defaultSource", dmgSrc, report); } - SetupSensorHubSlotNames(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report); + SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report); report.Add("★ 设置 BossSkillExecutor._bossId = \"ChaoFeng\"。"); report.Add("★ 将各 Phase1 HitBox 引用拖入 BossSkillExecutor._hitBoxes 数组。"); @@ -1145,20 +1207,56 @@ namespace BaseGames.Editor } /// - /// 设置 EnemySensorHub._slots 的 slotName 字段(Sensor 引用需在 Inspector 中手工绑定)。 + /// 在 上预填充 _slots 数组, + /// 根据 slotName 自动选择类型、半径、检测层及 GizmoColor。 /// - private static void SetupSensorHubSlotNames(EnemySensorHub hub, string[] slotNames, List report) + private static void SetupPerceptionSystemSlots(PhysicsPerceptionSystem system, string[] slotNames, List report) { - var so = new SerializedObject(hub); + var so = new SerializedObject(system); var slots = so.FindProperty("_slots"); if (slots == null || !slots.isArray) { - report?.Add("EnemySensorHub._slots 字段未找到,传感器槽位需手工配置。"); + report?.Add("PhysicsPerceptionSystem._slots 字段未找到,请检查脚本序列化。"); return; } + + int playerLayer = LayerMask.GetMask("Player"); + slots.arraySize = slotNames.Length; for (int i = 0; i < slotNames.Length; i++) - slots.GetArrayElementAtIndex(i).FindPropertyRelative("slotName").stringValue = slotNames[i]; + { + var elem = slots.GetArrayElementAtIndex(i); + string name = slotNames[i]; + + elem.FindPropertyRelative("slotName").stringValue = name; + + int enumIdx = 0; // RangeCircle + float radius = 3f; + int layer = playerLayer; + + switch (name) + { + case "aggro": enumIdx = 0; radius = 5f; layer = playerLayer; break; + case "los": enumIdx = 1; radius = 0f; layer = 0; break; + case "attack_melee":enumIdx = 0; radius = 1.5f; layer = playerLayer; break; + case "attack_range":enumIdx = 0; radius = 8f; layer = playerLayer; break; + } + + elem.FindPropertyRelative("type").enumValueIndex = enumIdx; + elem.FindPropertyRelative("radius").floatValue = radius; + elem.FindPropertyRelative("detectLayer").intValue = layer; + + // 各 slot 分配语义化默认颜色,可在 Inspector 中按需覆盖 + Color defaultColor = name switch + { + "aggro" => new Color(1.00f, 0.60f, 0.10f, 1f), // 橙 + "los" => new Color(0.00f, 0.80f, 1.00f, 1f), // 青 + "attack_melee" => new Color(1.00f, 0.20f, 0.20f, 1f), // 红 + "attack_range" => new Color(1.00f, 0.40f, 0.60f, 1f), // 粉红 + _ => Color.clear, // 未知 slot 回退为紫色 + }; + elem.FindPropertyRelative("gizmoColor").colorValue = defaultColor; + } so.ApplyModifiedPropertiesWithoutUndo(); } @@ -1713,6 +1811,25 @@ namespace BaseGames.Editor return Vector3.zero; } + private static Collider2D CreateBodyCollider(GameObject go, EnemyBodyColliderType type, Vector2 size) + { + switch (type) + { + case EnemyBodyColliderType.Capsule: + var cap = GetOrAddComponent(go); + cap.size = size; + return cap; + case EnemyBodyColliderType.Circle: + var cir = GetOrAddComponent(go); + cir.radius = Mathf.Min(size.x, size.y) * 0.5f; + return cir; + default: // Box + var box = GetOrAddComponent(go); + box.size = size; + return box; + } + } + private static T GetOrAddComponent(GameObject go) where T : Component { T comp = go.GetComponent(); diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs b/Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs index 5d8a95d..4ae1171 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs @@ -79,6 +79,11 @@ namespace BaseGames.Enemies.AI Vector2 playerPos = _enemy.PlayerTransform.position; + // 若配置了巡逻区域,且玩家超出追击边界 → 放弃追击(优先级高于纯距离限制) + var zone = _enemy.PatrolZone; + if (zone != null && !zone.ContainsChase(playerPos)) + return TaskStatus.Failure; + if (_enemy.IsPlayerVisible()) { // 视线恢复:Searching → Tracking,恢复奔跑速度 diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsNearEdge.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsNearEdge.cs index d4f2934..1e95000 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_IsNearEdge.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsNearEdge.cs @@ -11,7 +11,7 @@ namespace BaseGames.Enemies.AI /// [TaskName("Is Near Edge?")] [TaskCategory("BaseGames/Enemy/State")] - [TaskDescription("检查前方是否有悬崖边缘(基于 SensorToolkit 或 Raycast)")] + [TaskDescription("检查前方是否有悬崖边缘(基于 EnemyNavAgent Raycast 检测)")] public class BD_IsNearEdge : Conditional { private EnemyBase _enemy; diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsOutsideZone.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsOutsideZone.cs new file mode 100644 index 0000000..ad4b5c1 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsOutsideZone.cs @@ -0,0 +1,61 @@ +#if GRAPH_DESIGNER +using UnityEngine; +using Opsive.BehaviorDesigner.Runtime.Tasks; +using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals; +using BaseGames.Enemies; + +namespace BaseGames.Enemies.AI +{ + /// + /// BD Conditional:判断目标坐标是否超出指定区域边界。 + /// + /// + /// 未配置 PatrolZone 时返回 Failure(表示"无限制",等同于不超界)。 + /// 超界 → Success;区域内 → Failure。 + /// + /// + /// 典型用法:在 Patrol BT 子树中用 BD_IsOutsideZone 检查敌人坐标, + /// 超出巡逻区域时触发归位序列。 + /// + [TaskName("Is Outside Zone")] + [TaskCategory("BaseGames/Enemy/Zone")] + [TaskDescription("判断敌人/玩家坐标是否超出巡逻或追击区域;无 Zone 时返回 Failure(不限制)")] + public sealed class BD_IsOutsideZone : Conditional + { + [Tooltip("true = 检查追击区域,false = 检查巡逻区域")] + [SerializeField] private bool m_CheckChaseZone = false; + + [Tooltip("true = 检查敌人自身坐标,false = 检查玩家坐标")] + [SerializeField] private bool m_CheckEnemy = true; + + private EnemyBase _enemy; + + public override void OnAwake() => _enemy = gameObject.GetComponent(); + + public override TaskStatus OnUpdate() + { + if (_enemy == null) return TaskStatus.Failure; + + var zone = _enemy.PatrolZone; + if (zone == null) return TaskStatus.Failure; // 无区域 = 不限制 + + Vector2 pos; + if (m_CheckEnemy) + { + pos = _enemy.transform.position; + } + else + { + if (_enemy.PlayerTransform == null) return TaskStatus.Failure; + pos = _enemy.PlayerTransform.position; + } + + bool inside = m_CheckChaseZone + ? zone.ContainsChase(pos) + : zone.ContainsPatrol(pos); + + return inside ? TaskStatus.Failure : TaskStatus.Success; + } + } +} +#endif diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsOutsideZone.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BD_IsOutsideZone.cs.meta new file mode 100644 index 0000000..800a2f5 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsOutsideZone.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bff87912e6defb849bea247df2801172 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs b/Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs index 0681028..0244487 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs @@ -7,23 +7,23 @@ using BaseGames.Enemies.Perception; namespace BaseGames.Enemies.AI { /// - /// 条件:EnemySensorHub 中名为 slotName 的 Sensor 是否检测到目标。 + /// 条件: 中名为 slotName 的槽位是否检测到目标。 /// 若 m_Target 为空则使用 EnemyBase.PlayerTransform。 /// [TaskName("Is Sensor Detecting?")] [TaskCategory("BaseGames/Enemy/Perception")] - [TaskDescription("检查 EnemySensorHub 中指定 Sensor 槽是否检测到目标")] + [TaskDescription("检查 PhysicsPerceptionSystem 中指定 Sensor 槽是否检测到目标")] public sealed class BD_IsSensorDetecting : Conditional { [SerializeField] private string m_SlotName = "aggro"; [SerializeField] private bool m_AnyTarget = false; - private EnemySensorHub _hub; - private EnemyBase _enemy; + private IPerceptionSystem _hub; + private EnemyBase _enemy; public override void OnAwake() { - _hub = gameObject.GetComponent(); + _hub = gameObject.GetComponent(); _enemy = gameObject.GetComponent(); } diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_Patrol.cs b/Assets/_Game/Scripts/Enemies/AI/BD_Patrol.cs index fa34d28..09346cb 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_Patrol.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_Patrol.cs @@ -3,44 +3,38 @@ using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; using BaseGames.Enemies; -using BaseGames.Enemies.Perception; namespace BaseGames.Enemies.AI { /// /// BD Action:来回踱步巡逻——持续向当前方向移动,遇墙或悬崖时自动翻转方向。 - /// 转向检测依赖 EnemySensorHub 的 "wall_ahead" / "ledge" 槽(SensorToolkit)。 + /// 转向检测通过 / + /// 进行;这两项是 EnemyMovement 内置的物理射线检测,不属于感知系统(PhysicsPerceptionSystem)。 /// /// 若需要按预设路点顺序巡逻,请使用 (支持 Transform 引用和内联坐标)。 /// [TaskName("Patrol (Pace)")] [TaskCategory("BaseGames/Enemy/Movement")] - [TaskDescription("来回踱步巡逻:遇墙或悬崖自动翻转方向(需配置 EnemySensorHub wall_ahead / ledge 槽)")] + [TaskDescription("来回踱步巡逻:遇墙或悬崖自动翻转方向(通过 EnemyMovement 物理射线检测,无需感知槽)")] public class BD_Patrol : Action { - private EnemyBase _enemy; - private EnemySensorHub _hub; - private float _dir = 1f; - private float _flipCooldown; // 翻转后短暂冷却,等待 RaySensor2D 刷新到新朝向 - - // 缓存:SensorHub 中对应槽位是否已配置(Awake 时查询一次,避免每帧 Dictionary 查找) - private bool _hasWallSensor; - private bool _hasEdgeSensor; + private EnemyBase _enemy; + private EnemyMovement _movement; + private float _dir = 1f; + private float _flipCooldown; // 翻转后短暂冷却,等待射线刷新到新朝向 public override void OnAwake() { - _enemy = GetComponent(); - _hub = GetComponent(); - _hasWallSensor = _hub != null && _hub.Get(SensorSlotNames.WallAhead) != null; - _hasEdgeSensor = _hub != null && _hub.Get(SensorSlotNames.Ledge) != null; + _enemy = GetComponent(); + _movement = _enemy?.Movement; } public override void OnStart() { _enemy?.SetAiPhase(AiPhase.Patrol); // 与敌人实际朝向同步,防止任务重入时 _dir 与朝向不符(如战斗后朝向已改变) - if (_enemy?.Movement != null) - _dir = _enemy.Movement.FacingDirection; + if (_movement != null) + _dir = _movement.FacingDirection; _flipCooldown = 0f; } @@ -48,13 +42,13 @@ namespace BaseGames.Enemies.AI { if (_enemy == null) return TaskStatus.Failure; - // 翻转冷却期间跳过传感器检测(等待 RaySensor2D 在新朝向完成刷新) + // 翻转冷却期间跳过物理检测(等待射线在新朝向完成刷新) if (_flipCooldown > 0f) _flipCooldown -= Time.deltaTime; else if (ShouldFlip()) { _dir = -_dir; - _flipCooldown = 0.1f; // ~6 帧缓冲(60 fps),防止传感器残留信号导致抖动 + _flipCooldown = 0.1f; // ~6 帧缓冲(60 fps),防止射线残留信号导致抖动 } _enemy.MoveInDirection(_dir); @@ -65,11 +59,9 @@ namespace BaseGames.Enemies.AI private bool ShouldFlip() { - // 转身进行中时不重复检测,防止 _dir 在转身期间被传感器残留信号反复翻转 - if (_enemy.Movement != null && _enemy.Movement.IsTurning) return false; - bool wallHit = _hasWallSensor && _hub.HasAnyDetection(SensorSlotNames.WallAhead); - bool edgeHit = _hasEdgeSensor && _hub.HasAnyDetection(SensorSlotNames.Ledge); - return wallHit || edgeHit; + // 转身进行中时不重复检测,防止 _dir 在转身期间被残留信号反复翻转 + if (_movement == null || _movement.IsTurning) return false; + return _movement.IsWallAhead || _movement.IsLedgeAhead; } } } diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs b/Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs index 449956d..60ae1b8 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs @@ -35,6 +35,7 @@ namespace BaseGames.Enemies.AI private bool _reached; private bool _pathFailed; private bool _subscribed; + private Vector2 _returnTarget; public override void OnAwake() => _enemy = gameObject.GetComponent(); @@ -54,7 +55,12 @@ namespace BaseGames.Enemies.AI // 切换为行走速度 float walkSpeed = _enemy.Stats?.WalkSpeed ?? 2f; _enemy.Nav?.SetSpeed(walkSpeed); - _enemy.MoveTo(_enemy.HomePosition); + + // 优先归位到巡逻区域中心;无区域时退回出生点 + _returnTarget = _enemy.PatrolZone != null + ? _enemy.PatrolZone.PatrolCenter + : _enemy.HomePosition; + _enemy.MoveTo(_returnTarget); } public override TaskStatus OnUpdate() @@ -66,7 +72,7 @@ namespace BaseGames.Enemies.AI // 兜底距离判断(事件可能因帧序问题延迟一帧) float radius = m_ArriveRadius > 0f ? m_ArriveRadius : (_enemy.Stats?.HomeRadius ?? 0.5f); - float sqr = ((Vector2)_enemy.transform.position - _enemy.HomePosition).sqrMagnitude; + float sqr = ((Vector2)_enemy.transform.position - _returnTarget).sqrMagnitude; if (sqr <= radius * radius) return CompleteReturn(); diff --git a/Assets/_Game/Scripts/Enemies/AI/BaseGames.Enemies.AI.asmdef b/Assets/_Game/Scripts/Enemies/AI/BaseGames.Enemies.AI.asmdef index 9a4c8c7..adf9867 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BaseGames.Enemies.AI.asmdef +++ b/Assets/_Game/Scripts/Enemies/AI/BaseGames.Enemies.AI.asmdef @@ -13,8 +13,7 @@ "BaseGames.Enemies", "BaseGames.Enemies.Boss.Patterns", "Opsive.BehaviorDesigner.Runtime", - "Kybernetik.Animancer", - "Micosmo.SensorToolkit" + "Kybernetik.Animancer" ], "autoReferenced": true, "overrideReferences": false, diff --git a/Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs b/Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs index 9276023..6812fd7 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs @@ -1,7 +1,6 @@ using System.Collections; using Animancer; using UnityEngine; -using BaseGames.Enemies.Perception; namespace BaseGames.Enemies.Abilities { @@ -18,7 +17,6 @@ namespace BaseGames.Enemies.Abilities [Header("感知与接触伤害")] [SerializeField] private BodyContactDamage _contactDamage; - [SerializeField] private EnemySensorHub _sensorHub; [Tooltip("用于追击感知判断的传感器槽位名,通常为 \"aggro\"")] [SerializeField] private string _aggroSlotName = "aggro"; @@ -36,7 +34,7 @@ namespace BaseGames.Enemies.Abilities while (true) { if (_enemy.PlayerTransform == null) break; - if (_sensorHub != null && !_sensorHub.IsDetecting(_aggroSlotName, _enemy.PlayerTransform.gameObject)) + if (_enemy.SensorHub != null && !_enemy.SensorHub.IsDetecting(_aggroSlotName, _enemy.PlayerTransform.gameObject)) break; _enemy.MoveTo(_enemy.PlayerTransform.position); yield return null; diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs index 2c0bd37..037b0d4 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs @@ -149,11 +149,11 @@ namespace BaseGames.Enemies.Abilities protected virtual void OnInterrupted(InterruptReason reason) { } - /// 子类辅助:朝向目标。委托给 EnemyMovement.FaceTarget 以保持转身动画系统一致。 + /// 子类辅助:朝向目标(写入输入信号,下一 FixedUpdate 由 EnemyMovement 消费)。 protected void FaceTarget(Transform target) { - if (target == null || _enemy?.Movement == null) return; - _enemy.Movement.FaceTarget(target.position); + if (target == null || _enemy == null) return; + _enemy.FaceTarget(target.position); } } diff --git a/Assets/_Game/Scripts/Enemies/Abilities/FacePlayerAbility.cs b/Assets/_Game/Scripts/Enemies/Abilities/FacePlayerAbility.cs index 6d875c7..43e0d55 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/FacePlayerAbility.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/FacePlayerAbility.cs @@ -31,7 +31,7 @@ namespace BaseGames.Enemies.Abilities { Phase = AbilityRunState.Active; - _enemy.Movement?.FaceTarget(_enemy.PlayerTransform.position); + _enemy.FacePlayer(); if (_faceClip.Clip == null) yield break; diff --git a/Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef b/Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef index 8806378..38eff23 100644 --- a/Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef +++ b/Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef @@ -12,7 +12,6 @@ "Opsive.BehaviorDesigner.Runtime", "Unity.Addressables", "Unity.ResourceManager", - "Micosmo.SensorToolkit", "BaseGames.Parry" ], "includePlatforms": [], diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs index e1dc3dc..10a3a29 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs @@ -39,6 +39,10 @@ namespace BaseGames.Enemies [SerializeField] protected EnemyFeedback _feedback; [SerializeField] protected HurtBox _hurtBox; + [Header("区域(可选)")] + [Tooltip("地图固定巡逻/追击区域;配置后 BD_ChasePlayer 以区域边界替代 MaxChaseDistance,BD_ReturnToHome 归位至区域中心。留空则沿用出生点 + MaxChaseDistance 旧逻辑。")] + [SerializeField] private EnemyPatrolZone _patrolZone; + [Header("事件频道")] [SerializeField] private BaseGames.Core.Events.StringEventChannelSO _onEnemyDied; /// @@ -101,6 +105,13 @@ namespace BaseGames.Enemies /// public Vector2 LastKnownPlayerPosition { get; set; } + /// + /// 地图固定巡逻/追击区域(可选)。 + /// 配置后 BD_ChasePlayer 以区域边界为追击上限;BD_ReturnToHome 归位至区域中心。 + /// 未配置时退回旧逻辑(HomePosition + MaxChaseDistance)。 + /// + public EnemyPatrolZone PatrolZone => _patrolZone; + #if UNITY_EDITOR [Header("── 运行时调试(仅 Editor)──")] [SerializeField] private EnemyStateType _dbg_CurrentState; @@ -212,9 +223,9 @@ namespace BaseGames.Enemies private readonly EnemyAbilityRegistry _abilities = new EnemyAbilityRegistry(); /// 由 _onPlayerSpawned 事件缓存的玩家 Transform,供 BD 任务读取。 public Transform PlayerTransform => _playerTransform; - /// 感知 Hub(SensorToolkit);供 QuotaManager 暂停/恢复 Sensor 使用。 + /// 感知 Hub;供 BD 任务及 QuotaManager 暂停/恢复感知使用。 public Perception.IPerceptionSystem SensorHub => _sensorHub; - private Perception.EnemySensorHub _sensorHub; + private Perception.IPerceptionSystem _sensorHub; /// 威胁评估器(可选):为原始 LOS 结果叠加反应延迟,使感知更自然。 public Perception.EnemyThreatAssessor ThreatAssessor => _threatAssessor; private Perception.EnemyThreatAssessor _threatAssessor; @@ -275,26 +286,6 @@ namespace BaseGames.Enemies public virtual bool IsPlayerInRange(float range) => _stats != null && _stats.SqrDistanceToPlayer <= range * range; - /// - /// 检查玩家是否在感知范围内。若 > 0, - /// 同时验证玩家是否在自身朝向的扇形角度内。 - /// - public virtual bool IsPlayerInDetectRange() - { - if (_stats == null || _playerTransform == null) return false; - float detectRange = _statsSO != null ? _statsSO.DetectRange : 6f; - if (_stats.SqrDistanceToPlayer > detectRange * detectRange) return false; - - float angleDeg = _statsSO?.DetectAngleDeg ?? 0f; - if (angleDeg <= 0f) return true; // 0 = 关闭方向限制 - - Vector2 toPlayer = ((Vector2)_playerTransform.position - (Vector2)transform.position).normalized; - float facingDir = _movement != null ? _movement.FacingDirection : 1f; - var forward = new Vector2(facingDir, 0f); - float angle = Vector2.Angle(forward, toPlayer); - return angle <= angleDeg; - } - /// 原始视线检测结果(BatchLOSSystem 写入,无感知延迟修正)。 public bool HasLineOfSight => _losResult; @@ -309,6 +300,23 @@ namespace BaseGames.Enemies _movement.PendingInput.FaceDir = 0; } + /// 朝向世界坐标点(通过输入信号,下一 FixedUpdate 消费)。 + public void FaceTarget(Vector2 worldPos) + { + if (_movement == null) return; + _movement.PendingInput.WantFace = true; + _movement.PendingInput.FaceTargetPos = worldPos; + _movement.PendingInput.FaceDir = 0; + } + + /// 直接指定朝向方向(+1 右 / -1 左,通过输入信号)。 + public void FaceDirection(int dir) + { + if (_movement == null) return; + _movement.PendingInput.WantFace = true; + _movement.PendingInput.FaceDir = dir; + } + /// /// 搜查"环顾"子步骤:停止移动,播放原地环顾动画。 /// 由搜查行为触发;动画细节由角色自己决定,外部无需感知 AnimConfig。 @@ -542,7 +550,7 @@ namespace BaseGames.Enemies _nav = GetComponent() ?? new NullPathAgent(); if (_movement == null) _movement = GetComponent(); _poiseSource = GetComponent(); - _sensorHub = GetComponentInChildren(); + _sensorHub = GetComponentInChildren(); _statusEffects = GetComponent(); _threatAssessor = GetComponent(); _pooledObject = GetComponent(); @@ -782,43 +790,8 @@ namespace BaseGames.Enemies #if UNITY_EDITOR if (_statsSO == null) return; - // ── 侦测范围(淡橙;若配置扇形角则绘制扇形弧)+ 攻击范围(淡红圆)──── - { - var c = new Vector3(transform.position.x, transform.position.y, 0f); - var prevM = UnityEditor.Handles.matrix; - UnityEditor.Handles.matrix = Matrix4x4.identity; - - // 攻击范围(全圆) - UnityEditor.Handles.color = new Color(1f, 0.15f, 0.15f, 0.15f); - UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.AttackRange); - UnityEditor.Handles.color = new Color(1f, 0.15f, 0.15f, 0.55f); - UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.AttackRange); - - // 侦测范围 - float angleDeg = _statsSO.DetectAngleDeg; - if (angleDeg > 0f) - { - // 扇形感知:绘制弧形扇区 - float facing = Application.isPlaying && _movement != null ? _movement.FacingDirection : 1f; - Vector3 forward3 = new Vector3(facing, 0f, 0f); - UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.12f); - UnityEditor.Handles.DrawSolidArc(c, Vector3.back, forward3, angleDeg, _statsSO.DetectRange); - UnityEditor.Handles.DrawSolidArc(c, Vector3.back, forward3, -angleDeg, _statsSO.DetectRange); - UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.6f); - UnityEditor.Handles.DrawWireArc(c, Vector3.back, forward3, angleDeg, _statsSO.DetectRange); - UnityEditor.Handles.DrawWireArc(c, Vector3.back, forward3, -angleDeg, _statsSO.DetectRange); - } - else - { - // 全圆感知 - UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.15f); - UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.DetectRange); - UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.55f); - UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.DetectRange); - } - - UnityEditor.Handles.matrix = prevM; - } + // 感知范围圆形 Gizmo 由 PhysicsPerceptionSystemEditor [DrawGizmo] 统一绘制, + // 此处不重复绘制,避免叠加覆盖导致 gizmoColor 设置无效。 // ── 运行时:AI 状态标签(常态可见,无需选中)──────────────── if (Application.isPlaying) @@ -843,10 +816,14 @@ namespace BaseGames.Enemies // ── 运行时:LOS 连线 ──────────────────────────────────────── if (!Application.isPlaying || _playerTransform == null) return; + float drawDetectRange = _sensorHub != null + ? _sensorHub.GetSensorRadius(Perception.SensorSlotNames.Aggro) + : -1f; + Vector3 eyeWorld = transform.position + new Vector3(_statsSO.EyeOffset.x, _statsSO.EyeOffset.y, 0f); Vector3 playerPos = _playerTransform.position; float sqrDist = (playerPos - transform.position).sqrMagnitude; - bool inRange = sqrDist <= _statsSO.DetectRange * _statsSO.DetectRange; + bool inRange = drawDetectRange >= 0f && sqrDist <= drawDetectRange * drawDetectRange; // 眼睛位置小圆点(金黄) Gizmos.color = new Color(1f, 0.9f, 0.2f, 0.85f); @@ -867,24 +844,8 @@ namespace BaseGames.Enemies #if UNITY_EDITOR if (_statsSO == null) return; - // 选中时加亮范围圆 - { - var c = new Vector3(transform.position.x, transform.position.y, 0f); - var prevM = UnityEditor.Handles.matrix; - UnityEditor.Handles.matrix = Matrix4x4.identity; - - UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.25f); - UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.DetectRange); - UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.90f); - UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.DetectRange); - - UnityEditor.Handles.color = new Color(1f, 0.2f, 0.2f, 0.25f); - UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.AttackRange); - UnityEditor.Handles.color = new Color(1f, 0.2f, 0.2f, 0.90f); - UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.AttackRange); - - UnityEditor.Handles.matrix = prevM; - } + // 感知范围圆形 Gizmo 由 PhysicsPerceptionSystemEditor [DrawGizmo] 统一绘制, + // 此处不重复绘制。 // 运行时:选中时绘制 AiPhase 彩色外圆(突出显示当前状态) if (Application.isPlaying) diff --git a/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs b/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs index df34f24..911aae2 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs @@ -79,6 +79,7 @@ namespace BaseGames.Enemies private System.Collections.Generic.List BuildLines() { + var list = new System.Collections.Generic.List(8); // 名称 diff --git a/Assets/_Game/Scripts/Enemies/EnemyMovement.cs b/Assets/_Game/Scripts/Enemies/EnemyMovement.cs index a0511bb..cd2c868 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyMovement.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyMovement.cs @@ -31,16 +31,36 @@ namespace BaseGames.Enemies [Tooltip("动画配置 SO;留空则在 Awake 时自动从 EnemyBase 读取")] [SerializeField] private EnemyAnimationConfigSO _animConfig; + [Header("视觉节点")] + [Tooltip("包含 SpriteRenderer / AnimancerComponent 的子节点(Visual);设置后 Awake 自动将其 localPosition 对齐到 Collider2D offset,使视觉中心与碰撞体中心重合。留空则不做偏移处理。")] + [SerializeField] private Transform _visualRoot; + [Tooltip("精灵资源本身的默认朝向:1 = 右(localScale.x 为正时面朝右),-1 = 左(localScale.x 为正时面朝左)。如果美术资源绘制方向朝左,此值填 -1;朝右填 1。大多数 Unity 项目美术朝右,默认值为 1。")] + [SerializeField] private int _spriteDefaultFacingDir = 1; + [Header("导航跳跃能力(INavLinkHandler)")] [Tooltip("可处理的最大跳跃垂直高度(超出则让 TBM 兜底)")] [SerializeField] private float _navJumpMaxHeight = 6f; [Tooltip("可处理的最大跳跃水平距离")] [SerializeField] private float _navJumpMaxDist = 10f; - [Tooltip("地面检测射线长度(用于判断跳跃是否落地)")] - [SerializeField] private float _groundCheckDist = 0.35f; + [Tooltip("用于确定射线起点宽度和底边的 Collider2D;留空则 Awake 时自动查找")] + [SerializeField] private Collider2D _groundCheckCollider; + [Tooltip("从碰撞体底边向下的射线检测距离")] + [SerializeField] private float _groundCheckDist = 0.15f; + [Tooltip("射线数量(1 = 仅中心,>1 时沿碰撞体底边均匀分布)")] + [SerializeField] [Min(1)] private int _groundCheckCount = 3; [Tooltip("地面层 LayerMask")] [SerializeField] private LayerMask _groundMask; + [Header("墙体 / 悬崖检测")] + [Tooltip("从碰撞体朝向前边缘水平发射的墙体检测距离(0 = 禁用)")] + [SerializeField] private float _wallCheckDist = 0.2f; + [Tooltip("悬崖检测:从碰撞体前下角再向前偏移此距离后向下发射射线(用于检测脚边是否有地面)")] + [SerializeField] private float _ledgeCheckFwdOffset = 0.1f; + [Tooltip("悬崖检测:向下的射线长度;射线未命中地面则 IsLedgeAhead = true(0 = 禁用)")] + [SerializeField] private float _ledgeCheckDownDist = 0.4f; + [Tooltip("墙体层 LayerMask;留空时复用地面 LayerMask")] + [SerializeField] private LayerMask _wallMask; + private Rigidbody2D _rb; private int _facingDir = 1; private Coroutine _linkCoroutine; @@ -54,10 +74,20 @@ namespace BaseGames.Enemies public EnemyMoveInput PendingInput; public bool IsGrounded { get; private set; } + /// 前方是否有墙体。在 FixedUpdate 中更新,仅当 _wallCheckDist > 0 时有效。 + public bool IsWallAhead { get; private set; } + /// 前方是否有悬崖(脚边地面缺失)。在 FixedUpdate 中更新,仅当 _ledgeCheckDownDist > 0 时有效。 + public bool IsLedgeAhead { get; private set; } /// 当前朝向:1 = 右,-1 = 左。 public int FacingDirection => _facingDir; /// 当前是否正在播放转身动画(移动输入在此期间被屏蔽)。 public bool IsTurning => _isTurning; + /// + /// 当 PathBerserker2d TransformBasedMovement 正在直接驱动 transform.position 时由 + /// 设为 true。 + /// 此时 MoveHorizontal/MoveWithSpeed 仅更新朝向,不写 rb.velocity,防止双重驱动冲突。 + /// + public bool NavDriving { get; set; } #if UNITY_EDITOR [Header("── 运行时调试(仅 Editor)──")] @@ -65,7 +95,10 @@ namespace BaseGames.Enemies [SerializeField] private float _dbg_VelocityX; [SerializeField] private float _dbg_VelocityY; [SerializeField] private bool _dbg_IsGrounded; + [SerializeField] private bool _dbg_IsWallAhead; + [SerializeField] private bool _dbg_IsLedgeAhead; [SerializeField] private bool _dbg_IsTurning; + [SerializeField] private bool _dbg_NavDriving; [Header("── 输入信号(仅 Editor)──")] [SerializeField] private float _dbg_Input_MoveDir; [SerializeField] private float _dbg_Input_MoveSpeed; @@ -147,36 +180,111 @@ namespace BaseGames.Enemies onComplete?.Invoke(); } - private bool IsGroundedCheck() => - Physics2D.Raycast(_rb.position, Vector2.down, _groundCheckDist, _groundMask); + private Vector2 GetGroundRayOrigin(int index) + { + // 优先用序列化字段,编辑器模式下 Awake 未执行时也能直接 GetComponent + var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent(); + if (col == null) + return (Vector2)transform.position; + + Bounds b = col.bounds; + float x = _groundCheckCount <= 1 + ? b.center.x + : Mathf.Lerp(b.min.x, b.max.x, (float)index / (_groundCheckCount - 1)); + return new Vector2(x, b.min.y); + } + + private bool IsGroundedCheck() + { + for (int i = 0; i < _groundCheckCount; i++) + { + if (Physics2D.Raycast(GetGroundRayOrigin(i), Vector2.down, _groundCheckDist, _groundMask)) + return true; + } + return false; + } + + // 墙体射线起点:碰撞体朝向侧边缘中心高度 + private Vector2 GetWallRayOrigin() + { + var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent(); + if (col == null) return (Vector2)transform.position; + Bounds b = col.bounds; + float x = _facingDir >= 0 ? b.max.x : b.min.x; + return new Vector2(x, b.center.y); + } + + // 悬崖射线起点:碰撞体前下角再向前偏移 _ledgeCheckFwdOffset + private Vector2 GetLedgeRayOrigin() + { + var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent(); + if (col == null) return (Vector2)transform.position; + Bounds b = col.bounds; + float x = _facingDir >= 0 + ? b.max.x + _ledgeCheckFwdOffset + : b.min.x - _ledgeCheckFwdOffset; + return new Vector2(x, b.min.y); + } + + private void WallAndLedgeCheck() + { + LayerMask wallLayer = (_wallMask.value != 0) ? _wallMask : _groundMask; + + if (_wallCheckDist > 0f) + IsWallAhead = Physics2D.Raycast( + GetWallRayOrigin(), + new Vector2(_facingDir, 0f), + _wallCheckDist, + wallLayer); + + if (_ledgeCheckDownDist > 0f) + IsLedgeAhead = !Physics2D.Raycast( + GetLedgeRayOrigin(), + Vector2.down, + _ledgeCheckDownDist, + _groundMask); + } private void Awake() { Debug.Assert(_config != null, "[EnemyMovement] _config 未赋值,请在 Prefab Inspector 中指定 EnemyStatsSO。", this); _rb = GetComponent(); + if (_groundCheckCollider == null) + _groundCheckCollider = GetComponent(); // 从 Sprite 或 localScale 的初始状态推断朝向,并统一切换为 localScale 翻转。 // 这样子对象(含 RaySensor2D)会随 localScale 正确翻转,不再依赖 flipX。 - if (_spriteRenderer != null) - { - // 两个信号均可能携带初始朝向信息:flipX 或 localScale.x < 0, - // XOR 组合:恰好一个翻转 → 面左;两个都翻(互相抵消)→ 面右。 - bool flippedBySprite = _spriteRenderer.flipX; - bool flippedByScale = transform.localScale.x < 0f; - _facingDir = (flippedBySprite ^ flippedByScale) ? -1 : 1; + // 三个信号均可能携带初始朝向信息,任意奇数个翻转表示实际方向与默认方向相反: + // flippedBySprite : SpriteRenderer.flipX + // flippedByScale : ROOT localScale.x < 0 + // flippedByVisual : _visualRoot.localScale.x < 0(需归一化,否则与 ROOT 产生双重翻转) + bool flippedBySprite = _spriteRenderer != null && _spriteRenderer.flipX; + bool flippedByScale = transform.localScale.x < 0f; + bool flippedByVisual = _visualRoot != null && _visualRoot.localScale.x < 0f; + _facingDir = (flippedBySprite ^ flippedByScale ^ flippedByVisual) + ? -_spriteDefaultFacingDir + : _spriteDefaultFacingDir; - _spriteRenderer.flipX = false; // 后续由 localScale 驱动,避免双重镜像 - Vector3 s = transform.localScale; - transform.localScale = new Vector3(Mathf.Abs(s.x) * _facingDir, s.y, s.z); - } - else + // 归一化:清除所有翻转来源,仅保留 ROOT localScale.x 作为唯一翻转驱动。 + if (_spriteRenderer != null) + _spriteRenderer.flipX = false; + if (_visualRoot != null && flippedByVisual) { - _facingDir = transform.localScale.x >= 0f ? 1 : -1; + var vs = _visualRoot.localScale; + _visualRoot.localScale = new Vector3(Mathf.Abs(vs.x), vs.y, vs.z); } + Vector3 s = transform.localScale; + float signX = (_facingDir == _spriteDefaultFacingDir) ? Mathf.Abs(s.x) : -Mathf.Abs(s.x); + transform.localScale = new Vector3(signX, s.y, s.z); + + // 将 Visual 子节点的 localPosition 对齐到 Collider2D offset,使视觉中心与碰撞体中心重合 + if (_visualRoot != null && _groundCheckCollider != null) + _visualRoot.localPosition = _groundCheckCollider.offset; if (_enableTurnAnimation) { - if (_animancer == null) _animancer = GetComponentInParent(true); + // AnimancerComponent 可能在 Visual 子节点上,用 GetComponentInChildren 兼容两种布局 + if (_animancer == null) _animancer = GetComponentInChildren(true); if (_animConfig == null) { var enemyBase = GetComponentInParent(true); @@ -194,7 +302,16 @@ namespace BaseGames.Enemies private void FixedUpdate() { - IsGrounded = IsGroundedCheck(); + // localScale.x 为正 → 精灵以 _spriteDefaultFacingDir 方向显示;为负则相反。 + if (!_isTurning) + _facingDir = transform.localScale.x >= 0f ? _spriteDefaultFacingDir : -_spriteDefaultFacingDir; + + // NavDriving: TBM 直接写 transform.position,零速防止物理重力积累和双重驱动冲突。 + if (NavDriving) + _rb.velocity = Vector2.zero; + + IsGrounded = IsGroundedCheck(); + WallAndLedgeCheck(); #if UNITY_EDITOR _dbg_Input_MoveDir = PendingInput.MoveDir; _dbg_Input_MoveSpeed = PendingInput.MoveSpeed; @@ -209,7 +326,10 @@ namespace BaseGames.Enemies _dbg_VelocityX = _rb != null ? _rb.velocity.x : 0f; _dbg_VelocityY = _rb != null ? _rb.velocity.y : 0f; _dbg_IsGrounded = IsGrounded; + _dbg_IsWallAhead = IsWallAhead; + _dbg_IsLedgeAhead = IsLedgeAhead; _dbg_IsTurning = _isTurning; + _dbg_NavDriving = NavDriving; #endif } @@ -222,8 +342,10 @@ namespace BaseGames.Enemies bool wantFace = PendingInput.WantFace; int faceDir = PendingInput.FaceDir; var facePosSnapshot = PendingInput.FaceTargetPos; - PendingInput.WantStop = false; - PendingInput.WantFace = false; + PendingInput.WantStop = false; + PendingInput.WantFace = false; + PendingInput.FaceDir = 0; // clear to prevent stale Inspector display + PendingInput.FaceTargetPos = default; // clear to prevent stale Inspector display // ── 持久字段:MoveDir / MoveSpeed 不清零 ───────────────────── // 解决 FixedUpdate 频率 > Update 频率时的空帧问题: @@ -256,20 +378,22 @@ namespace BaseGames.Enemies public void MoveHorizontal(float dir) { if (_isTurning) return; + UpdateFacing(dir); + if (NavDriving) return; // TBM 驱动位置,仅更新朝向 var vel = _rb.velocity; vel.x = dir * _config.WalkSpeed; _rb.velocity = vel; - UpdateFacing(dir); } /// 显式指定速度(BD 追击任务调用)。转身动画期间调用无效。 public void MoveWithSpeed(float dir, float speed) { if (_isTurning) return; + UpdateFacing(dir); + if (NavDriving) return; // TBM 驱动位置,仅更新朝向 var vel = _rb.velocity; vel.x = dir * speed; _rb.velocity = vel; - UpdateFacing(dir); } /// 朝向指定世界坐标(通常传入玩家位置)。 @@ -361,20 +485,54 @@ namespace BaseGames.Enemies } } - /// 转身动画协程:停止水平移动 → 播放 Turn 动画 → 翻转朝向 → 恢复。 + /// 转身动画协程:停止水平移动 → 播放 Turn 动画 → 翻转朝向 → 恢复移动动画。 private IEnumerator TurnCoroutine(int newDir) { _isTurning = true; StopHorizontal(); - // yield return state:Animancer 的 AnimancerState 是 CustomYieldInstruction, - // 等待动画自然播完,与 Layer/State 速度缩放无关,比手动计时更可靠。 + // 用 WaitForSeconds 代替 "yield return state": + // AnimancerState.IsLooping 是只读属性(反映 clip 自身设置),无法强制单次播放; + // 若 Turn clip 被误配为 Loop,"yield return state" 的 keepWaiting 永远为 true, + // 导致 _isTurning 卡住、走路/攻击动画无法播放。 + // WaitForSeconds(Length / Speed) 精确等待一个周期,与 clip 的 Loop 设置无关。 var state = _animancer.Play(_animConfig.Turn); - yield return state; + float waitSec = state.Length > 0f + ? state.Length / Mathf.Max(0.001f, Mathf.Abs(state.EffectiveSpeed)) + : 0.3f; + yield return new WaitForSeconds(waitSec); ApplyFacingFlip(newDir); _isTurning = false; _turnCoroutine = null; + + // 转身完成后恢复运动动画:Turn 覆盖了之前的 Walk/Run, + // 上层(EnemyBase.SetAiPhase)只在阶段切换时播放一次动画,不会在此处重播。 + ResumeMovementAnimation(); + } + + /// + /// 根据当前输入状态恢复合适的移动动画(Walk / Run / Idle)。 + /// 转身协程结束、CancelTurn 时调用,避免动画停留在 Turn 最后一帧。 + /// + private void ResumeMovementAnimation() + { + if (_animancer == null || _animConfig == null) return; + + if (PendingInput.WantStop || Mathf.Approximately(PendingInput.MoveDir, 0f)) + { + if (_animConfig.Idle != null) _animancer.Play(_animConfig.Idle); + return; + } + + // 有速度且明显超过步行速度 → 跑步动画 + float spd = PendingInput.MoveSpeed > 0f ? PendingInput.MoveSpeed : 0f; + if (_animConfig.Run != null && _config != null && spd > _config.WalkSpeed + 0.05f) + _animancer.Play(_animConfig.Run); + else if (_animConfig.Walk != null) + _animancer.Play(_animConfig.Walk); + else if (_animConfig.Idle != null) + _animancer.Play(_animConfig.Idle); } /// @@ -401,7 +559,9 @@ namespace BaseGames.Enemies if (_spriteRenderer != null) _spriteRenderer.flipX = false; Vector3 s = transform.localScale; - transform.localScale = new Vector3(Mathf.Abs(s.x) * newDir, s.y, s.z); + // newDir 与精灵默认方向一致 → 正比例(不翻转),否则取反(翻转)。 + float signX = (newDir == _spriteDefaultFacingDir) ? Mathf.Abs(s.x) : -Mathf.Abs(s.x); + transform.localScale = new Vector3(signX, s.y, s.z); } private void OnDrawGizmos() @@ -427,9 +587,38 @@ namespace BaseGames.Enemies Gizmos.color = grounded ? new Color(0.2f, 1f, 0.35f, 0.90f) : new Color(0.4f, 0.75f, 0.4f, 0.40f); - Vector3 origin = transform.position; - Gizmos.DrawLine(origin, origin + Vector3.down * _groundCheckDist); - Gizmos.DrawWireSphere(origin + Vector3.down * _groundCheckDist, 0.04f); + for (int i = 0; i < _groundCheckCount; i++) + { + Vector3 origin = GetGroundRayOrigin(i); + Gizmos.DrawLine(origin, origin + Vector3.down * _groundCheckDist); + Gizmos.DrawWireSphere(origin + Vector3.down * _groundCheckDist, 0.04f); + } + } + + // ── 4. 墙体检测射线(命中红色 / 无命中青色)───────────────── + if (_wallCheckDist > 0f) + { + bool hit = Application.isPlaying && IsWallAhead; + Gizmos.color = hit + ? new Color(1f, 0.2f, 0.2f, 0.90f) + : new Color(0.2f, 0.9f, 1f, 0.50f); + Vector3 wallOrigin = GetWallRayOrigin(); + Vector3 wallEnd = wallOrigin + new Vector3(_facingDir * _wallCheckDist, 0f, 0f); + Gizmos.DrawLine(wallOrigin, wallEnd); + Gizmos.DrawWireSphere(wallEnd, 0.04f); + } + + // ── 5. 悬崖检测射线(无地面橙色 / 有地面灰色)─────────────── + if (_ledgeCheckDownDist > 0f) + { + bool ledge = Application.isPlaying && IsLedgeAhead; + Gizmos.color = ledge + ? new Color(1f, 0.65f, 0.1f, 0.90f) + : new Color(0.6f, 0.6f, 0.6f, 0.40f); + Vector3 ledgeOrigin = GetLedgeRayOrigin(); + Vector3 ledgeEnd = ledgeOrigin + Vector3.down * _ledgeCheckDownDist; + Gizmos.DrawLine(ledgeOrigin, ledgeEnd); + Gizmos.DrawWireSphere(ledgeEnd, 0.04f); } #endif } @@ -446,6 +635,76 @@ namespace BaseGames.Enemies #endif } + +#if UNITY_EDITOR + /// + /// 一键在 Enemy Prefab 上创建 Visual 子节点,将 SpriteRenderer / AnimancerComponent + /// 迁移到该子节点,并自动将 _visualRoot / _spriteRenderer / EnemyBase._animancer 引用指向新节点。 + /// 在 Inspector 右键菜单或 Component Header 菜单中调用。 + /// ⚠️ 请在 Prefab 编辑模式(或 Prefab Stage)中执行,以便变更能正确保存。 + /// + [ContextMenu("Setup Visual Node")] + public void SetupVisualNode() + { + // 1. 找或创建 Visual 子节点 + Transform visual = transform.Find("Visual"); + if (visual == null) + { + var go = new GameObject("Visual"); + UnityEditor.Undo.RegisterCreatedObjectUndo(go, "Create Enemy Visual Node"); + go.transform.SetParent(transform, false); + visual = go.transform; + } + + // 2. 对齐 localPosition 到 Collider2D offset + var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent(); + if (col != null) + { + UnityEditor.Undo.RecordObject(visual, "Set Visual LocalPosition"); + visual.localPosition = col.offset; + } + + // 3. 迁移 SpriteRenderer(仅在 Visual 上尚无 SpriteRenderer 时执行) + var sr = GetComponent(); + if (sr != null && visual.GetComponent() == null) + { + UnityEditorInternal.ComponentUtility.CopyComponent(sr); + UnityEditorInternal.ComponentUtility.PasteComponentAsNew(visual.gameObject); + UnityEditor.Undo.DestroyObjectImmediate(sr); + } + + // 4. 迁移 AnimancerComponent + var anim = GetComponent(); + if (anim != null && visual.GetComponent() == null) + { + UnityEditorInternal.ComponentUtility.CopyComponent(anim); + UnityEditorInternal.ComponentUtility.PasteComponentAsNew(visual.gameObject); + UnityEditor.Undo.DestroyObjectImmediate(anim); + } + + // 5. 更新 EnemyMovement 字段引用 + var movSO = new UnityEditor.SerializedObject(this); + movSO.FindProperty("_visualRoot").objectReferenceValue = visual; + movSO.FindProperty("_spriteRenderer").objectReferenceValue = visual.GetComponent(); + movSO.FindProperty("_animancer").objectReferenceValue = visual.GetComponent(); + movSO.ApplyModifiedProperties(); + + // 6. 更新 EnemyBase._animancer 引用 + var enemyBase = GetComponent(); + if (enemyBase != null) + { + var baseSO = new UnityEditor.SerializedObject(enemyBase); + baseSO.FindProperty("_animancer").objectReferenceValue = visual.GetComponent(); + baseSO.ApplyModifiedProperties(); + } + + UnityEditor.EditorUtility.SetDirty(gameObject); + Debug.Log($"[EnemyMovement] Visual node setup complete on '{gameObject.name}'.\n" + + $"Visual.localPosition = {visual.localPosition}\n" + + $"请在 Prefab 编辑器中手动保存(Ctrl+S)。", this); + } +#endif + // 在 Gizmos 空间绘制带箭头的 2D 有向线段 private static void DrawArrow2D(Vector3 from, Vector3 to, Color color, float headLen = 0.15f) { diff --git a/Assets/_Game/Scripts/Enemies/EnemyPatrolZone.cs b/Assets/_Game/Scripts/Enemies/EnemyPatrolZone.cs new file mode 100644 index 0000000..c383f12 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/EnemyPatrolZone.cs @@ -0,0 +1,128 @@ +using UnityEngine; + +namespace BaseGames.Enemies +{ + /// + /// 地图固定巡逻/追击区域(矩形)。 + /// + /// 放置于场景中(非敌人子节点),通过 EnemyBase Inspector 的 _patrolZone 字段引用。 + /// 同一区域可被多个敌人共享(哨兵组、竞技场等)。 + /// + /// 区域层级: + /// + /// 巡逻区域(Patrol):绿色矩形,巡逻点限制范围。 + /// 追击区域(Chase):橙色矩形 = 巡逻区域 + ; + /// 玩家进入/出此区域触发追击开始/放弃。 + /// + /// + public class EnemyPatrolZone : MonoBehaviour + { + [Header("巡逻区域")] + [Tooltip("巡逻区域中心相对 transform.position 的偏移(局部偏移,方便在 Scene 中移动节点)")] + public Vector2 PatrolOffset = Vector2.zero; + + [Tooltip("巡逻区域尺寸(宽 × 高,单位 m)")] + public Vector2 PatrolSize = new Vector2(10f, 4f); + + [Header("追击区域")] + [Tooltip("追击区域向四周扩展的边距(m);追击区域 = 巡逻区域各边 + 此值。0 = 不扩展(追击 = 巡逻)")] + [Min(0f)] + public float ChaseExpandPadding = 8f; + + [Tooltip("自定义追击区域尺寸(宽×高);保持 Vector2.zero 时使用巡逻区域 + ChaseExpandPadding 自动计算")] + public Vector2 CustomChaseSize = Vector2.zero; + + // ── 计算属性 ────────────────────────────────────────────────────── + + /// 巡逻区域中心(世界坐标)。 + public Vector2 PatrolCenter => (Vector2)transform.position + PatrolOffset; + + /// 追击区域中心与巡逻区域共享。 + public Vector2 ChaseCenter => PatrolCenter; + + /// 有效追击区域尺寸:自定义 > 0 时使用自定义,否则巡逻区域 + 边距。 + public Vector2 EffectiveChaseSize + { + get + { + if (CustomChaseSize.sqrMagnitude > 0f) return CustomChaseSize; + return PatrolSize + Vector2.one * (ChaseExpandPadding * 2f); + } + } + + // ── 空间查询 ────────────────────────────────────────────────────── + + /// 判断世界坐标 是否在巡逻区域内。 + public bool ContainsPatrol(Vector2 worldPos) + { + Vector2 delta = worldPos - PatrolCenter; + Vector2 half = PatrolSize * 0.5f; + return Mathf.Abs(delta.x) <= half.x && Mathf.Abs(delta.y) <= half.y; + } + + /// 判断世界坐标 是否在追击区域内。 + public bool ContainsChase(Vector2 worldPos) + { + Vector2 delta = worldPos - ChaseCenter; + Vector2 half = EffectiveChaseSize * 0.5f; + return Mathf.Abs(delta.x) <= half.x && Mathf.Abs(delta.y) <= half.y; + } + + /// 夹紧到巡逻区域内最近点(用于归位目标)。 + public Vector2 ClampToPatrol(Vector2 worldPos) + { + Vector2 center = PatrolCenter; + Vector2 half = PatrolSize * 0.5f; + return new Vector2( + Mathf.Clamp(worldPos.x, center.x - half.x, center.x + half.x), + Mathf.Clamp(worldPos.y, center.y - half.y, center.y + half.y) + ); + } + + // ── Gizmos ──────────────────────────────────────────────────────── +#if UNITY_EDITOR + private void OnDrawGizmos() + { + Vector2 patrol = PatrolCenter; + Vector2 chase = ChaseCenter; + + // 追击区域(橙色,后绘,垫在巡逻区域下方) + Vector2 chaseSize = EffectiveChaseSize; + Gizmos.color = new Color(1f, 0.55f, 0.1f, 0.07f); + Gizmos.DrawCube(chase, chaseSize); + Gizmos.color = new Color(1f, 0.55f, 0.1f, 0.7f); + Gizmos.DrawWireCube(chase, chaseSize); + + // 巡逻区域(绿色,前绘,覆盖在追击区域上方) + Gizmos.color = new Color(0.2f, 0.9f, 0.2f, 0.12f); + Gizmos.DrawCube(patrol, PatrolSize); + Gizmos.color = new Color(0.2f, 0.9f, 0.2f, 0.85f); + Gizmos.DrawWireCube(patrol, PatrolSize); + + // 中心点 + Gizmos.color = new Color(0.2f, 0.9f, 0.2f, 1f); + Gizmos.DrawSphere(patrol, 0.15f); + } + + private void OnDrawGizmosSelected() + { + // 选中时用更鲜艳的线框突出显示 + Vector2 patrol = PatrolCenter; + Gizmos.color = Color.green; + Gizmos.DrawWireCube(patrol, PatrolSize); + + Gizmos.color = new Color(1f, 0.7f, 0f); + Gizmos.DrawWireCube(ChaseCenter, EffectiveChaseSize); + + // 标注尺寸(仅在 Selected 时绘制) + UnityEditor.Handles.color = Color.green; + UnityEditor.Handles.Label(patrol + Vector2.right * PatrolSize.x * 0.5f, + $"Patrol {PatrolSize.x:F1}×{PatrolSize.y:F1}"); + UnityEditor.Handles.color = new Color(1f, 0.7f, 0f); + Vector2 cs = EffectiveChaseSize; + UnityEditor.Handles.Label(ChaseCenter + Vector2.right * cs.x * 0.5f, + $"Chase {cs.x:F1}×{cs.y:F1}"); + } +#endif + } +} diff --git a/Assets/_Game/Scripts/Enemies/EnemyPatrolZone.cs.meta b/Assets/_Game/Scripts/Enemies/EnemyPatrolZone.cs.meta new file mode 100644 index 0000000..e438bfd --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/EnemyPatrolZone.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6eaf6ff027820884daab5fa51164c899 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/EnemyPoiseComponent.cs b/Assets/_Game/Scripts/Enemies/EnemyPoiseComponent.cs index 174698a..035b825 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyPoiseComponent.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyPoiseComponent.cs @@ -19,8 +19,8 @@ namespace BaseGames.Enemies { _currentPoiseLevel = _defaultPoiseLevel; - // 自动注入到同节点 HurtBox(架构 06 §13) - if (TryGetComponent(out var hurtBox)) + // 自动注入到所有子节点 HurtBox(支持多形状受击区) + foreach (var hurtBox in GetComponentsInChildren(true)) hurtBox.SetPoiseSource(this); } diff --git a/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs b/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs index 6c2fd51..8bc9e25 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs @@ -113,7 +113,7 @@ namespace BaseGames.Enemies if (bt != null && bt.enabled != active) { bt.enabled = active; - // 同步暂停/恢复 SensorToolkit Sensor,避免远处敌人无效 tick + // 同步暂停/恢复感知系统,避免远处敌人无效 tick enemy.SensorHub?.SetSuspended(!active); } } diff --git a/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs b/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs index 2d6eca1..82650a0 100644 --- a/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs +++ b/Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs @@ -45,6 +45,7 @@ namespace BaseGames.Enemies.Navigation // ── 私有 ──────────────────────────────────────────────────────── private NavAgent _navAgent; private TransformBasedMovement _movement; + private EnemyMovement _enemyMovement; // 能力 handler 注册表(NavLinkType → INavLinkHandler) private readonly Dictionary _handlers @@ -53,6 +54,7 @@ namespace BaseGames.Enemies.Navigation // 连接段状态缓存 private NavLinkType _currentLinkType = NavLinkType.None; + private bool _wasNavOnSegment; private Vector2 _currentLinkStart; private Vector2 _currentLinkEnd; @@ -61,6 +63,7 @@ namespace BaseGames.Enemies.Navigation { _navAgent = GetComponent(); _movement = GetComponent(); + _enemyMovement = GetComponent(); // 自动发现 INavLinkHandler 组件并注册(包含子对象) foreach (var handler in GetComponentsInChildren(true)) @@ -196,6 +199,32 @@ namespace BaseGames.Enemies.Navigation private void HandlePathFailed(NavAgent _) => OnNavPathFailed?.Invoke(); private void HandleGoalReached(NavAgent _) => OnGoalReached?.Invoke(); + // ── NavDriving 信号桥 ─────────────────────────────────────────── + /// + /// 每物理帧向 写入导航方向信号,并设置 NavDriving 标志。 + /// NavDriving=true 时 EnemyMovement 只更新朝向,TBM 保留对 transform.position 的控制权。 + /// + private void FixedUpdate() + { + if (_enemyMovement == null || _navAgent == null) return; + + bool onSegment = _navAgent.IsMovingOnSegment; + _enemyMovement.NavDriving = onSegment; + + if (onSegment) + { + float dx = _navAgent.PathSubGoal.x - transform.position.x; + _enemyMovement.PendingInput.MoveDir = Mathf.Abs(dx) > 0.01f ? Mathf.Sign(dx) : 0f; + } + else if (_wasNavOnSegment && !_navAgent.IsFollowingAPath) + { + // 导航刚结束(到达目标 / 路径失败)→ 清除残留 MoveDir + _enemyMovement.PendingInput.WantStop = true; + } + + _wasNavOnSegment = onSegment; + } + // ── 工具 ─────────────────────────────────────────────────────── private static NavLinkType ParseLinkType(string name) => name switch { diff --git a/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs b/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs index 6099b9a..934d09f 100644 --- a/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs +++ b/Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs @@ -1,5 +1,6 @@ using System; using UnityEngine; +using BaseGames.Enemies; namespace BaseGames.Enemies.Navigation { @@ -48,10 +49,11 @@ namespace BaseGames.Enemies.Navigation public event Action OnGoalReached; // ── 状态 ─────────────────────────────────────────────────────── - private Rigidbody2D _rb; - private Vector2? _destination; - private bool _isMoving; - private bool _goalFired; + private Rigidbody2D _rb; + private EnemyMovement _movement; + private Vector2? _destination; + private bool _isMoving; + private bool _goalFired; private float _hoverTimer; private float _hoverFlipTimer; @@ -67,7 +69,8 @@ namespace BaseGames.Enemies.Navigation // ── Unity 生命周期 ───────────────────────────────────────────── private void Awake() { - _rb = GetComponent(); + _rb = GetComponent(); + _movement = GetComponent(); _rb.gravityScale = 0f; _rb.constraints = RigidbodyConstraints2D.FreezeRotation; } @@ -139,13 +142,23 @@ namespace BaseGames.Enemies.Navigation Vector2 newPos = Vector2.MoveTowards(myPos, target, _moveSpeed * Time.fixedDeltaTime); _rb.MovePosition(newPos); - // 面向移动方向 + // 面向移动方向(通过 EnemyMovement 输入信号,保持 _facingDir 与动画系统同步) float dx = target.x - myPos.x; if (Mathf.Abs(dx) > 0.05f) { - var s = transform.localScale; - s.x = Mathf.Abs(s.x) * Mathf.Sign(dx); - transform.localScale = s; + int dir = dx > 0f ? 1 : -1; + if (_movement != null) + { + _movement.PendingInput.WantFace = true; + _movement.PendingInput.FaceDir = dir; + } + else + { + // 降级:没有 EnemyMovement 时直接翻转(独立飞行单位) + var s = transform.localScale; + s.x = Mathf.Abs(s.x) * dir; + transform.localScale = s; + } } } diff --git a/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs b/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs deleted file mode 100644 index 688b56c..0000000 --- a/Assets/_Game/Scripts/Enemies/Perception/EnemySensorHub.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; -using Micosmo.SensorToolkit; - -namespace BaseGames.Enemies.Perception -{ - /// - /// 敌人感知 Hub(架构 07_EnemyModule §9)。 - /// 集中暴露挂载在敌人 Prefab 上的各种 SensorToolkit Sensor,BD 任务通过 - /// 字符串槽位查询,避免在 BD 任务 Inspector 中拖具体 Sensor 引用。 - /// - /// 典型槽位命名约定: - /// - "aggro" : RangeSensor2D,玩家入侵警戒圈 - /// - "attack_melee" : RangeSensor2D,近战触发距离 - /// - "attack_range" : RangeSensor2D,远程触发距离 - /// - "los" : LOSSensor2D,视线 - /// - "wall_ahead" : RaySensor2D,前方墙体检测 - /// - "ledge" : RaySensor2D,前方悬崖检测 - /// - [DisallowMultipleComponent] - public sealed class EnemySensorHub : MonoBehaviour, IPerceptionSystem - { - [System.Serializable] - public struct SensorSlot - { - public string slotName; - public Sensor sensor; - } - - [SerializeField] private SensorSlot[] _slots; - - private Dictionary _map; - - private void Awake() - { - _map = new Dictionary(_slots?.Length ?? 0); - if (_slots == null) return; - for (int i = 0; i < _slots.Length; i++) - { - var s = _slots[i]; - if (s.sensor != null && !string.IsNullOrEmpty(s.slotName)) - _map[s.slotName] = s.sensor; - } - } - - public Sensor Get(string slotName) - { - if (_map == null || string.IsNullOrEmpty(slotName)) return null; - _map.TryGetValue(slotName, out var s); - return s; - } - - public bool IsDetecting(string slotName, GameObject target) - { - var s = Get(slotName); - return s != null && target != null && s.IsDetected(target); - } - - public bool HasAnyDetection(string slotName) - { - var s = Get(slotName); - if (s == null) return false; - foreach (var _ in s.Detections) return true; - return false; - } - - public GameObject GetFirstDetection(string slotName) - { - var s = Get(slotName); - if (s == null) return null; - foreach (var go in s.Detections) return go; - return null; - } - - /// - /// 暂停或恢复所有插槽的 Sensor。 - /// 当敌人超出 QuotaManager 活跃范围时调用(关闭),归入活跃范围时恢复(开启)。 - /// - public void SetSuspended(bool suspended) - { - if (_slots == null) return; - for (int i = 0; i < _slots.Length; i++) - { - var sensor = _slots[i].sensor; - if (sensor != null) sensor.enabled = !suspended; - } - } - } -} diff --git a/Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs b/Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs index 7bad8aa..56b8f35 100644 --- a/Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs +++ b/Assets/_Game/Scripts/Enemies/Perception/IPerceptionSystem.cs @@ -4,10 +4,15 @@ namespace BaseGames.Enemies.Perception { /// /// 敌人感知系统接口。 - /// EnemyBase 通过此接口与感知实现解耦,支持运行时替换(SensorToolkit / 自定义实现)。 + /// EnemyBase 通过此接口与感知实现解耦,支持运行时替换。 + /// 当前实现为 (纯物理射线 / 圆形范围检测)。 + /// 若未来替换底层传感器实现,只需重新实现此接口,上层代码无需改动。 /// public interface IPerceptionSystem { + /// 指定槽位是否已配置(用于运行前的能力检测,避免无效查询)。 + bool HasSlot(string slotName); + /// 指定槽位是否检测到任意目标。 bool HasAnyDetection(string slotName); @@ -17,6 +22,20 @@ namespace BaseGames.Enemies.Perception /// 返回指定槽位第一个检测到的对象,无检测则返回 null。 GameObject GetFirstDetection(string slotName); + /// + /// 返回指定槽位感知区域的半径(圆形区域)。 + /// 槽位不存在、非圆形区域或实现不支持时返回 -1。 + /// 主要供编辑器 Gizmos 绘制使用。 + /// + float GetSensorRadius(string slotName); + + /// + /// 返回指定槽位检测原点相对于感知组件 transform 的偏移(X 分量已根据朝向翻转)。 + /// 槽位不存在时返回 。 + /// 供 EnemyBase.OnDrawGizmos 定位各感知圆心使用,避免所有圆重叠在 transform.position。 + /// + Vector2 GetSensorOffset(string slotName); + /// 暂停或恢复感知系统(LOD / 超出活跃范围时调用)。 void SetSuspended(bool suspended); } diff --git a/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs b/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs new file mode 100644 index 0000000..673dc62 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace BaseGames.Enemies.Perception +{ + /// + /// 敌人感知系统(自研纯物理实现)。 + /// 每个 独立配置并独立运行,支持四种检测模式: + /// • RangeCircle — Physics2D.OverlapCircleNonAlloc(可选 LOS 视线遮挡校验) + /// • BatchLOS — 委托 EnemyBase.IsPlayerVisible()(BatchLOSSystem 批量射线) + /// • FanCast — 以朝向为轴的扇形多射线视野(支持遮挡层) + /// • BoxCast — 矩形区域重叠检测(X 偏移随 localScale.x 自动翻转) + /// + /// EnemyBase.Awake() 通过 GetComponentInChildren<IPerceptionSystem>() + /// 自动发现本组件,无需修改 EnemyBase。 + /// 槽位名称常量统一定义于 。 + /// + [DisallowMultipleComponent] + public sealed class PhysicsPerceptionSystem : MonoBehaviour, IPerceptionSystem + { + // ── 槽位类型 ────────────────────────────────────────────────────────── + + public enum SlotType + { + /// Physics2D 圆形重叠检测 + RangeCircle, + /// 委托 EnemyBase.IsPlayerVisible()(BatchLOSSystem 批量射线视线检测) + BatchLOS, + /// 以朝向为轴的扇形射线视野,遮挡层阻断视线 + FanCast, + /// 矩形区域重叠检测,X 偏移随 localScale.x 自动翻转 + BoxCast + } + + // ── 槽位定义 ────────────────────────────────────────────────────────── + + [Serializable] + public struct PerceptionSlot + { + [Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\n(aggro / los / attack_melee / attack_range)")] + public string slotName; + + [Tooltip("RangeCircle:Physics2D 圆形范围检测\nBatchLOS:视线射线检测(BatchLOSSystem)\nFanCast:以朝向为轴的扇形射线视野\nBoxCast:矩形区域重叠检测")] + public SlotType type; + + [Min(0f)] + [Tooltip("RangeCircle / FanCast:检测半径(米)\nBatchLOS:最大视线检测距离(0 = 不限制)\nBoxCast:忽略此值")] + public float radius; + + [Tooltip("目标检测层(通常为 Player 层);BatchLOS 忽略此值")] + public LayerMask detectLayer; + + [Tooltip("RangeCircle / BoxCast:基础重叠命中后额外校验视线(Physics2D.Raycast)\nFanCast:true = 射线被 losBlockMask 层遮挡;false = 穿透所有障碍物")] + public bool requireLOS; + + [Tooltip("requireLOS = true / FanCast(requireLOS = true):视线遮挡检测层(通常为 Platform + Wall)\nFanCast 射线只在 requireLOS = true 时被此层遮挡")] + public LayerMask losBlockMask; + + [Header("Origin")] + [Tooltip("检测原点相对于 transform.position 的偏移(米)。\nX 分量随 localScale.x 朝向自动翻转;BatchLOS 仅影响 Gizmo,不影响实际射线。")] + public Vector2 offset; + + [Header("FanCast")] + [Tooltip("FanCast:扇形张角(度),以朝向为中轴左右均匀展开")] + public float fanAngle; + + [Tooltip("FanCast:扇形内均匀分布的射线数量(建议 5–11 条)")] + [Min(2)] + public int fanRayCount; + + [Header("BoxCast")] + [Tooltip("BoxCast:检测框尺寸 (宽, 高),单位米")] + public Vector2 boxSize; + + [Tooltip("BoxCast:相对于感知中心的偏移,X 分量随 localScale.x 自动翻转")] + public Vector2 boxOffset; + + [Header("Gizmos")] + [Tooltip("Scene 视图 Gizmo 颜色。留空(全透明黑色)时自动使用紫色回退,以确保可见。")] + public Color gizmoColor; + } + + // ── 字段 ────────────────────────────────────────────────────────────── + + [SerializeField] private PerceptionSlot[] _slots; + + private readonly Dictionary> _detected = + new Dictionary>(); + private readonly Collider2D[] _overlapBuffer = new Collider2D[32]; + private bool _suspended; + private EnemyBase _owner; + + // ── Unity 生命周期 ──────────────────────────────────────────────────── + + private void Awake() + { + _owner = GetComponentInParent(); + if (_slots == null) return; + foreach (var slot in _slots) + { + if (!string.IsNullOrEmpty(slot.slotName) && !_detected.ContainsKey(slot.slotName)) + _detected[slot.slotName] = new List(4); + } + } + + private void FixedUpdate() + { + if (_suspended || _slots == null) return; + foreach (var slot in _slots) + RefreshSlot(slot); + } + + // ── 内部检测逻辑 ────────────────────────────────────────────────────── + + private void RefreshSlot(PerceptionSlot slot) + { + if (string.IsNullOrEmpty(slot.slotName)) return; + if (!_detected.TryGetValue(slot.slotName, out var list)) return; + list.Clear(); + + switch (slot.type) + { + case SlotType.BatchLOS: + if (_owner != null && _owner.IsPlayerVisible() && _owner.PlayerTransform != null) + { + if (slot.radius > 0f) + { + float dist = Vector2.Distance( + (Vector2)transform.position, (Vector2)_owner.PlayerTransform.position); + if (dist > slot.radius) break; + } + list.Add(_owner.PlayerTransform.gameObject); + } + break; + + case SlotType.RangeCircle: + RefreshRangeCircle(slot, list); + break; + + case SlotType.FanCast: + RefreshFanCast(slot, list); + break; + + case SlotType.BoxCast: + RefreshBoxCast(slot, list); + break; + } + } + + private void RefreshRangeCircle(in PerceptionSlot slot, System.Collections.Generic.List list) + { + if (slot.radius <= 0f || slot.detectLayer == 0) return; + float facingSign = transform.localScale.x < 0f ? -1f : 1f; + Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y); + int count = Physics2D.OverlapCircleNonAlloc(origin, slot.radius, _overlapBuffer, slot.detectLayer); + for (int i = 0; i < count; i++) + { + var col = _overlapBuffer[i]; + if (col == null) continue; + if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue; + list.Add(col.gameObject); + } + } + + private void RefreshFanCast(in PerceptionSlot slot, System.Collections.Generic.List list) + { + if (slot.radius <= 0f || slot.detectLayer == 0 || slot.fanAngle <= 0f) return; + + float facingSign = transform.localScale.x < 0f ? -1f : 1f; + var forward = new Vector2(facingSign, 0f); + Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y); + 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++) + { + float t = (float)r / (rays - 1); + Vector2 dir = RotateVector(forward, Mathf.Lerp(-halfAngle, halfAngle, t)); + RaycastHit2D hit = Physics2D.Raycast(origin, dir, slot.radius, castMask); + if (hit.collider == null) continue; + if (((1 << hit.collider.gameObject.layer) & (int)slot.detectLayer) == 0) continue; + + var go = hit.collider.gameObject; + if (!list.Contains(go)) list.Add(go); + } + } + + private void RefreshBoxCast(in PerceptionSlot slot, System.Collections.Generic.List list) + { + if (slot.boxSize.x <= 0f || slot.boxSize.y <= 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 center = origin + new Vector2(slot.boxOffset.x * facingSign, slot.boxOffset.y); + + int count = Physics2D.OverlapBoxNonAlloc(center, slot.boxSize, 0f, _overlapBuffer, slot.detectLayer); + for (int i = 0; i < count; i++) + { + var col = _overlapBuffer[i]; + if (col == null) continue; + if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue; + list.Add(col.gameObject); + } + } + + private bool HasLineOfSight(Vector2 origin, GameObject target, LayerMask blockMask) + { + // 优先使用碰撞体包围盒中心(比 transform.position 更准确,避免偏心轴误判) + Vector2 targetPos; + var col = target.GetComponent(); + targetPos = col != null ? (Vector2)col.bounds.center : (Vector2)target.transform.position; + + var dir = targetPos - origin; + float dist = dir.magnitude; + if (dist <= 0f) return true; + var hit = Physics2D.Raycast(origin, dir / dist, dist, blockMask); + // 未命中任何障碍,或者第一个命中的就是目标自身(含子物体) + return hit.collider == null || hit.collider.transform.IsChildOf(target.transform) || hit.collider.gameObject == target; + } + + private static Vector2 RotateVector(Vector2 v, float angleDeg) + { + float rad = angleDeg * Mathf.Deg2Rad; + float cos = Mathf.Cos(rad); + float sin = Mathf.Sin(rad); + return new Vector2(cos * v.x - sin * v.y, sin * v.x + cos * v.y); + } + + // ── IPerceptionSystem ───────────────────────────────────────────────── + + public bool HasSlot(string slotName) + { + if (string.IsNullOrEmpty(slotName)) return false; + // 运行时通过字典; 编辑器模式遍历数组 + if (_detected.Count > 0) return _detected.ContainsKey(slotName); + if (_slots == null) return false; + foreach (var s in _slots) + if (s.slotName == slotName) return true; + return false; + } + + public bool HasAnyDetection(string slotName) => + _detected.TryGetValue(slotName, out var list) && list.Count > 0; + + public bool IsDetecting(string slotName, GameObject target) + { + if (!_detected.TryGetValue(slotName, out var list)) return false; + for (int i = 0; i < list.Count; i++) + if (list[i] == target) return true; + return false; + } + + public GameObject GetFirstDetection(string slotName) + { + if (!_detected.TryGetValue(slotName, out var list) || list.Count == 0) return null; + return list[0]; + } + + public float GetSensorRadius(string slotName) + { + if (string.IsNullOrEmpty(slotName) || _slots == null) return -1f; + foreach (var s in _slots) + if (s.slotName == slotName && s.type == SlotType.RangeCircle) + return s.radius; + return -1f; + } + + public Vector2 GetSensorOffset(string slotName) + { + if (string.IsNullOrEmpty(slotName) || _slots == null) return Vector2.zero; + float facingSign = transform.localScale.x < 0f ? -1f : 1f; + foreach (var s in _slots) + if (s.slotName == slotName) + return new Vector2(s.offset.x * facingSign, s.offset.y); + return Vector2.zero; + } + + public void SetSuspended(bool suspended) + { + _suspended = suspended; + if (suspended) + foreach (var list in _detected.Values) + list.Clear(); + } + + // ── 编辑器 API(仅 UNITY_EDITOR 访问)──────────────────────────────── + +#if UNITY_EDITOR + public PerceptionSlot[] EditorSlots => _slots; + public IReadOnlyDictionary> EditorDetected => _detected; + public EnemyBase EditorOwner => _owner; +#endif + } +} diff --git a/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs.meta b/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs.meta new file mode 100644 index 0000000..610d9ba --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Perception/PhysicsPerceptionSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c0026fe36cfaffc4e95698bccd0a8380 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs b/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs index 1dbf0c1..47bb026 100644 --- a/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs +++ b/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs @@ -1,43 +1,33 @@ namespace BaseGames.Enemies.Perception { /// - /// 槽位名称常量。 + /// 槽位名称常量。 /// /// 统一定义字符串键,避免在 BD Task Inspector 和代码中散布魔法字符串。 - /// Prefab 上 EnemySensorHub 组件的 slotName 字段必须与此处常量保持一致。 + /// Prefab 上 组件的 slotName 字段必须与此处常量保持一致。 /// public static class SensorSlotNames { /// - /// 警戒范围(RangeSensor2D):玩家进入此圈触发 Alert 阶段。 + /// 警戒范围(RangeCircle):玩家进入此圈触发 Alert 阶段。 /// 通常半径大于攻击范围,小于视线检测范围。 /// public const string Aggro = "aggro"; /// - /// 视线检测(LOSSensor2D):敌我之间无遮挡时持续为 true。 + /// 视线检测(BatchLOS):敌我之间无遮挡时持续为 true。 /// 由 BatchLOSSystem 批量计算,BD_IsPlayerVisible 读取结果。 /// public const string LOS = "los"; /// - /// 近战攻击范围(RangeSensor2D):玩家进入时触发近战攻击条件。 + /// 近战攻击范围(RangeCircle):玩家进入时触发近战攻击条件。 /// public const string AttackMelee = "attack_melee"; /// - /// 远程攻击范围(RangeSensor2D):玩家进入时触发远程攻击条件。 + /// 远程攻击范围(RangeCircle):玩家进入时触发远程攻击条件。 /// public const string AttackRange = "attack_range"; - - /// - /// 前方墙体(RaySensor2D):水平方向检测,用于巡逻转向。 - /// - public const string WallAhead = "wall_ahead"; - - /// - /// 前方悬崖(RaySensor2D):斜向下检测地面是否存在,用于巡逻转向。 - /// - public const string Ledge = "ledge"; } } diff --git a/Assets/_Game/Scripts/Input/InputReaderSO.cs b/Assets/_Game/Scripts/Input/InputReaderSO.cs index 2ecb109..d6dd9aa 100644 --- a/Assets/_Game/Scripts/Input/InputReaderSO.cs +++ b/Assets/_Game/Scripts/Input/InputReaderSO.cs @@ -47,11 +47,14 @@ namespace BaseGames.Input // ── Polling ─────────────────────────────────────────────────────────── public Vector2 MoveInput { get; private set; } + /// 跳跃键当前是否处于按下状态。供 JumpState 在进入时检测短按是否已松开。 + public bool IsJumpHeld => _jumpAction != null && _jumpAction.IsPressed(); // ── Runtime state ───────────────────────────────────────────────────── private InputActionMap _gameplay; private InputActionMap _ui; private bool _isBound; + private InputAction _jumpAction; private void EnsureInitialized() { @@ -92,6 +95,7 @@ namespace BaseGames.Input _gameplay = null; _ui = null; _isBound = false; + _jumpAction = null; EnsureInitialized(); } @@ -171,6 +175,7 @@ namespace BaseGames.Input BindStarted(_gameplay, "Jump", () => JumpStartedEvent?.Invoke()); BindCanceled(_gameplay, "Jump", () => JumpCancelledEvent?.Invoke()); + _jumpAction = _gameplay.FindAction("Jump", throwIfNotFound: false); BindStarted(_gameplay, "Attack", () => AttackEvent?.Invoke()); BindStarted(_gameplay, "Parry", () => ParryEvent?.Invoke()); BindStarted(_gameplay, "Dash", () => DashEvent?.Invoke()); diff --git a/Assets/_Game/Scripts/Player/PlayerMovement.cs b/Assets/_Game/Scripts/Player/PlayerMovement.cs index 5223e8f..b398570 100644 --- a/Assets/_Game/Scripts/Player/PlayerMovement.cs +++ b/Assets/_Game/Scripts/Player/PlayerMovement.cs @@ -100,6 +100,20 @@ namespace BaseGames.Player // 开启连续碰撞检测(CCD):Kinematic 移动平台通过 MovePosition 将 Dynamic 角色推向墙体时, // CCD 会沿移动路径追踪碰撞,确保角色在物理层被墙体表面拦截,而不是在离散步骤中穿透墙体。 _rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; + if (_config != null) + _config.OnConfigChanged += OnMovementConfigChanged; + } + + private void OnDestroy() + { + if (_config != null) + _config.OnConfigChanged -= OnMovementConfigChanged; + } + + private void OnMovementConfigChanged() + { + if (_rb != null) + _rb.gravityScale = _config.DefaultGravityScale; } private void FixedUpdate() diff --git a/Assets/_Game/Scripts/Player/PlayerMovementConfigSO.cs b/Assets/_Game/Scripts/Player/PlayerMovementConfigSO.cs index e5e610c..0917655 100644 --- a/Assets/_Game/Scripts/Player/PlayerMovementConfigSO.cs +++ b/Assets/_Game/Scripts/Player/PlayerMovementConfigSO.cs @@ -1,3 +1,4 @@ +using System; using UnityEngine; namespace BaseGames.Player @@ -26,6 +27,11 @@ namespace BaseGames.Player [Tooltip("松开跳跃键时速度保留比例(变高跳)。推荐 0.35,越小跳跃越低。")] [Range(0f, 1f)] public float JumpCutMultiplier = 0.35f; + [Tooltip("短按最小跳跃保护窗口(秒)。\n" + + "窗口内松开跳跃键不会立即截断速度;窗口结束时统一判断是否截断,\n" + + "保证无论短按多快/多慢,最小跳跃高度始终一致(消除帧级手感抖动)。\n" + + "推荐 0.08(≈5帧@60fps)。调高 → 最小跳更高;调低 → 最小跳更低。")] + public float MinJumpTime = 0.08f; [Header("跳跃 — 顶点悬停")] [Tooltip("顶点悬停触发阈值(单位/秒)。当 |垂直速度| 低于此值时,重力缩减为 ApexGravityMultiplier 倍,\n产生\"滞空感\"。推荐 3。调高 → 悬停段更长;调低 → 悬停段更短乃至消失。")] @@ -92,5 +98,16 @@ namespace BaseGames.Player [Header("重力")] public float DefaultGravityScale = 3f; + + // ── 运行时热更新通知 ────────────────────────────────────────────────── + /// + /// Inspector 中修改配置时触发(仅编辑器),订阅方可立即应用新值,无需重启 PlayMode。 + /// + public event Action OnConfigChanged; + + private void OnValidate() + { + OnConfigChanged?.Invoke(); + } } } diff --git a/Assets/_Game/Scripts/Player/States/JumpState.cs b/Assets/_Game/Scripts/Player/States/JumpState.cs index c3dc931..92e34d0 100644 --- a/Assets/_Game/Scripts/Player/States/JumpState.cs +++ b/Assets/_Game/Scripts/Player/States/JumpState.cs @@ -16,6 +16,11 @@ namespace BaseGames.Player.States { private bool _isDoubleJump; + // 最小跳跃窗口:窗口内松键记录 _cutPending,窗口结束时统一执行 CutJump, + // 保证短按始终在相同 vy 时截断 → 最小跳跃高度完全一致,消除帧级手感抖动。 + private float _minJumpTimer; + private bool _cutPending; + public JumpState(PlayerController owner) : base(owner) { } /// @@ -40,6 +45,7 @@ namespace BaseGames.Player.States Move.Jump(); _isDoubleJump = false; // 消耗标记 + ResetMinJumpWindow(); Input.JumpCancelledEvent += OnJumpCancelled; // 开启上升阶段贴墙 vy 保护:防止物理摩擦降低跳跃最高点 Move.SetPreserveVyOnWallContact(true); @@ -96,6 +102,7 @@ namespace BaseGames.Player.States { Owner.UseAirJump(); Move.DoubleJump(); + ResetMinJumpWindow(); // 二段跳重置最小跳跃窗口 // 播放空中跳跃动画,未配置时回退到 Jump var airJumpClip = AnimCfg?.AirJump ?? AnimCfg?.Jump; if (airJumpClip != null) Anim?.Play(airJumpClip); @@ -134,6 +141,20 @@ namespace BaseGames.Player.States public override void OnStateFixedUpdate() { + // ── 最小跳跃窗口:窗口内松键不立即截断,窗口结束时统一处理 ───────────── + // 保证短按(无论在窗口内哪帧松键)都在相同 vy 时执行 CutJump → 一致的最小跳跃高度。 + if (_minJumpTimer > 0f) + { + _minJumpTimer -= Time.fixedDeltaTime; + if (_minJumpTimer <= 0f) + { + // 窗口到期:已收到松键事件,或当前键未按住(InputBuffer 延迟导致进入时键已释放) + if (_cutPending || !Input.IsJumpHeld) + Move.CutJump(); + _cutPending = false; + } + } + // ── 顶点悬停:第一判断 |vy|,动态切换重力缩放系数 ──────────────── // |垂直速度| 低于顶点阈值时,重力缩减至 ApexGravityMultiplier 倍, // 产生角色在跳跃顶点起起“滒空”的手感,属于高重力平台游戏的标志性特征。 @@ -191,6 +212,18 @@ namespace BaseGames.Player.States Input.JumpCancelledEvent -= OnJumpCancelled; } - private void OnJumpCancelled() => Move.CutJump(); + private void OnJumpCancelled() + { + if (_minJumpTimer > 0f) + _cutPending = true; // 窗口内:推迟到窗口结束时执行,保证一致的截断时机 + else + Move.CutJump(); // 窗口已过(长按):立即截断 → 变高跳 + } + + private void ResetMinJumpWindow() + { + _minJumpTimer = Cfg.MinJumpTime; + _cutPending = false; + } } } diff --git a/Assets/_Game/Scripts/Player/States/PlayerController.cs b/Assets/_Game/Scripts/Player/States/PlayerController.cs index 81e6186..6585c77 100644 --- a/Assets/_Game/Scripts/Player/States/PlayerController.cs +++ b/Assets/_Game/Scripts/Player/States/PlayerController.cs @@ -226,12 +226,12 @@ namespace BaseGames.Player.States _overlayLayer = _animancer.Layers[1]; } - // 注入 HurtBox 依赖 - if (_hurtBox != null) + // 注入 HurtBox 依赖(覆盖所有子节点 HurtBox,支持多形状受击区) + foreach (var hb in GetComponentsInChildren(true)) { - if (_shield != null) _hurtBox.SetShieldable(_shield); - if (_parrySystem != null) _hurtBox.SetParrySystem(_parrySystem); - _hurtBox.SetPoiseSource(this); + if (_shield != null) hb.SetShieldable(_shield); + if (_parrySystem != null) hb.SetParrySystem(_parrySystem); + hb.SetPoiseSource(this); } // 将唯一配置点(_inputReader)注入到 ParrySystem。