角色能力,存档

This commit is contained in:
2026-05-19 11:50:21 +08:00
parent d25f237e76
commit 2dcb7a961a
136 changed files with 36035 additions and 27551 deletions

View File

@@ -1,6 +1,6 @@
--- ---
description: "编写或修改 C# 代码时确保命名、注释、Tooltip、Header 中不出现其他游戏的专有名称或明确引用。适用于所有 .cs 文件。" name: no-game-references
applyTo: "**/*.cs" description: '编写或修改 C# 代码时加载此规则确保命名、注释、Tooltip、Header 中不出现其他游戏的专有名称(如 HK / Hollow Knight / Silksong / Ori 等)或明确引用。触发场景:新建 .cs 文件、修改已有 .cs 文件、Code Review .cs 代码是否合规。'
--- ---
# 代码命名与注释:禁止引用其他游戏 # 代码命名与注释:禁止引用其他游戏

View File

@@ -0,0 +1,26 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 36baaa8bdcb9d8b49b9199833965d2c3, type: 3}
m_Name: Main Camera Custom Blends
m_EditorClassIdentifier:
CustomBlends:
- From: '**ANY CAMERA**'
To: '**ANY CAMERA**'
Blend:
Style: 1
Time: 1
CustomCurve:
serializedVersion: 2
m_Curve: []
m_PreInfinity: 0
m_PostInfinity: 0
m_RotationOrder: 0

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: c8d85f4a62eaed04abaa7f5bea0ebccc guid: 9906ca91115b34d498a040782ef36e92
NativeFormatImporter: NativeFormatImporter:
externalObjects: {} externalObjects: {}
mainObjectFileID: 11400000 mainObjectFileID: 11400000

View File

@@ -10,9 +10,9 @@ MonoBehaviour:
m_Enabled: 1 m_Enabled: 1
m_EditorHideFlags: 0 m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 96b10c11e6173394a8fa8d9c614b0035, type: 3} m_Script: {fileID: 11500000, guid: 96b10c11e6173394a8fa8d9c614b0035, type: 3}
m_Name: DS_TestEnemyBody m_Name: CMB_Player_Attack1
m_EditorClassIdentifier: m_EditorClassIdentifier:
sourceId: enemy_body sourceId:
skillId: skillId:
BaseDamage: 10 BaseDamage: 10
DamageMultiplier: 1 DamageMultiplier: 1

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a0e13ba45445ebe408ef0353f5adcbfb
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,28 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 96b10c11e6173394a8fa8d9c614b0035, type: 3}
m_Name: CMB_Player_Attack2
m_EditorClassIdentifier:
sourceId:
skillId:
BaseDamage: 10
DamageMultiplier: 1
Type: 0
Category: 0
Flags: 2
Tags: 1
KnockbackForce: 5
HitStunDuration: 0.1
BreakLevel: 1
FxType: 1
ComboWindowDuration: 0.4
CancelWindowEnd: 0.5

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d4b575099a307fb46b19cf67f8b1e76c
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,28 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 96b10c11e6173394a8fa8d9c614b0035, type: 3}
m_Name: CMB_Player_Attack3
m_EditorClassIdentifier:
sourceId:
skillId:
BaseDamage: 10
DamageMultiplier: 1
Type: 0
Category: 0
Flags: 2
Tags: 1
KnockbackForce: 5
HitStunDuration: 0.1
BreakLevel: 1
FxType: 1
ComboWindowDuration: 0.4
CancelWindowEnd: 0.5

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3f5dc1ab0247ba343885d3b83c80da8a
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,139 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d2443d04d1c179d4d8a4f36e7ca7156e, type: 3}
m_Name: WPN_DiHun
m_EditorClassIdentifier:
weaponId: WPN_DiHun
displayName:
icon: {fileID: 0}
weaponType: 1
groundComboSteps:
- clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 7400000, guid: bde6a54ec8fc04f45a424f9bcd7845df, type: 2}
_NormalizedStartTime: NaN
damageSource: {fileID: 11400000, guid: a0e13ba45445ebe408ef0353f5adcbfb, type: 2}
hitBoxEnter: 0
hitBoxExit: 0
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0
comboTimeout: 0
hitBoxId:
- clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 7400000, guid: c0ef319f521c2a2448022cebcf9c9266, type: 2}
_NormalizedStartTime: NaN
damageSource: {fileID: 11400000, guid: d4b575099a307fb46b19cf67f8b1e76c, type: 2}
hitBoxEnter: 0.032
hitBoxExit: 0.669
comboInputOpen: 0.057
comboInputClose: 0.292
cancelWindowOpen: 0.061
recoveryTime: 0
comboTimeout: 0
hitBoxId:
airComboSteps:
- clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.1
hitBoxExit: 0.8
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
- clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.1
hitBoxExit: 0.8
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
upStep:
clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.2
hitBoxExit: 0.7
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
downStep:
clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.1
hitBoxExit: 0.9
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
hitBoxPrefab: {fileID: 4821376343125962025, guid: 2fce42e66b8d4a042a471a661a05ee1b, type: 3}
vfxConfig:
onEquipPresetId:
weaponTrailPrefab: {fileID: 0}
trailColor: {r: 1, g: 1, b: 1, a: 1}
soulPowerGain: 10
references:
version: 2
RefIds: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bde7d85bdf2d3e54da22d07b1f8d2901
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,103 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d2443d04d1c179d4d8a4f36e7ca7156e, type: 3}
m_Name: WPN_MingHun
m_EditorClassIdentifier:
weaponId: WPN_MingHun
displayName:
icon: {fileID: 0}
weaponType: 2
groundComboSteps:
- clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.3
hitBoxExit: 0.6
comboInputOpen: 0.3
comboInputClose: 0
cancelWindowOpen: 0.5
recoveryTime: 0.05
comboTimeout: 0.25
hitBoxId:
airComboSteps:
- clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.1
hitBoxExit: 0.8
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
upStep:
clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.2
hitBoxExit: 0.7
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
downStep:
clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.1
hitBoxExit: 0.9
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
hitBoxPrefab: {fileID: 0}
vfxConfig:
onEquipPresetId:
weaponTrailPrefab: {fileID: 0}
trailColor: {r: 1, g: 1, b: 1, a: 1}
soulPowerGain: 10
references:
version: 2
RefIds: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fbe1ff6f23c995541a5833b51c52dc01
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,103 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d2443d04d1c179d4d8a4f36e7ca7156e, type: 3}
m_Name: WPN_TianHun
m_EditorClassIdentifier:
weaponId: WPN_TianHun
displayName:
icon: {fileID: 0}
weaponType: 0
groundComboSteps:
- clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.3
hitBoxExit: 0.6
comboInputOpen: 0.3
comboInputClose: 0
cancelWindowOpen: 0.5
recoveryTime: 0.05
comboTimeout: 0.25
hitBoxId:
airComboSteps:
- clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.1
hitBoxExit: 0.8
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
upStep:
clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.2
hitBoxExit: 0.7
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
downStep:
clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.1
hitBoxExit: 0.9
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
hitBoxPrefab: {fileID: 0}
vfxConfig:
onEquipPresetId:
weaponTrailPrefab: {fileID: 0}
trailColor: {r: 1, g: 1, b: 1, a: 1}
soulPowerGain: 10
references:
version: 2
RefIds: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 533d4711e21d8584597a5d4569fe2eb0
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d2ff92bffe90e0f499b70bdb9d045552, type: 3}
m_Name: PLY_EquipmentConfig
m_EditorClassIdentifier:
initialNotchCount: 3
maxCollectionSize: -1

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f167dd4c0f40ff7499127f917066994a
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -13,6 +13,6 @@ MonoBehaviour:
m_Name: PLY_FormConfig m_Name: PLY_FormConfig
m_EditorClassIdentifier: m_EditorClassIdentifier:
forms: forms:
- {fileID: 0} - {fileID: 11400000, guid: b2ba655d7ab18bc48b2855c56d25cc1b, type: 2}
- {fileID: 0} - {fileID: 11400000, guid: 82d209d1343ca524dbf22fe56c489246, type: 2}
- {fileID: 0} - {fileID: 11400000, guid: 76b37db14bfccc2488ad29268d02d54a, type: 2}

View File

@@ -12,23 +12,20 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 5ec15df6b0d345c4f92ba459e89dc02f, type: 3} m_Script: {fileID: 11500000, guid: 5ec15df6b0d345c4f92ba459e89dc02f, type: 3}
m_Name: PLY_PlayerAnimationConfig m_Name: PLY_PlayerAnimationConfig
m_EditorClassIdentifier: m_EditorClassIdentifier:
Idle: {fileID: 0} Idle: {fileID: 7400000, guid: 0fba2927fa4627449b8e277805c0b3b5, type: 2}
Run: {fileID: 0} Run: {fileID: 7400000, guid: 8f7b8659b100c93499761109be066543, type: 2}
Jump: {fileID: 0} Jump: {fileID: 7400000, guid: 8ef02c7477ee3ea4585a4d0bcc20ab42, type: 2}
Fall: {fileID: 0} AirJump: {fileID: 7400000, guid: 904dfb09ecf693d45aea778ecfbd8eb7, type: 2}
Dash: {fileID: 0} Fall: {fileID: 7400000, guid: 525660d3a8f083c49b48fce0f82f604e, type: 2}
WallSlide: {fileID: 0} Dash: {fileID: 7400000, guid: 407a732f86675164a9d61ac1b61fb8bf, type: 2}
Hurt: {fileID: 0} DashInvincible: {fileID: 7400000, guid: 612dcd6dfa8c62743bbd1e9fc30071ae, type: 2}
Dead: {fileID: 0} WallSlide: {fileID: 7400000, guid: fb9661f2571db684b8a09c25f884a31f, type: 2}
WallJumpAway: {fileID: 7400000, guid: 8ef02c7477ee3ea4585a4d0bcc20ab42, type: 2}
WallJumpToward: {fileID: 7400000, guid: 8ef02c7477ee3ea4585a4d0bcc20ab42, type: 2}
Hurt: {fileID: 7400000, guid: 3f4bb0b6b3ca2224f9697775b61c8ab6, type: 2}
Dead: {fileID: 7400000, guid: 720c61e4274ae5745b3777cbc7f2fcea, type: 2}
HurtDuration: 0.4 HurtDuration: 0.4
UseSpring: {fileID: 0} UseSpring: {fileID: 0}
GroundAttacks: []
GroundAttackTimings:
- HitBoxEnter: 0.3
HitBoxExit: 0.6
AirAttack: {fileID: 0}
UpAttack: {fileID: 0}
DownAttack: {fileID: 0}
ParryStart: {fileID: 0} ParryStart: {fileID: 0}
ParrySuccess: {fileID: 0} ParrySuccess: {fileID: 0}
SwimIdle: {fileID: 0} SwimIdle: {fileID: 0}

View File

@@ -12,21 +12,27 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 81da55e0fcf99d34693cbc5a348225c3, type: 3} m_Script: {fileID: 11500000, guid: 81da55e0fcf99d34693cbc5a348225c3, type: 3}
m_Name: PLY_PlayerMovementConfig m_Name: PLY_PlayerMovementConfig
m_EditorClassIdentifier: m_EditorClassIdentifier:
RunSpeed: 7 RunSpeed: 10
Acceleration: 50 AirDragFactor: 1
Deceleration: 80 JumpForce: 24
JumpForce: 18
CoyoteTime: 0.12 CoyoteTime: 0.12
FallGravityMult: 2.5 FallGravityMult: 2.5
MaxFallSpeed: 20 MaxFallSpeed: 28
JumpCutMultiplier: 0.321
ApexThreshold: 3
ApexGravityMultiplier: 0.3
MaxAirJumps: 1
DoubleJumpForce: 19
DashSpeed: 20 DashSpeed: 20
DashDuration: 0.18 DashDuration: 0.25
DashCooldown: 0.4 DashCooldown: 0.4
MaxAerialDashes: 1 MaxAerialDashes: 1
DashInvincibilityDuration: 0.2
DashInvincibilityCooldown: 0.9
WallSlideSpeed: 2 WallSlideSpeed: 2
WallJumpForceX: 12 WallJumpForceX: 12
WallJumpForceY: 16 WallJumpForceY: 16
WallRayLength: 0.55 WallRayLength: 0.37
WallRayOffsetY: 0.2 WallRayOffsetY: 0.2
WallGrabMaxHeightGain: 0.5 WallGrabMaxHeightGain: 0.5
WallGrabReleaseDelay: 0.08 WallGrabReleaseDelay: 0.08
@@ -34,4 +40,4 @@ MonoBehaviour:
WallJumpAwayForceX: 10 WallJumpAwayForceX: 10
WallJumpAwayForceY: 18 WallJumpAwayForceY: 18
WallJumpInputLockDuration: 0.15 WallJumpInputLockDuration: 0.15
DefaultGravityScale: 3 DefaultGravityScale: 6

View File

@@ -0,0 +1,24 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 31a9f22bef1315643bf5a49f2a6edd2b, type: 3}
m_Name: PLY_PlayerStats
m_EditorClassIdentifier:
MaxHP: 5
MaxSoulPower: 100
MaxSpiritPower: 100
SpiritRegenRate: 5
MaxSpringCharges: 3
SpringHealAmount: 2
SpringKillThreshold: 4
InvincibilityDuration: 0.6
InitialLingZhu: 0
InitialAbilities: 524279

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: eaaee0817c0cc9e449142241ad75827e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cf52457264252d34db476527ee76c8d2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,103 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d2443d04d1c179d4d8a4f36e7ca7156e, type: 3}
m_Name: Weapon_DiHun
m_EditorClassIdentifier:
weaponId:
displayName:
icon: {fileID: 0}
weaponType: 0
groundComboSteps:
- clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.3
hitBoxExit: 0.6
comboInputOpen: 0.3
comboInputClose: 0
cancelWindowOpen: 0.5
recoveryTime: 0.05
comboTimeout: 0.25
hitBoxId:
airComboSteps:
- clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.1
hitBoxExit: 0.8
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
upStep:
clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.2
hitBoxExit: 0.7
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
downStep:
clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
damageSource: {fileID: 0}
hitBoxEnter: 0.1
hitBoxExit: 0.9
comboInputOpen: 0
comboInputClose: 0
cancelWindowOpen: 0
recoveryTime: 0.05
comboTimeout: 0
hitBoxId:
hitBoxPrefab: {fileID: 0}
vfxConfig:
onEquipPresetId:
weaponTrailPrefab: {fileID: 0}
trailColor: {r: 1, g: 1, b: 1, a: 1}
soulPowerGain: 10
references:
version: 2
RefIds: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f80486c9cd3d1db459ce6df915b11546
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,87 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d2443d04d1c179d4d8a4f36e7ca7156e, type: 3}
m_Name: Weapon_MingHun
m_EditorClassIdentifier:
weaponId:
displayName:
icon: {fileID: 0}
weaponType: 0
attack1Clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
attack2Clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
attack3Clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
airAttackClip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
upAttackClip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
downAttackClip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
attack1Source: {fileID: 0}
attack2Source: {fileID: 0}
attack3Source: {fileID: 0}
airAttackSource: {fileID: 0}
upAttackSource: {fileID: 0}
downAttackSource: {fileID: 0}
hitBoxPrefab: {fileID: 0}
vfxConfig:
onEquipPresetId:
weaponTrailPrefab: {fileID: 0}
trailColor: {r: 1, g: 1, b: 1, a: 1}
soulPowerGain: 10
references:
version: 2
RefIds: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d87ae01ed8a2b4f4cb27d159a52d1a14
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,87 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d2443d04d1c179d4d8a4f36e7ca7156e, type: 3}
m_Name: Weapon_TianHun
m_EditorClassIdentifier:
weaponId:
displayName:
icon: {fileID: 0}
weaponType: 0
attack1Clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
attack2Clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
attack3Clip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
airAttackClip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
upAttackClip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
downAttackClip:
_FadeDuration: 0.25
_Speed: 1
_Events:
_NormalizedTimes: []
_Callbacks: []
_Names: []
_Clip: {fileID: 0}
_NormalizedStartTime: NaN
attack1Source: {fileID: 0}
attack2Source: {fileID: 0}
attack3Source: {fileID: 0}
airAttackSource: {fileID: 0}
upAttackSource: {fileID: 0}
downAttackSource: {fileID: 0}
hitBoxPrefab: {fileID: 0}
vfxConfig:
onEquipPresetId:
weaponTrailPrefab: {fileID: 0}
trailColor: {r: 1, g: 1, b: 1, a: 1}
soulPowerGain: 10
references:
version: 2
RefIds: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 76de8c0ba36dcce4db8990c5b62e9ed8
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,15 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 277db74c72224f149b8805dc31b2f944, type: 3}
m_Name: CHM_Catalog
m_EditorClassIdentifier:
_charms: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 62b057558c311d649ba7d5d91633b544
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 53a03bf20d81ee84394d0bde462f5b98
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,632 @@
%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: 8
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:
_rivalHitBoxMask:
serializedVersion: 2
m_Bits: 32832
--- !u!1 &1932889250901504761
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6088225995420515986}
- component: {fileID: 4288322330375545731}
- component: {fileID: 6478051166999031478}
m_Layer: 8
m_Name: HitBox_Down
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &6088225995420515986
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
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_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 &4288322330375545731
BoxCollider2D:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1932889250901504761}
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.027121663, y: -0.15051937}
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.189852, y: 0.80103874}
m_EdgeRadius: 0
--- !u!114 &6478051166999031478
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1932889250901504761}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3}
m_Name:
m_EditorClassIdentifier:
_defaultSource: {fileID: 0}
_hitCooldown: 0.1
_id:
_rivalHitBoxMask:
serializedVersion: 2
m_Bits: 0
--- !u!1 &2584603199706918030
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1660186156348129284}
- component: {fileID: 1152578598430080845}
- component: {fileID: 3007294148525084107}
m_Layer: 8
m_Name: HitBox_Ground _2
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1660186156348129284
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2584603199706918030}
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 &1152578598430080845
BoxCollider2D:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2584603199706918030}
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.117884755, y: 0.01309824}
m_SpriteTilingProperty:
border: {x: 0, y: 0, z: 0, w: 0}
pivot: {x: 0, y: 0}
oldSize: {x: 0, y: 0}
newSize: {x: 0, y: 0}
adaptiveTilingThreshold: 0
drawMode: 0
adaptiveTiling: 0
m_AutoTiling: 0
serializedVersion: 2
m_Size: {x: 0.7642305, y: 1.1956644}
m_EdgeRadius: 0
--- !u!114 &3007294148525084107
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2584603199706918030}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3}
m_Name:
m_EditorClassIdentifier:
_defaultSource: {fileID: 0}
_hitCooldown: 0.1
_id:
_rivalHitBoxMask:
serializedVersion: 2
m_Bits: 32832
--- !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: 8
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:
_rivalHitBoxMask:
serializedVersion: 2
m_Bits: 0
--- !u!1 &4335406389674002762
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7468586589501741901}
- component: {fileID: 4473084966422755714}
- component: {fileID: 1392799324577637263}
m_Layer: 8
m_Name: HitBox_Up
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &7468586589501741901
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
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_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 &4473084966422755714
BoxCollider2D:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4335406389674002762}
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.072324514, 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.1599612, y: 1}
m_EdgeRadius: 0
--- !u!114 &1392799324577637263
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4335406389674002762}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3}
m_Name:
m_EditorClassIdentifier:
_defaultSource: {fileID: 0}
_hitCooldown: 0.1
_id:
_rivalHitBoxMask:
serializedVersion: 2
m_Bits: 0
--- !u!1 &4821376343125962025
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 8975424752584779179}
- component: {fileID: 3691925044832415471}
m_Layer: 0
m_Name: WPN_WPN_DiHun_HitBox
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &8975424752584779179
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4821376343125962025}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 7119158475861943178}
- {fileID: 1660186156348129284}
- {fileID: 7468586589501741901}
- {fileID: 6088225995420515986}
- {fileID: 4362395311111627733}
- {fileID: 4405470499151834857}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &3691925044832415471
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4821376343125962025}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: ec12dacf2519f58429dd3c59da8f93b0, type: 3}
m_Name:
m_EditorClassIdentifier:
_hitBoxGround: {fileID: 4639356286286040131}
_hitBoxUp: {fileID: 1392799324577637263}
_hitBoxDown: {fileID: 6478051166999031478}
_hitBoxAir: {fileID: 9014207169512774676}
--- !u!1 &8582289489283119946
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 4362395311111627733}
- component: {fileID: 922051492914393482}
- component: {fileID: 9014207169512774676}
m_Layer: 8
m_Name: HitBox_Air_1
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &4362395311111627733
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8582289489283119946}
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 &922051492914393482
BoxCollider2D:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8582289489283119946}
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.46717286, 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.4343457, y: 1}
m_EdgeRadius: 0
--- !u!114 &9014207169512774676
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8582289489283119946}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3}
m_Name:
m_EditorClassIdentifier:
_defaultSource: {fileID: 0}
_hitCooldown: 0.1
_id:
_rivalHitBoxMask:
serializedVersion: 2
m_Bits: 0

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2fce42e66b8d4a042a471a661a05ee1b
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -351,11 +351,7 @@ MonoBehaviour:
_brain: {fileID: 533647439} _brain: {fileID: 533647439}
_impulseSource: {fileID: 51085361} _impulseSource: {fileID: 51085361}
_lookSystem: {fileID: 843380225} _lookSystem: {fileID: 843380225}
_vcamA: {fileID: 2015076228} _defaultBlendProfile: {fileID: 11400000, guid: 33f7ac6591bc7db4ea52d89d3441b567, type: 2}
_vcamB: {fileID: 852869520}
_globalActivePriority: 10
_standbyPriority: 1
_defaultBlendProfile: {fileID: 0}
_lensConfig: {fileID: 11400000, guid: 12fec951ce5cc3d499b00e38b5dfa14a, type: 2} _lensConfig: {fileID: 11400000, guid: 12fec951ce5cc3d499b00e38b5dfa14a, type: 2}
_onPlayerSpawned: {fileID: 11400000, guid: 7e2c7e614f6627b449a244ab44443adf, type: 2} _onPlayerSpawned: {fileID: 11400000, guid: 7e2c7e614f6627b449a244ab44443adf, type: 2}
_showDebugOverlay: 1 _showDebugOverlay: 1
@@ -1014,6 +1010,7 @@ GameObject:
- component: {fileID: 533647440} - component: {fileID: 533647440}
- component: {fileID: 533647439} - component: {fileID: 533647439}
- component: {fileID: 533647443} - component: {fileID: 533647443}
- component: {fileID: 533647444}
m_Layer: 0 m_Layer: 0
m_Name: Main Camera m_Name: Main Camera
m_TagString: MainCamera m_TagString: MainCamera
@@ -1052,7 +1049,7 @@ MonoBehaviour:
m_PreInfinity: 2 m_PreInfinity: 2
m_PostInfinity: 2 m_PostInfinity: 2
m_RotationOrder: 4 m_RotationOrder: 4
CustomBlends: {fileID: 0} CustomBlends: {fileID: 11400000, guid: 9906ca91115b34d498a040782ef36e92, type: 2}
--- !u!81 &533647440 --- !u!81 &533647440
AudioListener: AudioListener:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -1096,7 +1093,7 @@ Camera:
far clip plane: 5000 far clip plane: 5000
field of view: 10 field of view: 10
orthographic: 0 orthographic: 0
orthographic size: 10 orthographic size: 8.4375
m_Depth: 0 m_Depth: 0
m_CullingMask: m_CullingMask:
serializedVersion: 2 serializedVersion: 2
@@ -1121,7 +1118,7 @@ Transform:
m_GameObject: {fileID: 533647438} m_GameObject: {fileID: 533647438}
serializedVersion: 2 serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: -64} m_LocalPosition: {x: 122.45427, y: -35, z: -64}
m_LocalScale: {x: 1, y: 1, z: 1} m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: [] m_Children: []
@@ -1171,6 +1168,30 @@ MonoBehaviour:
m_MipBias: 0 m_MipBias: 0
m_VarianceClampScale: 0.9 m_VarianceClampScale: 0.9
m_ContrastAdaptiveSharpening: 0 m_ContrastAdaptiveSharpening: 0
--- !u!114 &533647444
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 533647438}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c88f5cead0c0b2a4eb05b5900433f8d1, type: 3}
m_Name:
m_EditorClassIdentifier:
m_ComponentVersion: 1
m_AssetsPPU: 32
m_RefResolutionX: 320
m_RefResolutionY: 180
m_CropFrame: 4
m_GridSnapping: 0
m_FilterMode: 0
m_UpscaleRT: 0
m_PixelSnapping: 0
m_CropFrameX: 0
m_CropFrameY: 0
m_StretchFill: 0
--- !u!1 &555895481 --- !u!1 &555895481
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -1535,182 +1556,6 @@ MonoBehaviour:
_lookSpeedH: 2 _lookSpeedH: 2
_resetSpeedH: 5 _resetSpeedH: 5
_speedGateThreshold: 2.5 _speedGateThreshold: 2.5
--- !u!1 &852869513
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 852869514}
- component: {fileID: 852869520}
- component: {fileID: 852869519}
- component: {fileID: 852869518}
- component: {fileID: 852869517}
- component: {fileID: 852869516}
- component: {fileID: 852869515}
m_Layer: 0
m_Name: VCamB
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &852869514
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 852869513}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: -64}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 990528702}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &852869515
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 852869513}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a12cbb2380ff137459b7ba80d492733f, type: 3}
m_Name:
m_EditorClassIdentifier:
_restScale: 0.25
_speedAtFullLookahead: 12
_speedSmoothing: 5
--- !u!114 &852869516
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 852869513}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: cb5a7225ab133e74b81d1f0ae22ccc77, type: 3}
m_Name:
m_EditorClassIdentifier:
_dampingDown: 0.08
_dampingUp: 0.65
--- !u!114 &852869517
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 852869513}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7e2e7849ca8d76f438c4b2899c9fb421, type: 3}
m_Name:
m_EditorClassIdentifier:
LockX: 0
LockY: 0
LockedX: 0
LockedY: 0
--- !u!114 &852869518
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 852869513}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f453f694addf4275988fac205bc91968, type: 3}
m_Name:
m_EditorClassIdentifier:
BoundingShape2D: {fileID: 0}
Damping: 0.5
SlowingDistance: 5
OversizeWindow:
Enabled: 1
MaxWindowSize: 0.001
Padding: 0
m_LegacyMaxWindowSize: -2
--- !u!114 &852869519
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 852869513}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 886251e9a18ece04ea8e61686c173e1b, type: 3}
m_Name:
m_EditorClassIdentifier:
CameraDistance: 64
DeadZoneDepth: 0
Composition:
ScreenPosition: {x: 0, y: -0.15}
DeadZone:
Enabled: 1
Size: {x: 0.15, y: 0.05}
HardLimits:
Enabled: 0
Size: {x: 0.8, y: 0.8}
Offset: {x: 0, y: 0}
CenterOnActivate: 1
TargetOffset: {x: 0, y: 0, z: 0}
Damping: {x: 0.5, y: 0, z: 0}
Lookahead:
Enabled: 1
Time: 0.28
Smoothing: 5
IgnoreY: 1
--- !u!114 &852869520
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 852869513}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f9dfa5b682dcd46bda6128250e975f58, type: 3}
m_Name:
m_EditorClassIdentifier:
Priority:
Enabled: 0
m_Value: 0
OutputChannel: 1
StandbyUpdate: 2
m_StreamingVersion: 20241001
m_LegacyPriority: 0
Target:
TrackingTarget: {fileID: 0}
LookAtTarget: {fileID: 0}
CustomLookAtTarget: 0
Lens:
FieldOfView: 10
OrthographicSize: 10
NearClipPlane: 0.1
FarClipPlane: 5000
Dutch: 0
ModeOverride: 0
PhysicalProperties:
GateFit: 2
SensorSize: {x: 21.946, y: 16.002}
LensShift: {x: 0, y: 0}
FocusDistance: 10
Iso: 200
ShutterSpeed: 0.005
Aperture: 16
BladeCount: 5
Curvature: {x: 2, y: 11}
BarrelClipping: 0.25
Anamorphism: 0
BlendHint: 0
--- !u!1 &990528701 --- !u!1 &990528701
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -1743,8 +1588,6 @@ Transform:
- {fileID: 533647442} - {fileID: 533647442}
- {fileID: 51085360} - {fileID: 51085360}
- {fileID: 843380224} - {fileID: 843380224}
- {fileID: 2015076222}
- {fileID: 852869514}
m_Father: {fileID: 313736080} m_Father: {fileID: 313736080}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1021425710 --- !u!1 &1021425710
@@ -2808,182 +2651,6 @@ Canvas:
m_SortingLayerID: 0 m_SortingLayerID: 0
m_SortingOrder: 0 m_SortingOrder: 0
m_TargetDisplay: 0 m_TargetDisplay: 0
--- !u!1 &2015076221
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 2015076222}
- component: {fileID: 2015076228}
- component: {fileID: 2015076227}
- component: {fileID: 2015076226}
- component: {fileID: 2015076225}
- component: {fileID: 2015076224}
- component: {fileID: 2015076223}
m_Layer: 0
m_Name: VCamA
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &2015076222
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2015076221}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: -64}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 990528702}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &2015076223
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2015076221}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a12cbb2380ff137459b7ba80d492733f, type: 3}
m_Name:
m_EditorClassIdentifier:
_restScale: 0.25
_speedAtFullLookahead: 12
_speedSmoothing: 5
--- !u!114 &2015076224
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2015076221}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: cb5a7225ab133e74b81d1f0ae22ccc77, type: 3}
m_Name:
m_EditorClassIdentifier:
_dampingDown: 0.08
_dampingUp: 0.65
--- !u!114 &2015076225
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2015076221}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7e2e7849ca8d76f438c4b2899c9fb421, type: 3}
m_Name:
m_EditorClassIdentifier:
LockX: 0
LockY: 0
LockedX: 0
LockedY: 0
--- !u!114 &2015076226
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2015076221}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f453f694addf4275988fac205bc91968, type: 3}
m_Name:
m_EditorClassIdentifier:
BoundingShape2D: {fileID: 0}
Damping: 0.5
SlowingDistance: 5
OversizeWindow:
Enabled: 1
MaxWindowSize: 0.001
Padding: 0
m_LegacyMaxWindowSize: -2
--- !u!114 &2015076227
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2015076221}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 886251e9a18ece04ea8e61686c173e1b, type: 3}
m_Name:
m_EditorClassIdentifier:
CameraDistance: 64
DeadZoneDepth: 0
Composition:
ScreenPosition: {x: 0, y: -0.15}
DeadZone:
Enabled: 1
Size: {x: 0.15, y: 0.05}
HardLimits:
Enabled: 0
Size: {x: 0.8, y: 0.8}
Offset: {x: 0, y: 0}
CenterOnActivate: 1
TargetOffset: {x: 0, y: 0, z: 0}
Damping: {x: 0.5, y: 0, z: 0}
Lookahead:
Enabled: 1
Time: 0.28
Smoothing: 5
IgnoreY: 1
--- !u!114 &2015076228
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2015076221}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f9dfa5b682dcd46bda6128250e975f58, type: 3}
m_Name:
m_EditorClassIdentifier:
Priority:
Enabled: 0
m_Value: 0
OutputChannel: 1
StandbyUpdate: 2
m_StreamingVersion: 20241001
m_LegacyPriority: 0
Target:
TrackingTarget: {fileID: 0}
LookAtTarget: {fileID: 0}
CustomLookAtTarget: 0
Lens:
FieldOfView: 10
OrthographicSize: 10
NearClipPlane: 0.1
FarClipPlane: 5000
Dutch: 0
ModeOverride: 0
PhysicalProperties:
GateFit: 2
SensorSize: {x: 21.946, y: 16.002}
LensShift: {x: 0, y: 0}
FocusDistance: 10
Iso: 200
ShutterSpeed: 0.005
Aperture: 16
BladeCount: 5
Curvature: {x: 2, y: 11}
BarrelClipping: 0.25
Anamorphism: 0
BlendHint: 0
--- !u!1 &2038852547 --- !u!1 &2038852547
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0

File diff suppressed because it is too large Load Diff

View File

@@ -77,7 +77,7 @@ namespace BaseGames.Camera
float rawSpeedX = Mathf.Abs(follow.position.x - _lastFollowX) / deltaTime; float rawSpeedX = Mathf.Abs(follow.position.x - _lastFollowX) / deltaTime;
_lastFollowX = follow.position.x; _lastFollowX = follow.position.x;
_estimatedSpeedX = Mathf.Lerp(_estimatedSpeedX, rawSpeedX, deltaTime * _speedSmoothing); _estimatedSpeedX = Mathf.Lerp(_estimatedSpeedX, rawSpeedX, 1f - Mathf.Exp(-deltaTime * _speedSmoothing));
} }
// ── 速度映射 → Lookahead 时长 ───────────────────────────────────── // ── 速度映射 → Lookahead 时长 ─────────────────────────────────────

View File

@@ -5,20 +5,19 @@ namespace BaseGames.Camera
{ {
/// <summary> /// <summary>
/// 相机区域数据组件。一个房间场景内可放置任意数量的 CameraArea /// 相机区域数据组件。一个房间场景内可放置任意数量的 CameraArea
/// 每个区域独立定义限位范围、可视边界与混合配置。 /// 每个区域独立定义限位范围、可视边界、跟随参数与混合配置。
/// ///
/// 运行时由 <see cref="CameraStateController"/> 管理: /// 运行时由 <see cref="CameraStateController"/> 管理:
/// - <c>_dedicatedCamera</c> 为空 → 使用 Persistent 场景中的两台全局 VCam 交替承接, /// 每个区域均拥有专属的 <c>_dedicatedCamera</c>,进入该区域时激活其专属 VCam。
/// 减少场景内 VCam 数量,相机参数统一由全局 VCam 配置。 /// <c>OverrideFollowBehaviour</c> 为真时,<see cref="CameraStateController"/> 会将本组件中的
/// - <c>_dedicatedCamera</c> 不为空 → 激活该专有 VCam优先级高于全局 VCam /// 跟随参数应用到专属 VCam否则 VCam 保持 Inspector 中的默认值。
/// 适用于需要独特相机参数FOV / Offset / 阻尼)的特殊区域。
/// </summary> /// </summary>
public class CameraArea : MonoBehaviour public class CameraArea : MonoBehaviour
{ {
[Header("限位区域")] [Header("限位区域")]
[Tooltip("定义相机移动边界的 PolygonCollider2D(通常挂载在子节点 AreaBoundary 上)。\n" + [Tooltip("定义相机移动边界的 BoxCollider通常挂载在子节点 AreaBoundary 上)。\n" +
"会被赋给全局 VCam 的 CinemachineConfiner2D.BoundingShape2D。")] "会被绑定到专属 VCam 的 CinemachineConfiner3D.BoundingVolume。")]
[SerializeField] private PolygonCollider2D _confinerCollider; [SerializeField] private BoxCollider _confinerCollider;
[Header("可视区域(透视相机)")] [Header("可视区域(透视相机)")]
[Tooltip("摄像机应显示的最大可视矩形(本地坐标,相对于此 GameObject 的 Transform 位置)。\n" + [Tooltip("摄像机应显示的最大可视矩形(本地坐标,相对于此 GameObject 的 Transform 位置)。\n" +
@@ -30,8 +29,7 @@ namespace BaseGames.Camera
[SerializeField] private float _cameraDepth = 0f; [SerializeField] private float _cameraDepth = 0f;
[Header("镜头配置")] [Header("镜头配置")]
[Tooltip("全局相机镜头参数 SO。与 CameraStateController 引用同一资产,\n" + [Tooltip("相机镜头参数 SO,提供 FOV、相机深度等参数。\n" +
"保证 FOV 等参数在 Room 场景中也能正确读取。\n" +
"SO 中的 fieldOfView 发生变化时,编辑器会自动重新同步限位多边形。")] "SO 中的 fieldOfView 发生变化时,编辑器会自动重新同步限位多边形。")]
[SerializeField] private CameraLensConfigSO _lensConfig; [SerializeField] private CameraLensConfigSO _lensConfig;
@@ -40,9 +38,9 @@ namespace BaseGames.Camera
[SerializeField] private float _lastSyncFOV = 0f; [SerializeField] private float _lastSyncFOV = 0f;
// ── 跟随行为 ────────────────────────────────────────────────────────── // ── 跟随行为 ──────────────────────────────────────────────────────────
[Header("跟随行为(覆盖全局 VCam 参数)")] [Header("跟随行为(专属 VCam 参数)")]
[Tooltip("启用后,进入此区域时将以下参数写入全局 VCam\n" + [Tooltip("启用后,激活此区域时将以下参数应用到专属 VCam\n" +
"关闭则 VCam 保持上一区域或 Inspector 中的默认。")] "关闭则 VCam 保持 Inspector 中的默认参数不作任何覆写。")]
[SerializeField] private bool _overrideFollowBehaviour = true; [SerializeField] private bool _overrideFollowBehaviour = true;
[Tooltip("玩家跟踪点在屏幕上的位置0 = 中心±0.5 = 边缘)。\n" + [Tooltip("玩家跟踪点在屏幕上的位置0 = 中心±0.5 = 边缘)。\n" +
@@ -78,6 +76,20 @@ namespace BaseGames.Camera
+ "防止相机因偏置超出 Confiner 边界。")] + "防止相机因偏置超出 Confiner 边界。")]
[SerializeField] private bool _disableFallBias = false; [SerializeField] private bool _disableFallBias = false;
// ── 方向感知水平偏置 ──────────────────────────────────────────────────
[Header("方向感知偏置(需配合 CameraFacingBiasExtension")]
[Tooltip("是否覆盖此区域的方向感知水平偏置量。\n"
+ "关闭时使用 CameraFacingBiasExtension 组件的默认值;\n"
+ "开启后可将此区域的偏置设为 0禁用或更小值如窄走廊。")]
[SerializeField] private bool _overrideFacingBias = false;
[Tooltip("方向感知水平偏置量(世界单位)。\n"
+ "0 = 禁用此区域的方向偏置(推荐用于宽度受限的走廊)。\n"
+ "仅在 Override Facing Bias = true 时生效。")]
[Min(0f)]
[SerializeField] private float _facingBiasOverride = 0f;
// ── 轴向约束 ────────────────────────────────────────────────────────── // ── 轴向约束 ──────────────────────────────────────────────────────────
[Header("轴向约束")] [Header("轴向约束")]
@@ -87,8 +99,9 @@ namespace BaseGames.Camera
[Tooltip("锁定相机 Y 轴水平走廊相机仅左右移动Y 固定在限位区域中心)。")] [Tooltip("锁定相机 Y 轴水平走廊相机仅左右移动Y 固定在限位区域中心)。")]
[SerializeField] private bool _lockVertical = false; [SerializeField] private bool _lockVertical = false;
[Header("镜头尺寸(正交相机")] [Header("镜头尺寸(视野缩放")]
[Tooltip("进入此区域时的目标正交尺寸(0 = 不覆盖当前尺寸)。\n" + [Tooltip("进入此区域时的目标可视半高(世界单位,0 = 不覆盖)。\n" +
"等价于正交相机的 OrthographicSize透视相机下自动换算为 FOV。\n" +
"适用于 Boss 战拉远或精密解谜区域拉近。")] "适用于 Boss 战拉远或精密解谜区域拉近。")]
[SerializeField] private float _lensSize = 0f; [SerializeField] private float _lensSize = 0f;
@@ -99,18 +112,31 @@ namespace BaseGames.Camera
[Header("混合配置")] [Header("混合配置")]
[SerializeField] private CameraBlendProfileSO _blendProfile; [SerializeField] private CameraBlendProfileSO _blendProfile;
[Header("专有虚拟相机(可选")] [Header("相机噪音(氛围震动")]
[Tooltip("为空时由全局双 VCam 交替过渡(推荐,节省 VCam 数量)。\n" + [Tooltip("此区域的相机噪音配置Noise Settings 资产)。\n"
"不为空时激活此专有 CinemachineCamera优先级高于全局 VCam。\n" + + "洞穴、水下、机械区等需要氛围震动时使用留空则禁用噪音AmplitudeGain = 0。\n"
"适用于需要独特 FOV / Noise / LookAt 等参数的特殊区域。")] + "专属 VCam 已包含 CinemachineBasicMultiChannelPerlin 组件(使用工具创建时自动附加)。")]
[SerializeField] private NoiseSettings _noiseProfile;
[Tooltip("噪音振幅增益0 = 无震动,推荐 0.2 ~ 0.8)。")]
[Min(0f)]
[SerializeField] private float _noiseAmplitude = 0.5f;
[Tooltip("噪音频率增益倍率1 = 资产原始频率,越大震动越快)。")]
[Min(0.01f)]
[SerializeField] private float _noiseFrequency = 1f;
[Header("虚拟相机")]
[Tooltip("此区域的专属 CinemachineCamera。\n" +
"每个区域均应配置自己的专属 VCam可通过编辑器工具Camera Area Setup一键创建。")]
[SerializeField] private CinemachineCamera _dedicatedCamera; [SerializeField] private CinemachineCamera _dedicatedCamera;
[Tooltip("专 VCam 激活时使用的优先级,须高于全局 VCam 的 _globalActivePriority默认 10。")] [Tooltip("专 VCam 激活时使用的优先级,默认 20。")]
[SerializeField] private int _dedicatedPriority = 20; [SerializeField] private int _dedicatedPriority = 20;
// ── 公开属性 ────────────────────────────────────────────────────────── // ── 公开属性 ──────────────────────────────────────────────────────────
public PolygonCollider2D ConfinerCollider => _confinerCollider; public BoxCollider ConfinerCollider => _confinerCollider;
public CameraLensConfigSO LensConfig => _lensConfig; public CameraLensConfigSO LensConfig => _lensConfig;
public float LastSyncFOV => _lastSyncFOV; public float LastSyncFOV => _lastSyncFOV;
public CameraBlendProfileSO BlendProfile => _blendProfile; public CameraBlendProfileSO BlendProfile => _blendProfile;
@@ -129,12 +155,17 @@ namespace BaseGames.Camera
public float LookaheadTime => _lookaheadTime; public float LookaheadTime => _lookaheadTime;
public float LookaheadSmoothing => _lookaheadSmoothing; public float LookaheadSmoothing => _lookaheadSmoothing;
public bool DisableFallBias => _disableFallBias; public bool DisableFallBias => _disableFallBias;
public bool OverrideFacingBias => _overrideFacingBias;
public float FacingBiasOverride => _facingBiasOverride;
public bool LockHorizontal => _lockHorizontal; public bool LockHorizontal => _lockHorizontal;
public bool LockVertical => _lockVertical; public bool LockVertical => _lockVertical;
public float DampingDown => _dampingDown; public float DampingDown => _dampingDown;
public float DampingUp => _dampingUp; public float DampingUp => _dampingUp;
public float LensSize => _lensSize; public float LensSize => _lensSize;
public float LensSizeDuration => _lensSizeDuration; public float LensSizeDuration => _lensSizeDuration;
public NoiseSettings NoiseProfile => _noiseProfile;
public float NoiseAmplitude => _noiseAmplitude;
public float NoiseFrequency => _noiseFrequency;
/// <summary> /// <summary>
/// 摄像机到场景平面的有效深度(用于透视视口换算)。 /// 摄像机到场景平面的有效深度(用于透视视口换算)。
@@ -150,6 +181,16 @@ namespace BaseGames.Camera
} }
} }
// ── Lifecycle ────────────────────────────────────────────────────────
private void Awake()
{
// 确保专属 VCam 启动时 Priority=0无论 Inspector 保存了什么值。
// CameraStateController.ActivateDedicated 会在进入区域时将其提升到 DedicatedPriority。
if (_dedicatedCamera != null)
_dedicatedCamera.Priority = 0;
}
// ── Gizmo ──────────────────────────────────────────────────────────── // ── Gizmo ────────────────────────────────────────────────────────────
private void OnDrawGizmosSelected() private void OnDrawGizmosSelected()

View File

@@ -34,10 +34,18 @@ namespace BaseGames.Camera
{ {
if (stage != CinemachineCore.Stage.Body) return; if (stage != CinemachineCore.Stage.Body) return;
var pos = state.RawPosition; // 通过覆写 PositionCorrection 而非 RawPosition 来锁定轴向。
if (LockX) pos.x = LockedX; // 最终相机位置 = RawPosition + PositionCorrection。
if (LockY) pos.y = LockedY; // 只修改 PositionCorrection 可确保:
state.RawPosition = pos; // - 锁定轴final = RawPos + (LockedVal - RawPos) = LockedVal无论 Confiner 之前写入了什么修正量
// - 非锁定轴:保留 CinemachineConfiner3D 已计算好的 PositionCorrection限位正常生效
// 若改为修改 RawPosition在 Confiner 之后运行时会导致
// final = LockedVal + ConfinementCorrection ≠ LockedVal使 Confiner 修正量错误叠加。
var rawPos = state.RawPosition;
var correction = state.PositionCorrection;
if (LockX) correction.x = LockedX - rawPos.x;
if (LockY) correction.y = LockedY - rawPos.y;
state.PositionCorrection = correction;
} }
} }
} }

View File

@@ -0,0 +1,164 @@
using UnityEngine;
using Unity.Cinemachine;
namespace BaseGames.Camera
{
/// <summary>
/// 方向感知水平偏置扩展Facing-aware Horizontal Bias
///
/// 持续估算跟随目标的水平移动速度,动态将相机 X 轴向玩家前进方向偏移,
/// 使玩家始终出现在画面偏后侧,前方预留更多可见视野。
///
/// <para>方向逻辑:</para>
/// <list type="bullet">
/// <item>向右移动 → 相机向右偏置 → 玩家出现在屏幕左侧 → 右方视野增加。</item>
/// <item>向左移动 → 相机向左偏置 → 玩家出现在屏幕右侧 → 左方视野增加。</item>
/// <item>静止或速度低于阈值 → 保持当前偏置方向,不回中(避免静止时镜头漂回)。</item>
/// </list>
///
/// <para>
/// 注意:本扩展通过直接修改 <c>state.RawPosition.x</c> 在 Body 阶段之后叠加偏置,
/// 不与 <see cref="CinemachinePositionComposer"/> 的 ScreenPosition.x 冲突;
/// 但偏置量会占用 Confiner 的裁剪空间,在较窄区域中建议通过
/// <see cref="CameraArea._facingBiasOverride"/> 设为较小值或 0 以防止越界。
/// </para>
///
/// <para>挂载顺序(扩展执行顺序由组件顺序决定):</para>
/// <list type="number">
/// <item><see cref="CameraAsymmetricDampingExtension"/> — Y 轴非对称阻尼</item>
/// <item><see cref="CameraFallBiasExtension"/> — 下坠 Y 偏置</item>
/// <item><b>本扩展CameraFacingBiasExtension</b> — X 轴方向偏置</item>
/// <item><c>CinemachineConfiner3D</c> — 最后裁剪至限位边界</item>
/// </list>
/// </summary>
[AddComponentMenu("Cinemachine/Extensions/Camera Facing Bias")]
[DisallowMultipleComponent]
public class CameraFacingBiasExtension : CinemachineExtension
{
[Tooltip("相机沿面朝方向的偏移量(世界单位)。\n" +
"玩家向右时相机向右偏此值,使玩家出现在画面左侧,前方(右侧)视野增加;反之亦然。\n" +
"0 = 禁用。推荐 1.5~2.5,视房间宽度和视口大小而定。")]
[Range(0f, 6f)]
[SerializeField] private float _facingBias = 2f;
[Tooltip("偏置方向切换时的过渡时长(秒)。越大越平滑但延迟越长。\n" +
"推荐 0.3~0.5。")]
[Min(0f)]
[SerializeField] private float _transitionTime = 0.4f;
[Tooltip("触发方向切换所需的最小水平速度(世界单位/秒)。\n" +
"低于此值时不更新目标偏置,防止静止时微小输入造成镜头反复横跳。\n" +
"推荐 1.0~2.0。")]
[Min(0f)]
[SerializeField] private float _directionThreshold = 1.5f;
[Tooltip("水平速度估算的平滑强度(越大响应越快)。推荐 6~10。")]
[Min(0.1f)]
[SerializeField] private float _velocitySmoothing = 8f;
// ── 内部状态 ──────────────────────────────────────────────────────────
private float _currentBiasX; // 当前实际偏置(世界单位,已指数平滑)
private float _targetBiasX; // 当前目标偏置
private float _estimatedVX; // 平滑后的水平速度估算(世界单位/秒)
private float _lastFollowX;
private bool _initialized;
private int _externalFacing = 0; // 0 = 未设置(回退到速度估算);+1 = 朝右;-1 = 朝左
// ── 公开 API ──────────────────────────────────────────────────────────
/// <summary>
/// 供 <see cref="CameraStateController"/> 运行时按区域写入偏置量。
/// 0 = 禁用此区域的方向偏置。
/// </summary>
public float FacingBias { get => _facingBias; set => _facingBias = value; }
/// <summary>
/// 由 PlayerController 在精灵翻转时直接注入面朝方向,使偏置立即响应角色转向。
/// direction: +1 = 朝右,-1 = 朝左0 = 清除外部输入(回退到速度估算)。
/// 外部常量优先于速度估算,即使玩家缓慢行走或原地站立翻转,相机也能立即更新。
/// </summary>
public void SetExternalFacing(int direction)
{
_externalFacing = Mathf.Clamp(direction, -1, 1);
}
/// <summary>
/// 重置内部状态。在相机硬切instantCut时由 CameraStateController 调用,
/// 防止旧房间的方向偏置和速度历史带入新房间。
/// </summary>
public void ResetState()
{
_initialized = false;
_estimatedVX = 0f;
// 硬切时直接吸附到当前目标值,不保留旧房间的过渡惯性
_currentBiasX = _targetBiasX;
}
// ── Cinemachine Extension ─────────────────────────────────────────────
protected override void PostPipelineStageCallback(
CinemachineVirtualCameraBase vcam,
CinemachineCore.Stage stage,
ref CameraState state,
float deltaTime)
{
// 在 Body 阶段之后Composer 已计算出位置)叠加水平偏置
if (stage != CinemachineCore.Stage.Body) return;
// 编辑器预览 / 初始帧:重置平滑器
if (deltaTime <= 0f)
{
_initialized = false;
return;
}
// 偏置量为 0 时跳过,不修改位置
if (_facingBias <= 0f) return;
Transform follow = vcam.Follow;
if (follow == null) return;
// ── 初始化:第一帧直接吸附,不做过渡动画 ────────────────────────
if (!_initialized)
{
_lastFollowX = follow.position.x;
_initialized = true;
_currentBiasX = _targetBiasX;
return;
}
// ── 估算水平速度(帧率无关指数平滑)────────────────────────────
float rawVX = (follow.position.x - _lastFollowX) / deltaTime;
_lastFollowX = follow.position.x;
_estimatedVX = Mathf.Lerp(_estimatedVX, rawVX, 1f - Mathf.Exp(-deltaTime * _velocitySmoothing));
// ── 方向判定(带死区,静止时保持当前目标方向)──────────────────
// 优先使用外部注入朝向PlayerController.OnFlip 传入);
// 未设置时回退到速度估算(却保留静止不回中行为)。
if (_externalFacing != 0)
{
_targetBiasX = _externalFacing * _facingBias;
}
else
{
if (_estimatedVX > _directionThreshold)
_targetBiasX = +_facingBias; // 向右 → 相机右偏 → 玩家在左,前方(右侧)视野增
else if (_estimatedVX < -_directionThreshold)
_targetBiasX = -_facingBias; // 向左 → 相机左偏 → 玩家在右,前方(左侧)视野增
// else: 速度不足,保持 _targetBiasX 不变(不回中)
}
// ── 指数平滑过渡 ─────────────────────────────────────────────────
float t = _transitionTime > 0f
? 1f - Mathf.Exp(-deltaTime / _transitionTime)
: 1f;
_currentBiasX = Mathf.Lerp(_currentBiasX, _targetBiasX, t);
// ── 叠加到 Composer 已计算的位置上 ──────────────────────────────
// 注意:此偏置在 CinemachineConfiner3D 之前施加,
// Confiner 会在之后将结果裁剪回限位边界内。
var pos = state.RawPosition;
pos.x += _currentBiasX;
state.RawPosition = pos;
}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 38830e1649a9eb548b20540a737bdf09 guid: db8aa13bc9ef2124d8512b64b173d768
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@@ -15,7 +15,7 @@ namespace BaseGames.Camera
/// <list type="number"> /// <list type="number">
/// <item><see cref="CameraAsymmetricDampingExtension"/> — 先对 Y 轴做非对称阻尼平滑;</item> /// <item><see cref="CameraAsymmetricDampingExtension"/> — 先对 Y 轴做非对称阻尼平滑;</item>
/// <item><b>本扩展CameraFallBiasExtension</b> — 将偏置叠加到平滑后的 Y 上;</item> /// <item><b>本扩展CameraFallBiasExtension</b> — 将偏置叠加到平滑后的 Y 上;</item>
/// <item><c>CinemachineConfiner2D</c> — 最后将偏置后的位置裁剪回限位边界内。</item> /// <item><c>CinemachineConfiner3D</c> — 最后将偏置后的位置裁剪回限位边界内。</item>
/// </list> /// </list>
/// <para>如果顺序错误(本扩展在 Confiner 之后),偏置会导致相机超出限位边界且不被修正。</para> /// <para>如果顺序错误(本扩展在 Confiner 之后),偏置会导致相机超出限位边界且不被修正。</para>
/// </summary> /// </summary>
@@ -98,10 +98,10 @@ namespace BaseGames.Camera
return; return;
} }
// ── 估算玩家 Y 轴速度(负值 = 下落)──────────────────────────── // ── 估算玩家 Y 轴速度(帧率无关指数平滑)─────────────────────
float rawVY = (follow.position.y - _lastFollowY) / deltaTime; float rawVY = (follow.position.y - _lastFollowY) / deltaTime;
_lastFollowY = follow.position.y; _lastFollowY = follow.position.y;
_smoothedVY = Mathf.Lerp(_smoothedVY, rawVY, deltaTime * 10f); _smoothedVY = Mathf.Lerp(_smoothedVY, rawVY, 1f - Mathf.Exp(-deltaTime * 10f));
bool isFalling = _smoothedVY < -_fallSpeedThreshold; bool isFalling = _smoothedVY < -_fallSpeedThreshold;
@@ -115,7 +115,7 @@ namespace BaseGames.Camera
// 超过 activationDelay 后线性增加偏置0.4s 达到最大 // 超过 activationDelay 后线性增加偏置0.4s 达到最大
float effectiveMax = _configuredMaxShift >= 0f ? _configuredMaxShift : _maxShift; float effectiveMax = _configuredMaxShift >= 0f ? _configuredMaxShift : _maxShift;
float ratio = Mathf.Clamp01((_fallTimer - _activationDelay) / 0.4f); float ratio = Mathf.Clamp01((_fallTimer - _activationDelay) / 0.4f);
float targetShift = -effectiveMax * ratio; // 负:相机向下 float targetShift = -effectiveMax * ratio; // 负:相机向下
// 使用指数衰减公式(帧率无关)替代 Lerp*deltaTime // 使用指数衰减公式(帧率无关)替代 Lerp*deltaTime
float dampingTime = targetShift < _currentShift float dampingTime = targetShift < _currentShift

View File

@@ -3,12 +3,10 @@ using UnityEngine;
namespace BaseGames.Camera namespace BaseGames.Camera
{ {
/// <summary> /// <summary>
/// 全局相机镜头配置。 /// 相机镜头配置。
/// ///
/// 作为 <see cref="CameraStateController"/> 和各 <see cref="CameraArea"/> 之间的 /// 作为 <see cref="CameraStateController"/> 和各 <see cref="CameraArea"/> 之间的
/// 单一参数来源: /// 单一参数来源:
/// - Persistent 场景的 <see cref="CameraStateController"/> 在 Awake 时将
/// <see cref="fieldOfView"/> 写入两台全局 VCam 的 Lens。
/// - Room 场景的 <see cref="CameraArea"/> 引用同一 SO编辑器工具在计算限位多边形 /// - Room 场景的 <see cref="CameraArea"/> 引用同一 SO编辑器工具在计算限位多边形
/// 时直接读取,无需依赖 Persistent 场景是否已加载。 /// 时直接读取,无需依赖 Persistent 场景是否已加载。
/// ///
@@ -21,15 +19,14 @@ namespace BaseGames.Camera
[CreateAssetMenu(menuName = "BaseGames/Camera/Lens Config", fileName = "CameraLensConfig")] [CreateAssetMenu(menuName = "BaseGames/Camera/Lens Config", fileName = "CameraLensConfig")]
public class CameraLensConfigSO : ScriptableObject public class CameraLensConfigSO : ScriptableObject
{ {
[Tooltip("全局虚拟相机的垂直 FOV。\n" + [Tooltip("虚拟相机的垂直 FOV。\n" +
"修改此后,编辑器会自动对所有已打开场景中的 CameraArea 重新同步限位多边形。\n" + "修改此后,编辑器会自动对所有已打开场景中的 CameraArea 重新同步限位多边形。")]
"运行时由 CameraStateController 在 Awake 时应用到全局 VCam。")]
[Range(1f, 179f)] [Range(1f, 179f)]
public float fieldOfView = 60f; public float fieldOfView = 60f;
[Tooltip("摄像机到场景平面Z = 0的垂直距离世界单位。\n" + [Tooltip("摄像机到场景平面Z = 0的垂直距离世界单位。\n" +
"与 fieldOfView 共同决定透视相机的视口尺寸,\n" + "与 fieldOfView 共同决定透视相机的视口尺寸,\n" +
"用于将可视区域VisibleBounds换算为 CinemachineConfiner2D 限位多边形。\n" + "用于将可视区域VisibleBounds换算为 CinemachineConfiner3D 限位体积。\n" +
"推荐与 Persistent 场景中相机 Transform 的 |Z| 保持一致(通常为 10。\n" + "推荐与 Persistent 场景中相机 Transform 的 |Z| 保持一致(通常为 10。\n" +
"CameraArea._cameraDepth > 0 时以区域专有值优先覆盖此全局值。")] "CameraArea._cameraDepth > 0 时以区域专有值优先覆盖此全局值。")]
[Min(0.1f)] [Min(0.1f)]

View File

@@ -150,7 +150,7 @@ namespace BaseGames.Camera
if (dt > 0f) if (dt > 0f)
{ {
float rawSpeed = (_baseTarget.position - _lastBasePosition).magnitude / dt; float rawSpeed = (_baseTarget.position - _lastBasePosition).magnitude / dt;
_estimatedSpeed = Mathf.Lerp(_estimatedSpeed, rawSpeed, dt * 8f); _estimatedSpeed = Mathf.Lerp(_estimatedSpeed, rawSpeed, 1f - Mathf.Exp(-dt * 8f));
} }
_lastBasePosition = _baseTarget.position; _lastBasePosition = _baseTarget.position;
@@ -170,7 +170,7 @@ namespace BaseGames.Camera
} }
float speedV = Mathf.Abs(_targetOffsetY) < 0.01f ? _resetSpeedV : _lookSpeedV; float speedV = Mathf.Abs(_targetOffsetY) < 0.01f ? _resetSpeedV : _lookSpeedV;
_currentOffsetY = Mathf.Lerp(_currentOffsetY, _targetOffsetY, dt * speedV); _currentOffsetY = Mathf.Lerp(_currentOffsetY, _targetOffsetY, 1f - Mathf.Exp(-dt * speedV));
// ── 水平窥视 ────────────────────────────────────────────────────── // ── 水平窥视 ──────────────────────────────────────────────────────
if (withinGate && Mathf.Abs(_inputX) > 0.5f) if (withinGate && Mathf.Abs(_inputX) > 0.5f)
@@ -186,7 +186,7 @@ namespace BaseGames.Camera
} }
float speedH = Mathf.Abs(_targetOffsetX) < 0.01f ? _resetSpeedH : _lookSpeedH; float speedH = Mathf.Abs(_targetOffsetX) < 0.01f ? _resetSpeedH : _lookSpeedH;
_currentOffsetX = Mathf.Lerp(_currentOffsetX, _targetOffsetX, dt * speedH); _currentOffsetX = Mathf.Lerp(_currentOffsetX, _targetOffsetX, 1f - Mathf.Exp(-dt * speedH));
// ── 虚拟目标 = 玩家位置 + 双轴偏移 ──────────────────────────────── // ── 虚拟目标 = 玩家位置 + 双轴偏移 ────────────────────────────────
_virtualTargetTransform.position = _baseTarget.position _virtualTargetTransform.position = _baseTarget.position

View File

@@ -0,0 +1,114 @@
using UnityEngine;
using Unity.Cinemachine;
namespace BaseGames.Camera
{
/// <summary>
/// 相机像素对齐Pixel-perfect Snapping
///
/// 每帧在 CinemachineBrain LateUpdate 完成后,将 Unity 主相机的世界坐标
/// 四舍五入到最近的像素网格消除像素艺术精灵的亚像素抖动sub-pixel jitter
///
/// <para>
/// 挂载位置Persistent 场景中与 <see cref="CinemachineBrain"/> 同一 GameObject[Camera] 节点)。
/// </para>
/// <para>
/// 执行顺序:+1000确保在 CinemachineBrain默认 -1000的 LateUpdate 之后运行。
/// 直接修改 Unity Camera 的 <c>transform.position</c>,不影响 Cinemachine 内部状态。
/// </para>
/// <para>
/// 混合进行中时可选择暂停对齐(<see cref="_disableDuringBlend"/> = true
/// 避免两台 VCam 插值结果在整数像素间跳变,产生阶梯感。
/// </para>
/// </summary>
[DefaultExecutionOrder(1000)]
[AddComponentMenu("BaseGames/Camera/Camera Pixel Snapper")]
[RequireComponent(typeof(UnityEngine.Camera))]
public class CameraPixelSnapper : MonoBehaviour
{
[Tooltip("每世界单位的像素数PPU。须与项目精灵的 Pixels Per Unit 设置一致。\n" +
"0 = 禁用像素对齐。常用值16粗像素、32标准、100高分辨率。")]
[Min(0f)]
[SerializeField] private float _pixelsPerUnit = 16f;
[Tooltip("相机混合动画Blend进行中时暂停像素对齐待混合结束后恢复。\n" +
"开启时混合动画更平滑(无阶梯感);关闭时混合期间也精确对齐但可能有轻微跳帧。\n" +
"推荐保持开启。")]
[SerializeField] private bool _disableDuringBlend = true;
[SerializeField] private CinemachineBrain _brain;
private UnityEngine.Camera _camera;
private void Reset()
{
_brain = GetComponent<CinemachineBrain>();
_camera = GetComponent<UnityEngine.Camera>();
}
private void Awake()
{
_camera = GetComponent<UnityEngine.Camera>();
}
private void LateUpdate()
{
if (_pixelsPerUnit <= 0f || _camera == null) return;
// 混合进行中时跳过,避免插值位置在像素网格间产生阶梯感
if (_disableDuringBlend && _brain != null && _brain.IsBlending) return;
float ppu = _pixelsPerUnit;
Vector3 pos = _camera.transform.position;
pos.x = Mathf.Round(pos.x * ppu) / ppu;
pos.y = Mathf.Round(pos.y * ppu) / ppu;
// Z 轴保持不变(透视深度不需要对齐)
// 像素取整可能将相机推出 Confiner 边界(最多 0.5/PPU 的微小超出)。
// 对取整后的位置施加限位矩形钳制,确保不超出当前激活区域的 Confiner 边界。
if (TryGetActiveConfinerBounds(out Bounds confinerBounds))
{
pos.x = Mathf.Clamp(pos.x, confinerBounds.min.x, confinerBounds.max.x);
pos.y = Mathf.Clamp(pos.y, confinerBounds.min.y, confinerBounds.max.y);
}
_camera.transform.position = pos;
}
/// <summary>
/// 获取当前活跃 VCam 的 CinemachineConfiner3D 边界盒(世界空间 AABB
/// 用于在像素取整后将相机钳制回限位区域内。
/// </summary>
private bool TryGetActiveConfinerBounds(out Bounds bounds)
{
bounds = default;
if (_brain == null) return false;
var vcam = _brain.ActiveVirtualCamera as CinemachineCamera;
if (vcam == null) return false;
var confiner = vcam.GetComponent<CinemachineConfiner3D>();
if (confiner == null || !confiner.IsValid) return false;
bounds = confiner.BoundingVolume.bounds;
return true;
}
#if UNITY_EDITOR
/// <summary>编辑器模式下实时预览对齐效果。</summary>
private void OnDrawGizmosSelected()
{
if (_pixelsPerUnit <= 0f || _camera == null) return;
float ppu = _pixelsPerUnit;
float cellW = 1f / ppu;
// 在 Scene 视图中围绕相机位置绘制 5×5 像素网格示意(仅辅助调试用)
Gizmos.color = new Color(0.6f, 1f, 0.6f, 0.4f);
Vector3 origin = _camera.transform.position;
origin.x = Mathf.Floor(origin.x / cellW) * cellW;
origin.y = Mathf.Floor(origin.y / cellW) * cellW;
for (int ix = -2; ix <= 3; ix++)
for (int iy = -2; iy <= 3; iy++)
Gizmos.DrawWireCube(
new Vector3(origin.x + ix * cellW, origin.y + iy * cellW, 0f),
new Vector3(cellW, cellW, 0.01f));
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3c2869ceeab4c184180a029bfc710cd0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,3 +1,4 @@
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using UnityEngine; using UnityEngine;
@@ -10,14 +11,9 @@ namespace BaseGames.Camera
/// <summary> /// <summary>
/// 相机状态单例控制器。须放置在 Persistent 场景中。 /// 相机状态单例控制器。须放置在 Persistent 场景中。
/// ///
/// 支持两种相机切换模式: /// 每个 <see cref="CameraArea"/> 均拥有自己专属的 <c>DedicatedCamera</c>
/// 1. 全局双 VCam 模式(推荐):<see cref="SwitchArea"/> /// 进入区域时调用 <see cref="ActivateDedicated"/> 激活对应 VCam
/// 两台全局 CinemachineCamera<c>_vcamA</c> / <c>_vcamB</c>)交替承接各区域, /// Cinemachine Brain 自动处理混合过渡。
/// 通过优先级 ping-pong 触发 Cinemachine 混合过渡。场景内无需每个区域都放置 VCam。
///
/// 2. 专有 VCam 模式:<see cref="SwitchArea"/>(区域含 dedicatedCamera 时自动使用)
/// 激活该区域专属的 CinemachineCamera优先级 > 全局 VCam
/// 适用于需要独特相机参数的特殊区域。
/// </summary> /// </summary>
[DefaultExecutionOrder(-100)] [DefaultExecutionOrder(-100)]
public class CameraStateController : MonoBehaviour, ICameraService public class CameraStateController : MonoBehaviour, ICameraService
@@ -27,43 +23,28 @@ namespace BaseGames.Camera
[SerializeField] private CinemachineImpulseSource _impulseSource; [SerializeField] private CinemachineImpulseSource _impulseSource;
[SerializeField] private CameraLookSystem _lookSystem; [SerializeField] private CameraLookSystem _lookSystem;
[Header("全局双 VCamPersistent 场景中放置两台通用虚拟相机)")]
[Tooltip("两台 VCam 交替承接各相机区域,通过优先级 ping-pong 触发混合过渡。\n" +
"须各自挂载 CinemachineCamera + CinemachineConfiner2D\n" +
"Follow 指向 Player/CameraFollowTarget或运行时调用 SetFollowTarget 赋值)。")]
[SerializeField] private CinemachineCamera _vcamA;
[SerializeField] private CinemachineCamera _vcamB;
[Tooltip("全局 VCam 激活时的优先级。专有 VCam 的 _dedicatedPriority 须高于此值。")]
[SerializeField] private int _globalActivePriority = 10;
[Tooltip("待机 VCam 的优先级。\n" +
"Cinemachine 3.x 中 Priority = 0 的 VCam 不会被 Brain 选中,导致主相机停止跟随。\n" +
"必须 > 0 且 < _globalActivePriority确保 Brain 始终有可用 VCam\n" +
"同时切换时两台 VCam 均在 Brain 视野内以完成正确的混合过渡。")]
[SerializeField] private int _standbyPriority = 1;
[Header("默认混合配置")] [Header("默认混合配置")]
[SerializeField] private CameraBlendProfileSO _defaultBlendProfile; [SerializeField] private CameraBlendProfileSO _defaultBlendProfile;
[Header("镜头配置")] [Header("镜头配置")]
[Tooltip("全局镜头参数 SO。Awake 时将 fieldOfView 应用到 _vcamA / _vcamB。\n" + [Tooltip("相机镜头参数 SO,提供 FOV 与相机深度。\n" +
"与各 CameraArea 引用同一资产,确保 FOV 参数一致。")] "与各 CameraArea 引用同一资产,确保 SetLensSize 换算结果与 VCam 配置一致。")]
[SerializeField] private CameraLensConfigSO _lensConfig; [SerializeField] private CameraLensConfigSO _lensConfig;
[Header("玩家跟随")] [Header("玩家跟随")]
[Tooltip("PlayerController 生成时广播的事件频道EVT_PlayerSpawned。\n" + [Tooltip("PlayerController 生成时广播的事件频道EVT_PlayerSpawned。\n" +
"收到后自动查找 CameraFollowTarget 子节点并赋值给两台全局 VCam Follow。")] "收到后自动查找 CameraFollowTarget 子节点并作为 VCam Follow 赋值。")]
[SerializeField] private TransformEventChannelSO _onPlayerSpawned; [SerializeField] private TransformEventChannelSO _onPlayerSpawned;
// ── 状态 ────────────────────────────────────────────────────────────── // ── 状态 ──────────────────────────────────────────────────────────────
private int _activeSlot = -1; // -1 = 未初始化0 = A1 = B
private CameraArea _roomBaselineArea; // SwitchArea(priority=0) 写入的房间基线,不被触发事件删除 private CameraArea _roomBaselineArea; // SwitchArea(priority=0) 写入的房间基线,不被触发事件删除
private readonly List<(CameraArea area, int priority)> _activeZones = new(); // 玩家当前所在的触发区域集合priority>0 private readonly List<(CameraArea area, int priority)> _activeZones = new(); // 玩家当前所在的触发区域集合priority>0
private CameraArea _currentArea; private CameraArea _currentArea;
private CinemachineCamera _activeDedicatedCam; private CinemachineCamera _activeDedicatedCam;
private CinemachineConfiner2D _confinerA; private CinemachineCamera _cutsceneCamera; // 过场模式专用高优先级 VCam
private CinemachineConfiner2D _confinerB; private const int CutscenePriority = 100; // 高于专有区域 VCam默认 20
private int _lastExternalFacing = 0; // 最近一次 SetPlayerFacing 的值,用于新激活 VCam 的初始化
private Transform _currentFollowTarget; // 最后一次 SetFollowTarget 设置的目标,激活 VCam 时自动同步 private Transform _currentFollowTarget; // 最后一次 SetFollowTarget 设置的目标,激活 VCam 时自动同步
private readonly CompositeDisposable _subs = new(); private readonly CompositeDisposable _subs = new();
@@ -74,17 +55,14 @@ namespace BaseGames.Camera
if (ServiceLocator.GetOrDefault<ICameraService>() != null) { Destroy(gameObject); return; } if (ServiceLocator.GetOrDefault<ICameraService>() != null) { Destroy(gameObject); return; }
ServiceLocator.Register<ICameraService>(this); ServiceLocator.Register<ICameraService>(this);
// 缓存 Confiner 引用 // 重置运行时状态,防止禁用 Domain Reload 时上一次 Play Mode 的数据残留。
if (_vcamA != null) _confinerA = _vcamA.GetComponent<CinemachineConfiner2D>(); // 非序列化字段在 Domain Reload 禁用时不会自动清零。
if (_vcamB != null) _confinerB = _vcamB.GetComponent<CinemachineConfiner2D>(); _roomBaselineArea = null;
_activeZones.Clear();
// 初始两台 VCam 均处于待机优先级(> 0 _currentArea = null;
// Cinemachine 3.x 中 Priority = 0 的 VCam 不被 Brain 选中,主相机会停止运动 _activeDedicatedCam = null;
if (_vcamA != null) _vcamA.Priority = _standbyPriority; _lastExternalFacing = 0;
if (_vcamB != null) _vcamB.Priority = _standbyPriority; _currentFollowTarget = null;
// 将 SO 中的 FOV 应用到两台全局 VCam
ApplyLensConfig();
// 订阅 PlayerSpawned 事件,运行时自动为 VCam 赋值 Follow // 订阅 PlayerSpawned 事件,运行时自动为 VCam 赋值 Follow
_onPlayerSpawned?.Subscribe(OnPlayerSpawned).AddTo(_subs); _onPlayerSpawned?.Subscribe(OnPlayerSpawned).AddTo(_subs);
@@ -103,35 +81,18 @@ namespace BaseGames.Camera
SetFollowTarget(follow); SetFollowTarget(follow);
} }
private void ApplyLensConfig() private void Start()
{ {
if (_lensConfig == null) return; // 场景启动时扫描全部 VCam提前暴露组件顺序错误
float fov = _lensConfig.fieldOfView; // 无需等待各区域被激活才触发检测。
float depth = _lensConfig.cameraDepth; var allVCams = FindObjectsByType<CinemachineCamera>(FindObjectsSortMode.None);
ApplyLensToVcam(_vcamA, fov, depth); foreach (var vcam in allVCams)
ApplyLensToVcam(_vcamB, fov, depth);
}
private static void ApplyLensToVcam(CinemachineCamera vcam, float fov, float depth)
{ {
if (vcam == null) return; var confiner = vcam.GetComponent<CinemachineConfiner3D>();
var lens = vcam.Lens; if (confiner != null)
lens.FieldOfView = fov; ValidateVCamExtensionOrder(vcam, confiner);
vcam.Lens = lens; }
// CinemachinePositionComposer.CameraDistance 是运行时真正控制 Z 距离的属性,
// 必须同步,否则 Transform Z 被 Cinemachine Pipeline 覆盖
var composer = vcam.GetComponent<CinemachinePositionComposer>();
if (composer != null)
composer.CameraDistance = depth;
// 同步 Transform Z保证编辑器预览与运行时一致
var pos = vcam.transform.position;
pos.z = -depth;
vcam.transform.position = pos;
} }
#if UNITY_EDITOR
private void OnValidate() => ApplyLensConfig();
#endif
// ── 公开 API ────────────────────────────────────────────────────────── // ── 公开 API ──────────────────────────────────────────────────────────
@@ -156,7 +117,7 @@ namespace BaseGames.Camera
_activeZones.RemoveAll(e => e.area == area); _activeZones.RemoveAll(e => e.area == area);
_activeZones.Add((area, priority)); _activeZones.Add((area, priority));
// 仅当此区域是当前最优且尚未激活时才切换,避免不必要的 ping-pong // 仅当此区域是当前最优且尚未激活时才切换
CameraArea best = GetEffectiveArea(); CameraArea best = GetEffectiveArea();
if (best == area && area != _currentArea) if (best == area && area != _currentArea)
ActivateArea(area, instantCut); ActivateArea(area, instantCut);
@@ -172,7 +133,8 @@ namespace BaseGames.Camera
bool wasActive = releasedArea == _currentArea; bool wasActive = releasedArea == _currentArea;
int removed = _activeZones.RemoveAll(e => e.area == releasedArea); int removed = _activeZones.RemoveAll(e => e.area == releasedArea);
if (removed == 0) return; // 若区域本就不在栈中,且又不是当前激活区,则无需任何操作
if (removed == 0 && !wasActive) return;
if (!wasActive) return; if (!wasActive) return;
@@ -209,10 +171,10 @@ namespace BaseGames.Camera
Time = 0f, Time = 0f,
}; };
// 重置窥视偏移,避免旧房间的窥视状态残留 // 重置窥视偏移,避免旧房间的窥视状态残留
_lookSystem?.ResetOffsets(snap: true); // 重置所有 VCam 扩展的内部状态,防止旧房间的速度/阻尼估算带入新房间 _lookSystem?.ResetOffsets(snap: true);
ResetVCamExtensions(_vcamA); // 重置专属 VCam 扩展的内部状态,防止旧房间的速度/阻尼估算带入新房间
ResetVCamExtensions(_vcamB); if (area.HasDedicated) ResetVCamExtensions(area.DedicatedCamera);
if (area.HasDedicated) ResetVCamExtensions(area.DedicatedCamera); } }
else else
{ {
ApplyBlendProfile(area.BlendProfile ?? _defaultBlendProfile); ApplyBlendProfile(area.BlendProfile ?? _defaultBlendProfile);
@@ -224,12 +186,12 @@ namespace BaseGames.Camera
if (area.HasDedicated) if (area.HasDedicated)
ActivateDedicated(area); ActivateDedicated(area);
else else
ActivateGlobalSlot(area); Debug.LogError($"[CameraStateController] {area.name} 缺少专属 VCam请通过 Camera Area Setup 工具为此区域创建 DedicatedCamera。");
} }
/// <summary> /// <summary>
/// 运行时为全局双 VCam 设置跟随目标。 /// 运行时设置跟随目标。
/// 若存在 <see cref="CameraLookSystem"/>VCam 跟随系统输出的虚拟目标(含窥视偏移)。 /// 若存在 <see cref="CameraLookSystem"/>VCam 跟随系统输出的虚拟目标(含窗斥偏移)。
/// </summary> /// </summary>
public void SetFollowTarget(Transform followTarget) public void SetFollowTarget(Transform followTarget)
{ {
@@ -239,9 +201,8 @@ namespace BaseGames.Camera
_lookSystem.SetBaseTarget(followTarget); _lookSystem.SetBaseTarget(followTarget);
actual = _lookSystem.VirtualTarget; actual = _lookSystem.VirtualTarget;
} }
_currentFollowTarget = actual; // 缓存供后续激活 VCam 时同步 _currentFollowTarget = actual;
if (_vcamA != null) _vcamA.Follow = actual; SyncFollowToVCam(_activeDedicatedCam); // 立即同步到当前活跃专有 VCam
if (_vcamB != null) _vcamB.Follow = actual;
} }
/// <summary>触发屏幕抖动。</summary> /// <summary>触发屏幕抖动。</summary>
@@ -255,30 +216,72 @@ namespace BaseGames.Camera
=> TriggerImpulse(Vector3.down * strength); => TriggerImpulse(Vector3.down * strength);
/// <summary> /// <summary>
/// 平滑过渡正交相机尺寸。<paramref name="duration"/> = 0 时瞬间切换。 /// 平滑过渡视野尺寸(可视半高,世界单位)。<paramref name="duration"/> = 0 时瞬间切换。
/// 透视相机下自动换算为 FOV语义等价于正交相机 OrthographicSize。
/// 区域进入时由 <see cref="CameraArea"/> 自动调用;游戏代码也可直接调用。 /// 区域进入时由 <see cref="CameraArea"/> 自动调用;游戏代码也可直接调用。
/// </summary> /// </summary>
public void SetLensSize(float orthographicSize, float duration = 0f) public void SetLensSize(float visibleHalfHeight, float duration = 0f)
{ {
if (_lensCoroutine != null) StopCoroutine(_lensCoroutine); if (_lensCoroutine != null) StopCoroutine(_lensCoroutine);
if (duration <= 0f) { ApplyLensSizeToAll(orthographicSize); return; } if (duration <= 0f) { ApplyLensSizeToAll(visibleHalfHeight); return; }
_lensCoroutine = StartCoroutine(LensSizeCo(orthographicSize, duration)); _lensCoroutine = StartCoroutine(LensSizeCo(visibleHalfHeight, duration));
} }
/// <summary>
/// 进入过场模式,将指定 VCam 提升至最高优先级Brain 自动混合到它。
/// <para>
/// 过场 VCam 由设计者在 Inspector 中预先配置位置、Follow、LookAt、Lens 等);
/// 此方法不强制覆写 Follow保留 Inspector 配置不变。
/// </para>
/// <para>适用场景Boss 登场固定镜头、对话拉近、剧情事件全景等。</para>
/// </summary>
public void EnterCutsceneMode(CinemachineCamera cutsceneCamera)
{
if (cutsceneCamera == null) return;
if (_cutsceneCamera != null && _cutsceneCamera != cutsceneCamera)
_cutsceneCamera.Priority = 0;
_cutsceneCamera = cutsceneCamera;
_cutsceneCamera.Priority = CutscenePriority;
}
/// <summary>
/// 退出过场模式,撤销过场 VCam 的优先级Brain 自动混合回当前区域相机。
/// </summary>
public void ExitCutsceneMode()
{
if (_cutsceneCamera == null) return;
_cutsceneCamera.Priority = 0;
_cutsceneCamera = null;
}
/// <summary>
/// 通知相机系统玩家的面朝方向,转发至所有活跃 VCam 的方向偏置扩展。
/// 由 PlayerController 在精灵翻转时调用:<c>ICameraService.SetPlayerFacing(翻转方向 ? +1 : -1)</c>。
/// </summary>
public void SetPlayerFacing(int direction)
{
_lastExternalFacing = direction;
SetFacingOnVCam(_activeDedicatedCam, direction);
}
private static void SetFacingOnVCam(CinemachineCamera vcam, int direction)
=> vcam?.GetComponent<CameraFacingBiasExtension>()?.SetExternalFacing(direction);
private Coroutine _lensCoroutine; private Coroutine _lensCoroutine;
private void ApplyLensSizeToAll(float size) private void ApplyLensSizeToAll(float size)
{ {
SetVcamLens(_vcamA, size);
SetVcamLens(_vcamB, size);
if (_activeDedicatedCam != null) SetVcamLens(_activeDedicatedCam, size); if (_activeDedicatedCam != null) SetVcamLens(_activeDedicatedCam, size);
} }
private static void SetVcamLens(CinemachineCamera vcam, float size) // size = 可见半高(世界单位),透视相机下等效于正交 OrthographicSize。
// 换算公式FOV = 2 * atan(size / depth)
private void SetVcamLens(CinemachineCamera vcam, float size)
{ {
if (vcam == null) return; if (vcam == null) return;
var lens = vcam.Lens; var lens = vcam.Lens;
lens.OrthographicSize = size; float depth = _lensConfig != null ? _lensConfig.cameraDepth : 10f;
lens.FieldOfView = 2f * Mathf.Atan(size / depth) * Mathf.Rad2Deg;
vcam.Lens = lens; vcam.Lens = lens;
} }
@@ -286,23 +289,24 @@ namespace BaseGames.Camera
{ {
CinemachineCamera active = GetActiveVcam(); CinemachineCamera active = GetActiveVcam();
if (active == null) { _lensCoroutine = null; yield break; } if (active == null) { _lensCoroutine = null; yield break; }
float start = active.Lens.OrthographicSize; // 透视相机:从当前 FOV 反算等效可见半高,作为插值起点
float depth = _lensConfig != null ? _lensConfig.cameraDepth : 10f;
float start = depth * Mathf.Tan(active.Lens.FieldOfView * 0.5f * Mathf.Deg2Rad);
float elapsed = 0f; float elapsed = 0f;
while (elapsed < duration) while (elapsed < duration)
{ {
elapsed += Time.deltaTime; elapsed += Time.deltaTime;
ApplyLensSizeToAll(Mathf.Lerp(start, target, elapsed / duration)); // 使用平滑过渡曲线ease-in-out视野缩放手感更自然。
// 线性插值会让镜头拉远感觉机械;平滑步多出平稳的起笔和收尾弹性。
float t = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(elapsed / duration));
ApplyLensSizeToAll(Mathf.Lerp(start, target, t));
yield return null; yield return null;
} }
ApplyLensSizeToAll(target); ApplyLensSizeToAll(target);
_lensCoroutine = null; _lensCoroutine = null;
} }
private CinemachineCamera GetActiveVcam() private CinemachineCamera GetActiveVcam() => _activeDedicatedCam;
{
if (_activeDedicatedCam != null) return _activeDedicatedCam;
return _activeSlot == 0 ? _vcamA : (_vcamB != null ? _vcamB : _vcamA);
}
// ── 内部方法 ────────────────────────────────────────────────────────── // ── 内部方法 ──────────────────────────────────────────────────────────
@@ -315,64 +319,89 @@ namespace BaseGames.Camera
_activeDedicatedCam = area.DedicatedCamera; _activeDedicatedCam = area.DedicatedCamera;
_activeDedicatedCam.Priority = area.DedicatedPriority; _activeDedicatedCam.Priority = area.DedicatedPriority;
SyncFollowToVCam(_activeDedicatedCam); // 确保专有 VCam 的 Follow 指向当前跟随目标
SetFacingOnVCam(_activeDedicatedCam, _lastExternalFacing); // 应用当前玩家朝向
// 应用 CameraArea 参数Confiner、Composer、扩展组件等
var dedicatedConfiner = _activeDedicatedCam.GetComponent<CinemachineConfiner3D>();
ValidateVCamExtensionOrder(_activeDedicatedCam, dedicatedConfiner);
ConfigureSlot(_activeDedicatedCam, dedicatedConfiner, area);
} }
/// <summary> /// <summary>
/// 使用全局 VCam ping-pong 切换到新区域 /// 检查 VCam 上各扩展组件的挂载顺序是否正确
/// 配置非活跃 VCam 的 Confiner → 提升其优先级 → 降低旧 VCam 优先级。 /// <list type="bullet">
/// Cinemachine Brain 检测到优先级变化后自动触发混合。 /// <item>FallBiasExtension / FacingBiasExtension 必须在 CinemachineConfiner3D 之前</item>
/// <item>AxisLockExtension 必须在 CinemachineConfiner3D 之后</item>
/// </list>
/// 顺序错误时相机会在应用偏置后逃出限位区域,或轴锁被 Confiner 覆盖失效。
/// </summary> /// </summary>
private void ActivateGlobalSlot(CameraArea area) private static void ValidateVCamExtensionOrder(CinemachineCamera vcam, CinemachineConfiner3D confiner)
{ {
// 收回专有 VCam if (vcam == null) return;
if (_activeDedicatedCam != null)
{
_activeDedicatedCam.Priority = 0;
_activeDedicatedCam = null;
}
bool noVCams = _vcamA == null && _vcamB == null; if (confiner == null)
if (noVCams)
{ {
Debug.LogWarning("[CameraStateController] 全局 VCam A / B 均未绑定,无法切换相机区域。"); Debug.LogWarning(
$"[CameraStateController] VCam <b>{vcam.name}</b> 缺少 CinemachineConfiner3D 组件!" +
"相机将不受任何限位约束,请通过 CameraAreaEditor 重新生成此 VCam 或手动添加。");
return; return;
} }
// 首次调用:直接激活 VCamA场景淡入阶段无需混合动画 Component[] comps = vcam.GetComponents<Component>();
if (_activeSlot < 0) int confinerIdx = -1;
int fallBiasIdx = -1;
int facingBiasIdx = -1;
int axisLockIdx = -1;
int asymDampIdx = -1;
int adaptiveLahIdx = -1;
for (int i = 0; i < comps.Length; i++)
{ {
var cam = _vcamA ?? _vcamB; switch (comps[i])
var confiner = _vcamA != null ? _confinerA : _confinerB; {
ConfigureSlot(cam, confiner, area); case CinemachineConfiner3D _: confinerIdx = i; break;
SyncFollowToVCam(cam); case CameraFallBiasExtension _: fallBiasIdx = i; break;
cam.Priority = _globalActivePriority; case CameraFacingBiasExtension _: facingBiasIdx = i; break;
_activeSlot = _vcamA != null ? 0 : 1; case CameraAxisLockExtension _: axisLockIdx = i; break;
return; case CameraAsymmetricDampingExtension _: asymDampIdx = i; break;
case CameraAdaptiveLookaheadExtension _: adaptiveLahIdx = i; break;
}
} }
// 只有一台 VCam 时:直接重新配置,不做优先级 ping-pong if (asymDampIdx >= 0 && asymDampIdx > confinerIdx)
// (之前的 null 保护令 inactiveCam == activeCam导致先升后降为 0 自毁) Debug.LogError(
if (_vcamA == null || _vcamB == null) $"[CameraStateController] VCam <b>{vcam.name}</b>" +
{ "CameraAsymmetricDampingExtension 必须在 CinemachineConfiner3D 之前!" +
var cam = _vcamA ?? _vcamB; "当前顺序导致Y轴阻尼平滑值将相机推出限位区域而不被重新裁剪。" +
var confiner = _vcamA != null ? _confinerA : _confinerB; "请在 RoomCameraSetupTool 中点击「自动修正组件顺序」按钮。");
ConfigureSlot(cam, confiner, area);
SyncFollowToVCam(cam);
cam.Priority = _globalActivePriority; // 保持激活,不改变 _activeSlot
return;
}
// 双 VCam ping-pong配置非活跃槽 → 升级其优先级 → 降低活跃槽优先级 if (fallBiasIdx >= 0 && fallBiasIdx > confinerIdx)
bool nextIsA = _activeSlot != 0; Debug.LogError(
var inactiveCam = nextIsA ? _vcamA : _vcamB; $"[CameraStateController] VCam <b>{vcam.name}</b>" +
var activeCam = nextIsA ? _vcamB : _vcamA; "CameraFallBiasExtension 必须在 CinemachineConfiner3D 之前!" +
var inactiveConfiner = nextIsA ? _confinerA : _confinerB; "当前顺序导致下坠偏置将相机推出限位区域而不被重新裁剪。" +
"请在 RoomCameraSetupTool 中点击「自动修正组件顺序」按钮。");
ConfigureSlot(inactiveCam, inactiveConfiner, area); if (facingBiasIdx >= 0 && facingBiasIdx > confinerIdx)
SyncFollowToVCam(inactiveCam); // 确保 Follow 正确(防止 SetFollowTarget 未被调用) Debug.LogError(
inactiveCam.Priority = _globalActivePriority; $"[CameraStateController] VCam <b>{vcam.name}</b>" +
activeCam.Priority = _standbyPriority; // 降到待机但仍 > 0Brain 可在混合期间读取其状态 "CameraFacingBiasExtension 必须在 CinemachineConfiner3D 之前!" +
_activeSlot = nextIsA ? 0 : 1; "当前顺序导致朝向偏置将相机推出限位区域而不被重新裁剪。" +
"请在 RoomCameraSetupTool 中点击「自动修正组件顺序」按钮。");
if (adaptiveLahIdx >= 0 && adaptiveLahIdx > confinerIdx)
Debug.LogError(
$"[CameraStateController] VCam <b>{vcam.name}</b>" +
"CameraAdaptiveLookaheadExtension 必须在 CinemachineConfiner3D 之前!" +
"请在 RoomCameraSetupTool 中点击「自动修正组件顺序」按钮。");
if (axisLockIdx >= 0 && axisLockIdx < confinerIdx)
Debug.LogError(
$"[CameraStateController] VCam <b>{vcam.name}</b>" +
"CameraAxisLockExtension 必须在 CinemachineConfiner3D 之后!" +
"当前顺序导致轴向锁定被 Confiner 覆盖而失效。" +
"请在 RoomCameraSetupTool 中点击「自动修正组件顺序」按钮。");
} }
/// <summary> /// <summary>
@@ -386,31 +415,18 @@ namespace BaseGames.Camera
} }
private static void ConfigureSlot( private static void ConfigureSlot(
CinemachineCamera vcam, CinemachineConfiner2D confiner, CameraArea area) CinemachineCamera vcam, CinemachineConfiner3D confiner, CameraArea area)
{ {
// 1. Confiner // 1. Confiner
if (confiner != null && area.ConfinerCollider != null) if (confiner != null && area.ConfinerCollider != null)
{ {
confiner.BoundingShape2D = area.ConfinerCollider; confiner.BoundingVolume = area.ConfinerCollider;
// 限位多边形已在编辑器中预收缩(可视区域 - 视口半尺寸 = 相机中心运动范围)。
// OversizeWindow.MaxWindowSize = 0.001f(极小正值):
// 使 Cinemachine 将实际视口高度裁剪至 0.001,几乎不再对多边形额外收缩,
// 从而以预收缩后的多边形直接作为相机中心约束边界。
// 对于小于视口的房间(预收缩后多边形退化为点),仍正确固定相机于中心。
confiner.OversizeWindow = new CinemachineConfiner2D.OversizeWindowSettings
{
Enabled = true,
MaxWindowSize = 0.001f,
Padding = 0.1f,
};
// BoundingShape2D 变更后必须刷新内部缓存路径,否则限位仍使用旧边界
confiner.InvalidateLensCache();
} }
else if (confiner != null && area.ConfinerCollider == null) else if (confiner != null && area.ConfinerCollider == null)
{ {
Debug.LogError( Debug.LogError(
$"[CameraStateController] {area.name} 未绑定 ConfinerCollider" + $"[CameraStateController] {area.name} 未绑定 ConfinerCollider" +
"请将子节点 AreaBoundary 的 PolygonCollider2D 拖入 CameraArea._confinerCollider 字段。"); "请将子节点 AreaBoundary 的 BoxCollider 拖入 CameraArea._confinerCollider 字段。");
} }
// 2. 跟随行为覆盖 // 2. 跟随行为覆盖
@@ -476,6 +492,26 @@ namespace BaseGames.Camera
var fallBias = vcam.GetComponent<CameraFallBiasExtension>(); var fallBias = vcam.GetComponent<CameraFallBiasExtension>();
if (fallBias != null) if (fallBias != null)
fallBias.SetConfiguredMax(area.DisableFallBias ? 0f : -1f); fallBias.SetConfiguredMax(area.DisableFallBias ? 0f : -1f);
// 5. 方向感知水平偏置
// X 轴已锁定时强制关闭偏置:两者均在 Body Stage 执行,若偏置后于锁定运行会破坏轴锁。
var facingBias = vcam.GetComponent<CameraFacingBiasExtension>();
if (facingBias != null)
{
if (area.LockHorizontal)
facingBias.FacingBias = 0f;
else if (area.OverrideFacingBias)
facingBias.FacingBias = area.FacingBiasOverride;
}
// 6. 相机噪音(区域氛围震动:洞穴、水下、机械等)
var noise = vcam.GetComponent<CinemachineBasicMultiChannelPerlin>();
if (noise != null)
{
noise.NoiseProfile = area.NoiseProfile;
noise.AmplitudeGain = area.NoiseProfile != null ? area.NoiseAmplitude : 0f;
noise.FrequencyGain = area.NoiseFrequency;
}
} }
private static void ResetVCamExtensions(CinemachineCamera vcam) private static void ResetVCamExtensions(CinemachineCamera vcam)
@@ -484,6 +520,7 @@ namespace BaseGames.Camera
vcam.GetComponent<CameraAsymmetricDampingExtension>()?.ResetState(); vcam.GetComponent<CameraAsymmetricDampingExtension>()?.ResetState();
vcam.GetComponent<CameraFallBiasExtension>()?.ResetState(); vcam.GetComponent<CameraFallBiasExtension>()?.ResetState();
vcam.GetComponent<CameraAdaptiveLookaheadExtension>()?.ResetState(); vcam.GetComponent<CameraAdaptiveLookaheadExtension>()?.ResetState();
vcam.GetComponent<CameraFacingBiasExtension>()?.ResetState();
} }
private void ApplyBlendProfile(CameraBlendProfileSO profile) private void ApplyBlendProfile(CameraBlendProfileSO profile)
@@ -546,15 +583,15 @@ namespace BaseGames.Camera
// 计算高度(先收集内容) // 计算高度(先收集内容)
string areaName = _currentArea != null ? _currentArea.name : "<无>"; string areaName = _currentArea != null ? _currentArea.name : "<无>";
string slotLabel = _activeSlot < 0 ? "未初始化" string dedicatedLabel = _activeDedicatedCam != null
: _activeSlot == 0 ? "VCam A" ? $"{_activeDedicatedCam.name} (P={_activeDedicatedCam.Priority})"
: "VCam B"; : "<无激活 VCam>";
string followLabel = _currentFollowTarget != null string followLabel = _currentFollowTarget != null
? _currentFollowTarget.name ? _currentFollowTarget.name
: "<未设置>"; : "<未设置>";
bool warnFollow = _currentFollowTarget == null; bool warnFollow = _currentFollowTarget == null;
bool warnNoVCam = _vcamA == null && _vcamB == null; bool warnNoVCam = _activeDedicatedCam == null;
bool warnNoBrain = _brain == null; bool warnNoBrain = _brain == null;
// 区域状态(基线 + 触发区域集合) // 区域状态(基线 + 触发区域集合)
@@ -569,7 +606,7 @@ namespace BaseGames.Camera
zoneLines.Add($" [{e.priority}] {(e.area != null ? e.area.name : "null")}{marker}"); zoneLines.Add($" [{e.priority}] {(e.area != null ? e.area.name : "null")}{marker}");
} }
int lineCount = 5 + zoneLines.Count + (warnFollow ? 1 : 0) + (warnNoVCam ? 1 : 0) + (warnNoBrain ? 1 : 0); int lineCount = 4 + zoneLines.Count + (warnFollow ? 1 : 0) + (warnNoVCam ? 1 : 0) + (warnNoBrain ? 1 : 0);
float rowH = 19f; float rowH = 19f;
float h = 28f + lineCount * rowH + 8f; float h = 28f + lineCount * rowH + 8f;
@@ -580,12 +617,7 @@ namespace BaseGames.Camera
cy += 22f; cy += 22f;
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"当前区域:{areaName}", _debugRowStyle); cy += rowH; GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"当前区域:{areaName}", _debugRowStyle); cy += rowH;
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"活跃 VCam 槽:{slotLabel}", _debugRowStyle); cy += rowH; GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"专有 VCam{dedicatedLabel}", warnNoVCam ? _debugWarnStyle : _debugRowStyle); cy += rowH;
string vcamALabel = _vcamA != null ? $"{_vcamA.name} (P={_vcamA.Priority})" : "<未绑定>";
string vcamBLabel = _vcamB != null ? $"{_vcamB.name} (P={_vcamB.Priority})" : "<未绑定>";
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"VCam A{vcamALabel}", _debugRowStyle); cy += rowH;
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"VCam B{vcamBLabel}", _debugRowStyle); cy += rowH;
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"Follow 目标:{followLabel}", warnFollow ? _debugWarnStyle : _debugRowStyle); cy += rowH; GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"Follow 目标:{followLabel}", warnFollow ? _debugWarnStyle : _debugRowStyle); cy += rowH;
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "区域状态(基线 + 触发区域):", _debugRowStyle); cy += rowH; GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "区域状态(基线 + 触发区域):", _debugRowStyle); cy += rowH;
@@ -598,7 +630,7 @@ namespace BaseGames.Camera
if (warnFollow) if (warnFollow)
{ GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ Follow 目标未设置(检查 _onPlayerSpawned", _debugWarnStyle); cy += rowH; } { GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ Follow 目标未设置(检查 _onPlayerSpawned", _debugWarnStyle); cy += rowH; }
if (warnNoVCam) if (warnNoVCam)
{ GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ VCam A/B 均未绑定", _debugWarnStyle); cy += rowH; } { GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ 当前区域无激活专有 VCam检查 DedicatedCamera 绑定", _debugWarnStyle); cy += rowH; }
if (warnNoBrain) if (warnNoBrain)
{ GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ CinemachineBrain 未绑定", _debugWarnStyle); cy += rowH; } { GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ CinemachineBrain 未绑定", _debugWarnStyle); cy += rowH; }
} }

View File

@@ -5,10 +5,27 @@ using BaseGames.Core;
namespace BaseGames.Camera namespace BaseGames.Camera
{ {
/// <summary>
/// 相机区域切换模式。
/// </summary>
public enum CameraZoneSwitchMode
{
/// <summary>进入即切换(默认)。
/// 只要走进触发区域就立刻切换,不需要完全离开当前区域。</summary>
Immediate,
/// <summary>必须离开当前区域才切换。
/// 进入新区域后仅将其加入候选列表,等玩家完全离开当前激活区域后再接管。</summary>
ExitFirst,
}
/// <summary> /// <summary>
/// 相机区域切换触发器。 /// 相机区域切换触发器。
/// 当触发区域重叠时,玩家必须先离开当前所在的触发区域,才会切换到下一个区域, /// 支持两种切换模式,可通过 Inspector 配置:
/// 而不是进入重叠区域时立即切换。 /// <list type="bullet">
/// <item><b>Immediate</b>:进入即切换,不等待离开旧区域。</item>
/// <item><b>ExitFirst</b>:必须离开当前激活区域后才切换。</item>
/// </list>
/// </summary> /// </summary>
[ExecuteAlways] [ExecuteAlways]
[RequireComponent(typeof(PolygonCollider2D))] [RequireComponent(typeof(PolygonCollider2D))]
@@ -24,17 +41,36 @@ namespace BaseGames.Camera
"相同优先级则后进入的胜出(推荐默认值 1。")] "相同优先级则后进入的胜出(推荐默认值 1。")]
[SerializeField] private int _priority = 1; [SerializeField] private int _priority = 1;
[Tooltip("切换模式。\n" +
"Immediate进入即切换无需离开当前区域默认。\n" +
"ExitFirst必须离开当前激活区域后才切换。")]
[SerializeField] private CameraZoneSwitchMode _switchMode = CameraZoneSwitchMode.Immediate;
[SerializeField] private string _playerTag = "Player"; [SerializeField] private string _playerTag = "Player";
private PolygonCollider2D _collider; private PolygonCollider2D _collider;
private bool _isPlayerInside; private bool _isPlayerInside;
/// <summary>触发区域优先级(只读),供外部按优先级选择最佳区域。</summary>
public int Priority => _priority;
// ── 静态:跨实例共享触发状态 ────────────────────────────────────────── // ── 静态:跨实例共享触发状态 ──────────────────────────────────────────
// 玩家当前物理上所在的所有触发区域(按进入顺序排列) // 玩家当前物理上所在的所有触发区域(按进入顺序排列)
private static readonly List<CameraTriggerZone> s_InsideZones = new(); private static readonly List<CameraTriggerZone> s_InsideZones = new();
// 当前已向 ICameraService 发出 SwitchArea 请求的触发区域 // 当前已向 ICameraService 发出 SwitchArea 请求的触发区域
private static CameraTriggerZone s_ActiveZone; private static CameraTriggerZone s_ActiveZone;
/// <summary>
/// 在每次进入 Play Mode 前(或禁用 Domain Reload 时的跨会话)重置静态状态,
/// 防止上一次游戏会话残留的区域引用导致触发逻辑错误。
/// </summary>
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStaticState()
{
s_InsideZones.Clear();
s_ActiveZone = null;
}
private void Awake() private void Awake()
{ {
_collider = GetComponent<PolygonCollider2D>(); _collider = GetComponent<PolygonCollider2D>();
@@ -44,12 +80,7 @@ namespace BaseGames.Camera
private void OnDisable() private void OnDisable()
{ {
if (!Application.isPlaying) return; if (!Application.isPlaying) return;
if (!_isPlayerInside) return; HandlePlayerExit();
_isPlayerInside = false;
s_InsideZones.Remove(this);
if (s_ActiveZone == this)
Deactivate(this);
} }
/// <summary> /// <summary>
@@ -76,46 +107,103 @@ namespace BaseGames.Camera
s_InsideZones.Add(this); s_InsideZones.Add(this);
} }
if (s_ActiveZone == null) EvaluateAndSwitch();
Activate(this);
} }
private void OnTriggerEnter2D(Collider2D other) private void OnTriggerEnter2D(Collider2D other)
{ {
if (!Application.isPlaying) return; if (!Application.isPlaying) return;
if (!other.CompareTag(_playerTag)) return; // 兼容碰撞体挂在子节点的玩家结构:先检查碰撞体本身标签,
// 再检查其挂载的 Rigidbody2D 所在节点标签(通常为带标签的角色根节点)。
if (!other.CompareTag(_playerTag) &&
(other.attachedRigidbody == null || !other.attachedRigidbody.CompareTag(_playerTag)))
return;
if (_targetArea == null || _isPlayerInside) return; if (_targetArea == null || _isPlayerInside) return;
_isPlayerInside = true; _isPlayerInside = true;
s_InsideZones.Add(this); s_InsideZones.Add(this);
// 没有激活的触发区域 → 立即切换 // Immediate进入即评估切换
// 已有激活的触发区域 → 等玩家离开后再接管(避免重叠区域间提前切换) // ExitFirst仅在当前无激活区域时才先先激活否则等待玩家离开当前激活区域。
if (s_ActiveZone == null) if (_switchMode == CameraZoneSwitchMode.Immediate || s_ActiveZone == null)
Activate(this); EvaluateAndSwitch();
} }
private void OnTriggerExit2D(Collider2D other) private void OnTriggerExit2D(Collider2D other)
{ {
if (!Application.isPlaying) return; if (!Application.isPlaying) return;
if (!other.CompareTag(_playerTag)) return; if (!other.CompareTag(_playerTag) &&
if (!_isPlayerInside) return; (other.attachedRigidbody == null || !other.attachedRigidbody.CompareTag(_playerTag)))
return;
// 复合碰撞体场景:某个子碰撞体退出时,验证玩家根节点是否仍在区域内。
// 若根节点还在区域内(其他碰撞体尚未退出),则忽略此次退出事件。
Transform playerRoot = other.attachedRigidbody != null
? other.attachedRigidbody.transform
: other.transform;
if (_collider != null && _collider.OverlapPoint(playerRoot.position)) return;
HandlePlayerExit();
}
/// <summary>
/// 玩家离开触发区域的统一处理(<see cref="OnTriggerExit2D"/>、
/// <see cref="FixedUpdate"/> 边缘检测及 <see cref="OnDisable"/> 共同调用)。
/// 带幂等保护,多次调用安全。
/// </summary>
private void HandlePlayerExit()
{
if (!_isPlayerInside) return; // 幂等保护:防止重复触发
_isPlayerInside = false; _isPlayerInside = false;
s_InsideZones.Remove(this); s_InsideZones.Remove(this);
if (s_ActiveZone == this) if (s_ActiveZone == this)
Deactivate(this); Deactivate(this);
else
ServiceLocator.GetOrDefault<ICameraService>()?.ReleaseArea(_targetArea, null);
} }
// ── 静态辅助方法 ──────────────────────────────────────────────────────── // ── 静态辅助方法 ────────────────────────────────────────────────────────
/// <summary>
/// 评估 <see cref="s_InsideZones"/> 并在需要时切换激活区域。
/// <list type="bullet">
/// <item>无激活区域时:直接激活最后进入的区域。</item>
/// <item>新 <c>SelectBest()</c> 与当前激活不同时:立即覆盖切换。
/// 由于 <c>SelectBest</c> 使用 &gt;= 规则,进入任何新区域都会触发切换。</item>
/// </list>
/// </summary>
private static void EvaluateAndSwitch()
{
if (s_ActiveZone == null)
{
Activate(s_InsideZones[s_InsideZones.Count - 1]);
return;
}
CameraTriggerZone best = SelectBest();
if (best != s_ActiveZone)
OverrideActive(best);
}
private static void Activate(CameraTriggerZone zone) private static void Activate(CameraTriggerZone zone)
{ {
s_ActiveZone = zone; s_ActiveZone = zone;
ServiceLocator.GetOrDefault<ICameraService>()?.SwitchArea(zone._targetArea, zone._priority); ServiceLocator.GetOrDefault<ICameraService>()?.SwitchArea(zone._targetArea, zone._priority);
} }
/// <summary>
/// 不经过 Exit 事件,直接将激活区域切换为 <paramref name="newZone"/>。
/// 旧区域保留在 <see cref="s_InsideZones"/> 中(玩家仍在其内部),
/// 不立即释放旧区域——等玩家物理离开旧区域时由 <see cref="OnTriggerExit2D"/> 清理。
/// </summary>
private static void OverrideActive(CameraTriggerZone newZone)
{
s_ActiveZone = newZone;
ServiceLocator.GetOrDefault<ICameraService>()?.SwitchArea(newZone._targetArea, newZone._priority);
}
/// <summary> /// <summary>
/// 离开 <paramref name="leaving"/> 时的处理: /// 离开 <paramref name="leaving"/> 时的处理:
/// 若还有其他触发区域,先激活最优者再释放 leaving避免短暂回退到房间基线 /// 若还有其他触发区域,先激活最优者再释放 leaving避免短暂回退到房间基线
@@ -140,12 +228,16 @@ namespace BaseGames.Camera
} }
} }
/// <summary>从 <see cref="s_InsideZones"/> 中选出优先级最高的区域。</summary> /// <summary>
/// 从 <see cref="s_InsideZones"/> 中选出最优区域。
/// 优先级高者优先;优先级相同时取最后进入的区域(后进入的胜出),
/// 确保进入任何新区域时都会立即切换,而不是等待离开旧区域。
/// </summary>
private static CameraTriggerZone SelectBest() private static CameraTriggerZone SelectBest()
{ {
CameraTriggerZone best = s_InsideZones[0]; CameraTriggerZone best = s_InsideZones[0];
for (int i = 1; i < s_InsideZones.Count; i++) for (int i = 1; i < s_InsideZones.Count; i++)
if (s_InsideZones[i]._priority > best._priority) if (s_InsideZones[i]._priority >= best._priority) // >= 使同优先级时后进入的胜出
best = s_InsideZones[i]; best = s_InsideZones[i];
return best; return best;
} }
@@ -155,19 +247,19 @@ namespace BaseGames.Camera
if (_collider == null) _collider = GetComponent<PolygonCollider2D>(); if (_collider == null) _collider = GetComponent<PolygonCollider2D>();
if (_collider == null || _collider.pathCount == 0) return; if (_collider == null || _collider.pathCount == 0) return;
var pts = new System.Collections.Generic.List<Vector2>();
_collider.GetPath(0, pts);
if (pts.Count < 2) return;
Gizmos.matrix = transform.localToWorldMatrix; Gizmos.matrix = transform.localToWorldMatrix;
Vector2 off = _collider.offset;
// 多边形触发边界(进入检测外框)
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.8f); Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.8f);
var pts = new List<Vector2>();
_collider.GetPath(0, pts);
for (int i = 0; i < pts.Count; i++) for (int i = 0; i < pts.Count; i++)
{ {
Vector2 a = pts[i] + off; Vector3 a = new Vector3(pts[i].x, pts[i].y, 0f);
Vector2 b = pts[(i + 1) % pts.Count] + off; Vector3 b = new Vector3(pts[(i + 1) % pts.Count].x, pts[(i + 1) % pts.Count].y, 0f);
Gizmos.DrawLine(new Vector3(a.x, a.y), new Vector3(b.x, b.y)); Gizmos.DrawLine(a, b);
} }
} }
} }
} }

View File

@@ -1,4 +1,5 @@
using UnityEngine; using UnityEngine;
using Unity.Cinemachine;
namespace BaseGames.Camera namespace BaseGames.Camera
{ {
@@ -27,7 +28,7 @@ namespace BaseGames.Camera
/// </summary> /// </summary>
void ReleaseArea(CameraArea releasedArea, CameraArea fallback); void ReleaseArea(CameraArea releasedArea, CameraArea fallback);
/// <summary>为全局双 VCam 设置跟随目标Player/CameraFollowTarget。</summary> /// <summary>运行时设置跟随目标Player/CameraFollowTarget,激活区域时自动同步到专属 VCam。</summary>
void SetFollowTarget(Transform followTarget); void SetFollowTarget(Transform followTarget);
/// <summary>触发屏幕抖动(指定速度矢量)。</summary> /// <summary>触发屏幕抖动(指定速度矢量)。</summary>
@@ -37,9 +38,28 @@ namespace BaseGames.Camera
void TriggerImpulse(float strength = 0.3f); void TriggerImpulse(float strength = 0.3f);
/// <summary> /// <summary>
/// 平滑过渡正交相机尺寸。<paramref name="duration"/> = 0 时瞬间切换。 /// 平滑过渡视野尺寸(可视半高,世界单位)。<paramref name="duration"/> = 0 时瞬间切换。
/// 透视相机下自动换算为 FOV与正交相机的 OrthographicSize 语义等价。
/// 适用于 Boss 战拉远、特殊演出室拉近等场景。 /// 适用于 Boss 战拉远、特殊演出室拉近等场景。
/// </summary> /// </summary>
void SetLensSize(float orthographicSize, float duration = 0f); void SetLensSize(float visibleHalfHeight, float duration = 0f);
/// <summary>
/// 进入过场模式,激活指定 VCam优先级高于所有区域相机Brain 自动混合。
/// VCam 的 Follow / LookAt / Lens 由设计者在 Inspector 中配置,接口不强制覆写。
/// </summary>
void EnterCutsceneMode(CinemachineCamera cutsceneCamera);
/// <summary>
/// 退出过场模式,撤销过场 VCam 的优先级Brain 自动混合回当前区域相机。
/// </summary>
void ExitCutsceneMode();
/// <summary>
/// 通知相机系统玩家的面朝方向,使方向感知偏置立即响应。
/// 由 PlayerController 在精灵翻转时调用(<c>OnFlip</c>)。
/// direction: +1 = 朝右,-1 = 朝左0 = 清除外部输入(回退到速度估算)。
/// </summary>
void SetPlayerFacing(int direction);
} }
} }

View File

@@ -0,0 +1,44 @@
using UnityEngine;
namespace BaseGames.Camera
{
/// <summary>
/// 精灵像素对齐组件。
///
/// 挂载在含 <see cref="SpriteRenderer"/> 的 GameObject 上(可与 Rigidbody2D 同节点)。
/// 每渲染帧在 LateUpdate+1000与 <see cref="CameraPixelSnapper"/> 同执行序)中,
/// 将 <c>transform.position</c> 四舍五入到最近像素网格,消除精灵移动时的亚像素模糊。
///
/// <para>
/// 与 <see cref="CameraPixelSnapper"/> 搭配使用时,须保持 <c>_pixelsPerUnit</c>
/// 与相机侧一致,使精灵和相机吸附到同一像素格,从而消除二者相对偏移造成的抖动。
/// </para>
/// <para>
/// Rigidbody2D 内部物理位置(<c>rb.position</c>)不受影响;
/// 仅 <c>transform.position</c>(用于渲染)被修正,最大偏差 ±0.5 / PPU。
/// 子节点HurtBox、GroundCheck 等)随父节点微小平移,但偏差在物理容差范围内,
/// 不影响碰撞检测精度。
/// </para>
/// </summary>
[DefaultExecutionOrder(1000)]
[AddComponentMenu("BaseGames/Camera/Sprite Pixel Snapper")]
public class SpritePixelSnapper : MonoBehaviour
{
[Tooltip("每世界单位像素数PPU。须与 CameraPixelSnapper 及精灵导入设置保持一致。\n" +
"常用值16粗像素风格、32标准像素风格。0 = 禁用对齐。")]
[Min(0f)]
[SerializeField] private float _pixelsPerUnit = 16f;
private void LateUpdate()
{
if (_pixelsPerUnit <= 0f) return;
float ppu = _pixelsPerUnit;
Vector3 pos = transform.position;
pos.x = Mathf.Round(pos.x * ppu) / ppu;
pos.y = Mathf.Round(pos.y * ppu) / ppu;
// Z 轴不参与像素对齐
transform.position = pos;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 91f07a72af066da4684b9194b370a6f9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,3 +1,4 @@
using BaseGames.Core;
using BaseGames.Core.Events; using BaseGames.Core.Events;
using UnityEngine; using UnityEngine;
@@ -10,8 +11,10 @@ namespace BaseGames.Combat
/// 并携带 IgnoreIFrame 标记(无视翻滚/受击无敌帧,保证陷阱的绝对威胁性)。 /// 并携带 IgnoreIFrame 标记(无视翻滚/受击无敌帧,保证陷阱的绝对威胁性)。
/// ② 若伤害导致玩家死亡 → PlayerController.TakeDamage 已 Raise EVT_PlayerDied /// ② 若伤害导致玩家死亡 → PlayerController.TakeDamage 已 Raise EVT_PlayerDied
/// 走完整死亡流程(死亡动画 → 重载场景)。 /// 走完整死亡流程(死亡动画 → 重载场景)。
/// ③ 若玩家存活 → 本组件 Raise EVT_PlayerDied强制返回最近检查点 /// ③ 若玩家存活且场景内有检查点 → Raise EVT_CheckpointRespawn
/// 同样触发 DeathRespawnService无死亡演出只是重新加载 /// 由 CheckpointRespawnHandler 执行淡出→传送→淡入,不重载场景
/// ④ 若玩家存活但场景内无检查点 → Raise EVT_PlayerDied 作为兜底,
/// 仍由 DeathRespawnService 重载场景回到存档点。
/// ///
/// 可下劈Pogo /// 可下劈Pogo
/// - 将 _canPogo = true并在地刺顶部添加子 GameObject /// - 将 _canPogo = true并在地刺顶部添加子 GameObject
@@ -34,9 +37,12 @@ namespace BaseGames.Combat
[SerializeField] private LayerMask _playerLayers; [SerializeField] private LayerMask _playerLayers;
[Header("事件")] [Header("事件")]
[Tooltip("EVT_PlayerDied — 触发后由 DeathRespawnService 接管回到检查点")] [Tooltip("EVT_PlayerDied — 玩家存活但场景内无检查点时触发,走完整死亡/重载流程")]
[SerializeField] private VoidEventChannelSO _onPlayerDied; [SerializeField] private VoidEventChannelSO _onPlayerDied;
[Tooltip("EVT_CheckpointRespawn — 玩家存活且场景内有检查点时触发,执行原地传送")]
[SerializeField] private VoidEventChannelSO _onCheckpointRespawn;
[Header("设计")] [Header("设计")]
[Tooltip("true = 顶部有 HurtBox 子节点可下劈弹跳(需手动添加 Pogo Surface 子节点)")] [Tooltip("true = 顶部有 HurtBox 子节点可下劈弹跳(需手动添加 Pogo Surface 子节点)")]
[SerializeField] private bool _canPogo = true; [SerializeField] private bool _canPogo = true;
@@ -85,10 +91,16 @@ namespace BaseGames.Combat
} }
} }
// ── 2. 若玩家存活(或无 HurtBox / damage=0强制触发检查点回溯 ─────────── // ── 2. 若玩家存活(或无 HurtBox / damage=0回溯至最近检查点 ────────────
if (!playerDiedFromDamage) if (!playerDiedFromDamage)
{
var cp = ServiceLocator.GetOrDefault<ICheckpointService>();
if (cp != null && cp.HasCheckpoint)
_onCheckpointRespawn?.Raise();
else
_onPlayerDied?.Raise(); _onPlayerDied?.Raise();
} }
}
#if UNITY_EDITOR #if UNITY_EDITOR
[UnityEngine.ContextMenu("打印 Pogo 提示")] [UnityEngine.ContextMenu("打印 Pogo 提示")]

View File

@@ -0,0 +1,111 @@
using System.Collections;
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Core
{
/// <summary>
/// 自动存档服务:在关键游戏事件发生后自动写盘,减少玩家因意外中断损失进度。
///
/// 触发时机:
/// · 进入新房间 — EVT_SceneLoaded
/// · 击败 Boss — EVT_BossFightEndedbool=true 表示玩家获胜)
/// · 获得新能力 — EVT_AbilityUnlockedStr
/// · 购买物品 — EVT_ShopPurchase
/// · 拾取关键物品 — EVT_CollectiblePickup
/// · 拾取 HP 容器 — EVT_MaxHPContainerPickedUp
/// · 开门/交互机关 — EVT_DoorOpened使用钥匙、触发机关等
/// · 完成任务 — EVT_QuestStateChangedState == Completed
///
/// 自动存档使用与手动存档(存档点)相同的存档槽。
/// 自动存档不会更改复活位置——SavePoint.OnSave 的 _isActivated 守卫确保
/// 只有玩家已坐过的存档点才会写入 Player.Scene 和 Meta.SavePointId。
///
/// 防抖:同一冷却窗口内的多次触发合并为一次写盘操作,避免频繁 I/O。
///
/// 挂载位置Persistent 场景根对象(与 GameServiceRegistrar 同级)。
/// </summary>
public class AutoSaveService : MonoBehaviour
{
[Header("触发事件频道")]
[Tooltip("EVT_SceneLoaded — 进入新房间时自动存档")]
[SerializeField] private StringEventChannelSO _onSceneLoaded;
[Tooltip("EVT_BossFightEnded — bool=true 表示玩家获胜,此时触发存档")]
[SerializeField] private BoolEventChannelSO _onBossFightEnded;
[Tooltip("EVT_AbilityUnlockedStr — 获得新能力时触发存档")]
[SerializeField] private StringEventChannelSO _onAbilityUnlocked;
[Tooltip("EVT_ShopPurchase — 购买物品后触发存档")]
[SerializeField] private ShopPurchaseEventChannelSO _onShopPurchase;
[Tooltip("EVT_CollectiblePickup — 拾取关键物品(护符、道具等)后触发存档")]
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
[Tooltip("EVT_MaxHPContainerPickedUp — 拾取 HP 容器后触发存档")]
[SerializeField] private StringEventChannelSO _onMaxHPContainerPickedUp;
[Tooltip("EVT_DoorOpened — 使用钥匙或触发机关开门后触发存档")]
[SerializeField] private StringEventChannelSO _onDoorOpened;
[Tooltip("EVT_QuestStateChanged — 任务完成State == Completed时触发存档")]
[SerializeField] private QuestStateChangedEventChannel _onQuestStateChanged;
[Header("防抖")]
[Tooltip("两次自动存档之间的最短间隔(秒)。防止短时间内多次触发导致频繁写盘。")]
[SerializeField] [Range(0.5f, 10f)] private float _cooldownSeconds = 2f;
private bool _onCooldown;
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onSceneLoaded? .Subscribe(OnSceneLoaded) .AddTo(_subs);
_onBossFightEnded? .Subscribe(OnBossFightEnded) .AddTo(_subs);
_onAbilityUnlocked? .Subscribe(_ => RequestAutoSave("ability")) .AddTo(_subs);
_onShopPurchase? .Subscribe(_ => RequestAutoSave("shop")) .AddTo(_subs);
_onCollectiblePickup? .Subscribe(_ => RequestAutoSave("collectible")) .AddTo(_subs);
_onMaxHPContainerPickedUp? .Subscribe(_ => RequestAutoSave("hp_container")) .AddTo(_subs);
_onDoorOpened? .Subscribe(_ => RequestAutoSave("door")) .AddTo(_subs);
_onQuestStateChanged? .Subscribe(OnQuestStateChanged) .AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
// ── 触发处理 ───────────────────────────────────────────────────────────
private void OnSceneLoaded(string _) => RequestAutoSave("scene");
private void OnBossFightEnded(bool won) { if (won) RequestAutoSave("boss"); }
private void OnQuestStateChanged(QuestStateChangedEvent e)
{
if (e.State == QuestState.Completed) RequestAutoSave("quest_complete");
}
// ── 防抖存档 ───────────────────────────────────────────────────────────
private void RequestAutoSave(string reason)
{
if (_onCooldown) return;
StartCoroutine(DoAutoSave(reason));
}
private IEnumerator DoAutoSave(string reason)
{
_onCooldown = true;
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc != null)
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.Log($"[AutoSave] 触发:{reason}");
#endif
// 与手动存档使用相同槽位fire-and-forget
_ = svc.SaveAsync(svc.ActiveSlot);
}
yield return new WaitForSecondsRealtime(_cooldownSeconds);
_onCooldown = false;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9bdcd96d81e589642a250987222c25e2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,48 @@
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Core
{
/// <summary>
/// 检查点服务实现。挂载在 Persistent 场景根对象上,由 GameServiceRegistrar 注册。
/// 订阅 EVT_SceneLoaded换房间时自动清空当前检查点。
/// </summary>
public class CheckpointService : MonoBehaviour, ICheckpointService
{
[Header("事件 - 监听")]
[Tooltip("EVT_SceneLoaded — 收到后自动清空检查点")]
[SerializeField] private StringEventChannelSO _onSceneLoaded;
private bool _hasCheckpoint;
private Vector2 _checkpointPosition;
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onSceneLoaded?.Subscribe(OnSceneLoaded).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
// ── ICheckpointService ────────────────────────────────────────────────
public bool HasCheckpoint => _hasCheckpoint;
public Vector2 CheckpointPosition => _checkpointPosition;
public void RegisterCheckpoint(Vector2 position)
{
_hasCheckpoint = true;
_checkpointPosition = position;
}
public void ClearCheckpoint()
{
_hasCheckpoint = false;
}
// ── Private ───────────────────────────────────────────────────────────
private void OnSceneLoaded(string _) => ClearCheckpoint();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 486e785d32d1c4c468a4eb0fd4cf1822
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -25,6 +25,7 @@ namespace BaseGames.Core
[SerializeField] private SceneService _sceneService; [SerializeField] private SceneService _sceneService;
[SerializeField] private EventChannelRegistry _eventChannelRegistry; [SerializeField] private EventChannelRegistry _eventChannelRegistry;
[SerializeField] private GameSaveManager _saveManager; [SerializeField] private GameSaveManager _saveManager;
[SerializeField] private CheckpointService _checkpointService;
/// <summary> /// <summary>
/// Persistent 场景中唯一保留的主 AudioListener通常挂在主相机上 /// Persistent 场景中唯一保留的主 AudioListener通常挂在主相机上
/// 在 Inspector 中绑定后可完全跳过 Awake 时的 FindObjectsOfType 全场景扫描。 /// 在 Inspector 中绑定后可完全跳过 Awake 时的 FindObjectsOfType 全场景扫描。
@@ -69,12 +70,18 @@ namespace BaseGames.Core
else else
Debug.LogWarning("[GameServiceRegistrar] ⚠ _saveManager 未绑定ISaveService 未注册。", this); Debug.LogWarning("[GameServiceRegistrar] ⚠ _saveManager 未绑定ISaveService 未注册。", this);
if (_checkpointService)
ServiceLocator.Register<ICheckpointService>(_checkpointService);
else
Debug.LogWarning("[GameServiceRegistrar] ⚠ _checkpointService 未绑定ICheckpointService 未注册。", this);
#if UNITY_EDITOR #if UNITY_EDITOR
var sb = new System.Text.StringBuilder("[GameServiceRegistrar] ✅ 服务注册完成:"); var sb = new System.Text.StringBuilder("[GameServiceRegistrar] ✅ 服务注册完成:");
if (_deathRespawnService) sb.Append(" IDeathRespawnService"); if (_deathRespawnService) sb.Append(" IDeathRespawnService");
if (_sceneService) sb.Append(" | ISceneService"); if (_sceneService) sb.Append(" | ISceneService");
if (_eventChannelRegistry) sb.Append(" | IEventChannelRegistry"); if (_eventChannelRegistry) sb.Append(" | IEventChannelRegistry");
if (_saveManager) sb.Append(" | ISaveService"); if (_saveManager) sb.Append(" | ISaveService");
if (_checkpointService) sb.Append(" | ICheckpointService");
sb.Append(" | IAudioService(Null→等待覆盖)"); sb.Append(" | IAudioService(Null→等待覆盖)");
Debug.Log(sb.ToString(), this); Debug.Log(sb.ToString(), this);
#endif #endif

View File

@@ -0,0 +1,30 @@
using UnityEngine;
namespace BaseGames.Core
{
/// <summary>
/// 检查点服务接口。
/// 运行时追踪玩家在当前房间内最近经过的检查点坐标,不持久化至存档。
/// 换房间时由 CheckpointService 自动清空。
/// </summary>
public interface ICheckpointService
{
/// <summary>当前场景内是否存在已激活的检查点。</summary>
bool HasCheckpoint { get; }
/// <summary>
/// 最近激活的检查点世界坐标。
/// <see cref="HasCheckpoint"/> 为 false 时无意义。
/// </summary>
Vector2 CheckpointPosition { get; }
/// <summary>
/// 将指定位置登记为当前检查点(由 CheckpointMarker 调用)。
/// 同一场景内多次调用时以最新值为准。
/// </summary>
void RegisterCheckpoint(Vector2 position);
/// <summary>清空当前检查点(换场景时自动调用)。</summary>
void ClearCheckpoint();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 985eec4caa88c87428f183c8b6a6e6ae
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using BaseGames.Player;
namespace BaseGames.Editor
{
/// <summary>
/// AbilityType [Flags] uint 的 PropertyDrawer。
/// 将枚举按能力类别分组,以可读的复选框网格呈现,替代默认的 MaskField。
///
/// 分组:
/// 移动能力 — WallCling / WallJump / Dash / AirDash / DoubleJump / SuperJump / Swim / Dive
/// 法术能力 — Spell1 / Spell2 / Spell3
/// 形态能力 — SpiritForm / SpiritDash
/// 战斗能力 — Parry / ChargeAttack / DownSlash
/// 互动能力 — Interact / FastTravel
/// 能力强化 — InvincibleDash
/// </summary>
[CustomPropertyDrawer(typeof(AbilityType))]
public sealed class AbilityTypeDrawer : PropertyDrawer
{
// ── 分组定义 ──────────────────────────────────────────────────────────
private static readonly (string groupLabel, (AbilityType flag, string label)[] members)[] Groups =
{
("移动能力", new[]
{
(AbilityType.WallCling, "贴墙悬挂"),
(AbilityType.WallJump, "墙跳"),
(AbilityType.Dash, "冲刺"),
(AbilityType.DoubleJump, "二段跳"),
(AbilityType.SuperJump, "超级跳"),
(AbilityType.Swim, "游泳"),
(AbilityType.Dive, "下劈"),
}),
("法术能力", new[]
{
(AbilityType.Spell1, "法术槽 1"),
(AbilityType.Spell2, "法术槽 2"),
(AbilityType.Spell3, "法术槽 3"),
}),
("灵魄形态", new[]
{
(AbilityType.SpiritForm, "灵魄形态"),
(AbilityType.SpiritDash, "灵魄冲刺"),
}),
("战斗能力", new[]
{
(AbilityType.Parry, "弹反"),
(AbilityType.ChargeAttack, "蓄力攻击"),
(AbilityType.DownSlash, "下斩"),
}),
("互动能力", new[]
{
(AbilityType.Interact, "互动"),
(AbilityType.FastTravel, "快速旅行"),
}),
("能力强化", new[]
{
(AbilityType.InvincibleDash, "无敌冲刺"),
}),
};
// ── 布局常量 ──────────────────────────────────────────────────────────
private static readonly float RowH = EditorGUIUtility.singleLineHeight;
private static readonly float GroupHeaderH = EditorGUIUtility.singleLineHeight + 4f;
private static readonly float BtnRowH = EditorGUIUtility.singleLineHeight + 4f;
private const float Spacing = 2f;
private const float MinToggleW = 100f; // 每列最小宽度,用于动态计算列数
private const int MaxColCount = 3; // 列数上限
// 缓存每个属性路径上次渲染时的列数,供 GetPropertyHeight 使用
private static readonly Dictionary<string, int> _colsCache = new();
private static int ComputeCols(float availableWidth)
=> Mathf.Clamp(Mathf.FloorToInt(availableWidth / MinToggleW), 1, MaxColCount);
// ── 高度计算 ──────────────────────────────────────────────────────────
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
float h = RowH + Spacing; // 属性标签行(含 None / All 按钮)
// 使用上次 OnGUI 缓存的列数;首次绘制前按视图宽度估算
float viewW = EditorGUIUtility.currentViewWidth - EditorGUI.indentLevel * 15f - 18f;
int cols = _colsCache.TryGetValue(property.propertyPath, out var cached)
? cached
: ComputeCols(viewW);
foreach (var (_, members) in Groups)
{
h += GroupHeaderH + Spacing;
int rows = Mathf.CeilToInt((float)members.Length / cols);
h += rows * (RowH + Spacing);
}
return h + 4f;
}
// ── 绘制 ──────────────────────────────────────────────────────────────
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
EditorGUI.BeginProperty(position, label, property);
float y = position.y;
float x = position.x;
float w = position.width;
int cols = ComputeCols(w);
_colsCache[property.propertyPath] = cols;
uint current = (uint)property.longValue;
bool changed = false;
// ── 统计已启用数量 ────────────────────────────────────────────────
int enabledCount = 0, totalCount = 0;
foreach (var (_, mems) in Groups)
foreach (var (flag, _) in mems)
{
totalCount++;
if ((current & (uint)flag) != 0) enabledCount++;
}
// ── 标签行 + None / All 快捷按钮 + 数量提示 ──────────────────────
Rect labelRect = new Rect(x, y, EditorGUIUtility.labelWidth, RowH);
Rect btnNoneRect = new Rect(x + EditorGUIUtility.labelWidth, y, 50f, RowH);
Rect btnAllRect = new Rect(btnNoneRect.xMax + 4f, y, 50f, RowH);
Rect countRect = new Rect(btnAllRect.xMax + 8f, y, w - (btnAllRect.xMax + 8f - x), RowH);
EditorGUI.LabelField(labelRect, label);
if (GUI.Button(btnNoneRect, "None"))
{
current = 0;
changed = true;
}
if (GUI.Button(btnAllRect, "All"))
{
uint all = 0;
foreach (var (_, mems) in Groups)
foreach (var (flag, _) in mems)
all |= (uint)flag;
current = all;
changed = true;
}
var countStyle = new GUIStyle(EditorStyles.miniLabel)
{
normal = { textColor = enabledCount > 0
? new Color(0.55f, 0.85f, 0.55f)
: new Color(0.6f, 0.6f, 0.6f) }
};
EditorGUI.LabelField(countRect, $"({enabledCount} / {totalCount} 项已解锁)", countStyle);
y += RowH + Spacing;
// ── 各分组 ────────────────────────────────────────────────────────
foreach (var (groupLabel, members) in Groups)
{
// 分组标题(含组级全选/清空按钮)
Rect groupRect = new Rect(x, y, w, GroupHeaderH);
uint newCurrent = DrawGroupHeader(groupRect, groupLabel, members, current);
if (newCurrent != current) { current = newCurrent; changed = true; }
y += GroupHeaderH + Spacing;
// 复选框网格(列宽均分可用宽度,列数随窗口大小自动调整)
float toggleW = w / cols;
for (int i = 0; i < members.Length; i++)
{
int col = i % cols;
if (col == 0 && i > 0)
y += RowH + Spacing; // 换行
Rect togRect = new Rect(x + col * toggleW, y, toggleW, RowH);
var (flag, toggleLabel) = members[i];
bool isOn = (current & (uint)flag) != 0;
bool newOn = GUI.Toggle(togRect, isOn, toggleLabel, EditorStyles.toggle);
if (newOn != isOn)
{
if (newOn) current |= (uint)flag;
else current &= ~(uint)flag;
changed = true;
}
// 若是最后一个且在本行
if (i == members.Length - 1)
y += RowH + Spacing;
}
}
if (changed)
{
property.longValue = (long)(uint)current;
property.serializedObject.ApplyModifiedProperties();
}
EditorGUI.EndProperty();
}
// ── 辅助:分组标题绘制(含组级全选/清空按钮与已选数量)────────────────
private static uint DrawGroupHeader(Rect rect, string text,
(AbilityType flag, string label)[] members, uint current)
{
// 计算本组已选数量
int groupEnabled = 0;
foreach (var (flag, _) in members)
if ((current & (uint)flag) != 0) groupEnabled++;
Color old = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.25f, 0.25f, 0.28f, 1f);
GUI.Box(new Rect(rect.x, rect.y, rect.width, rect.height - 2f), GUIContent.none, EditorStyles.helpBox);
GUI.backgroundColor = old;
// 分组标签(含已选/总数)
Rect labelRect = new Rect(rect.x + 4f, rect.y + 1f, rect.width - 86f, RowH);
EditorGUI.LabelField(labelRect,
$"{text} {groupEnabled}/{members.Length}",
EditorStyles.boldLabel);
// 组级按钮:全选 / 清空
const float BtnW = 36f;
Rect btnAll = new Rect(rect.xMax - BtnW * 2 - 6f, rect.y + 1f, BtnW, RowH);
Rect btnNone = new Rect(rect.xMax - BtnW - 4f, rect.y + 1f, BtnW, RowH);
if (GUI.Button(btnAll, "全选", EditorStyles.miniButton))
foreach (var (flag, _) in members) current |= (uint)flag;
if (GUI.Button(btnNone, "清空", EditorStyles.miniButton))
foreach (var (flag, _) in members) current &= ~(uint)flag;
// 底部分割线
EditorGUI.DrawRect(new Rect(rect.x, rect.yMax - 2f, rect.width, 1f),
new Color(0.45f, 0.45f, 0.50f, 0.8f));
return current;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 60df999cbd27df94eb8ffd215c336b27
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -27,6 +27,7 @@
"Kybernetik.Animancer", "Kybernetik.Animancer",
"BaseGames.Animation", "BaseGames.Animation",
"BaseGames.Equipment", "BaseGames.Equipment",
"BaseGames.Parry",
"BaseGames.Skills", "BaseGames.Skills",
"BaseGames.World.Map", "BaseGames.World.Map",
"BaseGames.EventChain", "BaseGames.EventChain",

View File

@@ -1,5 +1,6 @@
using System.Reflection; using System.Reflection;
using UnityEditor; using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine; using UnityEngine;
using Unity.Cinemachine; using Unity.Cinemachine;
using BaseGames.Camera; using BaseGames.Camera;
@@ -17,7 +18,7 @@ namespace BaseGames.Editor
/// FOV 优先级(降序): /// FOV 优先级(降序):
/// 专有 DedicatedCamera.Lens.FieldOfView /// 专有 DedicatedCamera.Lens.FieldOfView
/// → CameraLensConfigSO.fieldOfView单一来源无跨场景依赖 /// → CameraLensConfigSO.fieldOfView单一来源无跨场景依赖
/// → CameraStateController._vcamAPersistent 场景已加载时 /// → CameraStateController 活动 VCam 的 FieldOfView编辑器备用
/// → Camera.main.fieldOfView /// → Camera.main.fieldOfView
/// → 60f默认 /// → 60f默认
/// </summary> /// </summary>
@@ -41,6 +42,7 @@ namespace BaseGames.Editor
private bool _foldFollow = true; private bool _foldFollow = true;
private bool _foldLens = false; private bool _foldLens = false;
private bool _foldCamera = false; private bool _foldCamera = false;
private bool _foldNoise = false;
private bool _foldTools = false; private bool _foldTools = false;
// ── 折叠标题样式缓存(深色背景 + 白色文字)──────────────────────────── // ── 折叠标题样式缓存(深色背景 + 白色文字)────────────────────────────
@@ -78,7 +80,7 @@ namespace BaseGames.Editor
EditorGUILayout.PropertyField(confinerProp, new GUIContent("Confiner Collider")); EditorGUILayout.PropertyField(confinerProp, new GUIContent("Confiner Collider"));
} }
if (!confinerOk) if (!confinerOk)
EditorGUILayout.HelpBox("必须绑定子节点 PolygonCollider2DAreaBoundary否则 Cinemachine 无法限位。", MessageType.Error); EditorGUILayout.HelpBox("必须绑定子节点 BoxColliderAreaBoundary否则 CinemachineConfiner3D 无法限位。", MessageType.Error);
EditorGUILayout.PropertyField(serializedObject.FindProperty("_visibleBounds"), new GUIContent("Visible Bounds本地坐标")); EditorGUILayout.PropertyField(serializedObject.FindProperty("_visibleBounds"), new GUIContent("Visible Bounds本地坐标"));
} }
@@ -86,11 +88,11 @@ namespace BaseGames.Editor
EditorGUILayout.Space(2f); EditorGUILayout.Space(2f);
// ── 跟随参数覆盖 ───────────────────────────────────────────────── // ── 跟随行为参数 ─────────────────────────────────────────────────
var overrideProp = serializedObject.FindProperty("_overrideFollowBehaviour"); var overrideProp = serializedObject.FindProperty("_overrideFollowBehaviour");
bool overrides = overrideProp.boolValue; bool overrides = overrideProp.boolValue;
_foldFollow = DrawFoldoutHeader( _foldFollow = DrawFoldoutHeader(
overrides ? "跟随参数覆盖 ●" : "跟随参数覆盖 ○ (使用全局默认)", _foldFollow); overrides ? "相机行为参数 ●" : "相机行为参数 ○ (使用 VCam 默认参数)", _foldFollow);
if (_foldFollow) if (_foldFollow)
{ {
using (new EditorGUI.IndentLevelScope()) using (new EditorGUI.IndentLevelScope())
@@ -108,6 +110,25 @@ namespace BaseGames.Editor
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lookaheadSmoothing"),new GUIContent("Lookahead Smoothing")); EditorGUILayout.PropertyField(serializedObject.FindProperty("_lookaheadSmoothing"),new GUIContent("Lookahead Smoothing"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lockHorizontal"), new GUIContent("Lock Horizontal")); EditorGUILayout.PropertyField(serializedObject.FindProperty("_lockHorizontal"), new GUIContent("Lock Horizontal"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lockVertical"), new GUIContent("Lock Vertical")); EditorGUILayout.PropertyField(serializedObject.FindProperty("_lockVertical"), new GUIContent("Lock Vertical"));
// ── 方向感知偏置 ──────────────────────────────────
EditorGUILayout.Space(4f);
EditorGUILayout.LabelField("方向感知水平偏置", EditorStyles.miniLabel);
var overrideFacingProp = serializedObject.FindProperty("_overrideFacingBias");
EditorGUILayout.PropertyField(overrideFacingProp, new GUIContent("Override Facing Bias",
"开启后可为此区域单独设置偏置量;关闭则使用 VCam 扩展组件的默认值。"));
if (overrideFacingProp.boolValue)
{
using (new EditorGUI.IndentLevelScope())
{
var biasProp = serializedObject.FindProperty("_facingBiasOverride");
EditorGUILayout.PropertyField(biasProp, new GUIContent("Facing Bias (units)",
"方向感知偏置量世界单位。0 = 禁用此区域的方向偏置。\n" +
"较窄走廊建议设 0防止相机超出 Confiner 边界。"));
if (biasProp.floatValue < 0.01f)
EditorGUILayout.HelpBox("设为 0 将禁用此区域的方向感知偏置。", MessageType.Info);
}
}
} }
} }
} }
@@ -128,8 +149,8 @@ namespace BaseGames.Editor
EditorGUILayout.Space(2f); EditorGUILayout.Space(2f);
// ── 专有相机(可选) ────────────────────────────────────────────── // ── 虚拟相机 ──────────────────────────────────────────────
_foldCamera = DrawFoldoutHeader("专有相机(可选)", _foldCamera); _foldCamera = DrawFoldoutHeader("虚拟相机", _foldCamera);
if (_foldCamera) if (_foldCamera)
{ {
using (new EditorGUI.IndentLevelScope()) using (new EditorGUI.IndentLevelScope())
@@ -140,7 +161,29 @@ namespace BaseGames.Editor
} }
EditorGUILayout.Space(2f); EditorGUILayout.Space(2f);
// ── 相机噪音(氛围震动) ────────────────────────────────────────
_foldNoise = DrawFoldoutHeader("相机噪音(氛围震动)", _foldNoise);
if (_foldNoise)
{
using (new EditorGUI.IndentLevelScope())
{
var noiseProp = serializedObject.FindProperty("_noiseProfile");
EditorGUILayout.PropertyField(noiseProp, new GUIContent("Noise Profile",
"氛围震动配置Noise Settings 资产),留空则禁用噪音。"));
if (noiseProp.objectReferenceValue != null)
{
EditorGUILayout.PropertyField(serializedObject.FindProperty("_noiseAmplitude"),
new GUIContent("Amplitude Gain", "振幅增益0 = 无震动,推荐 0.2 ~ 0.8)。"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("_noiseFrequency"),
new GUIContent("Frequency Gain", "频率增益1 = 资产原始频率)。"));
}
EditorGUILayout.HelpBox(
"专属 VCam 已包含 CinemachineBasicMultiChannelPerlin 组件(使用工具创建时自动附加)。",
MessageType.Info);
}
}
EditorGUILayout.Space(2f);
// ── 可视区域工具 ────────────────────────────────────────────────── // ── 可视区域工具 ──────────────────────────────────────────────────
_foldTools = DrawFoldoutHeader("可视区域工具", _foldTools); _foldTools = DrawFoldoutHeader("可视区域工具", _foldTools);
if (_foldTools) if (_foldTools)
@@ -247,7 +290,7 @@ namespace BaseGames.Editor
EditorGUILayout.Space(4f); EditorGUILayout.Space(4f);
DrawLegend("■ 黄色矩形Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围"); DrawLegend("■ 黄色矩形Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
DrawLegend("■ 蓝色多边Scene 视图)", kConfinerLine, "限位区域 — CinemachineConfiner2D 的运动边界"); DrawLegend("■ 蓝色Scene 视图)", kConfinerLine, "限位区域 — CinemachineConfiner3D 的运动边界");
} }
} }
@@ -319,7 +362,7 @@ namespace BaseGames.Editor
{ {
Undo.RecordObject(area, "Edit Visible Bounds"); Undo.RecordObject(area, "Edit Visible Bounds");
if (area.ConfinerCollider != null) if (area.ConfinerCollider != null)
Undo.RecordObject(area.ConfinerCollider, "Sync Confiner"); Undo.RecordObject(area.ConfinerCollider, "Sync Confiner"); // BoxCollider
// 世界坐标 → 本地坐标,存入序列化字段 // 世界坐标 → 本地坐标,存入序列化字段
boundsP.rectValue = new Rect(r.x - areaPos.x, r.y - areaPos.y, r.width, r.height); boundsP.rectValue = new Rect(r.x - areaPos.x, r.y - areaPos.y, r.width, r.height);
@@ -380,17 +423,21 @@ namespace BaseGames.Editor
private static void DrawConfinerGizmo(CameraArea area) private static void DrawConfinerGizmo(CameraArea area)
{ {
var poly = area.ConfinerCollider; var box = area.ConfinerCollider;
if (poly == null || poly.pathCount == 0) return; if (box == null) return;
int ptCount = poly.GetTotalPointCount(); // 将 BoxCollider 的 XY 范围投影到 Scene 视图(忽略 Z2D 俯视)
if (ptCount < 3) return; Vector3 centerWorld = box.transform.TransformPoint(box.center);
float hw = box.size.x * 0.5f;
float hh = box.size.y * 0.5f;
var pts2 = new System.Collections.Generic.List<Vector2>(ptCount); var pts3 = new Vector3[]
poly.GetPath(0, pts2); {
var pts3 = new Vector3[ptCount]; new Vector3(centerWorld.x - hw, centerWorld.y - hh, 0f), // BL
for (int i = 0; i < ptCount; i++) new Vector3(centerWorld.x + hw, centerWorld.y - hh, 0f), // BR
pts3[i] = poly.transform.TransformPoint(pts2[i]); new Vector3(centerWorld.x + hw, centerWorld.y + hh, 0f), // TR
new Vector3(centerWorld.x - hw, centerWorld.y + hh, 0f), // TL
};
DrawPolyGizmo(pts3, kConfinerFill, kConfinerLine, 2.0f); DrawPolyGizmo(pts3, kConfinerFill, kConfinerLine, 2.0f);
@@ -510,17 +557,16 @@ namespace BaseGames.Editor
internal static void SyncConfinerFromVisibleBounds(CameraArea area, float vFOV, float aspect) internal static void SyncConfinerFromVisibleBounds(CameraArea area, float vFOV, float aspect)
{ {
var poly = area.ConfinerCollider; var box = area.ConfinerCollider;
if (poly == null) if (box == null)
{ {
Debug.LogWarning($"[CameraAreaEditor] {area.name}ConfinerCollider 未绑定,无法同步。"); Debug.LogWarning($"[CameraAreaEditor] {area.name}ConfinerCollider 未绑定,无法同步。");
return; return;
} }
// VisibleBounds 已含 transform.position为世界坐标。 // VisibleBounds 已含 transform.position为世界坐标。
// 限位多边形 = 相机中心运动范围 = VisibleBounds 向内收缩视口半尺寸。 // 限位体积 = 相机中心运动范围 = VisibleBounds 向内收缩视口半尺寸。
// 运行时 ConfigureSlot 设置 OversizeWindow.MaxWindowSize ≈ 0 // Confiner3D 以 BoxCollider 直接约束相机 3D 位置,无需 OversizeWindow 补偿。
// 阻止 Cinemachine 再次收缩此多边形,确保边界精确匹配可视区域。
Rect visible = area.VisibleBounds; // 世界坐标 Rect visible = area.VisibleBounds; // 世界坐标
float depth = area.CameraDepth; float depth = area.CameraDepth;
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad); float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
@@ -532,25 +578,21 @@ namespace BaseGames.Editor
float yMax = visible.yMax - halfH; float yMax = visible.yMax - halfH;
// 小房间:视口大于可视区域时收缩至中心点,相机固定在可视区域中心 // 小房间:视口大于可视区域时收缩至中心点,相机固定在可视区域中心
const float kMinSize = 0.001f; // BoxCollider 允许 size = 0Confiner3D 不需要最小尺寸
if (xMin > xMax) { float cx = visible.center.x; xMin = cx - kMinSize * 0.5f; xMax = cx + kMinSize * 0.5f; } if (xMin > xMax) { float cx = visible.center.x; xMin = cx; xMax = cx; }
if (yMin > yMax) { float cy = visible.center.y; yMin = cy - kMinSize * 0.5f; yMax = cy + kMinSize * 0.5f; } if (yMin > yMax) { float cy = visible.center.y; yMin = cy; yMax = cy; }
Transform polyT = poly.transform; // BoxCollider center 以其 Transform 本地坐标表示
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w); // 相机在世界 Z = -depthAreaBoundary 节点在 Z = 0所以 center.z = -depth
Transform boxT = box.transform;
Vector3 centerWorld = new Vector3((xMin + xMax) * 0.5f, (yMin + yMax) * 0.5f, 0f);
Vector3 centerLocal = boxT.InverseTransformPoint(centerWorld);
centerLocal.z = -depth;
Undo.RecordObject(poly, "Sync Confiner from Visible Bounds"); Undo.RecordObject(box, "Sync Confiner from Visible Bounds");
// 顶点必须 CCW逆时针Clipper 对 CW 多边形area<0会取反 delta box.center = centerLocal;
// 导致 Confiner 向外膨胀而非向内收缩,相机完全不受限。 box.size = new Vector3(xMax - xMin, yMax - yMin, 1f);
// CCW 顺序BL → BR → TR → TL EditorUtility.SetDirty(box);
poly.SetPath(0, new[]
{
Local(new Vector3(xMin, yMin, 0f)), // BL
Local(new Vector3(xMax, yMin, 0f)), // BR
Local(new Vector3(xMax, yMax, 0f)), // TR
Local(new Vector3(xMin, yMax, 0f)), // TL
});
EditorUtility.SetDirty(poly);
// 记录本次同步所用的 FOV供编辑器过期检测使用 // 记录本次同步所用的 FOV供编辑器过期检测使用
var areaSO = new SerializedObject(area); var areaSO = new SerializedObject(area);
@@ -575,6 +617,180 @@ namespace BaseGames.Editor
internal static void SyncConfinerAuto(CameraArea area) => internal static void SyncConfinerAuto(CameraArea area) =>
SyncConfinerFromVisibleBounds(area, GetFOV(area), GetAspect()); SyncConfinerFromVisibleBounds(area, GetFOV(area), GetAspect());
// ── VCam 组件顺序检测 / 修复 ─────────────────────────────────────────
// 必须在 CinemachineConfiner3D 之前运行的扩展类型(修改 RawPosition需被 Confiner 裁剪)
private static readonly System.Type[] s_MustBeforeConfiner =
{
typeof(CameraAsymmetricDampingExtension),
typeof(CameraFallBiasExtension),
typeof(CameraFacingBiasExtension),
typeof(CameraAdaptiveLookaheadExtension),
};
/// <summary>
/// 检查 <paramref name="vcam"/> 的扩展组件顺序是否正确。
/// 返回问题描述字符串;若无问题则返回 <c>null</c>。
/// </summary>
internal static string CheckVCamExtensionOrderIssue(CinemachineCamera vcam)
{
if (vcam == null) return null;
var confiner = vcam.GetComponent<CinemachineConfiner3D>();
if (confiner == null) return "缺少 CinemachineConfiner3D 组件";
Component[] comps = vcam.GetComponents<Component>();
int confinerIdx = System.Array.IndexOf(comps, confiner);
var sb = new System.Text.StringBuilder();
foreach (var t in s_MustBeforeConfiner)
{
var comp = vcam.GetComponent(t);
if (comp == null) continue;
if (System.Array.IndexOf(comps, comp) > confinerIdx)
sb.AppendLine($" · {t.Name} 在 Confiner 之后(偏置绕过限位 → 相机逃出区域)");
}
var axisLock = vcam.GetComponent<CameraAxisLockExtension>();
if (axisLock != null && System.Array.IndexOf(comps, axisLock) < confinerIdx)
sb.AppendLine(" · CameraAxisLockExtension 在 Confiner 之前(轴向锁定会被 Confiner 覆盖失效)");
return sb.Length > 0 ? sb.ToString().TrimEnd() : null;
}
/// <summary>
/// 自动修正 <paramref name="vcam"/> 上扩展组件的挂载顺序:
/// 将偏置扩展移到 <see cref="CinemachineConfiner3D"/> 之前,
/// 将 <see cref="CameraAxisLockExtension"/> 移到之后。
/// </summary>
internal static void FixVCamExtensionOrder(CinemachineCamera vcam)
{
if (vcam == null) return;
var confiner = vcam.GetComponent<CinemachineConfiner3D>();
if (confiner == null)
{
Debug.LogWarning($"[CameraAreaEditor] {vcam.name} 缺少 CinemachineConfiner3D无法修正顺序。");
return;
}
Undo.RegisterCompleteObjectUndo(vcam.gameObject, "Fix VCam Extension Order");
// 将偏置扩展逐个移到 Confiner 之前
foreach (var t in s_MustBeforeConfiner)
{
var comp = vcam.GetComponent(t);
if (comp == null) continue;
// 反复上移,直到位于 Confiner 之前(最多 30 步防死循环)
for (int guard = 0; guard < 30; guard++)
{
Component[] comps = vcam.GetComponents<Component>();
int compIdx = System.Array.IndexOf(comps, comp);
int confinerIdx = System.Array.IndexOf(comps, confiner);
if (compIdx < confinerIdx) break;
UnityEditorInternal.ComponentUtility.MoveComponentUp(comp);
}
}
// 将 AxisLock 移到 Confiner 之后
var axisLock = vcam.GetComponent<CameraAxisLockExtension>();
if (axisLock != null)
{
for (int guard = 0; guard < 30; guard++)
{
Component[] comps = vcam.GetComponents<Component>();
int axisIdx = System.Array.IndexOf(comps, axisLock);
int confinerIdx = System.Array.IndexOf(comps, confiner);
if (axisIdx > confinerIdx) break;
UnityEditorInternal.ComponentUtility.MoveComponentDown(axisLock);
}
}
EditorUtility.SetDirty(vcam.gameObject);
Debug.Log($"[CameraAreaEditor] 已修正 {vcam.name} 的扩展组件顺序。");
}
/// <summary>
/// 为指定 <paramref name="area"/> 创建子节点专有 VCam附加所有必要组件并绑定到
/// <c>_dedicatedCamera</c>。若已有专有 VCam 则直接返回现有实例,不重复创建。
/// </summary>
internal static CinemachineCamera CreateDedicatedVCamForArea(CameraArea area)
{
if (area == null) return null;
if (area.DedicatedCamera != null)
{
Debug.LogWarning(
$"[CameraAreaEditor] {area.name} 已有专有 VCam{area.DedicatedCamera.name},跳过创建。");
return area.DedicatedCamera;
}
string vcamName = $"VCam_{area.gameObject.name}";
var vcamGO = new GameObject(vcamName);
Undo.RegisterCreatedObjectUndo(vcamGO, "Create Dedicated VCam");
vcamGO.transform.SetParent(area.transform);
vcamGO.transform.localPosition = Vector3.zero;
// ── CinemachineCamera ─────────────────────────────────────────────
var vcam = vcamGO.AddComponent<CinemachineCamera>();
vcam.Priority = 0; // 非激活;由 CameraStateController.ActivateDedicated 提升优先级
// ── Body位置合成器 ───────────────────────────────────────────────
var composer = vcamGO.AddComponent<CinemachinePositionComposer>();
// ── 扩展组件管线顺序Body 阶段偏置扩展 → PostBody Confiner → 独立 AxisLock → Noise
// AsymmetricDamping → FallBias → FacingBias → AdaptiveLookahead
// vcamGO.AddComponent<CameraAsymmetricDampingExtension>();
// vcamGO.AddComponent<CameraFallBiasExtension>();
// vcamGO.AddComponent<CameraFacingBiasExtension>();
// vcamGO.AddComponent<CameraAdaptiveLookaheadExtension>();
vcamGO.AddComponent<CinemachinePixelPerfect>();
// ── 限位 ConfinerPostBody 阶段,须在位置偏置扩展之后)────────────
var confiner3d = vcamGO.AddComponent<CinemachineConfiner3D>();
if (area.ConfinerCollider != null)
confiner3d.BoundingVolume = area.ConfinerCollider;
// ── 轴向约束独立PostBody────────────────────────────────────
vcamGO.AddComponent<CameraAxisLockExtension>();
// ── 噪音Noise 阶段,须排在 Confiner / AxisLock 之后)───────────
vcamGO.AddComponent<CinemachineBasicMultiChannelPerlin>();
// ── 应用镜头参数FOV + CameraDistance + Transform Z ─────────────
// 与 CameraStateController.ApplyLensToVcam 逻辑保持一致:
// 1. Lens.FieldOfView
// 2. composer.CameraDistance控制运行时真实 Z 距离,否则管线会覆盖)
// 3. transform.localPosition.z编辑器预览与运行时保持一致
float fov = area.LensConfig != null ? area.LensConfig.fieldOfView : 60f;
float depth = area.CameraDepth > 0f ? area.CameraDepth : 10f;
var lens = vcam.Lens;
lens.FieldOfView = fov;
vcam.Lens = lens;
composer.CameraDistance = depth;
var localPos = vcamGO.transform.localPosition;
localPos.z = -depth;
vcamGO.transform.localPosition = localPos;
// ── 写入 CameraArea._dedicatedCamera及默认优先级───────────────
var so = new SerializedObject(area);
so.Update();
so.FindProperty("_dedicatedCamera").objectReferenceValue = vcam;
var priProp = so.FindProperty("_dedicatedPriority");
if (priProp.intValue == 0) priProp.intValue = 20;
so.ApplyModifiedProperties();
EditorUtility.SetDirty(area);
EditorSceneManager.MarkSceneDirty(area.gameObject.scene);
EditorGUIUtility.PingObject(vcamGO);
Debug.Log($"[CameraAreaEditor] 已为 {area.name} 创建专有 VCam{vcamName}" +
$" FOV={fov:F1}° Depth={depth:F1}");
return vcam;
}
/// <summary> /// <summary>
/// 与 <see cref="SyncConfinerFromVisibleBounds"/> 逻辑相同,但不记录 Undo、不输出日志。 /// 与 <see cref="SyncConfinerFromVisibleBounds"/> 逻辑相同,但不记录 Undo、不输出日志。
/// 供拖拽 Handle 时每帧调用,避免 Undo 堆积和 Console 刷屏。 /// 供拖拽 Handle 时每帧调用,避免 Undo 堆积和 Console 刷屏。
@@ -582,8 +798,8 @@ namespace BaseGames.Editor
/// </summary> /// </summary>
private static void SyncConfinerQuiet(CameraArea area, float vFOV, float aspect) private static void SyncConfinerQuiet(CameraArea area, float vFOV, float aspect)
{ {
var poly = area.ConfinerCollider; var box = area.ConfinerCollider;
if (poly == null) return; if (box == null) return;
// VisibleBounds 已含 transform.position为世界坐标。 // VisibleBounds 已含 transform.position为世界坐标。
Rect visible = area.VisibleBounds; Rect visible = area.VisibleBounds;
@@ -596,22 +812,17 @@ namespace BaseGames.Editor
float yMin = visible.yMin + halfH; float yMin = visible.yMin + halfH;
float yMax = visible.yMax - halfH; float yMax = visible.yMax - halfH;
const float kMinSize = 0.001f; if (xMin > xMax) { float cx = visible.center.x; xMin = cx; xMax = cx; }
if (xMin > xMax) { float cx = visible.center.x; xMin = cx - kMinSize * 0.5f; xMax = cx + kMinSize * 0.5f; } if (yMin > yMax) { float cy = visible.center.y; yMin = cy; yMax = cy; }
if (yMin > yMax) { float cy = visible.center.y; yMin = cy - kMinSize * 0.5f; yMax = cy + kMinSize * 0.5f; }
Transform polyT = poly.transform; Transform boxT = box.transform;
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w); Vector3 centerWorld = new Vector3((xMin + xMax) * 0.5f, (yMin + yMax) * 0.5f, 0f);
Vector3 centerLocal = boxT.InverseTransformPoint(centerWorld);
centerLocal.z = -depth;
// CCW 顺序BL → BR → TR → TL同 SyncConfinerFromVisibleBounds box.center = centerLocal;
poly.SetPath(0, new[] box.size = new Vector3(xMax - xMin, yMax - yMin, 1f);
{ EditorUtility.SetDirty(box);
Local(new Vector3(xMin, yMin, 0f)), // BL
Local(new Vector3(xMax, yMin, 0f)), // BR
Local(new Vector3(xMax, yMax, 0f)), // TR
Local(new Vector3(xMin, yMax, 0f)), // TL
});
EditorUtility.SetDirty(poly);
} }
/// <summary>在 Scene 视图左上角绘制叠加信息面板(屏幕空间)。</summary> /// <summary>在 Scene 视图左上角绘制叠加信息面板(屏幕空间)。</summary>
@@ -663,7 +874,7 @@ namespace BaseGames.Editor
// ══ 工具方法 ══════════════════════════════════════════════════════════ // ══ 工具方法 ══════════════════════════════════════════════════════════
/// <summary> /// <summary>
/// 获取用于透视计算的 FOV优先级 VCam → 全局 VCamA → Camera.main → 60f /// 获取用于透视计算的 FOV优先级 VCam → CameraLensConfigSO → Camera.main → 60f
/// </summary> /// </summary>
private static float GetFOV(CameraArea area) private static float GetFOV(CameraArea area)
{ {
@@ -675,7 +886,7 @@ namespace BaseGames.Editor
if (area.LensConfig != null) if (area.LensConfig != null)
return area.LensConfig.fieldOfView; return area.LensConfig.fieldOfView;
// 3. Persistent 场景已加载时,实时读取全局 VCamA兆底 // 3. CameraStateController 存在时,通过 LensConfig 读取 FOV备用底线
#pragma warning disable UNT0023 // FindObjectOfType 在编辑器工具中可接受 #pragma warning disable UNT0023 // FindObjectOfType 在编辑器工具中可接受
var ctrl = Object.FindObjectOfType<CameraStateController>(); var ctrl = Object.FindObjectOfType<CameraStateController>();
#pragma warning restore UNT0023 #pragma warning restore UNT0023

View File

@@ -17,8 +17,9 @@ namespace BaseGames.Editor
/// ///
/// 新格式: /// 新格式:
/// [新 CameraArea GO]CameraArea 组件_visibleBounds = 本地 Rect /// [新 CameraArea GO]CameraArea 组件_visibleBounds = 本地 Rect
/// ├─ AreaBoundaryPolygonCollider2DisTrigger=true,对应旧 Confiner /// ├─ AreaBoundaryBoxCollider对应旧 Confiner
/// ─ TriggerZoneCameraTriggerZone + PolygonCollider2D对应旧 TriggerRegion /// ─ TriggerZoneCameraTriggerZone + PolygonCollider2D对应旧 TriggerRegion
/// └─ VCam_xxxCinemachineCamera + 所有扩展组件,专属虚拟相机)
/// ///
/// 菜单BaseGames → Camera → 相机区域迁移工具 /// 菜单BaseGames → Camera → 相机区域迁移工具
/// </summary> /// </summary>
@@ -35,6 +36,7 @@ namespace BaseGames.Editor
private Transform _sourcesParent; // 旧 Zone_xxx 的父节点(通常名为 Zones private Transform _sourcesParent; // 旧 Zone_xxx 的父节点(通常名为 Zones
private Transform _targetParent; // 新对象放置位置(留空 = 与旧区域同级) private Transform _targetParent; // 新对象放置位置(留空 = 与旧区域同级)
private CameraLensConfigSO _lensConfig; // 绑定到新 CameraArea._lensConfig private CameraLensConfigSO _lensConfig; // 绑定到新 CameraArea._lensConfig
private bool _createDedicatedVCam = true; // 为每个区域创建专属 CinemachineCamera
// ── 运行时状态 ──────────────────────────────────────────────────────── // ── 运行时状态 ────────────────────────────────────────────────────────
@@ -86,6 +88,12 @@ namespace BaseGames.Editor
new GUIContent("镜头配置 SO", "赋给所有新 CameraArea._lensConfig留空则不赋值"), new GUIContent("镜头配置 SO", "赋给所有新 CameraArea._lensConfig留空则不赋值"),
_lensConfig, typeof(CameraLensConfigSO), false); _lensConfig, typeof(CameraLensConfigSO), false);
_createDedicatedVCam = EditorGUILayout.Toggle(
new GUIContent("创建专属 VCam",
"为每个迁移区域创建子节点 CinemachineCamera含所有扩展组件\n" +
"并绑定到 CameraArea._dedicatedCamera。"),
_createDedicatedVCam);
EditorGUILayout.Space(8); EditorGUILayout.Space(8);
@@ -220,17 +228,24 @@ namespace BaseGames.Editor
foreach (Transform child in _sourcesParent) foreach (Transform child in _sourcesParent)
{ {
Debug.Log($"[root]:{child.name}");
// 旧 Zone 的标识:子节点直属,且挂有 BoxCollider2D // 旧 Zone 的标识:子节点直属,且挂有 BoxCollider2D
var box = child.GetComponent<BoxCollider2D>(); var box = child.GetComponent<BoxCollider2D>();
if (box == null) continue; if (box == null) continue;
var entry = new ZoneEntry { ZoneObj = child.gameObject, VisibleBox = box }; var entry = new ZoneEntry { ZoneObj = child.gameObject, VisibleBox = box };
// 收集触发多边形顶点TriggerRegion 子节点的各个点对象) // 收集触发多边形顶点——xxx_TriggerRegion 下每个子节点的世界坐标即一个顶点,
Transform triggerRoot = FindChildContaining(child, "TriggerRegion"); // 按子节点顺序依次连线围成多边形触发区域。
if (triggerRoot != null) Transform triggerRoot = FindChildContaining(child, "Trigger");
foreach (Transform pt in triggerRoot)
if (triggerRoot != null){
Debug.Log($"[trigger]:{triggerRoot.name}");
foreach (Transform pt in triggerRoot){
Debug.Log($"{pt.name}");
entry.TriggerWorldPts.Add((Vector2)pt.position); entry.TriggerWorldPts.Add((Vector2)pt.position);
}
}
// 读取限位碰撞体Zone_xxx_Confiner 上的 Collider2D // 读取限位碰撞体Zone_xxx_Confiner 上的 Collider2D
Transform confinerT = FindChildContaining(child, "Confiner"); Transform confinerT = FindChildContaining(child, "Confiner");
@@ -298,20 +313,20 @@ namespace BaseGames.Editor
soArea.FindProperty("_lensConfig").objectReferenceValue = _lensConfig; soArea.FindProperty("_lensConfig").objectReferenceValue = _lensConfig;
soArea.ApplyModifiedProperties(); soArea.ApplyModifiedProperties();
// ── 3. 创建 AreaBoundary限位多边形isTrigger = true────────── // ── 3. 创建 AreaBoundary限位体积 BoxCollider─────────────────────────────
GameObject boundaryGO = new GameObject($"{zoneGO.name}_AreaBoundary"); GameObject boundaryGO = new GameObject($"{zoneGO.name}_AreaBoundary");
Undo.RegisterCreatedObjectUndo(boundaryGO, "Migrate Camera Zone"); Undo.RegisterCreatedObjectUndo(boundaryGO, "Migrate Camera Zone");
boundaryGO.transform.SetParent(areaGO.transform, worldPositionStays: false); boundaryGO.transform.SetParent(areaGO.transform, worldPositionStays: false);
boundaryGO.transform.localPosition = Vector3.zero; boundaryGO.transform.localPosition = Vector3.zero;
PolygonCollider2D confinerPoly = boundaryGO.AddComponent<PolygonCollider2D>(); BoxCollider confinerBox = boundaryGO.AddComponent<BoxCollider>();
confinerPoly.isTrigger = true; confinerBox.isTrigger = true;
confinerPoly.pathCount = 1; confinerBox.center = new Vector3(0f, 0f, -10f); // Z 占位符SyncConfiner 会立即重算
confinerPoly.SetPath(0, BuildConfinerPath(entry, worldPos, localBounds)); confinerBox.size = new Vector3(localBounds.width, localBounds.height, 1f);
// 绑定 _confinerCollider // 绑定 _confinerCollider
soArea.Update(); soArea.Update();
soArea.FindProperty("_confinerCollider").objectReferenceValue = confinerPoly; soArea.FindProperty("_confinerCollider").objectReferenceValue = confinerBox;
soArea.ApplyModifiedProperties(); soArea.ApplyModifiedProperties();
EditorUtility.SetDirty(area); EditorUtility.SetDirty(area);
@@ -330,9 +345,10 @@ namespace BaseGames.Editor
// AddComponent 会因 [RequireComponent] 自动添加 PolygonCollider2D // AddComponent 会因 [RequireComponent] 自动添加 PolygonCollider2D
CameraTriggerZone triggerComp = triggerGO.AddComponent<CameraTriggerZone>(); CameraTriggerZone triggerComp = triggerGO.AddComponent<CameraTriggerZone>();
PolygonCollider2D triggerPoly = triggerGO.GetComponent<PolygonCollider2D>(); PolygonCollider2D triggerPoly = triggerGO.GetComponent<PolygonCollider2D>();
// 将旧触发多边形路径(本地坐标,相对于新 CameraArea 世界位置)直接赋给 PolygonCollider2D
Vector2[] triggerPath = BuildTriggerPath(entry, worldPos, localBounds);
triggerPoly.isTrigger = true; triggerPoly.isTrigger = true;
triggerPoly.pathCount = 1; triggerPoly.SetPath(0, triggerPath);
triggerPoly.SetPath(0, BuildTriggerPath(entry, worldPos, localBounds));
// _targetArea → 指向刚创建的 CameraArea // _targetArea → 指向刚创建的 CameraArea
var soTrigger = new SerializedObject(triggerComp); var soTrigger = new SerializedObject(triggerComp);
@@ -340,7 +356,11 @@ namespace BaseGames.Editor
soTrigger.ApplyModifiedProperties(); soTrigger.ApplyModifiedProperties();
EditorUtility.SetDirty(triggerComp); EditorUtility.SetDirty(triggerComp);
// ── 5. 处理旧对象 ────────────────────────────────────────────── // ── 5. 创建专属 VCam每区域独立相机─────────────────────────────
if (_createDedicatedVCam)
CameraAreaEditor.CreateDedicatedVCamForArea(area);
// ── 6. 处理旧对象 ──────────────────────────────────────────────
// 先记录原始激活状态,再对旧对象做处理,避免 SetActive(false) 后误读 // 先记录原始激活状态,再对旧对象做处理,避免 SetActive(false) 后误读
bool wasActive = zoneGO.activeSelf; bool wasActive = zoneGO.activeSelf;
@@ -356,8 +376,8 @@ namespace BaseGames.Editor
Debug.Log($"[迁移工具] {zoneGO.name} → {areaGO.name} " + Debug.Log($"[迁移工具] {zoneGO.name} → {areaGO.name} " +
$"可视 {localBounds.width:F0}×{localBounds.height:F0} " + $"可视 {localBounds.width:F0}×{localBounds.height:F0} " +
$"触发 {triggerPoly.GetTotalPointCount()} pt " + $"触发 PolygonCollider2D ({triggerPath.Length} 点) " +
$"限位 {confinerPoly.GetTotalPointCount()} pt"); $"限位 BoxCollider ({confinerBox.size.x:F1}×{confinerBox.size.y:F1})");
} }
// ── 限位多边形路径 ──────────────────────────────────────────────────── // ── 限位多边形路径 ────────────────────────────────────────────────────
@@ -405,14 +425,40 @@ namespace BaseGames.Editor
{ {
if (entry.TriggerWorldPts.Count >= 3) if (entry.TriggerWorldPts.Count >= 3)
{ {
var path = new Vector2[entry.TriggerWorldPts.Count]; // 将世界坐标转换为 areaGO 本地坐标
for (int i = 0; i < path.Length; i++) var localPts = new Vector2[entry.TriggerWorldPts.Count];
path[i] = entry.TriggerWorldPts[i] - (Vector2)areaWorldPos; for (int i = 0; i < localPts.Length; i++)
return path; localPts[i] = entry.TriggerWorldPts[i] - (Vector2)areaWorldPos;
// 按照质心角度排序,确保顶点顺序能够围成合法多边形
return SortPointsByAngle(localPts);
} }
return RectToPolygon(fallback); return RectToPolygon(fallback);
} }
/// <summary>
/// 将一组点按照围绕质心的角度(逆时针)排序,使其能够围成合法的简单多边形。
/// 适用于凸多边形及质心在多边形内部的凹多边形。
/// </summary>
private static Vector2[] SortPointsByAngle(Vector2[] points)
{
// 计算质心
Vector2 centroid = Vector2.zero;
foreach (var p in points)
centroid += p;
centroid /= points.Length;
// 按照相对质心的极角升序排列(逆时针)
var sorted = new System.Collections.Generic.List<Vector2>(points);
sorted.Sort((a, b) =>
{
float angleA = Mathf.Atan2(a.y - centroid.y, a.x - centroid.x);
float angleB = Mathf.Atan2(b.y - centroid.y, b.x - centroid.x);
return angleA.CompareTo(angleB);
});
return sorted.ToArray();
}
/// <summary> /// <summary>
/// 手动计算 BoxCollider2D 的世界 AABB不依赖 .boundsinactive 对象上 .bounds 无效)。 /// 手动计算 BoxCollider2D 的世界 AABB不依赖 .boundsinactive 对象上 .bounds 无效)。
/// </summary> /// </summary>

View File

@@ -98,6 +98,9 @@ namespace BaseGames.Editor
if (GUILayout.Button("↺ 全部同步限位区域", EditorStyles.toolbarButton)) if (GUILayout.Button("↺ 全部同步限位区域", EditorStyles.toolbarButton))
SyncAllConfiners(); SyncAllConfiners();
if (GUILayout.Button("✔ 批量创建专属 VCam", EditorStyles.toolbarButton))
BatchCreateDedicatedVCams();
} }
// ── 创建 CameraArea 面板 ─────────────────────────────────────── // ── 创建 CameraArea 面板 ───────────────────────────────────────
@@ -217,6 +220,40 @@ namespace BaseGames.Editor
Debug.Log($"[CameraAreaSetupTool] 已同步 {count} 个 CameraArea 的限位区域。"); Debug.Log($"[CameraAreaSetupTool] 已同步 {count} 个 CameraArea 的限位区域。");
} }
/// <summary>
/// 为所有尚未配置专有 VCam 的 CameraArea 批量创建专有 CinemachineCamera。
/// 已有 _dedicatedCamera 的区域将跳过。
/// </summary>
private void BatchCreateDedicatedVCams()
{
if (_cameraAreas.Count == 0)
{
Debug.LogWarning("[CameraAreaSetupTool] 场景中无 CameraArea跳过批量创建。");
return;
}
int count = 0;
foreach (var area in _cameraAreas)
{
if (area == null) continue;
if (area.DedicatedCamera != null) continue; // 已有专有 VCam跳过
CameraAreaEditor.CreateDedicatedVCamForArea(area);
count++;
}
if (count > 0)
{
RescanScene();
Debug.Log($"[CameraAreaSetupTool] 已为 {count} 个 CameraArea 创建专有 VCam。");
}
else
{
Debug.Log("[CameraAreaSetupTool] 所有 CameraArea 均已有专有 VCam无需创建。");
}
}
// ── CameraStateController ────────────────────────────────────────── // ── CameraStateController ──────────────────────────────────────────
private void DrawControllerSection() private void DrawControllerSection()
@@ -240,8 +277,6 @@ namespace BaseGames.Editor
} }
SerializedObject so = new SerializedObject(_controller); SerializedObject so = new SerializedObject(_controller);
DrawFieldCheck(so, "_vcamA", "全局 VCam A (CinemachineCamera)");
DrawFieldCheck(so, "_vcamB", "全局 VCam B (CinemachineCamera)");
DrawFieldCheck(so, "_brain", "CinemachineBrain"); DrawFieldCheck(so, "_brain", "CinemachineBrain");
DrawFieldCheck(so, "_onPlayerSpawned", "玩家生成事件 (EVT_PlayerSpawned) → VCam 自动绑 Follow"); DrawFieldCheck(so, "_onPlayerSpawned", "玩家生成事件 (EVT_PlayerSpawned) → VCam 自动绑 Follow");
DrawFieldCheck(so, "_impulseSource", "CinemachineImpulseSource", optional: true); DrawFieldCheck(so, "_impulseSource", "CinemachineImpulseSource", optional: true);
@@ -334,7 +369,8 @@ namespace BaseGames.Editor
bool confinerOk = so.FindProperty("_confinerCollider").objectReferenceValue != null; bool confinerOk = so.FindProperty("_confinerCollider").objectReferenceValue != null;
var boundZones = FindTriggerZonesForArea(area); var boundZones = FindTriggerZonesForArea(area);
bool hasZone = boundZones.Count > 0; bool hasZone = boundZones.Count > 0;
bool allOk = confinerOk && hasZone; bool hasVCam = so.FindProperty("_dedicatedCamera").objectReferenceValue != null;
bool allOk = confinerOk && hasZone && hasVCam;
using (new EditorGUILayout.VerticalScope(_boxStyle)) using (new EditorGUILayout.VerticalScope(_boxStyle))
{ {
@@ -353,6 +389,10 @@ namespace BaseGames.Editor
GUI.color = hasZone ? kOk : kError; GUI.color = hasZone ? kOk : kError;
GUILayout.Label(hasZone ? $"[{boundZones.Count} 触发器]" : "[无触发器]", GUILayout.Label(hasZone ? $"[{boundZones.Count} 触发器]" : "[无触发器]",
EditorStyles.miniLabel, GUILayout.Width(74f)); EditorStyles.miniLabel, GUILayout.Width(74f));
GUI.color = hasVCam ? kOk : kError;
GUILayout.Label(hasVCam ? "[VCam ✔]" : "[VCam ✗]",
EditorStyles.miniLabel, GUILayout.Width(54f));
GUI.color = prevC; GUI.color = prevC;
if (GUILayout.Button("⊙", GUILayout.Width(24f))) if (GUILayout.Button("⊙", GUILayout.Width(24f)))
@@ -366,12 +406,61 @@ namespace BaseGames.Editor
EditorGUILayout.Space(3f); EditorGUILayout.Space(3f);
// ── 绑定字段 ──────────────────────────────────────────────── // ── 绑定字段 ────────────────────────────────────────────────
DrawCheckRow("_confinerCollider可视边界 PolygonCollider2D", confinerOk); DrawCheckRow("_confinerCollider可视边界 BoxCollider", confinerOk);
DrawCheckRow("_dedicatedCamera专有 VCam可选",
so.FindProperty("_dedicatedCamera").objectReferenceValue != null, optional: true); // ── 专有 VCam 状态行(创建 / Ping 按鈕)────────────────────────
using (new EditorGUILayout.HorizontalScope())
{
Color prevC2 = GUI.color;
GUI.color = hasVCam ? kOk : kError;
GUILayout.Label(hasVCam ? "●" : "✗", GUILayout.Width(16f));
GUI.color = prevC2;
if (hasVCam)
{
var vcamObj = so.FindProperty("_dedicatedCamera").objectReferenceValue;
EditorGUILayout.LabelField($"_dedicatedCamera{vcamObj.name}",
GUILayout.ExpandWidth(true));
if (GUILayout.Button("⊙", GUILayout.Width(24f)))
EditorGUIUtility.PingObject(vcamObj);
if (GUILayout.Button("选中", GUILayout.Width(36f)))
Selection.activeObject = vcamObj;
}
else
{
EditorGUILayout.LabelField("_dedicatedCamera专有 VCam未创建",
GUILayout.ExpandWidth(true));
if (GUILayout.Button("创建专有 VCam", GUILayout.Width(90f), GUILayout.Height(18f)))
{
CameraAreaEditor.CreateDedicatedVCamForArea(area);
RescanScene();
}
}
}
DrawCheckRow("_blendProfile可选未设则用全局默认", DrawCheckRow("_blendProfile可选未设则用全局默认",
so.FindProperty("_blendProfile").objectReferenceValue != null, optional: true); so.FindProperty("_blendProfile").objectReferenceValue != null, optional: true);
// ── VCam 扩展组件顺序检查 ────────────────────────────────────
// AsymmetricDamping/FallBias/FacingBias 必须在 CinemachineConfiner3D 之前;
// AxisLock 必须在之后。顺序错误会使相机逃出限位区域。
if (hasVCam)
{
var vcam = so.FindProperty("_dedicatedCamera").objectReferenceValue as CinemachineCamera;
string issue = CameraAreaEditor.CheckVCamExtensionOrderIssue(vcam);
if (issue != null)
{
EditorGUILayout.HelpBox(
$"VCam 扩展组件顺序错误!相机会逃出限位区域:\n{issue}",
MessageType.Error);
if (GUILayout.Button("⚙ 自动修正组件顺序", GUILayout.Height(22f)))
{
CameraAreaEditor.FixVCamExtensionOrder(vcam);
RescanScene();
}
}
}
// ── 触发区域列表 ───────────────────────────────────────────── // ── 触发区域列表 ─────────────────────────────────────────────
EditorGUILayout.Space(3f); EditorGUILayout.Space(3f);
using (new EditorGUILayout.HorizontalScope()) using (new EditorGUILayout.HorizontalScope())
@@ -412,16 +501,16 @@ namespace BaseGames.Editor
EditorGUILayout.Space(3f); EditorGUILayout.Space(3f);
if (!confinerOk) if (!confinerOk)
{ {
// 区分:有非 Trigger 的 PolygonCollider2D 可直接绑定 vs 完全没有 AreaBoundary // 区分:有 BoxCollider 可直接绑定 vs 完全没有 AreaBoundary
var existingBoundary = FindBoundaryPoly(area); var existingBoundary = FindBoundaryBox(area);
if (existingBoundary != null) if (existingBoundary != null)
{ {
if (GUILayout.Button("修复:绑定子节点 PolygonCollider2D", GUILayout.Height(22f))) if (GUILayout.Button("修复:绑定子节点 BoxCollider", GUILayout.Height(22f)))
FixConfinerBinding(area); FixConfinerBinding(area);
} }
else else
{ {
if (GUILayout.Button("创建 AreaBoundary限位多边形,默认 24 × 12", GUILayout.Height(22f))) if (GUILayout.Button("创建 AreaBoundary限位体积,默认 24 × 12", GUILayout.Height(22f)))
{ {
CreateAreaBoundary(area); CreateAreaBoundary(area);
RescanScene(); RescanScene();
@@ -433,7 +522,7 @@ namespace BaseGames.Editor
Color helpC = GUI.color; Color helpC = GUI.color;
GUI.color = kMuted; GUI.color = kMuted;
EditorGUILayout.LabelField( EditorGUILayout.LabelField(
"★ 可视边界:选中子节点的 PolygonCollider2D,在 Scene 视图中编辑顶点。", "★ 限位体积:选中子节点的 BoxColliderInspector 中编辑 Center / Size。",
EditorStyles.miniLabel); EditorStyles.miniLabel);
GUI.color = helpC; GUI.color = helpC;
} }
@@ -557,34 +646,34 @@ namespace BaseGames.Editor
Debug.LogWarning("[CameraAreaSetupTool] _vcamA/_vcamB 均未绑定,无法赋值 Follow。请先在 Inspector 中绑定。"); Debug.LogWarning("[CameraAreaSetupTool] _vcamA/_vcamB 均未绑定,无法赋值 Follow。请先在 Inspector 中绑定。");
} }
/// <summary>将子节点中找到的第一个不含 CameraTriggerZone 的 PolygonCollider2D 绑定到 CameraArea._confinerCollider。</summary> /// <summary>将子节点中找到的第一个不含 CameraTriggerZone 的 BoxCollider 绑定到 CameraArea._confinerCollider。</summary>
private static void FixConfinerBinding(CameraArea area) private static void FixConfinerBinding(CameraArea area)
{ {
PolygonCollider2D poly = FindBoundaryPoly(area) BoxCollider box = FindBoundaryBox(area)
?? area.GetComponentInChildren<PolygonCollider2D>(true); ?? area.GetComponentInChildren<BoxCollider>(true);
if (poly == null) if (box == null)
{ {
Debug.LogWarning($"[CameraAreaSetupTool] {area.name}:子节点中未找到 PolygonCollider2D。"); Debug.LogWarning($"[CameraAreaSetupTool] {area.name}:子节点中未找到 BoxCollider。");
return; return;
} }
SerializedObject so = new SerializedObject(area); SerializedObject so = new SerializedObject(area);
so.FindProperty("_confinerCollider").objectReferenceValue = poly; so.FindProperty("_confinerCollider").objectReferenceValue = box;
so.ApplyModifiedProperties(); so.ApplyModifiedProperties();
Debug.Log($"[CameraAreaSetupTool] {area.name}_confinerCollider → {poly.gameObject.name}"); Debug.Log($"[CameraAreaSetupTool] {area.name}_confinerCollider → {box.gameObject.name}");
} }
/// <summary>返回 area 子节点中第一个不含 CameraTriggerZone 的 PolygonCollider2D(即 AreaBoundary 限位体)。</summary> /// <summary>返回 area 子节点中第一个不含 CameraTriggerZone 的 BoxCollider即 AreaBoundary 限位体)。</summary>
private static PolygonCollider2D FindBoundaryPoly(CameraArea area) private static BoxCollider FindBoundaryBox(CameraArea area)
{ {
foreach (var p in area.GetComponentsInChildren<PolygonCollider2D>(true)) foreach (var b in area.GetComponentsInChildren<BoxCollider>(true))
if (p.GetComponent<CameraTriggerZone>() == null) return p; if (b.GetComponent<CameraTriggerZone>() == null) return b;
return null; return null;
} }
/// <summary> /// <summary>
/// 为指定 CameraArea 创建 AreaBoundary 子节点(默认矩形限位多边形isTrigger = false)并绑定到 _confinerCollider。 /// 为指定 CameraArea 创建 AreaBoundary 子节点(默认 BoxCollider 限位体)并绑定到 _confinerCollider。
/// </summary> /// </summary>
private static void CreateAreaBoundary(CameraArea area) private static void CreateAreaBoundary(CameraArea area)
{ {
@@ -602,27 +691,20 @@ namespace BaseGames.Editor
childGo.transform.localPosition = Vector3.zero; childGo.transform.localPosition = Vector3.zero;
} }
PolygonCollider2D poly = childGo.GetComponent<PolygonCollider2D>() BoxCollider box = childGo.GetComponent<BoxCollider>()
?? childGo.AddComponent<PolygonCollider2D>(); ?? childGo.AddComponent<BoxCollider>();
poly.isTrigger = true; // 限位多边形,仅作为相机约束边界,不产生物理碰撞 box.center = new Vector3(0f, 0f, -10f); // Z 占位符,绑定 LensConfig 后点击「同步限位区域」更新
poly.pathCount = 1; box.size = new Vector3(24f, 12f, 1f); // 默认 24 × 12 占位符
poly.SetPath(0, new Vector2[]
{
new Vector2(-12f, -6f),
new Vector2(-12f, 6f),
new Vector2( 12f, 6f),
new Vector2( 12f, -6f),
});
EditorUtility.SetDirty(childGo); EditorUtility.SetDirty(childGo);
SerializedObject so = new SerializedObject(area); SerializedObject so = new SerializedObject(area);
so.Update(); so.Update();
so.FindProperty("_confinerCollider").objectReferenceValue = poly; so.FindProperty("_confinerCollider").objectReferenceValue = box;
so.ApplyModifiedProperties(); so.ApplyModifiedProperties();
EditorUtility.SetDirty(area); EditorUtility.SetDirty(area);
EditorGUIUtility.PingObject(childGo); EditorGUIUtility.PingObject(childGo);
Debug.Log($"[CameraAreaSetupTool] 已为 {area.name} 创建 AreaBoundary矩形 24 × 12。"); Debug.Log($"[CameraAreaSetupTool] 已为 {area.name} 创建 AreaBoundaryBoxCollider 默认 24 × 12。");
} }
/// <summary>返回所有以此 area 为激活目标的 CameraTriggerZone。</summary> /// <summary>返回所有以此 area 为激活目标的 CameraTriggerZone。</summary>
@@ -642,18 +724,10 @@ namespace BaseGames.Editor
/// <summary>为指定 CameraArea 创建配对的 CameraTriggerZone自动匹配 Confiner 范围。</summary> /// <summary>为指定 CameraArea 创建配对的 CameraTriggerZone自动匹配 Confiner 范围。</summary>
private static void CreateTriggerZoneForArea(CameraArea area) private static void CreateTriggerZoneForArea(CameraArea area)
{ {
// 用 PolygonCollider2D 包围盒作为放置中心和尺寸;没有则退回到 area 自身位置 // 用 VisibleBounds 作为放置中心和初始多边形范围
Vector3 center = area.transform.position; Rect visible = area.VisibleBounds;
Vector2 size = new Vector2(4f, 4f); Vector3 center = new Vector3(visible.center.x, visible.center.y, area.transform.position.z);
Vector2 half = visible.size * 0.5f;
var poly = area.GetComponentInChildren<PolygonCollider2D>(true);
if (poly != null)
{
Bounds b = poly.bounds;
center = b.center;
center.z = area.transform.position.z;
size = new Vector2(b.size.x, b.size.y);
}
var go = new GameObject($"{area.gameObject.name}_TriggerZone"); var go = new GameObject($"{area.gameObject.name}_TriggerZone");
Undo.RegisterCreatedObjectUndo(go, "Create CameraTriggerZone"); Undo.RegisterCreatedObjectUndo(go, "Create CameraTriggerZone");
@@ -661,19 +735,20 @@ namespace BaseGames.Editor
go.transform.SetParent(area.transform); go.transform.SetParent(area.transform);
go.transform.position = center; go.transform.position = center;
var col = go.AddComponent<PolygonCollider2D>(); // [RequireComponent] 会自动附加 PolygonCollider2D;先 AddComponent<CameraTriggerZone>
// 再通过 GetComponent 引用,避免顺序依赖问题
var zone = go.AddComponent<CameraTriggerZone>();
var col = go.GetComponent<PolygonCollider2D>();
col.isTrigger = true; col.isTrigger = true;
float hw = size.x * 0.5f; // 以 VisibleBounds 矩形四角为默认路径(可在 Inspector 中进一步编辑顶点)
float hh = size.y * 0.5f;
col.SetPath(0, new Vector2[] col.SetPath(0, new Vector2[]
{ {
new Vector2(-hw, -hh), new Vector2(-half.x, -half.y),
new Vector2(-hw, hh), new Vector2(-half.x, half.y),
new Vector2( hw, hh), new Vector2( half.x, half.y),
new Vector2( hw, -hh), new Vector2( half.x, -half.y),
}); });
var zone = go.AddComponent<CameraTriggerZone>();
var so = new SerializedObject(zone); var so = new SerializedObject(zone);
so.Update(); so.Update();
so.FindProperty("_targetArea").objectReferenceValue = area; so.FindProperty("_targetArea").objectReferenceValue = area;

View File

@@ -0,0 +1,835 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using BaseGames.Boss;
using BaseGames.Combat;
using BaseGames.Enemies;
using BaseGames.Equipment;
using BaseGames.Input;
using BaseGames.Parry;
using BaseGames.Player;
using BaseGames.Player.States;
using BaseGames.Skills;
namespace BaseGames.Editor
{
/// <summary>
/// 角色创建向导W-01— 统一入口窗口。
/// 技术UI Toolkit三标签页玩家 / 小怪 / Boss
/// 菜单BaseGames / Tools / Character Wizard
///
/// 各标签页均提供:
/// ① 当前 SO 资产状态速览(绿色=已存在 / 橙色=缺失)
/// ② SO 资产工厂(一键创建所需的所有 ScriptableObject
/// ③ 场景搭建快捷按钮(调用 SceneObjectPlacerTool.PlaceXxx
/// ④ 跳转到对应专项编辑器窗口
/// </summary>
public class CharacterWizardWindow : EditorWindow
{
// ── 常量 ──────────────────────────────────────────────────────────────
private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
private const string DataRoot = "Assets/_Game/Data";
private static StyleSheet _uss;
private static StyleSheet Uss =>
_uss != null ? _uss : (_uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath));
[MenuItem("BaseGames/Tools/Character Wizard", priority = 1)]
public static void Open()
{
var wnd = GetWindow<CharacterWizardWindow>();
wnd.titleContent = new GUIContent("Character Wizard", EditorGUIUtility.IconContent("d_Prefab Icon").image);
wnd.minSize = new Vector2(520, 600);
}
// ── 状态 ──────────────────────────────────────────────────────────────
private int _activeTab = 0;
private Button _btnPlayer, _btnEnemy, _btnBoss;
// SO 状态缓存(避免每帧重查)
private List<(string label, bool exists)> _playerSOStatus = new();
private List<(string label, bool exists)> _enemySOStatus = new();
private List<(string label, bool exists)> _bossSOStatus = new();
private double _lastRefreshTime;
// 小怪类型选择
private int _enemyTypeIndex = 0;
private static readonly string[] EnemyTypeLabels = { "普通(近战)", "远程", "飞行" };
// Boss 命名字段
private string _bossId = "NewBoss";
private string _enemyId = "NewEnemy";
private string _playerId = "Player";
// SO 状态面板(按标签页缓存)
private VisualElement _playerStatusPanel;
private VisualElement _enemyStatusPanel;
private VisualElement _bossStatusPanel;
// ── 生命周期 ──────────────────────────────────────────────────────────
public void CreateGUI()
{
if (Uss != null)
rootVisualElement.styleSheets.Add(Uss);
rootVisualElement.style.flexDirection = FlexDirection.Column;
BuildTabBar();
BuildTabContents();
RefreshSOStatus();
SwitchTab(0);
}
private void OnFocus() => RefreshSOStatus();
// ── 标签栏 ────────────────────────────────────────────────────────────
private void BuildTabBar()
{
var bar = new VisualElement();
bar.AddToClassList("tab-bar");
_btnPlayer = MakeTabButton("玩家", () => SwitchTab(0));
_btnEnemy = MakeTabButton("小怪", () => SwitchTab(1));
_btnBoss = MakeTabButton("Boss", () => SwitchTab(2));
bar.Add(_btnPlayer);
bar.Add(_btnEnemy);
bar.Add(_btnBoss);
rootVisualElement.Add(bar);
}
private Button MakeTabButton(string label, Action onClick)
{
var btn = new Button(onClick) { text = label };
btn.AddToClassList("tab-btn");
return btn;
}
private void SwitchTab(int idx)
{
_activeTab = idx;
var tabs = rootVisualElement.Query<VisualElement>(className: "tab-content").ToList();
for (int i = 0; i < tabs.Count; i++)
tabs[i].style.display = (i == idx) ? DisplayStyle.Flex : DisplayStyle.None;
_btnPlayer.EnableInClassList("tab-btn--active", idx == 0);
_btnEnemy .EnableInClassList("tab-btn--active", idx == 1);
_btnBoss .EnableInClassList("tab-btn--active", idx == 2);
}
// ── 标签页内容 ────────────────────────────────────────────────────────
private void BuildTabContents()
{
rootVisualElement.Add(BuildPlayerTab());
rootVisualElement.Add(BuildEnemyTab());
rootVisualElement.Add(BuildBossTab());
}
// ════════════════════════════════════════════════════════════════════════
// 玩家标签页
// ════════════════════════════════════════════════════════════════════════
private VisualElement BuildPlayerTab()
{
var root = MakeTabContent();
root.Add(MakeSectionHeader("▶ SO 资产状态"));
_playerStatusPanel = new VisualElement();
root.Add(_playerStatusPanel);
root.Add(MakeSectionHeader("▶ SO 资产工厂"));
root.Add(MakeHelpBox("在 Project 中创建下列 ScriptableObject 资产。若已存在则跳过。"));
var idRow = MakeLabeledTextField("资产名称前缀", _playerId, v => _playerId = v);
root.Add(idRow);
var factory = MakeActionGroup();
factory.Add(MakeFactoryButton("PlayerStatsSO", () => CreatePlayerStat()));
factory.Add(MakeFactoryButton("PlayerMovementConfigSO", () => CreateMovementConfig()));
factory.Add(MakeFactoryButton("PlayerAnimationConfigSO", () => CreateAnimConfig()));
factory.Add(MakeFactoryButton("FormConfigSO + 3 FormSO", () => CreateFormConfig()));
factory.Add(MakeFactoryButton("WeaponSO × 3形态", () => CreateFormWeapons()));
factory.Add(MakeFactoryButton("DamageSourceSO连击×3", () => CreatePlayerDamageSources()));
factory.Add(MakeFactoryButton("ParryConfigSO", () => CreateParryConfig()));
factory.Add(MakeFactoryButton("ShieldConfigSO", () => CreateShieldConfig()));
factory.Add(MakeFactoryButton("EquipmentConfigSO", () => CreateEquipmentConfig()));
factory.Add(MakeFactoryButton("CharmCatalogSO", () => CreateCharmCatalog()));
root.Add(factory);
var createAllBtn = new Button(CreateAllPlayerSOs) { text = "★ 一键创建全部 Player SO" };
createAllBtn.style.marginTop = 6;
createAllBtn.style.height = 26;
root.Add(createAllBtn);
root.Add(MakeSeparator());
root.Add(MakeSectionHeader("▶ 场景搭建"));
root.Add(MakeHelpBox("在当前活动场景中放置玩家 GameObject带完整组件树。"));
var sceneGroup = MakeActionGroup();
sceneGroup.Add(MakeSceneButton("放置玩家到场景", SceneObjectPlacerTool.PlacePlayer));
sceneGroup.Add(MakeSceneButton("指定所有 SO 到场景角色", AssignAllPlayerSOsToScene));
sceneGroup.Add(MakeSceneButton("放置地面平台", SceneObjectPlacerTool.PlaceGroundPlatform));
sceneGroup.Add(MakeSceneButton("放置存档点", SceneObjectPlacerTool.PlaceSavePoint));
root.Add(sceneGroup);
root.Add(MakeSeparator());
root.Add(MakeSectionHeader("▶ 专项编辑器"));
var jumpGroup = MakeActionGroup();
jumpGroup.Add(MakeJumpButton("武器编辑器", () => Combat.WeaponEditorWindow.Open()));
jumpGroup.Add(MakeJumpButton("技能编辑器", () => Skills.SkillEditorWindow.Open()));
jumpGroup.Add(MakeJumpButton("形态编辑器", () => FormEditorWindow.Open()));
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
root.Add(jumpGroup);
return root;
}
// ════════════════════════════════════════════════════════════════════════
// 小怪标签页
// ════════════════════════════════════════════════════════════════════════
private VisualElement BuildEnemyTab()
{
var root = MakeTabContent();
root.Add(MakeSectionHeader("▶ SO 资产状态"));
_enemySOStatus.Clear();
_enemyStatusPanel = new VisualElement();
root.Add(_enemyStatusPanel);
root.Add(MakeSectionHeader("▶ 敌人类型选择"));
var typeRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginBottom = 4 } };
for (int i = 0; i < EnemyTypeLabels.Length; i++)
{
int captured = i;
var btn = new Button(() =>
{
_enemyTypeIndex = captured;
// 高亮激活按钮(简单刷新所有同类按钮样式)
RefreshEnemyTypeButtons(root);
})
{ text = EnemyTypeLabels[i] };
btn.name = $"enemy-type-{i}";
btn.EnableInClassList("type-btn--active", i == _enemyTypeIndex);
typeRow.Add(btn);
}
root.Add(typeRow);
root.Add(MakeSectionHeader("▶ SO 资产工厂"));
root.Add(MakeHelpBox("每个敌人建议独立命名,便于 Loot / BD 资产管理。"));
var idRow = MakeLabeledTextField("敌人 ID", _enemyId, v => _enemyId = v);
root.Add(idRow);
var factory = MakeActionGroup();
factory.Add(MakeFactoryButton("EnemyStatsSO", () => CreateEnemyStat()));
factory.Add(MakeFactoryButton("LootTableSO", () => CreateLootTable()));
factory.Add(MakeFactoryButton("AttackPatternSO × 2", () => CreateEnemyAttackPatterns()));
factory.Add(MakeFactoryButton("DamageSourceSO", () => CreateEnemyDamageSource()));
root.Add(factory);
root.Add(MakeSeparator());
root.Add(MakeSectionHeader("▶ 场景搭建"));
root.Add(MakeHelpBox("根据选中的类型在场景中生成对应的敌人 GameObject。"));
var sceneGroup = MakeActionGroup();
sceneGroup.Add(MakeSceneButton("放置敌人到场景", PlaceSelectedEnemyType));
root.Add(sceneGroup);
root.Add(MakeSeparator());
root.Add(MakeSectionHeader("▶ 专项编辑器"));
var jumpGroup = MakeActionGroup();
jumpGroup.Add(MakeJumpButton("敌人数据管理", () => Enemies.EnemyDataWindow.Open()));
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
root.Add(jumpGroup);
return root;
}
// ════════════════════════════════════════════════════════════════════════
// Boss 标签页
// ════════════════════════════════════════════════════════════════════════
private VisualElement BuildBossTab()
{
var root = MakeTabContent();
root.Add(MakeSectionHeader("▶ SO 资产状态"));
_bossStatusPanel = new VisualElement();
root.Add(_bossStatusPanel);
root.Add(MakeSectionHeader("▶ SO 资产工厂"));
root.Add(MakeHelpBox("每个 Boss 独立目录Assets/_Game/Data/Boss/<BossId>/"));
var idRow = MakeLabeledTextField("Boss ID", _bossId, v => _bossId = v);
root.Add(idRow);
var factory = MakeActionGroup();
factory.Add(MakeFactoryButton("EnemyStatsSOBoss", () => CreateBossStat()));
factory.Add(MakeFactoryButton("LootTableSOBoss", () => CreateBossLoot()));
factory.Add(MakeFactoryButton("AttackPatternSO × 3阶段", () => CreateBossAttackPatterns()));
factory.Add(MakeFactoryButton("BossSkillSO × 3", () => CreateBossSkills()));
factory.Add(MakeFactoryButton("SkillSequenceSOPhase 1", () => CreateBossSkillSequence(1)));
factory.Add(MakeFactoryButton("SkillSequenceSOPhase 2", () => CreateBossSkillSequence(2)));
factory.Add(MakeFactoryButton("DamageSourceSO × 3", () => CreateBossDamageSources()));
root.Add(factory);
root.Add(MakeSeparator());
root.Add(MakeSectionHeader("▶ 场景搭建"));
var sceneGroup = MakeActionGroup();
sceneGroup.Add(MakeSceneButton("放置 Boss 到场景", SceneObjectPlacerTool.PlaceBossEnemy));
root.Add(sceneGroup);
root.Add(MakeSeparator());
root.Add(MakeSectionHeader("▶ 专项编辑器"));
var jumpGroup = MakeActionGroup();
jumpGroup.Add(MakeJumpButton("Boss 技能序列查看器", BossSkillSequenceWindow.OpenWindow));
jumpGroup.Add(MakeJumpButton("敌人数据管理", () => Enemies.EnemyDataWindow.Open()));
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
root.Add(jumpGroup);
return root;
}
// ── SO 资产工厂:玩家 ────────────────────────────────────────────────
private void CreatePlayerStat()
{
var asset = EditorScaffoldUtils.CreateSOAsset<PlayerStatsSO>(
$"{DataRoot}/Player", "PLY_PlayerStats");
if (asset != null) RefreshSOStatus();
}
private void CreateMovementConfig()
{
var asset = EditorScaffoldUtils.CreateSOAsset<PlayerMovementConfigSO>(
$"{DataRoot}/Player", "PLY_PlayerMovementConfig");
if (asset != null) RefreshSOStatus();
}
private void CreateAnimConfig()
{
var asset = EditorScaffoldUtils.CreateSOAsset<PlayerAnimationConfigSO>(
$"{DataRoot}/Player", "PLY_PlayerAnimationConfig");
if (asset != null) RefreshSOStatus();
}
private void CreateFormConfig()
{
string configDir = $"{DataRoot}/Player";
string formsDir = $"{DataRoot}/Player/Forms";
EditorScaffoldUtils.EnsureFolder(formsDir);
var cfg = EditorScaffoldUtils.CreateSOAsset<FormConfigSO>(configDir, "PLY_FormConfig");
if (cfg == null) cfg = AssetDatabase.LoadAssetAtPath<FormConfigSO>($"{configDir}/PLY_FormConfig.asset");
var formTypes = new[] { ("TianHun", FormType.TianHun, "天魂"), ("DiHun", FormType.DiHun, "地魂"), ("MingHun", FormType.MingHun, "命魂") };
var forms = new List<FormSO>();
foreach (var (id, ftype, dname) in formTypes)
{
string path = $"{formsDir}/PLY_Form_{id}.asset";
var form = AssetDatabase.LoadAssetAtPath<FormSO>(path);
if (form == null)
{
form = ScriptableObject.CreateInstance<FormSO>();
form.formId = $"Form_{id}";
form.displayName = dname;
form.formType = ftype;
AssetDatabase.CreateAsset(form, path);
}
forms.Add(form);
}
if (cfg != null && (cfg.forms == null || cfg.forms.Length == 0))
{
cfg.forms = forms.ToArray();
EditorUtility.SetDirty(cfg);
}
AssetDatabase.SaveAssets();
EditorGUIUtility.PingObject(cfg);
RefreshSOStatus();
}
private void CreateFormWeapons()
{
string dir = $"{DataRoot}/Combat/Weapons";
foreach (var id in new[] { "TianHun", "DiHun", "MingHun" })
EditorScaffoldUtils.CreateSOAsset<WeaponSO>(dir, $"WPN_{id}");
RefreshSOStatus();
}
private void CreatePlayerDamageSources()
{
string dir = $"{DataRoot}/Combat/DamageSources";
foreach (var label in new[] { "Attack1", "Attack2", "Attack3" })
EditorScaffoldUtils.CreateSOAsset<DamageSourceSO>(dir, $"CMB_Player_{label}");
RefreshSOStatus();
}
// ── SO 资产工厂玩家Config 类)────────────────────────────────────────
private void CreateParryConfig()
{
var asset = EditorScaffoldUtils.CreateSOAsset<ParryConfigSO>(
$"{DataRoot}/Player", "PLY_ParryConfig");
if (asset != null) RefreshSOStatus();
}
private void CreateShieldConfig()
{
var asset = EditorScaffoldUtils.CreateSOAsset<ShieldConfigSO>(
$"{DataRoot}/Player", "PLY_ShieldConfig");
if (asset != null) RefreshSOStatus();
}
private void CreateEquipmentConfig()
{
var asset = EditorScaffoldUtils.CreateSOAsset<EquipmentConfigSO>(
$"{DataRoot}/Player", "PLY_EquipmentConfig");
if (asset != null) RefreshSOStatus();
}
private void CreateCharmCatalog()
{
var asset = EditorScaffoldUtils.CreateSOAsset<CharmCatalogSO>(
$"{DataRoot}/Progression/Charms", "PLY_CharmCatalog");
if (asset != null) RefreshSOStatus();
}
/// <summary>
/// 一键创建全部 Player 所需 SO已存在则跳过
/// 完成后提示用户点击"指定所有 SO 到场景角色"完成绑定。
/// </summary>
private void CreateAllPlayerSOs()
{
CreatePlayerStat();
CreateMovementConfig();
CreateAnimConfig();
CreateFormConfig();
CreateFormWeapons();
CreatePlayerDamageSources();
CreateParryConfig();
CreateShieldConfig();
CreateEquipmentConfig();
CreateCharmCatalog();
AssetDatabase.SaveAssets();
EditorUtility.DisplayDialog("创建完成",
"全部 Player SO 已创建(已存在的跳过)。\n" +
"请在场景中放置角色后,点击「▣ 指定所有 SO 到场景角色」完成绑定。",
"确定");
}
/// <summary>
/// 查找场景中的 PlayerController将项目中已存在的配置 SO 全部指定给对应组件字段。
/// 使用 SerializedObject 赋值,自动标记 dirty 并保存。
/// </summary>
private void AssignAllPlayerSOsToScene()
{
var pc = UnityEngine.Object.FindObjectOfType<PlayerController>();
if (pc == null)
{
EditorUtility.DisplayDialog("未找到角色",
"场景中没有 PlayerController。\n请先使用「▣ 放置玩家到场景」。", "确定");
return;
}
var plyGo = pc.gameObject;
int count = 0;
// 通过 SerializedObject 写入 SerializeField支持撤销
var missing = new System.Collections.Generic.List<string>();
void TryAssign<T>(Component comp, string field) where T : ScriptableObject
{
if (comp == null) return;
var so = EditorScaffoldUtils.FindAllAssetsOfType<T>().FirstOrDefault();
if (so == null)
{
missing.Add($"{typeof(T).Name}{comp.GetType().Name}.{field}");
return;
}
var sObj = new SerializedObject(comp);
var prop = sObj.FindProperty(field);
if (prop == null) return;
prop.objectReferenceValue = so;
sObj.ApplyModifiedProperties();
count++;
}
var stats = plyGo.GetComponent<PlayerStats>();
var movement = plyGo.GetComponent<PlayerMovement>();
var form = plyGo.GetComponent<FormController>();
var parry = plyGo.GetComponent<ParrySystem>();
var shield = plyGo.GetComponent<ShieldComponent>();
var equip = plyGo.GetComponent<EquipmentManager>();
var wall = plyGo.GetComponent<PlayerWallDetector>();
// PlayerStats
TryAssign<PlayerStatsSO> (stats, "_config");
// PlayerMovement
TryAssign<PlayerMovementConfigSO> (movement, "_config");
// PlayerController多个字段
TryAssign<PlayerMovementConfigSO> (pc, "_movementConfig");
TryAssign<PlayerAnimationConfigSO> (pc, "_animConfig");
TryAssign<InputReaderSO> (pc, "_inputReader");
TryAssign<FormConfigSO> (pc, "_formConfig");
// FormController
TryAssign<FormConfigSO> (form, "_config");
// ParrySystem
TryAssign<ParryConfigSO> (parry, "_config");
// ShieldComponent
TryAssign<ShieldConfigSO> (shield, "_config");
// EquipmentManager
TryAssign<EquipmentConfigSO> (equip, "_config");
TryAssign<CharmCatalogSO> (equip, "_charmCatalog");
// PlayerWallDetector复用移动配置
TryAssign<PlayerMovementConfigSO> (wall, "_config");
EditorUtility.SetDirty(plyGo);
RefreshSOStatus();
string msg = $"已将 {count} 个 SO 引用指定到场景角色 [{plyGo.name}]。";
if (missing.Count > 0)
msg += $"\n\n★ 以下 SO 尚未创建,请先点击工厂按钮创建后再次指定:\n • " +
string.Join("\n • ", missing);
else
msg += "\n全部 SO 绑定完成,可在 Inspector 中确认各组件字段。";
EditorUtility.DisplayDialog("指定完成", msg, "确定");
}
// ── SO 资产工厂:小怪 ────────────────────────────────────────────────
private void CreateEnemyStat()
{
string dir = $"{DataRoot}/Enemies/{_enemyId}";
var asset = EditorScaffoldUtils.CreateSOAsset<EnemyStatsSO>(dir, $"ENM_{_enemyId}_Stats");
if (asset != null) RefreshSOStatus();
}
private void CreateLootTable()
{
string dir = $"{DataRoot}/Enemies/{_enemyId}";
var asset = EditorScaffoldUtils.CreateSOAsset<LootTableSO>(dir, $"ENM_{_enemyId}_Loot");
if (asset != null) RefreshSOStatus();
}
private void CreateEnemyAttackPatterns()
{
string dir = $"{DataRoot}/Enemies/{_enemyId}";
foreach (var label in new[] { "Melee", "Ranged" })
EditorScaffoldUtils.CreateSOAsset<AttackPatternSO>(dir, $"ENM_{_enemyId}_Pattern_{label}");
RefreshSOStatus();
}
private void CreateEnemyDamageSource()
{
string dir = $"{DataRoot}/Enemies/{_enemyId}";
EditorScaffoldUtils.CreateSOAsset<DamageSourceSO>(dir, $"ENM_{_enemyId}_DS");
RefreshSOStatus();
}
// ── SO 资产工厂Boss ─────────────────────────────────────────────────
private void CreateBossStat()
{
string dir = $"{DataRoot}/Boss/{_bossId}";
EditorScaffoldUtils.CreateSOAsset<EnemyStatsSO>(dir, $"ENM_{_bossId}_Stats");
RefreshSOStatus();
}
private void CreateBossLoot()
{
string dir = $"{DataRoot}/Boss/{_bossId}";
EditorScaffoldUtils.CreateSOAsset<LootTableSO>(dir, $"ENM_{_bossId}_Loot");
RefreshSOStatus();
}
private void CreateBossAttackPatterns()
{
string dir = $"{DataRoot}/Boss/{_bossId}/Patterns";
foreach (var label in new[] { "Phase1", "Phase2_A", "Phase2_B" })
EditorScaffoldUtils.CreateSOAsset<AttackPatternSO>(dir, $"ENM_{_bossId}_Pattern_{label}");
RefreshSOStatus();
}
private void CreateBossSkills()
{
string dir = $"{DataRoot}/Boss/{_bossId}/Skills";
foreach (var label in new[] { "Skill_Slam", "Skill_Sweep", "Skill_Summon" })
EditorScaffoldUtils.CreateSOAsset<BossSkillSO>(dir, $"SKL_{_bossId}_{label}");
RefreshSOStatus();
}
private void CreateBossSkillSequence(int phase)
{
string dir = $"{DataRoot}/Boss/{_bossId}/Skills";
EditorScaffoldUtils.CreateSOAsset<SkillSequenceSO>(dir, $"SKL_{_bossId}_Phase{phase}_Sequence");
RefreshSOStatus();
}
private void CreateBossDamageSources()
{
string dir = $"{DataRoot}/Boss/{_bossId}/DamageSources";
foreach (var label in new[] { "Slam", "Sweep", "Projectile" })
EditorScaffoldUtils.CreateSOAsset<DamageSourceSO>(dir, $"ENM_{_bossId}_DS_{label}");
RefreshSOStatus();
}
// ── 场景搭建 ──────────────────────────────────────────────────────────
private void PlaceSelectedEnemyType()
{
switch (_enemyTypeIndex)
{
case 0: SceneObjectPlacerTool.PlaceEnemy(); break;
case 1: SceneObjectPlacerTool.PlaceEnemy(); break; // 复用,类型通过 SO 区分
case 2: SceneObjectPlacerTool.PlaceEnemy(); break;
}
}
// ── SO 状态面板刷新 ───────────────────────────────────────────────────
private void RefreshSOStatus()
{
_lastRefreshTime = EditorApplication.timeSinceStartup;
BuildPlayerStatus();
BuildEnemyStatus();
BuildBossStatus();
}
private void BuildPlayerStatus()
{
if (_playerStatusPanel == null) return;
_playerStatusPanel.Clear();
var checks = new (string label, UnityEngine.Object asset)[]
{
("PlayerStatsSO", FindFirst<PlayerStatsSO>()),
("PlayerMovementConfigSO", FindFirst<PlayerMovementConfigSO>()),
("PlayerAnimationConfigSO", FindFirst<PlayerAnimationConfigSO>()),
("FormConfigSO", FindFirst<FormConfigSO>()),
("FormSO天魂", FindFormOfType(FormType.TianHun)),
("FormSO地魂", FindFormOfType(FormType.DiHun)),
("FormSO命魂", FindFormOfType(FormType.MingHun)),
("WeaponSO≥3", FindFirstIfCount<WeaponSO>(3)),
("ParryConfigSO", FindFirst<ParryConfigSO>()),
("ShieldConfigSO", FindFirst<ShieldConfigSO>()),
("EquipmentConfigSO", FindFirst<EquipmentConfigSO>()),
("CharmCatalogSO", FindFirst<CharmCatalogSO>()),
};
_playerStatusPanel.Add(MakeStatusGrid(checks));
}
private void BuildEnemyStatus()
{
if (_enemyStatusPanel == null) return;
_enemyStatusPanel.Clear();
string dir = $"{DataRoot}/Enemies/{_enemyId}";
var checks = new (string label, UnityEngine.Object asset)[]
{
("EnemyStatsSO", FindAtPath<EnemyStatsSO>($"{dir}/ENM_{_enemyId}_Stats.asset")),
("LootTableSO", FindAtPath<LootTableSO>($"{dir}/ENM_{_enemyId}_Loot.asset")),
("AttackPatternSO×2", FindAtPath<AttackPatternSO>($"{dir}/ENM_{_enemyId}_Pattern_Melee.asset")),
("DamageSourceSO", FindAtPath<DamageSourceSO>($"{dir}/ENM_{_enemyId}_DS.asset")),
};
_enemyStatusPanel.Add(MakeStatusGrid(checks));
}
private void BuildBossStatus()
{
if (_bossStatusPanel == null) return;
_bossStatusPanel.Clear();
string dir = $"{DataRoot}/Boss/{_bossId}";
var checks = new (string label, UnityEngine.Object asset)[]
{
("EnemyStatsSOBoss", FindAtPath<EnemyStatsSO>($"{dir}/ENM_{_bossId}_Stats.asset")),
("LootTableSO", FindAtPath<LootTableSO>($"{dir}/ENM_{_bossId}_Loot.asset")),
("AttackPatternSOPhase1", FindAtPath<AttackPatternSO>($"{dir}/Patterns/ENM_{_bossId}_Pattern_Phase1.asset")),
("BossSkillSO≥1", EditorScaffoldUtils.FindAllAssetsOfType<BossSkillSO>()
.FirstOrDefault(s => s.name.StartsWith("SKL_" + _bossId, StringComparison.OrdinalIgnoreCase))),
("SkillSequenceSOPhase1", FindAtPath<SkillSequenceSO>($"{dir}/Skills/SKL_{_bossId}_Phase1_Sequence.asset")),
};
_bossStatusPanel.Add(MakeStatusGrid(checks));
}
// ── 辅助:状态格 ─────────────────────────────────────────────────────
private static VisualElement MakeStatusGrid((string label, UnityEngine.Object asset)[] items)
{
var grid = new VisualElement();
grid.style.flexDirection = FlexDirection.Row;
grid.style.flexWrap = Wrap.Wrap;
grid.style.marginBottom = 6;
foreach (var (label, asset) in items)
{
bool exists = asset != null;
VisualElement chip;
if (exists)
{
var captured = asset;
var btn = new Button(() =>
{
EditorGUIUtility.PingObject(captured);
Selection.activeObject = captured;
}) { text = $"✔ {label}" };
chip = btn;
}
else
{
chip = new Label($"✘ {label}");
}
chip.style.marginRight = 6;
chip.style.marginBottom = 4;
chip.style.paddingLeft = 6;
chip.style.paddingRight = 6;
chip.style.paddingTop = 2;
chip.style.paddingBottom = 2;
chip.style.borderTopLeftRadius = 4;
chip.style.borderTopRightRadius = 4;
chip.style.borderBottomLeftRadius = 4;
chip.style.borderBottomRightRadius = 4;
if (exists)
{
chip.style.backgroundColor = new Color(0.18f, 0.55f, 0.22f, 0.85f);
chip.style.color = new Color(0.85f, 1.00f, 0.85f);
}
else
{
chip.style.backgroundColor = new Color(0.65f, 0.35f, 0.05f, 0.85f);
chip.style.color = new Color(1.00f, 0.90f, 0.70f);
}
grid.Add(chip);
}
return grid;
}
// ── 辅助UI 组件构建 ─────────────────────────────────────────────────
private static VisualElement MakeTabContent()
{
var root = new ScrollView(ScrollViewMode.Vertical);
root.AddToClassList("tab-content");
root.style.flexGrow = 1;
root.contentContainer.style.paddingLeft = 8;
root.contentContainer.style.paddingRight = 8;
root.contentContainer.style.paddingTop = 8;
root.contentContainer.style.paddingBottom = 8;
return root;
}
private static Label MakeSectionHeader(string text)
{
var lbl = new Label(text);
lbl.AddToClassList("section-header");
return lbl;
}
private static VisualElement MakeActionGroup()
{
var group = new VisualElement();
group.AddToClassList("action-buttons");
return group;
}
private static Button MakeFactoryButton(string label, Action onClick)
{
var btn = new Button(onClick) { text = $"+ {label}" };
btn.AddToClassList("wizard-factory-btn");
return btn;
}
private static Button MakeSceneButton(string label, Action onClick)
{
var btn = new Button(onClick) { text = $"▣ {label}" };
btn.AddToClassList("wizard-scene-btn");
return btn;
}
private static Button MakeJumpButton(string label, Action onClick)
{
var btn = new Button(onClick) { text = $"⇒ {label}" };
btn.AddToClassList("wizard-jump-btn");
return btn;
}
private static HelpBox MakeHelpBox(string text)
{
return new HelpBox(text, HelpBoxMessageType.Info);
}
private static VisualElement MakeSeparator()
{
var sep = new VisualElement();
sep.style.height = 1;
sep.style.backgroundColor = new Color(0.3f, 0.3f, 0.3f, 0.6f);
sep.style.marginTop = 8;
sep.style.marginBottom = 8;
return sep;
}
private static VisualElement MakeLabeledTextField(string label, string value, Action<string> onChange)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.marginBottom = 4;
row.style.alignItems = Align.Center;
var lbl = new Label(label) { style = { minWidth = 110, marginRight = 6 } };
var field = new TextField { value = value, style = { flexGrow = 1 } };
field.RegisterValueChangedCallback(e => onChange(e.newValue));
row.Add(lbl);
row.Add(field);
return row;
}
private void RefreshEnemyTypeButtons(VisualElement tabRoot)
{
for (int i = 0; i < EnemyTypeLabels.Length; i++)
{
var btn = tabRoot.Q<Button>($"enemy-type-{i}");
btn?.EnableInClassList("type-btn--active", i == _enemyTypeIndex);
}
}
// ── 资产查找辅助 ──────────────────────────────────────────
private static T FindFirst<T>() where T : ScriptableObject
=> EditorScaffoldUtils.FindAllAssetsOfType<T>().FirstOrDefault();
/// <summary>返回第一个,仅当总数达到 minCount 时才认为满足。</summary>
private static T FindFirstIfCount<T>(int minCount) where T : ScriptableObject
{
var list = EditorScaffoldUtils.FindAllAssetsOfType<T>();
return list.Count >= minCount ? list[0] : null;
}
private static T FindAtPath<T>(string path) where T : UnityEngine.Object
=> AssetDatabase.LoadAssetAtPath<T>(path);
private static FormSO FindFormOfType(FormType type)
=> EditorScaffoldUtils.FindAllAssetsOfType<FormSO>()
.FirstOrDefault(f => f.formType == type);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 301eee333a6bf174bac93f44362e72bd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -197,11 +197,10 @@ namespace BaseGames.Editor.Combat
_detailRoot.Add(btnRow); _detailRoot.Add(btnRow);
} }
/// <summary>attack1 → attack2 → attack3 连击链数值横排预览。</summary> /// <summary>连击序列数值横排预览。</summary>
private void BuildComboPreview(WeaponSO weapon) private void BuildComboPreview(WeaponSO weapon)
{ {
// 只在有至少一个连击数据时显示 if (weapon.groundComboSteps == null || weapon.groundComboSteps.Length == 0)
if (weapon.attack1Source == null && weapon.attack2Source == null && weapon.attack3Source == null)
return; return;
var section = new Label("连击链预览") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginBottom = 4 } }; var section = new Label("连击链预览") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginBottom = 4 } };
@@ -210,8 +209,11 @@ namespace BaseGames.Editor.Combat
var chain = new VisualElement(); var chain = new VisualElement();
chain.AddToClassList("stats-preview"); chain.AddToClassList("stats-preview");
void AddSegment(string label, ClipTransition clip, DamageSourceSO src, bool addArrow) for (int i = 0; i < weapon.groundComboSteps.Length; i++)
{ {
var step = weapon.groundComboSteps[i];
bool addArrow = i < weapon.groundComboSteps.Length - 1;
var cell = new VisualElement var cell = new VisualElement
{ {
style = style =
@@ -230,24 +232,21 @@ namespace BaseGames.Editor.Combat
} }
}; };
// 段名 cell.Add(new Label($"攻击{i + 1}")
cell.Add(new Label(label)
{ {
style = { fontSize = 10, color = new Color(0.65f, 0.65f, 0.65f) } style = { fontSize = 10, color = new Color(0.65f, 0.65f, 0.65f) }
}); });
// Clip 名称 string clipName = step.clip?.Clip != null ? step.clip.Clip.name : "<无动画>";
string clipName = clip?.Clip != null ? clip.Clip.name : "<无动画>";
cell.Add(new Label(clipName) cell.Add(new Label(clipName)
{ {
style = { fontSize = 11, unityFontStyleAndWeight = FontStyle.Bold } style = { fontSize = 11, unityFontStyleAndWeight = FontStyle.Bold }
}); });
// 伤害数值 if (step.damageSource != null)
if (src != null)
{ {
int dmg = Mathf.RoundToInt(src.BaseDamage * src.DamageMultiplier); int dmg = Mathf.RoundToInt(step.damageSource.BaseDamage * step.damageSource.DamageMultiplier);
cell.Add(new Label($"伤害 {dmg} [{src.BreakLevel}]") cell.Add(new Label($"伤害 {dmg} [{step.damageSource.BreakLevel}]")
{ {
style = { fontSize = 10, color = new Color(1f, 0.7f, 0.3f) } style = { fontSize = 10, color = new Color(1f, 0.7f, 0.3f) }
}); });
@@ -266,10 +265,6 @@ namespace BaseGames.Editor.Combat
chain.Add(new Label("→") { style = { alignSelf = Align.Center, marginLeft = 2, marginRight = 2 } }); chain.Add(new Label("→") { style = { alignSelf = Align.Center, marginLeft = 2, marginRight = 2 } });
} }
AddSegment("攻击1", weapon.attack1Clip, weapon.attack1Source, true);
AddSegment("攻击2", weapon.attack2Clip, weapon.attack2Source, true);
AddSegment("攻击3", weapon.attack3Clip, weapon.attack3Source, false);
_detailRoot.Add(chain); _detailRoot.Add(chain);
// 追加空中/上/下攻击的简要行 // 追加空中/上/下攻击的简要行
@@ -288,9 +283,9 @@ namespace BaseGames.Editor.Combat
}); });
} }
ExtraStat("空中", weapon.airAttackSource); ExtraStat("空中", weapon.airComboSteps?[0].damageSource);
ExtraStat("上", weapon.upAttackSource); ExtraStat("上", weapon.upStep.damageSource);
ExtraStat("下", weapon.downAttackSource); ExtraStat("下", weapon.downStep.damageSource);
if (extraRow.childCount > 0) if (extraRow.childCount > 0)
_detailRoot.Add(extraRow); _detailRoot.Add(extraRow);

View File

@@ -0,0 +1,118 @@
using UnityEditor;
using UnityEngine;
using BaseGames.Combat;
using BaseGames.Player;
namespace BaseGames.Editor.Combat
{
/// <summary>
/// WeaponSO 自定义 Inspector在 hitBoxPrefab 下方提供一键生成按钮。
/// 等同于菜单 BaseGames / Create / Weapon HitBox Prefab但已预填 weaponId 并自动赋值。
/// </summary>
[CustomEditor(typeof(WeaponSO))]
public class WeaponSOEditor : UnityEditor.Editor
{
private const string OutputFolder = "Assets/_Game/Prefabs/Weapons";
public override void OnInspectorGUI()
{
DrawDefaultInspector();
var weapon = (WeaponSO)target;
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("HitBox Prefab 工具", EditorStyles.boldLabel);
bool hasId = !string.IsNullOrWhiteSpace(weapon.weaponId);
bool hasPrefab = weapon.hitBoxPrefab != null;
if (!hasId)
{
EditorGUILayout.HelpBox("请先填写 weaponId再生成 HitBox Prefab。", MessageType.Info);
return;
}
string prefabPath = $"{OutputFolder}/WPN_{weapon.weaponId}_HitBox.prefab";
EditorGUILayout.HelpBox($"输出路径:{prefabPath}", MessageType.None);
string btnLabel = hasPrefab ? "重新生成 HitBox Prefab" : "一键生成 HitBox Prefab";
if (GUILayout.Button(btnLabel))
{
if (hasPrefab && !EditorUtility.DisplayDialog(
"确认重新生成",
$"hitBoxPrefab 已有引用,是否覆盖并重新赋值?\n\n{prefabPath}",
"覆盖", "取消"))
return;
var prefab = CreateHitBoxPrefab(weapon.weaponId, prefabPath);
if (prefab != null)
{
Undo.RecordObject(weapon, "Assign HitBox Prefab");
weapon.hitBoxPrefab = prefab;
EditorUtility.SetDirty(weapon);
AssetDatabase.SaveAssets();
}
}
}
// ── 创建逻辑 ──────────────────────────────────────────────────────────
private static GameObject CreateHitBoxPrefab(string weaponId, string assetPath)
{
EditorScaffoldUtils.EnsureFolder(OutputFolder);
int layer = LayerMask.NameToLayer("PlayerHitBox");
if (layer < 0)
{
Debug.LogWarning("[WeaponSOEditor] 未找到 Physics Layer 'PlayerHitBox',子节点 Layer 将设为 Default。");
layer = 0;
}
string prefabName = $"WPN_{weaponId}_HitBox";
var root = new GameObject(prefabName);
var instance = root.AddComponent<WeaponHitBoxInstance>();
var so = new SerializedObject(instance);
AddHitBoxChild(root, so, "HitBox_Ground", "_hitBoxGround", layer, new Vector2(1f, 0.5f));
AddHitBoxChild(root, so, "HitBox_Up", "_hitBoxUp", layer, new Vector2(0.5f, 1f ));
AddHitBoxChild(root, so, "HitBox_Down", "_hitBoxDown", layer, new Vector2(1f, 0.5f));
AddHitBoxChild(root, so, "HitBox_Air", "_hitBoxAir", layer, new Vector2(0.5f, 1f ));
so.ApplyModifiedPropertiesWithoutUndo();
var prefab = PrefabUtility.SaveAsPrefabAsset(root, assetPath);
Object.DestroyImmediate(root);
AssetDatabase.Refresh();
if (prefab != null)
{
EditorScaffoldUtils.PingAndSelect(prefab);
Debug.Log($"[WeaponSOEditor] 已创建:{assetPath}");
}
else
{
Debug.LogError($"[WeaponSOEditor] Prefab 保存失败:{assetPath}");
}
return prefab;
}
private static void AddHitBoxChild(GameObject root, SerializedObject so,
string nodeName, string fieldName,
int layer, Vector2 boxSize)
{
var child = new GameObject(nodeName);
child.transform.SetParent(root.transform, false);
child.layer = layer;
var col = child.AddComponent<BoxCollider2D>();
col.isTrigger = true;
col.size = boxSize;
var hb = child.AddComponent<HitBox>();
var prop = so.FindProperty(fieldName);
if (prop != null)
prop.objectReferenceValue = hb;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df9abfb2b89aa244bbcc1f4e62694dd6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -48,6 +48,7 @@ namespace BaseGames.Editor
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_PlayerRespawned"); CreateAsset<VoidEventChannelSO> ("Combat", "EVT_PlayerRespawned");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_RespawnStarted"); CreateAsset<VoidEventChannelSO> ("Combat", "EVT_RespawnStarted");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_RespawnCompleted"); CreateAsset<VoidEventChannelSO> ("Combat", "EVT_RespawnCompleted");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_CheckpointRespawn");
// ── Boss ────────────────────────────────────────────────────────── // ── Boss ──────────────────────────────────────────────────────────
CreateAsset<BossSkillEventChannelSO> ("Boss", "EVT_BossSkill"); CreateAsset<BossSkillEventChannelSO> ("Boss", "EVT_BossSkill");
@@ -70,6 +71,8 @@ namespace BaseGames.Editor
// ── World ───────────────────────────────────────────────────────── // ── World ─────────────────────────────────────────────────────────
CreateAsset<StringEventChannelSO> ("World", "EVT_SavePointActivated"); CreateAsset<StringEventChannelSO> ("World", "EVT_SavePointActivated");
CreateAsset<VoidEventChannelSO> ("World", "EVT_CheckpointReached");
CreateAsset<StringEventChannelSO> ("World", "EVT_DoorOpened"); // 开门/交互机关(钥匙、机关等)触发自动存档
// ── 对话/商店 ───────────────────────────────────────────────────── // ── 对话/商店 ─────────────────────────────────────────────────────
CreateAsset<ShopPurchaseEventChannelSO> ("Dialogue", "EVT_ShopPurchase"); CreateAsset<ShopPurchaseEventChannelSO> ("Dialogue", "EVT_ShopPurchase");
@@ -92,6 +95,7 @@ namespace BaseGames.Editor
// ── 进度/成就 ───────────────────────────────────────────────────── // ── 进度/成就 ─────────────────────────────────────────────────────
CreateAsset<ToolUsedEventChannelSO> ("Progression", "EVT_ToolUsed"); CreateAsset<ToolUsedEventChannelSO> ("Progression", "EVT_ToolUsed");
CreateAsset<AchievementEventChannelSO> ("Progression", "EVT_AchievementUnlocked"); CreateAsset<AchievementEventChannelSO> ("Progression", "EVT_AchievementUnlocked");
CreateAsset<StringEventChannelSO> ("Progression", "EVT_MaxHPContainerPickedUp");
AssetDatabase.SaveAssets(); AssetDatabase.SaveAssets();
AssetDatabase.Refresh(); AssetDatabase.Refresh();

View File

@@ -0,0 +1,413 @@
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using BaseGames.Player;
namespace BaseGames.Editor
{
/// <summary>
/// 形态系统可视化编辑器W-06
/// 技术UI Toolkit TwoPaneSplitView + 三列形态网格。
/// 菜单BaseGames / Data / Form Editor
///
/// 左栏FormConfigSO 列表 + [新建] 按钮。
/// 右栏:
/// · 三列网格(天魂 / 地魂 / 命魂),每列显示对应 FormSO 详情及武器引用。
/// · 各列可独立新建/重新绑定 FormSO。
/// · "一键自动填充" 按钮:按 formType 枚举在 Project 中搜索已有 FormSO 并赋值。
/// · 底部:在 Project 中选中 FormConfigSO / 在 Inspector 中编辑 原始字段。
/// </summary>
public class FormEditorWindow : EditorWindow
{
// ── 常量 ──────────────────────────────────────────────────────────────
private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
private const string DataRoot = "Assets/_Game/Data/Player/Forms";
private static readonly StyleSheet _uss;
private static readonly (FormType type, string label, Color accent)[] FormDefs =
{
(FormType.TianHun, "天魂", new Color(0.40f, 0.70f, 1.00f)),
(FormType.DiHun, "地魂", new Color(0.55f, 0.85f, 0.40f)),
(FormType.MingHun, "命魂", new Color(0.80f, 0.30f, 0.30f)),
};
static FormEditorWindow()
{
_uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
}
[MenuItem("BaseGames/Data/Form Editor", priority = 103)]
public static void Open()
{
var wnd = GetWindow<FormEditorWindow>();
wnd.titleContent = new GUIContent("Form Editor");
wnd.minSize = new Vector2(760, 420);
}
// ── 状态 ──────────────────────────────────────────────────────────────
private List<FormConfigSO> _configs = new();
private List<FormConfigSO> _filtered = new();
private FormConfigSO _selected;
private string _searchText = "";
private ListView _configList;
private VisualElement _detailRoot;
private InspectorElement _rawInspector;
// 三列形态格(每列一个 FormSO ObjectField
private readonly ObjectField[] _formFields = new ObjectField[3];
private readonly ObjectField[] _weaponFields = new ObjectField[3];
private readonly VisualElement[]_columnRoots = new VisualElement[3];
// ── 生命周期 ──────────────────────────────────────────────────────────
public void CreateGUI()
{
if (_uss != null)
rootVisualElement.styleSheets.Add(_uss);
// ── Toolbar ───────────────────────────────────────────────────────
var toolbar = new Toolbar();
var search = new ToolbarSearchField { style = { flexGrow = 1 } };
search.RegisterValueChangedCallback(e => { _searchText = e.newValue; RefreshFilter(); });
search.tooltip = "按名称过滤 FormConfigSO";
toolbar.Add(search);
var btnNew = new ToolbarButton(CreateNewFormConfig) { text = "+ 新建 FormConfig" };
var btnRefresh = new ToolbarButton(RefreshAll) { text = "↺" };
btnRefresh.tooltip = "重新扫描 Project 中的 FormConfigSO 资产";
toolbar.Add(btnNew);
toolbar.Add(btnRefresh);
rootVisualElement.Add(toolbar);
// ── 分栏 ──────────────────────────────────────────────────────────
var split = new TwoPaneSplitView(0, 200, TwoPaneSplitViewOrientation.Horizontal);
// 左栏
var leftPane = new VisualElement { style = { minWidth = 140 } };
_configList = new ListView
{
selectionType = SelectionType.Single,
makeItem = MakeListItem,
bindItem = BindListItem,
style = { flexGrow = 1 },
showAlternatingRowBackgrounds = AlternatingRowBackground.ContentOnly,
};
_configList.selectionChanged += OnConfigSelected;
leftPane.Add(_configList);
split.Add(leftPane);
// 右栏ScrollView
_detailRoot = new ScrollView(ScrollViewMode.Vertical) { style = { flexGrow = 1 } };
_detailRoot.contentContainer.style.paddingLeft = 10;
_detailRoot.contentContainer.style.paddingRight = 10;
_detailRoot.contentContainer.style.paddingTop = 10;
_detailRoot.contentContainer.style.paddingBottom = 10;
_detailRoot.Add(new HelpBox("← 在左侧选择一个 FormConfigSO 开始编辑", HelpBoxMessageType.Info));
split.Add(_detailRoot);
rootVisualElement.Add(split);
RefreshAll();
}
// ── 列表 ──────────────────────────────────────────────────────────────
private VisualElement MakeListItem()
{
var label = new Label();
label.AddToClassList("list-item");
return label;
}
private void BindListItem(VisualElement ve, int idx)
{
if (ve is Label lbl && idx < _filtered.Count)
lbl.text = _filtered[idx].name;
}
private void OnConfigSelected(IEnumerable<object> objs)
{
_selected = objs.FirstOrDefault() as FormConfigSO;
RebuildDetail();
}
private void RefreshFilter()
{
string s = _searchText.ToLowerInvariant();
_filtered = string.IsNullOrEmpty(s)
? new List<FormConfigSO>(_configs)
: _configs.Where(c => c.name.ToLowerInvariant().Contains(s)).ToList();
_configList.itemsSource = _filtered;
_configList.Rebuild();
}
private void RefreshAll()
{
_configs = EditorScaffoldUtils.FindAllAssetsOfType<FormConfigSO>();
_configs.Sort((a, b) => string.Compare(a.name, b.name, System.StringComparison.Ordinal));
RefreshFilter();
}
// ── 右栏详情 ──────────────────────────────────────────────────────────
private void RebuildDetail()
{
_detailRoot.Clear();
if (_selected == null) return;
// 标题 + 快捷操作
var header = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginBottom = 8 } };
header.Add(new Label(_selected.name) { style = { fontSize = 14, unityFontStyleAndWeight = FontStyle.Bold, flexGrow = 1 } });
var btnPing = new Button(() => EditorGUIUtility.PingObject(_selected)) { text = "⌖ Ping" };
var btnAuto = new Button(AutoFillForms) { text = "自动填充形态", tooltip = "按 formType 枚举在 Project 中搜索已有 FormSO 并赋值到 forms[] 数组" };
header.Add(btnAuto);
header.Add(btnPing);
_detailRoot.Add(header);
// 三列形态网格
var formGrid = new VisualElement();
formGrid.style.flexDirection = FlexDirection.Row;
formGrid.style.marginBottom = 10;
_detailRoot.Add(formGrid);
for (int i = 0; i < 3; i++)
formGrid.Add(BuildFormColumn(i));
// 分割线
_detailRoot.Add(MakeSeparator());
// 原始 Inspector完整字段编辑
var inspHeader = new Label("原始 Inspector 编辑") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginBottom = 4 } };
_detailRoot.Add(inspHeader);
_rawInspector = new InspectorElement(_selected);
_detailRoot.Add(_rawInspector);
}
private VisualElement BuildFormColumn(int colIdx)
{
var (formType, label, accent) = FormDefs[colIdx];
var col = new VisualElement();
col.style.flexGrow = 1;
col.style.marginRight = colIdx < 2 ? 8 : 0;
col.style.borderTopWidth = 2;
col.style.borderTopColor = accent;
col.style.borderLeftWidth = 1;
col.style.borderRightWidth = 1;
col.style.borderBottomWidth = 1;
col.style.borderLeftColor = new Color(accent.r, accent.g, accent.b, 0.35f);
col.style.borderRightColor = new Color(accent.r, accent.g, accent.b, 0.35f);
col.style.borderBottomColor = new Color(accent.r, accent.g, accent.b, 0.35f);
col.style.borderTopLeftRadius = 4;
col.style.borderTopRightRadius = 4;
col.style.borderBottomLeftRadius = 4;
col.style.borderBottomRightRadius = 4;
col.style.paddingLeft = 8;
col.style.paddingRight = 8;
col.style.paddingTop = 8;
col.style.paddingBottom = 8;
_columnRoots[colIdx] = col;
// 列标题 + 色块
var titleRow = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginBottom = 6 } };
var colorDot = new VisualElement();
colorDot.style.width = 12;
colorDot.style.height = 12;
colorDot.style.borderTopLeftRadius = 6;
colorDot.style.borderTopRightRadius = 6;
colorDot.style.borderBottomLeftRadius = 6;
colorDot.style.borderBottomRightRadius = 6;
colorDot.style.backgroundColor = accent;
colorDot.style.marginRight = 6;
titleRow.Add(colorDot);
titleRow.Add(new Label(label) { style = { unityFontStyleAndWeight = FontStyle.Bold } });
col.Add(titleRow);
// 当前 FormSO 显示
FormSO current = GetFormByType(formType);
var formField = new ObjectField("FormSO") { objectType = typeof(FormSO), value = current };
formField.RegisterValueChangedCallback(e =>
{
SetFormByType(formType, e.newValue as FormSO);
// 联动刷新武器字段
if (_weaponFields[colIdx] != null)
_weaponFields[colIdx].value = (e.newValue as FormSO)?.defaultWeapon;
});
_formFields[colIdx] = formField;
col.Add(formField);
// 武器预览(只读,跟随 FormSO.defaultWeapon
var weaponField = new ObjectField("默认武器") { objectType = typeof(WeaponSO), value = current?.defaultWeapon };
weaponField.SetEnabled(false);
_weaponFields[colIdx] = weaponField;
col.Add(weaponField);
// 新建该形态 FormSO
var btnCreate = new Button(() => CreateFormForType(formType, colIdx))
{
text = current == null ? $"+ 新建 {label} FormSO" : "重新新建(覆盖)",
tooltip = $"在 {DataRoot} 创建 Form_{formType}.asset",
style = { marginTop = 8 }
};
col.Add(btnCreate);
// 新建该形态 WeaponSO
var btnWeapon = new Button(() => CreateWeaponForType(formType, colIdx))
{
text = $"+ 新建 {label} WeaponSO",
tooltip = $"在 Assets/_Game/Data/Player/Weapons 创建 Weapon_{formType}.asset",
style = { marginTop = 2 }
};
col.Add(btnWeapon);
return col;
}
// ── 操作:新建 FormConfigSO ───────────────────────────────────────────
private void CreateNewFormConfig()
{
var cfg = EditorScaffoldUtils.CreateSOAsset<FormConfigSO>(DataRoot, "FormConfig");
if (cfg != null)
{
RefreshAll();
// 选中新建项
int idx = _filtered.IndexOf(cfg);
if (idx >= 0) _configList.SetSelection(idx);
}
}
// ── 操作:新建 FormSO ──────────────────────────────────────────────────
private void CreateFormForType(FormType formType, int colIdx)
{
if (_selected == null) return;
EditorScaffoldUtils.EnsureFolder(DataRoot);
string path = $"{DataRoot}/Form_{formType}.asset";
var form = AssetDatabase.LoadAssetAtPath<FormSO>(path);
if (form == null)
{
form = ScriptableObject.CreateInstance<FormSO>();
form.formId = $"Form_{formType}";
form.displayName = FormDefs[colIdx].label;
form.formType = formType;
AssetDatabase.CreateAsset(form, path);
AssetDatabase.SaveAssets();
}
SetFormByType(formType, form);
_formFields[colIdx].value = form;
EditorGUIUtility.PingObject(form);
}
// ── 操作:新建 WeaponSO ───────────────────────────────────────────────
private void CreateWeaponForType(FormType formType, int colIdx)
{
string dir = "Assets/_Game/Data/Player/Weapons";
var weapon = EditorScaffoldUtils.CreateSOAsset<WeaponSO>(dir, $"Weapon_{formType}");
if (weapon == null) weapon = AssetDatabase.LoadAssetAtPath<WeaponSO>($"{dir}/Weapon_{formType}.asset");
if (weapon == null) return;
// 赋值到对应 FormSO.defaultWeapon
var form = GetFormByType(formType);
if (form != null)
{
form.defaultWeapon = weapon;
EditorUtility.SetDirty(form);
AssetDatabase.SaveAssets();
_weaponFields[colIdx].value = weapon;
}
}
// ── 操作:自动填充形态 ────────────────────────────────────────────────
private void AutoFillForms()
{
if (_selected == null) return;
var allForms = EditorScaffoldUtils.FindAllAssetsOfType<FormSO>();
bool changed = false;
for (int i = 0; i < 3; i++)
{
var (ftype, _, _) = FormDefs[i];
if (GetFormByType(ftype) != null) continue;
var match = allForms.FirstOrDefault(f => f.formType == ftype);
if (match != null)
{
SetFormByType(ftype, match);
_formFields[i].value = match;
changed = true;
}
}
if (changed)
{
EditorUtility.SetDirty(_selected);
AssetDatabase.SaveAssets();
Debug.Log("[FormEditorWindow] 自动填充完成。");
}
else
{
Debug.Log("[FormEditorWindow] 未发现需要填充的空槽,或 Project 中无匹配 FormSO。");
}
}
// ── 数据访问(操作 FormConfigSO.forms[])────────────────────────────
private FormSO GetFormByType(FormType type)
{
if (_selected == null || _selected.forms == null) return null;
return _selected.forms.FirstOrDefault(f => f != null && f.formType == type);
}
private void SetFormByType(FormType type, FormSO form)
{
if (_selected == null) return;
if (_selected.forms == null || _selected.forms.Length < 3)
{
var arr = new FormSO[3];
if (_selected.forms != null)
for (int i = 0; i < _selected.forms.Length && i < 3; i++)
arr[i] = _selected.forms[i];
_selected.forms = arr;
}
// 按 FormDefs 顺序TianHun=0, DiHun=1, MingHun=2
for (int i = 0; i < FormDefs.Length; i++)
{
if (FormDefs[i].type == type)
{
_selected.forms[i] = form;
EditorUtility.SetDirty(_selected);
AssetDatabase.SaveAssets();
return;
}
}
}
// ── UI 辅助 ───────────────────────────────────────────────────────────
private static VisualElement MakeSeparator()
{
var sep = new VisualElement();
sep.style.height = 1;
sep.style.backgroundColor = new Color(0.3f, 0.3f, 0.3f, 0.6f);
sep.style.marginTop = 10;
sep.style.marginBottom = 10;
return sep;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 15618e4fc32a98346a68e945428fcb47
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,493 @@
using UnityEditor;
using UnityEngine;
using BaseGames.Player;
using BaseGames.Player.States;
namespace BaseGames.Editor
{
/// <summary>
/// 开发阶段 GM 调试工具窗口(仅 Play Mode 有效)。
/// 功能:资源快速填充(灵铢/灵力/魄元)、能力解锁/锁定、形态切换、调试辅助。
/// 菜单BaseGames / Tools / GM Debug Tool
/// </summary>
public class GMToolWindow : EditorWindow
{
// ── 菜单 ──────────────────────────────────────────────────────────────
[MenuItem("BaseGames/Tools/GM Debug Tool", priority = 2)]
public static void Open()
{
var wnd = GetWindow<GMToolWindow>();
wnd.titleContent = new GUIContent("GM Debug Tool");
wnd.minSize = new Vector2(320, 500);
}
// ── 资源输入字段 ──────────────────────────────────────────────────────
private int _lingZhuAmount = 9999;
private int _soulPowerAmount = 100;
private int _spiritAmount = 100;
// ── 折叠状态 ──────────────────────────────────────────────────────────
private bool _foldResources = true;
private bool _foldJump = true;
private bool _foldForms = true;
private bool _foldAbilities = false;
private bool _foldDebug = true;
// ── 缓存(避免每帧 FindObjectOfType─────────────────────────────────
private PlayerStats _stats;
private FormController _formCtrl;
private PlayerController _playerCtrl;
private double _lastCacheTime = -10;
// ── 能力分组定义(与 AbilityTypeDrawer 保持一致)────────────────────
private static readonly (string label, AbilityType[] flags)[] AbilityGroups =
{
("移动能力", new[]
{
AbilityType.WallCling, AbilityType.WallJump,
AbilityType.Dash, AbilityType.Dash,
AbilityType.DoubleJump, AbilityType.SuperJump,
AbilityType.Swim, AbilityType.Dive,
}),
("法术能力", new[] { AbilityType.Spell1, AbilityType.Spell2, AbilityType.Spell3 }),
("灵魄形态", new[] { AbilityType.SpiritForm, AbilityType.SpiritDash }),
("战斗能力", new[] { AbilityType.Parry, AbilityType.ChargeAttack, AbilityType.DownSlash }),
("互动能力", new[] { AbilityType.Interact, AbilityType.FastTravel }),
("能力强化", new[] { AbilityType.InvincibleDash }),
};
private static readonly string[] AbilityFlagNames =
{
"贴墙悬挂", "墙跳", "地面冲刺", "空中冲刺", "二段跳", "超级跳", "游泳", "下劈",
"法术槽 1", "法术槽 2", "法术槽 3",
"灵魄形态", "灵魄冲刺",
"弹反", "蓄力攻击", "下斩",
"互动", "快速旅行",
"无敌冲刺",
};
// ── 样式(懒初始化)──────────────────────────────────────────────────
private GUIStyle _headerStyle;
private GUIStyle _boxStyle;
// ── 滚动 ──────────────────────────────────────────────────────────────
private Vector2 _scroll;
// ── EditorWindow 回调 ─────────────────────────────────────────────────
private void OnEnable() => EditorApplication.playModeStateChanged += OnPlayModeChanged;
private void OnDisable() => EditorApplication.playModeStateChanged -= OnPlayModeChanged;
private void OnPlayModeChanged(PlayModeStateChange state)
{
_stats = null;
_formCtrl = null;
_playerCtrl = null;
Repaint();
}
private void OnGUI()
{
EnsureStyles();
if (!Application.isPlaying)
{
EditorGUILayout.HelpBox("GM 工具仅在 Play Mode 下有效。\n请先运行游戏。", MessageType.Info);
return;
}
RefreshCache();
if (_stats == null)
{
EditorGUILayout.HelpBox("场景中未找到 PlayerStats 组件。\n请确认玩家已生成。", MessageType.Warning);
if (GUILayout.Button("重新扫描")) _lastCacheTime = -10;
return;
}
_scroll = EditorGUILayout.BeginScrollView(_scroll);
DrawResourceSection();
DrawJumpSection();
DrawFormSection();
DrawAbilitySection();
DrawDebugSection();
EditorGUILayout.EndScrollView();
}
// ── 分区:资源 ────────────────────────────────────────────────────────
private void DrawResourceSection()
{
_foldResources = DrawFoldout(_foldResources, "资源快速填充");
if (!_foldResources) return;
EditorGUILayout.BeginVertical(_boxStyle);
// 灵铢
EditorGUILayout.LabelField("灵铢", EditorStyles.boldLabel);
EditorGUILayout.LabelField($"当前:{_stats.CurrentLingZhu}", EditorStyles.miniLabel);
EditorGUILayout.BeginHorizontal();
_lingZhuAmount = EditorGUILayout.IntField(_lingZhuAmount, GUILayout.Width(80));
if (GUILayout.Button("增加")) _stats.AddLingZhu(_lingZhuAmount);
if (GUILayout.Button("设为 9999")) _stats.AddLingZhu(Mathf.Max(0, 9999 - _stats.CurrentLingZhu));
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(6);
// 灵力SoulPower
EditorGUILayout.LabelField("灵力(技能用)", EditorStyles.boldLabel);
EditorGUILayout.LabelField($"当前:{_stats.CurrentSoulPower} / {_stats.MaxSoulPower}", EditorStyles.miniLabel);
EditorGUILayout.BeginHorizontal();
_soulPowerAmount = EditorGUILayout.IntField(_soulPowerAmount, GUILayout.Width(80));
if (GUILayout.Button("增加")) _stats.AddSoulPower(_soulPowerAmount);
if (GUILayout.Button("填满")) _stats.AddSoulPower(_stats.MaxSoulPower);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(6);
// 魄元SpiritPower
EditorGUILayout.LabelField("魄元(魄技能用)", EditorStyles.boldLabel);
EditorGUILayout.LabelField($"当前:{_stats.CurrentSpiritPower} / {_stats.MaxSpiritPower}", EditorStyles.miniLabel);
EditorGUILayout.BeginHorizontal();
_spiritAmount = EditorGUILayout.IntField(_spiritAmount, GUILayout.Width(80));
if (GUILayout.Button("增加")) _stats.AddSpiritPower(_spiritAmount);
if (GUILayout.Button("填满")) _stats.AddSpiritPower(_stats.MaxSpiritPower);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
if (GUILayout.Button("▶ 全部资源填满"))
{
_stats.AddLingZhu(Mathf.Max(0, 9999 - _stats.CurrentLingZhu));
_stats.AddSoulPower(_stats.MaxSoulPower);
_stats.AddSpiritPower(_stats.MaxSpiritPower);
}
EditorGUILayout.EndVertical();
}
// ── 分区:跳跃快捷 ────────────────────────────────────────────────────
private void DrawJumpSection()
{
_foldJump = DrawFoldout(_foldJump, "跳跃能力快捷");
if (!_foldJump) return;
EditorGUILayout.BeginVertical(_boxStyle);
// ── 当前状态 ──
bool hasDoubleJump = _stats.HasAbility(AbilityType.DoubleJump);
bool hasDash = _stats.HasAbility(AbilityType.Dash);
bool hasWallJump = _stats.HasAbility(AbilityType.WallJump);
bool hasWallCling = _stats.HasAbility(AbilityType.WallCling);
int airJumpsLeft = _playerCtrl != null ? _playerCtrl.AirJumpsLeft : -1;
int maxAirJumps = _playerCtrl != null && _playerCtrl.MovConfig != null
? _playerCtrl.MovConfig.MaxAirJumps : -1;
string airStr = airJumpsLeft >= 0
? $"{airJumpsLeft} / {maxAirJumps}"
: "N/A";
EditorGUILayout.LabelField(
$"二段跳:{(hasDoubleJump ? " " : " ")} | 腾空剩余:{airStr}",
EditorStyles.miniLabel);
// ── MaxAirJumps 控制 ──
if (_playerCtrl != null && _playerCtrl.MovConfig != null)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("最大空中跳跃次数", GUILayout.Width(120));
int newMax = EditorGUILayout.IntSlider(
_playerCtrl.MovConfig.MaxAirJumps, 1, 5, GUILayout.ExpandWidth(true));
if (newMax != _playerCtrl.MovConfig.MaxAirJumps)
{
_playerCtrl.MovConfig.MaxAirJumps = newMax;
EditorUtility.SetDirty(_playerCtrl.MovConfig);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.LabelField(
$" 1=二段跳 2=三段跳 3=四段跳…(需先解锁 DoubleJump 能力)",
EditorStyles.miniLabel);
}
EditorGUILayout.Space(4);
// ── 跳跃系列快捷按钮(每行 2 个)──
EditorGUILayout.BeginHorizontal();
DrawToggleAbilityBtn(hasDoubleJump, AbilityType.DoubleJump, "二段跳");
DrawToggleAbilityBtn(hasDash, AbilityType.Dash, "冲刺(地面+空中)");
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
DrawToggleAbilityBtn(hasWallJump, AbilityType.WallJump, "墙跳");
DrawToggleAbilityBtn(hasWallCling, AbilityType.WallCling, "贴墙悬挂");
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
DrawToggleAbilityBtn(_stats.HasAbility(AbilityType.SuperJump), AbilityType.SuperJump, "超级跳");
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
// ── 批量快捷 ──
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("解锁全部移动能力"))
{
foreach (var f in new[]
{
AbilityType.Dash,
AbilityType.DoubleJump, AbilityType.SuperJump,
AbilityType.WallCling, AbilityType.WallJump,
})
_stats.UnlockAbility(f);
}
if (GUILayout.Button("锁定全部移动能力"))
{
foreach (var f in new[]
{
AbilityType.Dash,
AbilityType.DoubleJump, AbilityType.SuperJump,
AbilityType.WallCling, AbilityType.WallJump,
})
_stats.LockAbility(f);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.HelpBox(
"MaxAirJumps 修改立即写入 ScriptableObject持久化。\n" +
"AirJumpsLeft 在角色下次落地时按新值重置。",
MessageType.None);
EditorGUILayout.EndVertical();
}
/// <summary>
/// 绘制一个「已解锁 → 锁定 / 未解锁 → 解锁」的切换按钮。
/// </summary>
private void DrawToggleAbilityBtn(bool hasIt, AbilityType flag, string label)
{
GUI.backgroundColor = hasIt ? new Color(0.6f, 1.0f, 0.6f) : new Color(1.0f, 0.85f, 0.6f);
string btnText = hasIt ? $"✔ {label}" : $"✘ {label}";
if (GUILayout.Button(btnText))
{
if (hasIt) _stats.LockAbility(flag);
else _stats.UnlockAbility(flag);
}
GUI.backgroundColor = Color.white;
}
// ── 分区:形态 ────────────────────────────────────────────────────────
private void DrawFormSection()
{
_foldForms = DrawFoldout(_foldForms, "形态快速切换");
if (!_foldForms) return;
EditorGUILayout.BeginVertical(_boxStyle);
if (_formCtrl == null)
{
EditorGUILayout.HelpBox("场景中未找到 FormController 组件。", MessageType.Warning);
}
else
{
string cur = _formCtrl.CurrentForm != null ? _formCtrl.CurrentForm.displayName : "未知";
EditorGUILayout.LabelField($"当前形态:{cur}", EditorStyles.boldLabel);
EditorGUILayout.Space(4);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("天魂")) SwitchForm(FormType.TianHun);
if (GUILayout.Button("地魂")) SwitchForm(FormType.DiHun);
if (GUILayout.Button("命魂")) SwitchForm(FormType.MingHun);
EditorGUILayout.EndHorizontal();
EditorGUILayout.HelpBox("提示切换形态前请确保已解锁灵魄形态SpiritForm能力。", MessageType.None);
}
EditorGUILayout.EndVertical();
}
private void SwitchForm(FormType type)
{
// 确保 SpiritForm 能力已解锁(否则 FSM 可能拒绝形态切换)
_stats.UnlockAbility(AbilityType.SpiritForm);
_formCtrl.SwitchForm(type);
}
// ── 分区:能力 ────────────────────────────────────────────────────────
private void DrawAbilitySection()
{
_foldAbilities = DrawFoldout(_foldAbilities, "能力解锁 / 锁定");
if (!_foldAbilities) return;
EditorGUILayout.BeginVertical(_boxStyle);
// 快捷全选/全清
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("全部解锁"))
{
foreach (var (_, flags) in AbilityGroups)
foreach (var f in flags)
_stats.UnlockAbility(f);
}
if (GUILayout.Button("全部锁定"))
{
foreach (var (_, flags) in AbilityGroups)
foreach (var f in flags)
_stats.LockAbility(f);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(6);
// 各分组
foreach (var (groupLabel, flags) in AbilityGroups)
{
EditorGUILayout.LabelField(groupLabel, EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
bool allOn = true;
foreach (var f in flags) if (!_stats.HasAbility(f)) { allOn = false; break; }
if (GUILayout.Button(allOn ? "全锁" : "全解", GUILayout.Width(42)))
{
foreach (var f in flags)
{
if (allOn) _stats.LockAbility(f);
else _stats.UnlockAbility(f);
}
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
int col = 0;
foreach (var flag in flags)
{
bool has = _stats.HasAbility(flag);
bool toggled = GUILayout.Toggle(has, FlagDisplayName(flag), GUILayout.Width(128));
if (toggled != has)
{
if (toggled) _stats.UnlockAbility(flag);
else _stats.LockAbility(flag);
}
col++;
if (col == 2) { col = 0; EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); }
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
}
EditorGUILayout.EndVertical();
}
// ── 分区:调试辅助 ────────────────────────────────────────────────────
private void DrawDebugSection()
{
_foldDebug = DrawFoldout(_foldDebug, "调试辅助");
if (!_foldDebug) return;
EditorGUILayout.BeginVertical(_boxStyle);
// HP
EditorGUILayout.LabelField($"HP{_stats.CurrentHP} / {_stats.MaxHP}", EditorStyles.miniLabel);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("满血")) _stats.FullHeal();
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
// 弹簧充能
EditorGUILayout.LabelField($"弹力充能:{_stats.CurrentSpringCharges} / {_stats.MaxSpringCharges}", EditorStyles.miniLabel);
if (GUILayout.Button("恢复全部弹力充能")) _stats.RestoreSpringCharges();
EditorGUILayout.Space(4);
// 无敌模式God Mode
bool godNow = _stats.IsInvincible; // 仅作参考GodMode 内部字段
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("开启无敌模式")) _stats.SetGodMode(true);
if (GUILayout.Button("关闭无敌模式")) _stats.SetGodMode(false);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
// 一键全开(开发快速进入测试状态)
GUI.backgroundColor = new Color(0.7f, 1.0f, 0.7f);
if (GUILayout.Button("▶ 一键满状态(资源 + 全能力 + 满血)", GUILayout.Height(32)))
{
_stats.FullHeal();
_stats.RestoreSpringCharges();
_stats.AddLingZhu(Mathf.Max(0, 9999 - _stats.CurrentLingZhu));
_stats.AddSoulPower(_stats.MaxSoulPower);
_stats.AddSpiritPower(_stats.MaxSpiritPower);
foreach (var (_, flags) in AbilityGroups)
foreach (var f in flags)
_stats.UnlockAbility(f);
}
GUI.backgroundColor = Color.white;
EditorGUILayout.EndVertical();
}
// ── 工具方法 ──────────────────────────────────────────────────────────
private void RefreshCache()
{
if (EditorApplication.timeSinceStartup - _lastCacheTime < 2.0) return;
_lastCacheTime = EditorApplication.timeSinceStartup;
_stats = FindObjectOfType<PlayerStats>();
_formCtrl = FindObjectOfType<FormController>();
_playerCtrl = FindObjectOfType<PlayerController>();
}
private bool DrawFoldout(bool state, string label)
{
EditorGUILayout.Space(4);
bool next = EditorGUILayout.Foldout(state, label, true, _headerStyle);
return next;
}
private static string FlagDisplayName(AbilityType flag) => flag switch
{
AbilityType.WallCling => "贴墙悬挂",
AbilityType.WallJump => "墙跳",
AbilityType.Dash => "冲刺",
AbilityType.DoubleJump => "二段跳",
AbilityType.SuperJump => "超级跳",
AbilityType.Swim => "游泳",
AbilityType.Dive => "下劈",
AbilityType.Spell1 => "法术槽 1",
AbilityType.Spell2 => "法术槽 2",
AbilityType.Spell3 => "法术槽 3",
AbilityType.SpiritForm => "灵魄形态",
AbilityType.SpiritDash => "灵魄冲刺",
AbilityType.Parry => "弹反",
AbilityType.ChargeAttack => "蓄力攻击",
AbilityType.DownSlash => "下斩",
AbilityType.Interact => "互动",
AbilityType.FastTravel => "快速旅行",
AbilityType.InvincibleDash => "无敌冲刺",
_ => flag.ToString(),
};
private void EnsureStyles()
{
if (_headerStyle != null) return;
_headerStyle = new GUIStyle(EditorStyles.foldout)
{
fontStyle = FontStyle.Bold,
fontSize = 12,
};
_boxStyle = new GUIStyle(EditorStyles.helpBox)
{
padding = new RectOffset(8, 8, 6, 6),
};
}
// ── 自动刷新(每秒重绘以显示最新数值)──────────────────────────────
private void OnInspectorUpdate() => Repaint();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fe104ad18cf3df743a6edd48b173115f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,393 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace BaseGames.Editor
{
/// <summary>
/// SO 资产总管理窗口 —— 浏览、搜索并在 Project 窗口定位项目中所有 ScriptableObject 资产。
///
/// 布局:顶部搜索栏 | 左侧分类列表 | 右侧资产列表(名称 / 类型 / 路径)
/// 功能:单击资产行 → Project 窗口 Ping 并选中;双击 → 同上并聚焦 Project 窗口。
/// 菜单BaseGames / Tools / SO Manager (Priority 2)
/// </summary>
public class SOManagerWindow : EditorWindow
{
private const string DataRoot = "Assets/_Game/Data";
private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
// ── 数据模型 ──────────────────────────────────────────────────────────
private sealed class CategoryEntry
{
public string Label;
public string Folder; // null = 全部
public int Count;
}
private sealed class AssetEntry
{
public string Name;
public string TypeName;
public string AssetPath;
public ScriptableObject Asset;
}
private readonly List<CategoryEntry> _categories = new();
private readonly List<AssetEntry> _allAssets = new();
private readonly List<AssetEntry> _filtered = new();
private int _selectedCatIdx = 0;
private string _search = "";
// ── UI 引用 ───────────────────────────────────────────────────────────
private ListView _catList;
private ListView _assetList;
private TextField _searchField;
private Label _statusLabel;
// ── 菜单入口 ──────────────────────────────────────────────────────────
[MenuItem("BaseGames/Tools/SO Manager", priority = 2)]
public static void Open()
{
var wnd = GetWindow<SOManagerWindow>();
wnd.titleContent = new GUIContent("SO Manager",
EditorGUIUtility.IconContent("d_ScriptableObject Icon").image);
wnd.minSize = new Vector2(680, 420);
}
// ── 生命周期 ──────────────────────────────────────────────────────────
public void CreateGUI()
{
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
if (uss != null) rootVisualElement.styleSheets.Add(uss);
BuildUI();
Refresh();
}
private void OnFocus() => Refresh();
// ── UI 构建 ───────────────────────────────────────────────────────────
private void BuildUI()
{
rootVisualElement.style.flexDirection = FlexDirection.Column;
// ─ 顶部工具栏 ──────────────────────────────────────────────────────
var toolbar = new VisualElement();
toolbar.style.flexDirection = FlexDirection.Row;
toolbar.style.paddingLeft = 8;
toolbar.style.paddingRight = 8;
toolbar.style.paddingTop = 5;
toolbar.style.paddingBottom = 5;
toolbar.style.borderBottomWidth = 1;
toolbar.style.borderBottomColor = new Color(0.15f, 0.15f, 0.15f);
toolbar.style.backgroundColor = new Color(0.22f, 0.22f, 0.22f, 0.6f);
var searchLbl = new Label("搜索:");
searchLbl.style.unityTextAlign = TextAnchor.MiddleLeft;
searchLbl.style.marginRight = 4;
_searchField = new TextField();
_searchField.style.flexGrow = 1;
_searchField.RegisterValueChangedCallback(e =>
{
_search = e.newValue;
ApplyFilter();
});
var refreshBtn = new Button(Refresh) { text = "↻ 刷新" };
refreshBtn.style.marginLeft = 8;
refreshBtn.style.width = 58;
toolbar.Add(searchLbl);
toolbar.Add(_searchField);
toolbar.Add(refreshBtn);
rootVisualElement.Add(toolbar);
// ─ 主体:两栏分割 ──────────────────────────────────────────────────
var split = new TwoPaneSplitView(0, 164, TwoPaneSplitViewOrientation.Horizontal);
split.style.flexGrow = 1;
// 左栏:分类列表 ────────────────────────────────────────────────────
var leftPane = new VisualElement();
leftPane.style.flexDirection = FlexDirection.Column;
leftPane.style.minWidth = 100;
var catHeader = new Label("分类");
catHeader.style.paddingLeft = 8;
catHeader.style.paddingTop = 5;
catHeader.style.paddingBottom = 5;
catHeader.style.unityFontStyleAndWeight = FontStyle.Bold;
catHeader.style.borderBottomWidth = 1;
catHeader.style.borderBottomColor = new Color(0.22f, 0.22f, 0.22f);
catHeader.style.backgroundColor = new Color(0.22f, 0.22f, 0.22f, 0.4f);
leftPane.Add(catHeader);
_catList = new ListView
{
makeItem = MakeCatItem,
bindItem = BindCatItem,
selectionType = SelectionType.Single,
fixedItemHeight = 26,
};
_catList.style.flexGrow = 1;
_catList.selectionChanged += _ =>
{
if (_catList.selectedIndex >= 0)
{
_selectedCatIdx = _catList.selectedIndex;
ApplyFilter();
}
};
leftPane.Add(_catList);
// 右栏:资产列表 ────────────────────────────────────────────────────
var rightPane = new VisualElement();
rightPane.style.flexDirection = FlexDirection.Column;
// 列标题行
var colHeader = new VisualElement();
colHeader.style.flexDirection = FlexDirection.Row;
colHeader.style.paddingLeft = 8;
colHeader.style.paddingRight = 8;
colHeader.style.paddingTop = 4;
colHeader.style.paddingBottom = 4;
colHeader.style.borderBottomWidth = 1;
colHeader.style.borderBottomColor = new Color(0.22f, 0.22f, 0.22f);
colHeader.style.backgroundColor = new Color(0.22f, 0.22f, 0.22f, 0.4f);
colHeader.Add(MakeHeaderLabel("资产名", true, 0));
colHeader.Add(MakeHeaderLabel("类型", false, 170));
colHeader.Add(MakeHeaderLabel("路径", true, 0));
rightPane.Add(colHeader);
_assetList = new ListView
{
makeItem = MakeAssetRow,
bindItem = BindAssetRow,
selectionType = SelectionType.Single,
fixedItemHeight = 22,
};
_assetList.style.flexGrow = 1;
_assetList.selectionChanged += _ => OnAssetPicked();
_assetList.itemsChosen += _ => FocusProjectWindow();
rightPane.Add(_assetList);
// 状态栏
_statusLabel = new Label("—");
_statusLabel.style.paddingLeft = 8;
_statusLabel.style.paddingTop = 3;
_statusLabel.style.paddingBottom = 3;
_statusLabel.style.borderTopWidth = 1;
_statusLabel.style.borderTopColor = new Color(0.15f, 0.15f, 0.15f);
_statusLabel.style.color = new Color(0.58f, 0.58f, 0.58f);
_statusLabel.style.fontSize = 11;
rightPane.Add(_statusLabel);
split.Add(leftPane);
split.Add(rightPane);
rootVisualElement.Add(split);
}
private static Label MakeHeaderLabel(string text, bool grow, int fixedWidth)
{
var lbl = new Label(text);
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
lbl.style.overflow = Overflow.Hidden;
if (grow) lbl.style.flexGrow = 1;
if (fixedWidth > 0) lbl.style.width = fixedWidth;
return lbl;
}
// ── 分类列表项 ────────────────────────────────────────────────────────
private static VisualElement MakeCatItem()
{
var lbl = new Label();
lbl.style.paddingLeft = 10;
lbl.style.paddingRight = 6;
lbl.style.overflow = Overflow.Hidden;
lbl.style.unityTextAlign = TextAnchor.MiddleLeft;
return lbl;
}
private void BindCatItem(VisualElement el, int i)
{
if (i >= _categories.Count) return;
var cat = _categories[i];
((Label)el).text = $"{cat.Label} ({cat.Count})";
}
// ── 资产列表项 ────────────────────────────────────────────────────────
private static VisualElement MakeAssetRow()
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.style.paddingLeft = 8;
row.style.paddingRight = 8;
var nameEl = new Label { name = "n" };
nameEl.style.flexGrow = 1;
nameEl.style.overflow = Overflow.Hidden;
nameEl.style.textOverflow = TextOverflow.Ellipsis;
var typeEl = new Label { name = "t" };
typeEl.style.width = 170;
typeEl.style.overflow = Overflow.Hidden;
typeEl.style.textOverflow = TextOverflow.Ellipsis;
typeEl.style.color = new Color(0.52f, 0.80f, 1.00f);
typeEl.style.fontSize = 11;
var pathEl = new Label { name = "p" };
pathEl.style.flexGrow = 1;
pathEl.style.overflow = Overflow.Hidden;
pathEl.style.textOverflow = TextOverflow.Ellipsis;
pathEl.style.color = new Color(0.48f, 0.48f, 0.48f);
pathEl.style.fontSize = 10;
row.Add(nameEl);
row.Add(typeEl);
row.Add(pathEl);
return row;
}
private void BindAssetRow(VisualElement el, int i)
{
if (i >= _filtered.Count) return;
var e = _filtered[i];
el.Q<Label>("n").text = e.Name;
el.Q<Label>("t").text = e.TypeName;
// 显示相对于 DataRoot 的路径,去掉文件名本身只保留目录
string rel = e.AssetPath.StartsWith(DataRoot + "/")
? e.AssetPath.Substring(DataRoot.Length + 1)
: e.AssetPath;
// 去掉最后的文件名,只显示目录部分
string dir = Path.GetDirectoryName(rel)?.Replace('\\', '/') ?? "";
el.Q<Label>("p").text = string.IsNullOrEmpty(dir) ? "/" : dir;
}
// ── 资产选中 ──────────────────────────────────────────────────────────
private void OnAssetPicked()
{
int idx = _assetList.selectedIndex;
if (idx < 0 || idx >= _filtered.Count) return;
var asset = _filtered[idx].Asset;
if (asset == null) return;
EditorGUIUtility.PingObject(asset);
Selection.activeObject = asset;
}
private static void FocusProjectWindow()
{
EditorApplication.ExecuteMenuItem("Window/General/Project");
}
// ── 数据逻辑 ──────────────────────────────────────────────────────────
private void Refresh()
{
_allAssets.Clear();
// 扫描 DataRoot 下所有 ScriptableObject 资产
var guids = AssetDatabase.FindAssets("t:ScriptableObject", new[] { DataRoot });
foreach (var guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
if (asset == null) continue;
_allAssets.Add(new AssetEntry
{
Name = asset.name,
TypeName = asset.GetType().Name,
AssetPath = path,
Asset = asset,
});
}
// 构建分类:首项为"全部",其余为 DataRoot 的直接子目录
_categories.Clear();
_categories.Add(new CategoryEntry
{
Label = "全部",
Folder = null,
Count = _allAssets.Count,
});
if (AssetDatabase.IsValidFolder(DataRoot))
{
foreach (var sub in AssetDatabase.GetSubFolders(DataRoot).OrderBy(f => f))
{
string folderName = Path.GetFileName(sub);
int count = _allAssets.Count(a =>
a.AssetPath.StartsWith(sub + "/", StringComparison.Ordinal));
if (count == 0) continue;
_categories.Add(new CategoryEntry
{
Label = folderName,
Folder = sub,
Count = count,
});
}
}
_catList.itemsSource = _categories;
_catList.Rebuild();
int clampedIdx = Mathf.Clamp(_selectedCatIdx, 0, _categories.Count - 1);
_catList.SetSelection(clampedIdx);
_selectedCatIdx = clampedIdx;
ApplyFilter();
}
private void ApplyFilter()
{
_filtered.Clear();
string folder = (_selectedCatIdx >= 0 && _selectedCatIdx < _categories.Count)
? _categories[_selectedCatIdx].Folder
: null;
IEnumerable<AssetEntry> source = folder == null
? _allAssets
: _allAssets.Where(a => a.AssetPath.StartsWith(folder + "/", StringComparison.Ordinal));
foreach (var entry in source)
{
if (string.IsNullOrEmpty(_search)
|| entry.Name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0
|| entry.TypeName.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0)
{
_filtered.Add(entry);
}
}
// 同分类内按类型分组,再按名称排序
_filtered.Sort((a, b) =>
{
int cmp = string.Compare(a.TypeName, b.TypeName, StringComparison.OrdinalIgnoreCase);
return cmp != 0 ? cmp : string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
});
_assetList.itemsSource = _filtered;
_assetList.Rebuild();
int total = folder == null
? _allAssets.Count
: _allAssets.Count(a => a.AssetPath.StartsWith(folder + "/", StringComparison.Ordinal));
_statusLabel.text = string.IsNullOrEmpty(_search)
? $"共 {_filtered.Count} 个资产"
: $"筛选 {_filtered.Count} / {total} 个资产(搜索:{_search}";
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7dd063f0750f2c24cae7c29f40b24a8a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,11 +1,16 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Reflection; using System.Reflection;
using Animancer;
using BaseGames.Camera; using BaseGames.Camera;
using BaseGames.Combat; using BaseGames.Combat;
using BaseGames.Combat.StatusEffects;
using BaseGames.Dialogue; using BaseGames.Dialogue;
using BaseGames.Enemies; using BaseGames.Enemies;
using BaseGames.Equipment;
using BaseGames.Parry;
using BaseGames.Player; using BaseGames.Player;
using BaseGames.Player.States; using BaseGames.Player.States;
using BaseGames.Skills;
using BaseGames.World; using BaseGames.World;
using PathBerserker2d; using PathBerserker2d;
using Unity.Cinemachine; using Unity.Cinemachine;
@@ -33,53 +38,83 @@ namespace BaseGames.Editor
{ {
var report = new List<string>(); var report = new List<string>();
GameObject go = new GameObject("Player"); // ── Player 根节点(行为+物理+标签三合一)──────────────────────────────
Undo.RegisterCreatedObjectUndo(go, "Place Player"); // Rigidbody2D / 所有 MonoBehaviour 集中于此节点。
go.transform.position = GetDropPosition(); // HurtBox 作为其子节点GetComponentInParent<IDamageable>() 向上即可找到
go.tag = "Player"; // 本节点上的 PlayerControllerIDamageable 实现者)。
SetLayer(go, "Player", report); GameObject root = new GameObject("Player");
Undo.RegisterCreatedObjectUndo(root, "Place Player");
root.transform.position = GetDropPosition();
root.tag = "Player";
SetLayer(root, "Player", report);
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go); // 物理组件PlayerMovement RequireComponent(Rigidbody2D),必须同节点)
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(root);
rb.bodyType = RigidbodyType2D.Dynamic; rb.bodyType = RigidbodyType2D.Dynamic;
rb.gravityScale = 2f; rb.gravityScale = 2f;
rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.constraints = RigidbodyConstraints2D.FreezeRotation;
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
rb.interpolation = RigidbodyInterpolation2D.Interpolate;
GetOrAddComponent<BoxCollider2D>(root);
GetOrAddComponent<CapsuleCollider2D>(go); // 动画组件AnimancerComponent 需要 Animator 存在PlayerController
GetOrAddComponent<Animator>(go); // [RequireComponent(typeof(AnimancerComponent))] 保证其存在)
SetupSpriteRenderer(go); GetOrAddComponent<Animator>(root);
GetOrAddComponent<AnimancerComponent>(root);
PlayerStats playerStats = GetOrAddComponent<PlayerStats>(go); SetupSpriteRenderer(root);
PlayerMovement playerMovement = GetOrAddComponent<PlayerMovement>(go);
PlayerController playerController = GetOrAddComponent<PlayerController>(go);
PlayerCombat playerCombat = GetOrAddComponent<PlayerCombat>(go);
// Ground check pivot // 核心行为组件
Transform groundCheckGo = GetOrCreateChild(go.transform, "GroundCheck"); PlayerStats playerStats = GetOrAddComponent<PlayerStats>(root);
groundCheckGo.localPosition = new Vector3(0f, -0.75f, 0f); PlayerMovement playerMovement = GetOrAddComponent<PlayerMovement>(root);
AssignReference(playerMovement, "_groundCheck", groundCheckGo, report); PlayerCombat playerCombat = GetOrAddComponent<PlayerCombat>(root);
AssignLayerMask(playerMovement, "_groundLayer", "Ground", report); FormController formController = GetOrAddComponent<FormController>(root);
WeaponManager weaponManager = GetOrAddComponent<WeaponManager>(root);
SkillManager skillManager = GetOrAddComponent<SkillManager>(root);
SpringSystem springSystem = GetOrAddComponent<SpringSystem>(root);
ParrySystem parrySystem = GetOrAddComponent<ParrySystem>(root);
ShieldComponent shield = GetOrAddComponent<ShieldComponent>(root);
PlayerWallDetector wallDetector = GetOrAddComponent<PlayerWallDetector>(root);
EquipmentManager equipmentManager = GetOrAddComponent<EquipmentManager>(root);
GetOrAddComponent<SkillModifierRegistry>(root);
GetOrAddComponent<StatusEffectManager>(root);
// PlayerController 最后添加RequireComponent 会拉取上方已加好的组件
PlayerController playerController = GetOrAddComponent<PlayerController>(root);
// Weapon socket (WeaponManager instantiates weapons here at runtime) // ── HurtBox 子节点 ───────────────────────────────────────────────────
GetOrCreateChild(go.transform, "WeaponSocket"); Transform hurtBoxT = GetOrCreateChild(root.transform, "HurtBox");
// Camera follow target — CinemachineCamera.Follow 使用此子节点而非 Player 根节点
GetOrCreateChild(go.transform, "CameraFollowTarget");
// HurtBox child
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "PlayerHurtBox", report); SetLayer(hurtBoxT.gameObject, "PlayerHurtBox", report);
CapsuleCollider2D hurtCollider = GetOrAddComponent<CapsuleCollider2D>(hurtBoxT.gameObject); BoxCollider2D hurtCollider = GetOrAddComponent<BoxCollider2D>(hurtBoxT.gameObject);
hurtCollider.isTrigger = true; hurtCollider.isTrigger = true;
HurtBox hurtBox = GetOrAddComponent<HurtBox>(hurtBoxT.gameObject); HurtBox hurtBox = GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
// Assign controller references // ── [WeaponSocket] 子节点WeaponManager 动态实例化武器 HitBox 的挂点)
AssignReference(playerController, "_stats", playerStats, report); GetOrCreateChild(root.transform, "[WeaponSocket]");
AssignReference(playerController, "_hurtBox", hurtBox, report);
AssignReference(playerController, "_movement", playerMovement, report);
AssignReference(playerController, "_combat", playerCombat, report);
// Event channels (all optional — will be skipped silently if assets missing) // ── GroundCheck 子节点(地面检测 Transform────────────────────────
Transform groundCheckT = GetOrCreateChild(root.transform, "GroundCheck");
groundCheckT.localPosition = new Vector3(0f, -0.75f, 0f);
AssignReference(playerMovement, "_groundCheck", groundCheckT, report);
AssignLayerMask(playerMovement, "_groundLayer", "Ground", report);
// ── SkillHitBox_Slot 子节点(技能 HitBox 实例化挂点)────────────────
GetOrCreateChild(root.transform, "SkillHitBox_Slot");
// ── CameraFollowTarget 子节点CinemachineCamera.Follow 目标)────────
GetOrCreateChild(root.transform, "CameraFollowTarget");
// ── PlayerController SerializeField 引用赋值 ──────────────────────
AssignReference(playerController, "_combat", playerCombat, report);
AssignReference(playerController, "_formController", formController, report);
AssignReference(playerController, "_weaponManager", weaponManager, report);
AssignReference(playerController, "_skillManager", skillManager, report);
AssignReference(playerController, "_springSystem", springSystem, report);
AssignReference(playerController, "_parrySystem", parrySystem, report);
AssignReference(playerController, "_hurtBox", hurtBox, report);
AssignReference(playerController, "_shield", shield, report);
AssignReference(playerController, "_wallDetector", wallDetector, report);
// ── 事件频道(可选,缺失时跳过) ───────────────────────────────────
AssignAsset(playerStats, "_onHPChanged", report, false, "EVT_HPChanged"); AssignAsset(playerStats, "_onHPChanged", report, false, "EVT_HPChanged");
AssignAsset(playerStats, "_onMaxHPChanged", report, false, "EVT_MaxHPChanged"); AssignAsset(playerStats, "_onMaxHPChanged", report, false, "EVT_MaxHPChanged");
AssignAsset(playerStats, "_onSoulPowerChanged", report, false, "EVT_SoulPowerChanged"); AssignAsset(playerStats, "_onSoulPowerChanged", report, false, "EVT_SoulPowerChanged");
@@ -93,17 +128,43 @@ namespace BaseGames.Editor
AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt"); AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed"); AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
// Config ScriptableObjects (optional — link manually after placing) // ── Config SO 自动查找(资产存在时自动绑定)──────────────────────
Object statsConfig = FindFirstAsset("PLY_PlayerStats", "PlayerStats"); Object statsConfig = FindFirstAsset("PLY_PlayerStats");
Object movConfig = FindFirstAsset("PLY_PlayerMovementConfig", "PlayerMovementConfig"); Object movConfig = FindFirstAsset("PLY_PlayerMovementConfig");
if (movConfig != null) AssignReference(playerController, "_movementConfig", movConfig, report); Object formConfig = FindFirstAsset("PLY_FormConfig");
Object parryConfig = FindFirstAsset("PLY_ParryConfig");
Object shieldConfig = FindFirstAsset("PLY_ShieldConfig");
Object inputReader = FindFirstAsset("InputReader");
Object equipmentConfig = FindFirstAsset("PLY_EquipmentConfig");
Object charmCatalog = FindFirstAsset("PLY_CharmCatalog");
if (statsConfig != null) AssignReference(playerStats, "_config", statsConfig, report); if (statsConfig != null) AssignReference(playerStats, "_config", statsConfig, report);
if (movConfig != null) AssignReference(playerMovement, "_config", movConfig, report); if (movConfig != null)
{
AssignReference(playerController, "_movementConfig", movConfig, report);
AssignReference(playerMovement, "_config", movConfig, report);
AssignReference(wallDetector, "_config", movConfig, report);
}
if (formConfig != null)
{
AssignReference(playerController, "_formConfig", formConfig, report);
AssignReference(formController, "_config", formConfig, report);
}
if (parryConfig != null) AssignReference(parrySystem, "_config", parryConfig, report);
if (shieldConfig != null) AssignReference(shield, "_config", shieldConfig, report);
if (inputReader != null) AssignReference(playerController, "_inputReader", inputReader, report);
if (equipmentConfig != null) AssignReference(equipmentManager, "_config", equipmentConfig, report);
if (charmCatalog != null) AssignReference(equipmentManager, "_charmCatalog", charmCatalog, report);
report.Add("PlayerMovement._config、PlayerController._animConfig、_inputReader 等需后续手动绑定。"); report.Add("★ 需手动绑定PlayerController._animConfigPLY_PlayerAnimationConfig");
if (statsConfig == null) report.Add("★ 需创建并绑定PlayerStats._configPlayerStatsSO");
if (inputReader == null) report.Add("★ 需手动绑定PlayerController._inputReaderInputReaderSO");
if (equipmentConfig == null) report.Add("★ 需创建并绑定EquipmentManager._configEquipmentConfigSO");
if (charmCatalog == null) report.Add("★ 需创建并绑定EquipmentManager._charmCatalogCharmCatalogSO");
report.Add("SkillManager 技能槽 SO 需手动填入。");
Selection.activeGameObject = go; Selection.activeGameObject = root;
MarkDirtyAndLog("Player", go, report); MarkDirtyAndLog("Player", root, report);
} }
[MenuItem("BaseGames/Scene/Place/Player Spawn Point", priority = 105)] [MenuItem("BaseGames/Scene/Place/Player Spawn Point", priority = 105)]
@@ -168,11 +229,11 @@ namespace BaseGames.Editor
AssignReference(enemyBase, "_stats", enemyStats, report); AssignReference(enemyBase, "_stats", enemyStats, report);
// DamageSourceSO for body contact (optional — create manually if missing) // DamageSourceSO for body contact (optional — create manually if missing)
Object dmgSrc = FindFirstAsset("DS_EnemyBody", "DS_TestEnemyBody"); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody", "DS_EnemyBody");
if (dmgSrc != null) if (dmgSrc != null)
AssignReference(hitBox, "_defaultSource", dmgSrc, report); AssignReference(hitBox, "_defaultSource", dmgSrc, report);
else else
report.Add("未找到 DamageSourceSO (DS_EnemyBody)HitBox_Body._defaultSource 未绑定。请创建后手动指定。"); report.Add("未找到 DamageSourceSOHitBox_Body._defaultSource 未绑定。请按规范创建 CMB_DS_EnemyBody.asset。");
// Event channels // Event channels
AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied"); AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied");
@@ -186,7 +247,7 @@ namespace BaseGames.Editor
if (enemyStatsSO != null) if (enemyStatsSO != null)
AssignReference(enemyBase, "_statsSO", enemyStatsSO, report); AssignReference(enemyBase, "_statsSO", enemyStatsSO, report);
else else
report.Add("未找到 EnemyStatsSOEnemyBase._statsSO 未绑定。请在 Data/Enemies/ 创建后手动指定。"); report.Add("未找到 EnemyStatsSOEnemyBase._statsSO 未绑定。请在 Data/Enemies/ 创建 ENM_{id}_Stats.asset 后手动指定。");
report.Add("行为树、导航参数NavAgent、动画片段需后续手工挂载。"); report.Add("行为树、导航参数NavAgent、动画片段需后续手工挂载。");
@@ -239,11 +300,11 @@ namespace BaseGames.Editor
AssignReference(bossBase, "_stats", bossStats, report); AssignReference(bossBase, "_stats", bossStats, report);
// DamageSourceSO // DamageSourceSO
Object dmgSrc = FindFirstAsset("DS_BossBody", "DS_EnemyBody"); Object dmgSrc = FindFirstAsset("CMB_DS_BossBody", "CMB_DS_EnemyBody", "DS_BossBody");
if (dmgSrc != null) if (dmgSrc != null)
AssignReference(hitBox, "_defaultSource", dmgSrc, report); AssignReference(hitBox, "_defaultSource", dmgSrc, report);
else else
report.Add("未找到 DamageSourceSOHitBox_Body._defaultSource 未绑定。"); report.Add("未找到 DamageSourceSOHitBox_Body._defaultSource 未绑定。请按规范创建 CMB_DS_BossBody.asset。");
// Event channels // Event channels
AssignAsset(bossBase, "_onEnemyDied", report, false, "EVT_EnemyDied"); AssignAsset(bossBase, "_onEnemyDied", report, false, "EVT_EnemyDied");
@@ -291,6 +352,7 @@ namespace BaseGames.Editor
GetOrAddComponent<HurtBox>(hurtBoxT.gameObject); GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
AssignAsset(trap, "_onPlayerDied", report, false, "EVT_PlayerDied"); AssignAsset(trap, "_onPlayerDied", report, false, "EVT_PlayerDied");
AssignAsset(trap, "_onCheckpointRespawn", report, false, "EVT_CheckpointRespawn");
report.Add("_canPogo=true子 HurtBox 供玩家下劈弹起;设为 false 可改为纯死亡区(无需子 HurtBox。"); report.Add("_canPogo=true子 HurtBox 供玩家下劈弹起;设为 false 可改为纯死亡区(无需子 HurtBox。");
@@ -298,7 +360,32 @@ namespace BaseGames.Editor
MarkDirtyAndLog("Hazard (LethalTrap)", go, report); MarkDirtyAndLog("Hazard (LethalTrap)", go, report);
} }
[MenuItem("BaseGames/Scene/Place/Collectible (LingZhu)", priority = 125)] [MenuItem("BaseGames/Scene/Place/Checkpoint Marker", priority = 125)]
public static void PlaceCheckpointMarker()
{
var report = new List<string>();
GameObject go = new GameObject("CheckpointMarker");
Undo.RegisterCreatedObjectUndo(go, "Place CheckpointMarker");
go.transform.position = GetDropPosition();
SetLayer(go, "TriggerZone", report);
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
col.isTrigger = true;
col.size = new Vector2(1f, 2f);
CheckpointMarker marker = GetOrAddComponent<CheckpointMarker>(go);
AssignLayerMask(marker, "_playerLayers", "Player", report);
AssignAsset(marker, "_onCheckpointReached", report, false, "EVT_CheckpointReached");
report.Add("放置于跳跳乐段落的关键节点处;玩家经过后成为该房间最近检查点。");
report.Add("同一房间可放置多个,以最近经过的为准。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Checkpoint Marker", go, report);
}
[MenuItem("BaseGames/Scene/Place/Collectible (LingZhu)", priority = 130)]
public static void PlaceCollectible() public static void PlaceCollectible()
{ {
var report = new List<string>(); var report = new List<string>();
@@ -349,14 +436,41 @@ namespace BaseGames.Editor
SavePoint savePoint = GetOrAddComponent<SavePoint>(go); SavePoint savePoint = GetOrAddComponent<SavePoint>(go);
AssignAsset(savePoint, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
AssignAsset(savePoint, "_onSavePointActivated", report, false, "EVT_SavePointActivated"); AssignAsset(savePoint, "_onSavePointActivated", report, false, "EVT_SavePointActivated");
AssignAsset(savePoint, "_onFastTravelOpen", report, false, "EVT_FastTravelOpen");
report.Add("填写 _savePointId全局唯一字符串用于存档点激活记录与复活定位。");
Selection.activeGameObject = go; Selection.activeGameObject = go;
MarkDirtyAndLog("Save Point", go, report); MarkDirtyAndLog("Save Point", go, report);
} }
[MenuItem("BaseGames/Scene/Place/Room Transition", priority = 135)] [MenuItem("BaseGames/Scene/Place/Teleport Station", priority = 135)]
public static void PlaceTeleportStation()
{
var report = new List<string>();
GameObject go = new GameObject("TeleportStation");
Undo.RegisterCreatedObjectUndo(go, "Place TeleportStation");
go.transform.position = GetDropPosition();
SetLayer(go, "TriggerZone", report);
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
col.isTrigger = true;
col.size = new Vector2(1.5f, 2f);
SetupSpriteRenderer(go);
TeleportStation station = GetOrAddComponent<TeleportStation>(go);
AssignAsset(station, "_onFastTravelOpen", report, false, "EVT_FastTravelOpen");
report.Add("填写 _stationId传送站唯一 ID用于地图 UI 标注)。");
report.Add("传送站不存档、不复活、不恢复 HP与存档点是独立对象。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Teleport Station", go, report);
}
[MenuItem("BaseGames/Scene/Place/Room Transition", priority = 140)]
public static void PlaceRoomTransition() public static void PlaceRoomTransition()
{ {
var report = new List<string>(); var report = new List<string>();
@@ -410,20 +524,12 @@ namespace BaseGames.Editor
CameraArea cameraArea = GetOrAddComponent<CameraArea>(go); CameraArea cameraArea = GetOrAddComponent<CameraArea>(go);
// AreaBoundary child — 提供 CinemachineConfiner2D 所需的限位多边形isTrigger = true仅作为相机约束边界 // AreaBoundary child — 提供 CinemachineConfiner3D 所需的限位体积
Transform boundaryT = GetOrCreateChild(go.transform, $"{areaName}_AreaBoundary"); Transform boundaryT = GetOrCreateChild(go.transform, $"{areaName}_AreaBoundary");
PolygonCollider2D boundaryCollider = GetOrAddComponent<PolygonCollider2D>(boundaryT.gameObject); BoxCollider boundaryCollider = GetOrAddComponent<BoxCollider>(boundaryT.gameObject);
boundaryCollider.isTrigger = true; boundaryCollider.isTrigger = true;
boundaryCollider.pathCount = 1; boundaryCollider.center = new Vector3(0f, 0f, -10f); // Z 占位符,实际深度由 SyncConfiner 按 LensConfig 计算
// 顶点必须逆时针CCW排列Cinemachine 底层 Clipper 库对 CW 多边形area<0会取反 delta boundaryCollider.size = new Vector3(24f, 12f, 1f); // 默认房间尺寸占位符
// 导致向外膨胀而非向内收缩,相机将不受限制地跑出边界。
boundaryCollider.SetPath(0, new Vector2[]
{
new Vector2(-12f, -6f), // BL
new Vector2( 12f, -6f), // BR
new Vector2( 12f, 6f), // TR
new Vector2(-12f, 6f), // TL
});
AssignReference(cameraArea, "_confinerCollider", boundaryCollider, report); AssignReference(cameraArea, "_confinerCollider", boundaryCollider, report);
@@ -433,26 +539,26 @@ namespace BaseGames.Editor
zoneGo.transform.position = pos; zoneGo.transform.position = pos;
SetLayer(zoneGo, "TriggerZone", report); SetLayer(zoneGo, "TriggerZone", report);
CameraTriggerZone zone = GetOrAddComponent<CameraTriggerZone>(zoneGo);
PolygonCollider2D col = GetOrAddComponent<PolygonCollider2D>(zoneGo); PolygonCollider2D col = GetOrAddComponent<PolygonCollider2D>(zoneGo);
col.isTrigger = true; col.isTrigger = true;
// 默认矩形轮廓CCW与 AreaBoundary 默认尺寸一致(可在 Inspector 中编辑顶点调整为任意多边形) // 默认矩形多边形24×12可在 Inspector 中编辑顶点
col.SetPath(0, new Vector2[] col.SetPath(0, new Vector2[]
{ {
new Vector2(-12f, -6f), // BL new Vector2(-12f, -6f),
new Vector2( 12f, -6f), // BR new Vector2(-12f, 6f),
new Vector2( 12f, 6f), // TR new Vector2( 12f, 6f),
new Vector2(-12f, 6f), // TL new Vector2( 12f, -6f),
}); });
CameraTriggerZone zone = GetOrAddComponent<CameraTriggerZone>(zoneGo);
AssignReference(zone, "_targetArea", cameraArea, report); AssignReference(zone, "_targetArea", cameraArea, report);
// TriggerZone 归入 CameraArea 节点,方便统一调整与查找 // TriggerZone 归入 CameraArea 节点,方便统一调整与查找
Undo.SetTransformParent(zoneGo.transform, go.transform, "Parent TriggerZone to CameraArea"); Undo.SetTransformParent(zoneGo.transform, go.transform, "Parent TriggerZone to CameraArea");
zoneGo.transform.localPosition = Vector3.zero; zoneGo.transform.localPosition = Vector3.zero;
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
report.Add($"调整 {areaName}_AreaBoundary PolygonCollider2D 顶点以匹配区域边界。"); report.Add($"绑定 LensConfig SO 后单击 Inspector 中「从可视区域更新限位区域」计算 {areaName}_AreaBoundary BoxCollider。");
report.Add($"调整 {areaName}_TriggerZone PolygonCollider2D 顶点以匹配入口走廊(支持任意多边形。"); report.Add($"编辑 {areaName}_TriggerZone PolygonCollider2D 顶点以匹配入口多边形区域。");
// ── 自动关联到同场景 RoomController若其 _cameraArea 为空)──────── // ── 自动关联到同场景 RoomController若其 _cameraArea 为空)────────
#if UNITY_6000_0_OR_NEWER #if UNITY_6000_0_OR_NEWER

View File

@@ -92,7 +92,7 @@ namespace BaseGames.Editor
GameObject vcamAGo = GetOrCreateChild(camera, "VCamA").gameObject; GameObject vcamAGo = GetOrCreateChild(camera, "VCamA").gameObject;
CinemachineCamera vcamA = GetOrAddComponent<CinemachineCamera>(vcamAGo); CinemachineCamera vcamA = GetOrAddComponent<CinemachineCamera>(vcamAGo);
GetOrAddComponent<CinemachineConfiner2D>(vcamAGo); GetOrAddComponent<CinemachineConfiner3D>(vcamAGo);
GetOrAddComponent<CameraAxisLockExtension>(vcamAGo); GetOrAddComponent<CameraAxisLockExtension>(vcamAGo);
GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamAGo); GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamAGo);
GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamAGo); GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamAGo);
@@ -102,7 +102,7 @@ namespace BaseGames.Editor
GameObject vcamBGo = GetOrCreateChild(camera, "VCamB").gameObject; GameObject vcamBGo = GetOrCreateChild(camera, "VCamB").gameObject;
CinemachineCamera vcamB = GetOrAddComponent<CinemachineCamera>(vcamBGo); CinemachineCamera vcamB = GetOrAddComponent<CinemachineCamera>(vcamBGo);
GetOrAddComponent<CinemachineConfiner2D>(vcamBGo); GetOrAddComponent<CinemachineConfiner3D>(vcamBGo);
GetOrAddComponent<CameraAxisLockExtension>(vcamBGo); GetOrAddComponent<CameraAxisLockExtension>(vcamBGo);
GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamBGo); GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamBGo);
GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamBGo); GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamBGo);
@@ -221,15 +221,11 @@ namespace BaseGames.Editor
GameObject cameraAreaGo = GetOrCreateChild(cameraGroup, "CameraArea").gameObject; GameObject cameraAreaGo = GetOrCreateChild(cameraGroup, "CameraArea").gameObject;
CameraArea cameraArea = GetOrAddComponent<CameraArea>(cameraAreaGo); CameraArea cameraArea = GetOrAddComponent<CameraArea>(cameraAreaGo);
// AreaBoundary — 提供 CinemachineConfiner2D 所需的限位多边形 // AreaBoundary — 提供 CinemachineConfiner3D 所需的限位体积
Transform boundaryT = GetOrCreateChild(cameraAreaGo.transform, "AreaBoundary"); Transform boundaryT = GetOrCreateChild(cameraAreaGo.transform, "AreaBoundary");
PolygonCollider2D boundaryCollider = GetOrAddComponent<PolygonCollider2D>(boundaryT.gameObject); BoxCollider boundaryCollider = GetOrAddComponent<BoxCollider>(boundaryT.gameObject);
boundaryCollider.pathCount = 1; boundaryCollider.center = new Vector3(0f, 0f, -10f); // Z 占位符,实际深度由 SyncConfiner 按 LensConfig 计算
boundaryCollider.SetPath(0, new Vector2[] boundaryCollider.size = new Vector3(24f, 12f, 1f); // 默认房间尺寸占位符
{
new Vector2(-12f, -6f), new Vector2(-12f, 6f),
new Vector2( 12f, 6f), new Vector2( 12f, -6f),
});
AssignReference(cameraArea, "_confinerCollider", boundaryCollider); AssignReference(cameraArea, "_confinerCollider", boundaryCollider);
@@ -282,7 +278,7 @@ namespace BaseGames.Editor
// ── Report ───────────────────────────────────────────────────── // ── Report ─────────────────────────────────────────────────────
report.Add("在 RoomController._roomId 填写唯一房间 ID如 \"Room_Forest_01\")。"); report.Add("在 RoomController._roomId 填写唯一房间 ID如 \"Room_Forest_01\")。");
report.Add("调整 AreaBoundary PolygonCollider2D 顶点以匹配实际房间大小。"); report.Add("绑定 LensConfig SO 后单击 Inspector 中「从可视区域更新限位区域」计算正确的 BoxCollider。");
report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形,然后在 NavSurface Inspector 中点击 Bake。"); report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形,然后在 NavSurface Inspector 中点击 Bake。");
report.Add("[Transitions] 子节点下使用 BaseGames/Scene/Place/Room Transition 添加过渡点。"); report.Add("[Transitions] 子节点下使用 BaseGames/Scene/Place/Room Transition 添加过渡点。");

View File

@@ -76,3 +76,79 @@
border-bottom-width: 2px; border-bottom-width: 2px;
border-bottom-color: rgb(100, 160, 255); border-bottom-color: rgb(100, 160, 255);
} }
/* ── CharacterWizardWindow 专用类 ───────────────────────── */
/* 向导标签页按钮(复用 tab-bar 容器) */
.tab-btn {
flex-grow: 1;
padding: 6px 0;
border-radius: 0;
border-width: 0;
border-bottom-width: 2px;
border-bottom-color: rgba(0,0,0,0);
background-color: rgba(0,0,0,0);
color: rgb(170, 170, 170);
-unity-font-style: bold;
font-size: 13px;
}
.tab-btn:hover {
background-color: rgba(255,255,255,0.06);
}
.tab-btn--active {
color: rgb(255, 255, 255);
border-bottom-width: 2px;
border-bottom-color: rgb(90, 160, 255);
}
/* SO 工厂按钮(绿调) */
.wizard-factory-btn {
background-color: rgba(40, 100, 55, 0.75);
color: rgb(200, 255, 210);
border-radius: 4px;
padding: 3px 10px;
margin-right: 4px;
margin-bottom: 4px;
border-width: 1px;
border-color: rgba(80, 180, 100, 0.60);
}
.wizard-factory-btn:hover {
background-color: rgba(55, 130, 70, 0.90);
}
/* 场景放置按钮(蓝调) */
.wizard-scene-btn {
background-color: rgba(30, 60, 120, 0.80);
color: rgb(190, 220, 255);
border-radius: 4px;
padding: 3px 10px;
margin-right: 4px;
margin-bottom: 4px;
border-width: 1px;
border-color: rgba(80, 130, 220, 0.55);
}
.wizard-scene-btn:hover {
background-color: rgba(40, 80, 155, 0.95);
}
/* 跳转按钮(中性灰调) */
.wizard-jump-btn {
background-color: rgba(55, 55, 60, 0.80);
color: rgb(210, 210, 220);
border-radius: 4px;
padding: 3px 10px;
margin-right: 4px;
margin-bottom: 4px;
border-width: 1px;
border-color: rgba(120, 120, 130, 0.45);
}
.wizard-jump-btn:hover {
background-color: rgba(75, 75, 85, 0.95);
}
/* 小怪类型选择按钮 */
.type-btn--active {
border-bottom-width: 2px;
border-bottom-color: rgb(255, 200, 50);
color: rgb(255, 220, 100);
}

View File

@@ -18,6 +18,14 @@ namespace BaseGames.Input
private float _attackBuffer; private float _attackBuffer;
private float _dashBuffer; private float _dashBuffer;
#if UNITY_EDITOR
// ── 运行时调试Inspector 中可见)───────────────────────────────
[Header("\u2500\u2500 \u8fd0\u884c\u65f6\u8c03\u8bd5 \u2500\u2500")]
[SerializeField] private float _dbg_JumpBuffer;
[SerializeField] private float _dbg_AttackBuffer;
[SerializeField] private float _dbg_DashBuffer;
#endif
// ── Named handlers to allow proper unsubscription ───────────────────── // ── Named handlers to allow proper unsubscription ─────────────────────
private void HandleJumpStarted() => _jumpBuffer = _jumpBufferDuration; private void HandleJumpStarted() => _jumpBuffer = _jumpBufferDuration;
private void HandleAttackStarted() => _attackBuffer = _attackBufferDuration; private void HandleAttackStarted() => _attackBuffer = _attackBufferDuration;
@@ -51,6 +59,12 @@ namespace BaseGames.Input
_jumpBuffer = Mathf.Max(0f, _jumpBuffer - dt); _jumpBuffer = Mathf.Max(0f, _jumpBuffer - dt);
_attackBuffer = Mathf.Max(0f, _attackBuffer - dt); _attackBuffer = Mathf.Max(0f, _attackBuffer - dt);
_dashBuffer = Mathf.Max(0f, _dashBuffer - dt); _dashBuffer = Mathf.Max(0f, _dashBuffer - dt);
#if UNITY_EDITOR
_dbg_JumpBuffer = _jumpBuffer;
_dbg_AttackBuffer = _attackBuffer;
_dbg_DashBuffer = _dashBuffer;
#endif
} }
/// <summary>消耗跳跃缓冲(读取并清空)。</summary> /// <summary>消耗跳跃缓冲(读取并清空)。</summary>

View File

@@ -17,8 +17,6 @@ namespace BaseGames.Input
public event Action JumpStartedEvent; public event Action JumpStartedEvent;
public event Action JumpCancelledEvent; public event Action JumpCancelledEvent;
public event Action AttackEvent; public event Action AttackEvent;
public event Action DownAttackEvent;
public event Action UpAttackEvent;
public event Action ParryEvent; public event Action ParryEvent;
public event Action DashEvent; public event Action DashEvent;
public event Action UseSpringEvent; public event Action UseSpringEvent;
@@ -167,8 +165,6 @@ namespace BaseGames.Input
BindStarted(_gameplay, "Jump", () => JumpStartedEvent?.Invoke()); BindStarted(_gameplay, "Jump", () => JumpStartedEvent?.Invoke());
BindCanceled(_gameplay, "Jump", () => JumpCancelledEvent?.Invoke()); BindCanceled(_gameplay, "Jump", () => JumpCancelledEvent?.Invoke());
BindStarted(_gameplay, "Attack", () => AttackEvent?.Invoke()); BindStarted(_gameplay, "Attack", () => AttackEvent?.Invoke());
BindStarted(_gameplay, "DownAttack", () => DownAttackEvent?.Invoke());
BindStarted(_gameplay, "UpAttack", () => UpAttackEvent?.Invoke());
BindStarted(_gameplay, "Parry", () => ParryEvent?.Invoke()); BindStarted(_gameplay, "Parry", () => ParryEvent?.Invoke());
BindStarted(_gameplay, "Dash", () => DashEvent?.Invoke()); BindStarted(_gameplay, "Dash", () => DashEvent?.Invoke());
BindStarted(_gameplay, "UseSpring", () => UseSpringEvent?.Invoke()); BindStarted(_gameplay, "UseSpring", () => UseSpringEvent?.Invoke());

View File

@@ -15,8 +15,7 @@ namespace BaseGames.Player
// ── 移动能力 ────────────────────────────────────────────────────── // ── 移动能力 ──────────────────────────────────────────────────────
WallCling = 1u << 0, // 贴墙悬挂 WallCling = 1u << 0, // 贴墙悬挂
WallJump = 1u << 1, // 墙跳 WallJump = 1u << 1, // 墙跳
Dash = 1u << 2, // 地面冲刺 Dash = 1u << 2, // 冲刺(地面与空中统一)
AirDash = 1u << 3, // 空中冲刺(二段冲刺)
DoubleJump = 1u << 4, // 二段跳 DoubleJump = 1u << 4, // 二段跳
SuperJump = 1u << 5, // 超级跳(聚气跳) SuperJump = 1u << 5, // 超级跳(聚气跳)
Swim = 1u << 6, // 游泳(液体中自由移动) Swim = 1u << 6, // 游泳(液体中自由移动)
@@ -44,12 +43,12 @@ namespace BaseGames.Player
/// <summary> /// <summary>
/// 无敌冲刺强化(解锁后冲刺前段获得无敌窗口)。 /// 无敌冲刺强化(解锁后冲刺前段获得无敌窗口)。
/// 仅持有 Dash 时:冲刺无无敌帧。 /// 仅持有 Dash 时:冲刺无无敌帧。
/// 解锁 InvincibleDash 后:冲刺期间完全无敌(地面 DashState + 空中 AerialDashState /// 解锁 InvincibleDash 后:冲刺期间完全无敌。
/// </summary> /// </summary>
InvincibleDash = 1u << 18, InvincibleDash = 1u << 18,
// ── 组合掩码 ───────────────────────────────────────────────────────── // ── 组合掩码 ─────────────────────────────────────────────────────────
AllMovement = WallCling | WallJump | Dash | AirDash | DoubleJump | SuperJump | Swim | Dive | InvincibleDash, AllMovement = WallCling | WallJump | Dash | DoubleJump | SuperJump | Swim | Dive | InvincibleDash,
AllSpells = Spell1 | Spell2 | Spell3, AllSpells = Spell1 | Spell2 | Spell3,
AllSpirit = SpiritForm | SpiritDash, AllSpirit = SpiritForm | SpiritDash,
} }

View File

@@ -0,0 +1,62 @@
using System.Collections;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.Player
{
/// <summary>
/// 监听 EVT_CheckpointRespawn将玩家瞬移至最近检查点坐标。
/// 通过淡出 → 移位 → 淡入实现无视觉跳变的传送。
///
/// 挂载在玩家根节点(与 Rigidbody2D 同一 GameObject
/// </summary>
public class CheckpointRespawnHandler : MonoBehaviour
{
[Header("事件 - 监听")]
[Tooltip("EVT_CheckpointRespawn — 由 LethalTrap 在玩家存活且场景有检查点时触发")]
[SerializeField] private VoidEventChannelSO _onCheckpointRespawn;
[Header("事件 - 触发")]
[Tooltip("EVT_FadeOutRequest — 传送前淡出屏幕")]
[SerializeField] private VoidEventChannelSO _onFadeOutRequest;
[Tooltip("EVT_FadeInRequest — 传送后淡入屏幕")]
[SerializeField] private VoidEventChannelSO _onFadeInRequest;
[Header("配置")]
[Tooltip("淡出结束到执行位移之间的等待时长(秒,不受 TimeScale 影响)")]
[SerializeField] private float _fadeHalfDuration = 0.2f;
private readonly CompositeDisposable _subs = new();
private void OnEnable() => _onCheckpointRespawn?.Subscribe(OnCheckpointRespawn).AddTo(_subs);
private void OnDisable() => _subs.Clear();
private void OnCheckpointRespawn() => StartCoroutine(RespawnCoroutine());
private IEnumerator RespawnCoroutine()
{
_onFadeOutRequest?.Raise();
yield return new WaitForSecondsRealtime(_fadeHalfDuration);
var svc = ServiceLocator.GetOrDefault<ICheckpointService>();
if (svc != null && svc.HasCheckpoint)
{
// 清零速度后移位,防止物理残留动量导致滑步
var rb = GetComponent<Rigidbody2D>();
if (rb != null)
{
rb.velocity = Vector2.zero;
rb.position = svc.CheckpointPosition;
}
else
{
transform.position = svc.CheckpointPosition;
}
}
_onFadeInRequest?.Raise();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7ca41f67644b6b843ba7ef65e78b13e5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,6 +1,7 @@
using System; using System;
using UnityEngine; using UnityEngine;
using BaseGames.Core.Events; using BaseGames.Core.Events;
using BaseGames.Input;
namespace BaseGames.Player namespace BaseGames.Player
{ {
@@ -16,6 +17,7 @@ namespace BaseGames.Player
{ {
[Header("配置")] [Header("配置")]
[SerializeField] private FormConfigSO _config; [SerializeField] private FormConfigSO _config;
[SerializeField] private InputReaderSO _input;
[Header("事件频道")] [Header("事件频道")]
[SerializeField] private IntEventChannelSO _onFormChanged; // 广播当前形态索引UI/Save [SerializeField] private IntEventChannelSO _onFormChanged; // 广播当前形态索引UI/Save
@@ -33,6 +35,22 @@ namespace BaseGames.Player
Debug.Assert(_config != null, "[FormController] _config 未赋值,请在 Inspector 中指定 FormConfigSO。", this); Debug.Assert(_config != null, "[FormController] _config 未赋值,请在 Inspector 中指定 FormConfigSO。", this);
} }
private void OnEnable()
{
if (_input == null) return;
_input.SwitchSkyFormEvent += OnSwitchSky;
_input.SwitchEarthFormEvent += OnSwitchEarth;
_input.SwitchDeathFormEvent += OnSwitchDeath;
}
private void OnDisable()
{
if (_input == null) return;
_input.SwitchSkyFormEvent -= OnSwitchSky;
_input.SwitchEarthFormEvent -= OnSwitchEarth;
_input.SwitchDeathFormEvent -= OnSwitchDeath;
}
private void Start() private void Start()
{ {
if (_config.forms != null && _config.forms.Length > 0) if (_config.forms != null && _config.forms.Length > 0)
@@ -67,5 +85,10 @@ namespace BaseGames.Player
if (form != null) if (form != null)
SwitchForm(form.formType); SwitchForm(form.formType);
} }
// ── 内部输入处理 ────────────────────────────────────────────────────────
private void OnSwitchSky() => SwitchForm(FormType.TianHun);
private void OnSwitchEarth() => SwitchForm(FormType.DiHun);
private void OnSwitchDeath() => SwitchForm(FormType.MingHun);
} }
} }

View File

@@ -9,11 +9,20 @@ namespace BaseGames.Player
public AnimationClip Idle; public AnimationClip Idle;
public AnimationClip Run; public AnimationClip Run;
public AnimationClip Jump; public AnimationClip Jump;
[Tooltip("空中跳跃(二段跳)动画。留空则复用 Jump 动画。")]
public AnimationClip AirJump;
public AnimationClip Fall; public AnimationClip Fall;
[Tooltip("普通冲刺动画(无无敌帧,过程中受伤会被打断)。")]
public AnimationClip Dash; public AnimationClip Dash;
[Tooltip("无敌冲刺动画(解锁 InvincibleDash 能力后使用)。留空则复用 Dash 动画。")]
public AnimationClip DashInvincible;
[Header("墙")] [Header("墙")]
public AnimationClip WallSlide; public AnimationClip WallSlide;
[Tooltip("背墙跳动画(无输入或反向输入时触发,远离墙壁斜上方弹出)。留空则复用 Jump 动画。")]
public AnimationClip WallJumpAway;
[Tooltip("对墙跳动画(朝向墙壁输入时触发,沿墙壁方向斜上方弹出)。留空则复用 Jump 动画。")]
public AnimationClip WallJumpToward;
[Header("受伤 / 死亡")] [Header("受伤 / 死亡")]
public AnimationClip Hurt; public AnimationClip Hurt;
@@ -24,28 +33,6 @@ namespace BaseGames.Player
[Header("弹簧")] [Header("弹簧")]
public AnimationClip UseSpring; public AnimationClip UseSpring;
[Header("地面攻击(连招序列)")]
public AnimationClip[] GroundAttacks;
/// <summary>每段连击的 HitBox 开启/关闭时间点(归一化 0-1与 GroundAttacks 索引对应。</summary>
[System.Serializable]
public struct AttackTimings
{
[Tooltip("HitBox 开启时间点(归一化 0-1")]
[UnityEngine.Range(0f, 1f)] public float HitBoxEnter;
[Tooltip("HitBox 关闭时间点(归一化 0-1")]
[UnityEngine.Range(0f, 1f)] public float HitBoxExit;
}
[Header("地面攻击 HitBox 激活窗口(归一化时间 0-1")]
[Tooltip("每段连击 HitBox 开启/关闭时间点,与 GroundAttacks 索引对应")]
public AttackTimings[] GroundAttackTimings = { new AttackTimings { HitBoxEnter = 0.3f, HitBoxExit = 0.6f } };
[Header("空中攻击")]
public AnimationClip AirAttack;
public AnimationClip UpAttack;
public AnimationClip DownAttack; // 戳击 (Pogo)
[Header("弹反")] [Header("弹反")]
public AnimationClip ParryStart; public AnimationClip ParryStart;
public AnimationClip ParrySuccess; public AnimationClip ParrySuccess;
@@ -53,12 +40,5 @@ namespace BaseGames.Player
[Header("游泳")] [Header("游泳")]
public AnimationClip SwimIdle; public AnimationClip SwimIdle;
public AnimationClip SwimMove; public AnimationClip SwimMove;
/// <summary>按连招步骤取地面攻击动画,越界自动取最后一个。</summary>
public AnimationClip GetAttackClip(int step)
{
if (GroundAttacks == null || GroundAttacks.Length == 0) return null;
return step < GroundAttacks.Length ? GroundAttacks[step] : GroundAttacks[^1];
}
} }
} }

View File

@@ -12,6 +12,7 @@ namespace BaseGames.Player
[SerializeField] private WeaponManager _weaponManager; [SerializeField] private WeaponManager _weaponManager;
private PlayerStats _stats; private PlayerStats _stats;
private PlayerMovement _movement;
private WeaponHitBoxInstance _currentHitBoxInstance; private WeaponHitBoxInstance _currentHitBoxInstance;
/// <summary>下劈 HitBox 命中确认事件(供 DownAttackState 订阅 pogo 弹跳逻辑)。</summary> /// <summary>下劈 HitBox 命中确认事件(供 DownAttackState 订阅 pogo 弹跳逻辑)。</summary>
@@ -20,6 +21,7 @@ namespace BaseGames.Player
private void Awake() private void Awake()
{ {
_stats = GetComponentInParent<PlayerStats>(); _stats = GetComponentInParent<PlayerStats>();
_movement = GetComponentInParent<PlayerMovement>();
} }
private void OnEnable() private void OnEnable()
@@ -52,30 +54,17 @@ namespace BaseGames.Player
private void HandleDownHitConfirmed(DamageInfo info) => OnDownHitConfirmed?.Invoke(info); private void HandleDownHitConfirmed(DamageInfo info) => OnDownHitConfirmed?.Invoke(info);
// ── 连击段伤害来源切换 ────────────────────────────────────────────────
/// <summary>
/// 根据当前连招段切换 HitBox 的 DamageSource由 AttackState 在每段开始时调用)。
/// </summary>
public void SetComboSegmentSource(int comboIndex)
{
WeaponSO w = _weaponManager?.ActiveWeapon;
if (w == null) return;
DamageSourceSO src = comboIndex switch
{
0 => w.attack1Source,
1 => w.attack2Source,
2 => w.attack3Source,
_ => w.attack1Source,
};
_weaponManager.ActiveHitBoxInstance?.SetDamageSource(AttackDirection.Ground, src);
}
// ── HitBox 激活(由 State / AnimationEvent 调用)───────────────────── // ── HitBox 激活(由 State / AnimationEvent 调用)─────────────────────
public void EnableWeaponHitBox(AttackDirection dir) /// <summary>
/// 激活 HitBox。
/// hitBoxId 非空时按 Id 精确激活 Prefab 中对应子节点;空 = 方向默认。
/// source 为 null 时回退到 WeaponSO.GetSourceByDir(dir)(方向第 0 段)。
/// </summary>
public void EnableWeaponHitBox(AttackDirection dir,
string hitBoxId = "", DamageSourceSO source = null)
{ {
var source = _weaponManager?.ActiveWeapon?.GetSourceByDir(dir); source ??= _weaponManager?.ActiveWeapon?.GetSourceByDir(dir);
_weaponManager?.ActiveHitBoxInstance?.Activate(dir, source, transform); _weaponManager?.ActiveHitBoxInstance?.Activate(dir, source, transform, hitBoxId);
} }
public void DisableWeaponHitBox(AttackDirection dir) public void DisableWeaponHitBox(AttackDirection dir)
@@ -89,6 +78,12 @@ namespace BaseGames.Player
{ {
int gain = _weaponManager?.ActiveWeapon?.soulPowerGain ?? 10; int gain = _weaponManager?.ActiveWeapon?.soulPowerGain ?? 10;
_stats?.AddSoulPower(gain); _stats?.AddSoulPower(gain);
// 攻击命中反嵈:向攻击反方向施加微小后退冲量,增强打击感
if (_movement?.Rb != null && info.KnockbackDirection.x != 0f)
_movement.Rb.AddForce(
new UnityEngine.Vector2(UnityEngine.Mathf.Sign(-info.KnockbackDirection.x) * 2f, 0f),
UnityEngine.ForceMode2D.Impulse);
} }
} }
} }

View File

@@ -39,6 +39,20 @@ namespace BaseGames.Player
private SurfaceType _currentSurface = SurfaceType.Ground; private SurfaceType _currentSurface = SurfaceType.Ground;
private readonly Collider2D[] _groundBuffer = new Collider2D[4]; private readonly Collider2D[] _groundBuffer = new Collider2D[4];
#if UNITY_EDITOR
// ── 运行时调试Inspector 中可见)───────────────────────────────
[Header("\u2500\u2500 \u8fd0\u884c\u65f6\u8c03\u8bd5 \u2500\u2500")]
[SerializeField] private string _dbg_Position;
[SerializeField] private float _dbg_VelocityX;
[SerializeField] private float _dbg_VelocityY;
[SerializeField] private bool _dbg_IsGrounded;
[SerializeField] private bool _dbg_HasCoyoteTime;
[SerializeField] private bool _dbg_IsWallLeft;
[SerializeField] private bool _dbg_IsWallRight;
[SerializeField] private bool _dbg_CancelWindowOpen;
[SerializeField] private int _dbg_FacingDirection;
#endif
public bool IsGrounded => _isGrounded; public bool IsGrounded => _isGrounded;
public bool HasCoyoteTime => _coyoteTimer > 0f; public bool HasCoyoteTime => _coyoteTimer > 0f;
public bool IsWallLeft => _isWallLeft; public bool IsWallLeft => _isWallLeft;
@@ -54,9 +68,12 @@ namespace BaseGames.Player
private void Awake() private void Awake()
{ {
Debug.Assert(_config != null, "[PlayerMovement] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this); Debug.Assert(_config != null, "[PlayerMovement] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this);
Debug.Assert(_groundCheck != null, "[PlayerMovement] GroundCheck 子节点未赋值,地面检测将无法工作,请在 Inspector 中指定 GroundCheck Transform。", this);
_rb = GetComponent<Rigidbody2D>(); _rb = GetComponent<Rigidbody2D>();
// 关闭位置插值:若开启插值,渲染位置会在速度清零后仍追赶 1~2 渲染帧,产生视觉滑行 // 开启位置插值:在物理帧50Hz与渲染帧60Hz+)之间平滑视觉位置,消除跳帧抖动
_rb.interpolation = RigidbodyInterpolation2D.None; // SpritePixelSnapperLateUpdate +1000在插值结果基础上吸附到像素网格
// 与 CameraPixelSnapper 同格对齐,消除亚像素模糊;停止时 ≤2 帧像素追赶不可感知。
_rb.interpolation = RigidbodyInterpolation2D.Interpolate;
if (_spriteRenderer == null) if (_spriteRenderer == null)
_spriteRenderer = GetComponentInChildren<SpriteRenderer>(); _spriteRenderer = GetComponentInChildren<SpriteRenderer>();
} }
@@ -77,6 +94,18 @@ namespace BaseGames.Player
_coyoteTimer = _config.CoyoteTime; _coyoteTimer = _config.CoyoteTime;
else else
_coyoteTimer = Mathf.Max(0f, _coyoteTimer - Time.fixedDeltaTime); _coyoteTimer = Mathf.Max(0f, _coyoteTimer - Time.fixedDeltaTime);
#if UNITY_EDITOR
_dbg_VelocityX = _rb.velocity.x;
_dbg_VelocityY = _rb.velocity.y;
_dbg_IsGrounded = _isGrounded;
_dbg_HasCoyoteTime = _coyoteTimer > 0f;
_dbg_IsWallLeft = _isWallLeft;
_dbg_IsWallRight = _isWallRight;
_dbg_CancelWindowOpen = _cancelWindowOpen;
_dbg_FacingDirection = _facingDirection;
_dbg_Position = $"({transform.position.x:F1}, {transform.position.y:F1})";
#endif
} }
// ── 移动 ────────────────────────────────────────────────────────────── // ── 移动 ──────────────────────────────────────────────────────────────
@@ -169,8 +198,8 @@ namespace BaseGames.Player
} }
/// <summary> /// <summary>
/// 壁滑:将垂直速度限制为 -WallSlideSpeed向下缓慢滑动 /// 壁滑:将垂直速度限制为 -WallSlideSpeed受限抓墙时向下缓慢滑动)。
/// WallSlideState.OnStateFixedUpdate 每帧调用。 /// WallSlideState.OnStateFixedUpdate 在受限模式下每帧调用。
/// </summary> /// </summary>
public void ApplyWallSlide() public void ApplyWallSlide()
{ {
@@ -180,29 +209,42 @@ namespace BaseGames.Player
} }
/// <summary> /// <summary>
/// 墙跳:对墙方向施加相反水平力 + 向上力 /// 墙跳Jump Away远离墙壁斜上方弹出
/// wallDir = +1 (右墙) 或 -1 (左墙)跳跃方向与之相反。 /// wallDir = +1 (右墙) 或 -1 (左墙)水平方向与之相反。
/// </summary> /// </summary>
public void WallJump(int wallDir) public void WallJumpAway(int wallDir)
{ {
float forceX = -wallDir * _config.WallJumpForceX; _rb.velocity = new Vector2(-wallDir * _config.WallJumpAwayForceX, _config.WallJumpAwayForceY);
float forceY = _config.WallJumpForceY;
_rb.velocity = new Vector2(forceX, forceY);
_coyoteTimer = 0f; _coyoteTimer = 0f;
} }
/// <summary>
/// 对墙跳Jump Toward沿墙壁方向偏向正上方弹出水平分量较小。
/// wallDir = +1 (右墙) 或 -1 (左墙),水平方向与之相同。
/// </summary>
public void WallJumpToward(int wallDir)
{
_rb.velocity = new Vector2(wallDir * _config.WallJumpTowardForceX, _config.WallJumpTowardForceY);
_coyoteTimer = 0f;
}
/// <summary>将垂直速度归零(抓墙悬挂时每帧调用,防止下滑)。</summary>
public void ZeroVerticalVelocity()
{
if (_rb.velocity.y < 0f)
_rb.velocity = new Vector2(_rb.velocity.x, 0f);
}
/// <summary>单向平台穿透(输入下行 + 跳跃键时触发)。</summary> /// <summary>单向平台穿透(输入下行 + 跳跃键时触发)。</summary>
public void DropThroughPlatform() { } public void DropThroughPlatform() { }
// ── Physics 检测 ────────────────────────────────────────────────────── // ── Physics 检测 ──────────────────────────────────────────────────────
private void CheckGrounded() private void CheckGrounded()
{ {
bool wasGrounded = _isGrounded; if (_groundCheck == null) return;
Vector2 origin = _groundCheck != null
? (Vector2)_groundCheck.position
: (Vector2)transform.position + Vector2.down * 0.5f;
_isGrounded = Physics2D.OverlapBoxNonAlloc(origin, _groundCheckSize, 0f, _groundBuffer, _groundLayer) > 0; bool wasGrounded = _isGrounded;
_isGrounded = Physics2D.OverlapBoxNonAlloc(_groundCheck.position, _groundCheckSize, 0f, _groundBuffer, _groundLayer) > 0;
if (_isGrounded && !wasGrounded) if (_isGrounded && !wasGrounded)
_coyoteTimer = _config.CoyoteTime; _coyoteTimer = _config.CoyoteTime;
@@ -234,15 +276,24 @@ namespace BaseGames.Player
Vector3 arrowEnd = center + new Vector3(_facingDirection * 0.55f, 0f, 0f); Vector3 arrowEnd = center + new Vector3(_facingDirection * 0.55f, 0f, 0f);
DrawArrow2D(center, arrowEnd, new Color(1f, 0.85f, 0.1f, 0.95f)); DrawArrow2D(center, arrowEnd, new Color(1f, 0.85f, 0.1f, 0.95f));
// ── 3. 站立检测框(落地亮绿 / 未落地淡绿)────────────────────── // ── 3. 站立检测框(落地亮绿 / 未落地淡绿;未赋值时显示红色警告框)──
Vector2 gOrigin = _groundCheck != null if (_groundCheck == null)
? (Vector2)_groundCheck.position {
: (Vector2)transform.position + Vector2.down * 0.5f; Gizmos.color = new Color(1f, 0.1f, 0.1f, 0.9f);
Gizmos.DrawWireSphere(transform.position, 0.25f);
#if UNITY_EDITOR
UnityEditor.Handles.color = new Color(1f, 0.1f, 0.1f, 1f);
UnityEditor.Handles.Label(transform.position + Vector3.up * 0.6f, "GroundCheck 未赋值!");
#endif
}
else
{
bool grounded = Application.isPlaying && _isGrounded; bool grounded = Application.isPlaying && _isGrounded;
Gizmos.color = grounded Gizmos.color = grounded
? new Color(0.1f, 1f, 0.3f, 0.9f) ? new Color(0.1f, 1f, 0.3f, 0.9f)
: new Color(0.4f, 0.85f, 0.4f, 0.35f); : new Color(0.4f, 0.85f, 0.4f, 0.35f);
BaseGames.Combat.HitBox.DrawWireRect2D(gOrigin, _groundCheckSize); BaseGames.Combat.HitBox.DrawWireRect2D(_groundCheck.position, _groundCheckSize);
}
// ── 4. 靠墙检测射线(碰墙橙色 / 未碰淡黄,带端点圆点)───────── // ── 4. 靠墙检测射线(碰墙橙色 / 未碰淡黄,带端点圆点)─────────
if (_config == null) return; if (_config == null) return;

View File

@@ -23,12 +23,21 @@ namespace BaseGames.Player
public float FallGravityMult = 3.5f; public float FallGravityMult = 3.5f;
[Tooltip("最大下落速度(终端速度)。推荐 22。")] [Tooltip("最大下落速度(终端速度)。推荐 22。")]
public float MaxFallSpeed = 22f; public float MaxFallSpeed = 22f;
[Tooltip("松开跳跃键时速度保留比例(变高跳)。推荐 0.45越小跳跃越低。")] [Tooltip("松开跳跃键时速度保留比例(变高跳)。推荐 0.35越小跳跃越低。")]
[Range(0f, 1f)] [Range(0f, 1f)]
public float JumpCutMultiplier = 0.45f; public float JumpCutMultiplier = 0.35f;
[Header("二段跳")] [Header("跳跃 — 顶点悬停")]
[Tooltip("二段跳初速度。设为与 JumpForce 相同可获得等高二段跳。")] [Tooltip("顶点悬停触发阈值(单位/秒)。当 |垂直速度| 低于此值时,重力缩减为 ApexGravityMultiplier 倍,\n产生\"滞空感\"。推荐 3。调高 → 悬停段更长;调低 → 悬停段更短乃至消失。")]
public float ApexThreshold = 3f;
[Tooltip("顶点区间内重力缩减比例(乘以 DefaultGravityScale。推荐 0.3。\n0 = 完全无重力悬停1 = 无悬停效果(等同于关闭此功能)。")]
[Range(0f, 1f)]
public float ApexGravityMultiplier = 0.3f;
[Header("空中跳跃N 段跳)")]
[Tooltip("腾空期间最多可追加的跳跃次数。1 = 二段跳2 = 三段跳,以此类推。\n需同时在 PlayerStats 中解锁 DoubleJump 能力,否则此值无效。")]
public int MaxAirJumps = 1;
[Tooltip("空中追加跳跃的初速度。所有段数共用同一数值;设为与 JumpForce 相同可获得等高多段跳。")]
public float DoubleJumpForce = 19f; public float DoubleJumpForce = 19f;
[Header("冲刺")] [Header("冲刺")]
@@ -38,8 +47,6 @@ namespace BaseGames.Player
public float DashDuration = 0.35f; public float DashDuration = 0.35f;
[Tooltip("冲刺冷却时长(秒)。推荐 0.6s,落地后才可再次冲刺。")] [Tooltip("冲刺冷却时长(秒)。推荐 0.6s,落地后才可再次冲刺。")]
public float DashCooldown = 0.6f; public float DashCooldown = 0.6f;
[Tooltip("每次腾空可使用的最大空中冲刺次数。通常设为 1单次空中冲刺。")]
public int MaxAerialDashes = 1;
[Header("冲刺无敌帧(窗口 < 冲刺时长,且有独立 CD")] [Header("冲刺无敌帧(窗口 < 冲刺时长,且有独立 CD")]
[Tooltip("冲刺无敌窗口时长(秒)。仅为冲刺前段;窗口结束后即使仍在冲刺中也可受伤被打断(推荐 0.20s)。")] [Tooltip("冲刺无敌窗口时长(秒)。仅为冲刺前段;窗口结束后即使仍在冲刺中也可受伤被打断(推荐 0.20s)。")]
@@ -47,17 +54,28 @@ namespace BaseGames.Player
[Tooltip("无敌的独立冷却。CD 内再次冲刺不会获得无敌帧,防止连冲变相持续无敌(推荐 0.9s)。")] [Tooltip("无敌的独立冷却。CD 内再次冲刺不会获得无敌帧,防止连冲变相持续无敌(推荐 0.9s)。")]
public float DashInvincibilityCooldown = 0.9f; public float DashInvincibilityCooldown = 0.9f;
[Header("墙 / 壁滑")] [Header("墙 / 壁滑")]
[Tooltip("受限抓墙时(高于 wallGrabY的下滑速度单位/秒)。推荐 2。")]
public float WallSlideSpeed = 2f; public float WallSlideSpeed = 2f;
public float WallJumpForceX = 12f;
public float WallJumpForceY = 16f;
public float WallRayLength = 0.55f; public float WallRayLength = 0.55f;
public float WallRayOffsetY = 0.2f; public float WallRayOffsetY = 0.2f;
public float WallGrabMaxHeightGain = 0.5f; [Tooltip("抓墙高度容差:当前 Y 不超过 wallGrabY + 此值时视为未抬升,防止浮点抖动误判。")]
public float WallGrabReleaseDelay = 0.08f; public float WallGrabHeightTolerance = 0.05f;
public float WallJumpBackForceX = 14f;
public float WallJumpAwayForceX = 10f; [Header("蹬墙跳 — 背墙跳(Jump Away,远离墙壁斜上方)")]
[Tooltip("背墙跳水平速度(远离墙壁方向)。推荐 14。")]
public float WallJumpAwayForceX = 14f;
[Tooltip("背墙跳垂直速度。推荐 18。")]
public float WallJumpAwayForceY = 18f; public float WallJumpAwayForceY = 18f;
[Header("蹬墙跳 — 对墙跳Jump Toward沿墙壁斜上方")]
[Tooltip("对墙跳水平速度(朝向墙壁方向,较小)。推荐 6。")]
public float WallJumpTowardForceX = 6f;
[Tooltip("对墙跳垂直速度(偏向正上方)。推荐 18。")]
public float WallJumpTowardForceY = 18f;
[Header("蹬墙跳 — 公共")]
[Tooltip("蹬墙跳后水平输入锁定时长(秒)。防止玩家立即向原墙壁方向输入取消起跳。推荐 0.15。")]
public float WallJumpInputLockDuration = 0.15f; public float WallJumpInputLockDuration = 0.15f;
[Header("重力")] [Header("重力")]

View File

@@ -46,6 +46,19 @@ namespace BaseGames.Player
private bool _isGodMode; private bool _isGodMode;
private readonly CompositeDisposable _subs = new(); private readonly CompositeDisposable _subs = new();
#if UNITY_EDITOR
// ── 运行时调试Inspector 中可见)───────────────────────────────
[Header("\u2500\u2500 \u8fd0\u884c\u65f6\u8c03\u8bd5 \u2500\u2500")]
[SerializeField] private string _dbg_HP;
[SerializeField] private string _dbg_Soul;
[SerializeField] private string _dbg_Spirit;
[SerializeField] private string _dbg_Spring;
[SerializeField] private bool _dbg_IsInvincible;
[SerializeField] private float _dbg_InvincibleTimer;
[SerializeField] private bool _dbg_GodMode;
[SerializeField] private string _dbg_Abilities;
#endif
// ── 护符属性修改器 ───────────────────────────────────────────────────────── // ── 护符属性修改器 ─────────────────────────────────────────────────────────
private readonly Dictionary<StatType, float> _flatModifiers = new(); private readonly Dictionary<StatType, float> _flatModifiers = new();
private readonly Dictionary<StatType, float> _percentModifiers = new(); private readonly Dictionary<StatType, float> _percentModifiers = new();
@@ -68,6 +81,7 @@ namespace BaseGames.Player
MaxSpringCharges = _config.MaxSpringCharges; MaxSpringCharges = _config.MaxSpringCharges;
CurrentSpringCharges = MaxSpringCharges; CurrentSpringCharges = MaxSpringCharges;
CurrentLingZhu = _config.InitialLingZhu; CurrentLingZhu = _config.InitialLingZhu;
_unlockedAbilities = _config.InitialAbilities;
} }
private void OnEnable() => _onDifficultyChanged?.Subscribe(HandleDifficultyChanged).AddTo(_subs); private void OnEnable() => _onDifficultyChanged?.Subscribe(HandleDifficultyChanged).AddTo(_subs);
@@ -101,6 +115,17 @@ namespace BaseGames.Player
AddSpiritPower(_config.SpiritRegenRate); AddSpiritPower(_config.SpiritRegenRate);
} }
} }
#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();
#endif
} }
// ── 护符修改器 API ───────────────────────────────────────────────────── // ── 护符修改器 API ─────────────────────────────────────────────────────

View File

@@ -25,5 +25,12 @@ namespace BaseGames.Player
[Header("初始货币")] [Header("初始货币")]
public int InitialLingZhu = 0; public int InitialLingZhu = 0;
[Header("初始已解锁能力")]
[Tooltip("角色出生时默认持有的能力([Flags] \n\n" +
"Dash地面与空中冲刺统一控制勾选后即可使用 DashState。\n\n" +
"DoubleJump追加次数由 PlayerMovementConfigSO.MaxAirJumps 决定。\n" +
"落地或 Pogo 命中后次数自动重置。")]
public AbilityType InitialAbilities = AbilityType.None;
} }
} }

View File

@@ -22,24 +22,74 @@ namespace BaseGames.Player
/// <summary>触碰到的墙壁方向:+1 = 右墙,-1 = 左墙0 = 无墙。</summary> /// <summary>触碰到的墙壁方向:+1 = 右墙,-1 = 左墙0 = 无墙。</summary>
public int WallDirection { get; private set; } public int WallDirection { get; private set; }
// 每侧"任意一根射线命中 OR 物理接触点命中"的结果,用于防止下落时卡在矮墙边角
private bool _anyRightContact;
private bool _anyLeftContact;
// 物理接触点缓冲区(避免每帧 GC
private Rigidbody2D _rb;
private static readonly ContactPoint2D[] _contactBuffer = new ContactPoint2D[8];
/// <summary>
/// 指定方向上是否存在墙壁接触(射线命中 OR 物理接触点命中,任一为 true
/// 用于在 FallState / JumpState 中防止角色卡在矮墙边角:<br/>
/// • 射线检测覆盖"检测点略高于墙顶、仅一根射线命中"的情况;<br/>
/// • 物理接触点覆盖"两根射线均高于墙顶,但碰撞体底角已卡在墙顶角"的极端情况。
/// </summary>
public bool HasPartialContact(int direction) =>
direction > 0 ? _anyRightContact : _anyLeftContact;
private void Awake() private void Awake()
{ {
Debug.Assert(_config != null, "[PlayerWallDetector] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this); Debug.Assert(_config != null, "[PlayerWallDetector] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this);
_rb = GetComponent<Rigidbody2D>();
} }
private void FixedUpdate() private void FixedUpdate()
{ {
bool rightWall = CheckSide(Vector2.right); bool rightWall = CheckSide(Vector2.right, out bool anyRightRay);
bool leftWall = CheckSide(Vector2.left); bool leftWall = CheckSide(Vector2.left, out bool anyLeftRay);
// 物理接触点兜底:两根射线都在墙顶以上时,仍可通过接触点检测到卡角
bool physRight = CheckPhysicalContact(1);
bool physLeft = CheckPhysicalContact(-1);
_anyRightContact = anyRightRay || physRight;
_anyLeftContact = anyLeftRay || physLeft;
IsTouchingWall = rightWall || leftWall; IsTouchingWall = rightWall || leftWall;
WallDirection = rightWall ? 1 : (leftWall ? -1 : 0); WallDirection = rightWall ? 1 : (leftWall ? -1 : 0);
} }
/// <summary> /// <summary>
/// 每侧发两根射线TopRay + BottomRay两根均命中才返回 true /// 通过物理接触点判断指定方向是否有墙壁(法线 X 分量超过 0.5 的水平接触)
/// direction = +1 检查右侧接触法线指向左normal.x &lt; -0.5
/// direction = -1 检查左侧接触法线指向右normal.x &gt; +0.5)。
/// </summary> /// </summary>
private bool CheckSide(Vector2 dir) private bool CheckPhysicalContact(int direction)
{
if (_rb == null) return false;
LayerMask mask = _wallLayer != 0 ? _wallLayer : LayerMask.GetMask("Wall", "Ground");
var filter = new ContactFilter2D();
filter.SetLayerMask(mask);
filter.useTriggers = false;
int count = _rb.GetContacts(filter, _contactBuffer);
for (int i = 0; i < count; i++)
{
float nx = _contactBuffer[i].normal.x;
// 右侧墙接触法线指向左nx < -0.5左侧墙接触法线指向右nx > +0.5
if (direction > 0 && nx < -0.5f) return true;
if (direction < 0 && nx > 0.5f) return true;
}
return false;
}
/// <summary>
/// 每侧发两根射线TopRay + BottomRay两根均命中才返回 true。
/// <paramref name="anyContact"/> 在任意一根命中时为 true用于防卡角判断
/// </summary>
private bool CheckSide(Vector2 dir, out bool anyContact)
{ {
Vector2 center = transform.position; Vector2 center = transform.position;
float len = _config.WallRayLength; float len = _config.WallRayLength;
@@ -48,6 +98,7 @@ namespace BaseGames.Player
bool top = Physics2D.Raycast(center + Vector2.up * oy, dir, len, layer); bool top = Physics2D.Raycast(center + Vector2.up * oy, dir, len, layer);
bool bot = Physics2D.Raycast(center + Vector2.down * oy, dir, len, layer); bool bot = Physics2D.Raycast(center + Vector2.down * oy, dir, len, layer);
anyContact = top || bot;
return top && bot; return top && bot;
} }

View File

@@ -1,14 +1,43 @@
using UnityEngine; using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Player namespace BaseGames.Player
{ {
/// <summary> /// <summary>
/// 治愈弹簧系统(架构 05_PlayerModule §7 /// 灵泉充能系统(架构 05_PlayerModule §7
/// PlayerStats 中已预留 CurrentSpringCharges / MaxSpringCharges / SpringKillPoints 字段。 /// 订阅 EVT_EnemyDied 事件,每次击杀调用 PlayerStats.AddKillPoints()
/// TODO: 实现弹簧充能逻辑: /// 积分达到阈值时由 PlayerStats 内部自动增加 SpringCharge 并重置积分。
/// - 击杀敌人时调用 PlayerStats.AddSpringKillPoint() 积累充能 /// 使用灵泉UseSpring由 PlayerController 处理输入、SpringState 执行动画与消耗。
/// - 按下治愈键时消耗充能槽并恢复玩家 HP
/// - 充能满格时触发特殊强化状态(视设计而定)
/// </summary> /// </summary>
public class SpringSystem : MonoBehaviour { } public class SpringSystem : MonoBehaviour
{
[SerializeField] private PlayerStats _stats;
[SerializeField] private StringEventChannelSO _onEnemyDied; // EVT_EnemyDied
private EventSubscription _sub;
private bool _subscribed;
private void OnEnable()
{
if (_onEnemyDied != null)
{
_sub = _onEnemyDied.Subscribe(OnEnemyDied);
_subscribed = true;
}
}
private void OnDisable()
{
if (_subscribed)
{
_sub.Dispose();
_subscribed = false;
}
}
private void OnEnemyDied(string _enemyId)
{
_stats?.AddKillPoints(1);
}
}
} }

View File

@@ -1,89 +0,0 @@
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 空中冲刺状态(架构 05_PlayerModule §12
/// 与地面 DashState 独立,消耗 MaxAerialDashes 次数;
/// 冲刺方向在进入时锁定为当前朝向(进入时锁定朝向,冲刺期间不可通过输入改变方向)。
/// </summary>
public class AerialDashState : PlayerStateBase
{
private float _timer;
private int _aerialDashesLeft;
private int _facingDir;
public bool HasAerialDash => _aerialDashesLeft > 0;
// ── IsInvincible 不再在状态层硬编码,与 DashState 保持一致:
// 实际无敌用 Stats.BeginInvincibility(DashInvincibilityDuration) 面题。
// PlayerController.TakeDamage 已将 Stats.IsInvincible 纳入硬直判断。
public AerialDashState(PlayerController owner) : base(owner)
{
_aerialDashesLeft = 1;
}
public override void OnStateEnter()
{
_aerialDashesLeft = Mathf.Max(0, _aerialDashesLeft - 1);
_facingDir = Owner.FacingDirection;
_timer = Cfg.DashDuration;
// 无敌帧:与地面冲刺共享同一无敌 CDDashState._invincibilityCooldownTimer
// 条件 1已解锁 InvincibleDash
// 条件 2共享无敌冷却已就绪
var dashState = Owner.GetState<DashState>();
if (Stats != null && Stats.HasAbility(AbilityType.InvincibleDash)
&& dashState != null && dashState.CanGrantInvincibility)
{
Stats.BeginInvincibility(Cfg.DashInvincibilityDuration);
dashState.ResetInvincibilityCooldown(Cfg.DashInvincibilityCooldown);
}
// 关闭重力,施加冲刺速度(方向锁定为进入时朝向,不受输入影响)
Move?.SetGravityScale(0f);
Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed);
// 播放冲刺动画(复用地面冲刺动画)
if (AnimCfg?.Dash != null) Anim?.Play(AnimCfg.Dash);
}
public override void OnStateUpdate()
{
_timer -= Time.deltaTime;
if (_timer <= 0f)
{
Move?.SetGravityScale(Cfg.DefaultGravityScale);
Owner.TransitionTo(Owner.GetState<FallState>());
return;
}
// 撞墙立即终止冲刺(碰到实体墙立即中止,避免压墙卡住)
var wd = Owner.WallDetector;
if (wd != null && wd.IsTouchingWall && wd.WallDirection == _facingDir)
{
Move?.SetGravityScale(Cfg.DefaultGravityScale);
Owner.TransitionTo(Owner.GetState<FallState>());
}
}
public override void OnStateExit()
{
Move?.SetGravityScale(Cfg.DefaultGravityScale);
}
public override void OnStateFixedUpdate()
{
// 冲刺期间保持锁定方向速度(与 DashState 一致,使用 _facingDir
if (_timer > 0f)
Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed);
}
/// <summary>着地时重置空中冲刺次数(由 PlayerController 在着地时调用)。</summary>
public void ResetAerialDashes()
{
_aerialDashesLeft = Cfg.MaxAerialDashes;
}
}
}

View File

@@ -1,34 +1,38 @@
using UnityEngine;
using BaseGames.Combat; using BaseGames.Combat;
namespace BaseGames.Player.States namespace BaseGames.Player.States
{ {
/// <summary> /// <summary>
/// 空中攻击状态(架构 05_PlayerModule §2 /// 空中攻击状态(架构 05_PlayerModule §2
/// 由 FallState / JumpState 接收攻击输入后转入; /// HitBox 时间由 ComboStepConfig.hitBoxEnter/Exit 驱动;结束后按 recoveryTime 延迟取消窗口。
/// Animancer 帧事件驱动 HitBoxAir 激活;结束后回到 FallState。
/// </summary> /// </summary>
public class AirAttackState : PlayerStateBase public class AirAttackState : PlayerStateBase
{ {
private float _recoveryEndTime;
public AirAttackState(PlayerController owner) : base(owner) { } public AirAttackState(PlayerController owner) : base(owner) { }
public override void OnStateEnter() public override void OnStateEnter()
{ {
Owner.Combat?.SetComboSegmentSource(0); var step = Owner.Weapon?.ActiveWeapon?.GetDirStep(AttackDirection.Air);
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Air); float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
var clip = Owner.Weapon?.ActiveWeapon?.airAttackClip; if (step?.clip?.Clip != null)
if (clip != null && clip.Clip != null)
{ {
var state = Anim.Play(clip); var animState = Anim.Play(step.Value.clip);
state.Events(this).OnEnd = OnClipEnd; animState.Speed *= spd;
}
else if (AnimCfg?.AirAttack != null) var events = animState.Events(this);
{ events.OnEnd = OnClipEnd;
var state = Anim.Play(AnimCfg.AirAttack);
state.Events(this).OnEnd = OnClipEnd; var s = step.Value;
events.Add(s.hitBoxEnter, () => Owner.Combat?.EnableWeaponHitBox(AttackDirection.Air, s.hitBoxId));
events.Add(s.hitBoxExit, () => Owner.Combat?.DisableAllWeaponHitBoxes());
} }
else else
{ {
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Air);
OnClipEnd(); OnClipEnd();
} }
} }
@@ -36,10 +40,24 @@ namespace BaseGames.Player.States
public override void OnStateExit() public override void OnStateExit()
{ {
Owner.Combat?.DisableAllWeaponHitBoxes(); Owner.Combat?.DisableAllWeaponHitBoxes();
Move?.SetCancelWindowOpen(false);
}
public override void OnStateUpdate()
{
if (Time.time >= _recoveryEndTime)
Move.SetCancelWindowOpen(true);
} }
private void OnClipEnd() private void OnClipEnd()
{ {
Owner.Combat?.DisableAllWeaponHitBoxes();
var step = Owner.Weapon?.ActiveWeapon?.GetDirStep(AttackDirection.Air);
float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
float recovery = (step?.recoveryTime ?? 0.05f) / spd;
_recoveryEndTime = Time.time + recovery;
Move.SetCancelWindowOpen(false);
Owner.TransitionTo(Owner.GetState<FallState>()); Owner.TransitionTo(Owner.GetState<FallState>());
} }
} }

View File

@@ -1,69 +1,193 @@
using UnityEngine;
using BaseGames.Combat; using BaseGames.Combat;
namespace BaseGames.Player.States namespace BaseGames.Player.States
{ {
/// <summary> /// <summary>
/// 地面攻击状态(3 段连击)。 /// 地面攻击状态(任意段连击)。
/// 由 PlayerController 实例化AttackEvent 触发切换 /// 攻速倍率Stats.AnimatorSpeedMultiplier缩放 clip 播放速度及 recoveryTime/comboTimeout
/// 通过 Animancer 帧事件驱动 HitBox 激活/关闭。 /// 状态机流程:
/// 播放动画 → [comboInputOpen] 接受输入 → [cancelWindowOpen] 允许跳跃/冲刺 →
/// 动画结束 → 硬直(recoveryTime) → 连击等待(comboTimeout) → 无输入则回 Idle
/// </summary> /// </summary>
public class AttackState : PlayerStateBase public class AttackState : PlayerStateBase
{ {
private int _comboIndex; private int _comboIndex;
private bool _comboInputPending; // 连击窗口内已收到攻击输入
private bool _comboWindowOpen; // 当前是否接受连击输入
// 动画结束后的两阶段计时
private bool _waitingAfterAnim; // 是否在动画结束后等待阶段
private float _recoveryEndTime; // 硬直结束时刻
private float _comboTimeoutEnd; // 连击等待结束时刻
public AttackState(PlayerController owner) : base(owner) { } public AttackState(PlayerController owner) : base(owner) { }
public override void OnStateEnter() public override void OnStateEnter()
{ {
_comboIndex = 0; _comboIndex = 0;
PlayAttackClip(); _comboInputPending = false;
_comboWindowOpen = false;
_waitingAfterAnim = false;
Input.AttackEvent += OnAttackInput; Input.AttackEvent += OnAttackInput;
PlayAttackClip();
} }
public override void OnStateExit() public override void OnStateExit()
{ {
Input.AttackEvent -= OnAttackInput; Input.AttackEvent -= OnAttackInput;
Owner.Combat?.DisableAllWeaponHitBoxes(); Owner.Combat?.DisableAllWeaponHitBoxes();
Move?.SetCancelWindowOpen(false);
}
public override void OnStateUpdate()
{
if (!_waitingAfterAnim)
{
// ── 动画播放中:只处理取消窗口(跳跃/冲刺打断)──────────────
if (!Move.CancelWindowOpen) return;
TryConsumeCancelInput();
return;
}
// ── 动画结束后等待阶段 ─────────────────────────────────────────
float now = Time.time;
// 硬直结束后开放取消窗口
if (!Move.CancelWindowOpen && now >= _recoveryEndTime)
Move.SetCancelWindowOpen(true);
// 有缓存的连击输入 → 立即推进
if (_comboInputPending)
{
AdvanceCombo();
return;
}
// 连击超时 → 返回 Idle
if (now >= _comboTimeoutEnd)
{
Owner.TransitionTo(Owner.GetState<IdleState>());
return;
}
// 恢复期结束后仍可跳跃/冲刺取消
if (Move.CancelWindowOpen)
TryConsumeCancelInput();
} }
public override void OnStateUpdate() { }
public override void OnStateFixedUpdate() { } public override void OnStateFixedUpdate() { }
// ── 内部 ────────────────────────────────────────────────────────────── // ── 内部 ──────────────────────────────────────────────────────────────
private void PlayAttackClip() private void PlayAttackClip()
{ {
// ⚠️ 字段名 GroundAttacks非 AttackChainClips _waitingAfterAnim = false;
var clip = AnimCfg.GroundAttacks[_comboIndex]; _comboWindowOpen = false;
var state = Anim.Play(clip); Move.SetCancelWindowOpen(false);
var events = state.Events(this);
var weapon = Owner.Weapon?.ActiveWeapon;
if (weapon == null)
{
UnityEngine.Debug.LogWarning("[AttackState] 未找到 ActiveWeapon请检查 WeaponManager 配置。");
Owner.TransitionTo(Owner.GetState<IdleState>());
return;
}
var step = weapon.GetGroundStep(_comboIndex);
if (step.clip == null || step.clip.Clip == null)
{
UnityEngine.Debug.LogWarning($"[AttackState] 连击段 {_comboIndex} 动画未配置,请检查 {weapon.weaponId}。");
Owner.TransitionTo(Owner.GetState<IdleState>());
return;
}
float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
var animState = Anim.Play(step.clip);
animState.Speed *= spd; // 在 ClipTransition 自身速度基础上叠加攻速
var events = animState.Events(this);
events.OnEnd = OnClipEnd; events.OnEnd = OnClipEnd;
// HitBox 由 Animancer 归一化时间事件驱动(时间点配置于 PlayerAnimationConfigSO // HitBox 时间窗口capture step by value for closure safety
var timings = AnimCfg?.GroundAttackTimings; var capturedStep = step;
float enterTime = timings != null && _comboIndex < timings.Length events.Add(capturedStep.hitBoxEnter, () =>
? timings[_comboIndex].HitBoxEnter : 0.3f; Owner.Combat?.EnableWeaponHitBox(AttackDirection.Ground,
float exitTime = timings != null && _comboIndex < timings.Length capturedStep.hitBoxId, capturedStep.damageSource));
? timings[_comboIndex].HitBoxExit : 0.6f; events.Add(capturedStep.hitBoxExit, () => Owner.Combat?.DisableAllWeaponHitBoxes());
events.Add(enterTime, () => Owner.Combat?.EnableWeaponHitBox(AttackDirection.Ground)); // 连击输入窗口
events.Add(exitTime, () => Owner.Combat?.DisableAllWeaponHitBoxes()); if (capturedStep.comboInputOpen > 0f)
events.Add(capturedStep.comboInputOpen, () => _comboWindowOpen = true);
else
_comboWindowOpen = true; // 0 = 立即开放
if (capturedStep.comboInputClose > 0f)
events.Add(capturedStep.comboInputClose, () => _comboWindowOpen = false);
// 取消窗口(跳跃/冲刺)
if (capturedStep.cancelWindowOpen > 0f)
events.Add(capturedStep.cancelWindowOpen, () => Move.SetCancelWindowOpen(true));
} }
private void OnClipEnd() private void OnClipEnd()
{ {
Owner.Combat?.DisableAllWeaponHitBoxes(); Owner.Combat?.DisableAllWeaponHitBoxes();
Owner.TransitionTo(Owner.GetState<IdleState>()); _comboWindowOpen = false;
Move.SetCancelWindowOpen(false);
// 如果已有缓存输入,直接推进(零延迟连击)
if (_comboInputPending)
{
AdvanceCombo();
return;
}
// 进入动画后等待阶段
var step = Owner.Weapon?.ActiveWeapon?.GetGroundStep(_comboIndex) ?? default;
float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
float now = Time.time;
_waitingAfterAnim = true;
_recoveryEndTime = now + step.recoveryTime / spd;
_comboTimeoutEnd = _recoveryEndTime + step.comboTimeout / spd;
}
private void AdvanceCombo()
{
int maxCombo = Owner.Weapon?.ActiveWeapon?.GroundComboCount ?? 1;
if (_comboIndex < maxCombo - 1)
{
_comboIndex++;
_comboInputPending = false;
PlayAttackClip();
}
else
{
// 已是最后一段,忽略多余输入,等待超时
_comboInputPending = false;
}
}
private void TryConsumeCancelInput()
{
if (Buffer.ConsumeJump())
{
Owner.TransitionTo(Owner.GetState<JumpState>());
return;
}
var ds = Owner.GetState<DashState>();
if (ds != null && ds.CanDash
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Buffer.ConsumeDash())
{
Owner.TransitionTo(ds);
}
} }
private void OnAttackInput() private void OnAttackInput()
{ {
// 连击段数从配置 SO 动态读取,无须修改代码即可支持任意段数连击 if (_comboWindowOpen || _waitingAfterAnim)
int maxCombo = AnimCfg?.GroundAttacks?.Length ?? 1; _comboInputPending = true;
if (_comboIndex < maxCombo - 1)
{
_comboIndex++;
PlayAttackClip();
}
} }
} }
} }

View File

@@ -19,7 +19,15 @@ namespace BaseGames.Player.States
private float _invincibilityCooldownTimer; private float _invincibilityCooldownTimer;
private int _facingDir; private int _facingDir;
/// <summary>本次离地后是否已消耗过一次空中冲刺。落地或下劈命中Pogo时重置。</summary>
private bool _airDashUsed;
public bool CanDash => _cooldownTimer <= 0f; public bool CanDash => _cooldownTimer <= 0f;
/// <summary>空中冲刺可用条件:冷却就绪 且 本次离地内尚未冲刺过。</summary>
public bool CanAirDash => _cooldownTimer <= 0f && !_airDashUsed;
/// <summary>重置空中冲刺次数(落地或 Pogo 时由 IdleState/RunState/DownAttackState 调用)。</summary>
public void ResetAirDash() => _airDashUsed = false;
/// <summary> /// <summary>
/// 无敌帧是否已冷却,即本次冲刺可以获得无敌。 /// 无敌帧是否已冷却,即本次冲刺可以获得无敌。
@@ -44,11 +52,20 @@ namespace BaseGames.Player.States
_facingDir = Owner.FacingDirection; _facingDir = Owner.FacingDirection;
_timer = Cfg.DashDuration; _timer = Cfg.DashDuration;
// 空中冲刺:记录本次离地已使用冲刺(地面冲刺不消耗,仅空中限制一次)
if (!Move.IsGrounded)
_airDashUsed = true;
// 无敌帧: // 无敌帧:
// 条件 1已解锁 InvincibleDash // 条件 1已解锁 InvincibleDash
// 条件 2无敌冷却已就绪防止 spam 冲刺连序无敌) // 条件 2无敌冷却已就绪防止 spam 冲刺连序无敌)
// 窗口时长 = DashInvincibilityDuration < DashDuration冲刺后段无保护 // 窗口时长 = DashInvincibilityDuration < DashDuration冲刺后段无保护
if (Stats != null && Stats.HasAbility(AbilityType.InvincibleDash) && CanGrantInvincibility) // 在设置冷却计时器前捕获,供后续动画选择使用
bool isInvincibleDash = Stats != null
&& Stats.HasAbility(AbilityType.InvincibleDash)
&& CanGrantInvincibility;
if (isInvincibleDash)
{ {
Stats.BeginInvincibility(Cfg.DashInvincibilityDuration); Stats.BeginInvincibility(Cfg.DashInvincibilityDuration);
_invincibilityCooldownTimer = Cfg.DashInvincibilityCooldown; _invincibilityCooldownTimer = Cfg.DashInvincibilityCooldown;
@@ -58,8 +75,11 @@ namespace BaseGames.Player.States
Move?.SetGravityScale(0f); Move?.SetGravityScale(0f);
Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed); Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed);
// 播放冲刺动画 // 播放冲刺动画:无敌冲刺使用专属 Clip留空时回退到普通冲刺 Clip
if (AnimCfg?.Dash != null) Anim?.Play(AnimCfg.Dash); var dashClip = (isInvincibleDash && AnimCfg?.DashInvincible != null)
? AnimCfg.DashInvincible
: AnimCfg?.Dash;
if (dashClip != null) Anim?.Play(dashClip);
} }
public override void OnStateUpdate() public override void OnStateUpdate()
@@ -71,10 +91,19 @@ namespace BaseGames.Player.States
return; return;
} }
// 撞墙立即终止冲刺(碰到实体墙立即中止,避免压墙卡住) // 跳跃可取消冲刺:冲刺期间按跳跃立即中断并起跳。
var wd = Owner.WallDetector; // 空中冲刺时若有剩余空中跳跃次数,消耗一次并使用二段跳力度。
if (wd != null && wd.IsTouchingWall && wd.WallDirection == _facingDir) if (Buffer.ConsumeJump())
EndDash(); {
if (!Move.IsGrounded && Owner.AirJumpsLeft > 0)
{
Owner.UseAirJump();
Owner.GetState<JumpState>()?.SetDoubleJump(true);
}
Owner.TransitionTo(Owner.GetState<JumpState>());
return;
}
// 注:碰墙时不中止冲刺,完成完整冲刺时长(物理阻止位移,但计时继续)
} }
public override void OnStateExit() public override void OnStateExit()
@@ -93,6 +122,8 @@ namespace BaseGames.Player.States
private void EndDash() private void EndDash()
{ {
// 双轴速度归零,防止冲刺结束时角色带着 DashSpeed 冲出平台边缘后继续向前飞行。
Move?.ZeroVelocity();
if (Move != null && Move.IsGrounded) if (Move != null && Move.IsGrounded)
Owner.TransitionTo(Owner.GetState<IdleState>()); Owner.TransitionTo(Owner.GetState<IdleState>());
else else

View File

@@ -23,21 +23,22 @@ namespace BaseGames.Player.States
if (Owner.Combat != null) if (Owner.Combat != null)
Owner.Combat.OnDownHitConfirmed += OnDownHitConfirmed; Owner.Combat.OnDownHitConfirmed += OnDownHitConfirmed;
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Down); var step = Owner.Weapon?.ActiveWeapon?.GetDirStep(AttackDirection.Down);
float spd = Stats?.AnimatorSpeedMultiplier ?? 1f;
var clip = Owner.Weapon?.ActiveWeapon?.downAttackClip; if (step?.clip?.Clip != null)
if (clip != null && clip.Clip != null)
{ {
var state = Anim.Play(clip); var animState = Anim.Play(step.Value.clip);
state.Events(this).OnEnd = OnClipEnd; animState.Speed *= spd;
} var events = animState.Events(this);
else if (AnimCfg?.DownAttack != null) events.OnEnd = OnClipEnd;
{ var s = step.Value;
var state = Anim.Play(AnimCfg.DownAttack); events.Add(s.hitBoxEnter, () => Owner.Combat?.EnableWeaponHitBox(AttackDirection.Down, s.hitBoxId));
state.Events(this).OnEnd = OnClipEnd; events.Add(step.Value.hitBoxExit, () => Owner.Combat?.DisableAllWeaponHitBoxes());
} }
else else
{ {
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Down);
OnClipEnd(); OnClipEnd();
} }
@@ -58,7 +59,9 @@ namespace BaseGames.Player.States
{ {
if (_hasHitEnemy) return; if (_hasHitEnemy) return;
_hasHitEnemy = true; _hasHitEnemy = true;
// Pogo 弹跳:命中敌人后向上弹起 // Pogo 弹跳:命中敌人后向上弹起,同时重置空中能力(等同落地效果)
Owner.ResetAirJumps();
Owner.GetState<DashState>()?.ResetAirDash();
Move.Jump(); Move.Jump();
} }

Some files were not shown because too many files have changed in this diff Show More