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