Merge branch 'master' of https://git.joywaygames.cn/basegames/zeling_v2
This commit is contained in:
8
Assets/_Game/Art/UI/Icons/InputKeys.meta
Normal file
8
Assets/_Game/Art/UI/Icons/InputKeys.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c70a04eb99184247a53f1631e082c50
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -134,6 +134,7 @@ MonoBehaviour:
|
||||
weaponTrailPrefab: {fileID: 0}
|
||||
trailColor: {r: 1, g: 1, b: 1, a: 1}
|
||||
soulPowerGain: 10
|
||||
hitWeight: 1
|
||||
references:
|
||||
version: 2
|
||||
RefIds: []
|
||||
|
||||
8
Assets/_Game/Data/Dialogue.meta
Normal file
8
Assets/_Game/Data/Dialogue.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad561a9e6beaaf04aa0aae9ea4cc7840
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
17
Assets/_Game/Data/Dialogue/DLG_New.asset
Normal file
17
Assets/_Game/Data/Dialogue/DLG_New.asset
Normal file
@@ -0,0 +1,17 @@
|
||||
%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: 037a9d55368dde649ac6c1c6a1e80dad, type: 3}
|
||||
m_Name: DLG_New
|
||||
m_EditorClassIdentifier:
|
||||
sequenceId:
|
||||
lines: []
|
||||
variants: []
|
||||
8
Assets/_Game/Data/Dialogue/DLG_New.asset.meta
Normal file
8
Assets/_Game/Data/Dialogue/DLG_New.asset.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 544a0224ccca01d45b8cd8c543b73d06
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Data/Enemies/E001.meta
Normal file
8
Assets/_Game/Data/Enemies/E001.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 16d3503453cb89640b705da44c5fbb53
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Data/Enemies/E001/Abilities.meta
Normal file
8
Assets/_Game/Data/Enemies/E001/Abilities.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ae476df26ac83f4bb5466462f840c61
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,27 @@
|
||||
%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: 9050afa76362dff469c64fbb48c9ff8d, type: 3}
|
||||
m_Name: ABL_E001_Alert
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e001_alert
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 157dc45e6b444c64ea1a80a5886a8b92
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,27 @@
|
||||
%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: 9050afa76362dff469c64fbb48c9ff8d, type: 3}
|
||||
m_Name: ABL_E001_Chase
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e001_chase
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0adeaa8a8508fbd40986dbb71cc85acd
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
25
Assets/_Game/Data/Enemies/E001/ENM_E001_AnimConfig.asset
Normal file
25
Assets/_Game/Data/Enemies/E001/ENM_E001_AnimConfig.asset
Normal file
@@ -0,0 +1,25 @@
|
||||
%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: f7dd720bca19fcc49b22106fb65f7652, type: 3}
|
||||
m_Name: ENM_E001_AnimConfig
|
||||
m_EditorClassIdentifier:
|
||||
Idle: {fileID: 0}
|
||||
Walk: {fileID: 0}
|
||||
Run: {fileID: 0}
|
||||
Turn: {fileID: 0}
|
||||
Attack: {fileID: 0}
|
||||
Hurt: {fileID: 0}
|
||||
Stagger: {fileID: 0}
|
||||
KnockUp: {fileID: 0}
|
||||
Dead: {fileID: 0}
|
||||
Alert: {fileID: 0}
|
||||
Investigate: {fileID: 0}
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 06936c5bc3358904cb269abdfa60ed14
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
41
Assets/_Game/Data/Enemies/E001/ENM_E001_Stats.asset
Normal file
41
Assets/_Game/Data/Enemies/E001/ENM_E001_Stats.asset
Normal file
@@ -0,0 +1,41 @@
|
||||
%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: ed4391dfa14c0304c8932f1ef9f8ce63, type: 3}
|
||||
m_Name: ENM_E001_Stats
|
||||
m_EditorClassIdentifier:
|
||||
MaxHP: 50
|
||||
Defense: 0
|
||||
WalkSpeed: 2
|
||||
RunSpeed: 4
|
||||
AttackDamage: 10
|
||||
AttackRange: 1.5
|
||||
AttackCooldown: 1
|
||||
DetectRange: 6
|
||||
MaxChaseDistance: 15
|
||||
LoseLinkTimeout: 2
|
||||
AlertDuration: 0.6
|
||||
InvestigateDuration: 3
|
||||
HomeRadius: 0.5
|
||||
KnockbackForce: 5
|
||||
HitStunDuration: 0.3
|
||||
HitTiers:
|
||||
heavyHitThreshold: 0
|
||||
launchThreshold: 0
|
||||
launchUpForce: 0
|
||||
launchHorzForce: 0
|
||||
knockUpDuration: 0
|
||||
EyeOffset: {x: 0, y: 0.8}
|
||||
LOSBlockingMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 1
|
||||
DetectAngleDeg: 0
|
||||
AlertBroadcastRadius: 0
|
||||
8
Assets/_Game/Data/Enemies/E001/ENM_E001_Stats.asset.meta
Normal file
8
Assets/_Game/Data/Enemies/E001/ENM_E001_Stats.asset.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 508afd17a0cf2fe47935c78097c3b093
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -12,31 +12,33 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: 81da55e0fcf99d34693cbc5a348225c3, type: 3}
|
||||
m_Name: PLY_PlayerMovementConfig
|
||||
m_EditorClassIdentifier:
|
||||
RunSpeed: 8
|
||||
RunSpeed: 6
|
||||
AirDragFactor: 1
|
||||
JumpForce: 24
|
||||
JumpForce: 17.5
|
||||
CoyoteTime: 0.12
|
||||
FallGravityMult: 2.5
|
||||
MaxFallSpeed: 28
|
||||
FallGravityMult: 2
|
||||
MaxFallSpeed: 15
|
||||
JumpCutMultiplier: 0.321
|
||||
ApexThreshold: 3
|
||||
ApexGravityMultiplier: 0.3
|
||||
MaxAirJumps: 1
|
||||
DoubleJumpForce: 19
|
||||
MaxAirJumps: 5
|
||||
DoubleJumpForce: 14
|
||||
DashSpeed: 20
|
||||
DashDuration: 0.25
|
||||
DashCooldown: 0.4
|
||||
DashInvincibilityDuration: 0.2
|
||||
DashInvincibilityCooldown: 0.9
|
||||
WallSlideSpeed: 2
|
||||
DownDashSpeed: 22
|
||||
DownDashDuration: 0.25
|
||||
WallSlideSpeed: 3
|
||||
WallHangSpeed: 1
|
||||
WallRayLength: 0.37
|
||||
WallRayOffsetY: 0.2
|
||||
WallGrabHeightTolerance: 0.05
|
||||
WallCoyoteTime: 0.12
|
||||
WallJumpAwayForceX: 10
|
||||
WallJumpAwayForceY: 18
|
||||
WallJumpAwayForceY: 14
|
||||
WallJumpTowardForceX: -6
|
||||
WallJumpTowardForceY: 18
|
||||
WallJumpTowardForceY: 14
|
||||
WallJumpInputLockDuration: 0.15
|
||||
DefaultGravityScale: 6
|
||||
DefaultGravityScale: 5
|
||||
|
||||
8
Assets/_Game/Data/UI/Icons.meta
Normal file
8
Assets/_Game/Data/UI/Icons.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9006d69429771544da69a6fb803ee6cf
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Data/UI/InputIcons.meta
Normal file
8
Assets/_Game/Data/UI/InputIcons.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6c4657c9f87cec046aef30d9c2e83bc7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
80
Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset
Normal file
80
Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset
Normal file
@@ -0,0 +1,80 @@
|
||||
%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: bf96d35cbe629854794062790e40afb7, type: 3}
|
||||
m_Name: ICN_Keyboard
|
||||
m_EditorClassIdentifier:
|
||||
_deviceType: 0
|
||||
_entries:
|
||||
- BindingPath: <Keyboard>/w
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/s
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/a
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/d
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/space
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/j
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/k
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/i
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/l
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/shift
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/e
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/1
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/2
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/3
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/q
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/z
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/x
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/f
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/escape
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/upArrow
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/downArrow
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/leftArrow
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/rightArrow
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: '*/{Submit}'
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: '*/{Cancel}'
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Mouse>/position
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Pen>/position
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Mouse>/leftButton
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Pen>/tip
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Mouse>/scroll
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Mouse>/middleButton
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Mouse>/rightButton
|
||||
Icon: {fileID: 0}
|
||||
8
Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset.meta
Normal file
8
Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7faaada188bdae2499f9607b5c13b11b
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Assets/_Game/Data/UI/InputIcons/ICN_PlayStation.asset
Normal file
16
Assets/_Game/Data/UI/InputIcons/ICN_PlayStation.asset
Normal 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: bf96d35cbe629854794062790e40afb7, type: 3}
|
||||
m_Name: ICN_PlayStation
|
||||
m_EditorClassIdentifier:
|
||||
_deviceType: 2
|
||||
_entries: []
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 441c8b987e18c07409de8d6ba9b871cc
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Assets/_Game/Data/UI/InputIcons/ICN_Switch.asset
Normal file
16
Assets/_Game/Data/UI/InputIcons/ICN_Switch.asset
Normal 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: bf96d35cbe629854794062790e40afb7, type: 3}
|
||||
m_Name: ICN_Switch
|
||||
m_EditorClassIdentifier:
|
||||
_deviceType: 3
|
||||
_entries: []
|
||||
8
Assets/_Game/Data/UI/InputIcons/ICN_Switch.asset.meta
Normal file
8
Assets/_Game/Data/UI/InputIcons/ICN_Switch.asset.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87d27ef72ec852548a127d7acb71d1a3
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Assets/_Game/Data/UI/InputIcons/ICN_Xbox.asset
Normal file
16
Assets/_Game/Data/UI/InputIcons/ICN_Xbox.asset
Normal 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: bf96d35cbe629854794062790e40afb7, type: 3}
|
||||
m_Name: ICN_Xbox
|
||||
m_EditorClassIdentifier:
|
||||
_deviceType: 1
|
||||
_entries: []
|
||||
8
Assets/_Game/Data/UI/InputIcons/ICN_Xbox.asset.meta
Normal file
8
Assets/_Game/Data/UI/InputIcons/ICN_Xbox.asset.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8acf7a7648c79274cb31cfe2285f7746
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,101 +1,5 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &467203328547477162
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 7119158475861943178}
|
||||
- component: {fileID: 7882116945389632025}
|
||||
- component: {fileID: 4639356286286040131}
|
||||
m_Layer: 14
|
||||
m_Name: HitBox_Ground_1
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &7119158475861943178
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 467203328547477162}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0.798, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 8975424752584779179}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!61 &7882116945389632025
|
||||
BoxCollider2D:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 467203328547477162}
|
||||
m_Enabled: 1
|
||||
m_Density: 1
|
||||
m_Material: {fileID: 0}
|
||||
m_IncludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_ExcludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_LayerOverridePriority: 0
|
||||
m_ForceSendLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_ForceReceiveLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_ContactCaptureLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_CallbackLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_IsTrigger: 1
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: 0, y: -0.16736698}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
oldSize: {x: 0, y: 0}
|
||||
newSize: {x: 0, y: 0}
|
||||
adaptiveTilingThreshold: 0
|
||||
drawMode: 0
|
||||
adaptiveTiling: 0
|
||||
m_AutoTiling: 0
|
||||
serializedVersion: 2
|
||||
m_Size: {x: 1, y: 0.83473396}
|
||||
m_EdgeRadius: 0
|
||||
--- !u!114 &4639356286286040131
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 467203328547477162}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_defaultSource: {fileID: 0}
|
||||
_hitCooldown: 0.1
|
||||
_id: ATK_Ground_1
|
||||
_rivalHitBoxMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 134217792
|
||||
--- !u!1 &1932889250901504761
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -123,7 +27,7 @@ Transform:
|
||||
m_GameObject: {fileID: 1932889250901504761}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: -0.562, z: 0}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
@@ -161,7 +65,7 @@ BoxCollider2D:
|
||||
m_IsTrigger: 1
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: -0.027121663, y: -0.15051937}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
@@ -172,7 +76,7 @@ BoxCollider2D:
|
||||
adaptiveTiling: 0
|
||||
m_AutoTiling: 0
|
||||
serializedVersion: 2
|
||||
m_Size: {x: 1.189852, y: 0.80103874}
|
||||
m_Size: {x: 1, y: 0.5}
|
||||
m_EdgeRadius: 0
|
||||
--- !u!114 &6478051166999031478
|
||||
MonoBehaviour:
|
||||
@@ -188,11 +92,11 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
_defaultSource: {fileID: 0}
|
||||
_hitCooldown: 0.1
|
||||
_id: ATK_Down
|
||||
_id:
|
||||
_rivalHitBoxMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 134217792
|
||||
--- !u!1 &2584603199706918030
|
||||
m_Bits: 0
|
||||
--- !u!1 &3989564331693126876
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
@@ -200,38 +104,38 @@ GameObject:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 1660186156348129284}
|
||||
- component: {fileID: 1152578598430080845}
|
||||
- component: {fileID: 3007294148525084107}
|
||||
- component: {fileID: 8294071144630811572}
|
||||
- component: {fileID: 4949779957213724475}
|
||||
- component: {fileID: 4757677899241504248}
|
||||
m_Layer: 14
|
||||
m_Name: HitBox_Ground _2
|
||||
m_Name: HitBox_Ground
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &1660186156348129284
|
||||
--- !u!4 &8294071144630811572
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2584603199706918030}
|
||||
m_GameObject: {fileID: 3989564331693126876}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0.798, y: 0, z: 0}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 8975424752584779179}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!61 &1152578598430080845
|
||||
--- !u!61 &4949779957213724475
|
||||
BoxCollider2D:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2584603199706918030}
|
||||
m_GameObject: {fileID: 3989564331693126876}
|
||||
m_Enabled: 1
|
||||
m_Density: 1
|
||||
m_Material: {fileID: 0}
|
||||
@@ -257,7 +161,7 @@ BoxCollider2D:
|
||||
m_IsTrigger: 1
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: -0.117884755, y: 0.01309824}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
@@ -268,15 +172,15 @@ BoxCollider2D:
|
||||
adaptiveTiling: 0
|
||||
m_AutoTiling: 0
|
||||
serializedVersion: 2
|
||||
m_Size: {x: 0.7642305, y: 1.1956644}
|
||||
m_Size: {x: 1, y: 0.5}
|
||||
m_EdgeRadius: 0
|
||||
--- !u!114 &3007294148525084107
|
||||
--- !u!114 &4757677899241504248
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2584603199706918030}
|
||||
m_GameObject: {fileID: 3989564331693126876}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3}
|
||||
@@ -284,106 +188,10 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
_defaultSource: {fileID: 0}
|
||||
_hitCooldown: 0.1
|
||||
_id: ATK_Ground_2
|
||||
_id:
|
||||
_rivalHitBoxMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 134217792
|
||||
--- !u!1 &4050057806632877121
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 4405470499151834857}
|
||||
- component: {fileID: 8597809172682257212}
|
||||
- component: {fileID: 1610035618021234136}
|
||||
m_Layer: 14
|
||||
m_Name: HitBox_Air_2
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &4405470499151834857
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 4050057806632877121}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0.553, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 8975424752584779179}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!61 &8597809172682257212
|
||||
BoxCollider2D:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 4050057806632877121}
|
||||
m_Enabled: 1
|
||||
m_Density: 1
|
||||
m_Material: {fileID: 0}
|
||||
m_IncludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_ExcludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_LayerOverridePriority: 0
|
||||
m_ForceSendLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_ForceReceiveLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_ContactCaptureLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_CallbackLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_IsTrigger: 1
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: 0.27943045, y: 0}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
oldSize: {x: 0, y: 0}
|
||||
newSize: {x: 0, y: 0}
|
||||
adaptiveTilingThreshold: 0
|
||||
drawMode: 0
|
||||
adaptiveTiling: 0
|
||||
m_AutoTiling: 0
|
||||
serializedVersion: 2
|
||||
m_Size: {x: 1.0588609, y: 1}
|
||||
m_EdgeRadius: 0
|
||||
--- !u!114 &1610035618021234136
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 4050057806632877121}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_defaultSource: {fileID: 0}
|
||||
_hitCooldown: 0.1
|
||||
_id: ATK_Air_2
|
||||
_rivalHitBoxMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 134217792
|
||||
--- !u!1 &4335406389674002762
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -411,7 +219,7 @@ Transform:
|
||||
m_GameObject: {fileID: 4335406389674002762}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0.918, z: 0}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
@@ -449,7 +257,7 @@ BoxCollider2D:
|
||||
m_IsTrigger: 1
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: 0.072324514, y: 0}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
@@ -460,7 +268,7 @@ BoxCollider2D:
|
||||
adaptiveTiling: 0
|
||||
m_AutoTiling: 0
|
||||
serializedVersion: 2
|
||||
m_Size: {x: 1.1599612, y: 1}
|
||||
m_Size: {x: 0.5, y: 1}
|
||||
m_EdgeRadius: 0
|
||||
--- !u!114 &1392799324577637263
|
||||
MonoBehaviour:
|
||||
@@ -476,10 +284,10 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
_defaultSource: {fileID: 0}
|
||||
_hitCooldown: 0.1
|
||||
_id: ATK_Up
|
||||
_id:
|
||||
_rivalHitBoxMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 134217792
|
||||
m_Bits: 0
|
||||
--- !u!1 &4821376343125962025
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -510,12 +318,10 @@ Transform:
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 7119158475861943178}
|
||||
- {fileID: 1660186156348129284}
|
||||
- {fileID: 8294071144630811572}
|
||||
- {fileID: 7468586589501741901}
|
||||
- {fileID: 6088225995420515986}
|
||||
- {fileID: 4362395311111627733}
|
||||
- {fileID: 4405470499151834857}
|
||||
- {fileID: 6913225169405126738}
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &3691925044832415471
|
||||
@@ -530,11 +336,11 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: ec12dacf2519f58429dd3c59da8f93b0, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_hitBoxGround: {fileID: 4639356286286040131}
|
||||
_hitBoxGround: {fileID: 4757677899241504248}
|
||||
_hitBoxUp: {fileID: 1392799324577637263}
|
||||
_hitBoxDown: {fileID: 6478051166999031478}
|
||||
_hitBoxAir: {fileID: 9014207169512774676}
|
||||
--- !u!1 &8582289489283119946
|
||||
_hitBoxAir: {fileID: 1382006829078153708}
|
||||
--- !u!1 &6434981771063321190
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
@@ -542,38 +348,38 @@ GameObject:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 4362395311111627733}
|
||||
- component: {fileID: 922051492914393482}
|
||||
- component: {fileID: 9014207169512774676}
|
||||
- component: {fileID: 6913225169405126738}
|
||||
- component: {fileID: 6843760498109474434}
|
||||
- component: {fileID: 1382006829078153708}
|
||||
m_Layer: 14
|
||||
m_Name: HitBox_Air_1
|
||||
m_Name: HitBox_Air
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &4362395311111627733
|
||||
--- !u!4 &6913225169405126738
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 8582289489283119946}
|
||||
m_GameObject: {fileID: 6434981771063321190}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0.553, y: 0, z: 0}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 8975424752584779179}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!61 &922051492914393482
|
||||
--- !u!61 &6843760498109474434
|
||||
BoxCollider2D:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 8582289489283119946}
|
||||
m_GameObject: {fileID: 6434981771063321190}
|
||||
m_Enabled: 1
|
||||
m_Density: 1
|
||||
m_Material: {fileID: 0}
|
||||
@@ -599,7 +405,7 @@ BoxCollider2D:
|
||||
m_IsTrigger: 1
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: 0.46717286, y: 0}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
@@ -610,15 +416,15 @@ BoxCollider2D:
|
||||
adaptiveTiling: 0
|
||||
m_AutoTiling: 0
|
||||
serializedVersion: 2
|
||||
m_Size: {x: 1.4343457, y: 1}
|
||||
m_Size: {x: 0.5, y: 1}
|
||||
m_EdgeRadius: 0
|
||||
--- !u!114 &9014207169512774676
|
||||
--- !u!114 &1382006829078153708
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 8582289489283119946}
|
||||
m_GameObject: {fileID: 6434981771063321190}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3}
|
||||
@@ -626,7 +432,7 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
_defaultSource: {fileID: 0}
|
||||
_hitCooldown: 0.1
|
||||
_id: ATK_Air_1
|
||||
_id:
|
||||
_rivalHitBoxMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 134217792
|
||||
m_Bits: 0
|
||||
|
||||
8
Assets/_Game/Resources.meta
Normal file
8
Assets/_Game/Resources.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5c84345088e4b444fa3691e4463195e6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Resources/Localization.meta
Normal file
8
Assets/_Game/Resources/Localization.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b95c3d78f6a24cf4b895ff9d7e2152c0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2f2d7dcc4913cd47a822348a0a382c0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"entries": [
|
||||
{ "key": "TOAST_ACHIEVEMENT_TITLE", "value": "成就解锁" },
|
||||
{ "key": "TOAST_ABILITY_TITLE", "value": "能力获得" },
|
||||
{ "key": "REBIND_WAITING_PROMPT", "value": "按下新按键…" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b467b2bd496d6a48bf14743bf6dc030
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Resources/Localization/English.meta
Normal file
8
Assets/_Game/Resources/Localization/English.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c88709988862d0449b435bba8e369238
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
7
Assets/_Game/Resources/Localization/English/UI.json
Normal file
7
Assets/_Game/Resources/Localization/English/UI.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"entries": [
|
||||
{ "key": "TOAST_ACHIEVEMENT_TITLE", "value": "Achievement Unlocked" },
|
||||
{ "key": "TOAST_ABILITY_TITLE", "value": "Ability Acquired" },
|
||||
{ "key": "REBIND_WAITING_PROMPT", "value": "Press New Key…" }
|
||||
]
|
||||
}
|
||||
7
Assets/_Game/Resources/Localization/English/UI.json.meta
Normal file
7
Assets/_Game/Resources/Localization/English/UI.json.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2c33f73665e4b149acab3559ee26bca
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Resources/Localization/Japanese.meta
Normal file
8
Assets/_Game/Resources/Localization/Japanese.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 560d5306a24338a43b2b5bc363f3be73
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
7
Assets/_Game/Resources/Localization/Japanese/UI.json
Normal file
7
Assets/_Game/Resources/Localization/Japanese/UI.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"entries": [
|
||||
{ "key": "TOAST_ACHIEVEMENT_TITLE", "value": "実績アンロック" },
|
||||
{ "key": "TOAST_ABILITY_TITLE", "value": "アビリティ獲得" },
|
||||
{ "key": "REBIND_WAITING_PROMPT", "value": "新しいキーを押してください…" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 82fc68d5bb02982459902d03c24068ab
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Resources/Localization/Korean.meta
Normal file
8
Assets/_Game/Resources/Localization/Korean.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d13f190ae2f8d744cae1f9cfbf3d2081
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
7
Assets/_Game/Resources/Localization/Korean/UI.json
Normal file
7
Assets/_Game/Resources/Localization/Korean/UI.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"entries": [
|
||||
{ "key": "TOAST_ACHIEVEMENT_TITLE", "value": "업적 잊금" },
|
||||
{ "key": "TOAST_ABILITY_TITLE", "value": "능력 획득" },
|
||||
{ "key": "REBIND_WAITING_PROMPT", "value": "새 키를 누르세요…" }
|
||||
]
|
||||
}
|
||||
7
Assets/_Game/Resources/Localization/Korean/UI.json.meta
Normal file
7
Assets/_Game/Resources/Localization/Korean/UI.json.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df65e76e977a65640a33c3b4a4321155
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -21339,6 +21339,7 @@ GameObject:
|
||||
- component: {fileID: 1354690328}
|
||||
- component: {fileID: 1354690327}
|
||||
- component: {fileID: 1354690326}
|
||||
- component: {fileID: 1354690329}
|
||||
m_Layer: 8
|
||||
m_Name: Square (1)
|
||||
m_TagString: Untagged
|
||||
@@ -21458,6 +21459,32 @@ Transform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &1354690329
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1354690325}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 87d47b3e0cb42914b8b2ae885bebf30b, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
costOverride: -1
|
||||
linkType: 1
|
||||
clearance: 2
|
||||
navTag: 0
|
||||
avgWaitTime: 0
|
||||
maxTraversableDistance: 0
|
||||
autoMap: 1
|
||||
start: {x: -2, y: 0}
|
||||
goal: {x: 2, y: 0}
|
||||
isBidirectional: 1
|
||||
visualizationType: 5
|
||||
traversalAngle: 0
|
||||
horizontalSpeed: 1
|
||||
bezierControlPoint: {x: 0, y: 3}
|
||||
--- !u!1 &1357827205
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
|
||||
@@ -39,6 +39,8 @@ namespace BaseGames.Camera
|
||||
[SerializeField] private CinemachineBrain _brain;
|
||||
|
||||
private UnityEngine.Camera _camera;
|
||||
private CinemachineCamera _cachedVCam;
|
||||
private CinemachineConfiner3D _cachedConfiner;
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
@@ -77,7 +79,8 @@ namespace BaseGames.Camera
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前活跃 VCam 的 CinemachineConfiner3D 边界盒(世界空间 AABB)。
|
||||
/// 用于在像素取整后将相机钳制回限位区域内。
|
||||
/// 缓存上次查询的 VCam 实例;仅在活跃 VCam 发生切换时重新调用 GetComponent,
|
||||
/// 避免每帧 GetComponent 开销。
|
||||
/// </summary>
|
||||
private bool TryGetActiveConfinerBounds(out Bounds bounds)
|
||||
{
|
||||
@@ -85,9 +88,14 @@ namespace BaseGames.Camera
|
||||
if (_brain == null) return false;
|
||||
var vcam = _brain.ActiveVirtualCamera as CinemachineCamera;
|
||||
if (vcam == null) return false;
|
||||
var confiner = vcam.GetComponent<CinemachineConfiner3D>();
|
||||
if (confiner == null || !confiner.IsValid) return false;
|
||||
bounds = confiner.BoundingVolume.bounds;
|
||||
// 只在活跃 VCam 切换时刷新缓存
|
||||
if (!ReferenceEquals(vcam, _cachedVCam))
|
||||
{
|
||||
_cachedVCam = vcam;
|
||||
_cachedConfiner = vcam.GetComponent<CinemachineConfiner3D>();
|
||||
}
|
||||
if (_cachedConfiner == null || !_cachedConfiner.IsValid) return false;
|
||||
bounds = _cachedConfiner.BoundingVolume.bounds;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,8 @@ namespace BaseGames.Camera
|
||||
}
|
||||
|
||||
// 触发区域进入:更新集合(同一区域去重后重新加入,保证最新优先级)
|
||||
_activeZones.RemoveAll(e => e.area == area);
|
||||
for (int i = _activeZones.Count - 1; i >= 0; i--)
|
||||
if (_activeZones[i].area == area) _activeZones.RemoveAt(i);
|
||||
_activeZones.Add((area, priority));
|
||||
|
||||
// 仅当此区域是当前最优且尚未激活时才切换
|
||||
@@ -132,7 +133,9 @@ namespace BaseGames.Camera
|
||||
if (releasedArea == null) return;
|
||||
|
||||
bool wasActive = releasedArea == _currentArea;
|
||||
int removed = _activeZones.RemoveAll(e => e.area == releasedArea);
|
||||
int removed = 0;
|
||||
for (int i = _activeZones.Count - 1; i >= 0; i--)
|
||||
if (_activeZones[i].area == releasedArea) { _activeZones.RemoveAt(i); removed++; }
|
||||
// 若区域本就不在栈中,且又不是当前激活区,则无需任何操作
|
||||
if (removed == 0 && !wasActive) return;
|
||||
|
||||
|
||||
@@ -57,9 +57,14 @@ namespace BaseGames.Camera
|
||||
// ── 静态:跨实例共享触发状态 ──────────────────────────────────────────
|
||||
// 玩家当前物理上所在的所有触发区域(按进入顺序排列)
|
||||
private static readonly List<CameraTriggerZone> s_InsideZones = new();
|
||||
// 场景内所有已启用的触发区域,供 RoomController 等查询(替代 FindObjectsOfType)
|
||||
private static readonly List<CameraTriggerZone> s_AllZones = new();
|
||||
// 当前已向 ICameraService 发出 SwitchArea 请求的触发区域
|
||||
private static CameraTriggerZone s_ActiveZone;
|
||||
|
||||
/// <summary>场景内当前所有已启用的触发区域(只读)。</summary>
|
||||
public static IReadOnlyList<CameraTriggerZone> AllZones => s_AllZones;
|
||||
|
||||
/// <summary>
|
||||
/// 在每次进入 Play Mode 前(或禁用 Domain Reload 时的跨会话)重置静态状态,
|
||||
/// 防止上一次游戏会话残留的区域引用导致触发逻辑错误。
|
||||
@@ -68,6 +73,7 @@ namespace BaseGames.Camera
|
||||
private static void ResetStaticState()
|
||||
{
|
||||
s_InsideZones.Clear();
|
||||
s_AllZones.Clear();
|
||||
s_ActiveZone = null;
|
||||
}
|
||||
|
||||
@@ -77,12 +83,22 @@ namespace BaseGames.Camera
|
||||
_collider.isTrigger = true;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
s_AllZones.Add(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (!Application.isPlaying) return;
|
||||
s_AllZones.Remove(this);
|
||||
HandlePlayerExit();
|
||||
}
|
||||
|
||||
/// <summary>判断世界坐标点是否在本触发区域多边形内(供 RoomController 等无需 GetComponent 直接查询)。</summary>
|
||||
public bool ContainsPoint(Vector2 worldPoint) => _collider != null && _collider.OverlapPoint(worldPoint);
|
||||
|
||||
/// <summary>
|
||||
/// 若玩家出生时已在触发区域内,OnTriggerEnter2D 不会触发。
|
||||
/// 延迟一帧(确保 RoomController.Start 先完成基准区域设置)后主动检测。
|
||||
|
||||
@@ -31,6 +31,8 @@ namespace BaseGames.Combat
|
||||
CanClash = 1 << 5,
|
||||
ForceBreak = 1 << 6,
|
||||
NoKnockback = 1 << 7,
|
||||
/// <summary>击飞:使敌人进入 KnockUp 状态(腾空 + 落地)。仅在伤害量 >= HitTierConfig.launchThreshold 时生效。</summary>
|
||||
Launch = 1 << 8,
|
||||
}
|
||||
|
||||
// ── 交互标签 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -142,8 +142,8 @@ namespace BaseGames.Combat
|
||||
bool isRivalHitBoxLayer = (_rivalHitBoxMask.value & (1 << otherLayer)) != 0;
|
||||
if (isRivalHitBoxLayer && CanClash)
|
||||
{
|
||||
var rivalHitBox = other.GetComponent<HitBox>();
|
||||
if (rivalHitBox != null && rivalHitBox.IsActive && rivalHitBox.CanClash)
|
||||
if (other.TryGetComponent<HitBox>(out var rivalHitBox) &&
|
||||
rivalHitBox.IsActive && rivalHitBox.CanClash)
|
||||
{
|
||||
_clashService?.ResolveClash(this, rivalHitBox);
|
||||
return; // 拼刀,中止伤害流水线
|
||||
@@ -151,8 +151,7 @@ namespace BaseGames.Combat
|
||||
}
|
||||
|
||||
// ② 命中 HurtBox
|
||||
var hurtBox = other.GetComponent<HurtBox>();
|
||||
if (hurtBox != null)
|
||||
if (other.TryGetComponent<HurtBox>(out var hurtBox))
|
||||
{
|
||||
// 用 HitBox 自身碰撞盒中心在 HurtBox 表面上的最近点作为受击位置。
|
||||
// 对大体积/长条形受击体(如地刺),此点远比 HurtBox 节点中心更准确。
|
||||
@@ -163,7 +162,8 @@ namespace BaseGames.Combat
|
||||
}
|
||||
|
||||
// ③ 命中 IBreakable(机关/障碍物)
|
||||
other.GetComponent<IBreakable>()?.TryInteract(info);
|
||||
if (other.TryGetComponent<IBreakable>(out var breakable))
|
||||
breakable.TryInteract(info);
|
||||
}
|
||||
|
||||
// ── 当前激活期已命中目标集合(防止复合子 Collider 导致同帧多次命中)────────────
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace BaseGames.Combat
|
||||
protected Rigidbody2D _rb;
|
||||
protected HitBox _hitBox;
|
||||
protected float _aliveTimer;
|
||||
// Lifetime 在 Initialize 时缓存,避免 Update 每帧访问 SO 成员并做 null check
|
||||
private float _lifetime = float.MaxValue;
|
||||
|
||||
private PooledObject _pooledObject;
|
||||
|
||||
@@ -32,6 +34,7 @@ namespace BaseGames.Combat
|
||||
public virtual void Initialize(ProjectileConfigSO config, DamageInfo damageInfo, Vector2 direction, int ownerLayer = 0)
|
||||
{
|
||||
_config = config;
|
||||
_lifetime = config.Lifetime;
|
||||
DamageInfo = damageInfo;
|
||||
Direction = direction.normalized;
|
||||
_aliveTimer = 0f;
|
||||
@@ -75,7 +78,7 @@ namespace BaseGames.Combat
|
||||
protected virtual void Update()
|
||||
{
|
||||
_aliveTimer += Time.deltaTime;
|
||||
if (_config != null && _aliveTimer >= _config.Lifetime)
|
||||
if (_aliveTimer >= _lifetime)
|
||||
ReturnToPool();
|
||||
}
|
||||
|
||||
@@ -91,6 +94,7 @@ namespace BaseGames.Combat
|
||||
protected virtual void OnDisable()
|
||||
{
|
||||
_aliveTimer = 0f;
|
||||
_lifetime = float.MaxValue; // 归还对象池后重置,防止未初始化时自毁
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ namespace BaseGames.Combat
|
||||
|
||||
public event System.Action<DamageInfo> OnHitConfirmed;
|
||||
|
||||
private Coroutine _returnCoroutine;
|
||||
// 按 duration 缓存 WaitForSeconds,同一技能复用无 GC 分配
|
||||
private WaitForSeconds _cachedWait;
|
||||
private float _cachedWaitDuration = float.NaN;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
foreach (var hb in _hitBoxes)
|
||||
@@ -35,7 +40,31 @@ namespace BaseGames.Combat
|
||||
hb?.Activate(source, attacker);
|
||||
}
|
||||
|
||||
/// <summary>duration 秒后自动销毁此 GameObject。</summary>
|
||||
/// <summary>
|
||||
/// duration 秒后归还对象池(SetActive false)。
|
||||
/// 由 SkillManager 对象池调用;替代旧版 Destroy 流程。
|
||||
/// </summary>
|
||||
public void AutoReturnAfter(float duration)
|
||||
{
|
||||
if (!Mathf.Approximately(_cachedWaitDuration, duration))
|
||||
{
|
||||
_cachedWaitDuration = duration;
|
||||
_cachedWait = new WaitForSeconds(duration);
|
||||
}
|
||||
if (_returnCoroutine != null) StopCoroutine(_returnCoroutine);
|
||||
_returnCoroutine = StartCoroutine(ReturnCoroutine());
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator ReturnCoroutine()
|
||||
{
|
||||
yield return _cachedWait;
|
||||
foreach (var hb in _hitBoxes)
|
||||
hb?.Deactivate();
|
||||
_returnCoroutine = null;
|
||||
gameObject.SetActive(false); // 触发对象池回收
|
||||
}
|
||||
|
||||
/// <summary>duration 秒后销毁(非池化路径,保留向后兼容)。</summary>
|
||||
public void AutoDestroyAfter(float duration)
|
||||
=> Destroy(gameObject, Mathf.Max(0f, duration));
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ namespace BaseGames.Combat.StatusEffects
|
||||
|
||||
public override StatusEffectType EffectType => StatusEffectType.Fire;
|
||||
public override int MaxStacks => 1;
|
||||
private static readonly StatusEffectType[] s_MutualExclusions = { StatusEffectType.Freeze };
|
||||
/// <summary>施加燃烧时移除冻结(火冰互斥)。</summary>
|
||||
public override StatusEffectType[] MutualExclusions => new[] { StatusEffectType.Freeze };
|
||||
public override StatusEffectType[] MutualExclusions => s_MutualExclusions;
|
||||
|
||||
public FireEffect()
|
||||
{
|
||||
|
||||
@@ -27,6 +27,8 @@ namespace BaseGames.Combat.StatusEffects
|
||||
// ── Shader 渲染(MaterialPropertyBlock,不修改共享材质)─────────
|
||||
private SpriteRenderer _renderer;
|
||||
private MaterialPropertyBlock _propBlock;
|
||||
// 缓存 Shader 属性 ID,避免每次调用 SetShaderParam 都做字符串哈希查找
|
||||
private readonly Dictionary<string, int> _shaderPropIds = new();
|
||||
|
||||
// ── DoT 伤害代理(由 StatusEffect.OnTick 通过 Owner 调用)──────────
|
||||
private IDamageable _damageable;
|
||||
@@ -135,8 +137,13 @@ namespace BaseGames.Combat.StatusEffects
|
||||
public void SetShaderParam(string param, float value)
|
||||
{
|
||||
if (_renderer == null) return;
|
||||
if (!_shaderPropIds.TryGetValue(param, out int propId))
|
||||
{
|
||||
propId = Shader.PropertyToID(param);
|
||||
_shaderPropIds[param] = propId;
|
||||
}
|
||||
_renderer.GetPropertyBlock(_propBlock);
|
||||
_propBlock.SetFloat(param, value);
|
||||
_propBlock.SetFloat(propId, value);
|
||||
_renderer.SetPropertyBlock(_propBlock);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ namespace BaseGames.Core.Assets
|
||||
/// <summary>Addressable key,用于 Addressables.LoadSceneAsync。</summary>
|
||||
public const string SceneMainMenu = "Scene_MainMenu";
|
||||
|
||||
/// <summary>Addressable key,第一个游戏章节场景。</summary>
|
||||
public const string SceneGameChapter1 = "Scene_Game_Chapter1";
|
||||
|
||||
// ── Player ──────────────────────────────────────────────────────
|
||||
public const string PrefabPlayer = "PLY_Player";
|
||||
|
||||
@@ -51,7 +54,10 @@ namespace BaseGames.Core.Assets
|
||||
public const string PrefabWeaponSoulStaff = "WPN_SoulStaff";
|
||||
|
||||
// ── Config ScriptableObjects ─────────────────────────────────────
|
||||
public const string DataFootstepCatalog = "Config/FootstepCatalog";
|
||||
public const string DataFootstepCatalog = "Config/FootstepCatalog";
|
||||
|
||||
/// <summary>流式加载预算配置 SO,RoomStreamingManager 与 TransitionDirector 均依赖此资产。</summary>
|
||||
public const string DataStreamingBudgetConfig = "Config/StreamingBudgetConfig";
|
||||
|
||||
/// <summary>
|
||||
/// Addressable Label 常量(用于批量加载与预热)。
|
||||
|
||||
22
Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs
Normal file
22
Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// 交互提示事件负载。
|
||||
/// 由 InteractableDetector 广播,包含触发动作名称和显示文本,
|
||||
/// UI 层(InteractPromptWidget)据此查询图标并显示提示。
|
||||
/// </summary>
|
||||
public readonly struct InteractPromptEvent
|
||||
{
|
||||
/// <summary>InputSystem Action 名称,如 "Interact"。用于查询按键图标。</summary>
|
||||
public readonly string ActionName;
|
||||
|
||||
/// <summary>交互物提供的说明文本,如 "对话"、"存档"、"传送"。</summary>
|
||||
public readonly string LabelText;
|
||||
|
||||
public InteractPromptEvent(string actionName, string labelText)
|
||||
{
|
||||
ActionName = actionName;
|
||||
LabelText = labelText;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0305b3bda1379324883e51f0fb0d5cb4
|
||||
guid: 9eccce8fdbd936b46a467d078957a387
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -0,0 +1,7 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/InteractPrompt")]
|
||||
public class InteractPromptEventChannelSO : BaseEventChannelSO<InteractPromptEvent> { }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46d9a88adb6ede743a783e306209d4e2
|
||||
guid: 5e6db212f7619344588f054af0c6330a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -0,0 +1,24 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// NPC 对话切换事件负载(强类型,替代 "{npcId}:{sequenceId}" 字符串拼接)。
|
||||
/// </summary>
|
||||
public struct NpcDialogueChangeEvent
|
||||
{
|
||||
/// <summary>NPC 的唯一 ID(对应 NpcSO.npcId)。</summary>
|
||||
public string npcId;
|
||||
|
||||
/// <summary>要切换到的对话序列 ID(对应 DialogueSequenceSO.sequenceId)。</summary>
|
||||
public string newSequenceId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NPC 对话切换事件频道。
|
||||
/// 由 ChangeNPCDialogueAction 在事件链中触发;NPC 组件订阅后根据自身 npcId 过滤处理。
|
||||
/// 资产路径建议:Assets/ScriptableObjects/Events/EVT_NpcDialogueChange.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/NpcDialogueChange")]
|
||||
public class NpcDialogueChangeEventChannelSO : BaseEventChannelSO<NpcDialogueChangeEvent> { }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 111b5e123d3c3bc4ab5114666d8d2641
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -9,7 +9,9 @@ namespace BaseGames.Core.Events
|
||||
Available = 1,
|
||||
Active = 2,
|
||||
Completed = 3,
|
||||
Failed = 4
|
||||
Failed = 4,
|
||||
/// <summary>任务已接取但被暂停(如剧情锁定),不推进目标,不触发失败判定。</summary>
|
||||
Paused = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -23,7 +25,7 @@ namespace BaseGames.Core.Events
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 任务目标进度事件负载。
|
||||
/// 任务目标进度事件负载(单目标)。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct QuestObjectiveEvent
|
||||
@@ -33,4 +35,17 @@ namespace BaseGames.Core.Events
|
||||
public int Progress;
|
||||
public int Required;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同帧内某任务多个目标同时更新时的批量事件负载。
|
||||
/// 订阅此事件可在一帧内一次性处理同任务的所有目标变更,避免 UI 多次重绘。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct QuestObjectiveBatchEvent
|
||||
{
|
||||
/// <summary>发生目标进度变更的任务 ID。</summary>
|
||||
public string QuestId;
|
||||
/// <summary>本帧内该任务所有更新过的单目标事件列表(至少 1 个)。</summary>
|
||||
public System.Collections.Generic.List<QuestObjectiveEvent> Updates;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,13 @@ namespace BaseGames.Core.Events
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/QuestObjective")]
|
||||
public class QuestObjectiveEventChannelSO : BaseEventChannelSO<QuestObjectiveEvent> { }
|
||||
|
||||
/// <summary>
|
||||
/// 批量任务目标进度事件频道。
|
||||
/// 同帧内同一任务多个目标同时更新时,聚合为一次广播,
|
||||
/// 供 UI 侧监听以避免同帧多次重绘任务追踪 HUD。
|
||||
/// 资产路径建议:Assets/ScriptableObjects/Events/EVT_QuestObjectiveBatchUpdated.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/QuestObjectiveBatch")]
|
||||
public class QuestObjectiveBatchEventChannelSO : BaseEventChannelSO<QuestObjectiveBatchEvent> { }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// 场景过渡类型,决定 <see cref="BaseGames.Core.SceneService"/> 的演出行为。
|
||||
/// 场景过渡类型,决定 <see cref="BaseGames.Core.SceneService"/> 或
|
||||
/// <see cref="BaseGames.Core.ITransitionDirector"/> 的演出行为。
|
||||
/// </summary>
|
||||
public enum TransitionType
|
||||
{
|
||||
@@ -12,5 +13,14 @@ namespace BaseGames.Core.Events
|
||||
/// <summary>跨大区域切换。完整淡出,显示加载画面。
|
||||
/// 适用于地图间传送、返回标题、大区域入口等有明显空间跳跃感的切换。</summary>
|
||||
Scene,
|
||||
|
||||
/// <summary>无缝切换。无任何遮挡,目标房间必须已预加载(Dormant 状态)。
|
||||
/// 相机跟随玩家越过边界,视觉上无任何打断感。
|
||||
/// 若目标房间尚未就绪,TransitionDirector 将等待预加载完成后再执行切换(有超时保护)。</summary>
|
||||
Seamless,
|
||||
|
||||
/// <summary>氛围淡入淡出切换。短暂淡出(≈0.25 s)+ 显示新区域名称 + 淡入。
|
||||
/// 适用于跨大区域边界、目标房间已预加载的情况,比 Room 有更强的"抵达感"。</summary>
|
||||
AtmosphericFade,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,17 @@ using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 色盲滤镜模式。运行时由后期处理(如 URP Volume)读取并切换对应的 LUT/Shader。
|
||||
/// </summary>
|
||||
public enum ColorblindMode
|
||||
{
|
||||
None = 0,
|
||||
Protanopia = 1,
|
||||
Deuteranopia = 2,
|
||||
Tritanopia = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 游戏全局设置数据(运行时值)。
|
||||
/// </summary>
|
||||
@@ -21,6 +32,16 @@ namespace BaseGames.Core
|
||||
public string Language = "zh-CN";
|
||||
|
||||
public bool ShowSpeedrunTimer = false;
|
||||
|
||||
// ── 可访问性 ────────────────────────────────────────────────────────
|
||||
[Tooltip("UI 整体缩放系数(0.8 ~ 1.5),通过 CanvasScaler 应用。")]
|
||||
public float UIScale = 1f;
|
||||
|
||||
[Tooltip("色盲滤镜模式。")]
|
||||
public ColorblindMode ColorblindMode = ColorblindMode.None;
|
||||
|
||||
[Tooltip("镜头/UI 震动开关;关闭后受击晃动、命中冲击等屏幕震动被屏蔽。")]
|
||||
public bool ScreenShakeEnabled = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -46,6 +67,11 @@ namespace BaseGames.Core
|
||||
[Header("Speedrun")]
|
||||
public bool ShowSpeedrunTimer = false;
|
||||
|
||||
[Header("Accessibility")]
|
||||
[Range(0.8f, 1.5f)] public float DefaultUIScale = 1f;
|
||||
public ColorblindMode DefaultColorblindMode = ColorblindMode.None;
|
||||
public bool DefaultScreenShakeEnabled = true;
|
||||
|
||||
/// <summary>将 SO 默认值填入 GlobalSettingsData。</summary>
|
||||
public GlobalSettingsData CreateDefault() => new GlobalSettingsData
|
||||
{
|
||||
@@ -58,6 +84,9 @@ namespace BaseGames.Core
|
||||
FullScreen = DefaultFullScreen,
|
||||
Language = DefaultLanguage,
|
||||
ShowSpeedrunTimer = ShowSpeedrunTimer,
|
||||
UIScale = DefaultUIScale,
|
||||
ColorblindMode = DefaultColorblindMode,
|
||||
ScreenShakeEnabled = DefaultScreenShakeEnabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
35
Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs
Normal file
35
Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 场景加载协调器接口。
|
||||
/// <para>
|
||||
/// 定义于 <c>BaseGames.Core</c> 以避免 <see cref="SceneService"/> 对
|
||||
/// <c>BaseGames.World.Streaming</c> 产生直接依赖。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 由流式加载系统(RoomStreamingManager)实现并在 Awake 中向
|
||||
/// <see cref="ServiceLocator"/> 注册。当注册存在时,<see cref="SceneService"/>
|
||||
/// 将符合条件的场景加载请求委托给本接口,确保房间生命周期(Dormant / Active / Cooling)
|
||||
/// 得到完整维护;否则退回到 SceneLoader 原生路径。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ISceneLoadCoordinator
|
||||
{
|
||||
/// <summary>
|
||||
/// 判断给定场景地址是否应由流式系统管理(而非 SceneLoader 直接加载)。
|
||||
/// <para>约定:以 <c>"Room_"</c> 前缀开头的地址均属于流式系统管辖范围。</para>
|
||||
/// </summary>
|
||||
bool OwnsScene(string sceneName);
|
||||
|
||||
/// <summary>
|
||||
/// 以完整流式路径加载并激活指定房间
|
||||
/// (Load → Dormant → Active,同时将前一个 Active 房间送入冷却队列)。
|
||||
/// </summary>
|
||||
/// <param name="sceneName">Addressable key(等同于 RoomId,前缀 "Room_")。</param>
|
||||
/// <param name="entryTransitionId">目标房间出生点 ID;null 表示使用默认出生点。</param>
|
||||
/// <param name="isRespawn">true = 复活流程,玩家应在最近存档点出生。</param>
|
||||
IEnumerator LoadAndActivateCoroutine(string sceneName, string entryTransitionId, bool isRespawn);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cddf13c179a032c4293e181de7e8470f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -17,6 +17,12 @@ namespace BaseGames.Core
|
||||
void SetVSync(bool enabled);
|
||||
void SetTargetFrameRate(int fps);
|
||||
void SetLanguage(string localeCode);
|
||||
|
||||
// ── 可访问性 ────────────────────────────────────────────────────────
|
||||
void SetUIScale(float scale);
|
||||
void SetColorblindMode(ColorblindMode mode);
|
||||
void SetScreenShakeEnabled(bool enabled);
|
||||
|
||||
void Save();
|
||||
}
|
||||
}
|
||||
|
||||
28
Assets/_Game/Scripts/Core/ITransitionDirector.cs
Normal file
28
Assets/_Game/Scripts/Core/ITransitionDirector.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 过渡导演接口。
|
||||
/// <para>
|
||||
/// <see cref="SceneService"/> 在处理 <see cref="SceneLoadRequest"/> 时,
|
||||
/// 若过渡类型为 <see cref="TransitionType.Seamless"/> 或 <see cref="TransitionType.AtmosphericFade"/>,
|
||||
/// 则通过 ServiceLocator 查找此接口并委托处理。
|
||||
/// 若未找到实现(非流式模式),则退回原有淡出加载流程。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ITransitionDirector
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理过渡请求。由 SceneService 在确认过渡类型后调用。
|
||||
/// 实现方负责完整的过渡流程(激活目标房间、相机切换、播放演出等)。
|
||||
/// </summary>
|
||||
void HandleTransition(SceneLoadRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// 查询目标场景是否已预加载完毕(处于 Dormant 状态),可执行无缝切换。
|
||||
/// 若返回 false,SceneService 将退回带黑屏的 Room 过渡。
|
||||
/// </summary>
|
||||
bool CanHandleSeamless(string targetSceneName);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/ITransitionDirector.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/ITransitionDirector.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ba229944271875048b97b953793bf37e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
12
Assets/_Game/Scripts/Core/IWorldStateReader.cs
Normal file
12
Assets/_Game/Scripts/Core/IWorldStateReader.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 只读世界状态查询接口(用于对话版本条件判断)。
|
||||
/// 解耦 NarrativeNPC / DialogueVersion 对 WorldStateRegistry 具体类型的直接依赖。
|
||||
/// </summary>
|
||||
public interface IWorldStateReader
|
||||
{
|
||||
/// <summary>检查指定 Flag 是否已设置。</summary>
|
||||
bool HasFlag(string key);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/IWorldStateReader.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/IWorldStateReader.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6453128890f813248a8067d3e3c919cd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
55
Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs
Normal file
55
Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记一个序列化字段必填。运行期 / Inspector 漏配时给出明确提示,
|
||||
/// 减少策划"为什么没显示"的排查成本。
|
||||
///
|
||||
/// 用法:[SerializeField, RequiredField] private GameObject _root;
|
||||
///
|
||||
/// 表现:
|
||||
/// - Inspector 中字段未赋值时显示红色 HelpBox 并加红框(见 Editor/RequiredFieldDrawer.cs)。
|
||||
/// - 调用方在 OnValidate / Awake 中可调用 RequiredFieldValidator.ValidateAll(this) 触发运行期警告。
|
||||
/// </summary>
|
||||
[System.AttributeUsage(System.AttributeTargets.Field, AllowMultiple = false, Inherited = true)]
|
||||
public class RequiredFieldAttribute : PropertyAttribute
|
||||
{
|
||||
public readonly string Hint;
|
||||
public RequiredFieldAttribute(string hint = null) { Hint = hint; }
|
||||
}
|
||||
|
||||
/// <summary>运行期辅助:在 OnValidate / Awake 中调用,扫描自身被 [RequiredField] 标注的字段。</summary>
|
||||
public static class RequiredFieldValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// 反射扫描 target 上所有 [RequiredField] 字段,未赋值时 Debug.LogWarning。
|
||||
/// 建议仅在 OnValidate / Awake 中调用(运行时调用反射有性能开销)。
|
||||
/// </summary>
|
||||
public static void ValidateAll(Object target)
|
||||
{
|
||||
if (target == null) return;
|
||||
var type = target.GetType();
|
||||
var fields = type.GetFields(System.Reflection.BindingFlags.Instance
|
||||
| System.Reflection.BindingFlags.Public
|
||||
| System.Reflection.BindingFlags.NonPublic);
|
||||
foreach (var f in fields)
|
||||
{
|
||||
var attr = (RequiredFieldAttribute)System.Attribute.GetCustomAttribute(f, typeof(RequiredFieldAttribute));
|
||||
if (attr == null) continue;
|
||||
var value = f.GetValue(target);
|
||||
if (IsNullOrMissingRef(value))
|
||||
{
|
||||
var hint = string.IsNullOrEmpty(attr.Hint) ? "" : $"({attr.Hint})";
|
||||
Debug.LogWarning($"[RequiredField] {type.Name}.{f.Name} 未赋值{hint}!", target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsNullOrMissingRef(object value)
|
||||
{
|
||||
if (value is Object uo) return uo == null; // 含 Missing Reference 的"虚假 null"也算
|
||||
return value == null;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/RequiredFieldAttribute.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee796ace5d7a52643a001ca1968b6e28
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -99,9 +99,11 @@ namespace BaseGames.Core.Save
|
||||
[Serializable]
|
||||
public class MapSaveData
|
||||
{
|
||||
public HashSet<string> ExploredRooms = new(); // 踏入过的房间 ID
|
||||
public HashSet<string> MappedRooms = new(); // 完整地图信息(购买/存档点揭示)
|
||||
public List<MapPin> Pins = new(); // 玩家自定义地图标记
|
||||
public HashSet<string> ExploredRooms = new(); // 踏入过的房间 ID
|
||||
public HashSet<string> MappedRooms = new(); // 完整地图信息(购买/存档点揭示)
|
||||
public List<MapPin> Pins = new(); // 玩家自定义地图标记
|
||||
public string LastRegionId; // 上次进入的区域 ID(避免读档后首次进房误触发 EVT_RegionChanged)
|
||||
public HashSet<string> UnlockedTeleportRoomIds = new(); // 已解锁传送站的房间 ID(由 TeleportService 维护)
|
||||
}
|
||||
|
||||
/// <summary>玩家在地图上放置的自定义标记(架构 15_MapShopModule §1.5)。</summary>
|
||||
@@ -134,10 +136,20 @@ namespace BaseGames.Core.Save
|
||||
[Serializable]
|
||||
public class QuestState
|
||||
{
|
||||
public string Status; // "NotStarted"|"Active"|"Completed"|"Failed"
|
||||
public int ObjectiveIndex;
|
||||
public List<int> ProgressCounts = new();
|
||||
public string GiverNpcId;
|
||||
/// <summary>此 QuestState 数据格式版本号,固定为 3。</summary>
|
||||
public int DataVersion = 3;
|
||||
/// <summary>任务运行时状态字符串。有效值:Unavailable|Available|Active|Paused|Completed|Failed。
|
||||
/// OnLoad 通过 Enum.TryParse 解析;无效值将触发开发模式警告并降级为 Unavailable。</summary>
|
||||
public string Status;
|
||||
/// <summary>objectiveId → progressCount,重排目标顺序后存档不会错位。</summary>
|
||||
public Dictionary<string, int> ObjectiveProgress = new();
|
||||
/// <summary>各目标是否已判定完成(objectiveId → completed)。
|
||||
/// 防止 GetRequiredCount 在版本迭代中变更后,重新计算结果与存档实际状态不一致。</summary>
|
||||
public Dictionary<string, bool> ObjectiveCompleted = new();
|
||||
/// <summary>任务接取时间(Unix 秒时间戳,UTC)。0 = 未记录,跳过。</summary>
|
||||
public long StartedAtUtc;
|
||||
/// <summary>任务完成时间(Unix 秒时间戳,UTC)。0 = 未完成或未记录。</summary>
|
||||
public long CompletedAtUtc;
|
||||
}
|
||||
|
||||
// ─── Achievements ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,6 +24,16 @@ namespace BaseGames.Core
|
||||
/// <item><b>Room</b>:极短淡出(<see cref="_roomFadeDuration"/>),无加载画面。</item>
|
||||
/// <item><b>Scene</b>:完整淡出(<see cref="_sceneFadeDuration"/>),显示加载画面。</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// 完整加载时序(保证场景物体在显示前完成状态初始化):
|
||||
/// <list type="number">
|
||||
/// <item>淡出(黑幕遮挡)</item>
|
||||
/// <item>Addressable 异步加载场景(场景物体 Awake / OnEnable 同步执行)</item>
|
||||
/// <item>触发 <see cref="_onSceneWorldStateRestored"/>:通知场景物体从 WorldStateRegistry 恢复初始状态</item>
|
||||
/// <item>等待一帧(保证所有场景物体 Start() 和事件处理器执行完毕)</item>
|
||||
/// <item>淡入(显示已完成初始化的场景)</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-900)]
|
||||
public class SceneService : MonoBehaviour, ISceneService
|
||||
@@ -35,6 +45,12 @@ namespace BaseGames.Core
|
||||
[SerializeField] private VoidEventChannelSO _onFadeInRequest;
|
||||
[SerializeField] private VoidEventChannelSO _onFadeOutRequest;
|
||||
|
||||
[Tooltip("场景加载完成、WorldStateRegistry 已就绪后触发。\n" +
|
||||
"场景内物体应订阅此事件,从 WorldStateRegistry 读取存档状态并应用(替代在 Start() 中读取)。\n" +
|
||||
"触发后会等待一帧,确保所有处理器执行完毕,再执行淡入显示场景。\n" +
|
||||
"对应 SO:EVT_SceneWorldStateRestored")]
|
||||
[SerializeField] private VoidEventChannelSO _onSceneWorldStateRestored;
|
||||
|
||||
[SerializeField] private SceneLoader _sceneLoader;
|
||||
|
||||
[Header("淡出时长")]
|
||||
@@ -54,7 +70,29 @@ namespace BaseGames.Core
|
||||
private void OnDisable() => _subscriptions.Clear();
|
||||
|
||||
private void HandleSceneLoadRequest(SceneLoadRequest request)
|
||||
=> StartCoroutine(LoadSceneCoroutine(request));
|
||||
{
|
||||
// Seamless / AtmosphericFade 由 ITransitionDirector 处理(需要预加载支持)
|
||||
if (request.TransitionType == TransitionType.Seamless ||
|
||||
request.TransitionType == TransitionType.AtmosphericFade)
|
||||
{
|
||||
var director = ServiceLocator.GetOrDefault<ITransitionDirector>();
|
||||
if (director != null)
|
||||
{
|
||||
// TransitionDirector 内部处理"立即切换"与"等待预加载后切换"两条路径
|
||||
director.HandleTransition(request);
|
||||
return;
|
||||
}
|
||||
|
||||
// 未注册 ITransitionDirector(非流式模式):降级为 Room 过渡
|
||||
Debug.LogWarning($"[SceneService] 未找到 ITransitionDirector,{request.TransitionType} 降级为 Room 过渡。");
|
||||
var degraded = request;
|
||||
degraded.TransitionType = TransitionType.Room;
|
||||
StartCoroutine(LoadSceneCoroutine(degraded));
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(LoadSceneCoroutine(request));
|
||||
}
|
||||
|
||||
public IEnumerator LoadSceneCoroutine(SceneLoadRequest request)
|
||||
{
|
||||
@@ -66,10 +104,31 @@ namespace BaseGames.Core
|
||||
if (fadeDuration > 0f)
|
||||
yield return new WaitForSeconds(fadeDuration);
|
||||
|
||||
if (_sceneLoader != null)
|
||||
// 流式模式优先:若流式协调器已注册且声明对本场景的所有权,委托给流式系统加载。
|
||||
// 这确保复活 / 快速传送等使用 Room/Scene 类型的路径也能正确触发冷却和卸载生命周期,
|
||||
// 避免前一房间在 RoomStreamingManager 中永远停留在 Active 状态。
|
||||
var coordinator = ServiceLocator.GetOrDefault<ISceneLoadCoordinator>();
|
||||
if (coordinator != null && coordinator.OwnsScene(request.SceneName))
|
||||
{
|
||||
yield return StartCoroutine(coordinator.LoadAndActivateCoroutine(
|
||||
request.SceneName, request.EntryTransitionId, request.IsRespawn));
|
||||
}
|
||||
else if (_sceneLoader != null)
|
||||
{
|
||||
yield return StartCoroutine(_sceneLoader.LoadSceneCoroutine(request));
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[SceneService] _sceneLoader 未赋值,场景加载中断。请在 Inspector 中绑定 SceneLoader 组件。");
|
||||
}
|
||||
|
||||
// 通知:WorldStateRegistry 已就绪,场景物体应在此帧内从中读取存档状态并应用初始状态。
|
||||
// 订阅者(WorldStateRegistrySaver、各场景 StateApplier 等)会在同一帧同步执行。
|
||||
_onSceneWorldStateRestored?.Raise();
|
||||
|
||||
// 等待一帧:确保所有场景物体的 Start() 和事件处理器都已执行完毕,
|
||||
// 场景物体处于正确的初始状态后再揭开黑幕,避免出现一帧状态错误的画面闪烁。
|
||||
yield return null;
|
||||
|
||||
_onFadeInRequest?.Raise();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -5,6 +6,8 @@ namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局设置管理器。从 GlobalSettingsSO 读取默认值,从文件加载用户覆盖。
|
||||
/// 任何 Setter 调用 Save() 后会触发 <see cref="SettingsChanged"/> 静态事件,
|
||||
/// 供 UIScaleApplier / ColorblindApplier / CameraShake 等订阅。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-800)]
|
||||
public class SettingsManager : MonoBehaviour, ISettingsService
|
||||
@@ -18,6 +21,9 @@ namespace BaseGames.Core
|
||||
|
||||
public GlobalSettingsData Current => _current;
|
||||
|
||||
/// <summary>设置变更后触发(用于 UIScaleApplier、色盲滤镜、Camera Shake 等订阅)。</summary>
|
||||
public static event Action<GlobalSettingsData> SettingsChanged;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
ServiceLocator.Register<ISettingsService>(this);
|
||||
@@ -28,6 +34,7 @@ namespace BaseGames.Core
|
||||
{
|
||||
_current = Load() ?? _defaultSettings?.CreateDefault() ?? new GlobalSettingsData();
|
||||
Apply(_current);
|
||||
SettingsChanged?.Invoke(_current);
|
||||
}
|
||||
|
||||
private GlobalSettingsData Load()
|
||||
@@ -55,6 +62,7 @@ namespace BaseGames.Core
|
||||
{
|
||||
Debug.LogWarning($"[SettingsManager] 设置保存失败: {e.Message}");
|
||||
}
|
||||
SettingsChanged?.Invoke(_current);
|
||||
}
|
||||
|
||||
private void Apply(GlobalSettingsData data)
|
||||
@@ -66,13 +74,13 @@ namespace BaseGames.Core
|
||||
Screen.fullScreenMode = FullScreenMode.FullScreenWindow;
|
||||
}
|
||||
|
||||
// ── 音量设置(调用 AudioManager)────────────────────
|
||||
// ── 音量设置(调用 AudioManager)─────────────────────────────────────
|
||||
public void SetMasterVolume(float v) { _current.MasterVolume = v; Save(); }
|
||||
public void SetBGMVolume(float v) { _current.BGMVolume = v; Save(); }
|
||||
public void SetSFXVolume(float v) { _current.SFXVolume = v; Save(); }
|
||||
public void SetAmbientVolume(float v) { _current.AmbientVolume = v; Save(); }
|
||||
|
||||
// ── 画面设置 ──────────────────────────────────────────────────────
|
||||
// ── 画面设置 ──────────────────────────────────────────────────────────
|
||||
public void SetResolution(int w, int h, FullScreenMode mode)
|
||||
{
|
||||
Screen.SetResolution(w, h, mode);
|
||||
@@ -98,6 +106,23 @@ namespace BaseGames.Core
|
||||
Save();
|
||||
}
|
||||
|
||||
// ── 可访问性 ──────────────────────────────────────────────────────────
|
||||
public void SetUIScale(float scale)
|
||||
{
|
||||
_current.UIScale = Mathf.Clamp(scale, 0.5f, 2f);
|
||||
Save();
|
||||
}
|
||||
public void SetColorblindMode(ColorblindMode mode)
|
||||
{
|
||||
_current.ColorblindMode = mode;
|
||||
Save();
|
||||
}
|
||||
public void SetScreenShakeEnabled(bool enabled)
|
||||
{
|
||||
_current.ScreenShakeEnabled = enabled;
|
||||
Save();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<ISettingsService>(this);
|
||||
|
||||
74
Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs
Normal file
74
Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using UnityEngine;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个世界状态标志的定义条目。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public class FlagEntry
|
||||
{
|
||||
[Tooltip("标志唯一 ID,与 SetFlagAction / FlagSetCondition 中填写的字符串完全一致。")]
|
||||
public string id;
|
||||
|
||||
[Tooltip("描述该标志代表的游戏事件或状态(仅供编辑器参考,运行时不使用)。")]
|
||||
public string description;
|
||||
|
||||
[Tooltip("下拉菜单中的分组路径,使用 '/' 分隔层级,例如 '剧情/Boss'。留空则不分组。")]
|
||||
public string group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 世界状态标志注册表 —— 统一维护项目中所有合法的世界标志 ID、描述和分组。
|
||||
/// 在 Inspector 中为 [WorldStateFlag] 属性提供下拉补全,减少手输错误。
|
||||
/// 创建方式:Project 右键 → Create / BaseGames / Core / WorldFlagRegistry
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Core/WorldFlagRegistry", fileName = "WorldFlagRegistry")]
|
||||
public class WorldFlagRegistrySO : ScriptableObject
|
||||
{
|
||||
[Tooltip("所有合法的世界状态标志定义。由策划/程序在此集中维护。")]
|
||||
public FlagEntry[] flags;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private static WorldFlagRegistrySO _editorInstance;
|
||||
private static double _editorInstanceExpiry;
|
||||
|
||||
/// <summary>
|
||||
/// 编辑器下 30 秒缓存的单例引用(扫描 AssetDatabase 得到)。
|
||||
/// 运行时不可用,请在 UNITY_EDITOR 条件块中调用。
|
||||
/// </summary>
|
||||
public static WorldFlagRegistrySO EditorInstance
|
||||
{
|
||||
get
|
||||
{
|
||||
double now = EditorApplication.timeSinceStartup;
|
||||
if (_editorInstance != null && now < _editorInstanceExpiry)
|
||||
return _editorInstance;
|
||||
|
||||
var guids = AssetDatabase.FindAssets("t:WorldFlagRegistrySO");
|
||||
if (guids.Length == 0)
|
||||
{
|
||||
_editorInstance = null;
|
||||
_editorInstanceExpiry = now + 30.0;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (guids.Length > 1)
|
||||
Debug.LogWarning($"[WorldFlagRegistrySO] 发现 {guids.Length} 个 WorldFlagRegistry.asset," +
|
||||
"将使用第一个。建议项目中只保留一个。");
|
||||
|
||||
string path = AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||
_editorInstance = AssetDatabase.LoadAssetAtPath<WorldFlagRegistrySO>(path);
|
||||
_editorInstanceExpiry = now + 30.0;
|
||||
return _editorInstance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>强制下次访问 EditorInstance 时重新扫描 AssetDatabase。</summary>
|
||||
public static void InvalidateEditorCache() => _editorInstance = null;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 748a2d0bc197fa0448028ab28d0309f2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
22
Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs
Normal file
22
Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记一个 string 或 string[] 字段为世界状态标志 Key。
|
||||
/// 在 Inspector 中会显示已知标志下拉菜单,支持直接输入新标志。
|
||||
/// 定义于 BaseGames.Core,可被 Dialogue / Quest / EventChain 等模块无耦合使用。
|
||||
/// </summary>
|
||||
public sealed class WorldStateFlagAttribute : PropertyAttribute { }
|
||||
|
||||
/// <summary>
|
||||
/// 世界状态标志的逻辑组合模式,供 Dialogue 条件变体和 Quest 分支条件共用。
|
||||
/// </summary>
|
||||
public enum WorldStateFlagLogic
|
||||
{
|
||||
/// <summary>全部 requiredFlags 均满足时条件成立(默认,向后兼容)。</summary>
|
||||
And,
|
||||
/// <summary>任意一个 requiredFlag 满足即可使条件成立。</summary>
|
||||
Or,
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e10b4c60cc9052f4e83381ceb09424a3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
89
Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs
Normal file
89
Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// (架构 14_NarrativeModule §3)。
|
||||
/// 将 NPC 的显示名、头像、对话气泡颜色集中在一处管理。
|
||||
/// DialogueLine.actor 引用此 SO,修改头像/名称只需改一个资产,
|
||||
/// 无需批量编辑所有对话行。
|
||||
///
|
||||
/// 资产路径:Assets/_Game/Data/Dialogue/Actors/Actor_{actorId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueActor")]
|
||||
public class DialogueActorSO : ScriptableObject, ILocalizableAsset
|
||||
{
|
||||
[Header("标识")]
|
||||
[Tooltip("唯一 ID,如 \"NPC_Elder\",供 DialogueLine 引用")]
|
||||
public string actorId;
|
||||
|
||||
[Header("显示")]
|
||||
[Tooltip("本地化 Key,格式如 \"NPC_Elder_Name\"")]
|
||||
public string nameKey;
|
||||
|
||||
[Tooltip("对话 UI 中显示的头像")]
|
||||
public Sprite portrait;
|
||||
|
||||
[Tooltip("对话气泡/说话人名称的主题颜色(可选)")]
|
||||
public Color accentColor = Color.white;
|
||||
|
||||
[Tooltip("是否为玩家角色(影响对话 UI 排版方向)")]
|
||||
public bool isPlayer;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// actorId → 资产路径,5 秒 TTL,跨所有 DialogueActorSO.OnValidate 共用。
|
||||
// 与 QuestSO / DialogueSequenceSO 保持一致的 O(1) 重复检测策略。
|
||||
private static System.Collections.Generic.Dictionary<string, string> s_actorIdToPath;
|
||||
private static double s_actorIdsCacheTime = -10.0;
|
||||
|
||||
private static System.Collections.Generic.Dictionary<string, string> GetActorIdCache()
|
||||
{
|
||||
double now = UnityEditor.EditorApplication.timeSinceStartup;
|
||||
if (s_actorIdToPath != null && now - s_actorIdsCacheTime < 5.0)
|
||||
return s_actorIdToPath;
|
||||
|
||||
s_actorIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:DialogueActorSO");
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||||
var actor = UnityEditor.AssetDatabase.LoadAssetAtPath<DialogueActorSO>(path);
|
||||
if (actor != null && !string.IsNullOrEmpty(actor.actorId) && !s_actorIdToPath.ContainsKey(actor.actorId))
|
||||
s_actorIdToPath[actor.actorId] = path;
|
||||
}
|
||||
s_actorIdsCacheTime = now;
|
||||
return s_actorIdToPath;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actorId))
|
||||
{
|
||||
Debug.LogWarning($"[DialogueActorSO] '{name}' 缺少 actorId,保存前请填写。", this);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测重复 actorId:缓存路径 vs 自身路径比对(O(1)),5 秒内无需重扫。
|
||||
var cache = GetActorIdCache();
|
||||
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
|
||||
if (!string.IsNullOrEmpty(myPath) &&
|
||||
cache.TryGetValue(actorId, out var existingPath) &&
|
||||
existingPath != myPath)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[DialogueActorSO] actorId '{actorId}' 与 " +
|
||||
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||||
s_actorIdsCacheTime = -10.0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
public IEnumerable<LocalizationKeyRef> GetLocalizationKeys()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(nameKey))
|
||||
yield return new LocalizationKeyRef(nameKey, "Dialogue", nameof(nameKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4ef7fae4d515f649bc8e5f51ad9510b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Input;
|
||||
@@ -14,26 +15,132 @@ namespace BaseGames.Dialogue
|
||||
/// </summary>
|
||||
public class DialogueManager : MonoBehaviour, IDialogueService
|
||||
{
|
||||
[Header("依赖")]
|
||||
[Tooltip("对话 UI 组件。负责打字机效果、头像/说话人渲染、显示隐藏。")]
|
||||
[SerializeField] private DialogueUI _dialogueBox;
|
||||
[Tooltip("输入读取器 SO。监听 SubmitEvent(确认/跳过)推进对话行。")]
|
||||
[SerializeField] private InputReaderSO _inputReader;
|
||||
[Tooltip("世界状态注册表 SO。对话序列 variants 条件分支据此读取标志位。")]
|
||||
[SerializeField] private WorldStateRegistry _worldState;
|
||||
|
||||
[Header("事件频道")]
|
||||
[Tooltip("EVT_DialogueStarted:对话序列开始时广播(无 payload)。供输入系统切换 Action Map 至 UI 模式等监听。")]
|
||||
[SerializeField] private VoidEventChannelSO _onDialogueStarted;
|
||||
[Tooltip("EVT_DialogueEnded:对话序列全部行播完后广播(无 payload)。\n" +
|
||||
"【重要】输入系统应监听此事件切回 Gameplay Action Map;\n" +
|
||||
"若未连接此频道,DialogueManager 会直接调用 InputReader.EnableGameplayInput() 作为兜底。")]
|
||||
[SerializeField] private VoidEventChannelSO _onDialogueEnded;
|
||||
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted (npcId)
|
||||
[Tooltip("EVT_NpcDialogueCompleted:payload = npcId(string)。每段对话结束时广播,驱动 QuestManager 中对话类目标进度。")]
|
||||
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted;
|
||||
[Tooltip("EVT_LineStarted:每行对话开始打字时广播(无 payload)。供音效/震动/打字音效系统监听。")]
|
||||
[SerializeField] private VoidEventChannelSO _onLineStarted;
|
||||
[Tooltip("EVT_LineEnded:玩家按确认键推进到下一行时广播(无 payload)。供音效/震动系统监听。")]
|
||||
[SerializeField] private VoidEventChannelSO _onLineEnded;
|
||||
[Tooltip("EVT_DialogueForceEnded:对话序列因超时被强制终止时广播(payload = npcId)。\n" +
|
||||
"供埋点/异常追踪系统监听,以区分正常结束和超时强制中断。\n" +
|
||||
"可选字段,留空则不广播此专用事件(ForceEnd 仍正常执行)。")]
|
||||
[SerializeField] private StringEventChannelSO _onDialogueForceEnded;
|
||||
[Tooltip("EVT_DialogueChoiceSelected:玩家选择对话选项时广播(payload = \"sequenceId/choiceIndex\")。\n" +
|
||||
"供 QA 埋点、成就系统、或数据分析监听,以还原玩家的对话选择路径。\n" +
|
||||
"可选字段,留空则不广播。")]
|
||||
[SerializeField] private StringEventChannelSO _onDialogueChoiceSelected;
|
||||
|
||||
[Header("运行时限制")]
|
||||
[Tooltip("分支选项最大嵌套深度。超过此深度触发循环引用保护,跳过当前分支继续播放。\n" +
|
||||
"普通对话通常不超过 6 层;极端场景可调高,但推荐保持默认值 16。")]
|
||||
[Min(1)] [SerializeField] private int _maxChoiceDepth = 16;
|
||||
[Tooltip("待播对话序列队列容量上限。超过后新请求将被丢弃并记录警告。\n" +
|
||||
"用于防止事件链或脚本误触导致无限排队。")]
|
||||
[Min(1)] [SerializeField] private int _pendingQueueCapacity = 8;
|
||||
[Tooltip("单段对话序列的最长播放时间(秒)。超时后强制结束当前序列,防止异常卡死。\n" +
|
||||
"0 = 不限时(不推荐用于正式发布)。推荐 300s(5 分钟)覆盖最长剧情段落。")]
|
||||
[Min(0)] [SerializeField] private float _sequenceTimeoutSeconds = 300f;
|
||||
|
||||
private bool _skipRequested;
|
||||
private int _selectedChoiceIndex = -1;
|
||||
private int _choiceDepth;
|
||||
/// <summary>
|
||||
/// 每次 PlayImmediate 递增。HandleChoices 的选项回调在写入 _selectedChoiceIndex 前
|
||||
/// 比对此值,确保打断后遗留的回调不会污染新序列的状态。
|
||||
/// </summary>
|
||||
private int _playbackId;
|
||||
|
||||
// ── 一次性对话完成回调 ────────────────────────────────────────────────
|
||||
// 通过 StartDialogue(..., onComplete) 注册;OnDialogueEnded 触发后调用一次后清空。
|
||||
private System.Action _onCompleteCallback;
|
||||
|
||||
// ── 子协程通信字段(避免协程间 ref/out 参数)─────────────────────────
|
||||
/// <summary>HandleChoices 子协程写入结果:玩家选中选项后的后续序列(null = 无后续)。</summary>
|
||||
private DialogueSequenceSO _choiceBranchResult;
|
||||
/// <summary>HandleChoices 子协程写入结果:true = 分支深度超限,优雅降级(继续播放后续行)。</summary>
|
||||
private bool _branchDepthExceeded;
|
||||
/// <summary>当前正在播放对话的 NPC ID(无对话时为 null)。供外部系统主动查询"谁在说话"。</summary>
|
||||
private string _currentNpcId;
|
||||
|
||||
// ── 复用 Yield 指令,避免协程每行 new WaitUntil 闭包 ───────────────
|
||||
private sealed class WaitTypingOrSkip : CustomYieldInstruction
|
||||
{
|
||||
private readonly DialogueManager _m;
|
||||
public WaitTypingOrSkip(DialogueManager m) => _m = m;
|
||||
public override bool keepWaiting => _m._dialogueBox.IsTyping && !_m._skipRequested;
|
||||
}
|
||||
private sealed class WaitSkip : CustomYieldInstruction
|
||||
{
|
||||
private readonly DialogueManager _m;
|
||||
public WaitSkip(DialogueManager m) => _m = m;
|
||||
public override bool keepWaiting => !_m._skipRequested;
|
||||
}
|
||||
// 等待玩家从分支选项中做出选择(_selectedChoiceIndex >= 0 时解除阻塞)
|
||||
private sealed class WaitForChoice : CustomYieldInstruction
|
||||
{
|
||||
private readonly DialogueManager _m;
|
||||
public WaitForChoice(DialogueManager m) => _m = m;
|
||||
public override bool keepWaiting => _m._selectedChoiceIndex < 0;
|
||||
}
|
||||
|
||||
private WaitTypingOrSkip _waitTypingOrSkip;
|
||||
private WaitSkip _waitSkip;
|
||||
private WaitForChoice _waitForChoice;
|
||||
// 延迟 0.15s 防止玩家快速连击穿透:跳过打字机后立即触发选项0
|
||||
private WaitForSeconds _waitChoiceInputGuard;
|
||||
// 超时守卫等待指令(与 _sequenceTimeoutSeconds 同步,在 Awake 初始化,避免每次 PlayImmediate 分配)
|
||||
private WaitForSeconds _waitSequenceTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// 当 IsDialogueActive 时排队等待的对话请求。
|
||||
/// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话),
|
||||
/// 但容量上限为 8,避免因误触导致无限排队。
|
||||
/// 使用 List 而非 Queue,以支持基于优先级的抢占式淘汰(队满时丢弃最低优先级项目)。
|
||||
/// </summary>
|
||||
private readonly List<(DialogueSequenceSO seq, string npcId, int priority)> _pending = new();
|
||||
|
||||
/// <summary>当前是否有对话正在播放。</summary>
|
||||
public bool IsDialogueActive { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前正在播放对话的 NPC ID。无对话活跃时为 <see langword="null"/>。
|
||||
/// 供地图标记、HUD、分析埋点等外部系统主动查询"当前谁在说话",无需订阅事件。
|
||||
/// </summary>
|
||||
public string CurrentNpcId => _currentNpcId;
|
||||
|
||||
/// <summary>当前正在播放的对话优先级(0 = 默认)。高优先级请求可打断低优先级。</summary>
|
||||
private int _currentPriority;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event System.Action OnDialogueEnded;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<IDialogueService>() != null) { Destroy(gameObject); return; }
|
||||
ServiceLocator.Register<IDialogueService>(this);
|
||||
_waitTypingOrSkip = new WaitTypingOrSkip(this);
|
||||
_waitSkip = new WaitSkip(this);
|
||||
_waitForChoice = new WaitForChoice(this);
|
||||
_waitChoiceInputGuard = new WaitForSeconds(0.15f);
|
||||
if (_sequenceTimeoutSeconds > 0f)
|
||||
_waitSequenceTimeout = new WaitForSeconds(_sequenceTimeoutSeconds);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
@@ -49,27 +156,149 @@ namespace BaseGames.Dialogue
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_inputReader != null) _inputReader.SubmitEvent -= OnSubmit;
|
||||
|
||||
// 若对话协程在组件禁用或场景切换时仍在运行,Unity 会强制杀死协程但不调用
|
||||
// EndDialogue(),导致 Action Map 永久停留在 UI 模式。复用 ForceEnd() 统一处理。
|
||||
ForceEnd();
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 启动对话序列。若已有对话在播放则忽略新请求。
|
||||
/// 启动对话序列。
|
||||
/// 若已有对话在播放:
|
||||
/// - 当 <paramref name="priority"/> 高于当前对话优先级时,立即打断并播放新序列;
|
||||
/// - 否则进入等待队列(上限 <see cref="_pendingQueueCapacity"/>),超出上限的请求被丢弃。
|
||||
/// 由 InteractableNPC.Interact() 调用。
|
||||
/// </summary>
|
||||
/// <param name="sequence">要播放的对话序列 SO。</param>
|
||||
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
|
||||
public void StartDialogue(DialogueSequenceSO sequence, string npcId = "")
|
||||
/// <param name="priority">优先级。数值越大越优先;相同或更低优先级不会打断当前对话。</param>
|
||||
public void StartDialogue(DialogueSequenceSO sequence, string npcId = "", int priority = 0)
|
||||
{
|
||||
if (IsDialogueActive || sequence == null) return;
|
||||
IsDialogueActive = true;
|
||||
_skipRequested = false;
|
||||
if (sequence == null) return;
|
||||
if (IsDialogueActive)
|
||||
{
|
||||
// 高优先级:打断当前对话,立即播放
|
||||
if (priority > _currentPriority)
|
||||
{
|
||||
StopAllCoroutines();
|
||||
_dialogueBox?.HideChoices();
|
||||
IsDialogueActive = false;
|
||||
_skipRequested = false;
|
||||
_selectedChoiceIndex = -1;
|
||||
_choiceDepth = 0;
|
||||
// 不清空队列,被打断的对话之后仍可继续播放
|
||||
PlayImmediate(sequence, npcId, priority);
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换到 UI Action Map(禁用玩家移动输入)
|
||||
_inputReader.EnableUIInput();
|
||||
if (_pending.Count < _pendingQueueCapacity)
|
||||
{
|
||||
_pending.Add((sequence, npcId, priority));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 队满时:查找优先级最低的项目,若新请求优先级更高则淘汰之,否则丢弃新请求
|
||||
int minIdx = 0;
|
||||
for (int i = 1; i < _pending.Count; i++)
|
||||
{
|
||||
if (_pending[i].priority < _pending[minIdx].priority) minIdx = i;
|
||||
}
|
||||
if (priority > _pending[minIdx].priority)
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
Debug.LogWarning(
|
||||
$"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," +
|
||||
$"序列 '{_pending[minIdx].seq.sequenceId}'(优先级 {_pending[minIdx].priority})" +
|
||||
$"被优先级更高的 '{sequence.sequenceId}'(优先级 {priority})淘汰。");
|
||||
#endif
|
||||
_pending.RemoveAt(minIdx);
|
||||
_pending.Add((sequence, npcId, priority));
|
||||
}
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
else
|
||||
Debug.LogWarning(
|
||||
$"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," +
|
||||
$"序列 '{sequence.sequenceId}'(优先级 {priority})被丢弃,队列中最低优先级为 {_pending[minIdx].priority}。");
|
||||
#endif
|
||||
}
|
||||
return;
|
||||
}
|
||||
PlayImmediate(sequence, npcId, priority);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IDialogueService.StartDialogue(DialogueSequenceSO,string,int,System.Action)"/>
|
||||
public void StartDialogue(DialogueSequenceSO sequence, string npcId, int priority, System.Action onComplete)
|
||||
{
|
||||
if (onComplete != null)
|
||||
{
|
||||
// 若已有待回调,链式追加(不覆盖),保证先来先到
|
||||
_onCompleteCallback += onComplete;
|
||||
}
|
||||
StartDialogue(sequence, npcId, priority);
|
||||
}
|
||||
|
||||
private void PlayImmediate(DialogueSequenceSO sequence, string npcId, int priority = 0)
|
||||
{
|
||||
IsDialogueActive = true;
|
||||
_currentNpcId = npcId;
|
||||
_currentPriority = priority;
|
||||
_skipRequested = false;
|
||||
_selectedChoiceIndex = -1;
|
||||
_choiceDepth = 0;
|
||||
_playbackId++;
|
||||
if (_inputReader != null) _inputReader.EnableUIInput();
|
||||
_onDialogueStarted?.Raise();
|
||||
StartCoroutine(PlaySequence(sequence, npcId));
|
||||
// 启动超时守卫(0 = 不限时)
|
||||
if (_sequenceTimeoutSeconds > 0f)
|
||||
StartCoroutine(SequenceTimeoutGuard(npcId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 超时守卫协程:若对话在 <see cref="_sequenceTimeoutSeconds"/> 内未正常结束,
|
||||
/// 强制终止并记录错误,防止游戏卡死在对话状态。
|
||||
/// </summary>
|
||||
private IEnumerator SequenceTimeoutGuard(string npcId)
|
||||
{
|
||||
yield return _waitSequenceTimeout ?? new WaitForSeconds(_sequenceTimeoutSeconds);
|
||||
if (!IsDialogueActive) yield break;
|
||||
Debug.LogError(
|
||||
$"[DialogueManager] 对话序列 (npcId='{npcId}') 超时 {_sequenceTimeoutSeconds}s 未结束," +
|
||||
"强制终止。请检查是否存在无法退出的等待分支。");
|
||||
_onDialogueForceEnded?.Raise(npcId);
|
||||
ForceEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 强制立即终止当前对话,清空等待队列,恢复游戏输入。
|
||||
/// 场景切换/演出打断时调用;若无对话活跃则无操作。
|
||||
/// </summary>
|
||||
public void ForceEnd()
|
||||
{
|
||||
if (!IsDialogueActive) return;
|
||||
StopAllCoroutines();
|
||||
_playbackId++; // 使所有残余的选项回调失效,防止下一帧写入新序列状态
|
||||
_pending.Clear();
|
||||
_dialogueBox?.HideChoices();
|
||||
_dialogueBox?.Hide();
|
||||
IsDialogueActive = false;
|
||||
_currentNpcId = null;
|
||||
_currentPriority = 0;
|
||||
_skipRequested = false;
|
||||
_selectedChoiceIndex = -1;
|
||||
_choiceDepth = 0;
|
||||
// 优先通过 _onDialogueEnded 事件让 InputManager 决定如何恢复输入;
|
||||
// 若未连接事件频道(旧场景配置),直接恢复 Gameplay 输入作为兜底。
|
||||
_onDialogueEnded?.Raise();
|
||||
if (_onDialogueEnded == null) _inputReader?.EnableGameplayInput();
|
||||
OnDialogueEnded?.Invoke();
|
||||
|
||||
// 触发一次性完成回调(正常结束和强制中断均触发)
|
||||
var cb = _onCompleteCallback;
|
||||
_onCompleteCallback = null;
|
||||
cb?.Invoke();
|
||||
}
|
||||
|
||||
// ── 输入回调 ──────────────────────────────────────────────────────
|
||||
@@ -78,64 +307,182 @@ namespace BaseGames.Dialogue
|
||||
|
||||
// ── 内部协程 ──────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator PlaySequence(DialogueSequenceSO sequence, string npcId)
|
||||
private IEnumerator PlaySequence(DialogueSequenceSO startSequence, string npcId)
|
||||
{
|
||||
// 选择条件变体(查询 ConditionalVariant.conditionFlag — 未实现 WorldStateRegistry 查询时直接使用默认序列)
|
||||
var resolved = ResolveVariant(sequence);
|
||||
|
||||
foreach (var line in resolved.lines)
|
||||
if (_dialogueBox == null)
|
||||
{
|
||||
_skipRequested = false;
|
||||
_dialogueBox.ShowLine(line);
|
||||
Debug.LogError("[DialogueManager] _dialogueBox 未配置,对话无法显示。请在 Inspector 中指定 DialogueUI 组件。", this);
|
||||
EndDialogue(npcId);
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 等待打字完成,期间允许跳过
|
||||
yield return new WaitUntil(() => !_dialogueBox.IsTyping || _skipRequested);
|
||||
if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping();
|
||||
// 使用显式序列栈替代递归,防止深链(100+ 序列)时 C# 调用栈溢出
|
||||
var sequenceStack = new System.Collections.Generic.Stack<DialogueSequenceSO>();
|
||||
sequenceStack.Push(startSequence);
|
||||
|
||||
// 等待玩家按 Submit 推进下一行
|
||||
_skipRequested = false;
|
||||
yield return new WaitUntil(() => _skipRequested);
|
||||
while (sequenceStack.Count > 0)
|
||||
{
|
||||
var sequence = sequenceStack.Pop();
|
||||
var resolved = ResolveVariant(sequence);
|
||||
|
||||
if (resolved.lines == null || resolved.lines.Length == 0)
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
Debug.LogWarning(
|
||||
$"[DialogueManager] 序列 '{resolved.sequenceId}' 没有对话行(lines 为空)。" +
|
||||
"对话将静默跳过此序列,可能是未完成配置。");
|
||||
#endif
|
||||
continue;
|
||||
}
|
||||
|
||||
bool branchChosen = false;
|
||||
foreach (var line in resolved.lines)
|
||||
{
|
||||
yield return StartCoroutine(PlayOneLine(line));
|
||||
|
||||
if (line.choices != null && line.choices.Length > 0)
|
||||
{
|
||||
yield return StartCoroutine(HandleChoices(line, resolved.sequenceId));
|
||||
|
||||
if (!_branchDepthExceeded)
|
||||
{
|
||||
if (_choiceBranchResult != null)
|
||||
sequenceStack.Push(_choiceBranchResult);
|
||||
branchChosen = true;
|
||||
break;
|
||||
}
|
||||
// 深度超限:优雅降级,继续播放当前序列后续行
|
||||
continue;
|
||||
}
|
||||
|
||||
// 普通行:等待玩家按 Submit 推进
|
||||
_skipRequested = false;
|
||||
yield return _waitSkip;
|
||||
_onLineEnded?.Raise();
|
||||
}
|
||||
|
||||
_ = branchChosen;
|
||||
}
|
||||
|
||||
EndDialogue(npcId);
|
||||
}
|
||||
|
||||
private void EndDialogue(string npcId)
|
||||
/// <summary>
|
||||
/// 显示一行对话并等待打字机效果完成(期间允许跳过)。
|
||||
/// 广播 EVT_LineStarted。不广播 EVT_LineEnded(由调用方在推进后广播)。
|
||||
/// </summary>
|
||||
private IEnumerator PlayOneLine(DialogueLine line)
|
||||
{
|
||||
_dialogueBox.Hide();
|
||||
IsDialogueActive = false;
|
||||
|
||||
// 恢复 Gameplay Action Map
|
||||
_inputReader.EnableGameplayInput();
|
||||
|
||||
_onDialogueEnded?.Raise();
|
||||
|
||||
if (!string.IsNullOrEmpty(npcId))
|
||||
_onNpcDialogueCompleted?.Raise(npcId);
|
||||
_skipRequested = false;
|
||||
_dialogueBox.ShowLine(line);
|
||||
_onLineStarted?.Raise();
|
||||
yield return _waitTypingOrSkip;
|
||||
if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ConditionalVariant 选择正确的序列版本。
|
||||
/// 按顺序检查 variants:第一个满足 WorldStateRegistry 标志的变体胜出;
|
||||
/// 未注入 WorldStateRegistry 或无满足条件的变体时,返回原序列(默认)。
|
||||
/// 显示分支选项,等待玩家选择,并将结果写入 <see cref="_choiceBranchResult"/>。
|
||||
/// <para>若选项嵌套深度超过 <see cref="_maxChoiceDepth"/>,将 <see cref="_branchDepthExceeded"/>
|
||||
/// 置为 true 并立即返回,调用方应优雅降级继续播放后续行而不是终止对话。</para>
|
||||
/// </summary>
|
||||
private IEnumerator HandleChoices(DialogueLine line, string sequenceId)
|
||||
{
|
||||
_choiceBranchResult = null;
|
||||
_branchDepthExceeded = false;
|
||||
|
||||
_choiceDepth++;
|
||||
if (_choiceDepth >= _maxChoiceDepth)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[DialogueManager] 分支对话深度超过 {_maxChoiceDepth}," +
|
||||
$"序列 \"{sequenceId}\" 可能存在循环引用。" +
|
||||
"已跳过当前选项分支,继续播放后续内容。");
|
||||
_dialogueBox?.HideChoices();
|
||||
_skipRequested = false;
|
||||
_selectedChoiceIndex = -1;
|
||||
_branchDepthExceeded = true;
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 清除打字机阶段积压的输入,防止选项显示后被立即误触发
|
||||
_skipRequested = false;
|
||||
_selectedChoiceIndex = -1;
|
||||
// 延迟 0.15s:确保此前积压的"确认键"输入已被彻底消耗,
|
||||
// 防止快速连击(跳过打字机→立即误选选项0)的穿透问题。
|
||||
// 使用预创建的缓存实例,避免每次分配 WaitForSeconds 对象。
|
||||
yield return _waitChoiceInputGuard;
|
||||
// 捕获当前播放标记,防止被打断后遗留回调写入新序列的选择索引
|
||||
int capturedId = _playbackId;
|
||||
_dialogueBox.ShowChoices(line.choices, idx =>
|
||||
{
|
||||
if (_playbackId == capturedId) _selectedChoiceIndex = idx;
|
||||
});
|
||||
yield return _waitForChoice;
|
||||
_dialogueBox.HideChoices();
|
||||
_skipRequested = false;
|
||||
_onLineEnded?.Raise();
|
||||
|
||||
var chosen = line.choices[_selectedChoiceIndex];
|
||||
|
||||
// 广播选择事件(供 QA 埋点、成就系统、数据分析使用)
|
||||
_onDialogueChoiceSelected?.Raise($"{sequenceId}/{_selectedChoiceIndex}");
|
||||
|
||||
// 可选:将世界状态标志写入 WorldStateRegistry
|
||||
if (!string.IsNullOrEmpty(chosen.setWorldFlag) && _worldState != null)
|
||||
_worldState.SetFlag(chosen.setWorldFlag);
|
||||
|
||||
_choiceBranchResult = chosen.nextSequence;
|
||||
}
|
||||
|
||||
private void EndDialogue(string npcId)
|
||||
{
|
||||
_dialogueBox?.Hide();
|
||||
IsDialogueActive = false;
|
||||
_currentNpcId = null;
|
||||
_currentPriority = 0;
|
||||
|
||||
// 优先通过 _onDialogueEnded 事件让 InputManager 决定如何恢复输入;
|
||||
// 若未连接事件频道(旧场景配置),直接恢复 Gameplay 输入作为兜底。
|
||||
_onDialogueEnded?.Raise();
|
||||
if (_onDialogueEnded == null) _inputReader?.EnableGameplayInput();
|
||||
|
||||
OnDialogueEnded?.Invoke();
|
||||
|
||||
if (!string.IsNullOrEmpty(npcId))
|
||||
_onNpcDialogueCompleted?.Raise(npcId);
|
||||
|
||||
// 触发一次性完成回调(正常结束和强制中断均触发)
|
||||
var cb = _onCompleteCallback;
|
||||
_onCompleteCallback = null;
|
||||
cb?.Invoke();
|
||||
|
||||
// 自动播放优先级最高的等待中对话(保证高优先级对话不被低优先级插队)
|
||||
if (_pending.Count > 0)
|
||||
{
|
||||
int best = 0;
|
||||
for (int i = 1; i < _pending.Count; i++)
|
||||
if (_pending[i].priority > _pending[best].priority) best = i;
|
||||
var item = _pending[best];
|
||||
_pending.RemoveAt(best);
|
||||
PlayImmediate(item.seq, item.npcId, item.priority);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 WorldState 标志选择正确的序列版本。
|
||||
/// 委托给 <see cref="DialogueSequenceSO.TryGetActiveVariant"/> 统一处理,消除重复逻辑。
|
||||
/// </summary>
|
||||
private DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence)
|
||||
{
|
||||
if (sequence.variants == null || sequence.variants.Length == 0)
|
||||
return sequence;
|
||||
|
||||
if (_worldState != null)
|
||||
{
|
||||
foreach (var variant in sequence.variants)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(variant.conditionFlag)
|
||||
&& variant.sequence != null
|
||||
&& _worldState.HasFlag(variant.conditionFlag))
|
||||
return variant.sequence;
|
||||
}
|
||||
}
|
||||
|
||||
return sequence;
|
||||
var resolved = sequence.TryGetActiveVariant(_worldState);
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
if (resolved == sequence && sequence.variants != null && sequence.variants.Length > 0 && _worldState == null)
|
||||
Debug.LogWarning(
|
||||
$"[DialogueManager] 序列 '{sequence.sequenceId}' 有 {sequence.variants.Length} 个条件变体," +
|
||||
"但 WorldStateRegistry 未注入,将使用默认序列。请检查 Inspector 中的 _worldState 字段。",
|
||||
this);
|
||||
#endif
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,79 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 对话行结构(架构 14_NarrativeModule §3)。
|
||||
/// 每行包含说话人、文本(本地化 Key)和可选的语音片段。
|
||||
///
|
||||
/// 推荐通过 actor 引用 DialogueActorSO 统一管理头像/名称;
|
||||
/// speakerNameKey / portraitSprite 作为无 SO 时的直接覆盖(保持向后兼容)。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct DialogueLine
|
||||
{
|
||||
public string speakerNameKey; // 本地化 key(如 "NPC_Elder_Name")
|
||||
[Tooltip("说话人角色(推荐)。actor 优先;留空时回退到 speakerNameKey / portraitSprite")]
|
||||
public DialogueActorSO actor;
|
||||
|
||||
[Tooltip("说话人本地化 key,留空时使用 actor.nameKey")]
|
||||
public string speakerNameKey;
|
||||
[Tooltip("对话文本本地化 Key,如 \"DLG_Elder_001\"。运行时通过 LocalizationManager.Get(textKey, \"Dialogue\") 获取实际文字。")]
|
||||
[TextArea(2, 5)]
|
||||
public string textKey; // 本地化文本 key(如 "DLG_Elder_001")
|
||||
public Sprite portraitSprite; // 可选说话人头像
|
||||
public AudioClip voiceClip; // 可选语音
|
||||
public string textKey;
|
||||
|
||||
[Tooltip("说话人头像,留空时使用 actor.portrait")]
|
||||
public Sprite portraitSprite;
|
||||
[Tooltip("对应该行对话的语音片段(可选)。由 DialogueUI 通过 AudioSource 播放,打字机阶段同步开始。")]
|
||||
public AudioClip voiceClip;
|
||||
[Tooltip("打字机每字符延迟(秒)。0 = 使用 DialogueUI 默认值(推荐 0.03s)。\n" +
|
||||
"调小 = 打字更快;调大 = 打字更慢。仅影响本行,不影响其他行。")]
|
||||
[Min(0.01f)]
|
||||
public float typewriterDelay; // 每字符延迟(秒,0 = 使用默认 0.03f)
|
||||
public float typewriterDelay;
|
||||
|
||||
[Tooltip("玩家选项(可选)。有值时,本行打字机效果结束后显示选项列表,等待玩家选择。\n" +
|
||||
"选择后根据 nextSequence 播放续集(或结束对话),并可选地设置 setWorldFlag 标志。\n" +
|
||||
"留空 = 普通对话行,玩家按确认键推进。")]
|
||||
public DialogueChoice[] choices;
|
||||
|
||||
/// <summary>
|
||||
/// 获取最终使用的说话人名称 Key:actor 优先,回退到直接字段。
|
||||
/// </summary>
|
||||
public string ResolvedNameKey => actor != null && !string.IsNullOrEmpty(actor.nameKey)
|
||||
? actor.nameKey : speakerNameKey;
|
||||
|
||||
/// <summary>
|
||||
/// 获取最终使用的头像:actor 优先,回退到直接字段。
|
||||
/// </summary>
|
||||
public Sprite ResolvedPortrait => actor != null && actor.portrait != null
|
||||
? actor.portrait : portraitSprite;
|
||||
|
||||
/// <summary>
|
||||
/// 获取最终使用的主题颜色:actor 有值时取 actor.accentColor,否则返回 white。
|
||||
/// </summary>
|
||||
public Color ResolvedAccentColor => actor != null ? actor.accentColor : Color.white;
|
||||
|
||||
/// <summary>
|
||||
/// 当前行是否由玩家角色说话(影响 UI 排版方向)。
|
||||
/// </summary>
|
||||
public bool ResolvedIsPlayer => actor != null && actor.isPlayer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 玩家可选的对话分支选项。
|
||||
/// 在对话行打字机效果结束后呈现给玩家,选择后播放对应续集序列或结束对话。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct DialogueChoice
|
||||
{
|
||||
[Tooltip("选项文字本地化 Key,如 \"DLG_Choice_AcceptQuest\"。\n运行时由 LocalizationManager 解析为实际文字。")]
|
||||
public string textKey;
|
||||
[Tooltip("选择此选项后继续播放的对话序列(留空 = 对话立即结束)。")]
|
||||
public DialogueSequenceSO nextSequence;
|
||||
[Tooltip("选择此选项后设置的世界状态标志(留空 = 不修改任何标志)。\n与 nextSequence 同时生效。")]
|
||||
public string setWorldFlag;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -24,18 +82,262 @@ namespace BaseGames.Dialogue
|
||||
/// 资产路径: Assets/ScriptableObjects/Dialogue/DLG_{NpcId}_{Context}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueSequence")]
|
||||
public class DialogueSequenceSO : ScriptableObject
|
||||
public class DialogueSequenceSO : ScriptableObject, ILocalizableAsset
|
||||
{
|
||||
[Header("标识")]
|
||||
[Tooltip("序列唯一 ID,如 \"DLG_Elder_Quest_Available\"。OnValidate 会自动以资产名填充,也可手动指定。")]
|
||||
public string sequenceId; // 全局唯一,如 "DLG_Elder_Quest_Available"
|
||||
|
||||
[Header("对话行")]
|
||||
[Tooltip("按顺序播放的对话行列表。每行包含说话人(actor 优先)、文本本地化 Key、可选头像与语音。")]
|
||||
public DialogueLine[] lines;
|
||||
|
||||
/// <summary>条件变体:满足特定世界标志时替换整个序列。</summary>
|
||||
/// <summary>
|
||||
/// 条件变体:requiredFlags 按 logic 逻辑满足时替换整个序列。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct ConditionalVariant
|
||||
{
|
||||
public string conditionFlag; // WorldState flag key
|
||||
public DialogueSequenceSO sequence;
|
||||
[Tooltip("条件判断逻辑:And(默认,全部满足)或 Or(任一满足)。\n" +
|
||||
"先选好逻辑再填标志,阅读顺序更自然。")]
|
||||
public BaseGames.Core.WorldStateFlagLogic logic;
|
||||
[Tooltip("条件标志列表。logic=And 时全部满足激活;logic=Or 时任一满足激活。留空表示无条件(总是激活)。")]
|
||||
[WorldStateFlag]
|
||||
public string[] requiredFlags;
|
||||
public DialogueSequenceSO sequence;
|
||||
}
|
||||
|
||||
[Header("条件变体(可选)")]
|
||||
[Tooltip("运行时根据 WorldState 标志动态替换整个序列。按优先级从高到低排列:满足条件的第一个变体胜出。\n" +
|
||||
"每个变体支持 And(全部满足)或 Or(任一满足)两种判断逻辑。\n" +
|
||||
"留空表示无变体,始终使用本序列默认台词。")]
|
||||
public ConditionalVariant[] variants;
|
||||
|
||||
// ── 运行时变体解析 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 检查单个条件变体的 requiredFlags 在给定 reader 下是否满足。
|
||||
/// 无条件(requiredFlags 为空)的变体始终返回 true。
|
||||
/// </summary>
|
||||
public bool CheckVariant(ConditionalVariant variant, BaseGames.Core.IWorldStateReader reader)
|
||||
{
|
||||
if (variant.sequence == null) return false;
|
||||
if (variant.requiredFlags == null || variant.requiredFlags.Length == 0) return true;
|
||||
if (reader == null) return false;
|
||||
|
||||
if (variant.logic == BaseGames.Core.WorldStateFlagLogic.Or)
|
||||
{
|
||||
foreach (var flag in variant.requiredFlags)
|
||||
if (!string.IsNullOrEmpty(flag) && reader.HasFlag(flag)) return true;
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var flag in variant.requiredFlags)
|
||||
if (!string.IsNullOrEmpty(flag) && !reader.HasFlag(flag)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 <paramref name="reader"/> 提供的世界状态,返回第一个满足条件的变体序列;
|
||||
/// 无满足变体或 reader 为 null 时返回 this(默认序列)。
|
||||
/// 开发构建下会在控制台输出命中的变体索引和标志,方便调试。
|
||||
/// </summary>
|
||||
public DialogueSequenceSO TryGetActiveVariant(BaseGames.Core.IWorldStateReader reader)
|
||||
{
|
||||
if (variants == null || variants.Length == 0) return this;
|
||||
if (reader != null)
|
||||
{
|
||||
for (int i = 0; i < variants.Length; i++)
|
||||
{
|
||||
var variant = variants[i];
|
||||
if (!CheckVariant(variant, reader)) continue;
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
string matchedFlags = variant.requiredFlags != null && variant.requiredFlags.Length > 0
|
||||
? string.Join(", ", variant.requiredFlags)
|
||||
: "(无条件)";
|
||||
string targetId = variant.sequence != null ? variant.sequence.sequenceId : "null";
|
||||
Debug.Log(
|
||||
$"[DialogueSequenceSO] '{sequenceId}' 选中变体[{i}]({matchedFlags})→ '{targetId}'",
|
||||
this);
|
||||
#endif
|
||||
return variant.sequence;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// sequenceId → 资产路径,5 秒 TTL,跨所有 DialogueSequenceSO.OnValidate 共用,
|
||||
// 避免每次 Save 都重扫所有同类 SO(O(1) 路径比对代替 O(n) 全量扫描)。
|
||||
private static System.Collections.Generic.Dictionary<string, string> s_seqIdToPath;
|
||||
private static double s_seqIdsCacheTime = -10.0;
|
||||
|
||||
private static System.Collections.Generic.Dictionary<string, string> GetSequenceIdCache()
|
||||
{
|
||||
double now = UnityEditor.EditorApplication.timeSinceStartup;
|
||||
if (s_seqIdToPath != null && now - s_seqIdsCacheTime < 5.0)
|
||||
return s_seqIdToPath;
|
||||
|
||||
s_seqIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:DialogueSequenceSO");
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||||
var seq = UnityEditor.AssetDatabase.LoadAssetAtPath<DialogueSequenceSO>(path);
|
||||
if (seq != null && !string.IsNullOrEmpty(seq.sequenceId) && !s_seqIdToPath.ContainsKey(seq.sequenceId))
|
||||
s_seqIdToPath[seq.sequenceId] = path;
|
||||
}
|
||||
s_seqIdsCacheTime = now;
|
||||
return s_seqIdToPath;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
if (string.IsNullOrEmpty(sequenceId))
|
||||
{
|
||||
sequenceId = name;
|
||||
UnityEditor.EditorUtility.SetDirty(this);
|
||||
}
|
||||
|
||||
// 检测重复 sequenceId:缓存路径 vs 自身路径比对(O(1)),5 秒内无需重扫。
|
||||
var cache = GetSequenceIdCache();
|
||||
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
|
||||
if (!string.IsNullOrEmpty(myPath) &&
|
||||
cache.TryGetValue(sequenceId, out var existingPath) &&
|
||||
existingPath != myPath)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[DialogueSequenceSO] sequenceId '{sequenceId}' 与 " +
|
||||
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||||
s_seqIdsCacheTime = -10.0;
|
||||
}
|
||||
|
||||
ValidateChoiceCycles();
|
||||
ValidateVariantOrder();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查 variants 数组中是否存在"无条件变体遮蔽后续变体"的配置错误:
|
||||
/// 若某变体 requiredFlags 为空(无条件)且不在数组末尾,则其后所有变体永远不会被匹配。
|
||||
/// </summary>
|
||||
private void ValidateVariantOrder()
|
||||
{
|
||||
if (variants == null || variants.Length <= 1) return;
|
||||
|
||||
for (int i = 0; i < variants.Length - 1; i++)
|
||||
{
|
||||
var v = variants[i];
|
||||
bool isUnconditional = v.requiredFlags == null || v.requiredFlags.Length == 0;
|
||||
if (!isUnconditional) continue;
|
||||
|
||||
if (v.sequence == null) continue; // 无效变体,忽略
|
||||
|
||||
Debug.LogWarning(
|
||||
$"[DialogueSequenceSO] '{name}' 的 variants[{i}] 没有设置任何条件(requiredFlags 为空)," +
|
||||
$"该变体将始终优先匹配,其后的 {variants.Length - 1 - i} 个变体永远不会生效。\n" +
|
||||
"请将无条件变体移到数组末尾作为兜底,或为此变体添加具体条件。", this);
|
||||
return; // 一次只报第一个问题
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateChoiceCycles()
|
||||
{
|
||||
if (lines == null && (variants == null || variants.Length == 0)) return;
|
||||
var visited = new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal);
|
||||
visited.Add(sequenceId);
|
||||
|
||||
// 检查选项链循环
|
||||
if (lines != null)
|
||||
{
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.choices == null) continue;
|
||||
foreach (var choice in line.choices)
|
||||
{
|
||||
if (choice.nextSequence == null) continue;
|
||||
if (HasChoiceCycle(choice.nextSequence, visited))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[DialogueSequenceSO] '{name}' 的选项链存在循环引用!" +
|
||||
$"序列 '{choice.nextSequence.name}' 最终指回自身或已访问序列," +
|
||||
"运行时将触发递归保护(强制终止对话)。请检查 nextSequence 配置。", this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查条件变体链循环(variant.sequence 也可能引用形成环路)
|
||||
if (variants != null)
|
||||
{
|
||||
foreach (var variant in variants)
|
||||
{
|
||||
if (variant.sequence == null) continue;
|
||||
if (HasChoiceCycle(variant.sequence, visited))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[DialogueSequenceSO] '{name}' 的条件变体链存在循环引用!" +
|
||||
$"变体序列 '{variant.sequence.name}' 最终指回自身或已访问序列," +
|
||||
"运行时将触发递归保护(强制终止对话)。请检查 variants 配置。", this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasChoiceCycle(DialogueSequenceSO seq,
|
||||
System.Collections.Generic.HashSet<string> visited, int depth = 0)
|
||||
{
|
||||
if (depth > 32)
|
||||
{
|
||||
Debug.LogError($"[DialogueSequenceSO] 选项链深度超过 32 层(路径末端:'{seq.name}'),已视为存在循环引用并中止检测。请减少 nextSequence 嵌套层数。");
|
||||
return true;
|
||||
}
|
||||
if (string.IsNullOrEmpty(seq.sequenceId)) return false;
|
||||
if (!visited.Add(seq.sequenceId)) return true;
|
||||
if (seq.lines != null)
|
||||
{
|
||||
foreach (var line in seq.lines)
|
||||
{
|
||||
if (line.choices == null) continue;
|
||||
foreach (var choice in line.choices)
|
||||
{
|
||||
if (choice.nextSequence != null && HasChoiceCycle(choice.nextSequence, visited, depth + 1))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 同时遍历条件变体序列,防止变体链形成环路
|
||||
if (seq.variants != null)
|
||||
{
|
||||
foreach (var variant in seq.variants)
|
||||
{
|
||||
if (variant.sequence != null && HasChoiceCycle(variant.sequence, visited, depth + 1))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
visited.Remove(seq.sequenceId);
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
public IEnumerable<LocalizationKeyRef> GetLocalizationKeys()
|
||||
{
|
||||
if (lines == null) yield break;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(line.textKey))
|
||||
yield return new LocalizationKeyRef(line.textKey, "Dialogue", "lines.textKey");
|
||||
// speakerNameKey only relevant when actor is absent (override path)
|
||||
if (line.actor == null && !string.IsNullOrEmpty(line.speakerNameKey))
|
||||
yield return new LocalizationKeyRef(line.speakerNameKey, "Dialogue", "lines.speakerNameKey");
|
||||
if (line.choices != null)
|
||||
foreach (var choice in line.choices)
|
||||
if (!string.IsNullOrEmpty(choice.textKey))
|
||||
yield return new LocalizationKeyRef(choice.textKey, "Dialogue", "lines.choices.textKey");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
@@ -20,35 +21,90 @@ namespace BaseGames.Dialogue
|
||||
[SerializeField] private GameObject _speakerNamePanel; // 无名称时隐藏整个名称框
|
||||
[SerializeField] private GameObject _continuePrompt; // "▼" 图标,打字完成后显示
|
||||
[SerializeField] private Image _speakerPortrait; // 角色头像框
|
||||
[SerializeField] private Image _speakerNameBackground; // 说话人名称框背景,用于应用 accentColor(可选)
|
||||
[SerializeField] private AudioSource _voiceSource; // 语音播放源(可不配置)
|
||||
|
||||
private Coroutine _typingCoroutine;
|
||||
[Header("选项系统(可选)")]
|
||||
[Tooltip("选项按钮的父节点容器。ShowChoices 通过对象池激活/停用按钮,HideChoices 停用全部。\n留空则分支选项功能静默禁用。")]
|
||||
[SerializeField] private Transform _choicesContainer;
|
||||
[Tooltip("选项按钮预制体(需包含 Button 组件和 TMP_Text 子组件)。\n首次使用时预热 PoolInitialSize 个到对象池,后续零 GC。")]
|
||||
[SerializeField] private GameObject _choiceButtonPrefab;
|
||||
[Tooltip("选项按钮池初始大小。设为预期最大选项数,默认 8 覆盖绝大多数情况。")]
|
||||
[SerializeField] [Range(2, 16)] private int _choicePoolSize = 8;
|
||||
|
||||
// 说话人名称框背景的默认色(Awake 时记录,切换角色后可还原)
|
||||
private Color _defaultNameBgColor = Color.white;
|
||||
// 缓存名称框 RectTransform,避免 ShowLine 每次调用 GetComponent(零堆分配)
|
||||
private RectTransform _speakerNamePanelRT;
|
||||
|
||||
// 选项按钮对象池:Awake 时按 _choicePoolSize 预热,ShowChoices/HideChoices 零 GC
|
||||
private readonly List<(GameObject go, Button btn, TMP_Text lbl)> _choicePool = new();
|
||||
|
||||
private Coroutine _typingCoroutine;
|
||||
private DialogueLine _currentLine;
|
||||
private const float DefaultTypewriterDelay = 0.03f;
|
||||
|
||||
// 缓存 WaitForSecondsRealtime:delay 值不变时直接复用,避免每行 new 分配。
|
||||
private WaitForSecondsRealtime _cachedTypeDelay;
|
||||
private float _cachedTypeDelayValue = -1f;
|
||||
|
||||
// 缓存 StringBuilder:每行 Clear() 复用,避免每行 new StringBuilder(n) 的堆分配。
|
||||
// 初始容量 256,足以容纳绝大多数对话行,超长时会自动扩容(扩容极少发生)。
|
||||
private readonly StringBuilder _typingSB = new(256);
|
||||
|
||||
/// <summary>当前是否仍在执行打字机效果。</summary>
|
||||
public bool IsTyping { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_speakerNameBackground != null)
|
||||
_defaultNameBgColor = _speakerNameBackground.color;
|
||||
if (_speakerNamePanel != null)
|
||||
_speakerNamePanelRT = _speakerNamePanel.GetComponent<RectTransform>();
|
||||
|
||||
// 预热选项按钮对象池:在此时创建可避免首次对话时的 Instantiate 停顿
|
||||
if (_choicesContainer != null && _choiceButtonPrefab != null)
|
||||
{
|
||||
for (int i = 0; i < _choicePoolSize; i++)
|
||||
{
|
||||
var go = Instantiate(_choiceButtonPrefab, _choicesContainer);
|
||||
var btn = go.GetComponent<Button>();
|
||||
var lbl = go.GetComponentInChildren<TMP_Text>();
|
||||
go.SetActive(false);
|
||||
_choicePool.Add((go, btn, lbl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>显示一行对话并开始打字机效果。</summary>
|
||||
public void ShowLine(DialogueLine line)
|
||||
{
|
||||
_currentLine = line;
|
||||
_rootPanel.SetActive(true);
|
||||
_continuePrompt.SetActive(false);
|
||||
if (_rootPanel != null) _rootPanel.SetActive(true);
|
||||
if (_continuePrompt != null) _continuePrompt.SetActive(false);
|
||||
|
||||
// 说话人名称
|
||||
bool hasSpeaker = !string.IsNullOrEmpty(line.speakerNameKey);
|
||||
// 说话人名称(actor 优先,回退到直接字段)
|
||||
string resolvedNameKey = line.ResolvedNameKey;
|
||||
bool hasSpeaker = !string.IsNullOrEmpty(resolvedNameKey);
|
||||
if (_speakerNamePanel != null) _speakerNamePanel.SetActive(hasSpeaker);
|
||||
if (hasSpeaker && _speakerNameText != null)
|
||||
_speakerNameText.text = LocalizationManager.Get(line.speakerNameKey, "Dialogue");
|
||||
_speakerNameText.text = LocalizationManager.Get(resolvedNameKey, LocalizationTable.Dialogue);
|
||||
|
||||
// 头像
|
||||
// 说话人名称框背景颜色(accentColor):有 actor 时着色,无 actor 时还原默认色
|
||||
if (_speakerNameBackground != null)
|
||||
_speakerNameBackground.color = hasSpeaker ? line.ResolvedAccentColor : _defaultNameBgColor;
|
||||
|
||||
// 排版方向:玩家角色说话时名称框靠右,NPC 靠左
|
||||
SetLayoutSide(line.ResolvedIsPlayer);
|
||||
|
||||
// 头像(actor 优先,回退到直接字段)
|
||||
var resolvedPortrait = line.ResolvedPortrait;
|
||||
if (_speakerPortrait != null)
|
||||
{
|
||||
_speakerPortrait.gameObject.SetActive(line.portraitSprite != null);
|
||||
if (line.portraitSprite != null) _speakerPortrait.sprite = line.portraitSprite;
|
||||
_speakerPortrait.gameObject.SetActive(resolvedPortrait != null);
|
||||
if (resolvedPortrait != null) _speakerPortrait.sprite = resolvedPortrait;
|
||||
}
|
||||
|
||||
// 语音播放
|
||||
@@ -78,7 +134,20 @@ namespace BaseGames.Dialogue
|
||||
}
|
||||
_voiceSource?.Stop();
|
||||
if (_dialogueText != null)
|
||||
_dialogueText.text = LocalizationManager.Get(_currentLine.textKey ?? "", "Dialogue");
|
||||
{
|
||||
string key = _currentLine.textKey;
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
Debug.LogWarning("[DialogueUI] 当前对话行 textKey 为空,跳过打字机后显示空文本。");
|
||||
#endif
|
||||
_dialogueText.text = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
_dialogueText.text = LocalizationManager.Get(key, LocalizationTable.Dialogue);
|
||||
}
|
||||
}
|
||||
IsTyping = false;
|
||||
if (_continuePrompt != null) _continuePrompt.SetActive(true);
|
||||
}
|
||||
@@ -86,26 +155,116 @@ namespace BaseGames.Dialogue
|
||||
/// <summary>隐藏对话框面板。</summary>
|
||||
public void Hide()
|
||||
{
|
||||
_voiceSource?.Stop();
|
||||
if (_rootPanel != null) _rootPanel.SetActive(false);
|
||||
}
|
||||
|
||||
// ── 布局辅助 ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 切换名称框的横向对齐方向:玩家说话时靠右,NPC 靠左。
|
||||
/// 修改 RectTransform 的 anchorMin.x / anchorMax.x / pivot.x,保持纵向不变。
|
||||
/// </summary>
|
||||
private void SetLayoutSide(bool isPlayer)
|
||||
{
|
||||
if (_speakerNamePanelRT == null) return;
|
||||
float x = isPlayer ? 1f : 0f;
|
||||
_speakerNamePanelRT.anchorMin = new Vector2(x, _speakerNamePanelRT.anchorMin.y);
|
||||
_speakerNamePanelRT.anchorMax = new Vector2(x, _speakerNamePanelRT.anchorMax.y);
|
||||
_speakerNamePanelRT.pivot = new Vector2(x, _speakerNamePanelRT.pivot.y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示玩家可选的分支选项列表。打字机效果结束后由 DialogueManager 调用。
|
||||
/// 使用对象池(零 GC):池不足时动态扩容;点击后回调 onSelected(index)。
|
||||
/// 若 _choicesContainer 或 _choiceButtonPrefab 未配置,则静默跳过(不影响流程)。
|
||||
/// </summary>
|
||||
public void ShowChoices(DialogueChoice[] choices, System.Action<int> onSelected)
|
||||
{
|
||||
if (_choicesContainer == null || _choiceButtonPrefab == null) return;
|
||||
|
||||
// 确保池中有足够按钮
|
||||
while (_choicePool.Count < choices.Length)
|
||||
{
|
||||
var go = Instantiate(_choiceButtonPrefab, _choicesContainer);
|
||||
var btn = go.GetComponent<Button>();
|
||||
var lbl = go.GetComponentInChildren<TMP_Text>();
|
||||
go.SetActive(false);
|
||||
_choicePool.Add((go, btn, lbl));
|
||||
}
|
||||
|
||||
// 激活前 N 个并绑定数据
|
||||
for (int i = 0; i < choices.Length; i++)
|
||||
{
|
||||
int captured = i;
|
||||
var (go, btn, lbl) = _choicePool[i];
|
||||
go.SetActive(true);
|
||||
if (lbl != null)
|
||||
lbl.text = LocalizationManager.Get(choices[i].textKey ?? "", LocalizationTable.Dialogue);
|
||||
if (btn != null)
|
||||
{
|
||||
btn.onClick.RemoveAllListeners();
|
||||
btn.onClick.AddListener(() => onSelected?.Invoke(captured));
|
||||
}
|
||||
}
|
||||
|
||||
// 多余的池对象保持隐藏
|
||||
for (int i = choices.Length; i < _choicePool.Count; i++)
|
||||
_choicePool[i].go.SetActive(false);
|
||||
|
||||
// 有选项时隐藏继续提示,避免与选项按钮视觉重叠
|
||||
if (_continuePrompt != null) _continuePrompt.SetActive(false);
|
||||
_choicesContainer.gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
/// <summary>隐藏选项列表,将池中按钮全部停用(零 GC)。</summary>
|
||||
public void HideChoices()
|
||||
{
|
||||
if (_choicesContainer == null) return;
|
||||
foreach (var (go, btn, _) in _choicePool)
|
||||
{
|
||||
if (btn != null) btn.onClick.RemoveAllListeners();
|
||||
go.SetActive(false);
|
||||
}
|
||||
_choicesContainer.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
// ── 内部协程 ──────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator TypeLine(DialogueLine line)
|
||||
{
|
||||
IsTyping = true;
|
||||
float delay = line.typewriterDelay > 0f ? line.typewriterDelay : DefaultTypewriterDelay;
|
||||
string text = LocalizationManager.Get(line.textKey ?? "", "Dialogue");
|
||||
|
||||
// 性能:StringBuilder 避免每帧字符串分配(O(n²) → O(n))
|
||||
var sb = new StringBuilder(text.Length);
|
||||
// 复用缓存的 WaitForSecondsRealtime;仅当 delay 值变化时才重新 new
|
||||
if (_cachedTypeDelay == null || !Mathf.Approximately(_cachedTypeDelayValue, delay))
|
||||
{
|
||||
_cachedTypeDelay = new WaitForSecondsRealtime(delay);
|
||||
_cachedTypeDelayValue = delay;
|
||||
}
|
||||
|
||||
string text;
|
||||
if (string.IsNullOrEmpty(line.textKey))
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
Debug.LogWarning("[DialogueUI] 对话行 textKey 为空,打字机将显示空文本。请检查 DialogueSequenceSO 配置。");
|
||||
#endif
|
||||
text = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
text = LocalizationManager.Get(line.textKey, LocalizationTable.Dialogue);
|
||||
}
|
||||
|
||||
// 复用缓存 StringBuilder,避免每行 new 分配;TMP SetText(StringBuilder) 零分配
|
||||
_typingSB.Clear();
|
||||
if (_dialogueText != null) _dialogueText.text = "";
|
||||
|
||||
foreach (char c in text)
|
||||
{
|
||||
sb.Append(c);
|
||||
if (_dialogueText != null) _dialogueText.SetText(sb); // TMP SetText(StringBuilder) 零分配
|
||||
yield return new WaitForSecondsRealtime(delay);
|
||||
_typingSB.Append(c);
|
||||
if (_dialogueText != null) _dialogueText.SetText(_typingSB);
|
||||
yield return _cachedTypeDelay;
|
||||
}
|
||||
|
||||
IsTyping = false;
|
||||
|
||||
@@ -9,10 +9,39 @@ namespace BaseGames.Dialogue
|
||||
bool IsDialogueActive { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 启动对话序列。若已有对话在播放则忽略新请求。
|
||||
/// 当前正在播放对话的 NPC ID。无对话活跃时为 <see langword="null"/>。
|
||||
/// 供地图标记、HUD、分析埋点等外部系统主动查询,无需订阅事件。
|
||||
/// </summary>
|
||||
string CurrentNpcId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 每次对话序列(含分支链)全部播完后触发。
|
||||
/// 测试代码可订阅此事件等待对话结束,无需依赖 VoidEventChannelSO 资产。
|
||||
/// </summary>
|
||||
event System.Action OnDialogueEnded;
|
||||
|
||||
/// <summary>
|
||||
/// 启动对话序列。
|
||||
/// 若已有对话在播放:priority 高于当前对话时立即打断;否则进入队列(上限 8),超出丢弃。
|
||||
/// </summary>
|
||||
/// <param name="sequence">要播放的对话序列 SO。</param>
|
||||
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
|
||||
void StartDialogue(DialogueSequenceSO sequence, string npcId = "");
|
||||
/// <param name="priority">优先级(默认 0)。数值越大越优先;高优先级可打断低优先级对话。</param>
|
||||
void StartDialogue(DialogueSequenceSO sequence, string npcId = "", int priority = 0);
|
||||
|
||||
/// <summary>
|
||||
/// 启动对话序列,并在本次对话(含所有排队续播)全部结束时回调 <paramref name="onComplete"/>。
|
||||
/// 若 <paramref name="onComplete"/> 为 null,行为与 <see cref="StartDialogue(DialogueSequenceSO,string,int)"/> 完全相同。
|
||||
/// 回调仅触发一次;若对话被 <see cref="ForceEnd"/> 打断,回调同样会被调用(在 ForceEnd 末尾)。
|
||||
/// 适用场景:EventChain 触发对话后等待完成再执行下一动作、CutsceneModule 同步对话与演出。
|
||||
/// </summary>
|
||||
void StartDialogue(DialogueSequenceSO sequence, string npcId, int priority, System.Action onComplete);
|
||||
|
||||
/// <summary>
|
||||
/// 立即强制结束当前对话(含清空等待队列),恢复游戏输入。
|
||||
/// 适用于:场景切换、演出系统打断、死亡/传送等需要硬中断的场合。
|
||||
/// 若当前没有活跃对话,则无操作。
|
||||
/// </summary>
|
||||
void ForceEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.World;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
@@ -10,13 +11,46 @@ namespace BaseGames.Dialogue
|
||||
/// </summary>
|
||||
public class InteractableNPC : MonoBehaviour, IInteractable
|
||||
{
|
||||
[Header("NPC 基础")]
|
||||
[Tooltip("NPC 唯一 ID(如 \"NPC_Elder\")。对话结束时随 EVT_NpcDialogueCompleted 广播,用于驱动对话类任务目标进度。\n" +
|
||||
"需与 QuestSO 目标中 targetNpcId 保持一致。")]
|
||||
[SerializeField] protected string _npcId;
|
||||
[Tooltip("默认对话序列。无其他逻辑覆盖时播放此序列。NarrativeNPC/QuestGiver 子类通过 GetCurrentDialogue() 返回更精确的版本。")]
|
||||
[SerializeField] protected DialogueSequenceSO _defaultDialogue;
|
||||
[Tooltip("玩家进入此半径(单位:Unity 单位)后显示交互提示。建议 1.0–2.0。\n编辑器下在场景视图中以黄色圆圈可视化。")]
|
||||
[SerializeField] protected float _interactRadius = 1.5f;
|
||||
[Tooltip("交互提示本地化 Key(如 \"INTERACT_Talk\")。运行时通过 LocalizationManager 解析为实际文字。\n" +
|
||||
"留空时回退到内置字符串 \"对话\"。")]
|
||||
[SerializeField] protected string _interactPromptKey = "INTERACT_Talk";
|
||||
|
||||
[Header("范围检测")]
|
||||
[Tooltip("玩家所在的物理层。OnTriggerEnter2D / OnTriggerExit2D 仅响应属于此层的碰撞体,\n" +
|
||||
"实现 NPC 自包含的交互范围检测,无需外部 PlayerInteractionDetector 组件。\n" +
|
||||
"将玩家 GameObject 的 Layer 与此 Mask 对齐即可(推荐专用 \"Player\" 层)。\n" +
|
||||
"若留空(值为 0),则跳过层级过滤,任意碰撞体均可触发(调试用,不推荐上线)。")]
|
||||
[SerializeField] protected LayerMask _playerLayer;
|
||||
|
||||
// ── IInteractable ──────────────────────────────────────────────────
|
||||
public virtual bool CanInteract => true;
|
||||
public virtual string InteractPrompt => "对话";
|
||||
public virtual bool CanInteract => true;
|
||||
|
||||
public virtual string InteractPrompt
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_interactPromptKey))
|
||||
{
|
||||
var resolved = LocalizationManager.Get(_interactPromptKey, LocalizationTable.UI);
|
||||
if (!string.IsNullOrEmpty(resolved)) return resolved;
|
||||
}
|
||||
return "对话";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 范围进出通知(供子组件订阅,如 InteractionPromptController)──────
|
||||
/// <summary>玩家进入交互范围时触发。参数为玩家 Transform。</summary>
|
||||
public event System.Action<Transform> PlayerEnteredRange;
|
||||
/// <summary>玩家离开交互范围时触发。</summary>
|
||||
public event System.Action PlayerExitedRange;
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
@@ -27,11 +61,31 @@ namespace BaseGames.Dialogue
|
||||
PlayDialogue(dialogue, player);
|
||||
}
|
||||
|
||||
public virtual void OnPlayerEnterRange(Transform player) { }
|
||||
public virtual void OnPlayerExitRange() { }
|
||||
public virtual void OnPlayerEnterRange(Transform player) { PlayerEnteredRange?.Invoke(player); }
|
||||
public virtual void OnPlayerExitRange() { PlayerExitedRange?.Invoke(); }
|
||||
|
||||
// ── 自包含物理范围检测 ─────────────────────────────────────────────
|
||||
// 需在 NPC Prefab 上挂载 Collider2D(设为 IsTrigger),并将 Collider2D.size/radius
|
||||
// 配置为期望的交互半径。OnTriggerEnter2D / Exit2D 会自动过滤非玩家碰撞体。
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_playerLayer.value != 0 && ((1 << other.gameObject.layer) & _playerLayer.value) == 0) return;
|
||||
OnPlayerEnterRange(other.transform);
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
if (_playerLayer.value != 0 && ((1 << other.gameObject.layer) & _playerLayer.value) == 0) return;
|
||||
OnPlayerExitRange();
|
||||
}
|
||||
|
||||
// ── 子类覆盖点 ──────────────────────────────────────────────────────
|
||||
/// <summary>组件启用时调用。子类可覆盖且应调用 base.OnEnable()。</summary>
|
||||
protected virtual void OnEnable() { }
|
||||
|
||||
/// <summary>组件禁用时调用。子类可覆盖且应调用 base.OnDisable()。</summary>
|
||||
protected virtual void OnDisable() { }
|
||||
/// <summary>交互前置逻辑(如任务接收/完成判断)。子类覆盖此方法。</summary>
|
||||
protected virtual void Interact_Internal(Transform player) { }
|
||||
|
||||
@@ -51,5 +105,42 @@ namespace BaseGames.Dialogue
|
||||
}
|
||||
manager.StartDialogue(sequence, _npcId);
|
||||
}
|
||||
|
||||
// ── 编辑器辅助 ────────────────────────────────────────────────────
|
||||
// 注意:OnValidate 声明在 #if 外,确保子类在非编辑器构建中调用 base.OnValidate() 不会编译失败。
|
||||
|
||||
protected virtual void OnValidate()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (_playerLayer.value == 0)
|
||||
Debug.LogWarning(
|
||||
$"[InteractableNPC:{name}] _playerLayer 未设置(value=0)。" +
|
||||
"OnTriggerEnter2D 将响应所有层,建议在 Inspector 中指定玩家所在层。", this);
|
||||
|
||||
// 检测 _interactRadius 与 CircleCollider2D.radius 是否同步(仅输出一次,非逐帧)
|
||||
var circle = GetComponent<UnityEngine.CircleCollider2D>();
|
||||
if (circle != null && !Mathf.Approximately(circle.radius, _interactRadius))
|
||||
Debug.LogWarning(
|
||||
$"[InteractableNPC:{name}] _interactRadius({_interactRadius:F2}) 与 " +
|
||||
$"CircleCollider2D.radius({circle.radius:F2}) 不一致," +
|
||||
"交互范围视觉(Gizmos)与物理碰撞可能不匹配,请手动对齐。", this);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
protected virtual void OnDrawGizmosSelected()
|
||||
{
|
||||
// Collider2D 不一致时改绘红色(警告已在 OnValidate 中输出,此处不重复 LogWarning)
|
||||
bool mismatch = false;
|
||||
var circle = GetComponent<UnityEngine.CircleCollider2D>();
|
||||
if (circle != null && !Mathf.Approximately(circle.radius, _interactRadius))
|
||||
mismatch = true;
|
||||
|
||||
UnityEditor.Handles.color = mismatch
|
||||
? new Color(1f, 0.2f, 0.2f, 0.8f)
|
||||
: new Color(1f, 0.92f, 0.016f, 0.6f);
|
||||
UnityEditor.Handles.DrawWireDisc(transform.position, Vector3.forward, _interactRadius);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
@@ -5,34 +6,147 @@ using UnityEngine.UI;
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 交互提示 UI 控制器(架构 14_NarrativeModule §2)。
|
||||
/// 挂载在每个 IInteractable GameObject 的子节点(Prefab 实例),默认隐藏。
|
||||
/// 根据当前活跃输入设备自动切换图标(键盘/手柄)。
|
||||
/// 世界空间交互提示控制器(架构 14_NarrativeModule §2 升级版)。
|
||||
/// 挂在每个 InteractableNPC 子节点(Prefab 实例),默认隐藏。
|
||||
///
|
||||
/// 功能:
|
||||
/// • 自动订阅父级 InteractableNPC 的进/出范围事件,免手动调用 Show/Hide
|
||||
/// • TMP_Text 实时显示 InteractPrompt(如"接受任务"/"提交任务"),随任务状态动态刷新
|
||||
/// • 根据当前活跃输入设备自动切换按键图标(键盘/手柄)
|
||||
/// • 支持淡入/淡出动画
|
||||
/// </summary>
|
||||
public class InteractionPromptController : MonoBehaviour
|
||||
{
|
||||
[Header("UI 引用")]
|
||||
[Tooltip("整个提示根节点(包含图标和文字),控制显示/隐藏。")]
|
||||
[SerializeField] private GameObject _promptRoot;
|
||||
[Tooltip("按键图标 Image 组件(可选)。有输入设备时显示对应图标。")]
|
||||
[SerializeField] private Image _icon;
|
||||
[SerializeField] private Sprite _keyboardIcon;
|
||||
[SerializeField] private Sprite _gamepadIcon;
|
||||
[Tooltip("提示文字 TMP_Text 组件(可选)。自动显示 InteractableNPC.InteractPrompt 的当前值。")]
|
||||
[SerializeField] private TMP_Text _label;
|
||||
|
||||
[Header("按键图标")]
|
||||
[Tooltip("键盘/鼠标设备激活时使用的按键图标 Sprite。")]
|
||||
[SerializeField] private Sprite _keyboardIcon;
|
||||
[Tooltip("手柄设备激活时使用的按键图标 Sprite。")]
|
||||
[SerializeField] private Sprite _gamepadIcon;
|
||||
|
||||
[Header("位置与动画")]
|
||||
[Tooltip("相对于本组件 transform 的世界空间偏移。调整此值可控制气泡与 NPC 的相对位置。")]
|
||||
[SerializeField] private Vector3 _offset = new Vector3(0f, 1.8f, 0f);
|
||||
[Tooltip("是否随相机方向 Billboard 朝向(世界空间 Canvas 推荐开启)。")]
|
||||
[SerializeField] private bool _billboard = true;
|
||||
[Tooltip("淡入持续时间(秒)。0 = 立即显示,无动画。")]
|
||||
[SerializeField] [Min(0f)] private float _fadeInDuration = 0.12f;
|
||||
[Tooltip("淡出持续时间(秒)。0 = 立即隐藏,无动画。")]
|
||||
[SerializeField] [Min(0f)] private float _fadeOutDuration = 0.08f;
|
||||
|
||||
// ── 运行时状态 ────────────────────────────────────────────────────────
|
||||
private InteractableNPC _npc;
|
||||
private bool _visible;
|
||||
private float _alpha;
|
||||
private Camera _cam;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_promptRoot != null) _promptRoot.SetActive(false);
|
||||
// 自动连接父级 InteractableNPC 事件(无需手动调用 Show/Hide)
|
||||
_npc = GetComponentInParent<InteractableNPC>();
|
||||
if (_npc != null)
|
||||
{
|
||||
_npc.PlayerEnteredRange += OnPlayerEntered;
|
||||
_npc.PlayerExitedRange += OnPlayerExited;
|
||||
}
|
||||
|
||||
SetVisible(false, immediate: true);
|
||||
}
|
||||
|
||||
/// <summary>显示交互提示,根据输入设备选择图标。</summary>
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_npc != null)
|
||||
{
|
||||
_npc.PlayerEnteredRange -= OnPlayerEntered;
|
||||
_npc.PlayerExitedRange -= OnPlayerExited;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 完全隐藏且不需要淡出时,跳过所有计算
|
||||
if (!_visible && _alpha <= 0f) return;
|
||||
|
||||
// 位置偏移(世界空间气泡)
|
||||
if (_offset != Vector3.zero)
|
||||
transform.position = (_npc != null ? _npc.transform.position : transform.parent.position) + _offset;
|
||||
|
||||
// Billboard
|
||||
if (_billboard && _visible)
|
||||
{
|
||||
if (_cam == null) _cam = Camera.main;
|
||||
if (_cam != null)
|
||||
transform.forward = _cam.transform.forward;
|
||||
}
|
||||
|
||||
// 淡入/淡出
|
||||
if (_promptRoot == null) return;
|
||||
if (_visible && _alpha < 1f)
|
||||
{
|
||||
float speed = _fadeInDuration > 0f ? Time.deltaTime / _fadeInDuration : 1f;
|
||||
_alpha = Mathf.MoveTowards(_alpha, 1f, speed);
|
||||
ApplyAlpha(_alpha);
|
||||
}
|
||||
else if (!_visible && _alpha > 0f)
|
||||
{
|
||||
float speed = _fadeOutDuration > 0f ? Time.deltaTime / _fadeOutDuration : 1f;
|
||||
_alpha = Mathf.MoveTowards(_alpha, 0f, speed);
|
||||
ApplyAlpha(_alpha);
|
||||
if (_alpha <= 0f) _promptRoot.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开 API(兼容旧调用 / 脚本手动控制)────────────────────────────
|
||||
|
||||
/// <summary>手动显示提示。通常由 InteractableNPC 自动调用,无需手动触发。</summary>
|
||||
public void Show()
|
||||
{
|
||||
if (_promptRoot == null) return;
|
||||
_promptRoot.SetActive(true);
|
||||
if (_npc != null) _label.text = _npc.InteractPrompt;
|
||||
SetVisible(true, immediate: false);
|
||||
UpdateIcon();
|
||||
}
|
||||
|
||||
/// <summary>隐藏交互提示。</summary>
|
||||
public void Hide()
|
||||
/// <summary>手动隐藏提示。通常由 InteractableNPC 自动调用,无需手动触发。</summary>
|
||||
public void Hide() => SetVisible(false, immediate: false);
|
||||
|
||||
// ── 私有辅助 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void OnPlayerEntered(Transform player)
|
||||
{
|
||||
if (_promptRoot != null) _promptRoot.SetActive(false);
|
||||
// 刷新文字(每次进入都读取最新 InteractPrompt,确保任务状态变化后文字正确)
|
||||
if (_label != null && _npc != null)
|
||||
_label.text = _npc.InteractPrompt;
|
||||
SetVisible(true, immediate: false);
|
||||
UpdateIcon();
|
||||
}
|
||||
|
||||
private void OnPlayerExited() => SetVisible(false, immediate: false);
|
||||
|
||||
private void SetVisible(bool show, bool immediate)
|
||||
{
|
||||
_visible = show;
|
||||
if (immediate)
|
||||
{
|
||||
_alpha = show ? 1f : 0f;
|
||||
if (_promptRoot != null)
|
||||
{
|
||||
_promptRoot.SetActive(show);
|
||||
ApplyAlpha(_alpha);
|
||||
}
|
||||
}
|
||||
else if (show && _promptRoot != null)
|
||||
{
|
||||
_promptRoot.SetActive(true); // 淡出由 Update 结束时 SetActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateIcon()
|
||||
@@ -41,5 +155,12 @@ namespace BaseGames.Dialogue
|
||||
bool isGamepad = Gamepad.current != null && Gamepad.current.enabled;
|
||||
_icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon;
|
||||
}
|
||||
|
||||
private void ApplyAlpha(float a)
|
||||
{
|
||||
if (_icon != null) { var c = _icon.color; c.a = a; _icon.color = c; }
|
||||
if (_label != null) { var c = _label.color; c.a = a; _label.color = c; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.World;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -6,26 +7,41 @@ namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 条件对话 NPC(架构 14_NarrativeModule §7)。
|
||||
/// 扩展 InteractableNPC,根据 WorldStateRegistry 标志动态选择对话版本。
|
||||
/// 扩展 InteractableNPC,根据世界状态标志动态选择对话版本。
|
||||
/// 版本列表从高到低优先级排列;第一个满足条件的版本生效。
|
||||
///
|
||||
/// _worldState 可留空:留空时自动从 ServiceLocator 获取全局注册的 IWorldStateReader,
|
||||
/// 便于无需在每个 NPC Prefab 上手动拖入 WorldStateRegistry 资产。
|
||||
/// </summary>
|
||||
public class NarrativeNPC : InteractableNPC
|
||||
{
|
||||
[Header("台词版本集(从高到低优先级排列)")]
|
||||
[Tooltip("条件对话版本列表。运行时从上到下检查,第一个满足条件的版本被播放。\n" +
|
||||
"版本之间的优先级由列表顺序决定——请将最具体的条件放在最上方。")]
|
||||
[SerializeField] private DialogueVersion[] _dialogueVersions;
|
||||
[SerializeField] private DialogueSequenceSO _fallbackDialogue; // 无条件满足时的兜底台词
|
||||
[SerializeField] private WorldStateRegistry _worldState; // SO 注入
|
||||
[Tooltip("所有版本均不满足条件时的兜底对话。务必配置,否则运行时会输出 LogWarning 且 NPC 无对话。")]
|
||||
[SerializeField] private DialogueSequenceSO _fallbackDialogue;
|
||||
[Tooltip("世界状态 SO(可选)。留空时自动从 ServiceLocator 获取全局 IWorldStateReader。\n" +
|
||||
"通常同场景下多个 NPC 共用同一个 WorldStateRegistry;\n" +
|
||||
"若全局已通过 ServiceLocator 注册,可不在此处手动指定。")]
|
||||
[SerializeField] private WorldStateRegistry _worldState;
|
||||
|
||||
protected override DialogueSequenceSO GetCurrentDialogue()
|
||||
{
|
||||
IWorldStateReader reader = _worldState
|
||||
?? ServiceLocator.GetOrDefault<IWorldStateReader>();
|
||||
|
||||
if (_dialogueVersions == null) return _fallbackDialogue;
|
||||
|
||||
foreach (var version in _dialogueVersions)
|
||||
{
|
||||
if (version != null && version.CheckConditions(_worldState))
|
||||
if (version != null && version.CheckConditions(reader))
|
||||
return version.dialogue;
|
||||
}
|
||||
|
||||
if (_fallbackDialogue == null)
|
||||
Debug.LogWarning($"[NarrativeNPC] '{name}' 没有版本满足当前条件,且未配置兜底对话 (_fallbackDialogue)。", gameObject);
|
||||
|
||||
return _fallbackDialogue;
|
||||
}
|
||||
}
|
||||
@@ -40,26 +56,32 @@ namespace BaseGames.Dialogue
|
||||
{
|
||||
[Tooltip("编辑器显示名,如'森林 Boss 击败后'")]
|
||||
public string versionLabel;
|
||||
[Tooltip("此版本对应的对话序列 SO。条件满足时播放。留空时等同于跳过此版本。")]
|
||||
public DialogueSequenceSO dialogue;
|
||||
|
||||
[Tooltip("全部满足才激活此版本(AND 关系)")]
|
||||
[WorldStateFlag]
|
||||
public string[] requiredFlags;
|
||||
|
||||
[Tooltip("有任意一个 = 此版本不激活(NOT 关系)")]
|
||||
[WorldStateFlag]
|
||||
public string[] blockedByFlags;
|
||||
|
||||
/// <summary>检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。</summary>
|
||||
public bool CheckConditions(WorldStateRegistry registry)
|
||||
/// <summary>
|
||||
/// 检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。
|
||||
/// reader 为 null 时直接返回 false(无法判断,视为条件不满足)。
|
||||
/// </summary>
|
||||
public bool CheckConditions(IWorldStateReader reader)
|
||||
{
|
||||
if (registry == null) return false;
|
||||
if (reader == null) return false;
|
||||
|
||||
if (requiredFlags != null)
|
||||
foreach (var f in requiredFlags)
|
||||
if (!registry.HasFlag(f)) return false;
|
||||
if (!reader.HasFlag(f)) return false;
|
||||
|
||||
if (blockedByFlags != null)
|
||||
foreach (var f in blockedByFlags)
|
||||
if (registry.HasFlag(f)) return false;
|
||||
if (reader.HasFlag(f)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
105
Assets/_Game/Scripts/Dialogue/NpcSO.cs
Normal file
105
Assets/_Game/Scripts/Dialogue/NpcSO.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// NPC 元数据资产(架构 14_NarrativeModule §2)。
|
||||
/// 将 NPC 的唯一 ID、本地化名称 Key、头像、好感度上限集中在一处管理。
|
||||
///
|
||||
/// 关联:
|
||||
/// • <see cref="InteractableNPC"/> 通过 _npcId 字段与此 SO 对应。
|
||||
/// • <see cref="DialogueActorSO"/> 管理对话 UI 侧头像/颜色(二者可共享同一 Sprite,或独立维护)。
|
||||
/// • <see cref="BaseGames.Quest.QuestSO"/> 的 <c>giverNpc</c> 字段直接引用此 SO,避免手填字符串。
|
||||
///
|
||||
/// 资产路径:Assets/_Game/Data/NPC/NPC_{npcId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/NPC/NPC")]
|
||||
public class NpcSO : ScriptableObject, ILocalizableAsset
|
||||
{
|
||||
[Header("标识")]
|
||||
[Tooltip("NPC 唯一 ID,如 \"NPC_Elder\"。需与 InteractableNPC._npcId 保持一致。")]
|
||||
public string npcId;
|
||||
|
||||
[Header("显示")]
|
||||
[Tooltip("本地化 Key,如 \"NPC_Elder_Name\"。通过 LocalizationManager 解析为实际名称。")]
|
||||
public string nameKey;
|
||||
[Tooltip("NPC 头像,用于地图、任务日志、DataHub 等 UI。")]
|
||||
public Sprite portrait;
|
||||
|
||||
[Header("好感度")]
|
||||
[Tooltip("该 NPC 的好感度上限(0 = 无上限)。\n" +
|
||||
"QuestManager.CompleteQuest 发放 affinityBonus 时,不超过此数值。\n" +
|
||||
"UI 侧可用此值绘制好感度进度条满格。")]
|
||||
[Min(0)] public int maxAffinity = 0;
|
||||
|
||||
[Header("本地化")]
|
||||
[Tooltip("nameKey 所在的本地化表名(默认 \"UI\")。\n" +
|
||||
"若 NPC 名称存储在非默认表(如 \"Character\"),在此修改后 NpcSOEditor 预览和跳转按钮将使用正确的表。")]
|
||||
public string localizationTable = "UI";
|
||||
|
||||
[Header("交互提示")]
|
||||
[Tooltip("与此 NPC 交互时显示的提示本地化 Key(如 \"INTERACT_Talk\")。\n" +
|
||||
"留空时 InteractableNPC 回退到内置字符串 \"对话\"。")]
|
||||
public string interactPromptKey = "INTERACT_Talk";
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// npcId → 资产路径,5 秒 TTL,跨所有 NpcSO.OnValidate 共用,O(1) 重复检测。
|
||||
private static System.Collections.Generic.Dictionary<string, string> s_npcIdToPath;
|
||||
private static double s_npcIdsCacheTime = -10.0;
|
||||
|
||||
private static System.Collections.Generic.Dictionary<string, string> GetNpcIdCache()
|
||||
{
|
||||
double now = UnityEditor.EditorApplication.timeSinceStartup;
|
||||
if (s_npcIdToPath != null && now - s_npcIdsCacheTime < 5.0)
|
||||
return s_npcIdToPath;
|
||||
|
||||
s_npcIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:NpcSO");
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||||
var npc = UnityEditor.AssetDatabase.LoadAssetAtPath<NpcSO>(path);
|
||||
if (npc != null && !string.IsNullOrEmpty(npc.npcId) && !s_npcIdToPath.ContainsKey(npc.npcId))
|
||||
s_npcIdToPath[npc.npcId] = path;
|
||||
}
|
||||
s_npcIdsCacheTime = now;
|
||||
return s_npcIdToPath;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
if (string.IsNullOrEmpty(localizationTable))
|
||||
localizationTable = "UI";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(npcId))
|
||||
{
|
||||
UnityEditor.EditorUtility.SetDirty(this);
|
||||
npcId = name;
|
||||
}
|
||||
|
||||
var cache = GetNpcIdCache();
|
||||
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
|
||||
if (!string.IsNullOrEmpty(myPath) &&
|
||||
cache.TryGetValue(npcId, out var existingPath) &&
|
||||
existingPath != myPath)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[NpcSO] npcId '{npcId}' 与 " +
|
||||
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||||
s_npcIdsCacheTime = -10.0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
public IEnumerable<LocalizationKeyRef> GetLocalizationKeys()
|
||||
{
|
||||
string table = string.IsNullOrEmpty(localizationTable) ? "UI" : localizationTable;
|
||||
if (!string.IsNullOrEmpty(nameKey))
|
||||
yield return new LocalizationKeyRef(nameKey, table, nameof(nameKey));
|
||||
if (!string.IsNullOrEmpty(interactPromptKey))
|
||||
yield return new LocalizationKeyRef(interactPromptKey, "UI", nameof(interactPromptKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Dialogue/NpcSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Dialogue/NpcSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a534ec2815a6bd4ebd50cf4b7bccf3e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
1274
Assets/_Game/Scripts/Editor/Addressables/AddressableManagerWindow.cs
Normal file
1274
Assets/_Game/Scripts/Editor/Addressables/AddressableManagerWindow.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: abd3b31b261435e4786f53b937c71742
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,527 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEditor.AddressableAssets;
|
||||
using UnityEditor.AddressableAssets.Settings;
|
||||
using UnityEngine;
|
||||
// 此文件已被 AddressableManagerWindow 取代。
|
||||
// 原有功能已整合到统一工具中,请使用:
|
||||
// BaseGames → Addressables → Addressables Manager(总入口)
|
||||
// BaseGames → Addressables → Rule Sync(直达规则校验 Tab)
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Addressable 规则同步窗口。
|
||||
///
|
||||
/// 功能:
|
||||
/// 1. 扫描所有已注册的 Addressable 资产
|
||||
/// 2. 根据 <see cref="AddressableRules"/> 中的规则计算期望分组与期望标签
|
||||
/// 3. 对比实际值,显示所有不符合规范的条目(分组错误 / 标签缺失 / 标签多余)
|
||||
/// 4. 一键自动修复全部问题
|
||||
/// 5. 导出 CSV 报告供存档或 Code Review
|
||||
///
|
||||
/// 菜单:BaseGames → Addressables → Rule Sync
|
||||
/// </summary>
|
||||
public class AddressableRuleSyncWindow : EditorWindow
|
||||
{
|
||||
// ── 内部数据结构 ───────────────────────────────────────────────────────
|
||||
|
||||
private enum IssueKind { None, WrongGroup, MissingLabel, ExtraLabel }
|
||||
|
||||
private class EntryReport
|
||||
{
|
||||
public string Address;
|
||||
public string AssetPath;
|
||||
public string CurrentGroup;
|
||||
public string ExpectedGroup; // null = 规则未覆盖,维持现状
|
||||
public string[] CurrentLabels;
|
||||
public string[] ExpectedLabels;
|
||||
public string[] MissingLabels; // 应有但没有(规则要求),红色错误
|
||||
public string[] ExtraLabels; // 规则不要求且在 KnownLabels 中(多余规则标签),红色错误
|
||||
public string[] UnknownLabels; // 规则不要求且不在 KnownLabels 中(自定义标签),黄色警告,不自动删除
|
||||
public bool GroupOk => ExpectedGroup == null || CurrentGroup == ExpectedGroup;
|
||||
public bool LabelsOk => MissingLabels.Length == 0 && ExtraLabels.Length == 0;
|
||||
public bool IsOk => GroupOk && LabelsOk;
|
||||
public bool HasWarnings => UnknownLabels.Length > 0;
|
||||
}
|
||||
|
||||
// ── 状态 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private List<EntryReport> _reports = new();
|
||||
private Vector2 _scrollPos;
|
||||
private bool _showOk = false;
|
||||
private bool _scanned = false;
|
||||
private string _searchFilter = "";
|
||||
|
||||
// ── 样式(惰性初始化)────────────────────────────────────────────────
|
||||
|
||||
private GUIStyle _okStyle;
|
||||
private GUIStyle _warnStyle;
|
||||
private GUIStyle _errorStyle;
|
||||
private GUIStyle _boldStyle;
|
||||
private GUIStyle _rowEven;
|
||||
private GUIStyle _rowOdd;
|
||||
private bool _stylesReady;
|
||||
|
||||
// ── 颜色 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly Color ColOk = new(0.20f, 0.78f, 0.35f, 1f);
|
||||
private static readonly Color ColWarn = new(0.95f, 0.75f, 0.10f, 1f);
|
||||
private static readonly Color ColError = new(0.90f, 0.25f, 0.20f, 1f);
|
||||
private static readonly Color ColRowEven = new(0.22f, 0.22f, 0.22f, 0.4f);
|
||||
|
||||
// ── 菜单入口 ──────────────────────────────────────────────────────────
|
||||
|
||||
[MenuItem("BaseGames/Addressables/Rule Sync", priority = 110)]
|
||||
public static void OpenWindow()
|
||||
{
|
||||
var win = GetWindow<AddressableRuleSyncWindow>("Addressable Rule Sync");
|
||||
win.minSize = new Vector2(1040, 540);
|
||||
win.Show();
|
||||
}
|
||||
|
||||
// ── GUI ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EnsureStyles();
|
||||
|
||||
if (AddressableAssetSettingsDefaultObject.Settings == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"Addressable Settings 未初始化。\n" +
|
||||
"请先执行 Window → Asset Management → Addressables → Groups → Create Addressables Settings。",
|
||||
MessageType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
DrawToolbar();
|
||||
DrawStats();
|
||||
DrawTable();
|
||||
DrawFooter();
|
||||
}
|
||||
|
||||
// ── 工具栏 ────────────────────────────────────────────────────────────
|
||||
|
||||
private void DrawToolbar()
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
|
||||
{
|
||||
if (GUILayout.Button("扫描", EditorStyles.toolbarButton, GUILayout.Width(80)))
|
||||
Scan();
|
||||
|
||||
if (GUILayout.Button("🔄 刷新", EditorStyles.toolbarButton, GUILayout.Width(60)))
|
||||
Scan();
|
||||
|
||||
GUILayout.Space(8);
|
||||
_showOk = GUILayout.Toggle(_showOk, "显示正常项", EditorStyles.toolbarButton, GUILayout.Width(80));
|
||||
GUILayout.Space(8);
|
||||
|
||||
EditorGUILayout.LabelField("搜索:", GUILayout.Width(42));
|
||||
_searchFilter = EditorGUILayout.TextField(_searchFilter, EditorStyles.toolbarSearchField,
|
||||
GUILayout.Width(200));
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
GUI.enabled = _scanned && _reports.Any(r => !r.IsOk);
|
||||
if (GUILayout.Button("✦ 修复所有问题", EditorStyles.toolbarButton, GUILayout.Width(120)))
|
||||
FixAll();
|
||||
GUI.enabled = _scanned;
|
||||
if (GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(80)))
|
||||
ExportCsv();
|
||||
GUI.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 统计行 ────────────────────────────────────────────────────────────
|
||||
|
||||
private void DrawStats()
|
||||
{
|
||||
if (!_scanned) return;
|
||||
|
||||
int total = _reports.Count;
|
||||
int ok = _reports.Count(r => r.IsOk);
|
||||
int issues = _reports.Count(r => !r.IsOk);
|
||||
int warnings = _reports.Count(r => r.IsOk && r.HasWarnings);
|
||||
int wrongGrp = _reports.Count(r => !r.GroupOk);
|
||||
int misLabel = _reports.Count(r => r.MissingLabels.Length > 0);
|
||||
int extLabel = _reports.Count(r => r.ExtraLabels.Length > 0);
|
||||
int unkLabel = _reports.Count(r => r.UnknownLabels.Length > 0);
|
||||
|
||||
EditorGUILayout.Space(2);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.Label($"共 {total} 条目", EditorStyles.miniLabel);
|
||||
GUILayout.Space(12);
|
||||
DrawColoredLabel($"✅ 正常 {ok}", ColOk);
|
||||
GUILayout.Space(12);
|
||||
DrawColoredLabel($"❌ 问题 {issues}", issues > 0 ? ColError : ColOk);
|
||||
GUILayout.Space(8);
|
||||
DrawColoredLabel($"⚠ 自定义标签 {unkLabel}", unkLabel > 0 ? ColWarn : ColOk);
|
||||
GUILayout.Space(20);
|
||||
GUILayout.Label($"分组错误 {wrongGrp} | 标签缺失 {misLabel} | 多余规则标签 {extLabel}",
|
||||
EditorStyles.miniLabel);
|
||||
GUILayout.FlexibleSpace();
|
||||
}
|
||||
EditorGUILayout.Space(2);
|
||||
}
|
||||
|
||||
// ── 主表格 ────────────────────────────────────────────────────────────
|
||||
|
||||
private void DrawTable()
|
||||
{
|
||||
// 表头
|
||||
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
|
||||
{
|
||||
GUILayout.Label("Address", _boldStyle, GUILayout.Width(200));
|
||||
GUILayout.Label("当前分组", _boldStyle, GUILayout.Width(120));
|
||||
GUILayout.Label("期望分组", _boldStyle, GUILayout.Width(120));
|
||||
GUILayout.Label("缺失标签", _boldStyle, GUILayout.Width(130));
|
||||
GUILayout.Label("多余规则标签", _boldStyle, GUILayout.Width(110));
|
||||
GUILayout.Label("自定义标签", _boldStyle, GUILayout.Width(110));
|
||||
GUILayout.Label("状态", _boldStyle, GUILayout.Width(80));
|
||||
}
|
||||
|
||||
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.ExpandHeight(true));
|
||||
|
||||
if (!_scanned)
|
||||
{
|
||||
EditorGUILayout.HelpBox("点击「扫描」按钮开始分析已注册的 Addressable 资产。", MessageType.Info);
|
||||
}
|
||||
else
|
||||
{
|
||||
var display = _reports
|
||||
.Where(r => _showOk || !r.IsOk)
|
||||
.Where(r => string.IsNullOrEmpty(_searchFilter)
|
||||
|| r.Address.IndexOf(_searchFilter, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
.ToList();
|
||||
|
||||
if (display.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
_showOk ? "没有匹配搜索条件的条目。" : "✅ 所有资产均符合规范!",
|
||||
MessageType.Info);
|
||||
}
|
||||
|
||||
for (int i = 0; i < display.Count; i++)
|
||||
DrawRow(display[i], i);
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void DrawRow(EntryReport r, int idx)
|
||||
{
|
||||
var bg = idx % 2 == 0 ? _rowEven : GUIStyle.none;
|
||||
using (new EditorGUILayout.HorizontalScope(bg, GUILayout.Height(20)))
|
||||
{
|
||||
// Address(点击可 Ping)
|
||||
if (GUILayout.Button(r.Address, EditorStyles.linkLabel, GUILayout.Width(200)))
|
||||
PingAsset(r.AssetPath);
|
||||
|
||||
// 当前分组
|
||||
var grpColor = r.GroupOk ? ColOk : ColError;
|
||||
DrawColoredLabel(r.CurrentGroup ?? "—", grpColor, GUILayout.Width(120));
|
||||
|
||||
// 期望分组
|
||||
var expGrpText = r.ExpectedGroup ?? "(规则未覆盖)";
|
||||
var expGrpColor = r.GroupOk ? ColOk : ColWarn;
|
||||
DrawColoredLabel(expGrpText, expGrpColor, GUILayout.Width(120));
|
||||
|
||||
// 缺失标签(红色,须补齐)
|
||||
var missingText = r.MissingLabels.Length > 0 ? string.Join(", ", r.MissingLabels) : "—";
|
||||
DrawColoredLabel(missingText, r.MissingLabels.Length > 0 ? ColError : ColOk, GUILayout.Width(130));
|
||||
|
||||
// 多余规则标签(红色,将被 FixEntry 移除)
|
||||
var extraText = r.ExtraLabels.Length > 0 ? string.Join(", ", r.ExtraLabels) : "—";
|
||||
DrawColoredLabel(extraText, r.ExtraLabels.Length > 0 ? ColError : ColOk, GUILayout.Width(110));
|
||||
|
||||
// 自定义标签(黄色警告,不会被自动删除,建议写入规范)
|
||||
var unknownText = r.UnknownLabels.Length > 0 ? string.Join(", ", r.UnknownLabels) : "—";
|
||||
DrawColoredLabel(unknownText, r.UnknownLabels.Length > 0 ? ColWarn : ColOk, GUILayout.Width(110));
|
||||
|
||||
// 状态 + 单条修复按钮
|
||||
if (r.IsOk)
|
||||
{
|
||||
var statusColor = r.HasWarnings ? ColWarn : ColOk;
|
||||
var statusText = r.HasWarnings ? "⚠ 自定义标签" : "✅ 正常";
|
||||
DrawColoredLabel(statusText, statusColor, GUILayout.Width(80));
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawColoredLabel("❌ 需修复", ColError, GUILayout.Width(60));
|
||||
if (GUILayout.Button("修复", EditorStyles.miniButton, GUILayout.Width(40)))
|
||||
FixEntry(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 底栏 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void DrawFooter()
|
||||
{
|
||||
EditorGUILayout.Space(4);
|
||||
EditorGUILayout.HelpBox(
|
||||
"规则来源:Docs/Standards/AddressablesLabelSpec.md §3 分组规则:AssetFolderSpec.md §8.1\n" +
|
||||
"「修复所有问题」仅修改已注册资产的分组/标签,不注册新资产,不删除自定义标签(黄色警告项)。\n" +
|
||||
"新增资产工作流:① Addressable Batch Tool → ⚡ 全量扫描 _Game/ → 注册所有 ② 返回此窗口 → 扫描 → 修复所有问题",
|
||||
MessageType.None);
|
||||
}
|
||||
|
||||
// ── 扫描逻辑 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void Scan()
|
||||
{
|
||||
_reports.Clear();
|
||||
var settings = AddressableAssetSettingsDefaultObject.Settings;
|
||||
if (settings == null) return;
|
||||
|
||||
foreach (var group in settings.groups)
|
||||
{
|
||||
if (group == null) continue;
|
||||
foreach (var entry in group.entries)
|
||||
{
|
||||
if (entry == null) continue;
|
||||
|
||||
var address = entry.address;
|
||||
var expectedGroup = AddressableRules.GetExpectedGroup(address);
|
||||
var expectedLbls = AddressableRules.GetExpectedLabels(address);
|
||||
var currentLbls = entry.labels.ToArray();
|
||||
|
||||
var missing = expectedLbls.Except(currentLbls, StringComparer.Ordinal).ToArray();
|
||||
|
||||
// 区分两类"多余标签":
|
||||
// extra = 规则已知标签(KnownLabels)中规则不要求的 → 红色,FixEntry 会移除
|
||||
// unknown = 不在 KnownLabels 中的自定义标签 → 黄色警告,FixEntry 保留,建议写入规范
|
||||
var notExpected = currentLbls.Except(expectedLbls, StringComparer.Ordinal);
|
||||
var extra = notExpected.Where(l => AddressableRules.KnownLabels.Contains(l)).ToArray();
|
||||
var unknown = notExpected.Where(l => !AddressableRules.KnownLabels.Contains(l)).ToArray();
|
||||
|
||||
_reports.Add(new EntryReport
|
||||
{
|
||||
Address = address,
|
||||
AssetPath = entry.AssetPath,
|
||||
CurrentGroup = group.name,
|
||||
ExpectedGroup = expectedGroup,
|
||||
CurrentLabels = currentLbls,
|
||||
ExpectedLabels = expectedLbls,
|
||||
MissingLabels = missing,
|
||||
ExtraLabels = extra,
|
||||
UnknownLabels = unknown,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 问题项排前面,仅有警告的次之,正常项排最后;同类按 Address 字母序
|
||||
_reports = _reports
|
||||
.OrderBy(r => r.IsOk ? (r.HasWarnings ? 1 : 2) : 0)
|
||||
.ThenBy(r => r.Address, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
_scanned = true;
|
||||
Repaint();
|
||||
|
||||
int issues = _reports.Count(r => !r.IsOk);
|
||||
int warnings = _reports.Count(r => r.IsOk && r.HasWarnings);
|
||||
Debug.Log($"[AddressableRuleSync] 扫描完成:{_reports.Count} 个条目," +
|
||||
$"{issues} 个需要修复,{warnings} 个含自定义标签警告。");
|
||||
}
|
||||
|
||||
// ── 修复逻辑 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void FixAll()
|
||||
{
|
||||
var issues = _reports.Where(r => !r.IsOk).ToList();
|
||||
if (issues.Count == 0) return;
|
||||
|
||||
int moveCount = issues.Count(r => !r.GroupOk);
|
||||
int addCount = issues.Sum(r => r.MissingLabels.Length);
|
||||
int removeCount = issues.Sum(r => r.ExtraLabels.Length);
|
||||
|
||||
// 干跑预览对话框
|
||||
bool confirmed = EditorUtility.DisplayDialog(
|
||||
"确认修复所有问题",
|
||||
$"将对 {issues.Count} 个条目执行以下操作:\n\n" +
|
||||
$" • 移动分组:{moveCount} 个\n" +
|
||||
$" • 添加标签:{addCount} 个\n" +
|
||||
$" • 移除多余规则标签:{removeCount} 个\n\n" +
|
||||
"⚠ 自定义标签(黄色警告项)不会被删除。\n" +
|
||||
"此操作不可撤销,请确认后继续。",
|
||||
"确认修复", "取消");
|
||||
if (!confirmed) return;
|
||||
|
||||
int fixedCount = 0;
|
||||
foreach (var r in issues)
|
||||
{
|
||||
if (FixEntry(r)) fixedCount++;
|
||||
}
|
||||
|
||||
SaveSettings();
|
||||
Scan(); // 修复后重新扫描以更新结果
|
||||
Debug.Log($"[AddressableRuleSync] 修复完成:共处理 {fixedCount} 个条目。");
|
||||
}
|
||||
|
||||
private bool FixEntry(EntryReport r)
|
||||
{
|
||||
var settings = AddressableAssetSettingsDefaultObject.Settings;
|
||||
if (settings == null) return false;
|
||||
|
||||
var entry = FindEntry(settings, r.Address);
|
||||
if (entry == null)
|
||||
{
|
||||
Debug.LogWarning($"[AddressableRuleSync] 找不到条目:{r.Address}");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
|
||||
// 修复分组
|
||||
if (!r.GroupOk && r.ExpectedGroup != null)
|
||||
{
|
||||
var targetGroup = GetOrCreateGroup(settings, r.ExpectedGroup);
|
||||
if (targetGroup != null && entry.parentGroup != targetGroup)
|
||||
{
|
||||
settings.MoveEntry(entry, targetGroup, false, false);
|
||||
r.CurrentGroup = r.ExpectedGroup;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加缺失标签
|
||||
foreach (var lbl in r.MissingLabels)
|
||||
{
|
||||
EnsureLabelExists(settings, lbl);
|
||||
entry.SetLabel(lbl, true, true);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 移除多余规则标签(ExtraLabels 只包含 KnownLabels 中规则不要求的标签;
|
||||
// UnknownLabels 是用户自定义标签,刻意保留,不做删除)
|
||||
foreach (var lbl in r.ExtraLabels)
|
||||
{
|
||||
entry.SetLabel(lbl, false, true);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ── 导出 CSV ──────────────────────────────────────────────────────────
|
||||
|
||||
private void ExportCsv()
|
||||
{
|
||||
if (_reports.Count == 0) return;
|
||||
|
||||
var path = EditorUtility.SaveFilePanel(
|
||||
"导出 Addressable Rule 报告", "", "AddressableRuleReport.csv", "csv");
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Address,CurrentGroup,ExpectedGroup,GroupOk,MissingLabels,ExtraLabels,Status");
|
||||
|
||||
foreach (var r in _reports)
|
||||
{
|
||||
var status = r.IsOk ? "OK" : "ISSUE";
|
||||
sb.AppendLine(
|
||||
$"\"{r.Address}\"," +
|
||||
$"\"{r.CurrentGroup}\"," +
|
||||
$"\"{r.ExpectedGroup ?? "(uncovered)"}\"," +
|
||||
$"{r.GroupOk}," +
|
||||
$"\"{string.Join(";", r.MissingLabels)}\"," +
|
||||
$"\"{string.Join(";", r.ExtraLabels)}\"," +
|
||||
$"{status}");
|
||||
}
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
Debug.Log($"[AddressableRuleSync] CSV 报告已导出:{path}");
|
||||
}
|
||||
|
||||
// ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static AddressableAssetEntry FindEntry(AddressableAssetSettings settings, string address)
|
||||
{
|
||||
foreach (var group in settings.groups)
|
||||
{
|
||||
if (group == null) continue;
|
||||
foreach (var e in group.entries)
|
||||
if (e != null && e.address == address) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings, string groupName)
|
||||
{
|
||||
var existing = settings.groups.FirstOrDefault(g => g != null && g.name == groupName);
|
||||
if (existing != null) return existing;
|
||||
|
||||
var template = settings.GroupTemplateObjects.FirstOrDefault()
|
||||
as AddressableAssetGroupTemplate;
|
||||
var newGroup = settings.CreateGroup(groupName, false, false, true,
|
||||
template != null
|
||||
? new List<AddressableAssetGroupSchema>(template.SchemaObjects)
|
||||
: null);
|
||||
|
||||
if (newGroup != null)
|
||||
Debug.Log($"[AddressableRuleSync] 已自动创建分组:{groupName}");
|
||||
|
||||
return newGroup ?? settings.DefaultGroup;
|
||||
}
|
||||
|
||||
private static void EnsureLabelExists(AddressableAssetSettings settings, string label)
|
||||
{
|
||||
var labels = settings.GetLabels();
|
||||
if (!labels.Contains(label))
|
||||
{
|
||||
settings.AddLabel(label, true);
|
||||
Debug.Log($"[AddressableRuleSync] 已创建标签:{label}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveSettings()
|
||||
{
|
||||
AssetDatabase.SaveAssets();
|
||||
AddressableAssetSettingsDefaultObject.Settings?.SetDirty(
|
||||
AddressableAssetSettings.ModificationEvent.EntryModified, null, true);
|
||||
}
|
||||
|
||||
private static void PingAsset(string assetPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetPath)) return;
|
||||
var obj = AssetDatabase.LoadMainAssetAtPath(assetPath);
|
||||
if (obj != null) EditorGUIUtility.PingObject(obj);
|
||||
}
|
||||
|
||||
// ── 样式初始化 ────────────────────────────────────────────────────────
|
||||
|
||||
private void EnsureStyles()
|
||||
{
|
||||
if (_stylesReady) return;
|
||||
|
||||
_boldStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 11 };
|
||||
_okStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = ColOk } };
|
||||
_warnStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = ColWarn } };
|
||||
_errorStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = ColError } };
|
||||
|
||||
_rowEven = new GUIStyle();
|
||||
_rowEven.normal.background = MakeTexture(1, 1, ColRowEven);
|
||||
|
||||
_stylesReady = true;
|
||||
}
|
||||
|
||||
private void DrawColoredLabel(string text, Color color, params GUILayoutOption[] options)
|
||||
{
|
||||
var prev = GUI.color;
|
||||
GUI.color = color;
|
||||
GUILayout.Label(text, EditorStyles.miniLabel, options);
|
||||
GUI.color = prev;
|
||||
}
|
||||
|
||||
private static Texture2D MakeTexture(int width, int height, Color color)
|
||||
{
|
||||
var tex = new Texture2D(width, height);
|
||||
tex.SetPixel(0, 0, color);
|
||||
tex.Apply();
|
||||
return tex;
|
||||
}
|
||||
}
|
||||
// 保留空类以避免 .meta 文件孤立。
|
||||
internal static class AddressableRuleSyncWindowStub { }
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ namespace BaseGames.Editor
|
||||
("SPL_", "Config"), // 法术配置 SO
|
||||
("ABL_", "Config"), // 能力配置 SO
|
||||
("MAP_", "Config"), // 地图数据 SO(AssetFolderSpec §4)
|
||||
("STR_", "Config"), // 流式加载配置 SO(StreamingBudgetConfigSO)
|
||||
("Config/", "Config"), // 路径前缀配置(AssetFolderSpec §8.2)
|
||||
// ── 音频(AUD_BGM_ / AUD_SFX_ 必须在通配 AUD_ 之前)─────────────
|
||||
("AUD_BGM_", "Audio_Music"), // BGM 流式音频
|
||||
@@ -79,6 +80,8 @@ namespace BaseGames.Editor
|
||||
{ AddressKeys.PrefabUIFloatingDmgText, new[] { AddressKeys.Labels.Poolable, AddressKeys.Labels.Preload } },
|
||||
// FootstepCatalog 是首帧必须可用的配置
|
||||
{ AddressKeys.DataFootstepCatalog, new[] { AddressKeys.Labels.Config, AddressKeys.Labels.Preload } },
|
||||
// 流式加载预算配置,运行时初始化前必须可用
|
||||
{ AddressKeys.DataStreamingBudgetConfig, new[] { AddressKeys.Labels.Config, AddressKeys.Labels.Preload } },
|
||||
};
|
||||
|
||||
// ── 前缀 → 标签列表 ─────────────────────────────────────────────────────
|
||||
@@ -105,6 +108,7 @@ namespace BaseGames.Editor
|
||||
// ── 配置数据 ─────────────────────────────────────────────────────
|
||||
("CHM_", new[] { AddressKeys.Labels.Charms }),
|
||||
("MAP_", new[] { AddressKeys.Labels.Config }), // 地图数据 SO 为动态加载配置
|
||||
("STR_", new[] { AddressKeys.Labels.Config }), // 流式加载配置 SO(StreamingBudgetConfigSO)
|
||||
("Config/", new[] { AddressKeys.Labels.Config }),
|
||||
// ── 技能 / 法术 / 能力 / 世界物件 / 持久化:无批量加载需求,不加 Label ──
|
||||
("SKL_", Array.Empty<string>()),
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"BaseGames.Player",
|
||||
"BaseGames.Player.States",
|
||||
"BaseGames.Enemies",
|
||||
"BaseGames.Enemies.Navigation",
|
||||
"BaseGames.Camera",
|
||||
"BaseGames.World",
|
||||
"BaseGames.UI",
|
||||
@@ -30,8 +31,11 @@
|
||||
"BaseGames.Parry",
|
||||
"BaseGames.Skills",
|
||||
"BaseGames.World.Map",
|
||||
"BaseGames.World.Streaming",
|
||||
"BaseGames.EventChain",
|
||||
"BaseGames.VFX"
|
||||
"BaseGames.VFX",
|
||||
"BaseGames.Localization",
|
||||
"Unity.InputSystem"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
|
||||
@@ -8,6 +8,7 @@ using UnityEngine.UIElements;
|
||||
using BaseGames.Boss;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
using BaseGames.Equipment;
|
||||
using BaseGames.Input;
|
||||
using BaseGames.Parry;
|
||||
@@ -56,13 +57,24 @@ namespace BaseGames.Editor
|
||||
private List<(string label, bool exists)> _bossSOStatus = new();
|
||||
private double _lastRefreshTime;
|
||||
|
||||
// 小怪类型选择
|
||||
private int _enemyTypeIndex = 0;
|
||||
private static readonly string[] EnemyTypeLabels = { "普通(近战)", "远程", "飞行" };
|
||||
// 小怪类型选择 — 具体敌人类型
|
||||
private int _enemyTypeIndex = 0;
|
||||
private static readonly (string id, string name)[] EnemyTypes =
|
||||
{
|
||||
("E001", "草蛭"),
|
||||
("E002", "簧蛭"),
|
||||
("E003", "幼蛭"),
|
||||
("E004", "蛭母"),
|
||||
("E005", "肥蛭"),
|
||||
("E006", "讙"),
|
||||
};
|
||||
|
||||
// 动态内容区(类型切换时重建)
|
||||
private VisualElement _enemyContentArea;
|
||||
|
||||
// Boss 命名字段
|
||||
private string _bossId = "NewBoss";
|
||||
private string _enemyId = "NewEnemy";
|
||||
private string _bossId = "NewBoss"; // kept for legacy SkillSequenceSO queries if any
|
||||
private string _enemyId = "E001"; // kept for legacy status calls if any
|
||||
private string _playerId = "Player";
|
||||
|
||||
// SO 状态面板(按标签页缓存)
|
||||
@@ -202,59 +214,88 @@ namespace BaseGames.Editor
|
||||
_enemyStatusPanel = new VisualElement();
|
||||
root.Add(_enemyStatusPanel);
|
||||
|
||||
root.Add(MakeSectionHeader("▶ 敌人类型选择"));
|
||||
root.Add(MakeSectionHeader("▶ 敌人类型"));
|
||||
root.Add(MakeHelpBox("选择要创建的具体敌人类型,对应 SO 工厂与场景放置按钮会自动切换。"));
|
||||
|
||||
var typeRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginBottom = 4 } };
|
||||
for (int i = 0; i < EnemyTypeLabels.Length; i++)
|
||||
var typeRow = new VisualElement();
|
||||
typeRow.style.flexDirection = FlexDirection.Row;
|
||||
typeRow.style.flexWrap = Wrap.Wrap;
|
||||
typeRow.style.marginBottom = 4;
|
||||
|
||||
for (int i = 0; i < EnemyTypes.Length; i++)
|
||||
{
|
||||
int captured = i;
|
||||
var (id, name) = EnemyTypes[i];
|
||||
var btn = new Button(() =>
|
||||
{
|
||||
_enemyTypeIndex = captured;
|
||||
// 高亮激活按钮(简单刷新所有同类按钮样式)
|
||||
RefreshEnemyTypeButtons(root);
|
||||
RefreshEnemyTypeButtons(typeRow);
|
||||
RefreshEnemyTabContent(_enemyContentArea);
|
||||
RefreshSOStatus();
|
||||
})
|
||||
{ text = EnemyTypeLabels[i] };
|
||||
{ text = $"{id} {name}" };
|
||||
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);
|
||||
_enemyContentArea = new VisualElement();
|
||||
root.Add(_enemyContentArea);
|
||||
RefreshEnemyTabContent(_enemyContentArea);
|
||||
|
||||
root.Add(MakeSeparator());
|
||||
root.Add(MakeSectionHeader("▶ 专项编辑器"));
|
||||
|
||||
var jumpGroup = MakeActionGroup();
|
||||
jumpGroup.Add(MakeJumpButton("Data Hub(敌人数据)", DataHubWindow.Open));
|
||||
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
|
||||
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
|
||||
root.Add(jumpGroup);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private void RefreshEnemyTabContent(VisualElement container)
|
||||
{
|
||||
if (container == null) return;
|
||||
container.Clear();
|
||||
|
||||
var (id, name) = EnemyTypes[_enemyTypeIndex];
|
||||
string dir = $"{DataRoot}/Enemies/{id}";
|
||||
string ablDir = $"{dir}/Abilities";
|
||||
|
||||
container.Add(MakeSectionHeader($"▶ SO 资产工厂({id} {name})"));
|
||||
container.Add(MakeHelpBox($"统计 SO 路径:{dir}\n能力配置:{ablDir}"));
|
||||
|
||||
var factory = MakeActionGroup();
|
||||
factory.Add(MakeFactoryButton($"ENM_{id}_Stats.asset", () => { CreateEnemyStatsSO(id); RefreshSOStatus(); }));
|
||||
factory.Add(MakeFactoryButton($"ENM_{id}_AnimConfig.asset", () => { CreateEnemyAnimConfigSO(id); RefreshSOStatus(); }));
|
||||
foreach (var (ablName, ablId) in GetEnemyAbilityDefs(id))
|
||||
{
|
||||
string capturedName = ablName;
|
||||
string capturedId = ablId;
|
||||
factory.Add(MakeFactoryButton($"ABL_{id}_{capturedName}.asset",
|
||||
() => { CreateEnemyAbilitySO(id, capturedName, capturedId); RefreshSOStatus(); }));
|
||||
}
|
||||
container.Add(factory);
|
||||
|
||||
var createAllBtn = new Button(() => { CreateAllEnemySOs(id); RefreshSOStatus(); })
|
||||
{ text = $"★ 一键创建全部 {id} SO" };
|
||||
createAllBtn.AddToClassList("wizard-create-all-btn");
|
||||
container.Add(createAllBtn);
|
||||
|
||||
container.Add(MakeSeparator());
|
||||
container.Add(MakeSectionHeader("▶ 场景搭建"));
|
||||
container.Add(MakeHelpBox("在当前活动场景中放置完整组件树并自动绑定已有 SO。"));
|
||||
|
||||
var sceneGroup = MakeActionGroup();
|
||||
string sceneLabel = $"放置 {id} {name} 到场景";
|
||||
sceneGroup.Add(MakeSceneButton(sceneLabel, () => PlaceSpecificEnemy(id)));
|
||||
container.Add(sceneGroup);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Boss 标签页
|
||||
// Boss 标签页(嘲风专属)
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
private VisualElement BuildBossTab()
|
||||
@@ -265,27 +306,30 @@ namespace BaseGames.Editor
|
||||
_bossStatusPanel = new VisualElement();
|
||||
root.Add(_bossStatusPanel);
|
||||
|
||||
root.Add(MakeSectionHeader("▶ SO 资产工厂"));
|
||||
root.Add(MakeHelpBox("每个 Boss 独立目录:Assets/_Game/Data/Enemies/<BossId>/"));
|
||||
|
||||
var idRow = MakeLabeledTextField("Boss ID", _bossId, v => _bossId = v);
|
||||
root.Add(idRow);
|
||||
root.Add(MakeSectionHeader("▶ SO 资产工厂(嘲风 ChaoFeng)"));
|
||||
root.Add(MakeHelpBox("路径:Assets/_Game/Data/Enemies/ChaoFeng/\n能力配置:Assets/_Game/Data/Enemies/ChaoFeng/Abilities/"));
|
||||
|
||||
var factory = MakeActionGroup();
|
||||
factory.Add(MakeFactoryButton("EnemyStatsSO(Boss)", () => CreateBossStat()));
|
||||
factory.Add(MakeFactoryButton("LootTableSO(Boss)", () => CreateBossLoot()));
|
||||
factory.Add(MakeFactoryButton("AttackPatternSO × 3(阶段)", () => CreateBossAttackPatterns()));
|
||||
factory.Add(MakeFactoryButton("BossSkillSO × 3", () => CreateBossSkills()));
|
||||
factory.Add(MakeFactoryButton("SkillSequenceSO(Phase 1)", () => CreateBossSkillSequence(1)));
|
||||
factory.Add(MakeFactoryButton("SkillSequenceSO(Phase 2)", () => CreateBossSkillSequence(2)));
|
||||
factory.Add(MakeFactoryButton("DamageSourceSO × 3", () => CreateBossDamageSources()));
|
||||
factory.Add(MakeFactoryButton("ENM_ChaoFeng_Stats.asset", () => { CreateChaoFengStatsSO(); RefreshSOStatus(); }));
|
||||
factory.Add(MakeFactoryButton("ENM_ChaoFeng_AnimConfig.asset",() => { CreateChaoFengAnimConfigSO(); RefreshSOStatus(); }));
|
||||
factory.Add(MakeFactoryButton("ABL_ChaoFeng_Idle.asset", () => { CreateChaoFengSkillSO("Idle", "chaofeng_idle"); RefreshSOStatus(); }));
|
||||
factory.Add(MakeFactoryButton("ABL_ChaoFeng_Slam.asset", () => { CreateChaoFengSkillSO("Slam", "chaofeng_slam"); RefreshSOStatus(); }));
|
||||
factory.Add(MakeFactoryButton("ABL_ChaoFeng_Sweep.asset", () => { CreateChaoFengSkillSO("Sweep", "chaofeng_sweep"); RefreshSOStatus(); }));
|
||||
factory.Add(MakeFactoryButton("ABL_ChaoFeng_WindBlade.asset", () => { CreateChaoFengSkillSO("WindBlade", "chaofeng_windblade"); RefreshSOStatus(); }));
|
||||
factory.Add(MakeFactoryButton("ABL_ChaoFeng_Summon.asset", () => { CreateChaoFengSkillSO("Summon", "chaofeng_summon"); RefreshSOStatus(); }));
|
||||
root.Add(factory);
|
||||
|
||||
var createAllBtn = new Button(() => { CreateAllChaoFengSOs(); RefreshSOStatus(); })
|
||||
{ text = "★ 一键创建全部 ChaoFeng SO" };
|
||||
createAllBtn.AddToClassList("wizard-create-all-btn");
|
||||
root.Add(createAllBtn);
|
||||
|
||||
root.Add(MakeSeparator());
|
||||
root.Add(MakeSectionHeader("▶ 场景搭建"));
|
||||
root.Add(MakeHelpBox("放置嘲风完整组件树(ChaoFengBoss + 浮空控制器 + 击倒计数 + Phase1 HitBox × 4 + 炮口 × 3)。"));
|
||||
|
||||
var sceneGroup = MakeActionGroup();
|
||||
sceneGroup.Add(MakeSceneButton("放置 Boss 到场景", SceneObjectPlacerTool.PlaceBossEnemy));
|
||||
sceneGroup.Add(MakeSceneButton("放置嘲风到场景并绑定 SO", SceneObjectPlacerTool.PlaceChaoFeng));
|
||||
root.Add(sceneGroup);
|
||||
|
||||
root.Add(MakeSeparator());
|
||||
@@ -294,7 +338,7 @@ namespace BaseGames.Editor
|
||||
var jumpGroup = MakeActionGroup();
|
||||
jumpGroup.Add(MakeJumpButton("Boss 技能序列查看器", BossSkillSequenceWindow.OpenWindow));
|
||||
jumpGroup.Add(MakeJumpButton("Data Hub(Boss技能)", DataHubWindow.Open));
|
||||
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
|
||||
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
|
||||
root.Add(jumpGroup);
|
||||
|
||||
return root;
|
||||
@@ -507,96 +551,113 @@ namespace BaseGames.Editor
|
||||
EditorUtility.DisplayDialog("指定完成", msg, "确定");
|
||||
}
|
||||
|
||||
// ── SO 资产工厂:小怪 ────────────────────────────────────────────────
|
||||
// ── SO 资产工厂:小怪(按类型) ───────────────────────────────────────────
|
||||
|
||||
private void CreateEnemyStat()
|
||||
/// <summary>返回指定敌人类型的 (abilityFileName, abilityId) 定义列表。</summary>
|
||||
private static (string ablName, string ablId)[] GetEnemyAbilityDefs(string enemyId) => enemyId switch
|
||||
{
|
||||
string dir = $"{DataRoot}/Enemies/{_enemyId}";
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<EnemyStatsSO>(dir, $"ENM_{_enemyId}_Stats");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
"E001" => new[] { ("Alert", "e001_alert"), ("Chase", "e001_chase") },
|
||||
"E002" => new[] { ("Strike", "e002_strike") },
|
||||
"E003" => new[] { ("Fall", "e003_fall") },
|
||||
"E004" => new[] { ("Bite", "e004_bite"), ("Slam", "e004_slam"), ("Acid", "e004_acid"),
|
||||
("Charge", "e004_charge"), ("Chase", "e004_chase") },
|
||||
"E005" => new[] { ("Bite", "e005_bite"), ("Acid", "e005_acid") },
|
||||
"E006" => new[] { ("Leap", "e006_leap"), ("Chase", "e006_chase") },
|
||||
_ => System.Array.Empty<(string, string)>(),
|
||||
};
|
||||
|
||||
private static void CreateEnemyStatsSO(string id)
|
||||
{
|
||||
string dir = $"Assets/_Game/Data/Enemies/{id}";
|
||||
EditorScaffoldUtils.CreateSOAsset<EnemyStatsSO>(dir, $"ENM_{id}_Stats");
|
||||
}
|
||||
|
||||
private void CreateLootTable()
|
||||
private static void CreateEnemyAnimConfigSO(string id)
|
||||
{
|
||||
string dir = $"{DataRoot}/Enemies/{_enemyId}";
|
||||
var asset = EditorScaffoldUtils.CreateSOAsset<LootTableSO>(dir, $"ENM_{_enemyId}_Loot");
|
||||
if (asset != null) RefreshSOStatus();
|
||||
string dir = $"Assets/_Game/Data/Enemies/{id}";
|
||||
EditorScaffoldUtils.CreateSOAsset<EnemyAnimationConfigSO>(dir, $"ENM_{id}_AnimConfig");
|
||||
}
|
||||
|
||||
private void CreateEnemyAttackPatterns()
|
||||
private static void CreateEnemyAbilitySO(string enemyId, string ablName, string ablId)
|
||||
{
|
||||
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}/Enemies/{_bossId}";
|
||||
EditorScaffoldUtils.CreateSOAsset<EnemyStatsSO>(dir, $"ENM_{_bossId}_Stats");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateBossLoot()
|
||||
{
|
||||
string dir = $"{DataRoot}/Enemies/{_bossId}";
|
||||
EditorScaffoldUtils.CreateSOAsset<LootTableSO>(dir, $"ENM_{_bossId}_Loot");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateBossAttackPatterns()
|
||||
{
|
||||
string dir = $"{DataRoot}/Enemies/{_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}/Enemies/{_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}/Enemies/{_bossId}/Skills";
|
||||
EditorScaffoldUtils.CreateSOAsset<SkillSequenceSO>(dir, $"SKL_{_bossId}_Phase{phase}_Sequence");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
private void CreateBossDamageSources()
|
||||
{
|
||||
string dir = $"{DataRoot}/Enemies/{_bossId}/DamageSources";
|
||||
foreach (var label in new[] { "Slam", "Sweep", "Projectile" })
|
||||
EditorScaffoldUtils.CreateSOAsset<DamageSourceSO>(dir, $"ENM_{_bossId}_DS_{label}");
|
||||
RefreshSOStatus();
|
||||
}
|
||||
|
||||
// ── 场景搭建 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void PlaceSelectedEnemyType()
|
||||
{
|
||||
switch (_enemyTypeIndex)
|
||||
string dir = $"Assets/_Game/Data/Enemies/{enemyId}/Abilities";
|
||||
string name = $"ABL_{enemyId}_{ablName}";
|
||||
var so = EditorScaffoldUtils.CreateSOAsset<EnemyAbilitySO>(dir, name);
|
||||
// Set abilityId on newly-created SO (skip if already existed = null returned)
|
||||
if (so != null)
|
||||
{
|
||||
case 0: SceneObjectPlacerTool.PlaceEnemy(); break;
|
||||
case 1: SceneObjectPlacerTool.PlaceEnemy(); break; // 复用,类型通过 SO 区分
|
||||
case 2: SceneObjectPlacerTool.PlaceEnemy(); break;
|
||||
so.abilityId = ablId;
|
||||
EditorUtility.SetDirty(so);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateAllEnemySOs(string id)
|
||||
{
|
||||
CreateEnemyStatsSO(id);
|
||||
CreateEnemyAnimConfigSO(id);
|
||||
foreach (var (ablName, ablId) in GetEnemyAbilityDefs(id))
|
||||
CreateEnemyAbilitySO(id, ablName, ablId);
|
||||
AssetDatabase.SaveAssets();
|
||||
EditorUtility.DisplayDialog("创建完成",
|
||||
$"全部 {id} SO 已创建(已存在的跳过)。\n请放置到场景后检查组件绑定。", "确定");
|
||||
}
|
||||
|
||||
private static void PlaceSpecificEnemy(string id)
|
||||
{
|
||||
switch (id)
|
||||
{
|
||||
case "E001": SceneObjectPlacerTool.PlaceE001_CaoZhi(); break;
|
||||
case "E002": SceneObjectPlacerTool.PlaceE002_HuangZhi(); break;
|
||||
case "E003": SceneObjectPlacerTool.PlaceE003_YouZhi_Enemy(); break;
|
||||
case "E004": SceneObjectPlacerTool.PlaceE004_ZhiMu_Enemy(); break;
|
||||
case "E005": SceneObjectPlacerTool.PlaceE005_FeiZhi_Enemy(); break;
|
||||
case "E006": SceneObjectPlacerTool.PlaceE006_Huan(); break;
|
||||
default: SceneObjectPlacerTool.PlaceEnemy(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── SO 资产工厂:嘲风 Boss ─────────────────────────────────────────────
|
||||
|
||||
private static void CreateChaoFengStatsSO()
|
||||
{
|
||||
string dir = "Assets/_Game/Data/Enemies/ChaoFeng";
|
||||
EditorScaffoldUtils.CreateSOAsset<EnemyStatsSO>(dir, "ENM_ChaoFeng_Stats");
|
||||
}
|
||||
|
||||
private static void CreateChaoFengAnimConfigSO()
|
||||
{
|
||||
string dir = "Assets/_Game/Data/Enemies/ChaoFeng";
|
||||
EditorScaffoldUtils.CreateSOAsset<EnemyAnimationConfigSO>(dir, "ENM_ChaoFeng_AnimConfig");
|
||||
}
|
||||
|
||||
private static void CreateChaoFengSkillSO(string skillName, string skillId)
|
||||
{
|
||||
string dir = "Assets/_Game/Data/Enemies/ChaoFeng/Abilities";
|
||||
string name = $"ABL_ChaoFeng_{skillName}";
|
||||
var so = EditorScaffoldUtils.CreateSOAsset<BossSkillSO>(dir, name);
|
||||
if (so != null)
|
||||
{
|
||||
EditorUtility.SetDirty(so);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateAllChaoFengSOs()
|
||||
{
|
||||
CreateChaoFengStatsSO();
|
||||
CreateChaoFengAnimConfigSO();
|
||||
foreach (var (n, id) in new[] { ("Idle","chaofeng_idle"), ("Slam","chaofeng_slam"),
|
||||
("Sweep","chaofeng_sweep"), ("WindBlade","chaofeng_windblade"),
|
||||
("Summon","chaofeng_summon") })
|
||||
CreateChaoFengSkillSO(n, id);
|
||||
AssetDatabase.SaveAssets();
|
||||
EditorUtility.DisplayDialog("创建完成",
|
||||
"全部嘲风 SO 已创建(已存在的跳过)。\n放置到场景后检查 BossSkillExecutor._skills 绑定。", "确定");
|
||||
}
|
||||
|
||||
// ── 场景搭建(已移至 RefreshEnemyTabContent 内的内联按钮) ─────────────
|
||||
|
||||
// ── SO 状态面板刷新 ───────────────────────────────────────────────────
|
||||
|
||||
private void RefreshSOStatus()
|
||||
@@ -636,16 +697,19 @@ namespace BaseGames.Editor
|
||||
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")),
|
||||
};
|
||||
var (id, name) = EnemyTypes[_enemyTypeIndex];
|
||||
string dir = $"{DataRoot}/Enemies/{id}";
|
||||
string ablDir = $"{dir}/Abilities";
|
||||
|
||||
_enemyStatusPanel.Add(MakeStatusGrid(checks));
|
||||
var items = new List<(string label, UnityEngine.Object asset)>
|
||||
{
|
||||
($"ENM_{id}_Stats", FindAtPath<EnemyStatsSO>($"{dir}/ENM_{id}_Stats.asset")),
|
||||
($"ENM_{id}_AnimConfig",FindAtPath<EnemyAnimationConfigSO>($"{dir}/ENM_{id}_AnimConfig.asset")),
|
||||
};
|
||||
foreach (var (ablName, _) in GetEnemyAbilityDefs(id))
|
||||
items.Add(($"ABL_{id}_{ablName}", FindAtPath<EnemyAbilitySO>($"{ablDir}/ABL_{id}_{ablName}.asset")));
|
||||
|
||||
_enemyStatusPanel.Add(MakeStatusGrid(items.ToArray()));
|
||||
}
|
||||
|
||||
private void BuildBossStatus()
|
||||
@@ -653,15 +717,17 @@ namespace BaseGames.Editor
|
||||
if (_bossStatusPanel == null) return;
|
||||
_bossStatusPanel.Clear();
|
||||
|
||||
string dir = $"{DataRoot}/Enemies/{_bossId}";
|
||||
const string dir = "Assets/_Game/Data/Enemies/ChaoFeng";
|
||||
const string ablDir = "Assets/_Game/Data/Enemies/ChaoFeng/Abilities";
|
||||
var checks = new (string label, UnityEngine.Object asset)[]
|
||||
{
|
||||
("EnemyStatsSO(Boss)", FindAtPath<EnemyStatsSO>($"{dir}/ENM_{_bossId}_Stats.asset")),
|
||||
("LootTableSO", FindAtPath<LootTableSO>($"{dir}/ENM_{_bossId}_Loot.asset")),
|
||||
("AttackPatternSO(Phase1)", FindAtPath<AttackPatternSO>($"{dir}/Patterns/ENM_{_bossId}_Pattern_Phase1.asset")),
|
||||
("BossSkillSO(≥1)", EditorScaffoldUtils.FindAllAssetsOfType<BossSkillSO>()
|
||||
.FirstOrDefault(s => s.name.StartsWith("SKL_" + _bossId, StringComparison.OrdinalIgnoreCase))),
|
||||
("SkillSequenceSO(Phase1)", FindAtPath<SkillSequenceSO>($"{dir}/Skills/SKL_{_bossId}_Phase1_Sequence.asset")),
|
||||
("ENM_ChaoFeng_Stats", FindAtPath<EnemyStatsSO>($"{dir}/ENM_ChaoFeng_Stats.asset")),
|
||||
("ENM_ChaoFeng_AnimConfig",FindAtPath<EnemyAnimationConfigSO>($"{dir}/ENM_ChaoFeng_AnimConfig.asset")),
|
||||
("ABL_ChaoFeng_Idle", FindAtPath<BossSkillSO>($"{ablDir}/ABL_ChaoFeng_Idle.asset")),
|
||||
("ABL_ChaoFeng_Slam", FindAtPath<BossSkillSO>($"{ablDir}/ABL_ChaoFeng_Slam.asset")),
|
||||
("ABL_ChaoFeng_Sweep", FindAtPath<BossSkillSO>($"{ablDir}/ABL_ChaoFeng_Sweep.asset")),
|
||||
("ABL_ChaoFeng_WindBlade", FindAtPath<BossSkillSO>($"{ablDir}/ABL_ChaoFeng_WindBlade.asset")),
|
||||
("ABL_ChaoFeng_Summon", FindAtPath<BossSkillSO>($"{ablDir}/ABL_ChaoFeng_Summon.asset")),
|
||||
};
|
||||
|
||||
_bossStatusPanel.Add(MakeStatusGrid(checks));
|
||||
@@ -782,11 +848,11 @@ namespace BaseGames.Editor
|
||||
return row;
|
||||
}
|
||||
|
||||
private void RefreshEnemyTypeButtons(VisualElement tabRoot)
|
||||
private void RefreshEnemyTypeButtons(VisualElement typeRow)
|
||||
{
|
||||
for (int i = 0; i < EnemyTypeLabels.Length; i++)
|
||||
for (int i = 0; i < EnemyTypes.Length; i++)
|
||||
{
|
||||
var btn = tabRoot.Q<Button>($"enemy-type-{i}");
|
||||
var btn = typeRow.Q<Button>($"enemy-type-{i}");
|
||||
btn?.EnableInClassList("type-btn--active", i == _enemyTypeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
8
Assets/_Game/Scripts/Editor/Dialogue.meta
Normal file
8
Assets/_Game/Scripts/Editor/Dialogue.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76d7c0ea7917c4444b0eede5ed06e14c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,584 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Dialogue;
|
||||
|
||||
namespace BaseGames.Editor.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 对话变体预览窗口。
|
||||
/// 给定一个 DialogueSequenceSO,模拟世界状态标志的开关组合,
|
||||
/// 实时显示各条件变体是否满足,并高亮胜出的变体。
|
||||
/// 菜单:BaseGames/Dialogue/Variant Preview
|
||||
/// </summary>
|
||||
public class DialogueVariantPreviewWindow : EditorWindow
|
||||
{
|
||||
private DialogueSequenceSO _target;
|
||||
private readonly HashSet<string> _enabledFlags = new(System.StringComparer.Ordinal);
|
||||
private readonly List<string> _allFlags = new();
|
||||
|
||||
private ObjectField _targetField;
|
||||
private VisualElement _flagContainer;
|
||||
private VisualElement _resultContainer;
|
||||
private VisualElement _matrixContainer;
|
||||
|
||||
private static readonly Color ColWin = new(0.20f, 0.75f, 0.35f, 1f);
|
||||
private static readonly Color ColFail = new(0.55f, 0.55f, 0.55f, 1f);
|
||||
private static readonly Color ColOverride = new(0.70f, 0.70f, 0.25f, 1f);
|
||||
private static readonly Color ColBlocked = new(0.85f, 0.35f, 0.30f, 1f);
|
||||
|
||||
[MenuItem("BaseGames/Dialogue/Variant Preview")]
|
||||
public static void Open()
|
||||
{
|
||||
var win = GetWindow<DialogueVariantPreviewWindow>("对话变体预览");
|
||||
win.minSize = new Vector2(480, 400);
|
||||
}
|
||||
|
||||
/// <summary>从外部打开并预填目标 SO。</summary>
|
||||
public static void OpenWith(DialogueSequenceSO target)
|
||||
{
|
||||
var win = GetWindow<DialogueVariantPreviewWindow>("对话变体预览");
|
||||
win.minSize = new Vector2(480, 400);
|
||||
win.SetTarget(target);
|
||||
}
|
||||
|
||||
private void CreateGUI()
|
||||
{
|
||||
_mockReader = new MockFlagReader(_enabledFlags);
|
||||
rootVisualElement.style.paddingLeft = 10;
|
||||
rootVisualElement.style.paddingRight = 10;
|
||||
rootVisualElement.style.paddingTop = 10;
|
||||
rootVisualElement.style.paddingBottom = 10;
|
||||
|
||||
// ── 标题栏 ──
|
||||
var header = new Label("对话变体预览工具");
|
||||
header.style.fontSize = 14;
|
||||
header.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
header.style.marginBottom = 8;
|
||||
rootVisualElement.Add(header);
|
||||
|
||||
var desc = new Label("在模拟的世界状态标志组合下,预览哪个条件变体会被选中。");
|
||||
desc.style.fontSize = 11;
|
||||
desc.style.opacity = 0.6f;
|
||||
desc.style.marginBottom = 10;
|
||||
rootVisualElement.Add(desc);
|
||||
|
||||
// ── 目标选择器 ──
|
||||
_targetField = new ObjectField("对话序列 SO")
|
||||
{
|
||||
objectType = typeof(DialogueSequenceSO),
|
||||
allowSceneObjects = false
|
||||
};
|
||||
_targetField.value = _target;
|
||||
_targetField.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
SetTarget(evt.newValue as DialogueSequenceSO);
|
||||
});
|
||||
rootVisualElement.Add(_targetField);
|
||||
|
||||
rootVisualElement.Add(MakeDivider());
|
||||
|
||||
// ── 标志模拟区 ──
|
||||
var flagHeader = new Label("模拟世界状态标志");
|
||||
flagHeader.style.fontSize = 12;
|
||||
flagHeader.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
flagHeader.style.marginBottom = 4;
|
||||
rootVisualElement.Add(flagHeader);
|
||||
|
||||
_flagContainer = new VisualElement();
|
||||
rootVisualElement.Add(_flagContainer);
|
||||
|
||||
rootVisualElement.Add(MakeDivider());
|
||||
|
||||
// ── 变体结果区 ──
|
||||
var resultHeader = new Label("变体求值结果");
|
||||
resultHeader.style.fontSize = 12;
|
||||
resultHeader.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
resultHeader.style.marginBottom = 4;
|
||||
rootVisualElement.Add(resultHeader);
|
||||
|
||||
var scrollView = new ScrollView(ScrollViewMode.Vertical);
|
||||
scrollView.style.flexGrow = 1;
|
||||
rootVisualElement.Add(scrollView);
|
||||
|
||||
_resultContainer = new VisualElement();
|
||||
scrollView.Add(_resultContainer);
|
||||
|
||||
rootVisualElement.Add(MakeDivider());
|
||||
|
||||
// ── 矩阵分析区 ──
|
||||
var matrixFoldout = new Foldout { text = "矩阵分析(所有标志组合 → 胜出变体)", value = false };
|
||||
matrixFoldout.style.marginTop = 4;
|
||||
rootVisualElement.Add(matrixFoldout);
|
||||
|
||||
_matrixContainer = new VisualElement();
|
||||
matrixFoldout.Add(_matrixContainer);
|
||||
|
||||
var matrixBtn = new Button(() => RebuildMatrix()) { text = "矩阵分析" };
|
||||
matrixBtn.style.marginBottom = 4;
|
||||
matrixFoldout.Add(matrixBtn);
|
||||
|
||||
var csvBtn = new Button(() => ExportMatrixCsv()) { text = "复制为 CSV" };
|
||||
csvBtn.style.marginBottom = 4;
|
||||
matrixFoldout.Add(csvBtn);
|
||||
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
private void SetTarget(DialogueSequenceSO target)
|
||||
{
|
||||
_target = target;
|
||||
_enabledFlags.Clear();
|
||||
if (_targetField != null && _targetField.value != target)
|
||||
_targetField.SetValueWithoutNotify(target);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
// ── 重建 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
RebuildFlagToggles();
|
||||
RebuildResults();
|
||||
}
|
||||
|
||||
private void RebuildFlagToggles()
|
||||
{
|
||||
if (_flagContainer == null) return;
|
||||
_flagContainer.Clear();
|
||||
_allFlags.Clear();
|
||||
|
||||
if (_target == null || _target.variants == null || _target.variants.Length == 0)
|
||||
{
|
||||
var empty = new Label(_target == null
|
||||
? "(请选择一个 DialogueSequenceSO)"
|
||||
: "(该序列无条件变体,无需模拟)");
|
||||
empty.style.opacity = 0.5f;
|
||||
empty.style.fontSize = 11;
|
||||
_flagContainer.Add(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集所有变体中涉及的 Flag
|
||||
var flagSet = new HashSet<string>(System.StringComparer.Ordinal);
|
||||
foreach (var v in _target.variants)
|
||||
{
|
||||
if (v.requiredFlags != null)
|
||||
foreach (var f in v.requiredFlags)
|
||||
if (!string.IsNullOrEmpty(f)) flagSet.Add(f);
|
||||
}
|
||||
_allFlags.AddRange(flagSet.OrderBy(x => x));
|
||||
|
||||
if (_allFlags.Count == 0)
|
||||
{
|
||||
var empty = new Label("(变体未使用任何 requiredFlags)");
|
||||
empty.style.opacity = 0.5f;
|
||||
empty.style.fontSize = 11;
|
||||
_flagContainer.Add(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// 全选 / 全不选 快速按钮
|
||||
var btnRow = new VisualElement();
|
||||
btnRow.style.flexDirection = FlexDirection.Row;
|
||||
btnRow.style.marginBottom = 4;
|
||||
|
||||
var btnAll = new Button(() =>
|
||||
{
|
||||
foreach (var f in _allFlags) _enabledFlags.Add(f);
|
||||
Rebuild();
|
||||
}) { text = "全选" };
|
||||
btnAll.style.fontSize = 10;
|
||||
btnAll.style.height = 18;
|
||||
btnRow.Add(btnAll);
|
||||
|
||||
var btnNone = new Button(() =>
|
||||
{
|
||||
_enabledFlags.Clear();
|
||||
Rebuild();
|
||||
}) { text = "全不选" };
|
||||
btnNone.style.fontSize = 10;
|
||||
btnNone.style.height = 18;
|
||||
btnRow.Add(btnNone);
|
||||
|
||||
_flagContainer.Add(btnRow);
|
||||
|
||||
// 每个 Flag 对应一个 Toggle
|
||||
foreach (var flag in _allFlags)
|
||||
{
|
||||
bool isOn = _enabledFlags.Contains(flag);
|
||||
var toggle = new Toggle(flag) { value = isOn };
|
||||
toggle.style.fontSize = 11;
|
||||
toggle.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
if (evt.newValue) _enabledFlags.Add(flag);
|
||||
else _enabledFlags.Remove(flag);
|
||||
RebuildResults();
|
||||
});
|
||||
_flagContainer.Add(toggle);
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildResults()
|
||||
{
|
||||
if (_resultContainer == null) return;
|
||||
_resultContainer.Clear();
|
||||
|
||||
if (_target == null)
|
||||
return;
|
||||
|
||||
if (_target.variants == null || _target.variants.Length == 0)
|
||||
{
|
||||
var msg = new Label("(序列无条件变体,直接使用本序列默认台词)");
|
||||
msg.style.opacity = 0.5f;
|
||||
msg.style.fontSize = 11;
|
||||
_resultContainer.Add(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
bool winnerFound = false;
|
||||
|
||||
for (int i = 0; i < _target.variants.Length; i++)
|
||||
{
|
||||
var variant = _target.variants[i];
|
||||
var row = BuildVariantRow(i, variant, winnerFound);
|
||||
_resultContainer.Add(row);
|
||||
|
||||
if (!winnerFound && EvaluateVariant(variant))
|
||||
winnerFound = true;
|
||||
}
|
||||
|
||||
// 若无变体胜出,提示将回退到本序列默认台词
|
||||
if (!winnerFound)
|
||||
{
|
||||
var fallback = new Label("↳ 无变体满足,将使用本序列默认台词(无变体覆盖)");
|
||||
fallback.style.fontSize = 11;
|
||||
fallback.style.opacity = 0.6f;
|
||||
fallback.style.marginTop = 4;
|
||||
_resultContainer.Add(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
private VisualElement BuildVariantRow(int index, DialogueSequenceSO.ConditionalVariant variant, bool higherWon)
|
||||
{
|
||||
bool condMet = EvaluateVariant(variant);
|
||||
bool isWinner = condMet && !higherWon;
|
||||
|
||||
var card = new VisualElement();
|
||||
card.style.borderLeftWidth = 3;
|
||||
card.style.paddingLeft = 8;
|
||||
card.style.paddingRight = 8;
|
||||
card.style.paddingTop = 5;
|
||||
card.style.paddingBottom = 5;
|
||||
card.style.marginBottom = 4;
|
||||
card.style.backgroundColor = new StyleColor(new Color(0.18f, 0.18f, 0.18f, 1f));
|
||||
|
||||
Color borderColor;
|
||||
string statusText;
|
||||
Color statusColor;
|
||||
|
||||
if (isWinner)
|
||||
{
|
||||
borderColor = ColWin;
|
||||
statusText = "✓ 胜出";
|
||||
statusColor = ColWin;
|
||||
}
|
||||
else if (condMet)
|
||||
{
|
||||
borderColor = ColOverride;
|
||||
statusText = "⏩ 被更高优先级覆盖";
|
||||
statusColor = ColOverride;
|
||||
}
|
||||
else
|
||||
{
|
||||
borderColor = ColFail;
|
||||
statusText = "✗ 条件不满足";
|
||||
statusColor = ColFail;
|
||||
}
|
||||
card.style.borderLeftColor = new StyleColor(borderColor);
|
||||
|
||||
// 标题行
|
||||
var titleRow = new VisualElement();
|
||||
titleRow.style.flexDirection = FlexDirection.Row;
|
||||
titleRow.style.alignItems = Align.Center;
|
||||
titleRow.style.marginBottom = 3;
|
||||
|
||||
var idxLabel = new Label($"变体 {index}");
|
||||
idxLabel.style.fontSize = 11;
|
||||
idxLabel.style.flexGrow = 1;
|
||||
idxLabel.style.unityFontStyleAndWeight = isWinner ? FontStyle.Bold : FontStyle.Normal;
|
||||
titleRow.Add(idxLabel);
|
||||
|
||||
var seqName = new Label(variant.sequence != null ? variant.sequence.name : "(未设置序列)");
|
||||
seqName.style.fontSize = 10;
|
||||
seqName.style.opacity = 0.6f;
|
||||
seqName.style.width = 160;
|
||||
titleRow.Add(seqName);
|
||||
|
||||
var statusLabel = new Label(statusText);
|
||||
statusLabel.style.fontSize = 10;
|
||||
statusLabel.style.color = new StyleColor(statusColor);
|
||||
statusLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
titleRow.Add(statusLabel);
|
||||
card.Add(titleRow);
|
||||
|
||||
// 逻辑类型
|
||||
var logicLabel = new Label($"逻辑:{variant.logic}");
|
||||
logicLabel.style.fontSize = 10;
|
||||
logicLabel.style.opacity = 0.5f;
|
||||
card.Add(logicLabel);
|
||||
|
||||
// 条件详情
|
||||
if (variant.requiredFlags != null && variant.requiredFlags.Length > 0)
|
||||
{
|
||||
foreach (var flag in variant.requiredFlags)
|
||||
{
|
||||
if (string.IsNullOrEmpty(flag)) continue;
|
||||
bool flagOn = _enabledFlags.Contains(flag);
|
||||
var flagRow = new VisualElement();
|
||||
flagRow.style.flexDirection = FlexDirection.Row;
|
||||
flagRow.style.alignItems = Align.Center;
|
||||
flagRow.style.marginTop = 1;
|
||||
|
||||
var icon = new Label(flagOn ? "✓" : "✗");
|
||||
icon.style.fontSize = 10;
|
||||
icon.style.color = new StyleColor(flagOn ? ColWin : ColBlocked);
|
||||
icon.style.width = 16;
|
||||
flagRow.Add(icon);
|
||||
|
||||
var flagLabel = new Label(flag);
|
||||
flagLabel.style.fontSize = 10;
|
||||
flagRow.Add(flagLabel);
|
||||
|
||||
card.Add(flagRow);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var noFlags = new Label("(无 requiredFlags — 无条件激活)");
|
||||
noFlags.style.fontSize = 10;
|
||||
noFlags.style.opacity = 0.5f;
|
||||
card.Add(noFlags);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private bool EvaluateVariant(DialogueSequenceSO.ConditionalVariant variant)
|
||||
{
|
||||
// 使用 DialogueSequenceSO.CheckVariant 统一变体求值逻辑,避免重复实现
|
||||
return _target != null && _target.CheckVariant(variant, _mockReader);
|
||||
}
|
||||
|
||||
/// <summary>将 _enabledFlags 包装为 IWorldStateReader,供 CheckVariant 调用。</summary>
|
||||
private MockFlagReader _mockReader;
|
||||
|
||||
private sealed class MockFlagReader : BaseGames.Core.IWorldStateReader
|
||||
{
|
||||
private readonly System.Collections.Generic.HashSet<string> _flags;
|
||||
public MockFlagReader(System.Collections.Generic.HashSet<string> flags) => _flags = flags;
|
||||
public bool HasFlag(string key) => _flags.Contains(key);
|
||||
}
|
||||
|
||||
// ── 矩阵分析 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 将矩阵分析结果复制为 CSV 字符串到系统剪贴板。
|
||||
/// 格式:首行为标志名称列头(各列)+ "胜出变体",后续每行为一个组合及其结果。
|
||||
/// N > 10 时与 <see cref="RebuildMatrix"/> 一致,提示用户先减少标志数量。
|
||||
/// </summary>
|
||||
private void ExportMatrixCsv()
|
||||
{
|
||||
if (_target == null || _target.variants == null || _target.variants.Length == 0)
|
||||
{
|
||||
EditorUtility.DisplayDialog("矩阵分析 CSV", "当前无可导出的变体数据,请先选择对话序列 SO。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
var matrixFlags = _allFlags.Count > 0 ? _allFlags : new List<string>();
|
||||
if (matrixFlags.Count == 0)
|
||||
{
|
||||
EditorUtility.DisplayDialog("矩阵分析 CSV", "变体未使用任何 requiredFlags,无数据可导出。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
const int MaxFlags = 10;
|
||||
if (matrixFlags.Count > MaxFlags)
|
||||
{
|
||||
EditorUtility.DisplayDialog("矩阵分析 CSV",
|
||||
$"标志数量 ({matrixFlags.Count}) 超过 {MaxFlags},无法导出。\n请先在上方取消勾选不关心的标志,再点击此按钮。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
int n = matrixFlags.Count;
|
||||
int combos = 1 << n;
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
// 表头
|
||||
foreach (var f in matrixFlags)
|
||||
sb.Append(EscapeCsv(f)).Append(',');
|
||||
sb.AppendLine("胜出变体");
|
||||
|
||||
// 数据行
|
||||
for (int mask = 0; mask < combos; mask++)
|
||||
{
|
||||
var combo = new HashSet<string>(System.StringComparer.Ordinal);
|
||||
for (int bit = 0; bit < n; bit++)
|
||||
if ((mask & (1 << bit)) != 0) combo.Add(matrixFlags[bit]);
|
||||
|
||||
var mockReader = new MockFlagReader(combo);
|
||||
int winner = -1;
|
||||
for (int vi = 0; vi < _target.variants.Length; vi++)
|
||||
if (_target.CheckVariant(_target.variants[vi], mockReader)) { winner = vi; break; }
|
||||
|
||||
for (int ci = 0; ci < n; ci++)
|
||||
sb.Append((mask & (1 << ci)) != 0 ? "1" : "0").Append(',');
|
||||
|
||||
string winnerLabel = winner >= 0
|
||||
? $"变体{winner}" +
|
||||
(_target.variants[winner].sequence != null
|
||||
? $"({_target.variants[winner].sequence.name})" : "(无序列)")
|
||||
: "默认台词";
|
||||
sb.AppendLine(EscapeCsv(winnerLabel));
|
||||
}
|
||||
|
||||
EditorGUIUtility.systemCopyBuffer = sb.ToString();
|
||||
Debug.Log($"[DialogueVariantPreviewWindow] 矩阵 CSV({combos} 行)已复制到剪贴板。");
|
||||
ShowNotification(new GUIContent($"✓ 已复制 {combos} 行 CSV 到剪贴板"));
|
||||
}
|
||||
|
||||
private static string EscapeCsv(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return string.Empty;
|
||||
if (s.Contains(',') || s.Contains('"') || s.Contains('\n'))
|
||||
return '"' + s.Replace("\"", "\"\"") + '"';
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 枚举全部 2^N 标志组合(N ≤ 10),以表格形式展示每种组合下胜出的变体索引。
|
||||
/// N > 10 时显示提示,建议手动筛选标志后分析。
|
||||
/// </summary>
|
||||
private void RebuildMatrix()
|
||||
{
|
||||
if (_matrixContainer == null) return;
|
||||
_matrixContainer.Clear();
|
||||
|
||||
if (_target == null || _target.variants == null || _target.variants.Length == 0)
|
||||
{
|
||||
_matrixContainer.Add(new Label("(无可分析的变体)") { style = { opacity = 0.5f, fontSize = 11 } });
|
||||
return;
|
||||
}
|
||||
|
||||
var matrixFlags = _allFlags.Count > 0 ? _allFlags : new List<string>();
|
||||
if (matrixFlags.Count == 0)
|
||||
{
|
||||
_matrixContainer.Add(new Label("(变体不使用任何 requiredFlags,无需矩阵分析)") { style = { opacity = 0.5f, fontSize = 11 } });
|
||||
return;
|
||||
}
|
||||
|
||||
const int MaxFlags = 10;
|
||||
if (matrixFlags.Count > MaxFlags)
|
||||
{
|
||||
var warn = new Label($"⚠ 标志数量 ({matrixFlags.Count}) 超过 {MaxFlags},枚举 2^N 组合代价过高。请在上方取消勾选不关心的标志后重新点击「矩阵分析」。");
|
||||
warn.style.fontSize = 11;
|
||||
warn.style.color = new StyleColor(new Color(0.9f, 0.7f, 0.2f));
|
||||
warn.style.whiteSpace = WhiteSpace.Normal;
|
||||
_matrixContainer.Add(warn);
|
||||
return;
|
||||
}
|
||||
|
||||
int n = matrixFlags.Count;
|
||||
int combos = 1 << n; // 2^n
|
||||
|
||||
// ── 表头 ──
|
||||
var headerRow = MakeMatrixRow(isHeader: true);
|
||||
for (int ci = 0; ci < n; ci++)
|
||||
{
|
||||
var cell = MakeMatrixCell(matrixFlags[ci], isHeader: true);
|
||||
cell.style.minWidth = 90;
|
||||
headerRow.Add(cell);
|
||||
}
|
||||
headerRow.Add(MakeMatrixCell("胜出变体", isHeader: true));
|
||||
_matrixContainer.Add(headerRow);
|
||||
|
||||
// ── 数据行 ──
|
||||
for (int mask = 0; mask < combos; mask++)
|
||||
{
|
||||
var combo = new HashSet<string>(System.StringComparer.Ordinal);
|
||||
for (int bit = 0; bit < n; bit++)
|
||||
if ((mask & (1 << bit)) != 0) combo.Add(matrixFlags[bit]);
|
||||
|
||||
// 求胜出变体
|
||||
var mockReader = new MockFlagReader(combo);
|
||||
int winner = -1;
|
||||
for (int vi = 0; vi < _target.variants.Length; vi++)
|
||||
if (_target.CheckVariant(_target.variants[vi], mockReader)) { winner = vi; break; }
|
||||
|
||||
string winnerText = winner >= 0
|
||||
? $"变体 {winner}" +
|
||||
(_target.variants[winner].sequence != null
|
||||
? $"\n({_target.variants[winner].sequence.name})"
|
||||
: "(无序列)")
|
||||
: "默认台词";
|
||||
|
||||
var dataRow = MakeMatrixRow(isHeader: false);
|
||||
// 标志列
|
||||
for (int ci = 0; ci < n; ci++)
|
||||
{
|
||||
bool on = (mask & (1 << ci)) != 0;
|
||||
var cell = MakeMatrixCell(on ? "✓" : "–", isHeader: false);
|
||||
cell.style.color = new StyleColor(on ? ColWin : ColFail);
|
||||
cell.style.minWidth = 90;
|
||||
dataRow.Add(cell);
|
||||
}
|
||||
// 胜出列
|
||||
var winCell = MakeMatrixCell(winnerText, isHeader: false);
|
||||
winCell.style.color = new StyleColor(winner >= 0 ? ColWin : new Color(0.5f, 0.5f, 0.5f));
|
||||
dataRow.Add(winCell);
|
||||
|
||||
_matrixContainer.Add(dataRow);
|
||||
}
|
||||
}
|
||||
|
||||
private static VisualElement MakeMatrixRow(bool isHeader)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.borderBottomWidth = 1;
|
||||
row.style.borderBottomColor = new StyleColor(new Color(0.3f, 0.3f, 0.3f, 0.5f));
|
||||
if (isHeader)
|
||||
row.style.backgroundColor = new StyleColor(new Color(0.22f, 0.22f, 0.28f, 1f));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Label MakeMatrixCell(string text, bool isHeader)
|
||||
{
|
||||
var lbl = new Label(text);
|
||||
lbl.style.fontSize = isHeader ? 10 : 10;
|
||||
lbl.style.unityFontStyleAndWeight = isHeader ? FontStyle.Bold : FontStyle.Normal;
|
||||
lbl.style.paddingLeft = 4;
|
||||
lbl.style.paddingRight = 4;
|
||||
lbl.style.paddingTop = 3;
|
||||
lbl.style.paddingBottom = 3;
|
||||
lbl.style.whiteSpace = WhiteSpace.Normal;
|
||||
lbl.style.width = 80;
|
||||
return lbl;
|
||||
}
|
||||
|
||||
// ── 辅助 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static VisualElement MakeDivider()
|
||||
{
|
||||
var d = new VisualElement();
|
||||
d.style.height = 1;
|
||||
d.style.backgroundColor = new StyleColor(new Color(0.35f, 0.35f, 0.35f, 0.5f));
|
||||
d.style.marginTop = 6;
|
||||
d.style.marginBottom = 6;
|
||||
return d;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae50b43961101f54f9b0c8c42f833c52
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
210
Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs
Normal file
210
Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.World;
|
||||
|
||||
namespace BaseGames.Editor.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// NarrativeNPC 自定义 Inspector。
|
||||
/// 在默认字段之下显示"对话版本激活状态"面板,
|
||||
/// 以色彩提示每个 DialogueVersion 在当前 WorldStateRegistry 中是否激活,
|
||||
/// 方便策划人员在编辑模式下即时核查对话版本切换逻辑。
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(NarrativeNPC))]
|
||||
public class NarrativeNPCEditor : UnityEditor.Editor
|
||||
{
|
||||
private static bool s_foldout = true;
|
||||
|
||||
private static readonly Color ColorActive = new(0.25f, 0.75f, 0.40f, 1f);
|
||||
private static readonly Color ColorInactive = new(0.55f, 0.55f, 0.55f, 1f);
|
||||
private static readonly Color ColorBlocked = new(0.85f, 0.40f, 0.35f, 1f);
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"PlayMode 中显示的是 WorldStateRegistry SO 的初始序列化值,非运行时动态 flags。\n" +
|
||||
"如需查看实际激活版本,请在运行时检查 NPC 日志或在 SO 上断点调试。",
|
||||
MessageType.Info);
|
||||
}
|
||||
|
||||
s_foldout = EditorGUILayout.BeginFoldoutHeaderGroup(s_foldout, "对话版本激活状态");
|
||||
|
||||
if (s_foldout)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
DrawVersionStatus();
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
// ── 版本状态绘制 ──────────────────────────────────────────────────────
|
||||
|
||||
private void DrawVersionStatus()
|
||||
{
|
||||
// 获取 WorldStateRegistry(运行时或编辑器 SO 引用均可)
|
||||
var worldStateProp = serializedObject.FindProperty("_worldState");
|
||||
var registry = worldStateProp?.objectReferenceValue as WorldStateRegistry;
|
||||
|
||||
var versionsProp = serializedObject.FindProperty("_dialogueVersions");
|
||||
if (versionsProp == null || !versionsProp.isArray || versionsProp.arraySize == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("未设置任何对话版本。", MessageType.None);
|
||||
return;
|
||||
}
|
||||
|
||||
if (registry == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("未指定 WorldStateRegistry,无法预览激活状态。\n" +
|
||||
"可在 Inspector 中设置 World State 字段,或确保已通过 ServiceLocator 注册全局 IWorldStateReader。", MessageType.Warning);
|
||||
DrawVersionLabelsOnly(versionsProp);
|
||||
return;
|
||||
}
|
||||
|
||||
bool higherActive = false;
|
||||
|
||||
for (int i = 0; i < versionsProp.arraySize; i++)
|
||||
{
|
||||
var element = versionsProp.GetArrayElementAtIndex(i);
|
||||
|
||||
string label = GetStringProp(element, "versionLabel");
|
||||
string displayName = string.IsNullOrEmpty(label) ? $"版本 {i}" : label;
|
||||
string dialogueName = GetObjectPropName(element, "dialogue");
|
||||
|
||||
// 计算激活状态(直接迭代 SerializedProperty,不分配中间 string[])
|
||||
bool missingRequired = false;
|
||||
string missingFlag = null;
|
||||
var reqProp = element.FindPropertyRelative("requiredFlags");
|
||||
if (reqProp != null && reqProp.isArray)
|
||||
{
|
||||
for (int j = 0; j < reqProp.arraySize; j++)
|
||||
{
|
||||
var f = reqProp.GetArrayElementAtIndex(j).stringValue;
|
||||
if (!string.IsNullOrEmpty(f) && !registry.HasFlag(f))
|
||||
{
|
||||
missingRequired = true;
|
||||
missingFlag = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool hasBlocker = false;
|
||||
string blockerKey = null;
|
||||
if (!missingRequired)
|
||||
{
|
||||
var blkProp = element.FindPropertyRelative("blockedByFlags");
|
||||
if (blkProp != null && blkProp.isArray)
|
||||
{
|
||||
for (int j = 0; j < blkProp.arraySize; j++)
|
||||
{
|
||||
var f = blkProp.GetArrayElementAtIndex(j).stringValue;
|
||||
if (!string.IsNullOrEmpty(f) && registry.HasFlag(f))
|
||||
{
|
||||
hasBlocker = true;
|
||||
blockerKey = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool conditionsMet = !missingRequired && !hasBlocker;
|
||||
bool isActive = conditionsMet && !higherActive;
|
||||
|
||||
Color statusColor;
|
||||
string statusText;
|
||||
|
||||
if (isActive)
|
||||
{
|
||||
statusColor = ColorActive;
|
||||
statusText = "✓ 激活中";
|
||||
}
|
||||
else if (conditionsMet && higherActive)
|
||||
{
|
||||
statusColor = ColorInactive;
|
||||
statusText = "⏩ 被更高优先级覆盖";
|
||||
}
|
||||
else if (hasBlocker)
|
||||
{
|
||||
statusColor = ColorBlocked;
|
||||
statusText = $"✗ blockedByFlag 阻断 [{blockerKey}]";
|
||||
}
|
||||
else
|
||||
{
|
||||
statusColor = ColorInactive;
|
||||
statusText = $"✗ 缺少 requiredFlag [{missingFlag}]";
|
||||
}
|
||||
|
||||
DrawVersionRow(i, displayName, dialogueName, statusText, statusColor);
|
||||
|
||||
if (conditionsMet) higherActive = true;
|
||||
}
|
||||
|
||||
// 兜底说明
|
||||
EditorGUILayout.Space(2);
|
||||
var fallbackProp = serializedObject.FindProperty("_fallbackDialogue");
|
||||
if (fallbackProp?.objectReferenceValue != null)
|
||||
{
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
EditorGUILayout.LabelField("兜底台词", fallbackProp.objectReferenceValue.name);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawVersionLabelsOnly(SerializedProperty versionsProp)
|
||||
{
|
||||
for (int i = 0; i < versionsProp.arraySize; i++)
|
||||
{
|
||||
var element = versionsProp.GetArrayElementAtIndex(i);
|
||||
string label = GetStringProp(element, "versionLabel");
|
||||
string display = string.IsNullOrEmpty(label) ? $"版本 {i}" : label;
|
||||
EditorGUILayout.LabelField($" {i}. {display}", EditorStyles.miniLabel);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawVersionRow(int index, string versionLabel, string dialogueName, string statusText, Color statusColor)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
// 序号+名称
|
||||
EditorGUILayout.LabelField($"{index}. {versionLabel}", GUILayout.Width(160));
|
||||
|
||||
// 对话 SO 名称
|
||||
if (!string.IsNullOrEmpty(dialogueName))
|
||||
{
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
EditorGUILayout.LabelField(dialogueName, EditorStyles.miniLabel, GUILayout.Width(140));
|
||||
}
|
||||
|
||||
// 状态徽章
|
||||
var prevColor = GUI.contentColor;
|
||||
GUI.contentColor = statusColor;
|
||||
EditorGUILayout.LabelField(statusText, EditorStyles.boldLabel);
|
||||
GUI.contentColor = prevColor;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助:从 SerializedProperty 读取字段 ─────────────────────────────
|
||||
|
||||
private static string GetStringProp(SerializedProperty parent, string name)
|
||||
{
|
||||
var p = parent.FindPropertyRelative(name);
|
||||
return p != null ? p.stringValue : string.Empty;
|
||||
}
|
||||
|
||||
private static string GetObjectPropName(SerializedProperty parent, string name)
|
||||
{
|
||||
var p = parent.FindPropertyRelative(name);
|
||||
if (p == null || p.objectReferenceValue == null) return string.Empty;
|
||||
return p.objectReferenceValue.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user