diff --git a/Assets/AddressableAssetsData/AddressableAssetSettings.asset b/Assets/AddressableAssetsData/AddressableAssetSettings.asset index 6e243d4..3a7a557 100644 --- a/Assets/AddressableAssetsData/AddressableAssetSettings.asset +++ b/Assets/AddressableAssetsData/AddressableAssetSettings.asset @@ -15,7 +15,7 @@ MonoBehaviour: m_DefaultGroup: 9ce5c865a2d3a0840aabdd8ccb3fd4b1 m_currentHash: serializedVersion: 2 - Hash: 00000000000000000000000000000000 + Hash: 3b5a6592fec2f53c65ab132b7f731fb2 m_OptimizeCatalogSize: 0 m_BuildRemoteCatalog: 0 m_BundleLocalCatalog: 0 @@ -88,6 +88,14 @@ MonoBehaviour: m_LabelTable: m_LabelNames: - default + - Preload + - Poolable + - Enemy + - BGM + - SFX + - Charms + - Config + - Weapon m_SchemaTemplates: [] m_GroupTemplateObjects: - {fileID: 11400000, guid: f9701da6026b3a54f9b4d6eb144ee443, type: 2} diff --git a/Assets/_Game/Data/Combat/Weapons/WPN_DiHun.asset b/Assets/_Game/Data/Combat/Weapons/WPN_DiHun.asset index 5ba8693..8b1587e 100644 --- a/Assets/_Game/Data/Combat/Weapons/WPN_DiHun.asset +++ b/Assets/_Game/Data/Combat/Weapons/WPN_DiHun.asset @@ -134,6 +134,7 @@ MonoBehaviour: weaponTrailPrefab: {fileID: 0} trailColor: {r: 1, g: 1, b: 1, a: 1} soulPowerGain: 10 + hitWeight: 1 references: version: 2 RefIds: [] diff --git a/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset b/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset index 851bc1f..63bc628 100644 --- a/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset +++ b/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset @@ -12,23 +12,25 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 81da55e0fcf99d34693cbc5a348225c3, type: 3} m_Name: PLY_PlayerMovementConfig m_EditorClassIdentifier: - RunSpeed: 8 + RunSpeed: 6 AirDragFactor: 1 - JumpForce: 24 + JumpForce: 20 CoyoteTime: 0.12 FallGravityMult: 2.5 MaxFallSpeed: 28 JumpCutMultiplier: 0.321 ApexThreshold: 3 ApexGravityMultiplier: 0.3 - MaxAirJumps: 1 - DoubleJumpForce: 19 + MaxAirJumps: 5 + DoubleJumpForce: 15 DashSpeed: 20 DashDuration: 0.25 DashCooldown: 0.4 DashInvincibilityDuration: 0.2 DashInvincibilityCooldown: 0.9 - WallSlideSpeed: 2 + DownDashSpeed: 22 + DownDashDuration: 0.25 + WallSlideSpeed: 3 WallHangSpeed: 1 WallRayLength: 0.37 WallRayOffsetY: 0.2 diff --git a/Assets/_Game/Prefabs/Weapons/WPN_WPN_DiHun_HitBox.prefab b/Assets/_Game/Prefabs/Weapons/WPN_WPN_DiHun_HitBox.prefab index fef1845..275a29a 100644 --- a/Assets/_Game/Prefabs/Weapons/WPN_WPN_DiHun_HitBox.prefab +++ b/Assets/_Game/Prefabs/Weapons/WPN_WPN_DiHun_HitBox.prefab @@ -1,101 +1,5 @@ %YAML 1.1 %TAG !u! tag:unity3d.com,2011: ---- !u!1 &467203328547477162 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 7119158475861943178} - - component: {fileID: 7882116945389632025} - - component: {fileID: 4639356286286040131} - m_Layer: 14 - m_Name: HitBox_Ground_1 - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!4 &7119158475861943178 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 467203328547477162} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0.798, y: 0, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 8975424752584779179} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!61 &7882116945389632025 -BoxCollider2D: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 467203328547477162} - 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.16736698} - m_SpriteTilingProperty: - border: {x: 0, y: 0, z: 0, w: 0} - pivot: {x: 0, y: 0} - oldSize: {x: 0, y: 0} - newSize: {x: 0, y: 0} - adaptiveTilingThreshold: 0 - drawMode: 0 - adaptiveTiling: 0 - m_AutoTiling: 0 - serializedVersion: 2 - m_Size: {x: 1, y: 0.83473396} - m_EdgeRadius: 0 ---- !u!114 &4639356286286040131 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 467203328547477162} - 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: ATK_Ground_1 - _rivalHitBoxMask: - serializedVersion: 2 - m_Bits: 134217792 --- !u!1 &1932889250901504761 GameObject: m_ObjectHideFlags: 0 @@ -123,7 +27,7 @@ Transform: m_GameObject: {fileID: 1932889250901504761} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: -0.562, z: 0} + m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] @@ -161,7 +65,7 @@ BoxCollider2D: m_IsTrigger: 1 m_UsedByEffector: 0 m_UsedByComposite: 0 - m_Offset: {x: -0.027121663, y: -0.15051937} + m_Offset: {x: 0, y: 0} m_SpriteTilingProperty: border: {x: 0, y: 0, z: 0, w: 0} pivot: {x: 0, y: 0} @@ -172,7 +76,7 @@ BoxCollider2D: adaptiveTiling: 0 m_AutoTiling: 0 serializedVersion: 2 - m_Size: {x: 1.189852, y: 0.80103874} + m_Size: {x: 1, y: 0.5} m_EdgeRadius: 0 --- !u!114 &6478051166999031478 MonoBehaviour: @@ -188,11 +92,11 @@ MonoBehaviour: m_EditorClassIdentifier: _defaultSource: {fileID: 0} _hitCooldown: 0.1 - _id: ATK_Down + _id: _rivalHitBoxMask: serializedVersion: 2 - m_Bits: 134217792 ---- !u!1 &2584603199706918030 + m_Bits: 0 +--- !u!1 &3989564331693126876 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -200,38 +104,38 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1660186156348129284} - - component: {fileID: 1152578598430080845} - - component: {fileID: 3007294148525084107} + - component: {fileID: 8294071144630811572} + - component: {fileID: 4949779957213724475} + - component: {fileID: 4757677899241504248} m_Layer: 14 - m_Name: HitBox_Ground _2 + m_Name: HitBox_Ground m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!4 &1660186156348129284 +--- !u!4 &8294071144630811572 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2584603199706918030} + m_GameObject: {fileID: 3989564331693126876} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0.798, y: 0, z: 0} + m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 8975424752584779179} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!61 &1152578598430080845 +--- !u!61 &4949779957213724475 BoxCollider2D: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2584603199706918030} + m_GameObject: {fileID: 3989564331693126876} m_Enabled: 1 m_Density: 1 m_Material: {fileID: 0} @@ -257,7 +161,7 @@ BoxCollider2D: m_IsTrigger: 1 m_UsedByEffector: 0 m_UsedByComposite: 0 - m_Offset: {x: -0.117884755, y: 0.01309824} + m_Offset: {x: 0, y: 0} m_SpriteTilingProperty: border: {x: 0, y: 0, z: 0, w: 0} pivot: {x: 0, y: 0} @@ -268,15 +172,15 @@ BoxCollider2D: adaptiveTiling: 0 m_AutoTiling: 0 serializedVersion: 2 - m_Size: {x: 0.7642305, y: 1.1956644} + m_Size: {x: 1, y: 0.5} m_EdgeRadius: 0 ---- !u!114 &3007294148525084107 +--- !u!114 &4757677899241504248 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2584603199706918030} + m_GameObject: {fileID: 3989564331693126876} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3} @@ -284,106 +188,10 @@ MonoBehaviour: m_EditorClassIdentifier: _defaultSource: {fileID: 0} _hitCooldown: 0.1 - _id: ATK_Ground_2 + _id: _rivalHitBoxMask: - serializedVersion: 2 - m_Bits: 134217792 ---- !u!1 &4050057806632877121 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 4405470499151834857} - - component: {fileID: 8597809172682257212} - - component: {fileID: 1610035618021234136} - m_Layer: 14 - m_Name: HitBox_Air_2 - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!4 &4405470499151834857 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 4050057806632877121} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0.553, y: 0, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 8975424752584779179} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!61 &8597809172682257212 -BoxCollider2D: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 4050057806632877121} - 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.27943045, y: 0} - m_SpriteTilingProperty: - border: {x: 0, y: 0, z: 0, w: 0} - pivot: {x: 0, y: 0} - oldSize: {x: 0, y: 0} - newSize: {x: 0, y: 0} - adaptiveTilingThreshold: 0 - drawMode: 0 - adaptiveTiling: 0 - m_AutoTiling: 0 - serializedVersion: 2 - m_Size: {x: 1.0588609, y: 1} - m_EdgeRadius: 0 ---- !u!114 &1610035618021234136 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 4050057806632877121} - 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: ATK_Air_2 - _rivalHitBoxMask: - serializedVersion: 2 - m_Bits: 134217792 --- !u!1 &4335406389674002762 GameObject: m_ObjectHideFlags: 0 @@ -411,7 +219,7 @@ Transform: m_GameObject: {fileID: 4335406389674002762} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0.918, z: 0} + m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] @@ -449,7 +257,7 @@ BoxCollider2D: m_IsTrigger: 1 m_UsedByEffector: 0 m_UsedByComposite: 0 - m_Offset: {x: 0.072324514, y: 0} + m_Offset: {x: 0, y: 0} m_SpriteTilingProperty: border: {x: 0, y: 0, z: 0, w: 0} pivot: {x: 0, y: 0} @@ -460,7 +268,7 @@ BoxCollider2D: adaptiveTiling: 0 m_AutoTiling: 0 serializedVersion: 2 - m_Size: {x: 1.1599612, y: 1} + m_Size: {x: 0.5, y: 1} m_EdgeRadius: 0 --- !u!114 &1392799324577637263 MonoBehaviour: @@ -476,10 +284,10 @@ MonoBehaviour: m_EditorClassIdentifier: _defaultSource: {fileID: 0} _hitCooldown: 0.1 - _id: ATK_Up + _id: _rivalHitBoxMask: serializedVersion: 2 - m_Bits: 134217792 + m_Bits: 0 --- !u!1 &4821376343125962025 GameObject: m_ObjectHideFlags: 0 @@ -510,12 +318,10 @@ Transform: m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: - - {fileID: 7119158475861943178} - - {fileID: 1660186156348129284} + - {fileID: 8294071144630811572} - {fileID: 7468586589501741901} - {fileID: 6088225995420515986} - - {fileID: 4362395311111627733} - - {fileID: 4405470499151834857} + - {fileID: 6913225169405126738} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &3691925044832415471 @@ -530,11 +336,11 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: ec12dacf2519f58429dd3c59da8f93b0, type: 3} m_Name: m_EditorClassIdentifier: - _hitBoxGround: {fileID: 4639356286286040131} + _hitBoxGround: {fileID: 4757677899241504248} _hitBoxUp: {fileID: 1392799324577637263} _hitBoxDown: {fileID: 6478051166999031478} - _hitBoxAir: {fileID: 9014207169512774676} ---- !u!1 &8582289489283119946 + _hitBoxAir: {fileID: 1382006829078153708} +--- !u!1 &6434981771063321190 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -542,38 +348,38 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 4362395311111627733} - - component: {fileID: 922051492914393482} - - component: {fileID: 9014207169512774676} + - component: {fileID: 6913225169405126738} + - component: {fileID: 6843760498109474434} + - component: {fileID: 1382006829078153708} m_Layer: 14 - m_Name: HitBox_Air_1 + m_Name: HitBox_Air m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!4 &4362395311111627733 +--- !u!4 &6913225169405126738 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 8582289489283119946} + m_GameObject: {fileID: 6434981771063321190} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0.553, y: 0, z: 0} + m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 8975424752584779179} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!61 &922051492914393482 +--- !u!61 &6843760498109474434 BoxCollider2D: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 8582289489283119946} + m_GameObject: {fileID: 6434981771063321190} m_Enabled: 1 m_Density: 1 m_Material: {fileID: 0} @@ -599,7 +405,7 @@ BoxCollider2D: m_IsTrigger: 1 m_UsedByEffector: 0 m_UsedByComposite: 0 - m_Offset: {x: 0.46717286, y: 0} + m_Offset: {x: 0, y: 0} m_SpriteTilingProperty: border: {x: 0, y: 0, z: 0, w: 0} pivot: {x: 0, y: 0} @@ -610,15 +416,15 @@ BoxCollider2D: adaptiveTiling: 0 m_AutoTiling: 0 serializedVersion: 2 - m_Size: {x: 1.4343457, y: 1} + m_Size: {x: 0.5, y: 1} m_EdgeRadius: 0 ---- !u!114 &9014207169512774676 +--- !u!114 &1382006829078153708 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 8582289489283119946} + m_GameObject: {fileID: 6434981771063321190} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3} @@ -626,7 +432,7 @@ MonoBehaviour: m_EditorClassIdentifier: _defaultSource: {fileID: 0} _hitCooldown: 0.1 - _id: ATK_Air_1 + _id: _rivalHitBoxMask: serializedVersion: 2 - m_Bits: 134217792 + m_Bits: 0 diff --git a/Assets/_Game/Scripts/Camera/CameraPixelSnapper.cs b/Assets/_Game/Scripts/Camera/CameraPixelSnapper.cs index 904eb43..5d6ff88 100644 --- a/Assets/_Game/Scripts/Camera/CameraPixelSnapper.cs +++ b/Assets/_Game/Scripts/Camera/CameraPixelSnapper.cs @@ -39,6 +39,8 @@ namespace BaseGames.Camera [SerializeField] private CinemachineBrain _brain; private UnityEngine.Camera _camera; + private CinemachineCamera _cachedVCam; + private CinemachineConfiner3D _cachedConfiner; private void Reset() { @@ -77,7 +79,8 @@ namespace BaseGames.Camera /// /// 获取当前活跃 VCam 的 CinemachineConfiner3D 边界盒(世界空间 AABB)。 - /// 用于在像素取整后将相机钳制回限位区域内。 + /// 缓存上次查询的 VCam 实例;仅在活跃 VCam 发生切换时重新调用 GetComponent, + /// 避免每帧 GetComponent 开销。 /// private bool TryGetActiveConfinerBounds(out Bounds bounds) { @@ -85,9 +88,14 @@ namespace BaseGames.Camera if (_brain == null) return false; var vcam = _brain.ActiveVirtualCamera as CinemachineCamera; if (vcam == null) return false; - var confiner = vcam.GetComponent(); - if (confiner == null || !confiner.IsValid) return false; - bounds = confiner.BoundingVolume.bounds; + // 只在活跃 VCam 切换时刷新缓存 + if (!ReferenceEquals(vcam, _cachedVCam)) + { + _cachedVCam = vcam; + _cachedConfiner = vcam.GetComponent(); + } + if (_cachedConfiner == null || !_cachedConfiner.IsValid) return false; + bounds = _cachedConfiner.BoundingVolume.bounds; return true; } diff --git a/Assets/_Game/Scripts/Camera/CameraStateController.cs b/Assets/_Game/Scripts/Camera/CameraStateController.cs index e3aa825..f2d6914 100644 --- a/Assets/_Game/Scripts/Camera/CameraStateController.cs +++ b/Assets/_Game/Scripts/Camera/CameraStateController.cs @@ -114,7 +114,8 @@ namespace BaseGames.Camera } // 触发区域进入:更新集合(同一区域去重后重新加入,保证最新优先级) - _activeZones.RemoveAll(e => e.area == area); + for (int i = _activeZones.Count - 1; i >= 0; i--) + if (_activeZones[i].area == area) _activeZones.RemoveAt(i); _activeZones.Add((area, priority)); // 仅当此区域是当前最优且尚未激活时才切换 @@ -132,7 +133,9 @@ namespace BaseGames.Camera if (releasedArea == null) return; bool wasActive = releasedArea == _currentArea; - int removed = _activeZones.RemoveAll(e => e.area == releasedArea); + int removed = 0; + for (int i = _activeZones.Count - 1; i >= 0; i--) + if (_activeZones[i].area == releasedArea) { _activeZones.RemoveAt(i); removed++; } // 若区域本就不在栈中,且又不是当前激活区,则无需任何操作 if (removed == 0 && !wasActive) return; diff --git a/Assets/_Game/Scripts/Camera/CameraTriggerZone.cs b/Assets/_Game/Scripts/Camera/CameraTriggerZone.cs index 00b160a..0c605ee 100644 --- a/Assets/_Game/Scripts/Camera/CameraTriggerZone.cs +++ b/Assets/_Game/Scripts/Camera/CameraTriggerZone.cs @@ -57,9 +57,14 @@ namespace BaseGames.Camera // ── 静态:跨实例共享触发状态 ────────────────────────────────────────── // 玩家当前物理上所在的所有触发区域(按进入顺序排列) private static readonly List s_InsideZones = new(); + // 场景内所有已启用的触发区域,供 RoomController 等查询(替代 FindObjectsOfType) + private static readonly List s_AllZones = new(); // 当前已向 ICameraService 发出 SwitchArea 请求的触发区域 private static CameraTriggerZone s_ActiveZone; + /// 场景内当前所有已启用的触发区域(只读)。 + public static IReadOnlyList AllZones => s_AllZones; + /// /// 在每次进入 Play Mode 前(或禁用 Domain Reload 时的跨会话)重置静态状态, /// 防止上一次游戏会话残留的区域引用导致触发逻辑错误。 @@ -68,6 +73,7 @@ namespace BaseGames.Camera private static void ResetStaticState() { s_InsideZones.Clear(); + s_AllZones.Clear(); s_ActiveZone = null; } @@ -77,12 +83,22 @@ namespace BaseGames.Camera _collider.isTrigger = true; } + private void OnEnable() + { + if (Application.isPlaying) + s_AllZones.Add(this); + } + private void OnDisable() { if (!Application.isPlaying) return; + s_AllZones.Remove(this); HandlePlayerExit(); } + /// 判断世界坐标点是否在本触发区域多边形内(供 RoomController 等无需 GetComponent 直接查询)。 + public bool ContainsPoint(Vector2 worldPoint) => _collider != null && _collider.OverlapPoint(worldPoint); + /// /// 若玩家出生时已在触发区域内,OnTriggerEnter2D 不会触发。 /// 延迟一帧(确保 RoomController.Start 先完成基准区域设置)后主动检测。 diff --git a/Assets/_Game/Scripts/Combat/HitBox.cs b/Assets/_Game/Scripts/Combat/HitBox.cs index 5f0d62a..ffa5026 100644 --- a/Assets/_Game/Scripts/Combat/HitBox.cs +++ b/Assets/_Game/Scripts/Combat/HitBox.cs @@ -142,8 +142,8 @@ namespace BaseGames.Combat bool isRivalHitBoxLayer = (_rivalHitBoxMask.value & (1 << otherLayer)) != 0; if (isRivalHitBoxLayer && CanClash) { - var rivalHitBox = other.GetComponent(); - if (rivalHitBox != null && rivalHitBox.IsActive && rivalHitBox.CanClash) + if (other.TryGetComponent(out var rivalHitBox) && + rivalHitBox.IsActive && rivalHitBox.CanClash) { _clashService?.ResolveClash(this, rivalHitBox); return; // 拼刀,中止伤害流水线 @@ -151,8 +151,7 @@ namespace BaseGames.Combat } // ② 命中 HurtBox - var hurtBox = other.GetComponent(); - if (hurtBox != null) + if (other.TryGetComponent(out var hurtBox)) { // 用 HitBox 自身碰撞盒中心在 HurtBox 表面上的最近点作为受击位置。 // 对大体积/长条形受击体(如地刺),此点远比 HurtBox 节点中心更准确。 @@ -163,7 +162,8 @@ namespace BaseGames.Combat } // ③ 命中 IBreakable(机关/障碍物) - other.GetComponent()?.TryInteract(info); + if (other.TryGetComponent(out var breakable)) + breakable.TryInteract(info); } // ── 当前激活期已命中目标集合(防止复合子 Collider 导致同帧多次命中)──────────── diff --git a/Assets/_Game/Scripts/Combat/Projectile.cs b/Assets/_Game/Scripts/Combat/Projectile.cs index bc548b0..6d2f25e 100644 --- a/Assets/_Game/Scripts/Combat/Projectile.cs +++ b/Assets/_Game/Scripts/Combat/Projectile.cs @@ -18,6 +18,8 @@ namespace BaseGames.Combat protected Rigidbody2D _rb; protected HitBox _hitBox; protected float _aliveTimer; + // Lifetime 在 Initialize 时缓存,避免 Update 每帧访问 SO 成员并做 null check + private float _lifetime = float.MaxValue; private PooledObject _pooledObject; @@ -32,6 +34,7 @@ namespace BaseGames.Combat public virtual void Initialize(ProjectileConfigSO config, DamageInfo damageInfo, Vector2 direction, int ownerLayer = 0) { _config = config; + _lifetime = config.Lifetime; DamageInfo = damageInfo; Direction = direction.normalized; _aliveTimer = 0f; @@ -75,7 +78,7 @@ namespace BaseGames.Combat protected virtual void Update() { _aliveTimer += Time.deltaTime; - if (_config != null && _aliveTimer >= _config.Lifetime) + if (_aliveTimer >= _lifetime) ReturnToPool(); } @@ -91,6 +94,7 @@ namespace BaseGames.Combat protected virtual void OnDisable() { _aliveTimer = 0f; + _lifetime = float.MaxValue; // 归还对象池后重置,防止未初始化时自毁 } } } diff --git a/Assets/_Game/Scripts/Combat/SkillHitBoxInstance.cs b/Assets/_Game/Scripts/Combat/SkillHitBoxInstance.cs index 82ed528..79e0b50 100644 --- a/Assets/_Game/Scripts/Combat/SkillHitBoxInstance.cs +++ b/Assets/_Game/Scripts/Combat/SkillHitBoxInstance.cs @@ -19,6 +19,11 @@ namespace BaseGames.Combat public event System.Action OnHitConfirmed; + private Coroutine _returnCoroutine; + // 按 duration 缓存 WaitForSeconds,同一技能复用无 GC 分配 + private WaitForSeconds _cachedWait; + private float _cachedWaitDuration = float.NaN; + private void Awake() { foreach (var hb in _hitBoxes) @@ -35,7 +40,31 @@ namespace BaseGames.Combat hb?.Activate(source, attacker); } - /// duration 秒后自动销毁此 GameObject。 + /// + /// duration 秒后归还对象池(SetActive false)。 + /// 由 SkillManager 对象池调用;替代旧版 Destroy 流程。 + /// + public void AutoReturnAfter(float duration) + { + if (!Mathf.Approximately(_cachedWaitDuration, duration)) + { + _cachedWaitDuration = duration; + _cachedWait = new WaitForSeconds(duration); + } + if (_returnCoroutine != null) StopCoroutine(_returnCoroutine); + _returnCoroutine = StartCoroutine(ReturnCoroutine()); + } + + private System.Collections.IEnumerator ReturnCoroutine() + { + yield return _cachedWait; + foreach (var hb in _hitBoxes) + hb?.Deactivate(); + _returnCoroutine = null; + gameObject.SetActive(false); // 触发对象池回收 + } + + /// duration 秒后销毁(非池化路径,保留向后兼容)。 public void AutoDestroyAfter(float duration) => Destroy(gameObject, Mathf.Max(0f, duration)); diff --git a/Assets/_Game/Scripts/Combat/StatusEffects/FireEffect.cs b/Assets/_Game/Scripts/Combat/StatusEffects/FireEffect.cs index 1dbbc50..8fd4381 100644 --- a/Assets/_Game/Scripts/Combat/StatusEffects/FireEffect.cs +++ b/Assets/_Game/Scripts/Combat/StatusEffects/FireEffect.cs @@ -11,8 +11,9 @@ namespace BaseGames.Combat.StatusEffects public override StatusEffectType EffectType => StatusEffectType.Fire; public override int MaxStacks => 1; + private static readonly StatusEffectType[] s_MutualExclusions = { StatusEffectType.Freeze }; /// 施加燃烧时移除冻结(火冰互斥)。 - public override StatusEffectType[] MutualExclusions => new[] { StatusEffectType.Freeze }; + public override StatusEffectType[] MutualExclusions => s_MutualExclusions; public FireEffect() { diff --git a/Assets/_Game/Scripts/Combat/StatusEffects/StatusEffectManager.cs b/Assets/_Game/Scripts/Combat/StatusEffects/StatusEffectManager.cs index 779586a..853d81a 100644 --- a/Assets/_Game/Scripts/Combat/StatusEffects/StatusEffectManager.cs +++ b/Assets/_Game/Scripts/Combat/StatusEffects/StatusEffectManager.cs @@ -27,6 +27,8 @@ namespace BaseGames.Combat.StatusEffects // ── Shader 渲染(MaterialPropertyBlock,不修改共享材质)───────── private SpriteRenderer _renderer; private MaterialPropertyBlock _propBlock; + // 缓存 Shader 属性 ID,避免每次调用 SetShaderParam 都做字符串哈希查找 + private readonly Dictionary _shaderPropIds = new(); // ── DoT 伤害代理(由 StatusEffect.OnTick 通过 Owner 调用)────────── private IDamageable _damageable; @@ -135,8 +137,13 @@ namespace BaseGames.Combat.StatusEffects public void SetShaderParam(string param, float value) { if (_renderer == null) return; + if (!_shaderPropIds.TryGetValue(param, out int propId)) + { + propId = Shader.PropertyToID(param); + _shaderPropIds[param] = propId; + } _renderer.GetPropertyBlock(_propBlock); - _propBlock.SetFloat(param, value); + _propBlock.SetFloat(propId, value); _renderer.SetPropertyBlock(_propBlock); } diff --git a/Assets/_Game/Scripts/Core/SceneService.cs b/Assets/_Game/Scripts/Core/SceneService.cs index 98baa3d..bf98551 100644 --- a/Assets/_Game/Scripts/Core/SceneService.cs +++ b/Assets/_Game/Scripts/Core/SceneService.cs @@ -24,6 +24,16 @@ namespace BaseGames.Core /// Room:极短淡出(),无加载画面。 /// Scene:完整淡出(),显示加载画面。 /// + /// + /// 完整加载时序(保证场景物体在显示前完成状态初始化): + /// + /// 淡出(黑幕遮挡) + /// Addressable 异步加载场景(场景物体 Awake / OnEnable 同步执行) + /// 触发 :通知场景物体从 WorldStateRegistry 恢复初始状态 + /// 等待一帧(保证所有场景物体 Start() 和事件处理器执行完毕) + /// 淡入(显示已完成初始化的场景) + /// + /// /// [DefaultExecutionOrder(-900)] public class SceneService : MonoBehaviour, ISceneService @@ -35,6 +45,12 @@ namespace BaseGames.Core [SerializeField] private VoidEventChannelSO _onFadeInRequest; [SerializeField] private VoidEventChannelSO _onFadeOutRequest; + [Tooltip("场景加载完成、WorldStateRegistry 已就绪后触发。\n" + + "场景内物体应订阅此事件,从 WorldStateRegistry 读取存档状态并应用(替代在 Start() 中读取)。\n" + + "触发后会等待一帧,确保所有处理器执行完毕,再执行淡入显示场景。\n" + + "对应 SO:EVT_SceneWorldStateRestored")] + [SerializeField] private VoidEventChannelSO _onSceneWorldStateRestored; + [SerializeField] private SceneLoader _sceneLoader; [Header("淡出时长")] @@ -71,6 +87,14 @@ namespace BaseGames.Core else Debug.LogError("[SceneService] _sceneLoader 未赋值,场景加载中断。请在 Inspector 中绑定 SceneLoader 组件。"); + // 通知:WorldStateRegistry 已就绪,场景物体应在此帧内从中读取存档状态并应用初始状态。 + // 订阅者(WorldStateRegistrySaver、各场景 StateApplier 等)会在同一帧同步执行。 + _onSceneWorldStateRestored?.Raise(); + + // 等待一帧:确保所有场景物体的 Start() 和事件处理器都已执行完毕, + // 场景物体处于正确的初始状态后再揭开黑幕,避免出现一帧状态错误的画面闪烁。 + yield return null; + _onFadeInRequest?.Raise(); } diff --git a/Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs b/Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs index 8f082a1..d28e612 100644 --- a/Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs +++ b/Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs @@ -1,1079 +1,10 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; -using UnityEditor; -using UnityEditor.AddressableAssets; -using UnityEditor.AddressableAssets.Settings; -using UnityEngine; -using BaseGames.Core.Assets; +// 此文件已被 AddressableManagerWindow 取代。 +// 原有功能已整合到统一工具中,请使用: +// BaseGames → Addressables → Addressables Manager(总入口) +// BaseGames → Addressables → Addressable Batch Tool(直达批量注册 Tab) namespace BaseGames.Editor { - /// - /// Addressable 批量注册工具 - /// 菜单:BaseGames → Tools → Addressable Batch Tool (Alt+Shift+A) - /// - /// 三种工作流: - /// ① 同步 AddressKeys — 读取 AddressKeys.cs 中的常量,按名称在 Project 中搜索对应资产并自动注册 - /// ② 文件夹批量注册 — 拖入文件夹,将其下所有指定类型的资产注册到选定分组,地址格式可配置 - /// ③ 选中资产注册 — 在 Project 窗口选中资产后,一键批量注册到选定分组 - /// - public class AddressableBatchTool : EditorWindow - { - // ── 常量 ───────────────────────────────────────────────────────────── - private const string Title = "Addressable 批量工具"; - private const string MenuPath = "BaseGames/Addressables/Addressable Batch Tool"; - private const string PrefsKey = "AddressableBatch."; - - // ── 状态 ───────────────────────────────────────────────────────────── - private int _tab; - private string[] _tabNames = { "① 同步 AddressKeys", "② 文件夹批量注册", "③ 选中资产注册" }; - - // Tab ① - private List _keyEntries; - private Vector2 _keyScrollPos; - private bool _onlyShowMissing = true; - private bool _autoSearch = true; // 自动按名称搜索资产 - private bool _autoGroupByPrefix = true; // 按 Key 前缀自动选/建分组 - - // Key 前缀 → 分组名称映射(Tab ① 自动分组用) - // 规范数据统一来自 AddressableRules,此处不再声明本地副本。 - - // Tab ② - private DefaultAsset _folderAsset; - private string _folderPath; - private bool _includeSubfolders = true; - private AddressFormat _addressFormat = AddressFormat.FileName; - private string _addressPrefix = ""; - private string[] _assetTypeFilters = { "*.prefab", "*.unity", "*.asset" }; - private bool _filterPrefab = true; - private bool _filterScene = true; - private bool _filterSO = true; - private bool _filterTexture; - private bool _filterAudio; - private List _folderEntries; - private Vector2 _folderScrollPos; - - // Tab ③ - private List _selectionEntries; - private Vector2 _selectionScrollPos; - private bool _selFilterPrefab = true; - private bool _selFilterScene = true; - private bool _selFilterSO = true; - private bool _selFilterTexture; - private bool _selFilterAudio; - - // 共用 - private int _targetGroupIndex; - private string[] _groupNames; - private string _newGroupName = "New Group"; - private string _newLabel = ""; - private bool _overwriteAddress; - private bool _applyRulesOnRegister = true; - - // ── 样式(惰性初始化)──────────────────────────────────────────────── - private GUIStyle _headerStyle; - private GUIStyle _okStyle; - private GUIStyle _warnStyle; - private GUIStyle _boldStyle; - private bool _stylesInitialized; - - // ───────────────────────────────────────────────────────────────────── - [MenuItem(MenuPath, priority = 200)] - public static void OpenWindow() - { - var win = GetWindow(Title); - win.minSize = new Vector2(920, 520); - win.Show(); - } - - // ══ GUI ══════════════════════════════════════════════════════════════ - - private void OnGUI() - { - InitStyles(); - - if (AddressableAssetSettingsDefaultObject.Settings == null) - { - EditorGUILayout.HelpBox( - "Addressable Settings 未初始化。\n" + - "请先执行 Window → Asset Management → Addressables → Groups → Create Addressables Settings。", - MessageType.Error); - return; - } - - RefreshGroupNames(); - - EditorGUILayout.Space(4); - _tab = GUILayout.Toolbar(_tab, _tabNames, GUILayout.Height(28)); - EditorGUILayout.Space(4); - - switch (_tab) - { - case 0: DrawSyncTab(); break; - case 1: DrawFolderTab(); break; - case 2: DrawSelectionTab(); break; - } - - EditorGUILayout.Space(4); - DrawSharedOptions(); - } - - // ══ Tab ① 同步 AddressKeys ═══════════════════════════════════════════ - - private void DrawSyncTab() - { - EditorGUILayout.LabelField("根据 AddressKeys.cs 中的常量,自动搜索匹配资产并注册到 Addressables。", EditorStyles.wordWrappedMiniLabel); - EditorGUILayout.Space(4); - - using (new EditorGUILayout.HorizontalScope()) - { - _onlyShowMissing = GUILayout.Toggle(_onlyShowMissing, "仅显示未注册项", GUILayout.Width(140)); - _autoSearch = GUILayout.Toggle(_autoSearch, "自动按文件名搜索", GUILayout.Width(140)); - _autoGroupByPrefix = GUILayout.Toggle(_autoGroupByPrefix, "按前缀自动分组", GUILayout.Width(120)); - GUILayout.FlexibleSpace(); - if (GUILayout.Button("刷新列表", GUILayout.Width(80))) - RefreshKeyEntries(); - if (GUILayout.Button("注册所有已匹配项", GUILayout.Width(120))) - RegisterAllMatchedKeys(); - } - - if (_keyEntries == null) - RefreshKeyEntries(); - - EditorGUILayout.Space(4); - - // 列表表头 - using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) - { - EditorGUILayout.LabelField("常量名", _boldStyle, GUILayout.Width(180)); - EditorGUILayout.LabelField("地址 Key", _boldStyle, GUILayout.Width(160)); - EditorGUILayout.LabelField("期望分组", _boldStyle, GUILayout.Width(110)); - EditorGUILayout.LabelField("期望标签", _boldStyle, GUILayout.Width(140)); - EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(80)); - EditorGUILayout.LabelField("匹配资产", _boldStyle); - } - - var displayList = _onlyShowMissing - ? _keyEntries.Where(e => !e.IsRegistered).ToList() - : _keyEntries; - - _keyScrollPos = EditorGUILayout.BeginScrollView(_keyScrollPos, GUILayout.ExpandHeight(true)); - - foreach (var entry in displayList) - { - using (new EditorGUILayout.HorizontalScope(GUILayout.Height(20))) - { - EditorGUILayout.LabelField(entry.FieldName, GUILayout.Width(180)); - EditorGUILayout.LabelField(entry.AddressKey, GUILayout.Width(160)); - EditorGUILayout.LabelField( - AddressableRules.GetExpectedGroup(entry.AddressKey) ?? "Default", - GUILayout.Width(110)); - EditorGUILayout.LabelField( - FormatLabels(AddressableRules.GetExpectedLabels(entry.AddressKey)), - GUILayout.Width(140)); - - if (entry.IsRegistered) - { - EditorGUILayout.LabelField("✅ 已注册", _okStyle, GUILayout.Width(80)); - EditorGUILayout.LabelField(entry.ExistingAssetPath ?? "—"); - } - else if (entry.FoundAssetPath != null) - { - EditorGUILayout.LabelField("⚠ 未注册", _warnStyle, GUILayout.Width(80)); - EditorGUILayout.LabelField(entry.FoundAssetPath, GUILayout.ExpandWidth(true)); - if (GUILayout.Button("注册", GUILayout.Width(50))) - RegisterKeyEntry(entry); - } - else - { - EditorGUILayout.LabelField("❌ 未找到", _warnStyle, GUILayout.Width(80)); - entry.ManualAsset = (UnityEngine.Object)EditorGUILayout.ObjectField( - entry.ManualAsset, typeof(UnityEngine.Object), false); - if (entry.ManualAsset != null) - { - if (GUILayout.Button("注册", GUILayout.Width(50))) - RegisterKeyEntryManual(entry); - } - } - } - } - - EditorGUILayout.EndScrollView(); - - EditorGUILayout.Space(4); - var total = _keyEntries.Count; - var registered = _keyEntries.Count(e => e.IsRegistered); - var matched = _keyEntries.Count(e => !e.IsRegistered && e.FoundAssetPath != null); - EditorGUILayout.LabelField( - $"总计 {total} 个 Key | 已注册 {registered} | 已搜索到但未注册 {matched} | 未找到 {total - registered - matched}", - EditorStyles.miniLabel); - } - - // ══ Tab ② 文件夹批量注册 ═════════════════════════════════════════════ - - private void DrawFolderTab() - { - EditorGUILayout.LabelField("将指定文件夹中所有符合条件的资产批量注册到 Addressables。", EditorStyles.wordWrappedMiniLabel); - EditorGUILayout.Space(4); - - // 文件夹选择 - using (new EditorGUILayout.HorizontalScope()) - { - _folderAsset = (DefaultAsset)EditorGUILayout.ObjectField( - "目标文件夹", _folderAsset, typeof(DefaultAsset), false); - if (_folderAsset != null) - _folderPath = AssetDatabase.GetAssetPath(_folderAsset); - } - - if (!string.IsNullOrEmpty(_folderPath) && !AssetDatabase.IsValidFolder(_folderPath)) - { - EditorGUILayout.HelpBox("请拖入一个文件夹(蓝色图标),不是文件。", MessageType.Warning); - _folderPath = null; - } - - _includeSubfolders = EditorGUILayout.Toggle("包含子文件夹", _includeSubfolders); - - // 资产类型筛选 - EditorGUILayout.LabelField("资产类型筛选", EditorStyles.boldLabel); - using (new EditorGUILayout.HorizontalScope()) - { - _filterPrefab = GUILayout.Toggle(_filterPrefab, "Prefab", GUILayout.Width(70)); - _filterScene = GUILayout.Toggle(_filterScene, "Scene", GUILayout.Width(70)); - _filterSO = GUILayout.Toggle(_filterSO, "SO/Asset", GUILayout.Width(80)); - _filterTexture = GUILayout.Toggle(_filterTexture, "Texture", GUILayout.Width(70)); - _filterAudio = GUILayout.Toggle(_filterAudio, "Audio", GUILayout.Width(70)); - } - - // 地址格式 - _addressFormat = (AddressFormat)EditorGUILayout.EnumPopup("地址格式", _addressFormat); - if (_addressFormat == AddressFormat.PrefixPlusFileName || - _addressFormat == AddressFormat.PrefixPlusRelativePath) - { - _addressPrefix = EditorGUILayout.TextField("地址前缀", _addressPrefix); - } - - EditorGUILayout.Space(4); - - using (new EditorGUILayout.HorizontalScope()) - { - if (GUILayout.Button("⚡ 全量扫描 _Game/", GUILayout.Width(150))) - QuickScanGameFolder(); - GUILayout.FlexibleSpace(); - GUI.enabled = !string.IsNullOrEmpty(_folderPath); - if (GUILayout.Button("扫描文件夹", GUILayout.Width(100))) - ScanFolder(); - GUI.enabled = _folderEntries != null && _folderEntries.Count > 0; - if (GUILayout.Button("注册所有", GUILayout.Width(100))) - RegisterAllFolderEntries(); - GUI.enabled = true; - } - - if (_folderEntries == null || _folderEntries.Count == 0) - { - EditorGUILayout.HelpBox("拖入文件夹后点击「扫描文件夹」。", MessageType.Info); - return; - } - - EditorGUILayout.Space(4); - using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) - { - EditorGUILayout.LabelField("资产路径", _boldStyle, GUILayout.Width(180)); - EditorGUILayout.LabelField("地址", _boldStyle, GUILayout.Width(150)); - if (_applyRulesOnRegister) - { - EditorGUILayout.LabelField("分组(规则)", _boldStyle, GUILayout.Width(120)); - EditorGUILayout.LabelField("标签(规则)", _boldStyle, GUILayout.Width(150)); - } - EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(70)); - } - - _folderScrollPos = EditorGUILayout.BeginScrollView(_folderScrollPos, GUILayout.ExpandHeight(true)); - foreach (var entry in _folderEntries) - { - using (new EditorGUILayout.HorizontalScope(GUILayout.Height(18))) - { - EditorGUILayout.LabelField(entry.AssetPath, GUILayout.Width(180)); - entry.Address = EditorGUILayout.TextField(entry.Address, GUILayout.Width(150)); - if (_applyRulesOnRegister) - { - EditorGUILayout.LabelField(entry.PredictedGroup ?? "Default", GUILayout.Width(120)); - EditorGUILayout.LabelField(entry.PredictedLabels ?? "—", GUILayout.Width(150)); - } - var label = entry.AlreadyRegistered ? "✅ 已有" : "待注册"; - var style = entry.AlreadyRegistered ? _okStyle : EditorStyles.miniLabel; - EditorGUILayout.LabelField(label, style, GUILayout.Width(70)); - } - } - EditorGUILayout.EndScrollView(); - - int newCount = _folderEntries.Count(e => !e.AlreadyRegistered); - EditorGUILayout.LabelField($"共 {_folderEntries.Count} 个资产,{newCount} 个待注册", EditorStyles.miniLabel); - } - - // ══ Tab ③ 选中资产注册 ════════════════════════════════════════════════ - - private void DrawSelectionTab() - { - EditorGUILayout.LabelField("在 Project 窗口中选中资产或文件夹,然后点击「读取选中项」。", EditorStyles.wordWrappedMiniLabel); - EditorGUILayout.Space(4); - - // 资产类型筛选(与 Tab ② 一致,防止误注册不该 Addressable 的文件类型) - EditorGUILayout.LabelField("资产类型筛选", EditorStyles.boldLabel); - using (new EditorGUILayout.HorizontalScope()) - { - _selFilterPrefab = GUILayout.Toggle(_selFilterPrefab, "Prefab", GUILayout.Width(70)); - _selFilterScene = GUILayout.Toggle(_selFilterScene, "Scene", GUILayout.Width(70)); - _selFilterSO = GUILayout.Toggle(_selFilterSO, "SO/Asset", GUILayout.Width(80)); - _selFilterTexture = GUILayout.Toggle(_selFilterTexture, "Texture", GUILayout.Width(70)); - _selFilterAudio = GUILayout.Toggle(_selFilterAudio, "Audio", GUILayout.Width(70)); - } - EditorGUILayout.Space(4); - - using (new EditorGUILayout.HorizontalScope()) - { - GUILayout.FlexibleSpace(); - if (GUILayout.Button("读取选中项", GUILayout.Width(110))) - LoadSelection(); - GUI.enabled = _selectionEntries != null && _selectionEntries.Count > 0; - if (GUILayout.Button("注册所有", GUILayout.Width(100))) - RegisterAllSelectionEntries(); - GUI.enabled = true; - } - - if (_selectionEntries == null || _selectionEntries.Count == 0) - { - EditorGUILayout.HelpBox("在 Project 窗口选中资产后点击「读取选中项」。", MessageType.Info); - return; - } - - EditorGUILayout.Space(4); - using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) - { - EditorGUILayout.LabelField("资产路径", _boldStyle, GUILayout.Width(180)); - EditorGUILayout.LabelField("注册地址", _boldStyle, GUILayout.Width(150)); - if (_applyRulesOnRegister) - { - EditorGUILayout.LabelField("分组(规则)", _boldStyle, GUILayout.Width(120)); - EditorGUILayout.LabelField("标签(规则)", _boldStyle, GUILayout.Width(150)); - } - EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(70)); - } - - _selectionScrollPos = EditorGUILayout.BeginScrollView(_selectionScrollPos, GUILayout.ExpandHeight(true)); - foreach (var entry in _selectionEntries) - { - using (new EditorGUILayout.HorizontalScope(GUILayout.Height(18))) - { - EditorGUILayout.LabelField(entry.AssetPath, GUILayout.Width(180)); - entry.Address = EditorGUILayout.TextField(entry.Address, GUILayout.Width(150)); - if (_applyRulesOnRegister) - { - EditorGUILayout.LabelField(entry.PredictedGroup ?? "Default", GUILayout.Width(120)); - EditorGUILayout.LabelField(entry.PredictedLabels ?? "—", GUILayout.Width(150)); - } - var label = entry.AlreadyRegistered ? "✅ 已有" : "待注册"; - var style = entry.AlreadyRegistered ? _okStyle : EditorStyles.miniLabel; - EditorGUILayout.LabelField(label, style, GUILayout.Width(70)); - } - } - EditorGUILayout.EndScrollView(); - - int newCount = _selectionEntries.Count(e => !e.AlreadyRegistered); - EditorGUILayout.LabelField($"共 {_selectionEntries.Count} 项,{newCount} 待注册", EditorStyles.miniLabel); - } - - // ══ 共用选项区 ════════════════════════════════════════════════════════ - - private void DrawSharedOptions() - { - EditorGUILayout.LabelField("── 注册选项 ──", EditorStyles.boldLabel); - using (new EditorGUILayout.HorizontalScope()) - { - EditorGUILayout.LabelField("目标分组", GUILayout.Width(70)); - // 自动规则模式下,目标分组由规则决定,手动选择无效 - GUI.enabled = !_applyRulesOnRegister; - if (_applyRulesOnRegister) - { - EditorGUILayout.LabelField("(由 AddressableRules 自动决定)", - EditorStyles.miniLabel, GUILayout.Width(200)); - } - else if (_groupNames != null && _groupNames.Length > 0) - { - _targetGroupIndex = EditorGUILayout.Popup(_targetGroupIndex, - _groupNames, GUILayout.Width(200)); - } - GUI.enabled = true; - - EditorGUILayout.Space(8); - EditorGUILayout.LabelField("附加标签", GUILayout.Width(52)); - _newLabel = EditorGUILayout.TextField(_newLabel, GUILayout.Width(120)); - GUILayout.Label("(可在规则标签基础上追加)", EditorStyles.miniLabel); - GUILayout.FlexibleSpace(); - } - - using (new EditorGUILayout.HorizontalScope()) - { - _overwriteAddress = GUILayout.Toggle(_overwriteAddress, "已注册的资产也覆盖地址"); - GUILayout.Space(16); - _applyRulesOnRegister = GUILayout.Toggle(_applyRulesOnRegister, "自动应用分组/标签规则"); - GUILayout.FlexibleSpace(); - if (GUILayout.Button("新建分组…", GUILayout.Width(100))) - ShowCreateGroupDialog(); - } - } - - // ══ 逻辑:Tab ① ══════════════════════════════════════════════════════ - - private void RefreshKeyEntries() - { - _keyEntries = new List(); - var settings = AddressableAssetSettingsDefaultObject.Settings; - if (settings == null) return; - - // 收集所有已注册地址 → 地址字符串 → 资产路径 - var registeredMap = new Dictionary(StringComparer.Ordinal); - foreach (var group in settings.groups) - { - if (group == null) continue; - foreach (var e in group.entries) - if (e != null) registeredMap[e.address] = e.AssetPath; - } - - // 遍历 AddressKeys 常量 - var fields = typeof(AddressKeys) - .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) - .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); - - foreach (var field in fields) - { - var key = (string)field.GetRawConstantValue(); - var entry = new KeySyncEntry { FieldName = field.Name, AddressKey = key }; - - if (registeredMap.TryGetValue(key, out string existingPath)) - { - entry.IsRegistered = true; - entry.ExistingAssetPath = existingPath; - } - else if (_autoSearch) - { - // 从地址 key 派生搜索名:取最后一段,去掉前缀(ENM_、VFX_ 等) - string searchName = DeriveName(key); - string[] guids = AssetDatabase.FindAssets(searchName); - if (guids.Length > 0) - { - // 优先取名称完全匹配的;排除文件夹、脚本及程序集定义文件 - string best = guids - .Select(AssetDatabase.GUIDToAssetPath) - .Where(p => !AssetDatabase.IsValidFolder(p) && IsAddressableAssetPath(p)) - .OrderBy(p => ExactNameMatch(p, searchName) ? 0 : 1) - .FirstOrDefault(); - entry.FoundAssetPath = best; - entry.FoundGuid = best != null ? AssetDatabase.AssetPathToGUID(best) : null; - } - } - - _keyEntries.Add(entry); - } - } - - private void RegisterKeyEntry(KeySyncEntry entry) - { - if (entry.FoundAssetPath == null) return; - string groupOverride = _autoGroupByPrefix ? DeriveGroupName(entry.AddressKey) : null; - Register(entry.FoundGuid, entry.AddressKey, groupOverride); - entry.IsRegistered = true; - entry.ExistingAssetPath = entry.FoundAssetPath; - entry.FoundAssetPath = null; - SaveSettings(); - } - - private void RegisterKeyEntryManual(KeySyncEntry entry) - { - string path = AssetDatabase.GetAssetPath(entry.ManualAsset); - string guid = AssetDatabase.AssetPathToGUID(path); - string groupOverride = _autoGroupByPrefix ? DeriveGroupName(entry.AddressKey) : null; - Register(guid, entry.AddressKey, groupOverride); - entry.IsRegistered = true; - entry.ExistingAssetPath = path; - entry.ManualAsset = null; - SaveSettings(); - } - - private void RegisterAllMatchedKeys() - { - int count = 0; - foreach (var entry in _keyEntries.Where(e => !e.IsRegistered && e.FoundAssetPath != null)) - { - string groupOverride = _autoGroupByPrefix ? DeriveGroupName(entry.AddressKey) : null; - Register(entry.FoundGuid, entry.AddressKey, groupOverride); - entry.IsRegistered = true; - entry.ExistingAssetPath = entry.FoundAssetPath; - entry.FoundAssetPath = null; - count++; - } - Debug.Log($"[AddressableBatch] 已注册 {count} 个 AddressKeys 条目。"); - SaveSettings(); - } - - // ══ 逻辑:Tab ② ══════════════════════════════════════════════════════ - - private void ScanFolder() - { - _folderEntries = new List(); - if (!AssetDatabase.IsValidFolder(_folderPath)) return; - - var settings = AddressableAssetSettingsDefaultObject.Settings; - var registeredGuids = CollectRegisteredGuids(settings); - - var filters = BuildSearchFilter(); - var option = _includeSubfolders - ? SearchOption.AllDirectories - : SearchOption.TopDirectoryOnly; - - string absFolder = Path.GetFullPath(_folderPath); - - // 收集所有文件 - var allFiles = new List(); - foreach (string filter in filters) - allFiles.AddRange(Directory.GetFiles(absFolder, filter, option)); - - try - { - for (int i = 0; i < allFiles.Count; i++) - { - string absPath = allFiles[i]; - - if (i % 20 == 0) - EditorUtility.DisplayProgressBar("扫描文件夹", - Path.GetFileName(absPath), - (float)i / allFiles.Count); - - string relPath = "Assets" + absPath.Substring(Application.dataPath.Length).Replace('\\', '/'); - if (!IsAddressableAssetPath(relPath)) continue; - if (ShouldExclude(relPath)) continue; - - string guid = AssetDatabase.AssetPathToGUID(relPath); - if (string.IsNullOrEmpty(guid)) continue; - - string addr = BuildAddress(relPath); - _folderEntries.Add(new FolderEntry - { - AssetPath = relPath, - Guid = guid, - Address = addr, - AlreadyRegistered = registeredGuids.Contains(guid), - PredictedGroup = AddressableRules.GetExpectedGroup(addr), - PredictedLabels = FormatLabels(AddressableRules.GetExpectedLabels(addr)), - }); - } - } - finally - { - EditorUtility.ClearProgressBar(); - } - - // 去重(多个 filter 可能匹配同一文件) - _folderEntries = _folderEntries - .GroupBy(e => e.Guid) - .Select(g => g.First()) - .ToList(); - } - - /// - /// 返回 true 表示该文件应从 Addressable 注册中排除。 - /// 规范来源:AddressablesLabelSpec §5.2(禁止注册项)。 - /// - private static bool ShouldExclude(string relPath) - { - string lowerPath = relPath.Replace('\\', '/').ToLowerInvariant(); - string fileName = Path.GetFileNameWithoutExtension(relPath); - - // 测试场景(放在 Scenes/Testings/ 下) - if (lowerPath.Contains("/scenes/testings/")) return true; - - // 事件频道 ScriptableObject(EVT_ 前缀) - if (fileName.StartsWith("EVT_", StringComparison.Ordinal)) return true; - - // Sprite Atlas — 随依赖它的 Prefab 隐式打包,不单独注册 - if (Path.GetExtension(relPath) == ".spriteatlas") return true; - - // Material — 随 Prefab 依赖关系打包,不单独注册 - if (Path.GetExtension(relPath) == ".mat") return true; - - // HitBox / HurtBox 等碰撞盒子 Prefab(子 Prefab,不独立寻址) - if (fileName.StartsWith("HitBox_", StringComparison.OrdinalIgnoreCase)) return true; - if (fileName.StartsWith("HurtBox_", StringComparison.OrdinalIgnoreCase)) return true; - - return false; - } - - private void RegisterAllFolderEntries() - { - int count = 0; - foreach (var entry in _folderEntries) - { - if (entry.AlreadyRegistered && !_overwriteAddress) continue; - Register(entry.Guid, entry.Address); - entry.AlreadyRegistered = true; - count++; - } - Debug.Log($"[AddressableBatch] 文件夹批量注册完成,共注册 {count} 个资产。"); - SaveSettings(); - } - - // ══ 逻辑:Tab ③ ══════════════════════════════════════════════════════ - - private void LoadSelection() - { - _selectionEntries = new List(); - var settings = AddressableAssetSettingsDefaultObject.Settings; - var registeredGuids = CollectRegisteredGuids(settings); - - foreach (string guid in Selection.assetGUIDs) - { - string path = AssetDatabase.GUIDToAssetPath(guid); - - if (AssetDatabase.IsValidFolder(path)) - { - // 展开文件夹 - foreach (string sub in AssetDatabase.FindAssets("", new[] { path })) - { - string subPath = AssetDatabase.GUIDToAssetPath(sub); - if (!AssetDatabase.IsValidFolder(subPath)) - AddSelectionEntry(sub, subPath, registeredGuids); - } - } - else - { - AddSelectionEntry(guid, path, registeredGuids); - } - } - - _selectionEntries = _selectionEntries - .GroupBy(e => e.Guid) - .Select(g => g.First()) - .ToList(); - } - - private void AddSelectionEntry(string guid, string path, HashSet registeredGuids) - { - if (!IsAddressableAssetPath(path)) return; - if (ShouldExclude(path)) return; - - // 资产类型筛选(与 Tab ③ 筛选 Toggle 联动) - string ext = Path.GetExtension(path).ToLowerInvariant(); - bool isMatch = (_selFilterPrefab && ext == ".prefab") - || (_selFilterScene && ext == ".unity") - || (_selFilterSO && ext == ".asset") - || (_selFilterTexture && (ext == ".png" || ext == ".jpg" || ext == ".tga")) - || (_selFilterAudio && (ext == ".mp3" || ext == ".wav" || ext == ".ogg")); - // 若没有任何类型 Toggle 被勾选,则接受所有类型(兜底行为,避免全部筛空) - bool anyToggled = _selFilterPrefab || _selFilterScene || _selFilterSO || _selFilterTexture || _selFilterAudio; - if (anyToggled && !isMatch) return; - - string addr = BuildAddress(path); - _selectionEntries.Add(new SelectionEntry - { - AssetPath = path, - Guid = guid, - Address = addr, - AlreadyRegistered = registeredGuids.Contains(guid), - PredictedGroup = AddressableRules.GetExpectedGroup(addr), - PredictedLabels = FormatLabels(AddressableRules.GetExpectedLabels(addr)), - }); - } - - private void RegisterAllSelectionEntries() - { - int count = 0; - foreach (var entry in _selectionEntries) - { - if (entry.AlreadyRegistered && !_overwriteAddress) continue; - Register(entry.Guid, entry.Address); - entry.AlreadyRegistered = true; - count++; - } - Debug.Log($"[AddressableBatch] 选中资产注册完成,共注册 {count} 个资产。"); - SaveSettings(); - } - - // ══ 核心注册 API ═════════════════════════════════════════════════════ - - private void Register(string guid, string address, string groupNameOverride = null) - { - if (string.IsNullOrEmpty(guid)) return; - - var settings = AddressableAssetSettingsDefaultObject.Settings; - - // 重复地址检查:同一 address 已注册到不同 GUID 时提示确认 - var existingByAddress = FindEntryByAddress(settings, address); - if (existingByAddress != null && existingByAddress.guid != guid) - { - bool proceed = EditorUtility.DisplayDialog( - "⚠ 地址已存在", - $"地址 \"{address}\" 已注册到:\n{existingByAddress.AssetPath}\n\n" + - $"继续会将该地址重新指向当前资产(GUID: {guid})。是否继续?", - "继续", "取消"); - if (!proceed) return; - } - - // Determine target group: explicit override → rules → manual selection - string effectiveGroup = groupNameOverride - ?? (_applyRulesOnRegister ? AddressableRules.GetExpectedGroup(address) : null); - var group = effectiveGroup != null - ? GetOrCreateGroup(settings, effectiveGroup) - : GetTargetGroup(settings); - if (group == null) return; - - AddressableAssetEntry entry = settings.FindAssetEntry(guid) ?? - settings.CreateOrMoveEntry(guid, group, false, false); - - if (entry == null) return; - - if (_overwriteAddress || entry.address != address) - entry.address = address; - - settings.MoveEntry(entry, group, false, false); - - if (!string.IsNullOrWhiteSpace(_newLabel)) - entry.SetLabel(_newLabel.Trim(), true, true); - - // Apply rules-based labels - if (_applyRulesOnRegister) - { - foreach (var lbl in AddressableRules.GetExpectedLabels(address)) - { - EnsureLabelExists(settings, lbl); - entry.SetLabel(lbl, true, true); - } - } - } - - private static AddressableAssetEntry FindEntryByAddress(AddressableAssetSettings settings, string address) - { - if (settings == null) return null; - foreach (var group in settings.groups) - { - if (group == null) continue; - foreach (var e in group.entries) - if (e != null && e.address == address) return e; - } - return null; - } - - private AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings, string groupName) - { - var existing = settings.groups.FirstOrDefault(g => g != null && g.name == groupName); - if (existing != null) return existing; - - var template = settings.GroupTemplateObjects.FirstOrDefault() as AddressableAssetGroupTemplate; - var newGroup = settings.CreateGroup(groupName, false, false, true, - template != null ? new List(template.SchemaObjects) : null); - - if (newGroup != null) - { - RefreshGroupNames(); - Debug.Log($"[AddressableBatch] 已自动创建分组:{groupName}"); - } - return newGroup ?? settings.DefaultGroup; - } - - private AddressableAssetGroup GetTargetGroup(AddressableAssetSettings settings) - { - RefreshGroupNames(); - if (_groupNames == null || _groupNames.Length == 0) return settings.DefaultGroup; - string name = _groupNames[Mathf.Clamp(_targetGroupIndex, 0, _groupNames.Length - 1)]; - return settings.groups.FirstOrDefault(g => g != null && g.name == name) - ?? settings.DefaultGroup; - } - - private static void SaveSettings() - { - AssetDatabase.SaveAssets(); - AddressableAssetSettingsDefaultObject.Settings?.SetDirty( - AddressableAssetSettings.ModificationEvent.EntryModified, null, true); - } - - private static void EnsureLabelExists(AddressableAssetSettings settings, string label) - { - if (!settings.GetLabels().Contains(label)) - { - settings.AddLabel(label, true); - Debug.Log($"[AddressableBatch] 已创建标签:{label}"); - } - } - - private static string FormatLabels(string[] labels) - => labels.Length > 0 ? string.Join(", ", labels) : "—"; - - // ══ 创建分组 ══════════════════════════════════════════════════════════ - - private void QuickScanGameFolder() - { - _folderPath = "Assets/_Game"; - _folderAsset = AssetDatabase.LoadAssetAtPath(_folderPath); - _includeSubfolders = true; - _filterPrefab = true; - _filterScene = true; - _filterSO = true; - _filterAudio = true; - _filterTexture = false; - _addressFormat = AddressFormat.FileName; - _applyRulesOnRegister = true; - _tab = 1; - ScanFolder(); - } - - private void ShowCreateGroupDialog() - { - _newGroupName = EditorInputDialog.Show("新建 Addressable 分组", "请输入分组名称:", _newGroupName); - if (string.IsNullOrWhiteSpace(_newGroupName)) return; - - var settings = AddressableAssetSettingsDefaultObject.Settings; - var template = settings.GroupTemplateObjects.FirstOrDefault() as AddressableAssetGroupTemplate; - var newGroup = settings.CreateGroup(_newGroupName.Trim(), false, false, true, - template != null ? new List(template.SchemaObjects) : null); - - if (newGroup != null) - { - Debug.Log($"[AddressableBatch] 已创建分组:{newGroup.name}"); - RefreshGroupNames(); - _targetGroupIndex = Array.IndexOf(_groupNames, newGroup.name); - } - } - - // ══ 辅助 ══════════════════════════════════════════════════════════════ - - private void RefreshGroupNames() - { - var settings = AddressableAssetSettingsDefaultObject.Settings; - if (settings == null) { _groupNames = Array.Empty(); return; } - _groupNames = settings.groups - .Where(g => g != null) - .Select(g => g.name) - .ToArray(); - _targetGroupIndex = Mathf.Clamp(_targetGroupIndex, 0, Mathf.Max(0, _groupNames.Length - 1)); - } - - private static HashSet CollectRegisteredGuids(AddressableAssetSettings settings) - { - var set = new HashSet(StringComparer.Ordinal); - if (settings == null) return set; - foreach (var group in settings.groups) - { - if (group == null) continue; - foreach (var e in group.entries) - if (e != null) set.Add(e.guid); - } - return set; - } - - private string BuildAddress(string assetPath) - { - string fileName = Path.GetFileNameWithoutExtension(assetPath); - string relativePath = MakeRelativePath(assetPath, _folderPath); - - return _addressFormat switch - { - AddressFormat.FileName => fileName, - AddressFormat.FullAssetPath => assetPath, - AddressFormat.RelativeToFolder => relativePath, - // 前缀拼接:前缀以 '/' 结尾时直接连接(如 "Config/" + "FootstepCatalog") - // 前缀不以 '/' 结尾时用 '_' 连接(如 "Room_Forest" + "_01") - AddressFormat.PrefixPlusFileName => - string.IsNullOrEmpty(_addressPrefix) - ? fileName - : (_addressPrefix.EndsWith("/") - ? _addressPrefix + fileName - : _addressPrefix + "_" + fileName), - AddressFormat.PrefixPlusRelativePath => - string.IsNullOrEmpty(_addressPrefix) - ? relativePath - : (_addressPrefix.EndsWith("/") - ? _addressPrefix + relativePath - : _addressPrefix + "_" + relativePath), - _ => fileName, - }; - } - - private static string MakeRelativePath(string assetPath, string baseFolderPath) - { - if (string.IsNullOrEmpty(baseFolderPath)) return assetPath; - return assetPath.StartsWith(baseFolderPath) - ? assetPath.Substring(baseFolderPath.Length).TrimStart('/') - : assetPath; - } - - private List BuildSearchFilter() - { - var list = new List(); - if (_filterPrefab) list.Add("*.prefab"); - if (_filterScene) list.Add("*.unity"); - if (_filterSO) list.Add("*.asset"); - if (_filterTexture) { list.Add("*.png"); list.Add("*.jpg"); list.Add("*.tga"); } - if (_filterAudio) { list.Add("*.mp3"); list.Add("*.wav"); list.Add("*.ogg"); } - if (list.Count == 0) list.Add("*.*"); - return list; - } - - /// - /// 判断路径是否为可寻址资产(排除脚本、程序集定义、Shader、Sprite Atlas、Material 等文件)。 - /// - private static bool IsAddressableAssetPath(string path) - { - string ext = Path.GetExtension(path); - if (string.IsNullOrEmpty(ext)) return false; - // 排除代码 / 元数据类文件 - return ext != ".cs" - && ext != ".asmdef" - && ext != ".asmref" - && ext != ".shader" - && ext != ".hlsl" - && ext != ".cginc" - && ext != ".glsl" - && ext != ".json" - && ext != ".xml" - && ext != ".txt" - && ext != ".md" - && ext != ".spriteatlas" // 随依赖它的 Prefab 隐式打包 - && ext != ".mat"; // Material 随 Prefab 依赖打包 - } - - /// 从 AddressKey(如 "ENM_GruntWarrior")派生搜索名("GruntWarrior")。 - private static string DeriveName(string key) - { - // 取最后一个 '/' 之后的部分(Config/FootstepCatalog → FootstepCatalog) - int slash = key.LastIndexOf('/'); - string last = slash >= 0 ? key.Substring(slash + 1) : key; - - // 去掉前缀(ENM_, VFX_, PROJ_ 等):找第一个 '_' 并截断前缀 - int underscore = last.IndexOf('_'); - return underscore >= 0 && underscore < last.Length - 1 - ? last.Substring(underscore + 1) - : last; - } - - /// 根据 AddressKey 前缀返回建议分组名,未匹配时返回 null(回退到手动选定分组)。 - private static string DeriveGroupName(string key) - => AddressableRules.GetExpectedGroup(key); - - private static bool ExactNameMatch(string assetPath, string searchName) - { - string name = Path.GetFileNameWithoutExtension(assetPath); - return string.Equals(name, searchName, StringComparison.OrdinalIgnoreCase); - } - - private void InitStyles() - { - if (_stylesInitialized) return; - _headerStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; - _okStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = new Color(0.2f, 0.8f, 0.2f) } }; - _warnStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = new Color(1f, 0.6f, 0.1f) } }; - _boldStyle = new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Bold }; - _stylesInitialized = true; - } - - // ══ 数据结构 ══════════════════════════════════════════════════════════ - - private class KeySyncEntry - { - public string FieldName; - public string AddressKey; - public bool IsRegistered; - public string ExistingAssetPath; - public string FoundAssetPath; - public string FoundGuid; - public UnityEngine.Object ManualAsset; - } - - private class FolderEntry - { - public string AssetPath; - public string Guid; - public string Address; - public bool AlreadyRegistered; - public string PredictedGroup; - public string PredictedLabels; - } - - private class SelectionEntry - { - public string AssetPath; - public string Guid; - public string Address; - public bool AlreadyRegistered; - public string PredictedGroup; - public string PredictedLabels; - } - - private enum AddressFormat - { - [InspectorName("文件名(推荐)")] FileName, - [InspectorName("完整 Asset 路径")] FullAssetPath, - [InspectorName("相对于选定文件夹")] RelativeToFolder, - [InspectorName("前缀 + 文件名")] PrefixPlusFileName, - [InspectorName("前缀 + 相对路径")] PrefixPlusRelativePath, - } - } - - // ── 轻量输入对话框(避免依赖 EditorInputDialog 插件)───────────────────── - internal static class EditorInputDialog - { - /// 弹出单行文本输入对话框。返回用户输入,取消则返回原始默认值。 - public static string Show(string title, string message, string defaultValue = "") - { - string result = defaultValue; - - // 通过简单的 EditorWindow 实现 - var win = ScriptableObject.CreateInstance(); - win.Init(title, message, defaultValue, v => { result = v; }); - win.ShowModal(); - - return result; - } - } - - internal class InputDialogWindow : EditorWindow - { - private string _title; - private string _message; - private string _value; - private Action _onConfirm; - - public void Init(string title, string message, string defaultValue, Action onConfirm) - { - titleContent = new GUIContent(title); - _title = title; - _message = message; - _value = defaultValue; - _onConfirm = onConfirm; - minSize = maxSize = new Vector2(340, 110); - } - - private void OnGUI() - { - EditorGUILayout.Space(8); - EditorGUILayout.LabelField(_message); - GUI.SetNextControlName("input"); - _value = EditorGUILayout.TextField(_value); - EditorGUI.FocusTextInControl("input"); - - EditorGUILayout.Space(8); - using (new EditorGUILayout.HorizontalScope()) - { - GUILayout.FlexibleSpace(); - if (GUILayout.Button("取消", GUILayout.Width(70))) - Close(); - if (GUILayout.Button("确认", GUILayout.Width(70))) - { - _onConfirm?.Invoke(_value); - Close(); - } - } - } - } + // 保留空类以避免 .meta 文件孤立。 + internal static class AddressableBatchToolStub { } } diff --git a/Assets/_Game/Scripts/Editor/Addressables/AddressableManagerWindow.cs b/Assets/_Game/Scripts/Editor/Addressables/AddressableManagerWindow.cs new file mode 100644 index 0000000..461bba9 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Addressables/AddressableManagerWindow.cs @@ -0,0 +1,1274 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using UnityEditor; +using UnityEditor.AddressableAssets; +using UnityEditor.AddressableAssets.Settings; +using UnityEditor.AddressableAssets.Settings.GroupSchemas; +using UnityEngine; +using BaseGames.Core.Assets; + +namespace BaseGames.Editor +{ + /// + /// Addressables 统一管理工具。 + /// 规范来源:AddressablesLabelSpec.md §3 | AssetFolderSpec.md §8。 + /// + /// 菜单:BaseGames → Addressables → Addressables Manager(总入口) + /// BaseGames → Addressables → Addressable Batch Tool(直达批量注册 Tab) + /// BaseGames → Addressables → Rule Sync(直达规则校验 Tab) + /// + public sealed class AddressableManagerWindow : EditorWindow + { + // ── Tabs ────────────────────────────────────────────────────────────── + + private static readonly GUIContent[] TabContents = + { + new GUIContent(" 📊 总览 "), + new GUIContent(" 📦 批量注册 "), + new GUIContent(" 🔑 键同步 "), + new GUIContent(" 🔧 规则校验 "), + }; + + private const int TabDashboard = 0; + private const int TabRegister = 1; + private const int TabKeySync = 2; + private const int TabRuleSync = 3; + + // ── Dashboard State ─────────────────────────────────────────────────── + + private int _dTotal, _dOk, _dIssue, _dWarn; + private bool _dReady; + private string _dTime = ""; + + // ── Register State ──────────────────────────────────────────────────── + + private int _regSrc; // 0=AllGame 1=Folder 2=Selection + private DefaultAsset _regFolderAsset; + private string _regSearch = ""; + private bool _regOnlyNew = true; + private readonly bool[] _regTypeOn = { true, true, true, false, false }; // Prefab/Scene/SO/Audio/Tex + + private List _regEntries; + private Vector2 _regScroll; + + // ── Key Sync State ──────────────────────────────────────────────────── + + private List _keyEntries; + private bool _keyOnlyMissing = true; + private Vector2 _keyScroll; + + // ── Rule Sync State ─────────────────────────────────────────────────── + + private List _ruleEntries; + private bool _ruleShowOk; + private bool _ruleScanned; + private string _ruleSearch = ""; + private Vector2 _ruleScroll; + + // ── Shared Options ──────────────────────────────────────────────────── + + private bool _applyRules = true; + private bool _overwrite; + private string _extraLabel = ""; + private string _newGroupName = ""; + + // ── Tab ─────────────────────────────────────────────────────────────── + + [SerializeField] private int _tab; + + // ── Styles / Colors ─────────────────────────────────────────────────── + + private GUIStyle _sBold, _sLink, _sCard, _sEvenRow, _sCenGrey; + private bool _stylesReady; + + private static readonly Color CG = new Color(0.25f, 0.82f, 0.40f); // green OK + private static readonly Color CY = new Color(0.95f, 0.76f, 0.12f); // yellow warn + private static readonly Color CR = new Color(0.90f, 0.28f, 0.22f); // red error + private static readonly Color CD = new Color(0.55f, 0.55f, 0.55f); // dim + private static readonly Color CE = new Color(0.18f, 0.18f, 0.18f, 0.35f); // even row bg + + // ── Column widths ───────────────────────────────────────────────────── + + private const float CW_Path = 272f; + private const float CW_Addr = 212f; + private const float CW_Group = 118f; + private const float CW_Labels = 152f; + + // ── Menu ────────────────────────────────────────────────────────────── + + [MenuItem("BaseGames/Addressables/Addressables Manager", priority = 100)] + public static void Open() => OpenAt(TabDashboard); + + [MenuItem("BaseGames/Addressables/Addressable Batch Tool", priority = 200)] + public static void OpenBatch() => OpenAt(TabRegister); + + [MenuItem("BaseGames/Addressables/Rule Sync", priority = 110)] + public static void OpenRuleSync() => OpenAt(TabRuleSync); + + public static void OpenAt(int tab) + { + var win = GetWindow("Addressables Manager"); + win.minSize = new Vector2(1060, 600); + win._tab = tab; + win.Show(); + win.Focus(); + } + + // ── Lifecycle ───────────────────────────────────────────────────────── + + private void OnGUI() + { + EnsureStyles(); + + if (AddressableAssetSettingsDefaultObject.Settings == null) + { + EditorGUILayout.Space(12); + EditorGUILayout.HelpBox( + "Addressable Settings 未初始化。\n" + + "请先执行:Window → Asset Management → Addressables → Groups → Create Addressables Settings", + MessageType.Error); + return; + } + + DrawWindowBar(); + EditorGUILayout.Space(2); + _tab = GUILayout.Toolbar(_tab, TabContents, GUILayout.Height(28)); + EditorGUILayout.Space(4); + + switch (_tab) + { + case TabDashboard: DrawDashboard(); break; + case TabRegister: DrawRegister(); break; + case TabKeySync: DrawKeySync(); break; + case TabRuleSync: DrawRuleSync(); break; + } + } + + // ── Window toolbar ──────────────────────────────────────────────────── + + private void DrawWindowBar() + { + using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) + { + GUILayout.Label("⚙ Addressables Manager", _sBold, GUILayout.Width(210)); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Groups 窗口", EditorStyles.toolbarButton, GUILayout.Width(90))) + EditorApplication.ExecuteMenuItem("Window/Asset Management/Addressables/Groups"); + if (GUILayout.Button("🔍 验证 AddressKeys", EditorStyles.toolbarButton, GUILayout.Width(124))) + AddressKeyValidator.ValidateAll(); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Tab 0 — 总览 (Dashboard) + // ═══════════════════════════════════════════════════════════════════════ + + private void DrawDashboard() + { + EditorGUILayout.Space(10); + + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.FlexibleSpace(); + if (GUILayout.Button("⚡ 全量扫描并注册", GUILayout.Width(168), GUILayout.Height(30))) + { + _regSrc = 0; + _regOnlyNew = true; + ScanRegisterEntries(); + _tab = TabRegister; + } + GUILayout.Space(10); + if (GUILayout.Button("🔧 扫描并修复规则", GUILayout.Width(168), GUILayout.Height(30))) + { + RunRuleScan(); + _tab = TabRuleSync; + } + GUILayout.FlexibleSpace(); + } + + EditorGUILayout.Space(14); + + if (_dReady) + { + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.FlexibleSpace(); + DrawStatCard("📦 总资产", _dTotal.ToString(), Color.white); + GUILayout.Space(10); + DrawStatCard("✅ 符合规范", _dOk.ToString(), CG); + GUILayout.Space(10); + DrawStatCard("❌ 需修复", _dIssue.ToString(), _dIssue > 0 ? CR : CG); + GUILayout.Space(10); + DrawStatCard("⚠ 自定义标签", _dWarn.ToString(), _dWarn > 0 ? CY : CD); + GUILayout.FlexibleSpace(); + } + EditorGUILayout.Space(6); + GUILayout.Label($"上次扫描:{_dTime}", _sCenGrey); + } + + EditorGUILayout.Space(18); + EditorGUILayout.LabelField("── 推荐工作流 ──", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + EditorGUILayout.HelpBox( + "① 按命名规范(前缀_描述)为新资产命名,放置到正确文件夹\n" + + "② 在 AddressKeys.cs 中添加对应 const 字符串常量\n" + + "③「批量注册」→「⚡ 全量扫描 _Game/」→「注册所有未注册项」\n" + + "④「规则校验」→「▶ 扫描全部」→「✦ 修复所有问题」\n" + + "⑤ 点击「验证 AddressKeys」确认无遗漏", + MessageType.None); + } + + private void DrawStatCard(string label, string value, Color valueColor) + { + using (new EditorGUILayout.VerticalScope(_sCard, GUILayout.Width(138), GUILayout.Height(68))) + { + EditorGUILayout.Space(4); + var prev = GUI.color; + GUI.color = valueColor; + GUILayout.Label(value, + new GUIStyle(EditorStyles.largeLabel) + { + fontSize = 26, + alignment = TextAnchor.MiddleCenter, + fontStyle = FontStyle.Bold, + }, + GUILayout.Height(34), GUILayout.ExpandWidth(true)); + GUI.color = prev; + GUILayout.Label(label, EditorStyles.centeredGreyMiniLabel, GUILayout.ExpandWidth(true)); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Tab 1 — 批量注册 (Batch Register) + // ═══════════════════════════════════════════════════════════════════════ + + private void DrawRegister() + { + // ── Source bar ──────────────────────────────────────────────────── + using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) + { + GUILayout.Label("数据源", EditorStyles.toolbarButton, GUILayout.Width(48)); + + string[] srcLabels = { "⚡ 全量扫描 _Game/", "📁 指定文件夹", "🖱 当前选中" }; + int[] srcWidths = { 130, 100, 86 }; + for (int i = 0; i < 3; i++) + { + bool was = _regSrc == i; + bool now = GUILayout.Toggle(was, srcLabels[i], EditorStyles.toolbarButton, + GUILayout.Width(srcWidths[i])); + if (now && !was) { _regSrc = i; _regEntries = null; } + } + + GUILayout.FlexibleSpace(); + if (GUILayout.Button("🔄 刷新", EditorStyles.toolbarButton, GUILayout.Width(60))) + _regEntries = null; + if (GUILayout.Button("▶ 扫描", EditorStyles.toolbarButton, GUILayout.Width(60))) + ScanRegisterEntries(); + } + + // ── Folder picker ───────────────────────────────────────────────── + if (_regSrc == 1) + { + using (new EditorGUILayout.HorizontalScope()) + _regFolderAsset = (DefaultAsset)EditorGUILayout.ObjectField( + "目标文件夹", _regFolderAsset, typeof(DefaultAsset), false); + } + + // ── Type filters + search ───────────────────────────────────────── + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.Label("类型:", EditorStyles.miniLabel, GUILayout.Width(36)); + string[] typeLabels = { "Prefab", "Scene", "SO/Asset", "Audio", "Texture" }; + for (int i = 0; i < typeLabels.Length; i++) + _regTypeOn[i] = GUILayout.Toggle(_regTypeOn[i], typeLabels[i], "Button", GUILayout.Width(62)); + + GUILayout.Space(10); + GUILayout.Label("搜索:", EditorStyles.miniLabel, GUILayout.Width(36)); + _regSearch = GUILayout.TextField(_regSearch, GUILayout.Width(160)); + GUILayout.Space(6); + _regOnlyNew = GUILayout.Toggle(_regOnlyNew, "仅未注册", GUILayout.Width(68)); + GUILayout.FlexibleSpace(); + } + + EditorGUILayout.Space(2); + + // ── Table header ────────────────────────────────────────────────── + using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) + { + GUILayout.Label("资产路径", _sBold, GUILayout.Width(CW_Path)); + GUILayout.Label("Addressable 地址", _sBold, GUILayout.Width(CW_Addr)); + GUILayout.Label("期望分组(规则)", _sBold, GUILayout.Width(CW_Group)); + GUILayout.Label("期望标签(规则)", _sBold, GUILayout.Width(CW_Labels)); + GUILayout.Label("状态 / 操作", _sBold); + } + + // ── Content ─────────────────────────────────────────────────────── + if (_regEntries == null) + { + EditorGUILayout.HelpBox( + _regSrc == 2 + ? "在 Project 窗口选中资产或文件夹,再点击「▶ 扫描」。" + : "点击「▶ 扫描」加载资产列表。", + MessageType.Info); + DrawSharedOptions(); + return; + } + + var display = FilterRegEntries(_regEntries); + + _regScroll = EditorGUILayout.BeginScrollView(_regScroll); + for (int i = 0; i < display.Count; i++) + DrawRegRow(display[i], i); + EditorGUILayout.EndScrollView(); + + // ── Footer ──────────────────────────────────────────────────────── + int newCnt = display.Count(e => !e.Registered); + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.Label( + $"显示 {display.Count} 个条目,其中 {newCnt} 个未注册", + EditorStyles.miniLabel); + GUILayout.FlexibleSpace(); + GUI.enabled = newCnt > 0; + if (GUILayout.Button($"注册所有未注册项 ({newCnt})", GUILayout.Width(186))) + { + RegisterAll(display); + SaveAssets(); + } + GUI.enabled = true; + } + + DrawSharedOptions(); + } + + private List FilterRegEntries(List src) + { + return src + .Where(e => !_regOnlyNew || !e.Registered) + .Where(e => string.IsNullOrEmpty(_regSearch) + || e.Path.IndexOf(_regSearch, StringComparison.OrdinalIgnoreCase) >= 0 + || e.Addr.IndexOf(_regSearch, StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + } + + private void DrawRegRow(RegEntry e, int idx) + { + using (new EditorGUILayout.HorizontalScope( + idx % 2 == 0 ? _sEvenRow : GUIStyle.none, GUILayout.Height(20))) + { + string disp = e.Path.Length > 46 + ? "…" + e.Path.Substring(e.Path.Length - 43) + : e.Path; + if (GUILayout.Button(new GUIContent(disp, e.Path), _sLink, GUILayout.Width(CW_Path))) + PingAt(e.Path); + + e.Addr = EditorGUILayout.TextField(e.Addr, GUILayout.Width(CW_Addr)); + GUILayout.Label(e.Group ?? "Default", GUILayout.Width(CW_Group)); + GUILayout.Label(e.Labels, GUILayout.Width(CW_Labels)); + + if (e.Registered) + Clr("✅ 已注册", CG, GUILayout.Width(90)); + else if (GUILayout.Button("注册", EditorStyles.miniButton, GUILayout.Width(50))) + { + RegisterOne(e); + SaveAssets(); + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Tab 2 — 键同步 (Key Sync) + // ═══════════════════════════════════════════════════════════════════════ + + private void DrawKeySync() + { + using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) + { + if (GUILayout.Button("🔄 刷新", EditorStyles.toolbarButton, GUILayout.Width(65))) + LoadKeyEntries(); + GUILayout.Space(6); + _keyOnlyMissing = GUILayout.Toggle( + _keyOnlyMissing, "仅未注册", EditorStyles.toolbarButton, GUILayout.Width(72)); + GUILayout.FlexibleSpace(); + + if (_keyEntries != null) + { + int reg = _keyEntries.Count(e => e.Registered); + GUILayout.Label( + $"{reg}/{_keyEntries.Count} 已注册", + EditorStyles.toolbarButton, GUILayout.Width(98)); + } + + bool canRegAll = _keyEntries?.Any(e => !e.Registered && e.FoundPath != null) == true; + GUI.enabled = canRegAll; + if (GUILayout.Button("注册所有已匹配", EditorStyles.toolbarButton, GUILayout.Width(110))) + { + RegisterAllMatchedKeys(); + SaveAssets(); + } + GUI.enabled = true; + } + + if (_keyEntries == null) LoadKeyEntries(); + + // ── Table header ────────────────────────────────────────────────── + using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) + { + GUILayout.Label("常量名", _sBold, GUILayout.Width(190)); + GUILayout.Label("地址 Key", _sBold, GUILayout.Width(200)); + GUILayout.Label("期望分组", _sBold, GUILayout.Width(110)); + GUILayout.Label("期望标签", _sBold, GUILayout.Width(148)); + GUILayout.Label("状态 / 资产 / 操作", _sBold); + } + + var display = (_keyOnlyMissing + ? _keyEntries.Where(e => !e.Registered) + : _keyEntries).ToList(); + + _keyScroll = EditorGUILayout.BeginScrollView(_keyScroll); + for (int i = 0; i < display.Count; i++) + DrawKeyRow(display[i], i); + EditorGUILayout.EndScrollView(); + + if (_keyEntries != null) + { + int r = _keyEntries.Count(e => e.Registered); + int m = _keyEntries.Count(e => !e.Registered && e.FoundPath != null); + int u = _keyEntries.Count(e => !e.Registered && e.FoundPath == null); + EditorGUILayout.LabelField( + $"共 {_keyEntries.Count} 个 Key · 已注册 {r} · 已找到待注册 {m} · 未找到 {u}", + EditorStyles.miniLabel); + } + + DrawSharedOptions(); + } + + private void DrawKeyRow(KeyEntry e, int idx) + { + using (new EditorGUILayout.HorizontalScope( + idx % 2 == 0 ? _sEvenRow : GUIStyle.none, GUILayout.Height(20))) + { + GUILayout.Label(e.Field, GUILayout.Width(190)); + GUILayout.Label(e.Key, GUILayout.Width(200)); + GUILayout.Label(AddressableRules.GetExpectedGroup(e.Key) ?? "Default", GUILayout.Width(110)); + GUILayout.Label(FmtLabels(AddressableRules.GetExpectedLabels(e.Key)), GUILayout.Width(148)); + + if (e.Registered) + { + Clr("✅ 已注册", CG, GUILayout.Width(75)); + if (GUILayout.Button( + new GUIContent(e.ExistingPath ?? "—", e.ExistingPath), _sLink)) + PingAt(e.ExistingPath); + } + else if (e.FoundPath != null) + { + Clr("⚠ 已找到", CY, GUILayout.Width(75)); + GUILayout.Label(e.FoundPath, GUILayout.ExpandWidth(true)); + if (GUILayout.Button("注册", EditorStyles.miniButton, GUILayout.Width(46))) + { + RegisterKey(e); + SaveAssets(); + } + } + else + { + Clr("❌ 未找到", CR, GUILayout.Width(75)); + e.ManualObj = EditorGUILayout.ObjectField( + e.ManualObj, typeof(UnityEngine.Object), false); + if (e.ManualObj != null && + GUILayout.Button("注册", EditorStyles.miniButton, GUILayout.Width(46))) + { + RegisterKeyManual(e); + SaveAssets(); + } + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Tab 3 — 规则校验 (Rule Sync) + // ═══════════════════════════════════════════════════════════════════════ + + private void DrawRuleSync() + { + // ── Toolbar ─────────────────────────────────────────────────────── + using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) + { + if (GUILayout.Button("▶ 扫描全部", EditorStyles.toolbarButton, GUILayout.Width(76))) + RunRuleScan(); + + GUILayout.Space(6); + _ruleShowOk = GUILayout.Toggle( + _ruleShowOk, "显示正常项", EditorStyles.toolbarButton, GUILayout.Width(76)); + GUILayout.Space(6); + _ruleSearch = GUILayout.TextField( + _ruleSearch, EditorStyles.toolbarSearchField, GUILayout.Width(180)); + + GUILayout.FlexibleSpace(); + + if (_ruleScanned && _ruleEntries != null) + { + int iss = _ruleEntries.Count(r => !r.Ok); + int wrn = _ruleEntries.Count(r => r.Ok && r.HasWarn); + var pc = GUI.color; + GUI.color = iss > 0 ? CR : CD; + GUILayout.Label($"❌ {iss}", EditorStyles.toolbarButton, GUILayout.Width(46)); + GUI.color = wrn > 0 ? CY : CD; + GUILayout.Label($"⚠ {wrn}", EditorStyles.toolbarButton, GUILayout.Width(46)); + GUI.color = pc; + } + + bool hasIssues = _ruleEntries?.Any(r => !r.Ok) == true; + GUI.enabled = _ruleScanned && hasIssues; + if (GUILayout.Button("✦ 修复所有问题", EditorStyles.toolbarButton, GUILayout.Width(108))) + FixAll(); + + GUI.enabled = _ruleScanned; + if (GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(68))) + ExportCsv(); + GUI.enabled = true; + } + + // ── Stats bar ───────────────────────────────────────────────────── + if (_ruleScanned && _ruleEntries != null) + { + int tot = _ruleEntries.Count; + int ok = _ruleEntries.Count(r => r.Ok); + int iss = tot - ok; + int wrn = _ruleEntries.Count(r => r.HasWarn); + + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.Label($"共 {tot} 条目", EditorStyles.miniLabel, GUILayout.Width(72)); + GUILayout.Space(6); + Clr($"✅ 正常 {ok}", CG); + GUILayout.Space(8); + Clr($"❌ 问题 {iss}", iss > 0 ? CR : CG); + GUILayout.Space(8); + Clr($"⚠ 自定义标签 {wrn}", wrn > 0 ? CY : CD); + GUILayout.FlexibleSpace(); + } + } + + // ── Table header ────────────────────────────────────────────────── + using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) + { + GUILayout.Label("Address", _sBold, GUILayout.Width(210)); + GUILayout.Label("当前分组", _sBold, GUILayout.Width(110)); + GUILayout.Label("期望分组", _sBold, GUILayout.Width(110)); + GUILayout.Label("缺失标签", _sBold, GUILayout.Width(118)); + GUILayout.Label("多余规则标签", _sBold, GUILayout.Width(108)); + GUILayout.Label("自定义标签", _sBold, GUILayout.Width(100)); + GUILayout.Label("状态", _sBold); + } + + // ── Rows ────────────────────────────────────────────────────────── + _ruleScroll = EditorGUILayout.BeginScrollView(_ruleScroll); + if (!_ruleScanned) + { + EditorGUILayout.HelpBox( + "点击「▶ 扫描全部」分析已注册资产与规范的差异。", MessageType.Info); + } + else + { + var show = _ruleEntries + .Where(r => _ruleShowOk || !r.Ok) + .Where(r => string.IsNullOrEmpty(_ruleSearch) + || r.Address.IndexOf(_ruleSearch, StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + + if (show.Count == 0) + EditorGUILayout.HelpBox("✅ 所有已注册资产均符合规范!", MessageType.Info); + + for (int i = 0; i < show.Count; i++) + DrawRuleRow(show[i], i); + } + EditorGUILayout.EndScrollView(); + + // ── Footer hint ─────────────────────────────────────────────────── + EditorGUILayout.HelpBox( + "规则来源:AddressablesLabelSpec.md §3 | 分组规则:AssetFolderSpec.md §8.1\n" + + "「修复所有问题」修正分组与标签;不注册新资产;不删除自定义标签(⚠ 黄色)。", + MessageType.None); + } + + private void DrawRuleRow(RuleEntry r, int idx) + { + using (new EditorGUILayout.HorizontalScope( + idx % 2 == 0 ? _sEvenRow : GUIStyle.none, GUILayout.Height(20))) + { + if (GUILayout.Button(r.Address, _sLink, GUILayout.Width(210))) + PingAt(r.AssetPath); + + Clr(r.CurGroup ?? "—", r.GroupOk ? CG : CR, GUILayout.Width(110)); + GUILayout.Label(r.ExpGroup ?? "(未覆盖)", GUILayout.Width(110)); + Clr(r.Missing.Length > 0 ? Jn(r.Missing) : "—", + r.Missing.Length > 0 ? CR : CG, GUILayout.Width(118)); + Clr(r.Extra.Length > 0 ? Jn(r.Extra) : "—", + r.Extra.Length > 0 ? CR : CD, GUILayout.Width(108)); + Clr(r.Unknown.Length > 0 ? Jn(r.Unknown) : "—", + r.Unknown.Length > 0 ? CY : CD, GUILayout.Width(100)); + + if (r.Ok) + Clr(r.HasWarn ? "⚠ 自定义标签" : "✅ 正常", r.HasWarn ? CY : CG); + else + { + Clr("❌ 需修复", CR, GUILayout.Width(62)); + if (GUILayout.Button("修复", EditorStyles.miniButton, GUILayout.Width(40))) + { + FixOne(r); + SaveAssets(); + RunRuleScan(); + } + } + } + } + + // ── Shared Options ──────────────────────────────────────────────────── + + private void DrawSharedOptions() + { + EditorGUILayout.Space(4); + using (new EditorGUILayout.HorizontalScope()) + { + _applyRules = GUILayout.Toggle(_applyRules, "自动应用分组/标签规则"); + GUILayout.Space(14); + _overwrite = GUILayout.Toggle(_overwrite, "覆盖已有地址"); + GUILayout.Space(14); + GUILayout.Label("附加标签:", GUILayout.Width(58)); + _extraLabel = GUILayout.TextField(_extraLabel, GUILayout.Width(90)); + GUILayout.Space(14); + GUILayout.Label("新建分组:", GUILayout.Width(58)); + _newGroupName = GUILayout.TextField(_newGroupName, GUILayout.Width(110)); + if (GUILayout.Button("创建", EditorStyles.miniButton, GUILayout.Width(40)) + && !string.IsNullOrWhiteSpace(_newGroupName)) + { + var s = AddressableAssetSettingsDefaultObject.Settings; + if (s != null) + { + EnsureGroup(s, _newGroupName.Trim()); + SaveAssets(); + } + } + GUILayout.FlexibleSpace(); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Register Logic + // ═══════════════════════════════════════════════════════════════════════ + + private void ScanRegisterEntries() + { + _regEntries = new List(); + var settings = AddressableAssetSettingsDefaultObject.Settings; + if (settings == null) return; + + var regGuids = CollectAllGuids(settings); + var files = GatherFiles(); + + try + { + for (int i = 0; i < files.Count; i++) + { + if (i % 20 == 0) + EditorUtility.DisplayProgressBar( + "扫描资产", Path.GetFileName(files[i]), (float)i / files.Count); + + string p = files[i]; + if (!IsManageableType(p) || ShouldExclude(p) || !PassesTypeFilter(p)) continue; + + string guid = AssetDatabase.AssetPathToGUID(p); + if (string.IsNullOrEmpty(guid)) continue; + + string addr = BuildAddr(p); + _regEntries.Add(new RegEntry + { + Path = p, + Guid = guid, + Addr = addr, + Registered = regGuids.Contains(guid), + Group = AddressableRules.GetExpectedGroup(addr), + Labels = FmtLabels(AddressableRules.GetExpectedLabels(addr)), + }); + } + } + finally + { + EditorUtility.ClearProgressBar(); + } + + _regEntries = _regEntries + .GroupBy(e => e.Guid) + .Select(g => g.First()) + .OrderBy(e => e.Registered ? 1 : 0) + .ThenBy(e => e.Addr) + .ToList(); + } + + private List GatherFiles() + { + var result = new List(); + + if (_regSrc == 0) + { + foreach (string g in AssetDatabase.FindAssets("t:Object", new[] { "Assets/_Game" })) + { + string p = AssetDatabase.GUIDToAssetPath(g); + if (!AssetDatabase.IsValidFolder(p)) result.Add(p); + } + return result; + } + + if (_regSrc == 1) + { + string fp = _regFolderAsset != null + ? AssetDatabase.GetAssetPath(_regFolderAsset) + : ""; + if (string.IsNullOrEmpty(fp) || !AssetDatabase.IsValidFolder(fp)) + return result; + foreach (string g in AssetDatabase.FindAssets("t:Object", new[] { fp })) + { + string p = AssetDatabase.GUIDToAssetPath(g); + if (!AssetDatabase.IsValidFolder(p)) result.Add(p); + } + return result; + } + + // Selection + foreach (string guid in Selection.assetGUIDs) + { + string p = AssetDatabase.GUIDToAssetPath(guid); + if (AssetDatabase.IsValidFolder(p)) + { + foreach (string sg in AssetDatabase.FindAssets("t:Object", new[] { p })) + { + string sp = AssetDatabase.GUIDToAssetPath(sg); + if (!AssetDatabase.IsValidFolder(sp)) result.Add(sp); + } + } + else + { + result.Add(p); + } + } + return result; + } + + private bool PassesTypeFilter(string p) + { + bool anyOn = _regTypeOn.Any(v => v); + if (!anyOn) return true; + + string ext = Path.GetExtension(p).ToLowerInvariant(); + if (_regTypeOn[0] && ext == ".prefab") return true; + if (_regTypeOn[1] && ext == ".unity") return true; + if (_regTypeOn[2] && ext == ".asset") return true; + if (_regTypeOn[3] && (ext == ".mp3" || ext == ".wav" || ext == ".ogg")) return true; + if (_regTypeOn[4] && (ext == ".png" || ext == ".jpg" || ext == ".tga")) return true; + return false; + } + + private void RegisterOne(RegEntry e) + { + if (e.Registered && !_overwrite) return; + var s = AddressableAssetSettingsDefaultObject.Settings; + if (s == null) return; + + if (!ConfirmAddressConflict(s, e.Addr, e.Guid)) return; + + var grp = _applyRules && e.Group != null ? EnsureGroup(s, e.Group) : s.DefaultGroup; + var entry = s.FindAssetEntry(e.Guid) + ?? s.CreateOrMoveEntry(e.Guid, grp, false, false); + if (entry == null) return; + + entry.address = e.Addr; + s.MoveEntry(entry, grp, false, false); + + if (_applyRules) + foreach (var lbl in AddressableRules.GetExpectedLabels(e.Addr)) + SetLabel(s, entry, lbl); + + if (!string.IsNullOrWhiteSpace(_extraLabel)) + SetLabel(s, entry, _extraLabel.Trim()); + + e.Registered = true; + } + + private void RegisterAll(List entries) + { + int cnt = 0; + foreach (var e in entries.Where(e => !e.Registered)) + { + RegisterOne(e); + cnt++; + } + Debug.Log($"[AddressablesManager] 批量注册完成:{cnt} 个资产"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Key Sync Logic + // ═══════════════════════════════════════════════════════════════════════ + + private void LoadKeyEntries() + { + _keyEntries = new List(); + var s = AddressableAssetSettingsDefaultObject.Settings; + if (s == null) return; + + // Build address → path map from all registered entries + var addrMap = new Dictionary(StringComparer.Ordinal); + foreach (var g in s.groups) + if (g != null) + foreach (var e in g.entries) + if (e != null) addrMap[e.address] = e.AssetPath; + + var fields = typeof(AddressKeys) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); + + foreach (var f in fields) + { + var key = (string)f.GetRawConstantValue(); + var ke = new KeyEntry { Field = f.Name, Key = key }; + + if (addrMap.TryGetValue(key, out string ep)) + { + ke.Registered = true; + ke.ExistingPath = ep; + } + else + { + // Auto-search by the last segment of the key (handles "Config/Name") + string searchTerm = key.Contains('/') + ? key.Substring(key.LastIndexOf('/') + 1) + : key; + + string[] guids = AssetDatabase.FindAssets(searchTerm); + string best = guids + .Select(AssetDatabase.GUIDToAssetPath) + .Where(p => !AssetDatabase.IsValidFolder(p) && IsManageableType(p)) + .OrderBy(p => Path.GetFileNameWithoutExtension(p) == searchTerm ? 0 : 1) + .FirstOrDefault(); + + if (best != null) + { + ke.FoundPath = best; + ke.FoundGuid = AssetDatabase.AssetPathToGUID(best); + } + } + + _keyEntries.Add(ke); + } + } + + private void RegisterKey(KeyEntry e) + { + if (e.FoundGuid == null) return; + string grp = _applyRules ? AddressableRules.GetExpectedGroup(e.Key) : null; + DoRegister(e.FoundGuid, e.Key, grp); + e.Registered = true; + e.ExistingPath = e.FoundPath; + e.FoundPath = null; + } + + private void RegisterKeyManual(KeyEntry e) + { + string p = AssetDatabase.GetAssetPath(e.ManualObj); + string guid = AssetDatabase.AssetPathToGUID(p); + string grp = _applyRules ? AddressableRules.GetExpectedGroup(e.Key) : null; + DoRegister(guid, e.Key, grp); + e.Registered = true; + e.ExistingPath = p; + e.ManualObj = null; + } + + private void RegisterAllMatchedKeys() + { + int cnt = 0; + foreach (var e in _keyEntries.Where(e => !e.Registered && e.FoundPath != null)) + { + RegisterKey(e); + cnt++; + } + Debug.Log($"[AddressablesManager] 键同步完成:注册 {cnt} 个 Key"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Rule Sync Logic + // ═══════════════════════════════════════════════════════════════════════ + + private void RunRuleScan() + { + _ruleEntries = new List(); + var s = AddressableAssetSettingsDefaultObject.Settings; + if (s == null) return; + + foreach (var grp in s.groups) + { + if (grp == null) continue; + foreach (var e in grp.entries) + { + if (e == null) continue; + + var expG = AddressableRules.GetExpectedGroup(e.address); + var expL = AddressableRules.GetExpectedLabels(e.address); + var curL = e.labels.ToArray(); + var notExp = curL.Except(expL, StringComparer.Ordinal).ToArray(); + + _ruleEntries.Add(new RuleEntry + { + Address = e.address, + AssetPath = e.AssetPath, + CurGroup = grp.name, + ExpGroup = expG, + Missing = expL.Except(curL, StringComparer.Ordinal).ToArray(), + Extra = notExp.Where(l => AddressableRules.KnownLabels.Contains(l)).ToArray(), + Unknown = notExp.Where(l => !AddressableRules.KnownLabels.Contains(l)).ToArray(), + }); + } + } + + _ruleEntries = _ruleEntries + .OrderBy(r => r.Ok ? 1 : 0) + .ThenBy(r => r.Address, StringComparer.Ordinal) + .ToList(); + + _ruleScanned = true; + + // Sync dashboard counters + _dTotal = _ruleEntries.Count; + _dOk = _ruleEntries.Count(r => r.Ok); + _dIssue = _ruleEntries.Count(r => !r.Ok); + _dWarn = _ruleEntries.Count(r => r.HasWarn); + _dReady = true; + _dTime = DateTime.Now.ToString("HH:mm:ss"); + + Debug.Log( + $"[AddressablesManager] 规则扫描:{_ruleEntries.Count} 条 · " + + $"{_dIssue} 需修复 · {_dWarn} 含自定义标签"); + Repaint(); + } + + private void FixAll() + { + var issues = _ruleEntries.Where(r => !r.Ok).ToList(); + if (issues.Count == 0) return; + + int moves = issues.Count(r => !r.GroupOk); + int adds = issues.Sum(r => r.Missing.Length); + int rems = issues.Sum(r => r.Extra.Length); + + if (!EditorUtility.DisplayDialog("确认修复所有问题", + $"即将对 {issues.Count} 个条目执行:\n\n" + + $" • 移动分组:{moves} 个\n" + + $" • 添加标签:{adds} 个\n" + + $" • 移除多余规则标签:{rems} 个\n\n" + + "⚠ 自定义标签(黄色⚠)不会被删除。此操作不可撤销。", + "确认修复", "取消")) + return; + + int cnt = 0; + foreach (var r in issues) + if (FixOne(r)) cnt++; + + SaveAssets(); + RunRuleScan(); + Debug.Log($"[AddressablesManager] 修复完成:共处理 {cnt} 个条目"); + } + + private bool FixOne(RuleEntry r) + { + var s = AddressableAssetSettingsDefaultObject.Settings; + if (s == null) return false; + + var entry = FindByAddr(s, r.Address); + if (entry == null) return false; + + bool changed = false; + + if (!r.GroupOk && r.ExpGroup != null) + { + var grp = EnsureGroup(s, r.ExpGroup); + if (grp != null && entry.parentGroup != grp) + { + s.MoveEntry(entry, grp, false, false); + changed = true; + } + } + + foreach (var lbl in r.Missing) + { + SetLabel(s, entry, lbl); + changed = true; + } + + foreach (var lbl in r.Extra) + { + entry.SetLabel(lbl, false, true); + changed = true; + } + + return changed; + } + + private void ExportCsv() + { + if (_ruleEntries == null) return; + string path = EditorUtility.SaveFilePanel( + "导出规则报告", "", "AddressableRuleReport.csv", "csv"); + if (string.IsNullOrEmpty(path)) return; + + var sb = new StringBuilder(); + sb.AppendLine("Address,CurrentGroup,ExpectedGroup,GroupOk,Missing,Extra,Unknown,Status"); + foreach (var r in _ruleEntries) + sb.AppendLine( + $"\"{r.Address}\",\"{r.CurGroup}\",\"{r.ExpGroup ?? ""}\"," + + $"{r.GroupOk},\"{Jn(r.Missing)}\",\"{Jn(r.Extra)}\",\"{Jn(r.Unknown)}\"," + + $"{(r.Ok ? "OK" : "ISSUE")}"); + + File.WriteAllText(path, sb.ToString(), Encoding.UTF8); + Debug.Log($"[AddressablesManager] CSV 已导出:{path}"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Core Register Primitive + // ═══════════════════════════════════════════════════════════════════════ + + private void DoRegister(string guid, string addr, string groupName) + { + var s = AddressableAssetSettingsDefaultObject.Settings; + if (s == null || string.IsNullOrEmpty(guid)) return; + + if (!ConfirmAddressConflict(s, addr, guid)) return; + + var grp = groupName != null ? EnsureGroup(s, groupName) : s.DefaultGroup; + var entry = s.FindAssetEntry(guid) + ?? s.CreateOrMoveEntry(guid, grp, false, false); + if (entry == null) return; + + entry.address = addr; + s.MoveEntry(entry, grp, false, false); + + if (_applyRules) + foreach (var lbl in AddressableRules.GetExpectedLabels(addr)) + SetLabel(s, entry, lbl); + + if (!string.IsNullOrWhiteSpace(_extraLabel)) + SetLabel(s, entry, _extraLabel.Trim()); + } + + private static bool ConfirmAddressConflict(AddressableAssetSettings s, string addr, string guid) + { + var dup = FindByAddr(s, addr); + if (dup == null || dup.guid == guid) return true; + + return EditorUtility.DisplayDialog( + "⚠ 地址冲突", + $"地址 \"{addr}\" 已绑定:\n{dup.AssetPath}\n\n继续将覆盖原绑定。", + "继续", "取消"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Helpers — Asset Discovery + // ═══════════════════════════════════════════════════════════════════════ + + private static bool IsManageableType(string p) + { + string ext = Path.GetExtension(p).ToLowerInvariant(); + return ext is ".prefab" or ".unity" or ".asset" + or ".png" or ".jpg" or ".tga" + or ".mp3" or ".wav" or ".ogg" + or ".controller"; + } + + /// + /// 不应注册为 Addressable 的资产(由 Prefab 依赖链加载,或仅供编辑器引用)。 + /// + private static bool ShouldExclude(string p) + { + string lp = p.Replace('\\', '/').ToLowerInvariant(); + string name = Path.GetFileNameWithoutExtension(p); + string ext = Path.GetExtension(p).ToLowerInvariant(); + + if (lp.Contains("/scenes/testings/")) return true; + if (name.StartsWith("EVT_", StringComparison.Ordinal)) return true; + if (ext == ".spriteatlas") return true; + if (ext == ".mat") return true; + // ENV_* Prefab 由场景直接引用,不注册 Addressable(AssetFolderSpec §4.1) + if (name.StartsWith("ENV_", StringComparison.OrdinalIgnoreCase)) return true; + // Build_* 是不符合规范命名的装饰性环境 Prefab,不纳入 Addressables 管理 + if (name.StartsWith("Build_", StringComparison.OrdinalIgnoreCase)) return true; + // HitBox / HurtBox 子 Prefab 不单独注册(名称中含关键词即排除) + if (name.IndexOf("HitBox", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (name.IndexOf("HurtBox", StringComparison.OrdinalIgnoreCase) >= 0) return true; + return false; + } + + /// + /// 从资产路径推导 Addressable 地址。 + /// 有已知前缀 → 直接用文件名;Data 文件夹内无前缀 SO → "Config/{name}"。 + /// + private static string BuildAddr(string p) + { + string name = Path.GetFileNameWithoutExtension(p); + + foreach (var (prefix, _) in AddressableRules.PrefixGroupMap) + { + if (prefix.EndsWith('/')) continue; // "Config/" 是地址前缀,不是文件名前缀 + if (name.StartsWith(prefix, StringComparison.Ordinal)) + return name; + } + + // Room_ / Boss_ 动态分组(不在 PrefixGroupMap 中,但地址就是文件名) + if (name.StartsWith("Room_", StringComparison.Ordinal)) return name; + if (name.StartsWith("Boss_", StringComparison.Ordinal)) return name; + + // Data / Config 文件夹内无标准前缀的 SO → Config/Name + string np = p.Replace('\\', '/'); + if ((np.Contains("/_Game/Data/") || np.Contains("/_Game/Config/")) + && Path.GetExtension(p).ToLowerInvariant() == ".asset") + return $"Config/{name}"; + + return name; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Helpers — Addressables API + // ═══════════════════════════════════════════════════════════════════════ + + private static HashSet CollectAllGuids(AddressableAssetSettings s) + { + var set = new HashSet(StringComparer.Ordinal); + foreach (var g in s.groups) + if (g != null) + foreach (var e in g.entries) + if (e != null) set.Add(e.guid); + return set; + } + + private static AddressableAssetEntry FindByAddr(AddressableAssetSettings s, string addr) + { + foreach (var g in s.groups) + if (g != null) + foreach (var e in g.entries) + if (e?.address == addr) return e; + return null; + } + + private static AddressableAssetGroup EnsureGroup(AddressableAssetSettings s, string name) + { + var existing = s.groups.FirstOrDefault(g => g?.name == name); + if (existing != null) return existing; + + var tmpl = s.GroupTemplateObjects.FirstOrDefault() as AddressableAssetGroupTemplate; + var schemas = tmpl != null + ? new List(tmpl.SchemaObjects) + : null; + var created = s.CreateGroup(name, false, false, true, schemas); + if (created != null) + Debug.Log($"[AddressablesManager] 已自动创建分组:{name}"); + return created ?? s.DefaultGroup; + } + + private static void SetLabel(AddressableAssetSettings s, AddressableAssetEntry entry, string label) + { + if (!s.GetLabels().Contains(label)) + { + s.AddLabel(label, true); + Debug.Log($"[AddressablesManager] 已创建标签:{label}"); + } + entry.SetLabel(label, true, true); + } + + private static void SaveAssets() + { + AssetDatabase.SaveAssets(); + AddressableAssetSettingsDefaultObject.Settings?.SetDirty( + AddressableAssetSettings.ModificationEvent.EntryModified, null, true); + } + + private static void PingAt(string assetPath) + { + if (string.IsNullOrEmpty(assetPath)) return; + var obj = AssetDatabase.LoadMainAssetAtPath(assetPath); + if (obj != null) EditorGUIUtility.PingObject(obj); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Helpers — Formatting / UI + // ═══════════════════════════════════════════════════════════════════════ + + private static string FmtLabels(string[] labels) + => labels.Length == 0 ? "—" : string.Join(", ", labels); + + private static string Jn(string[] arr) + => arr is { Length: > 0 } ? string.Join("; ", arr) : ""; + + private void Clr(string text, Color c, params GUILayoutOption[] opts) + { + var prev = GUI.color; + GUI.color = c; + GUILayout.Label(text, opts); + GUI.color = prev; + } + + // ── Styles ──────────────────────────────────────────────────────────── + + private void EnsureStyles() + { + if (_stylesReady) return; + + _sBold = new GUIStyle(EditorStyles.boldLabel) { fontSize = 11 }; + _sLink = new GUIStyle(EditorStyles.linkLabel); + _sCenGrey = new GUIStyle(EditorStyles.centeredGreyMiniLabel); + _sCard = new GUIStyle("HelpBox"); + _sEvenRow = new GUIStyle { normal = { background = MkTex(CE) } }; + + _stylesReady = true; + } + + private static Texture2D MkTex(Color c) + { + var t = new Texture2D(1, 1, TextureFormat.RGBA32, false); + t.SetPixel(0, 0, c); + t.Apply(); + return t; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Data Types + // ═══════════════════════════════════════════════════════════════════════ + + private class RegEntry + { + public string Path, Guid, Addr, Group, Labels; + public bool Registered; + } + + private class KeyEntry + { + public string Field, Key; + public bool Registered; + public string ExistingPath, FoundPath, FoundGuid; + public UnityEngine.Object ManualObj; + } + + private class RuleEntry + { + public string Address, AssetPath, CurGroup, ExpGroup; + public string[] Missing = Array.Empty(); + public string[] Extra = Array.Empty(); + public string[] Unknown = Array.Empty(); + + public bool GroupOk => ExpGroup == null || CurGroup == ExpGroup; + public bool LabelsOk => Missing.Length == 0 && Extra.Length == 0; + public bool Ok => GroupOk && LabelsOk; + public bool HasWarn => Unknown.Length > 0; + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Addressables/AddressableManagerWindow.cs.meta b/Assets/_Game/Scripts/Editor/Addressables/AddressableManagerWindow.cs.meta new file mode 100644 index 0000000..e06a608 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Addressables/AddressableManagerWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: abd3b31b261435e4786f53b937c71742 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Addressables/AddressableRuleSyncWindow.cs b/Assets/_Game/Scripts/Editor/Addressables/AddressableRuleSyncWindow.cs index a30b5a7..2cdce22 100644 --- a/Assets/_Game/Scripts/Editor/Addressables/AddressableRuleSyncWindow.cs +++ b/Assets/_Game/Scripts/Editor/Addressables/AddressableRuleSyncWindow.cs @@ -1,527 +1,10 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using UnityEditor; -using UnityEditor.AddressableAssets; -using UnityEditor.AddressableAssets.Settings; -using UnityEngine; +// 此文件已被 AddressableManagerWindow 取代。 +// 原有功能已整合到统一工具中,请使用: +// BaseGames → Addressables → Addressables Manager(总入口) +// BaseGames → Addressables → Rule Sync(直达规则校验 Tab) namespace BaseGames.Editor { - /// - /// Addressable 规则同步窗口。 - /// - /// 功能: - /// 1. 扫描所有已注册的 Addressable 资产 - /// 2. 根据 中的规则计算期望分组与期望标签 - /// 3. 对比实际值,显示所有不符合规范的条目(分组错误 / 标签缺失 / 标签多余) - /// 4. 一键自动修复全部问题 - /// 5. 导出 CSV 报告供存档或 Code Review - /// - /// 菜单:BaseGames → Addressables → Rule Sync - /// - public class AddressableRuleSyncWindow : EditorWindow - { - // ── 内部数据结构 ─────────────────────────────────────────────────────── - - private enum IssueKind { None, WrongGroup, MissingLabel, ExtraLabel } - - private class EntryReport - { - public string Address; - public string AssetPath; - public string CurrentGroup; - public string ExpectedGroup; // null = 规则未覆盖,维持现状 - public string[] CurrentLabels; - public string[] ExpectedLabels; - public string[] MissingLabels; // 应有但没有(规则要求),红色错误 - public string[] ExtraLabels; // 规则不要求且在 KnownLabels 中(多余规则标签),红色错误 - public string[] UnknownLabels; // 规则不要求且不在 KnownLabels 中(自定义标签),黄色警告,不自动删除 - public bool GroupOk => ExpectedGroup == null || CurrentGroup == ExpectedGroup; - public bool LabelsOk => MissingLabels.Length == 0 && ExtraLabels.Length == 0; - public bool IsOk => GroupOk && LabelsOk; - public bool HasWarnings => UnknownLabels.Length > 0; - } - - // ── 状态 ────────────────────────────────────────────────────────────── - - private List _reports = new(); - private Vector2 _scrollPos; - private bool _showOk = false; - private bool _scanned = false; - private string _searchFilter = ""; - - // ── 样式(惰性初始化)──────────────────────────────────────────────── - - private GUIStyle _okStyle; - private GUIStyle _warnStyle; - private GUIStyle _errorStyle; - private GUIStyle _boldStyle; - private GUIStyle _rowEven; - private GUIStyle _rowOdd; - private bool _stylesReady; - - // ── 颜色 ───────────────────────────────────────────────────────────── - - private static readonly Color ColOk = new(0.20f, 0.78f, 0.35f, 1f); - private static readonly Color ColWarn = new(0.95f, 0.75f, 0.10f, 1f); - private static readonly Color ColError = new(0.90f, 0.25f, 0.20f, 1f); - private static readonly Color ColRowEven = new(0.22f, 0.22f, 0.22f, 0.4f); - - // ── 菜单入口 ────────────────────────────────────────────────────────── - - [MenuItem("BaseGames/Addressables/Rule Sync", priority = 110)] - public static void OpenWindow() - { - var win = GetWindow("Addressable Rule Sync"); - win.minSize = new Vector2(1040, 540); - win.Show(); - } - - // ── GUI ─────────────────────────────────────────────────────────────── - - private void OnGUI() - { - EnsureStyles(); - - if (AddressableAssetSettingsDefaultObject.Settings == null) - { - EditorGUILayout.HelpBox( - "Addressable Settings 未初始化。\n" + - "请先执行 Window → Asset Management → Addressables → Groups → Create Addressables Settings。", - MessageType.Error); - return; - } - - DrawToolbar(); - DrawStats(); - DrawTable(); - DrawFooter(); - } - - // ── 工具栏 ──────────────────────────────────────────────────────────── - - private void DrawToolbar() - { - using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) - { - if (GUILayout.Button("扫描", EditorStyles.toolbarButton, GUILayout.Width(80))) - Scan(); - - if (GUILayout.Button("🔄 刷新", EditorStyles.toolbarButton, GUILayout.Width(60))) - Scan(); - - GUILayout.Space(8); - _showOk = GUILayout.Toggle(_showOk, "显示正常项", EditorStyles.toolbarButton, GUILayout.Width(80)); - GUILayout.Space(8); - - EditorGUILayout.LabelField("搜索:", GUILayout.Width(42)); - _searchFilter = EditorGUILayout.TextField(_searchFilter, EditorStyles.toolbarSearchField, - GUILayout.Width(200)); - - GUILayout.FlexibleSpace(); - - GUI.enabled = _scanned && _reports.Any(r => !r.IsOk); - if (GUILayout.Button("✦ 修复所有问题", EditorStyles.toolbarButton, GUILayout.Width(120))) - FixAll(); - GUI.enabled = _scanned; - if (GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(80))) - ExportCsv(); - GUI.enabled = true; - } - } - - // ── 统计行 ──────────────────────────────────────────────────────────── - - private void DrawStats() - { - if (!_scanned) return; - - int total = _reports.Count; - int ok = _reports.Count(r => r.IsOk); - int issues = _reports.Count(r => !r.IsOk); - int warnings = _reports.Count(r => r.IsOk && r.HasWarnings); - int wrongGrp = _reports.Count(r => !r.GroupOk); - int misLabel = _reports.Count(r => r.MissingLabels.Length > 0); - int extLabel = _reports.Count(r => r.ExtraLabels.Length > 0); - int unkLabel = _reports.Count(r => r.UnknownLabels.Length > 0); - - EditorGUILayout.Space(2); - using (new EditorGUILayout.HorizontalScope()) - { - GUILayout.Label($"共 {total} 条目", EditorStyles.miniLabel); - GUILayout.Space(12); - DrawColoredLabel($"✅ 正常 {ok}", ColOk); - GUILayout.Space(12); - DrawColoredLabel($"❌ 问题 {issues}", issues > 0 ? ColError : ColOk); - GUILayout.Space(8); - DrawColoredLabel($"⚠ 自定义标签 {unkLabel}", unkLabel > 0 ? ColWarn : ColOk); - GUILayout.Space(20); - GUILayout.Label($"分组错误 {wrongGrp} | 标签缺失 {misLabel} | 多余规则标签 {extLabel}", - EditorStyles.miniLabel); - GUILayout.FlexibleSpace(); - } - EditorGUILayout.Space(2); - } - - // ── 主表格 ──────────────────────────────────────────────────────────── - - private void DrawTable() - { - // 表头 - using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) - { - GUILayout.Label("Address", _boldStyle, GUILayout.Width(200)); - GUILayout.Label("当前分组", _boldStyle, GUILayout.Width(120)); - GUILayout.Label("期望分组", _boldStyle, GUILayout.Width(120)); - GUILayout.Label("缺失标签", _boldStyle, GUILayout.Width(130)); - GUILayout.Label("多余规则标签", _boldStyle, GUILayout.Width(110)); - GUILayout.Label("自定义标签", _boldStyle, GUILayout.Width(110)); - GUILayout.Label("状态", _boldStyle, GUILayout.Width(80)); - } - - _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.ExpandHeight(true)); - - if (!_scanned) - { - EditorGUILayout.HelpBox("点击「扫描」按钮开始分析已注册的 Addressable 资产。", MessageType.Info); - } - else - { - var display = _reports - .Where(r => _showOk || !r.IsOk) - .Where(r => string.IsNullOrEmpty(_searchFilter) - || r.Address.IndexOf(_searchFilter, StringComparison.OrdinalIgnoreCase) >= 0) - .ToList(); - - if (display.Count == 0) - { - EditorGUILayout.HelpBox( - _showOk ? "没有匹配搜索条件的条目。" : "✅ 所有资产均符合规范!", - MessageType.Info); - } - - for (int i = 0; i < display.Count; i++) - DrawRow(display[i], i); - } - - EditorGUILayout.EndScrollView(); - } - - private void DrawRow(EntryReport r, int idx) - { - var bg = idx % 2 == 0 ? _rowEven : GUIStyle.none; - using (new EditorGUILayout.HorizontalScope(bg, GUILayout.Height(20))) - { - // Address(点击可 Ping) - if (GUILayout.Button(r.Address, EditorStyles.linkLabel, GUILayout.Width(200))) - PingAsset(r.AssetPath); - - // 当前分组 - var grpColor = r.GroupOk ? ColOk : ColError; - DrawColoredLabel(r.CurrentGroup ?? "—", grpColor, GUILayout.Width(120)); - - // 期望分组 - var expGrpText = r.ExpectedGroup ?? "(规则未覆盖)"; - var expGrpColor = r.GroupOk ? ColOk : ColWarn; - DrawColoredLabel(expGrpText, expGrpColor, GUILayout.Width(120)); - - // 缺失标签(红色,须补齐) - var missingText = r.MissingLabels.Length > 0 ? string.Join(", ", r.MissingLabels) : "—"; - DrawColoredLabel(missingText, r.MissingLabels.Length > 0 ? ColError : ColOk, GUILayout.Width(130)); - - // 多余规则标签(红色,将被 FixEntry 移除) - var extraText = r.ExtraLabels.Length > 0 ? string.Join(", ", r.ExtraLabels) : "—"; - DrawColoredLabel(extraText, r.ExtraLabels.Length > 0 ? ColError : ColOk, GUILayout.Width(110)); - - // 自定义标签(黄色警告,不会被自动删除,建议写入规范) - var unknownText = r.UnknownLabels.Length > 0 ? string.Join(", ", r.UnknownLabels) : "—"; - DrawColoredLabel(unknownText, r.UnknownLabels.Length > 0 ? ColWarn : ColOk, GUILayout.Width(110)); - - // 状态 + 单条修复按钮 - if (r.IsOk) - { - var statusColor = r.HasWarnings ? ColWarn : ColOk; - var statusText = r.HasWarnings ? "⚠ 自定义标签" : "✅ 正常"; - DrawColoredLabel(statusText, statusColor, GUILayout.Width(80)); - } - else - { - DrawColoredLabel("❌ 需修复", ColError, GUILayout.Width(60)); - if (GUILayout.Button("修复", EditorStyles.miniButton, GUILayout.Width(40))) - FixEntry(r); - } - } - } - - // ── 底栏 ────────────────────────────────────────────────────────────── - - private void DrawFooter() - { - EditorGUILayout.Space(4); - EditorGUILayout.HelpBox( - "规则来源:Docs/Standards/AddressablesLabelSpec.md §3 分组规则:AssetFolderSpec.md §8.1\n" + - "「修复所有问题」仅修改已注册资产的分组/标签,不注册新资产,不删除自定义标签(黄色警告项)。\n" + - "新增资产工作流:① Addressable Batch Tool → ⚡ 全量扫描 _Game/ → 注册所有 ② 返回此窗口 → 扫描 → 修复所有问题", - MessageType.None); - } - - // ── 扫描逻辑 ────────────────────────────────────────────────────────── - - private void Scan() - { - _reports.Clear(); - var settings = AddressableAssetSettingsDefaultObject.Settings; - if (settings == null) return; - - foreach (var group in settings.groups) - { - if (group == null) continue; - foreach (var entry in group.entries) - { - if (entry == null) continue; - - var address = entry.address; - var expectedGroup = AddressableRules.GetExpectedGroup(address); - var expectedLbls = AddressableRules.GetExpectedLabels(address); - var currentLbls = entry.labels.ToArray(); - - var missing = expectedLbls.Except(currentLbls, StringComparer.Ordinal).ToArray(); - - // 区分两类"多余标签": - // extra = 规则已知标签(KnownLabels)中规则不要求的 → 红色,FixEntry 会移除 - // unknown = 不在 KnownLabels 中的自定义标签 → 黄色警告,FixEntry 保留,建议写入规范 - var notExpected = currentLbls.Except(expectedLbls, StringComparer.Ordinal); - var extra = notExpected.Where(l => AddressableRules.KnownLabels.Contains(l)).ToArray(); - var unknown = notExpected.Where(l => !AddressableRules.KnownLabels.Contains(l)).ToArray(); - - _reports.Add(new EntryReport - { - Address = address, - AssetPath = entry.AssetPath, - CurrentGroup = group.name, - ExpectedGroup = expectedGroup, - CurrentLabels = currentLbls, - ExpectedLabels = expectedLbls, - MissingLabels = missing, - ExtraLabels = extra, - UnknownLabels = unknown, - }); - } - } - - // 问题项排前面,仅有警告的次之,正常项排最后;同类按 Address 字母序 - _reports = _reports - .OrderBy(r => r.IsOk ? (r.HasWarnings ? 1 : 2) : 0) - .ThenBy(r => r.Address, StringComparer.Ordinal) - .ToList(); - - _scanned = true; - Repaint(); - - int issues = _reports.Count(r => !r.IsOk); - int warnings = _reports.Count(r => r.IsOk && r.HasWarnings); - Debug.Log($"[AddressableRuleSync] 扫描完成:{_reports.Count} 个条目," + - $"{issues} 个需要修复,{warnings} 个含自定义标签警告。"); - } - - // ── 修复逻辑 ────────────────────────────────────────────────────────── - - private void FixAll() - { - var issues = _reports.Where(r => !r.IsOk).ToList(); - if (issues.Count == 0) return; - - int moveCount = issues.Count(r => !r.GroupOk); - int addCount = issues.Sum(r => r.MissingLabels.Length); - int removeCount = issues.Sum(r => r.ExtraLabels.Length); - - // 干跑预览对话框 - bool confirmed = EditorUtility.DisplayDialog( - "确认修复所有问题", - $"将对 {issues.Count} 个条目执行以下操作:\n\n" + - $" • 移动分组:{moveCount} 个\n" + - $" • 添加标签:{addCount} 个\n" + - $" • 移除多余规则标签:{removeCount} 个\n\n" + - "⚠ 自定义标签(黄色警告项)不会被删除。\n" + - "此操作不可撤销,请确认后继续。", - "确认修复", "取消"); - if (!confirmed) return; - - int fixedCount = 0; - foreach (var r in issues) - { - if (FixEntry(r)) fixedCount++; - } - - SaveSettings(); - Scan(); // 修复后重新扫描以更新结果 - Debug.Log($"[AddressableRuleSync] 修复完成:共处理 {fixedCount} 个条目。"); - } - - private bool FixEntry(EntryReport r) - { - var settings = AddressableAssetSettingsDefaultObject.Settings; - if (settings == null) return false; - - var entry = FindEntry(settings, r.Address); - if (entry == null) - { - Debug.LogWarning($"[AddressableRuleSync] 找不到条目:{r.Address}"); - return false; - } - - bool changed = false; - - // 修复分组 - if (!r.GroupOk && r.ExpectedGroup != null) - { - var targetGroup = GetOrCreateGroup(settings, r.ExpectedGroup); - if (targetGroup != null && entry.parentGroup != targetGroup) - { - settings.MoveEntry(entry, targetGroup, false, false); - r.CurrentGroup = r.ExpectedGroup; - changed = true; - } - } - - // 添加缺失标签 - foreach (var lbl in r.MissingLabels) - { - EnsureLabelExists(settings, lbl); - entry.SetLabel(lbl, true, true); - changed = true; - } - - // 移除多余规则标签(ExtraLabels 只包含 KnownLabels 中规则不要求的标签; - // UnknownLabels 是用户自定义标签,刻意保留,不做删除) - foreach (var lbl in r.ExtraLabels) - { - entry.SetLabel(lbl, false, true); - changed = true; - } - - return changed; - } - - // ── 导出 CSV ────────────────────────────────────────────────────────── - - private void ExportCsv() - { - if (_reports.Count == 0) return; - - var path = EditorUtility.SaveFilePanel( - "导出 Addressable Rule 报告", "", "AddressableRuleReport.csv", "csv"); - if (string.IsNullOrEmpty(path)) return; - - var sb = new StringBuilder(); - sb.AppendLine("Address,CurrentGroup,ExpectedGroup,GroupOk,MissingLabels,ExtraLabels,Status"); - - foreach (var r in _reports) - { - var status = r.IsOk ? "OK" : "ISSUE"; - sb.AppendLine( - $"\"{r.Address}\"," + - $"\"{r.CurrentGroup}\"," + - $"\"{r.ExpectedGroup ?? "(uncovered)"}\"," + - $"{r.GroupOk}," + - $"\"{string.Join(";", r.MissingLabels)}\"," + - $"\"{string.Join(";", r.ExtraLabels)}\"," + - $"{status}"); - } - - File.WriteAllText(path, sb.ToString(), Encoding.UTF8); - Debug.Log($"[AddressableRuleSync] CSV 报告已导出:{path}"); - } - - // ── 辅助方法 ────────────────────────────────────────────────────────── - - private static AddressableAssetEntry FindEntry(AddressableAssetSettings settings, string address) - { - foreach (var group in settings.groups) - { - if (group == null) continue; - foreach (var e in group.entries) - if (e != null && e.address == address) return e; - } - return null; - } - - private static AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings, string groupName) - { - var existing = settings.groups.FirstOrDefault(g => g != null && g.name == groupName); - if (existing != null) return existing; - - var template = settings.GroupTemplateObjects.FirstOrDefault() - as AddressableAssetGroupTemplate; - var newGroup = settings.CreateGroup(groupName, false, false, true, - template != null - ? new List(template.SchemaObjects) - : null); - - if (newGroup != null) - Debug.Log($"[AddressableRuleSync] 已自动创建分组:{groupName}"); - - return newGroup ?? settings.DefaultGroup; - } - - private static void EnsureLabelExists(AddressableAssetSettings settings, string label) - { - var labels = settings.GetLabels(); - if (!labels.Contains(label)) - { - settings.AddLabel(label, true); - Debug.Log($"[AddressableRuleSync] 已创建标签:{label}"); - } - } - - private static void SaveSettings() - { - AssetDatabase.SaveAssets(); - AddressableAssetSettingsDefaultObject.Settings?.SetDirty( - AddressableAssetSettings.ModificationEvent.EntryModified, null, true); - } - - private static void PingAsset(string assetPath) - { - if (string.IsNullOrEmpty(assetPath)) return; - var obj = AssetDatabase.LoadMainAssetAtPath(assetPath); - if (obj != null) EditorGUIUtility.PingObject(obj); - } - - // ── 样式初始化 ──────────────────────────────────────────────────────── - - private void EnsureStyles() - { - if (_stylesReady) return; - - _boldStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 11 }; - _okStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = ColOk } }; - _warnStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = ColWarn } }; - _errorStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = ColError } }; - - _rowEven = new GUIStyle(); - _rowEven.normal.background = MakeTexture(1, 1, ColRowEven); - - _stylesReady = true; - } - - private void DrawColoredLabel(string text, Color color, params GUILayoutOption[] options) - { - var prev = GUI.color; - GUI.color = color; - GUILayout.Label(text, EditorStyles.miniLabel, options); - GUI.color = prev; - } - - private static Texture2D MakeTexture(int width, int height, Color color) - { - var tex = new Texture2D(width, height); - tex.SetPixel(0, 0, color); - tex.Apply(); - return tex; - } - } + // 保留空类以避免 .meta 文件孤立。 + internal static class AddressableRuleSyncWindowStub { } } diff --git a/Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs b/Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs index 9e07185..2a2638d 100644 --- a/Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs +++ b/Assets/_Game/Scripts/Enemies/AI/BatchLOSSystem.cs @@ -81,7 +81,8 @@ namespace BaseGames.Enemies.AI if (distance > 0.01f) { - var hit = Physics2D.Raycast(origin, direction.normalized, distance, requester.LOSBlockingMask); + // direction / distance == direction.normalized,避免重复开方 + var hit = Physics2D.Raycast(origin, direction / distance, distance, requester.LOSBlockingMask); // 若无遮挡物(hit.collider == null),则视线畅通 hasLOS = hit.collider == null; } diff --git a/Assets/_Game/Scripts/Enemies/EnemyMovement.cs b/Assets/_Game/Scripts/Enemies/EnemyMovement.cs index 27e7d8d..374da6c 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyMovement.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyMovement.cs @@ -27,15 +27,18 @@ namespace BaseGames.Enemies /// 按 SO 配置速度水平移动。dir: +1 右 / -1 左 / 0 停止。 public void MoveHorizontal(float dir) { - float speed = _config.WalkSpeed; - _rb.velocity = new Vector2(dir * speed, _rb.velocity.y); + var vel = _rb.velocity; + vel.x = dir * _config.WalkSpeed; + _rb.velocity = vel; UpdateFacing(dir); } /// 显式指定速度(BD 追击任务调用)。 public void MoveWithSpeed(float dir, float speed) { - _rb.velocity = new Vector2(dir * speed, _rb.velocity.y); + var vel = _rb.velocity; + vel.x = dir * speed; + _rb.velocity = vel; UpdateFacing(dir); } @@ -52,7 +55,9 @@ namespace BaseGames.Enemies public void StopHorizontal() { - _rb.velocity = new Vector2(0f, _rb.velocity.y); + var vel = _rb.velocity; + vel.x = 0f; + _rb.velocity = vel; } /// diff --git a/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs b/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs index a6c6979..25059cf 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs @@ -23,6 +23,8 @@ namespace BaseGames.Enemies private readonly HashSet _registeredSet = new(); private readonly List _registered = new(); private readonly Dictionary _indexMap = new(); + // 排序临时缓冲区:预计算每个敌人到玩家的距离,避免 Sort 比较器内重复 Vector3 运算(O(n logn) → O(n)) + private readonly List<(EnemyBase enemy, float sqDist)> _sortTemp = new(); // 缓存玩家 Transform private Transform _playerTransform; private readonly CompositeDisposable _subs = new(); @@ -72,22 +74,35 @@ namespace BaseGames.Enemies // ── 内部 ────────────────────────────────────────────────────────── private void Rebalance() { - if (_registered.Count == 0) return; + int n = _registered.Count; + if (n == 0) return; var playerPos = _playerTransform != null ? _playerTransform.position : Vector3.zero; - // 按距离平方升序排序(避免开方,性能更好) - _registered.Sort((a, b) => + // ① 预计算距离(O(n) Vector3 运算,而非在比较器内重复执行 O(n logn) 次) + _sortTemp.Clear(); + for (int i = 0; i < n; i++) { - if (a == null) return 1; - if (b == null) return -1; - float sqA = (a.transform.position - playerPos).sqrMagnitude; - float sqB = (b.transform.position - playerPos).sqrMagnitude; - return sqA.CompareTo(sqB); - }); + var e = _registered[i]; + float sqd = e != null + ? (e.transform.position - playerPos).sqrMagnitude + : float.MaxValue; + _sortTemp.Add((e, sqd)); + } + + // ② 对临时列表排序(比较器只做 float 比较,无额外 Vector3 开销) + _sortTemp.Sort(static (a, b) => a.sqDist.CompareTo(b.sqDist)); + + // ③ 将排序结果写回 _registered,同步重建 _indexMap(修复排序后索引过期的 bug) + for (int i = 0; i < n; i++) + { + var e = _sortTemp[i].enemy; + _registered[i] = e; + if (e != null) _indexMap[e] = i; + } #if GRAPH_DESIGNER - for (int i = _registered.Count - 1; i >= 0; i--) + for (int i = n - 1; i >= 0; i--) { var enemy = _registered[i]; if (enemy == null) { _registered.RemoveAt(i); continue; } diff --git a/Assets/_Game/Scripts/Feedback/WeaponFeedback.cs b/Assets/_Game/Scripts/Feedback/WeaponFeedback.cs new file mode 100644 index 0000000..79b78aa --- /dev/null +++ b/Assets/_Game/Scripts/Feedback/WeaponFeedback.cs @@ -0,0 +1,64 @@ +using UnityEngine; +using MoreMountains.Feedbacks; + +namespace BaseGames.Feedback +{ + /// + /// 武器反馈组件,挂载在武器 HitBox Prefab 根节点上。 + /// 实现 IFeedbackPlayer,专注于武器本地反馈(命中粒子、击打音效、破风等)。 + /// 角色级别的全局反馈(震屏、手柄振动)仍由 PlayerFeedback 负责。 + /// + public class WeaponFeedback : MonoBehaviour, IFeedbackPlayer + { + [Header("命中反馈")] + [SerializeField] private MMF_Player _onHitLight; + [SerializeField] private MMF_Player _onHitMedium; + [SerializeField] private MMF_Player _onHitHeavy; + + [Header("攻击破风")] + [SerializeField] private MMF_Player _onAttackWhoosh; + + [Header("通用命名预设")] + [SerializeField] private NamedFeedbackPreset[] _namedPresets; + + [System.Serializable] + private struct NamedFeedbackPreset + { + public string presetId; + public MMF_Player player; + } + + // ── IFeedbackPlayer 实现 ────────────────────────────────────────────── + + public void PlayHit(HitWeight weight) + { + var player = weight switch + { + HitWeight.Light => _onHitLight, + HitWeight.Heavy => _onHitHeavy, + _ => _onHitMedium, + }; + player?.PlayFeedbacks(); + } + + public void PlayAttackWhoosh() => _onAttackWhoosh?.PlayFeedbacks(); + + public void TriggerPreset(string presetId) + { + if (_namedPresets == null) return; + foreach (var p in _namedPresets) + if (p.presetId == presetId) { p.player?.PlayFeedbacks(); return; } + } + + // ── 武器上不适用的反馈,空实现 ──────────────────────────────────────── + public void PlayParrySuccess() { } + public void PlayTakeHit() { } + public void PlayDeath() { } + public void PlayHeal() { } + public void PlayLandImpact() { } + public void PlayJumpLaunch() { } + public void PlayFootstep() { } + public void PlaySFXById(string sfxId) { } + public void PlayFormSwitch(int formIndex){ } + } +} diff --git a/Assets/_Game/Scripts/Feedback/WeaponFeedback.cs.meta b/Assets/_Game/Scripts/Feedback/WeaponFeedback.cs.meta new file mode 100644 index 0000000..e56f897 --- /dev/null +++ b/Assets/_Game/Scripts/Feedback/WeaponFeedback.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: adb4af2f574f356449634bc130f94592 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Parry/ParrySystem.cs b/Assets/_Game/Scripts/Parry/ParrySystem.cs index cdb68dc..7ca507e 100644 --- a/Assets/_Game/Scripts/Parry/ParrySystem.cs +++ b/Assets/_Game/Scripts/Parry/ParrySystem.cs @@ -44,6 +44,8 @@ namespace BaseGames.Parry private ParryPhase _phase = ParryPhase.Inactive; private float _phaseTimer; private float _cooldownTimer; + // 缓存 WaitForSecondsRealtime,避免每次完美弹反都触发 GC 分配 + private WaitForSecondsRealtime _bulletTimeWait; public ParryPhase CurrentPhase => _phase; /// 是否处于弹反有效窗口(供外部检测)。 @@ -74,6 +76,13 @@ namespace BaseGames.Parry Debug.Assert(_config != null, "[ParrySystem] _config 未赋值,请在 Inspector 中指定 ParryConfigSO。", this); } + private void Start() + { + // 在 Start 中初始化,确保 _config 已被赋值(Awake 之后) + if (_config != null) + _bulletTimeWait = new WaitForSecondsRealtime(_config.BulletTimeDuration); + } + /// 由 PlayerController 在 Awake 中注入 InputReader,无需在 Inspector 单独指定。 public void SetInputReader(InputReaderSO reader) { @@ -206,7 +215,7 @@ namespace BaseGames.Parry private IEnumerator ApplyBulletTime() { Time.timeScale = _config.BulletTimeScale; - yield return new WaitForSecondsRealtime(_config.BulletTimeDuration); + yield return _bulletTimeWait; Time.timeScale = 1f; } } diff --git a/Assets/_Game/Scripts/Player/PlayerCombat.cs b/Assets/_Game/Scripts/Player/PlayerCombat.cs index da8ddb2..8a102df 100644 --- a/Assets/_Game/Scripts/Player/PlayerCombat.cs +++ b/Assets/_Game/Scripts/Player/PlayerCombat.cs @@ -1,5 +1,6 @@ using UnityEngine; using BaseGames.Combat; +using BaseGames.Feedback; namespace BaseGames.Player { @@ -14,6 +15,7 @@ namespace BaseGames.Player private PlayerStats _stats; private PlayerMovement _movement; private WeaponHitBoxInstance _currentHitBoxInstance; + private IFeedbackPlayer _feedback; /// 下劈 HitBox 命中确认事件(供 DownAttackState 订阅 pogo 弹跳逻辑)。 public event System.Action OnDownHitConfirmed; @@ -22,6 +24,9 @@ namespace BaseGames.Player { _stats = GetComponentInParent(); _movement = GetComponentInParent(); + _feedback = GetComponentInParent() + ?? GetComponentInChildren() + ?? NullFeedbackPlayer.Instance; } private void OnEnable() @@ -83,6 +88,12 @@ namespace BaseGames.Player int gain = _weaponManager?.ActiveWeapon?.soulPowerGain ?? 10; _stats?.AddSoulPower(gain); + // 命中反馈:按伤害量决定力度档位 + var weight = info.FinalDamage <= 5 ? HitWeight.Light + : info.FinalDamage <= 15 ? HitWeight.Medium + : HitWeight.Heavy; + _feedback.PlayHit(weight); + // 攻击命中反嵈:向攻击反方向施加微小后退冲量,增强打击感 if (_movement?.Rb != null && info.KnockbackDirection.x != 0f) _movement.Rb.AddForce( diff --git a/Assets/_Game/Scripts/Player/PlayerMovement.cs b/Assets/_Game/Scripts/Player/PlayerMovement.cs index 7429d73..2f02215 100644 --- a/Assets/_Game/Scripts/Player/PlayerMovement.cs +++ b/Assets/_Game/Scripts/Player/PlayerMovement.cs @@ -46,8 +46,12 @@ namespace BaseGames.Player private bool _facingLocked; // 为 true 时 UpdateFacing() 不覆盖朝向 private bool _cancelWindowOpen; private SurfaceType _currentSurface = SurfaceType.Ground; - private readonly Collider2D[] _groundBuffer = new Collider2D[4]; - private int _groundHitCount; + private bool _wasGrounded; + // 跳跃/二段跳期间禁用斜坡吸附,防止把起跳判定成斜坡而立即下压 + private bool _slopeSnapDisabled; + private readonly Collider2D[] _groundBuffer = new Collider2D[4]; + private int _groundHitCount; + private readonly ContactPoint2D[] _slopeContactBuffer = new ContactPoint2D[8]; #if UNITY_EDITOR // ── 运行时调试(Inspector 中可见)─────────────────────────────── @@ -118,17 +122,20 @@ namespace BaseGames.Player _wallCoyoteTimer = Mathf.Max(0f, _wallCoyoteTimer - Time.fixedDeltaTime); #if UNITY_EDITOR - _dbg_VelocityX = _rb.velocity.x; - _dbg_VelocityY = _rb.velocity.y; - _dbg_IsGrounded = _isGrounded; - _dbg_OnOneWayPlatform = _onOneWayPlatform; - _dbg_HasCoyoteTime = _coyoteTimer > 0f; - _dbg_HasWallCoyoteTime = _wallCoyoteTimer > 0f; - _dbg_IsWallLeft = _isWallLeft; - _dbg_IsWallRight = _isWallRight; - _dbg_CancelWindowOpen = _cancelWindowOpen; - _dbg_FacingDirection = _facingDirection; - _dbg_Position = $"({transform.position.x:F1}, {transform.position.y:F1})"; + // 值类型字段每帧同步(无分配) + _dbg_VelocityX = _rb.velocity.x; + _dbg_VelocityY = _rb.velocity.y; + _dbg_IsGrounded = _isGrounded; + _dbg_OnOneWayPlatform = _onOneWayPlatform; + _dbg_HasCoyoteTime = _coyoteTimer > 0f; + _dbg_HasWallCoyoteTime = _wallCoyoteTimer > 0f; + _dbg_IsWallLeft = _isWallLeft; + _dbg_IsWallRight = _isWallRight; + _dbg_CancelWindowOpen = _cancelWindowOpen; + _dbg_FacingDirection = _facingDirection; + // 字符串格式化限速到 ~10 Hz,避免每帧分配 + if (Time.frameCount % 6 == 0) + _dbg_Position = $"({transform.position.x:F1}, {transform.position.y:F1})"; #endif } @@ -175,6 +182,7 @@ namespace BaseGames.Player { _rb.velocity = new Vector2(_rb.velocity.x, _config.JumpForce); _coyoteTimer = 0f; + _slopeSnapDisabled = true; } public void CutJump() @@ -191,6 +199,7 @@ namespace BaseGames.Player { _rb.velocity = new Vector2(_rb.velocity.x, _config.DoubleJumpForce); _coyoteTimer = 0f; + _slopeSnapDisabled = true; } // ── 重力 ────────────────────────────────────────────────────────────── @@ -379,10 +388,38 @@ namespace BaseGames.Player { if (_groundCheck == null) return; + _wasGrounded = _isGrounded; + _groundHitCount = Physics2D.OverlapBoxNonAlloc( _groundCheck.position, _groundCheckSize, 0f, _groundBuffer, _groundLayer); _isGrounded = _groundHitCount > 0; + // 斜坡吸附禁用标记:仅在重新落地(从空中→地面)时重置, + // 而非每帧在地面时都重置。 + // 这样 Jump() 设置的 _slopeSnapDisabled = true 可以存活到玩家真正离开地面, + // 防止起跳后的首个 FixedUpdate 仍检测到地面时把标记清零, + // 导致紧接着的斜坡吸附把垂直速度归零(即"一直按方向键起跳立即落地"bug)。 + if (_isGrounded && !_wasGrounded) + _slopeSnapDisabled = false; + + // 斜坡吸附:OverlapBox 是水平矩形,在平地→斜坡转折处可能短暂离地。 + // 读取 Rigidbody2D 已有的物理接触点(零额外物理查询开销), + // 接触法线 Y > 0.5 即视为地面接触,保持 IsGrounded 为 true。 + if (!_isGrounded && _wasGrounded && !_slopeSnapDisabled + && Mathf.Abs(_rb.velocity.x) > 0.1f) + { + int contactCount = _rb.GetContacts(_slopeContactBuffer); + for (int i = 0; i < contactCount; i++) + { + if (_slopeContactBuffer[i].normal.y > 0.5f) + { + _isGrounded = true; + _rb.velocity = new Vector2(_rb.velocity.x, 0f); + break; + } + } + } + // 检测是否站在单向平台(含 IDropThrough 组件的碰撞体) _onOneWayPlatform = false; for (int i = 0; i < _groundHitCount; i++) diff --git a/Assets/_Game/Scripts/Player/PlayerStats.cs b/Assets/_Game/Scripts/Player/PlayerStats.cs index d6fccad..f8c318a 100644 --- a/Assets/_Game/Scripts/Player/PlayerStats.cs +++ b/Assets/_Game/Scripts/Player/PlayerStats.cs @@ -127,14 +127,19 @@ namespace BaseGames.Player } #if UNITY_EDITOR - _dbg_HP = $"{CurrentHP} / {MaxHP}"; - _dbg_Soul = $"{CurrentSoulPower} / {MaxSoulPower}"; - _dbg_Spirit = $"{CurrentSpiritPower} / {MaxSpiritPower}"; - _dbg_Spring = $"{CurrentSpringCharges} / {MaxSpringCharges}"; + // 非字符串字段每帧同步(拷贝值,无分配) _dbg_IsInvincible = IsInvincible; _dbg_InvincibleTimer = _invincibleTimer; _dbg_GodMode = _isGodMode; - _dbg_Abilities = _unlockedAbilities == AbilityType.None ? "\u65e0" : _unlockedAbilities.ToString(); + // 字符串插值限速到 ~10 Hz,避免每帧分配(GC) + if (Time.frameCount % 6 == 0) + { + _dbg_HP = $"{CurrentHP} / {MaxHP}"; + _dbg_Soul = $"{CurrentSoulPower} / {MaxSoulPower}"; + _dbg_Spirit = $"{CurrentSpiritPower} / {MaxSpiritPower}"; + _dbg_Spring = $"{CurrentSpringCharges} / {MaxSpringCharges}"; + _dbg_Abilities = _unlockedAbilities == AbilityType.None ? "\u65e0" : _unlockedAbilities.ToString(); + } #endif } // ── 护符修改器 API ───────────────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/Player/PlayerWallDetector.cs b/Assets/_Game/Scripts/Player/PlayerWallDetector.cs index 6acd090..4fec0fc 100644 --- a/Assets/_Game/Scripts/Player/PlayerWallDetector.cs +++ b/Assets/_Game/Scripts/Player/PlayerWallDetector.cs @@ -29,6 +29,8 @@ namespace BaseGames.Player // 物理接触点缓冲区(避免每帧 GC) private Rigidbody2D _rb; private static readonly ContactPoint2D[] _contactBuffer = new ContactPoint2D[8]; + // LayerMask 在 Awake 解析一次,避免 FixedUpdate(50Hz)每帧字符串查找 + private LayerMask _resolvedWallMask; /// /// 指定方向上是否存在墙壁接触(射线命中 OR 物理接触点命中,任一为 true)。 @@ -43,6 +45,7 @@ namespace BaseGames.Player { Debug.Assert(_config != null, "[PlayerWallDetector] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this); _rb = GetComponent(); + _resolvedWallMask = _wallLayer != 0 ? _wallLayer : LayerMask.GetMask("Wall", "Platform"); } private void FixedUpdate() @@ -69,9 +72,8 @@ namespace BaseGames.Player private bool CheckPhysicalContact(int direction) { if (_rb == null) return false; - LayerMask mask = _wallLayer != 0 ? _wallLayer : LayerMask.GetMask("Wall", "Platform"); var filter = new ContactFilter2D(); - filter.SetLayerMask(mask); + filter.SetLayerMask(_resolvedWallMask); filter.useTriggers = false; int count = _rb.GetContacts(filter, _contactBuffer); @@ -94,7 +96,7 @@ namespace BaseGames.Player Vector2 center = transform.position; float len = _config.WallRayLength; float oy = _config.WallRayOffsetY; - int layer = _wallLayer != 0 ? (int)_wallLayer : LayerMask.GetMask("Wall", "Platform"); + int layer = _resolvedWallMask; bool top = Physics2D.Raycast(center + Vector2.up * oy, dir, len, layer); bool bot = Physics2D.Raycast(center + Vector2.down * oy, dir, len, layer); diff --git a/Assets/_Game/Scripts/Player/States/DashState.cs b/Assets/_Game/Scripts/Player/States/DashState.cs index 00cfe84..7ebd643 100644 --- a/Assets/_Game/Scripts/Player/States/DashState.cs +++ b/Assets/_Game/Scripts/Player/States/DashState.cs @@ -82,6 +82,8 @@ namespace BaseGames.Player.States ? AnimCfg.DashInvincible : AnimCfg?.Dash; if (dashClip != null) Anim?.Play(dashClip); + + Feedback.TriggerPreset("dash"); } public override void OnStateUpdate() diff --git a/Assets/_Game/Scripts/Player/States/DeadState.cs b/Assets/_Game/Scripts/Player/States/DeadState.cs index b8d0286..e624ce6 100644 --- a/Assets/_Game/Scripts/Player/States/DeadState.cs +++ b/Assets/_Game/Scripts/Player/States/DeadState.cs @@ -19,6 +19,8 @@ namespace BaseGames.Player.States if (Owner.HurtBox != null) Owner.HurtBox.SetActive(false); + Feedback.PlayDeath(); + // 播放死亡动画 if (AnimCfg?.Dead != null) Anim?.Play(AnimCfg.Dead); diff --git a/Assets/_Game/Scripts/Player/States/HurtState.cs b/Assets/_Game/Scripts/Player/States/HurtState.cs index eb5296a..4575c70 100644 --- a/Assets/_Game/Scripts/Player/States/HurtState.cs +++ b/Assets/_Game/Scripts/Player/States/HurtState.cs @@ -28,6 +28,7 @@ namespace BaseGames.Player.States _timer = Owner.AnimConfig?.HurtDuration ?? 0.4f; _ended = false; Stats?.BeginInvincibility(); + Feedback.PlayTakeHit(); if (AnimCfg?.Hurt != null) { diff --git a/Assets/_Game/Scripts/Player/States/PlayerController.cs b/Assets/_Game/Scripts/Player/States/PlayerController.cs index e40bf4e..81e6186 100644 --- a/Assets/_Game/Scripts/Player/States/PlayerController.cs +++ b/Assets/_Game/Scripts/Player/States/PlayerController.cs @@ -4,6 +4,7 @@ using Animancer; using BaseGames.Core.Events; using BaseGames.Input; using BaseGames.Combat; +using BaseGames.Feedback; using BaseGames.Parry; using BaseGames.Skills; @@ -35,6 +36,7 @@ namespace BaseGames.Player.States // ── 战斗组件 ────────────────────────────────────────────────────────── [Header("战斗")] + [SerializeField] private PlayerFeedback _feedback; [SerializeField] private PlayerCombat _combat; [SerializeField] private FormController _formController; [SerializeField] private WeaponManager _weaponManager; @@ -57,6 +59,8 @@ namespace BaseGames.Player.States private InputBuffer _inputBuffer; private bool _missingDependencyLogged; private bool _dependenciesReady; + // DashState 在 Update 每帧访问(TickCooldown + CanDash),提前缓存避免重复 Dictionary 查找 + private DashState _dashState; /// /// 当前腾空可用的额外跳跃次数(二段跳)。 /// 由 IdleState/RunState.OnStateEnter 落地时通过 ResetAirJumps() 重置; @@ -128,6 +132,7 @@ namespace BaseGames.Player.States public InputReaderSO Input => _inputReader; public InputBuffer Buffer => _inputBuffer; + public IFeedbackPlayer Feedback => _feedback != null ? (IFeedbackPlayer)_feedback : NullFeedbackPlayer.Instance; public PlayerCombat Combat => _combat; public FormController Form => _formController; public WeaponManager Weapon => _weaponManager; @@ -272,6 +277,7 @@ namespace BaseGames.Player.States { _stats?.AddSoul(info.SoulGained); _shield?.OnParrySuccess(); + Feedback.PlayParrySuccess(); } /// 灵泉输入:地面且有剩余充能时转入 SpringState 使用一次。 @@ -301,7 +307,7 @@ namespace BaseGames.Player.States return; // 冲刺冷却计时 - GetState()?.TickCooldown(Time.deltaTime); + _dashState?.TickCooldown(Time.deltaTime); _currentState?.OnStateUpdate(); @@ -309,7 +315,7 @@ namespace BaseGames.Player.States _dbg_CurrentState = _currentState?.GetType().Name ?? "None"; _dbg_IsGrounded = _movement != null && _movement.IsGrounded; _dbg_AirJumpsLeft = _airJumpsLeft; - _dbg_CanDash = GetState()?.CanDash ?? false; + _dbg_CanDash = _dashState?.CanDash ?? false; _dbg_IsInvincible = _stats != null && _stats.IsInvincible; #endif } @@ -359,8 +365,9 @@ namespace BaseGames.Player.States _states[typeof(HurtState)] = new HurtState(this); _states[typeof(DeadState)] = new DeadState(this); _states[typeof(SpringState)] = new SpringState(this); - _states[typeof(ParryState)] = new ParryState(this); - _states[typeof(SwimState)] = new SwimState(this); + _states[typeof(ParryState)] = new ParryState(this); + _states[typeof(SwimState)] = new SwimState(this); + _dashState = (DashState)_states[typeof(DashState)]; } /// diff --git a/Assets/_Game/Scripts/Player/States/PlayerStateBase.cs b/Assets/_Game/Scripts/Player/States/PlayerStateBase.cs index 4c68edb..6ed83c9 100644 --- a/Assets/_Game/Scripts/Player/States/PlayerStateBase.cs +++ b/Assets/_Game/Scripts/Player/States/PlayerStateBase.cs @@ -1,4 +1,5 @@ using Animancer; +using BaseGames.Feedback; using BaseGames.Input; using BaseGames.Player; @@ -41,6 +42,7 @@ namespace BaseGames.Player.States protected InputBuffer Buffer => _owner.Buffer; protected PlayerMovement Move => _owner.Movement; protected PlayerStats Stats => _owner.Stats; + protected IFeedbackPlayer Feedback => _owner.Feedback; protected AnimancerComponent Anim => _owner.Animancer; protected PlayerMovementConfigSO Cfg => _owner.MovConfig; protected PlayerAnimationConfigSO AnimCfg => _owner.AnimConfig; diff --git a/Assets/_Game/Scripts/Player/States/SpringState.cs b/Assets/_Game/Scripts/Player/States/SpringState.cs index 9a27ae3..63ef440 100644 --- a/Assets/_Game/Scripts/Player/States/SpringState.cs +++ b/Assets/_Game/Scripts/Player/States/SpringState.cs @@ -49,8 +49,9 @@ namespace BaseGames.Player.States private void OnSpringEnd() { - // 前摇正常结束 → 执行回血 + // 前摇正常结束 → 执行回血 + 反馈 Stats?.ApplySpringHeal(); + Feedback.PlayHeal(); Owner.TransitionTo(Owner.GetState()); } } diff --git a/Assets/_Game/Scripts/Player/WeaponHitBoxInstance.cs b/Assets/_Game/Scripts/Player/WeaponHitBoxInstance.cs index 4f62bb9..0b55717 100644 --- a/Assets/_Game/Scripts/Player/WeaponHitBoxInstance.cs +++ b/Assets/_Game/Scripts/Player/WeaponHitBoxInstance.cs @@ -1,5 +1,6 @@ using UnityEngine; using BaseGames.Combat; +using BaseGames.Feedback; namespace BaseGames.Player { @@ -28,6 +29,7 @@ namespace BaseGames.Player private HitBox[] _allHitBoxes; private AttackDirection _activeDir; + private IFeedbackPlayer _feedback; /// 下劈命中确认事件(供 DownAttackState Pogo 逻辑)。 public event System.Action OnDownHitConfirmed; @@ -40,10 +42,16 @@ namespace BaseGames.Player _allHitBoxes = GetComponentsInChildren(true); foreach (var hb in _allHitBoxes) hb.OnHitConfirmed += OnAnyHitConfirmed; + _feedback = GetComponentInChildren() ?? NullFeedbackPlayer.Instance; } private void OnAnyHitConfirmed(DamageInfo info) { + var weight = info.FinalDamage <= 5 ? HitWeight.Light + : info.FinalDamage <= 15 ? HitWeight.Medium + : HitWeight.Heavy; + _feedback.PlayHit(weight); + OnHitConfirmed?.Invoke(info); if (_activeDir == AttackDirection.Down) OnDownHitConfirmed?.Invoke(info); @@ -59,6 +67,7 @@ namespace BaseGames.Player string hitBoxId = "") { _activeDir = dir; + _feedback.PlayAttackWhoosh(); var hitBox = string.IsNullOrEmpty(hitBoxId) ? GetHitBox(dir) : (GetHitBoxById(hitBoxId) ?? GetHitBox(dir)); diff --git a/Assets/_Game/Scripts/Player/WeaponSO.cs b/Assets/_Game/Scripts/Player/WeaponSO.cs index d4cadb3..bff2c26 100644 --- a/Assets/_Game/Scripts/Player/WeaponSO.cs +++ b/Assets/_Game/Scripts/Player/WeaponSO.cs @@ -59,6 +59,9 @@ namespace BaseGames.Player [Min(0)] public int soulPowerGain = 10; + [Tooltip("命中敌人时的打击力度反馈档位(影响摄像机震屏和控制器振动强度)。")] + public HitWeight hitWeight = HitWeight.Medium; + // ── 查询 API ────────────────────────────────────────────────────────── /// 取指定方向、指定段的完整配置,越界自动取最后一个。 diff --git a/Assets/_Game/Scripts/Skills/BaseGames.Skills.asmdef b/Assets/_Game/Scripts/Skills/BaseGames.Skills.asmdef index 974f1f4..2c18757 100644 --- a/Assets/_Game/Scripts/Skills/BaseGames.Skills.asmdef +++ b/Assets/_Game/Scripts/Skills/BaseGames.Skills.asmdef @@ -12,6 +12,7 @@ "BaseGames.Player", "BaseGames.Input", "BaseGames.Combat", + "BaseGames.Feedback", "Kybernetik.Animancer" ], "autoReferenced": true, diff --git a/Assets/_Game/Scripts/Skills/SkillManager.cs b/Assets/_Game/Scripts/Skills/SkillManager.cs index 2f9e23f..eb50758 100644 --- a/Assets/_Game/Scripts/Skills/SkillManager.cs +++ b/Assets/_Game/Scripts/Skills/SkillManager.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using BaseGames.Player; using BaseGames.Input; using BaseGames.Combat; +using BaseGames.Feedback; namespace BaseGames.Skills { @@ -41,13 +42,23 @@ namespace BaseGames.Skills private FormSkillSO _soulSkill; private FormSkillSO _spirit1; private FormSkillSO _spirit2; + private IFeedbackPlayer _feedback; // 冷却字典(FormSkillSO → 剩余冷却秒数),UpdateSkillSet 时重建 private readonly Dictionary _cooldowns = new(3); // 无分配 Update 遍历用的快照数组 private FormSkillSO[] _activeSkills = System.Array.Empty(); + // 技能 HitBox 对象池:prefab → 已创建的实例列表,通过 activeSelf 判断是否可复用 + private readonly Dictionary> _hitBoxPools = new(); // ── 生命周期 ────────────────────────────────────────────────────────── + private void Awake() + { + _feedback = GetComponentInChildren() + ?? GetComponentInParent() + ?? NullFeedbackPlayer.Instance; + } + private void OnEnable() { if (_input != null) @@ -147,6 +158,9 @@ namespace BaseGames.Skills _cooldowns[skill] = p.effectiveCooldown; + // 施放反馈 + _feedback.TriggerPreset("skill_cast"); + // 播放动画(优先修改器动画,回退技能默认动画) var clip = p.effectiveAnimation.Clip != null ? p.effectiveAnimation @@ -158,14 +172,40 @@ namespace BaseGames.Skills if (skill.SkillHitBoxPrefab != null) { var socket = _skillSocket != null ? _skillSocket : transform; - var go = Object.Instantiate(skill.SkillHitBoxPrefab, socket.position, - socket.rotation, socket); - var inst = go.GetComponent(); + var inst = GetOrCreateHitBox(skill.SkillHitBoxPrefab, socket); inst?.Activate(skill.damageSource, transform); - inst?.AutoDestroyAfter(skill.castLockDuration > 0f ? skill.castLockDuration : 0.5f); + inst?.AutoReturnAfter(skill.castLockDuration > 0f ? skill.castLockDuration : 0.5f); } } + /// + /// 从对象池获取或新建 SkillHitBoxInstance。 + /// 扫描该 prefab 已创建的实例列表,找到首个未激活的复用; + /// 无可用实例时 Instantiate,并追加到列表供下次复用。 + /// + private SkillHitBoxInstance GetOrCreateHitBox(GameObject prefab, Transform socket) + { + if (!_hitBoxPools.TryGetValue(prefab, out var list)) + _hitBoxPools[prefab] = list = new List(2); + + for (int i = 0; i < list.Count; i++) + { + var pooled = list[i]; + if (pooled != null && !pooled.gameObject.activeSelf) + { + pooled.transform.SetParent(socket); + pooled.transform.SetPositionAndRotation(socket.position, socket.rotation); + pooled.gameObject.SetActive(true); + return pooled; + } + } + + var go = Object.Instantiate(prefab, socket.position, socket.rotation, socket); + var inst = go.GetComponent(); + if (inst != null) list.Add(inst); + return inst; + } + // ── 属性查询 ───────────────────────────────────────────────────────── public FormSkillSO SoulSkill => _soulSkill; public FormSkillSO Spirit1 => _spirit1; diff --git a/Assets/_Game/Scripts/Spells/BaseGames.Spells.asmdef b/Assets/_Game/Scripts/Spells/BaseGames.Spells.asmdef index 4b2b7cb..7961102 100644 --- a/Assets/_Game/Scripts/Spells/BaseGames.Spells.asmdef +++ b/Assets/_Game/Scripts/Spells/BaseGames.Spells.asmdef @@ -12,6 +12,7 @@ "BaseGames.Input", "BaseGames.Player", "BaseGames.Combat", + "BaseGames.Feedback", "BaseGames.Skills", "Kybernetik.Animancer" ], diff --git a/Assets/_Game/Scripts/Spells/SpellManager.cs b/Assets/_Game/Scripts/Spells/SpellManager.cs index 4806981..586c129 100644 --- a/Assets/_Game/Scripts/Spells/SpellManager.cs +++ b/Assets/_Game/Scripts/Spells/SpellManager.cs @@ -1,6 +1,7 @@ using UnityEngine; using BaseGames.Player; using BaseGames.Input; +using BaseGames.Feedback; namespace BaseGames.Spells { @@ -24,9 +25,17 @@ namespace BaseGames.Spells // 当前装备的法术(单槽;如需多槽可扩展为数组) private SpellSO _equippedSpell; private float _cooldownRemaining; + private IFeedbackPlayer _feedback; // ── 生命周期 ────────────────────────────────────────────────────────── + private void Awake() + { + _feedback = GetComponentInChildren() + ?? GetComponentInParent() + ?? NullFeedbackPlayer.Instance; + } + private void OnEnable() { if (_input != null) @@ -86,6 +95,9 @@ namespace BaseGames.Spells _cooldownRemaining = _equippedSpell.cooldown; + // 施放反馈 + _feedback.TriggerPreset("spell_cast"); + ExecuteSpellEffect(_equippedSpell); } diff --git a/Assets/_Game/Scripts/UI/FloatingDamageText.cs b/Assets/_Game/Scripts/UI/FloatingDamageText.cs index 84d160d..d0b6335 100644 --- a/Assets/_Game/Scripts/UI/FloatingDamageText.cs +++ b/Assets/_Game/Scripts/UI/FloatingDamageText.cs @@ -27,11 +27,12 @@ namespace BaseGames.UI private RectTransform _rectTransform; private Coroutine _animCoroutine; + // 每次 Show() 解析一次,协程期间(< 1s)复用,避免每帧走 FindObjectByTag + private Camera _cachedCamera; private void Awake() { _rectTransform = (RectTransform)transform; - // 不在 Awake 缓存 Camera.main,避免 Boss 过场切换主摄像机后引用过期 } /// @@ -42,6 +43,12 @@ namespace BaseGames.UI { if (_animCoroutine != null) StopCoroutine(_animCoroutine); + // 每次 Show 解析一次摄像机:动画时长 < 1s,期间不会切换主摄像机; + // 若 Boss 过场后再次 Show,会自动获取新的主摄像机。 + _cachedCamera = (_parentCanvas != null && _parentCanvas.renderMode == RenderMode.ScreenSpaceCamera) + ? _parentCanvas.worldCamera + : UnityEngine.Camera.main; + _text.text = damage.ToString(); _text.color = GetColorForType(type); @@ -51,9 +58,7 @@ namespace BaseGames.UI private void SetAnchoredPosition(Vector2 worldPosition) { - var cam = (_parentCanvas != null && _parentCanvas.renderMode == RenderMode.ScreenSpaceCamera) - ? _parentCanvas.worldCamera - : UnityEngine.Camera.main; + var cam = _cachedCamera; var screenPoint = cam != null ? (Vector2)cam.WorldToScreenPoint(worldPosition) @@ -122,7 +127,7 @@ namespace BaseGames.UI [Header("预制体(对象池 key = AddressKeys.PrefabUIFloatingDmgText)")] [SerializeField] private GameObject _floatingDmgPrefab; // Fallback:Inspector 直接拖入 - private readonly Queue _pool = new(); + private readonly List _pool = new(); private readonly CompositeDisposable _subs = new(); private void OnEnable() => _onDamageDealt?.Subscribe(OnDamageDealt).AddTo(_subs); @@ -138,25 +143,22 @@ namespace BaseGames.UI private FloatingDamageText GetOrCreate() { - // 从池中找到已停用的实例 - while (_pool.Count > 0) + // 线性扫描全部已创建实例,找首个未激活的复用 + for (int i = 0; i < _pool.Count; i++) { - var pooled = _pool.Dequeue(); - if (pooled == null) continue; - if (!pooled.gameObject.activeSelf) + var pooled = _pool[i]; + if (pooled != null && !pooled.gameObject.activeSelf) { pooled.gameObject.SetActive(true); return pooled; } - _pool.Enqueue(pooled); // 仍在使用,放回 - break; } - // 没有可用实例则实例化 + // 没有可用实例则实例化新的,加入列表供下次复用 if (_floatingDmgPrefab == null) return null; var go = Instantiate(_floatingDmgPrefab, transform); var comp = go.GetComponent(); - if (comp != null) _pool.Enqueue(comp); + if (comp != null) _pool.Add(comp); return comp; } } diff --git a/Assets/_Game/Scripts/UI/HUD/BossHPBar.cs b/Assets/_Game/Scripts/UI/HUD/BossHPBar.cs index b99fc4b..42903f6 100644 --- a/Assets/_Game/Scripts/UI/HUD/BossHPBar.cs +++ b/Assets/_Game/Scripts/UI/HUD/BossHPBar.cs @@ -83,7 +83,11 @@ namespace BaseGames.UI _maxHP = max; // 重建阶段标记(每次 BossHPMax 改变时清空并按已存阈值重建,此处简化为清空) if (_phaseMarkersRoot != null) - foreach (Transform t in _phaseMarkersRoot) Destroy(t.gameObject); + { + // 逆序删除:避免正序枚举 Transform 子节点同时销毁时的迭代器失效 + for (int i = _phaseMarkersRoot.childCount - 1; i >= 0; i--) + Destroy(_phaseMarkersRoot.GetChild(i).gameObject); + } } // ── 动画协程 ────────────────────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/UI/HUD/HUDController.cs b/Assets/_Game/Scripts/UI/HUD/HUDController.cs index af063c6..f62b155 100644 --- a/Assets/_Game/Scripts/UI/HUD/HUDController.cs +++ b/Assets/_Game/Scripts/UI/HUD/HUDController.cs @@ -42,6 +42,7 @@ namespace BaseGames.UI.HUD private readonly List _hpCells = new(); private readonly List _springIcons = new(); private readonly CompositeDisposable _subs = new(); + private int _lastLingZhu = int.MinValue; private void OnEnable() { @@ -91,7 +92,9 @@ namespace BaseGames.UI.HUD private void UpdateLingZhu(int val) { - if (_lingZhuText != null) _lingZhuText.text = val.ToString(); + if (_lingZhuText == null || val == _lastLingZhu) return; + _lastLingZhu = val; + _lingZhuText.text = val.ToString(); } private void RebuildSpringIcons(int charges) diff --git a/Assets/_Game/Scripts/World/RoomController.cs b/Assets/_Game/Scripts/World/RoomController.cs index f3df717..fb0f7d8 100644 --- a/Assets/_Game/Scripts/World/RoomController.cs +++ b/Assets/_Game/Scripts/World/RoomController.cs @@ -46,19 +46,17 @@ namespace BaseGames.World { Vector2 playerPos = player.transform.position; -#if UNITY_6000_0_OR_NEWER - var zones = Object.FindObjectsByType(FindObjectsSortMode.None); -#else - var zones = Object.FindObjectsOfType(); -#endif + // 使用 CameraTriggerZone 静态注册表,避免 FindObjectsOfType 全场景扫描 + var zones = CameraTriggerZone.AllZones; + // 选取优先级最高的匹配区域(避免多区域重叠时选错基线) CameraArea bestArea = null; int bestPriority = int.MinValue; foreach (var zone in zones) { - var poly = zone.GetComponent(); - if (poly != null && poly.OverlapPoint(playerPos)) + if (zone == null) continue; + if (zone.ContainsPoint(playerPos)) { var area = zone.GetComponentInParent(); if (area != null && zone.Priority > bestPriority) diff --git a/Docs/Profiler/jump.data b/Docs/Profiler/jump.data new file mode 100644 index 0000000..b5bac3b Binary files /dev/null and b/Docs/Profiler/jump.data differ