摄像机区域的优化
This commit is contained in:
40
.github/instructions/no-game-references.instructions.md
vendored
Normal file
40
.github/instructions/no-game-references.instructions.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
description: "编写或修改 C# 代码时,确保命名、注释、Tooltip、Header 中不出现其他游戏的专有名称或明确引用。适用于所有 .cs 文件。"
|
||||
applyTo: "**/*.cs"
|
||||
---
|
||||
|
||||
# 代码命名与注释:禁止引用其他游戏
|
||||
|
||||
## 规则
|
||||
|
||||
在编写或修改任何 C# 代码时,**命名(变量名、方法名、类名)、注释(`//`、`///`)、`[Tooltip]`、`[Header]` 字段标签** 中均不得出现以下内容:
|
||||
|
||||
- 其他游戏的**专有名称**(如:空洞骑士、丝之歌、奥日,以及对应英文 Hollow Knight、HK、Silksong、Ori 等)
|
||||
- 其他游戏中的**专属机制/道具名称**(如:Mothwing Cloak、Monarch Wings、暗影披风 等)
|
||||
- "对齐 XX 游戏"、"仿 XX 风格"、"参考 XX"、"类比 XX"等措辞中明确出现游戏名
|
||||
|
||||
## 正确做法:描述功能特点
|
||||
|
||||
若某设计参考了其他游戏的机制,**只描述该机制的功能特点**,不写出游戏名。
|
||||
|
||||
| ❌ 禁止 | ✅ 替换为 |
|
||||
|---|---|
|
||||
| `// 对齐空洞骑士手感` | `// 下落比上升更快,手感紧实` |
|
||||
| `[Header("跳跃(对齐空洞骑士手感)")]` | `[Header("跳跃")]` |
|
||||
| `[Tooltip("HK ~0.12s")]` | `[Tooltip("推荐 0.12s")]` |
|
||||
| `// 类比 HK Monarch Wings,随时可二段跳` | `// 上升阶段即可触发二段跳` |
|
||||
| `// 仿丝之歌风格 Lookahead` | `// 速度自适应 Lookahead:速度越快,预测距离越大` |
|
||||
| `/// 模拟空洞骑士的标志性相机手感` | `/// 实现下落快、起跳缓的非对称相机追随手感` |
|
||||
| `// 对齐 HK:碰到实体墙不会压墙卡住` | `// 碰到实体墙立即中止,避免压墙卡住` |
|
||||
| `private void ApplyHKComposerDefaults(...)` | `private void ApplyComposerDefaults(...)` |
|
||||
| `[Tooltip("对齐空洞骑士 = 1 格血")]` | `[Tooltip("建议设为 1,确保任意触碰即扣一格血量")]` |
|
||||
|
||||
## 允许保留的通用术语
|
||||
|
||||
以下术语为业界通用设计词汇,**不视为游戏专名**,可正常使用:
|
||||
|
||||
- `Pogo` / `pogo`(踩踏弹跳,通用机制术语)
|
||||
- `Charm`(可收集护符,本项目自有道具类型)
|
||||
- `platformer`、`横版平台游戏`(泛指游戏类型)
|
||||
- `Metroidvania`(泛指游戏类型)
|
||||
- `手感`、`风格`(描述本项目自身功能的通用词)
|
||||
8
Assets/_Game/Data/Camera.meta
Normal file
8
Assets/_Game/Data/Camera.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5c02493f8244514a8934537e868616b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
40
Assets/_Game/Data/Camera/Camera Blend Profile SO.asset
Normal file
40
Assets/_Game/Data/Camera/Camera Blend Profile SO.asset
Normal file
@@ -0,0 +1,40 @@
|
||||
%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: 04f7b183b6d364d4ea85283d30339db7, type: 3}
|
||||
m_Name: Camera Blend Profile SO
|
||||
m_EditorClassIdentifier:
|
||||
Style: 1
|
||||
BlendTime: 0.5
|
||||
CustomCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 1
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33f7ac6591bc7db4ea52d89d3441b567
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Assets/_Game/Data/Camera/CameraLensConfig.asset
Normal file
16
Assets/_Game/Data/Camera/CameraLensConfig.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: 4e825a1ad33662d41819655575a49941, type: 3}
|
||||
m_Name: CameraLensConfig
|
||||
m_EditorClassIdentifier:
|
||||
fieldOfView: 10
|
||||
cameraDepth: 64
|
||||
8
Assets/_Game/Data/Camera/CameraLensConfig.asset.meta
Normal file
8
Assets/_Game/Data/Camera/CameraLensConfig.asset.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12fec951ce5cc3d499b00e38b5dfa14a
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
681
Assets/_Game/Scenes/Testings/New Scene.unity
Normal file
681
Assets/_Game/Scenes/Testings/New Scene.unity
Normal file
@@ -0,0 +1,681 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!29 &1
|
||||
OcclusionCullingSettings:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 2
|
||||
m_OcclusionBakeSettings:
|
||||
smallestOccluder: 5
|
||||
smallestHole: 0.25
|
||||
backfaceThreshold: 100
|
||||
m_SceneGUID: 00000000000000000000000000000000
|
||||
m_OcclusionCullingData: {fileID: 0}
|
||||
--- !u!104 &2
|
||||
RenderSettings:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 9
|
||||
m_Fog: 0
|
||||
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
|
||||
m_FogMode: 3
|
||||
m_FogDensity: 0.01
|
||||
m_LinearFogStart: 0
|
||||
m_LinearFogEnd: 300
|
||||
m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1}
|
||||
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
|
||||
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
|
||||
m_AmbientIntensity: 1
|
||||
m_AmbientMode: 3
|
||||
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
|
||||
m_SkyboxMaterial: {fileID: 0}
|
||||
m_HaloStrength: 0.5
|
||||
m_FlareStrength: 1
|
||||
m_FlareFadeSpeed: 3
|
||||
m_HaloTexture: {fileID: 0}
|
||||
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
|
||||
m_DefaultReflectionMode: 0
|
||||
m_DefaultReflectionResolution: 128
|
||||
m_ReflectionBounces: 1
|
||||
m_ReflectionIntensity: 1
|
||||
m_CustomReflection: {fileID: 0}
|
||||
m_Sun: {fileID: 0}
|
||||
m_UseRadianceAmbientProbe: 0
|
||||
--- !u!157 &3
|
||||
LightmapSettings:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 12
|
||||
m_GIWorkflowMode: 1
|
||||
m_GISettings:
|
||||
serializedVersion: 2
|
||||
m_BounceScale: 1
|
||||
m_IndirectOutputScale: 1
|
||||
m_AlbedoBoost: 1
|
||||
m_EnvironmentLightingMode: 0
|
||||
m_EnableBakedLightmaps: 0
|
||||
m_EnableRealtimeLightmaps: 0
|
||||
m_LightmapEditorSettings:
|
||||
serializedVersion: 12
|
||||
m_Resolution: 2
|
||||
m_BakeResolution: 40
|
||||
m_AtlasSize: 1024
|
||||
m_AO: 0
|
||||
m_AOMaxDistance: 1
|
||||
m_CompAOExponent: 1
|
||||
m_CompAOExponentDirect: 0
|
||||
m_ExtractAmbientOcclusion: 0
|
||||
m_Padding: 2
|
||||
m_LightmapParameters: {fileID: 0}
|
||||
m_LightmapsBakeMode: 1
|
||||
m_TextureCompression: 1
|
||||
m_FinalGather: 0
|
||||
m_FinalGatherFiltering: 1
|
||||
m_FinalGatherRayCount: 256
|
||||
m_ReflectionCompression: 2
|
||||
m_MixedBakeMode: 2
|
||||
m_BakeBackend: 1
|
||||
m_PVRSampling: 1
|
||||
m_PVRDirectSampleCount: 32
|
||||
m_PVRSampleCount: 512
|
||||
m_PVRBounces: 2
|
||||
m_PVREnvironmentSampleCount: 256
|
||||
m_PVREnvironmentReferencePointCount: 2048
|
||||
m_PVRFilteringMode: 1
|
||||
m_PVRDenoiserTypeDirect: 1
|
||||
m_PVRDenoiserTypeIndirect: 1
|
||||
m_PVRDenoiserTypeAO: 1
|
||||
m_PVRFilterTypeDirect: 0
|
||||
m_PVRFilterTypeIndirect: 0
|
||||
m_PVRFilterTypeAO: 0
|
||||
m_PVREnvironmentMIS: 1
|
||||
m_PVRCulling: 1
|
||||
m_PVRFilteringGaussRadiusDirect: 1
|
||||
m_PVRFilteringGaussRadiusIndirect: 5
|
||||
m_PVRFilteringGaussRadiusAO: 2
|
||||
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
|
||||
m_PVRFilteringAtrousPositionSigmaIndirect: 2
|
||||
m_PVRFilteringAtrousPositionSigmaAO: 1
|
||||
m_ExportTrainingData: 0
|
||||
m_TrainingDataDestination: TrainingData
|
||||
m_LightProbeSampleCountMultiplier: 4
|
||||
m_LightingDataAsset: {fileID: 0}
|
||||
m_LightingSettings: {fileID: 0}
|
||||
--- !u!196 &4
|
||||
NavMeshSettings:
|
||||
serializedVersion: 2
|
||||
m_ObjectHideFlags: 0
|
||||
m_BuildSettings:
|
||||
serializedVersion: 3
|
||||
agentTypeID: 0
|
||||
agentRadius: 0.5
|
||||
agentHeight: 2
|
||||
agentSlope: 45
|
||||
agentClimb: 0.4
|
||||
ledgeDropHeight: 0
|
||||
maxJumpAcrossDistance: 0
|
||||
minRegionArea: 2
|
||||
manualCellSize: 0
|
||||
cellSize: 0.16666667
|
||||
manualTileSize: 0
|
||||
tileSize: 256
|
||||
buildHeightMesh: 0
|
||||
maxJobWorkers: 0
|
||||
preserveTilesOutsideBounds: 0
|
||||
debug:
|
||||
m_Flags: 0
|
||||
m_NavMeshData: {fileID: 0}
|
||||
--- !u!1 &265623115
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 265623118}
|
||||
- component: {fileID: 265623117}
|
||||
- component: {fileID: 265623116}
|
||||
- component: {fileID: 265623120}
|
||||
- component: {fileID: 265623119}
|
||||
m_Layer: 0
|
||||
m_Name: Main Camera
|
||||
m_TagString: MainCamera
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!81 &265623116
|
||||
AudioListener:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 265623115}
|
||||
m_Enabled: 1
|
||||
--- !u!20 &265623117
|
||||
Camera:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 265623115}
|
||||
m_Enabled: 1
|
||||
serializedVersion: 2
|
||||
m_ClearFlags: 1
|
||||
m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0}
|
||||
m_projectionMatrixMode: 1
|
||||
m_GateFitMode: 2
|
||||
m_FOVAxisMode: 0
|
||||
m_Iso: 200
|
||||
m_ShutterSpeed: 0.005
|
||||
m_Aperture: 16
|
||||
m_FocusDistance: 10
|
||||
m_FocalLength: 50
|
||||
m_BladeCount: 5
|
||||
m_Curvature: {x: 2, y: 11}
|
||||
m_BarrelClipping: 0.25
|
||||
m_Anamorphism: 0
|
||||
m_SensorSize: {x: 36, y: 24}
|
||||
m_LensShift: {x: 0, y: 0}
|
||||
m_NormalizedViewPortRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
y: 0
|
||||
width: 1
|
||||
height: 1
|
||||
near clip plane: 0.1
|
||||
far clip plane: 5000
|
||||
field of view: 10
|
||||
orthographic: 0
|
||||
orthographic size: 10
|
||||
m_Depth: -1
|
||||
m_CullingMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_RenderingPath: -1
|
||||
m_TargetTexture: {fileID: 0}
|
||||
m_TargetDisplay: 0
|
||||
m_TargetEye: 3
|
||||
m_HDR: 1
|
||||
m_AllowMSAA: 1
|
||||
m_AllowDynamicResolution: 0
|
||||
m_ForceIntoRT: 0
|
||||
m_OcclusionCulling: 1
|
||||
m_StereoConvergence: 10
|
||||
m_StereoSeparation: 0.022
|
||||
--- !u!4 &265623118
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 265623115}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: -0.19243693, y: -1.6213, z: -64}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &265623119
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 265623115}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 72ece51f2901e7445ab60da3685d6b5f, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
ShowDebugText: 0
|
||||
ShowCameraFrustum: 1
|
||||
IgnoreTimeScale: 0
|
||||
WorldUpOverride: {fileID: 0}
|
||||
ChannelMask: -1
|
||||
UpdateMethod: 2
|
||||
BlendUpdateMethod: 1
|
||||
LensModeOverride:
|
||||
Enabled: 1
|
||||
DefaultMode: 2
|
||||
DefaultBlend:
|
||||
Style: 1
|
||||
Time: 2
|
||||
CustomCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve: []
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
CustomBlends: {fileID: 0}
|
||||
--- !u!114 &265623120
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 265623115}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_RenderShadows: 1
|
||||
m_RequiresDepthTextureOption: 2
|
||||
m_RequiresOpaqueTextureOption: 2
|
||||
m_CameraType: 0
|
||||
m_Cameras: []
|
||||
m_RendererIndex: -1
|
||||
m_VolumeLayerMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 1
|
||||
m_VolumeTrigger: {fileID: 0}
|
||||
m_VolumeFrameworkUpdateModeOption: 2
|
||||
m_RenderPostProcessing: 0
|
||||
m_Antialiasing: 0
|
||||
m_AntialiasingQuality: 2
|
||||
m_StopNaN: 0
|
||||
m_Dithering: 0
|
||||
m_ClearDepth: 1
|
||||
m_AllowXRRendering: 1
|
||||
m_AllowHDROutput: 1
|
||||
m_UseScreenCoordOverride: 0
|
||||
m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_RequiresDepthTexture: 0
|
||||
m_RequiresColorTexture: 0
|
||||
m_Version: 2
|
||||
m_TaaSettings:
|
||||
m_Quality: 3
|
||||
m_FrameInfluence: 0.1
|
||||
m_JitterScale: 1
|
||||
m_MipBias: 0
|
||||
m_VarianceClampScale: 0.9
|
||||
m_ContrastAdaptiveSharpening: 0
|
||||
--- !u!1 &373938368
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 373938370}
|
||||
- component: {fileID: 373938369}
|
||||
m_Layer: 0
|
||||
m_Name: Square
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!212 &373938369
|
||||
SpriteRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 373938368}
|
||||
m_Enabled: 1
|
||||
m_CastShadows: 0
|
||||
m_ReceiveShadows: 0
|
||||
m_DynamicOccludee: 1
|
||||
m_StaticShadowCaster: 0
|
||||
m_MotionVectors: 1
|
||||
m_LightProbeUsage: 1
|
||||
m_ReflectionProbeUsage: 1
|
||||
m_RayTracingMode: 0
|
||||
m_RayTraceProcedural: 0
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: a97c105638bdf8b4a8650670310a4cd3, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
m_StaticBatchRoot: {fileID: 0}
|
||||
m_ProbeAnchor: {fileID: 0}
|
||||
m_LightProbeVolumeOverride: {fileID: 0}
|
||||
m_ScaleInLightmap: 1
|
||||
m_ReceiveGI: 1
|
||||
m_PreserveUVs: 0
|
||||
m_IgnoreNormalsForChartDetection: 0
|
||||
m_ImportantGI: 0
|
||||
m_StitchLightmapSeams: 1
|
||||
m_SelectedEditorRenderState: 0
|
||||
m_MinimumChartSize: 4
|
||||
m_AutoUVMaxDistance: 0.5
|
||||
m_AutoUVMaxAngle: 89
|
||||
m_LightmapParameters: {fileID: 0}
|
||||
m_SortingLayerID: 0
|
||||
m_SortingLayer: 0
|
||||
m_SortingOrder: 0
|
||||
m_Sprite: {fileID: 7482667652216324306, guid: 311925a002f4447b3a28927169b83ea6, type: 3}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_FlipX: 0
|
||||
m_FlipY: 0
|
||||
m_DrawMode: 0
|
||||
m_Size: {x: 1, y: 1}
|
||||
m_AdaptiveModeThreshold: 0.5
|
||||
m_SpriteTileMode: 0
|
||||
m_WasSpriteAssigned: 1
|
||||
m_MaskInteraction: 0
|
||||
m_SpriteSortPoint: 0
|
||||
--- !u!4 &373938370
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 373938368}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &526694927
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 526694934}
|
||||
- component: {fileID: 526694933}
|
||||
- component: {fileID: 526694932}
|
||||
- component: {fileID: 526694931}
|
||||
m_Layer: 0
|
||||
m_Name: VCamA
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!114 &526694931
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 526694927}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: f453f694addf4275988fac205bc91968, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
BoundingShape2D: {fileID: 1088668304}
|
||||
Damping: 0.5
|
||||
SlowingDistance: 4
|
||||
OversizeWindow:
|
||||
Enabled: 1
|
||||
MaxWindowSize: 0.5
|
||||
Padding: 0
|
||||
m_LegacyMaxWindowSize: -2
|
||||
--- !u!114 &526694932
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 526694927}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 886251e9a18ece04ea8e61686c173e1b, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
CameraDistance: 64
|
||||
DeadZoneDepth: 0
|
||||
Composition:
|
||||
ScreenPosition: {x: 0, y: -0.15}
|
||||
DeadZone:
|
||||
Enabled: 1
|
||||
Size: {x: 0.15, y: 0.05}
|
||||
HardLimits:
|
||||
Enabled: 0
|
||||
Size: {x: 0.8, y: 0.8}
|
||||
Offset: {x: 0, y: 0}
|
||||
CenterOnActivate: 1
|
||||
TargetOffset: {x: 0, y: 0, z: 0}
|
||||
Damping: {x: 0, y: 0, z: 0}
|
||||
Lookahead:
|
||||
Enabled: 0
|
||||
Time: 0.28
|
||||
Smoothing: 5
|
||||
IgnoreY: 1
|
||||
--- !u!114 &526694933
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 526694927}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: f9dfa5b682dcd46bda6128250e975f58, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
Priority:
|
||||
Enabled: 0
|
||||
m_Value: 0
|
||||
OutputChannel: 1
|
||||
StandbyUpdate: 2
|
||||
m_StreamingVersion: 20241001
|
||||
m_LegacyPriority: 0
|
||||
Target:
|
||||
TrackingTarget: {fileID: 373938370}
|
||||
LookAtTarget: {fileID: 0}
|
||||
CustomLookAtTarget: 0
|
||||
Lens:
|
||||
FieldOfView: 10
|
||||
OrthographicSize: 10
|
||||
NearClipPlane: 0.1
|
||||
FarClipPlane: 5000
|
||||
Dutch: 0
|
||||
ModeOverride: 0
|
||||
PhysicalProperties:
|
||||
GateFit: 2
|
||||
SensorSize: {x: 21.946, y: 16.002}
|
||||
LensShift: {x: 0, y: 0}
|
||||
FocusDistance: 10
|
||||
Iso: 200
|
||||
ShutterSpeed: 0.005
|
||||
Aperture: 16
|
||||
BladeCount: 5
|
||||
Curvature: {x: 2, y: 11}
|
||||
BarrelClipping: 0.25
|
||||
Anamorphism: 0
|
||||
BlendHint: 0
|
||||
--- !u!4 &526694934
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 526694927}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
|
||||
m_LocalPosition: {x: -0.00000071525574, y: -1.6797824, z: -64}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &722689907
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 722689909}
|
||||
- component: {fileID: 722689908}
|
||||
m_Layer: 0
|
||||
m_Name: Square (1)
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!212 &722689908
|
||||
SpriteRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 722689907}
|
||||
m_Enabled: 1
|
||||
m_CastShadows: 0
|
||||
m_ReceiveShadows: 0
|
||||
m_DynamicOccludee: 1
|
||||
m_StaticShadowCaster: 0
|
||||
m_MotionVectors: 1
|
||||
m_LightProbeUsage: 1
|
||||
m_ReflectionProbeUsage: 1
|
||||
m_RayTracingMode: 0
|
||||
m_RayTraceProcedural: 0
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: a97c105638bdf8b4a8650670310a4cd3, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
m_StaticBatchRoot: {fileID: 0}
|
||||
m_ProbeAnchor: {fileID: 0}
|
||||
m_LightProbeVolumeOverride: {fileID: 0}
|
||||
m_ScaleInLightmap: 1
|
||||
m_ReceiveGI: 1
|
||||
m_PreserveUVs: 0
|
||||
m_IgnoreNormalsForChartDetection: 0
|
||||
m_ImportantGI: 0
|
||||
m_StitchLightmapSeams: 1
|
||||
m_SelectedEditorRenderState: 0
|
||||
m_MinimumChartSize: 4
|
||||
m_AutoUVMaxDistance: 0.5
|
||||
m_AutoUVMaxAngle: 89
|
||||
m_LightmapParameters: {fileID: 0}
|
||||
m_SortingLayerID: 0
|
||||
m_SortingLayer: 0
|
||||
m_SortingOrder: 0
|
||||
m_Sprite: {fileID: 7482667652216324306, guid: 311925a002f4447b3a28927169b83ea6, type: 3}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_FlipX: 0
|
||||
m_FlipY: 0
|
||||
m_DrawMode: 0
|
||||
m_Size: {x: 1, y: 1}
|
||||
m_AdaptiveModeThreshold: 0.5
|
||||
m_SpriteTileMode: 0
|
||||
m_WasSpriteAssigned: 1
|
||||
m_MaskInteraction: 0
|
||||
m_SpriteSortPoint: 0
|
||||
--- !u!4 &722689909
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 722689907}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 2.4, y: 1.89, z: 0}
|
||||
m_LocalScale: {x: 21.2999, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &1088668303
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 1088668305}
|
||||
- component: {fileID: 1088668304}
|
||||
m_Layer: 0
|
||||
m_Name: GameObject
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!60 &1088668304
|
||||
PolygonCollider2D:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1088668303}
|
||||
m_Enabled: 1
|
||||
m_Density: 1
|
||||
m_Material: {fileID: 0}
|
||||
m_IncludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_ExcludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_LayerOverridePriority: 0
|
||||
m_ForceSendLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_ForceReceiveLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_ContactCaptureLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_CallbackLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_IsTrigger: 0
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: 0, y: 0}
|
||||
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
|
||||
m_Points:
|
||||
m_Paths:
|
||||
- - {x: 0, y: 1}
|
||||
- {x: -0.95105654, y: 0.30901697}
|
||||
- {x: -0.5877852, y: -0.80901706}
|
||||
- {x: 7.27957, y: -11.23575}
|
||||
- {x: 19.703613, y: -1.4806461}
|
||||
m_UseDelaunayMesh: 0
|
||||
--- !u!4 &1088668305
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1088668303}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: -8.416406, y: 7.92642, z: -64.17764}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1660057539 &9223372036854775807
|
||||
SceneRoots:
|
||||
m_ObjectHideFlags: 0
|
||||
m_Roots:
|
||||
- {fileID: 265623118}
|
||||
- {fileID: 526694934}
|
||||
- {fileID: 373938370}
|
||||
- {fileID: 1088668305}
|
||||
- {fileID: 722689909}
|
||||
7
Assets/_Game/Scenes/Testings/New Scene.unity.meta
Normal file
7
Assets/_Game/Scenes/Testings/New Scene.unity.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 38a94d79d28868442a2120e31405ec0a
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
91
Assets/_Game/Scripts/Audio/FootstepSoundPlayer.cs
Normal file
91
Assets/_Game/Scripts/Audio/FootstepSoundPlayer.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using BaseGames.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 材质感知脚步声播放器。挂在玩家 GameObject 上,由 PlayerFeedback.PlayFootstep() 驱动。
|
||||
/// 每次触发时向下 OverlapCircle 探测脚下碰撞体,读取 FootstepMaterialMarker 决定材质,
|
||||
/// 再从 FootstepAudioConfigSO 随机选取 Clip 并叠加 Pitch 扰动后播放。
|
||||
/// 未探测到 Marker 时回落到 FootstepMaterial.Stone。
|
||||
/// </summary>
|
||||
public class FootstepSoundPlayer : MonoBehaviour
|
||||
{
|
||||
[Header("音效配置")]
|
||||
[SerializeField] private FootstepAudioConfigSO _config;
|
||||
|
||||
[Header("地面探测")]
|
||||
[Tooltip("通常复用 PlayerMovement 下的 GroundCheck 子对象")]
|
||||
[SerializeField] private Transform _groundProbe;
|
||||
[SerializeField] private LayerMask _groundLayer;
|
||||
[SerializeField] private float _probeRadius = 0.15f;
|
||||
|
||||
private AudioSource _audioSource;
|
||||
private readonly Collider2D[] _probeBuffer = new Collider2D[4];
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 优先使用同对象已有的 AudioSource,否则自动添加
|
||||
_audioSource = GetComponent<AudioSource>();
|
||||
if (_audioSource == null)
|
||||
_audioSource = gameObject.AddComponent<AudioSource>();
|
||||
|
||||
_audioSource.spatialBlend = 0f; // 始终 2D
|
||||
_audioSource.playOnAwake = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由 PlayerFeedback.PlayFootstep() 调用。
|
||||
/// </summary>
|
||||
public void Play()
|
||||
{
|
||||
if (_config == null) return;
|
||||
|
||||
var material = DetectGroundMaterial();
|
||||
var entry = _config.GetEntry(material);
|
||||
if (entry == null) return;
|
||||
|
||||
var e = entry.Value;
|
||||
if (e.clips == null || e.clips.Length == 0) return;
|
||||
|
||||
var clip = e.clips[Random.Range(0, e.clips.Length)];
|
||||
if (clip == null) return;
|
||||
|
||||
// pitchVariance 为 [0.8, 1.2],以 1.0 为中心扩展偏移区间
|
||||
float half = e.pitchVariance - 1f;
|
||||
_audioSource.pitch = Random.Range(1f - half, 1f + half);
|
||||
_audioSource.PlayOneShot(clip, e.volume);
|
||||
}
|
||||
|
||||
// ── 地面材质探测 ──────────────────────────────────────────────────────
|
||||
|
||||
private FootstepMaterial DetectGroundMaterial()
|
||||
{
|
||||
Vector2 origin = _groundProbe != null
|
||||
? (Vector2)_groundProbe.position
|
||||
: (Vector2)transform.position + Vector2.down * 0.5f;
|
||||
|
||||
int count = Physics2D.OverlapCircleNonAlloc(origin, _probeRadius, _probeBuffer, _groundLayer);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var marker = _probeBuffer[i].GetComponent<FootstepMaterialMarker>();
|
||||
if (marker != null)
|
||||
return marker.material;
|
||||
}
|
||||
|
||||
return FootstepMaterial.Stone; // 默认回落
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
Gizmos.color = new Color(0.6f, 1f, 0.6f, 0.5f);
|
||||
Vector3 origin = _groundProbe != null
|
||||
? _groundProbe.position
|
||||
: transform.position + Vector3.down * 0.5f;
|
||||
Gizmos.DrawWireSphere(origin, _probeRadius);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 38af2eabab7039c4a919181e4c507d12
|
||||
guid: 1a74182d14151114ea7691f3e7183583
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
105
Assets/_Game/Scripts/Camera/CameraAdaptiveLookaheadExtension.cs
Normal file
105
Assets/_Game/Scripts/Camera/CameraAdaptiveLookaheadExtension.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 速度自适应 Lookahead 扩展。
|
||||
///
|
||||
/// 玩家水平速度越快,Lookahead.Time 越接近 CameraArea 配置的最大值;
|
||||
/// 静止时衰减至最大值的 <see cref="_restScale"/> 倍,避免静止时镜头无谓偏移。
|
||||
///
|
||||
/// 挂载位置:Persistent 场景中的 VCamA / VCamB GameObject。
|
||||
/// <see cref="CameraStateController.ConfigureSlot"/> 在每次切换区域时调用
|
||||
/// <see cref="SetConfiguredMax"/> 传入该区域的 LookaheadTime。
|
||||
/// </summary>
|
||||
[AddComponentMenu("Cinemachine/Extensions/Camera Adaptive Lookahead")]
|
||||
[DisallowMultipleComponent]
|
||||
public class CameraAdaptiveLookaheadExtension : CinemachineExtension
|
||||
{
|
||||
[Tooltip("静止时 Lookahead 缩减比例(0~1)。\n" +
|
||||
"0 = 静止时完全无 Lookahead;0.25 = 静止时使用配置值的 25%。\n" +
|
||||
"推荐 0.2~0.3。")]
|
||||
[Range(0f, 1f)]
|
||||
[SerializeField] private float _restScale = 0.25f;
|
||||
|
||||
[Tooltip("达到最大 Lookahead 所需的水平速度(世界单位/秒)。\n" +
|
||||
"玩家以此速度奔跑时 Lookahead.Time = 100% 配置值。推荐 10~15。")]
|
||||
[SerializeField] private float _speedAtFullLookahead = 12f;
|
||||
|
||||
[Tooltip("水平速度估算的平滑强度。越大响应越快。推荐 4~6。")]
|
||||
[SerializeField] private float _speedSmoothing = 5f;
|
||||
|
||||
// ── 内部状态 ──────────────────────────────────────────────────────────
|
||||
|
||||
private float _configuredMaxTime = -1f; // -1 = ConfigureSlot 尚未调用
|
||||
private float _estimatedSpeedX;
|
||||
private float _lastFollowX;
|
||||
private bool _trackingInitialized;
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 由 <see cref="CameraStateController.ConfigureSlot"/> 调用,
|
||||
/// 传入当前 CameraArea 配置的最大 Lookahead 时长。
|
||||
/// </summary>
|
||||
public void SetConfiguredMax(float maxTime) => _configuredMaxTime = maxTime;
|
||||
|
||||
// ── Extension ─────────────────────────────────────────────────────────
|
||||
|
||||
protected override void PostPipelineStageCallback(
|
||||
CinemachineVirtualCameraBase vcam,
|
||||
CinemachineCore.Stage stage,
|
||||
ref CameraState state,
|
||||
float deltaTime)
|
||||
{
|
||||
if (stage != CinemachineCore.Stage.Body) return;
|
||||
|
||||
// 编辑器预览时不运行
|
||||
if (deltaTime <= 0f)
|
||||
{
|
||||
_trackingInitialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// ConfigureSlot 尚未调用时跳过(避免覆盖默认值)
|
||||
if (_configuredMaxTime < 0f) return;
|
||||
|
||||
// ── 估算玩家水平速度 ──────────────────────────────────────────────
|
||||
Transform follow = vcam.Follow;
|
||||
if (follow != null)
|
||||
{
|
||||
if (!_trackingInitialized)
|
||||
{
|
||||
_lastFollowX = follow.position.x;
|
||||
_trackingInitialized = true;
|
||||
}
|
||||
|
||||
float rawSpeedX = Mathf.Abs(follow.position.x - _lastFollowX) / deltaTime;
|
||||
_lastFollowX = follow.position.x;
|
||||
_estimatedSpeedX = Mathf.Lerp(_estimatedSpeedX, rawSpeedX, deltaTime * _speedSmoothing);
|
||||
}
|
||||
|
||||
// ── 速度映射 → Lookahead 时长 ─────────────────────────────────────
|
||||
float fraction = Mathf.Clamp01(_estimatedSpeedX / _speedAtFullLookahead);
|
||||
float scaledTime = Mathf.Lerp(_configuredMaxTime * _restScale, _configuredMaxTime, fraction);
|
||||
|
||||
var composer = vcam.GetComponent<CinemachinePositionComposer>();
|
||||
if (composer == null) return;
|
||||
|
||||
var lah = composer.Lookahead;
|
||||
lah.Time = scaledTime;
|
||||
composer.Lookahead = lah;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置速度估算状态。在相机硬切时由 CameraStateController 调用,
|
||||
/// 避免上一区域的奔跑速度影响新区域的初始 Lookahead 量。
|
||||
/// </summary>
|
||||
public void ResetState()
|
||||
{
|
||||
_estimatedSpeedX = 0f;
|
||||
_trackingInitialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b358a30ac16c6a34fb673ede0a288e48
|
||||
guid: a12cbb2380ff137459b7ba80d492733f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -21,15 +21,81 @@ namespace BaseGames.Camera
|
||||
[SerializeField] private PolygonCollider2D _confinerCollider;
|
||||
|
||||
[Header("可视区域(透视相机)")]
|
||||
[Tooltip("摄像机应显示的最大可视矩形(世界坐标)。\n" +
|
||||
[Tooltip("摄像机应显示的最大可视矩形(本地坐标,相对于此 GameObject 的 Transform 位置)。\n" +
|
||||
"Scene 视图中可直接拖拽四条边编辑,然后点击 Inspector 中的\n" +
|
||||
"「从可视区域更新限位区域(透视)」按钮将其换算为限位多边形。")]
|
||||
[SerializeField] private Rect _visibleBounds = new Rect(-12f, -6f, 24f, 12f);
|
||||
|
||||
[Tooltip("摄像机到场景平面(Z = 0)的垂直距离,用于透视视口尺寸计算。\n" +
|
||||
"留 0 时自动取 transform.position.z 的绝对值(推荐)。")]
|
||||
[HideInInspector]
|
||||
[SerializeField] private float _cameraDepth = 0f;
|
||||
|
||||
[Header("镜头配置")]
|
||||
[Tooltip("全局相机镜头参数 SO。与 CameraStateController 引用同一资产,\n" +
|
||||
"保证 FOV 等参数在 Room 场景中也能正确读取。\n" +
|
||||
"SO 中的 fieldOfView 发生变化时,编辑器会自动重新同步限位多边形。")]
|
||||
[SerializeField] private CameraLensConfigSO _lensConfig;
|
||||
|
||||
// 编辑器通过它检测限位多边形是否需要重新同步(不展示在 Inspector 中)
|
||||
[HideInInspector]
|
||||
[SerializeField] private float _lastSyncFOV = 0f;
|
||||
// ── 跟随行为 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("跟随行为(覆盖全局 VCam 参数)")]
|
||||
[Tooltip("启用后,进入此区域时将把以下参数写入全局 VCam;\n" +
|
||||
"关闭则 VCam 保持上一区域或 Inspector 中的默认值。")]
|
||||
[SerializeField] private bool _overrideFollowBehaviour = true;
|
||||
|
||||
[Tooltip("玩家跟踪点在屏幕上的位置(0 = 中心,±0.5 = 边缘)。\n" +
|
||||
"推荐 (0, -0.1):玩家稍低于中心,上方有更多视野(对横版山洞类游戏第三视角推荐)。")]
|
||||
[SerializeField] private Vector2 _screenPosition = new Vector2(0f, -0.15f);
|
||||
|
||||
[Tooltip("水平(X)/ 垂直(Y)跟随阻尼(秒)。越大越滞后。\n" +
|
||||
"推荐:X=0.5 Y=0.2(水平稍慢、垂直快速响应)。\n" +
|
||||
"同时挂载 CameraAsymmetricDampingExtension 时,Y 分量自动被清零,改由下方两个字段控制。")]
|
||||
[SerializeField] private Vector2 _damping = new Vector2(0.5f, 0.2f);
|
||||
|
||||
[Tooltip("非对称 Y 阻尼 —— 相机向下(下落)时的 Y 轴阻尼(秒)。越小相机越快跟随,玩家能提前看到地面。")]
|
||||
[SerializeField] private float _dampingDown = 0.06f;
|
||||
|
||||
[Tooltip("非对称 Y 阻尼 —— 相机向上(起跳)时的 Y 轴阻尼(秒)。越大越慢,保留地面视野不被立刻拉高。")]
|
||||
[SerializeField] private float _dampingUp = 0.65f;
|
||||
|
||||
[Tooltip("死区范围(全屏 = 1)。玩家在死区内相机不移动,产生松散跟随感。\n" +
|
||||
"推荐:X=0.1 Y=0.05。")]
|
||||
[SerializeField] private Vector2 _deadZoneSize = new Vector2(0.15f, 0.05f);
|
||||
|
||||
[Tooltip("引领预测时长(秒,0 = 不引领)。相机超前于玩家移动方向,令玩家更早看到前方地形。")]
|
||||
[Range(0f, 1f)]
|
||||
[SerializeField] private float _lookaheadTime = 0.28f;
|
||||
|
||||
[Tooltip("引领算法平滑度(0~30)。越大越平滑但预测延迟更大。")]
|
||||
[Range(0f, 30f)]
|
||||
[SerializeField] private float _lookaheadSmoothing = 5f;
|
||||
|
||||
[Header("下坠视野偏置(需配合 CameraFallBiasExtension)")]
|
||||
[Tooltip("禁用此区域的下坠视野偏置效果。\n"
|
||||
+ "在垂直高度较小的房间(短走廊 / 矬间)中建议开启,\n"
|
||||
+ "防止相机因偏置超出 Confiner 边界。")]
|
||||
[SerializeField] private bool _disableFallBias = false;
|
||||
|
||||
// ── 轴向约束 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("轴向约束")]
|
||||
[Tooltip("锁定相机 X 轴(垂直竖井:相机仅上下移动,X 固定在限位区域中心)。")]
|
||||
[SerializeField] private bool _lockHorizontal = false;
|
||||
|
||||
[Tooltip("锁定相机 Y 轴(水平走廊:相机仅左右移动,Y 固定在限位区域中心)。")]
|
||||
[SerializeField] private bool _lockVertical = false;
|
||||
|
||||
[Header("镜头尺寸(正交相机)")]
|
||||
[Tooltip("进入此区域时的目标正交尺寸(0 = 不覆盖当前尺寸)。\n" +
|
||||
"适用于 Boss 战拉远或精密解谜区域拉近。")]
|
||||
[SerializeField] private float _lensSize = 0f;
|
||||
|
||||
[Tooltip("镜头尺寸过渡时长(秒)。0 = 瞬间切换。")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float _lensSizeDuration = 0.5f;
|
||||
|
||||
[Header("混合配置")]
|
||||
[SerializeField] private CameraBlendProfileSO _blendProfile;
|
||||
|
||||
@@ -44,24 +110,43 @@ namespace BaseGames.Camera
|
||||
|
||||
// ── 公开属性 ──────────────────────────────────────────────────────────
|
||||
|
||||
public PolygonCollider2D ConfinerCollider => _confinerCollider;
|
||||
public CameraBlendProfileSO BlendProfile => _blendProfile;
|
||||
public Rect VisibleBounds => _visibleBounds;
|
||||
public bool HasDedicated => _dedicatedCamera != null;
|
||||
public CinemachineCamera DedicatedCamera => _dedicatedCamera;
|
||||
public int DedicatedPriority => _dedicatedPriority;
|
||||
public PolygonCollider2D ConfinerCollider => _confinerCollider;
|
||||
public CameraLensConfigSO LensConfig => _lensConfig;
|
||||
public float LastSyncFOV => _lastSyncFOV;
|
||||
public CameraBlendProfileSO BlendProfile => _blendProfile;
|
||||
/// <summary>世界坐标可视区域(本地 _visibleBounds + transform.position)。</summary>
|
||||
public Rect VisibleBounds => new Rect(
|
||||
_visibleBounds.x + transform.position.x,
|
||||
_visibleBounds.y + transform.position.y,
|
||||
_visibleBounds.width, _visibleBounds.height);
|
||||
public bool HasDedicated => _dedicatedCamera != null;
|
||||
public CinemachineCamera DedicatedCamera => _dedicatedCamera;
|
||||
public int DedicatedPriority => _dedicatedPriority;
|
||||
public bool OverrideFollowBehaviour => _overrideFollowBehaviour;
|
||||
public Vector2 ScreenPosition => _screenPosition;
|
||||
public Vector2 Damping => _damping;
|
||||
public Vector2 DeadZoneSize => _deadZoneSize;
|
||||
public float LookaheadTime => _lookaheadTime;
|
||||
public float LookaheadSmoothing => _lookaheadSmoothing;
|
||||
public bool DisableFallBias => _disableFallBias;
|
||||
public bool LockHorizontal => _lockHorizontal;
|
||||
public bool LockVertical => _lockVertical;
|
||||
public float DampingDown => _dampingDown;
|
||||
public float DampingUp => _dampingUp;
|
||||
public float LensSize => _lensSize;
|
||||
public float LensSizeDuration => _lensSizeDuration;
|
||||
|
||||
/// <summary>
|
||||
/// 摄像机到场景平面的有效深度(用于透视视口换算)。
|
||||
/// _cameraDepth > 0 时使用配置值,否则自动读取 |transform.position.z|,再兜底 10。
|
||||
/// 来源:区域专有 _cameraDepth(>0 时) → LensConfig SO。
|
||||
/// 未绑定 SO 时返回 0,限位同步工具会在 Inspector 中给出警告。
|
||||
/// </summary>
|
||||
public float CameraDepth
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cameraDepth > 0f) return _cameraDepth;
|
||||
float z = Mathf.Abs(transform.position.z);
|
||||
return z > 0.01f ? z : 10f;
|
||||
return _lensConfig != null ? _lensConfig.cameraDepth : 0f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,16 +154,33 @@ namespace BaseGames.Camera
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
// 黄色:可视区域
|
||||
Vector3 center = new Vector3(_visibleBounds.center.x, _visibleBounds.center.y, 0f);
|
||||
Vector3 size = new Vector3(_visibleBounds.width, _visibleBounds.height, 0.01f);
|
||||
// 黄色:可视区域(本地坐标 + transform.position = 世界坐标)
|
||||
Vector3 center = new Vector3(
|
||||
_visibleBounds.center.x + transform.position.x,
|
||||
_visibleBounds.center.y + transform.position.y, 0f);
|
||||
Vector3 size = new Vector3(_visibleBounds.width, _visibleBounds.height, 0.01f);
|
||||
|
||||
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.10f);
|
||||
Gizmos.DrawCube(center, size);
|
||||
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.90f);
|
||||
Gizmos.DrawWireCube(center, size);
|
||||
|
||||
// 青色:专有 VCam 指示线
|
||||
// 青色:轴向锁定指示
|
||||
if ((_lockHorizontal || _lockVertical) && _confinerCollider != null)
|
||||
{
|
||||
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.9f);
|
||||
var bounds = _confinerCollider.bounds;
|
||||
if (_lockHorizontal)
|
||||
Gizmos.DrawLine(
|
||||
new Vector3(bounds.center.x, bounds.min.y, 0f),
|
||||
new Vector3(bounds.center.x, bounds.max.y, 0f));
|
||||
if (_lockVertical)
|
||||
Gizmos.DrawLine(
|
||||
new Vector3(bounds.min.x, bounds.center.y, 0f),
|
||||
new Vector3(bounds.max.x, bounds.center.y, 0f));
|
||||
}
|
||||
|
||||
// 青绿:专有 VCam 指示线
|
||||
if (_dedicatedCamera != null)
|
||||
{
|
||||
Gizmos.color = new Color(0.2f, 1f, 0.8f, 0.8f);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// Y 轴非对称阻尼扩展。实现下落快、起跳缓的非对称相机追随手感:
|
||||
/// - <b>下落时快速跟随</b>(低阻尼):玩家落下时相机迅速移动,提前呈现地面地形;
|
||||
/// - <b>起跳时缓慢上移</b>(高阻尼):相机不会在跳跃峰值前立刻拉高,保留地面视野。
|
||||
///
|
||||
/// <para>使用须知:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>挂载在 VCamA / VCamB 上(<see cref="CameraStateController"/> 初始化时自动识别)。</item>
|
||||
/// <item>此扩展存在时,<see cref="CameraStateController.ConfigureSlot"/> 会自动将
|
||||
/// <see cref="CinemachinePositionComposer.Damping"/> 的 Y 分量清零,避免双重阻尼。</item>
|
||||
/// <item>阻尼值可由 <see cref="CameraArea"/> 通过 <c>DampingDown</c> / <c>DampingUp</c>
|
||||
/// 属性 per-area 覆写,无需手动修改此组件。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[AddComponentMenu("Cinemachine/Extensions/Camera Asymmetric Damping")]
|
||||
[DisallowMultipleComponent]
|
||||
public class CameraAsymmetricDampingExtension : CinemachineExtension
|
||||
{
|
||||
[Tooltip("相机向下(下落)时的 Y 轴阻尼(秒)。\n" +
|
||||
"越小跟随越快,玩家能提前看到地面。推荐 0.05 ~ 0.15。")]
|
||||
[SerializeField] private float _dampingDown = 0.08f;
|
||||
|
||||
[Tooltip("相机向上(起跳)时的 Y 轴阻尼(秒)。\n" +
|
||||
"越大跟随越慢,保留地面视野、不会在起跳瞬间拉高。推荐 0.5 ~ 0.8。")]
|
||||
[SerializeField] private float _dampingUp = 0.65f;
|
||||
|
||||
/// <summary>供 <see cref="CameraStateController"/> 运行时按区域写入阻尼值。</summary>
|
||||
public float DampingDown { get => _dampingDown; set => _dampingDown = value; }
|
||||
/// <summary>供 <see cref="CameraStateController"/> 运行时按区域写入阻尼值。</summary>
|
||||
public float DampingUp { get => _dampingUp; set => _dampingUp = value; }
|
||||
|
||||
private float _smoothedY;
|
||||
private bool _initialized;
|
||||
|
||||
protected override void PostPipelineStageCallback(
|
||||
CinemachineVirtualCameraBase vcam,
|
||||
CinemachineCore.Stage stage,
|
||||
ref CameraState state,
|
||||
float deltaTime)
|
||||
{
|
||||
if (stage != CinemachineCore.Stage.Body) return;
|
||||
|
||||
// deltaTime <= 0:编辑器预览 / 初始帧,重置平滑器避免脏状态
|
||||
if (deltaTime <= 0f)
|
||||
{
|
||||
_initialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 此时 state.RawPosition 是 CinemachinePositionComposer(Damping.y = 0)输出的"理想"位置
|
||||
float idealY = state.RawPosition.y;
|
||||
|
||||
if (!_initialized)
|
||||
{
|
||||
_smoothedY = idealY;
|
||||
_initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// idealY < _smoothedY → 相机目标在当前位置下方(玩家下落) → 低阻尼快速跟随
|
||||
// idealY > _smoothedY → 相机目标在当前位置上方(玩家起跳) → 高阻尼缓慢跟随
|
||||
float damping = idealY < _smoothedY ? _dampingDown : _dampingUp;
|
||||
float t = damping > 0f
|
||||
? 1f - Mathf.Exp(-deltaTime / damping)
|
||||
: 1f;
|
||||
|
||||
_smoothedY = Mathf.LerpUnclamped(_smoothedY, idealY, t);
|
||||
|
||||
var pos = state.RawPosition;
|
||||
pos.y = _smoothedY;
|
||||
state.RawPosition = pos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置内部平滑状态。在相机硬切(instantCut)时由 CameraStateController 调用,
|
||||
/// 确保新房间的 Y 坐标从目标位置开始,不受旧房间阻尼状态影响。
|
||||
/// </summary>
|
||||
public void ResetState() => _initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e733a7cb718909842b12f5994eb841c4
|
||||
guid: cb5a7225ab133e74b81d1f0ae22ccc77
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
43
Assets/_Game/Scripts/Camera/CameraAxisLockExtension.cs
Normal file
43
Assets/_Game/Scripts/Camera/CameraAxisLockExtension.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// Cinemachine 扩展:在 Body 阶段之后硬锁定相机某一轴向。
|
||||
///
|
||||
/// 用途:
|
||||
/// - <see cref="LockX"/> = true → 垂直竖井 / 电梯:相机仅上下移动,X 固定于限位区域中心。
|
||||
/// - <see cref="LockY"/> = true → 水平走廊:相机仅左右移动,Y 固定于限位区域中心。
|
||||
///
|
||||
/// 由 <see cref="CameraStateController"/> 在切换区域时自动写入 <see cref="LockedX"/> /
|
||||
/// <see cref="LockedY"/>(从 ConfinerCollider.bounds.center 取值)并切换锁定开关。
|
||||
/// </summary>
|
||||
[AddComponentMenu("Cinemachine/Extensions/Camera Axis Lock")]
|
||||
[DisallowMultipleComponent]
|
||||
public class CameraAxisLockExtension : CinemachineExtension
|
||||
{
|
||||
/// <summary>锁定 X 轴(垂直竖井)。</summary>
|
||||
[HideInInspector] public bool LockX = false;
|
||||
/// <summary>锁定 Y 轴(水平走廊)。</summary>
|
||||
[HideInInspector] public bool LockY = false;
|
||||
/// <summary>X 轴锁定到的世界坐标(由 CameraStateController 写入)。</summary>
|
||||
[HideInInspector] public float LockedX = 0f;
|
||||
/// <summary>Y 轴锁定到的世界坐标(由 CameraStateController 写入)。</summary>
|
||||
[HideInInspector] public float LockedY = 0f;
|
||||
|
||||
protected override void PostPipelineStageCallback(
|
||||
CinemachineVirtualCameraBase vcam,
|
||||
CinemachineCore.Stage stage,
|
||||
ref CameraState state,
|
||||
float deltaTime)
|
||||
{
|
||||
if (stage != CinemachineCore.Stage.Body) return;
|
||||
|
||||
var pos = state.RawPosition;
|
||||
if (LockX) pos.x = LockedX;
|
||||
if (LockY) pos.y = LockedY;
|
||||
state.RawPosition = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af7e12583264b8c4da8dcd69df274793
|
||||
guid: 7e2e7849ca8d76f438c4b2899c9fb421
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -1,22 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BaseGames/Camera/CameraConfig")]
|
||||
public class CameraConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("跟随")]
|
||||
public float FollowDamping = 0.15f;
|
||||
public float LookAheadTime = 0.3f;
|
||||
public float LookAheadSmoothing = 0.1f;
|
||||
public Vector2 DeadZoneSize = new Vector2(1f, 0.5f);
|
||||
public Vector2 SoftZoneSize = new Vector2(2.5f, 2f);
|
||||
|
||||
[Header("偏移")]
|
||||
public float LookDownOffset = -1.5f;
|
||||
public float LookUpOffset = 1.5f;
|
||||
|
||||
[Header("画面抖动默认强度")]
|
||||
public float DefaultImpulseStrength = 0.3f;
|
||||
}
|
||||
}
|
||||
136
Assets/_Game/Scripts/Camera/CameraFallBiasExtension.cs
Normal file
136
Assets/_Game/Scripts/Camera/CameraFallBiasExtension.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 下坠视野偏置扩展。
|
||||
///
|
||||
/// 玩家持续高速下落超过 <see cref="_activationDelay"/> 秒后,
|
||||
/// 将相机 Y 坐标平滑下移最多 <see cref="_maxShift"/> 世界单位,
|
||||
/// 使玩家出现在画面上方区域,提前暴露落点地形。
|
||||
/// 玩家减速或着地后快速复位。
|
||||
///
|
||||
/// <para>挂载顺序(三者顺序必须严格遵守):</para>
|
||||
/// <list type="number">
|
||||
/// <item><see cref="CameraAsymmetricDampingExtension"/> — 先对 Y 轴做非对称阻尼平滑;</item>
|
||||
/// <item><b>本扩展(CameraFallBiasExtension)</b> — 将偏置叠加到平滑后的 Y 上;</item>
|
||||
/// <item><c>CinemachineConfiner2D</c> — 最后将偏置后的位置裁剪回限位边界内。</item>
|
||||
/// </list>
|
||||
/// <para>如果顺序错误(本扩展在 Confiner 之后),偏置会导致相机超出限位边界且不被修正。</para>
|
||||
/// </summary>
|
||||
[AddComponentMenu("Cinemachine/Extensions/Camera Fall Bias")]
|
||||
[DisallowMultipleComponent]
|
||||
public class CameraFallBiasExtension : CinemachineExtension
|
||||
{
|
||||
[Tooltip("触发下坠偏置所需的最小下落速度(世界单位/秒,绝对值)。\n" +
|
||||
"低于此值不激活(短跳不触发)。推荐 6~10。")]
|
||||
[Range(1f, 20f)]
|
||||
[SerializeField] private float _fallSpeedThreshold = 7f;
|
||||
|
||||
[Tooltip("持续下落超过此时长后开始偏置(秒)。\n" +
|
||||
"避免短暂跳跃/下落触发偏移。推荐 0.25~0.40。")]
|
||||
[Range(0f, 1f)]
|
||||
[SerializeField] private float _activationDelay = 0.30f;
|
||||
|
||||
[Tooltip("最大相机 Y 偏移量(世界单位,相机向下移动 = 玩家在画面上方 = 视野暴露下方地形)。\n" +
|
||||
"推荐 1.5~2.5 单位。")]
|
||||
[Range(0f, 5f)]
|
||||
[SerializeField] private float _maxShift = 2f;
|
||||
|
||||
[Tooltip("偏置增加速度(Lerp 系数)。越大偏移越快达到最大值。推荐 3~5。")]
|
||||
[SerializeField] private float _shiftSpeed = 3f;
|
||||
|
||||
[Tooltip("偏置复位速度。着陆后应快速恢复以避免画面跳变。推荐 8~12。")]
|
||||
[SerializeField] private float _resetSpeed = 10f;
|
||||
|
||||
// ── 内部状态 ──────────────────────────────────────────────────────────
|
||||
private float _configuredMaxShift = -1f; // -1 = 不覆盖,使用检查器默认属性值
|
||||
private float _lastFollowY;
|
||||
private float _smoothedVY;
|
||||
private bool _initialized;
|
||||
private float _fallTimer;
|
||||
private float _currentShift;
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 由 <see cref="CameraStateController.ConfigureSlot"/> 调用:
|
||||
/// 传入 0 表示禁用此区域的下坠偏置;-1 表示使用检查器默认属性值。
|
||||
/// </summary>
|
||||
public void SetConfiguredMax(float maxShift) => _configuredMaxShift = maxShift;
|
||||
|
||||
/// <summary>
|
||||
/// 重置内部状态。在相机硬切(instantCut)时由 CameraStateController 调用,
|
||||
/// 避免旧房间的下坠计时 / 偷移量带入新房间。
|
||||
/// </summary>
|
||||
public void ResetState()
|
||||
{
|
||||
_initialized = false;
|
||||
_fallTimer = 0f;
|
||||
_currentShift = 0f;
|
||||
_smoothedVY = 0f;
|
||||
}
|
||||
// ── Extension ─────────────────────────────────────────────────────────
|
||||
|
||||
protected override void PostPipelineStageCallback(
|
||||
CinemachineVirtualCameraBase vcam,
|
||||
CinemachineCore.Stage stage,
|
||||
ref CameraState state,
|
||||
float deltaTime)
|
||||
{
|
||||
if (stage != CinemachineCore.Stage.Body) return;
|
||||
|
||||
if (deltaTime <= 0f)
|
||||
{
|
||||
_initialized = false;
|
||||
_fallTimer = 0f;
|
||||
_currentShift = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
Transform follow = vcam.Follow;
|
||||
if (follow == null) return;
|
||||
|
||||
if (!_initialized)
|
||||
{
|
||||
_lastFollowY = follow.position.y;
|
||||
_initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 估算玩家 Y 轴速度(负值 = 下落)────────────────────────────
|
||||
float rawVY = (follow.position.y - _lastFollowY) / deltaTime;
|
||||
_lastFollowY = follow.position.y;
|
||||
_smoothedVY = Mathf.Lerp(_smoothedVY, rawVY, deltaTime * 10f);
|
||||
|
||||
bool isFalling = _smoothedVY < -_fallSpeedThreshold;
|
||||
|
||||
// ── 下落计时器 ────────────────────────────────────────────────
|
||||
if (isFalling)
|
||||
_fallTimer = Mathf.Min(_fallTimer + deltaTime, _activationDelay + 1f);
|
||||
else
|
||||
_fallTimer = Mathf.Max(_fallTimer - deltaTime * 3f, 0f); // 快速衰减
|
||||
|
||||
// ── 目标偏置 ──────────────────────────────────────────────────
|
||||
// 超过 activationDelay 后线性增加偏置;0.4s 达到最大
|
||||
float effectiveMax = _configuredMaxShift >= 0f ? _configuredMaxShift : _maxShift;
|
||||
float ratio = Mathf.Clamp01((_fallTimer - _activationDelay) / 0.4f);
|
||||
float targetShift = -effectiveMax * ratio; // 负就:相机向下
|
||||
|
||||
// 使用指数衰减公式(帧率无关)替代 Lerp*deltaTime
|
||||
float dampingTime = targetShift < _currentShift
|
||||
? 1f / Mathf.Max(_shiftSpeed, 0.001f)
|
||||
: 1f / Mathf.Max(_resetSpeed, 0.001f);
|
||||
float t = 1f - Mathf.Exp(-deltaTime / dampingTime);
|
||||
_currentShift = Mathf.Lerp(_currentShift, targetShift, t);
|
||||
|
||||
// ── 写入相机 Y 偏置 ───────────────────────────────────────────
|
||||
if (Mathf.Abs(_currentShift) > 0.001f)
|
||||
{
|
||||
var pos = state.RawPosition;
|
||||
pos.y += _currentShift;
|
||||
state.RawPosition = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Camera/CameraFallBiasExtension.cs.meta
Normal file
11
Assets/_Game/Scripts/Camera/CameraFallBiasExtension.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 67a1710d47e3d4c4b9ac15dcb4a17036
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
38
Assets/_Game/Scripts/Camera/CameraLensConfigSO.cs
Normal file
38
Assets/_Game/Scripts/Camera/CameraLensConfigSO.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局相机镜头配置。
|
||||
///
|
||||
/// 作为 <see cref="CameraStateController"/> 和各 <see cref="CameraArea"/> 之间的
|
||||
/// 单一参数来源:
|
||||
/// - Persistent 场景的 <see cref="CameraStateController"/> 在 Awake 时将
|
||||
/// <see cref="fieldOfView"/> 写入两台全局 VCam 的 Lens。
|
||||
/// - Room 场景的 <see cref="CameraArea"/> 引用同一 SO,编辑器工具在计算限位多边形
|
||||
/// 时直接读取,无需依赖 Persistent 场景是否已加载。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// 1. Project 窗口右键 → BaseGames/Camera/Lens Config 新建一个 SO 资产。
|
||||
/// 2. 将该资产同时赋给 CameraStateController._lensConfig 和所有 CameraArea._lensConfig。
|
||||
/// 3. 修改 <see cref="fieldOfView"/> 后,编辑器会自动重新同步所有已打开场景中的
|
||||
/// CameraArea 限位多边形。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Camera/Lens Config", fileName = "CameraLensConfig")]
|
||||
public class CameraLensConfigSO : ScriptableObject
|
||||
{
|
||||
[Tooltip("全局虚拟相机的垂直 FOV(度)。\n" +
|
||||
"修改此值后,编辑器会自动对所有已打开场景中的 CameraArea 重新同步限位多边形。\n" +
|
||||
"运行时由 CameraStateController 在 Awake 时应用到全局 VCam。")]
|
||||
[Range(1f, 179f)]
|
||||
public float fieldOfView = 60f;
|
||||
|
||||
[Tooltip("摄像机到场景平面(Z = 0)的垂直距离(世界单位)。\n" +
|
||||
"与 fieldOfView 共同决定透视相机的视口尺寸,\n" +
|
||||
"用于将可视区域(VisibleBounds)换算为 CinemachineConfiner2D 限位多边形。\n" +
|
||||
"推荐与 Persistent 场景中相机 Transform 的 |Z| 保持一致(通常为 10)。\n" +
|
||||
"CameraArea._cameraDepth > 0 时以区域专有值优先覆盖此全局值。")]
|
||||
[Min(0.1f)]
|
||||
public float cameraDepth = 10f;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Camera/CameraLensConfigSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Camera/CameraLensConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e825a1ad33662d41819655575a49941
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
196
Assets/_Game/Scripts/Camera/CameraLookSystem.cs
Normal file
196
Assets/_Game/Scripts/Camera/CameraLookSystem.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 双轴窥视系统(Look Up / Down / Left / Right)。
|
||||
///
|
||||
/// - <b>垂直窥视</b>:玩家按住垂直方向超过 _holdDelayV 秒后,
|
||||
/// 相机在 Y 轴漂移最多 _lookDistanceV 个世界单位,松开后平滑回弹。
|
||||
/// - <b>水平窥视</b>:玩家静止时按住左/右超过 _holdDelayH 秒后,
|
||||
/// 相机在 X 轴漂移最多 _lookDistanceH 个单位。
|
||||
/// - <b>速度门控</b>:玩家速度超过 _speedGateThreshold 时窥视不再激活。
|
||||
///
|
||||
/// 挂载位置:Persistent 场景 [Camera] 节点下。
|
||||
/// CameraStateController 持有此组件的引用(_lookSystem 字段),
|
||||
/// 在 SetFollowTarget 时自动注册玩家目标,VCam.Follow 指向 VirtualTarget。
|
||||
///
|
||||
/// 玩家输入接入:由 PlayerController 调用 SetLookInput(float, float)。
|
||||
/// </summary>
|
||||
public class CameraLookSystem : MonoBehaviour
|
||||
{
|
||||
[Header("窥视参数 —— 垂直")]
|
||||
[Tooltip("持续按住垂直方向键多少秒后触发垂直窥视。0 = 立即触发。推荐 0.8。")]
|
||||
[SerializeField] private float _holdDelayV = 0.8f;
|
||||
|
||||
[Tooltip("最大垂直偏移量(世界单位)。推荐 3~4 单位。")]
|
||||
[SerializeField] private float _lookDistanceV = 3.5f;
|
||||
|
||||
[Tooltip("垂直偏移过渡速度(越大收敛越快)。")]
|
||||
[SerializeField] private float _lookSpeedV = 2.5f;
|
||||
|
||||
[Tooltip("垂直回弹速度(建议稍快于 _lookSpeedV,避免回弹拖沓)。")]
|
||||
[SerializeField] private float _resetSpeedV = 5f;
|
||||
|
||||
[Header("窥视参数 —— 水平")]
|
||||
[Tooltip("静止后持续按住水平方向键多少秒后触发水平窥视。推荐 0.5。")]
|
||||
[SerializeField] private float _holdDelayH = 0.5f;
|
||||
|
||||
[Tooltip("最大水平偏移量(世界单位)。水平面比垂直面小,避免与 Lookahead 叠加过度。推荐 2.5。")]
|
||||
[SerializeField] private float _lookDistanceH = 2.5f;
|
||||
|
||||
[Tooltip("水平偏移过渡速度。")]
|
||||
[SerializeField] private float _lookSpeedH = 2.0f;
|
||||
|
||||
[Tooltip("水平回弹速度。")]
|
||||
[SerializeField] private float _resetSpeedH = 5f;
|
||||
|
||||
[Header("速度门控")]
|
||||
[Tooltip("玩家移动速度超过此值时窥视系统不再新增偏移,已有偏移平滑回弹。推荐 2.5。")]
|
||||
[SerializeField] private float _speedGateThreshold = 2.5f;
|
||||
|
||||
// ── 内部状态 ──────────────────────────────────────────────────────────
|
||||
|
||||
private Transform _baseTarget;
|
||||
private Transform _virtualTargetTransform;
|
||||
private Vector3 _lastBasePosition;
|
||||
private float _estimatedSpeed;
|
||||
|
||||
// 垂直窥视
|
||||
private float _holdTimerV;
|
||||
private float _inputY;
|
||||
private float _currentOffsetY;
|
||||
private float _targetOffsetY;
|
||||
|
||||
// 水平窥视
|
||||
private float _holdTimerH;
|
||||
private float _inputX;
|
||||
private float _currentOffsetX;
|
||||
private float _targetOffsetX;
|
||||
|
||||
/// <summary>
|
||||
/// VCam 应跟随此 Transform(玩家位置 + 窥视偏移)。
|
||||
/// 由 <see cref="CameraStateController"/> 赋值给 VCam.Follow。
|
||||
/// </summary>
|
||||
public Transform VirtualTarget => _virtualTargetTransform;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
var go = new GameObject("[CameraLookTarget]")
|
||||
{
|
||||
hideFlags = HideFlags.HideInHierarchy
|
||||
};
|
||||
DontDestroyOnLoad(go);
|
||||
_virtualTargetTransform = go.transform;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_virtualTargetTransform != null)
|
||||
Destroy(_virtualTargetTransform.gameObject);
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 注册玩家的 CameraFollowTarget。通常由 <see cref="CameraStateController.SetFollowTarget"/> 调用。
|
||||
/// </summary>
|
||||
public void SetBaseTarget(Transform target)
|
||||
{
|
||||
_baseTarget = target;
|
||||
_lastBasePosition = target != null ? target.position : Vector3.zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 传入归一化输入(x = 水平,y = 垂直)。
|
||||
/// 由 PlayerController / InputReader 在 Update 中调用。
|
||||
/// </summary>
|
||||
public void SetLookInput(float horizontal, float vertical)
|
||||
{
|
||||
_inputX = Mathf.Clamp(horizontal, -1f, 1f);
|
||||
_inputY = Mathf.Clamp(vertical, -1f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>仅设置垂直窥视输入(向后兼容旧调用方式)。</summary>
|
||||
public void SetLookInput(float vertical) => SetLookInput(0f, vertical);
|
||||
|
||||
/// <summary>
|
||||
/// 重置窥视状态。房间切换(即时硬切)时调用,
|
||||
/// 避免旧房间的窥视偏移残留影响新房间的相机初始位置。
|
||||
/// </summary>
|
||||
/// <param name="snap">
|
||||
/// true = 立即将当前偏移归零(硬切时推荐);
|
||||
/// false = 仅清除目标偏移,让当前偏移通过正常 Update 平滑回弹。
|
||||
/// </param>
|
||||
public void ResetOffsets(bool snap = false)
|
||||
{
|
||||
_holdTimerV = 0f;
|
||||
_holdTimerH = 0f;
|
||||
_targetOffsetY = 0f;
|
||||
_targetOffsetX = 0f;
|
||||
if (snap)
|
||||
{
|
||||
_currentOffsetY = 0f;
|
||||
_currentOffsetX = 0f;
|
||||
if (_baseTarget != null && _virtualTargetTransform != null)
|
||||
_virtualTargetTransform.position = _baseTarget.position;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (_baseTarget == null || _virtualTargetTransform == null) return;
|
||||
|
||||
// ── 速度估算(速度门控基准)────────────────────────────────────────
|
||||
float dt = Time.deltaTime;
|
||||
if (dt > 0f)
|
||||
{
|
||||
float rawSpeed = (_baseTarget.position - _lastBasePosition).magnitude / dt;
|
||||
_estimatedSpeed = Mathf.Lerp(_estimatedSpeed, rawSpeed, dt * 8f);
|
||||
}
|
||||
_lastBasePosition = _baseTarget.position;
|
||||
|
||||
bool withinGate = _estimatedSpeed < _speedGateThreshold;
|
||||
|
||||
// ── 垂直窥视 ──────────────────────────────────────────────────────
|
||||
if (withinGate && Mathf.Abs(_inputY) > 0.5f)
|
||||
{
|
||||
_holdTimerV += dt;
|
||||
if (_holdTimerV >= _holdDelayV)
|
||||
_targetOffsetY = _inputY * _lookDistanceV;
|
||||
}
|
||||
else
|
||||
{
|
||||
_holdTimerV = 0f;
|
||||
_targetOffsetY = 0f;
|
||||
}
|
||||
|
||||
float speedV = Mathf.Abs(_targetOffsetY) < 0.01f ? _resetSpeedV : _lookSpeedV;
|
||||
_currentOffsetY = Mathf.Lerp(_currentOffsetY, _targetOffsetY, dt * speedV);
|
||||
|
||||
// ── 水平窥视 ──────────────────────────────────────────────────────
|
||||
if (withinGate && Mathf.Abs(_inputX) > 0.5f)
|
||||
{
|
||||
_holdTimerH += dt;
|
||||
if (_holdTimerH >= _holdDelayH)
|
||||
_targetOffsetX = _inputX * _lookDistanceH;
|
||||
}
|
||||
else
|
||||
{
|
||||
_holdTimerH = 0f;
|
||||
_targetOffsetX = 0f;
|
||||
}
|
||||
|
||||
float speedH = Mathf.Abs(_targetOffsetX) < 0.01f ? _resetSpeedH : _lookSpeedH;
|
||||
_currentOffsetX = Mathf.Lerp(_currentOffsetX, _targetOffsetX, dt * speedH);
|
||||
|
||||
// ── 虚拟目标 = 玩家位置 + 双轴偏移 ────────────────────────────────
|
||||
_virtualTargetTransform.position = _baseTarget.position
|
||||
+ new Vector3(_currentOffsetX, _currentOffsetY, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Camera/CameraLookSystem.cs.meta
Normal file
11
Assets/_Game/Scripts/Camera/CameraLookSystem.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af0ce1cbe43451741ae32ee518e7bc2a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
@@ -22,6 +25,7 @@ namespace BaseGames.Camera
|
||||
[Header("引用")]
|
||||
[SerializeField] private CinemachineBrain _brain;
|
||||
[SerializeField] private CinemachineImpulseSource _impulseSource;
|
||||
[SerializeField] private CameraLookSystem _lookSystem;
|
||||
|
||||
[Header("全局双 VCam(Persistent 场景中放置两台通用虚拟相机)")]
|
||||
[Tooltip("两台 VCam 交替承接各相机区域,通过优先级 ping-pong 触发混合过渡。\n" +
|
||||
@@ -30,17 +34,38 @@ namespace BaseGames.Camera
|
||||
[SerializeField] private CinemachineCamera _vcamA;
|
||||
[SerializeField] private CinemachineCamera _vcamB;
|
||||
|
||||
[Tooltip("全局 VCam 激活时的优先级(非活跃时为 0)。专有 VCam 的 _dedicatedPriority 须高于此值。")]
|
||||
[Tooltip("全局 VCam 激活时的优先级。专有 VCam 的 _dedicatedPriority 须高于此值。")]
|
||||
[SerializeField] private int _globalActivePriority = 10;
|
||||
|
||||
[Tooltip("待机 VCam 的优先级。\n" +
|
||||
"Cinemachine 3.x 中 Priority = 0 的 VCam 不会被 Brain 选中,导致主相机停止跟随。\n" +
|
||||
"必须 > 0 且 < _globalActivePriority,确保 Brain 始终有可用 VCam,\n" +
|
||||
"同时切换时两台 VCam 均在 Brain 视野内以完成正确的混合过渡。")]
|
||||
[SerializeField] private int _standbyPriority = 1;
|
||||
|
||||
[Header("默认混合配置")]
|
||||
[SerializeField] private CameraBlendProfileSO _defaultBlendProfile;
|
||||
|
||||
[Header("镜头配置")]
|
||||
[Tooltip("全局镜头参数 SO。Awake 时将 fieldOfView 应用到 _vcamA / _vcamB。\n" +
|
||||
"与各 CameraArea 引用同一资产,确保 FOV 参数一致。")]
|
||||
[SerializeField] private CameraLensConfigSO _lensConfig;
|
||||
|
||||
[Header("玩家跟随")]
|
||||
[Tooltip("PlayerController 生成时广播的事件频道(EVT_PlayerSpawned)。\n" +
|
||||
"收到后自动查找 CameraFollowTarget 子节点并赋值给两台全局 VCam 的 Follow。")]
|
||||
[SerializeField] private TransformEventChannelSO _onPlayerSpawned;
|
||||
|
||||
// ── 状态 ──────────────────────────────────────────────────────────────
|
||||
private int _activeSlot = -1; // -1 = 未初始化;0 = A;1 = B
|
||||
private int _activeSlot = -1; // -1 = 未初始化;0 = A;1 = B
|
||||
private CameraArea _roomBaselineArea; // SwitchArea(priority=0) 写入的房间基线,不被触发事件删除
|
||||
private readonly List<(CameraArea area, int priority)> _activeZones = new(); // 玩家当前所在的触发区域集合(priority>0)
|
||||
private CameraArea _currentArea;
|
||||
private CinemachineCamera _activeDedicatedCam;
|
||||
private CinemachineConfiner2D _confinerA;
|
||||
private CinemachineConfiner2D _confinerB;
|
||||
private Transform _currentFollowTarget; // 最后一次 SetFollowTarget 设置的目标,激活 VCam 时自动同步
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,45 +78,170 @@ namespace BaseGames.Camera
|
||||
if (_vcamA != null) _confinerA = _vcamA.GetComponent<CinemachineConfiner2D>();
|
||||
if (_vcamB != null) _confinerB = _vcamB.GetComponent<CinemachineConfiner2D>();
|
||||
|
||||
// 初始两台 VCam 均处于非活跃优先级
|
||||
if (_vcamA != null) _vcamA.Priority = 0;
|
||||
if (_vcamB != null) _vcamB.Priority = 0;
|
||||
// 初始两台 VCam 均处于待机优先级(> 0)
|
||||
// Cinemachine 3.x 中 Priority = 0 的 VCam 不被 Brain 选中,主相机会停止运动
|
||||
if (_vcamA != null) _vcamA.Priority = _standbyPriority;
|
||||
if (_vcamB != null) _vcamB.Priority = _standbyPriority;
|
||||
|
||||
// 将 SO 中的 FOV 应用到两台全局 VCam
|
||||
ApplyLensConfig();
|
||||
|
||||
// 订阅 PlayerSpawned 事件,运行时自动为 VCam 赋值 Follow
|
||||
_onPlayerSpawned?.Subscribe(OnPlayerSpawned).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_subs.Dispose();
|
||||
ServiceLocator.Unregister<ICameraService>(this);
|
||||
}
|
||||
|
||||
private void OnPlayerSpawned(Transform playerRoot)
|
||||
{
|
||||
const string followNodeName = "CameraFollowTarget";
|
||||
Transform follow = playerRoot.Find(followNodeName) ?? playerRoot;
|
||||
SetFollowTarget(follow);
|
||||
}
|
||||
|
||||
private void ApplyLensConfig()
|
||||
{
|
||||
if (_lensConfig == null) return;
|
||||
float fov = _lensConfig.fieldOfView;
|
||||
float depth = _lensConfig.cameraDepth;
|
||||
ApplyLensToVcam(_vcamA, fov, depth);
|
||||
ApplyLensToVcam(_vcamB, fov, depth);
|
||||
}
|
||||
|
||||
private static void ApplyLensToVcam(CinemachineCamera vcam, float fov, float depth)
|
||||
{
|
||||
if (vcam == null) return;
|
||||
var lens = vcam.Lens;
|
||||
lens.FieldOfView = fov;
|
||||
vcam.Lens = lens;
|
||||
// CinemachinePositionComposer.CameraDistance 是运行时真正控制 Z 距离的属性,
|
||||
// 必须同步,否则 Transform Z 被 Cinemachine Pipeline 覆盖
|
||||
var composer = vcam.GetComponent<CinemachinePositionComposer>();
|
||||
if (composer != null)
|
||||
composer.CameraDistance = depth;
|
||||
// 同步 Transform Z,保证编辑器预览与运行时一致
|
||||
var pos = vcam.transform.position;
|
||||
pos.z = -depth;
|
||||
vcam.transform.position = pos;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnValidate() => ApplyLensConfig();
|
||||
#endif
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 切换到目标相机区域。
|
||||
/// <list type="bullet">
|
||||
/// <item>区域有专有 VCam → 激活它(高优先级),全局 VCam 保持当前状态。</item>
|
||||
/// <item>区域无专有 VCam → 配置非活跃全局 VCam,ping-pong 切换优先级触发混合。</item>
|
||||
/// </list>
|
||||
/// 切换到目标相机区域。<paramref name="priority"/> < 当前激活优先级时忽略。
|
||||
/// <para>priority = 0:始终执行(适合 RoomController 入场初始化)。</para>
|
||||
/// </summary>
|
||||
public void SwitchArea(CameraArea targetArea)
|
||||
public void SwitchArea(CameraArea area, int priority = 0, bool instantCut = false)
|
||||
{
|
||||
if (targetArea == null) return;
|
||||
if (area == null) return;
|
||||
|
||||
ApplyBlendProfile(targetArea.BlendProfile ?? _defaultBlendProfile);
|
||||
if (priority == 0)
|
||||
{
|
||||
// 房间初始化 / 无条件切换:记录基线并清空触发集合
|
||||
_roomBaselineArea = area;
|
||||
_activeZones.Clear();
|
||||
ActivateArea(area, instantCut);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetArea.HasDedicated)
|
||||
ActivateDedicated(targetArea);
|
||||
else
|
||||
ActivateGlobalSlot(targetArea);
|
||||
// 触发区域进入:更新集合(同一区域去重后重新加入,保证最新优先级)
|
||||
_activeZones.RemoveAll(e => e.area == area);
|
||||
_activeZones.Add((area, priority));
|
||||
|
||||
// 仅当此区域是当前最优且尚未激活时才切换,避免不必要的 ping-pong
|
||||
CameraArea best = GetEffectiveArea();
|
||||
if (best == area && area != _currentArea)
|
||||
ActivateArea(area, instantCut);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 运行时为两台全局 VCam 统一设置跟随目标(如 Player/CameraFollowTarget)。
|
||||
/// 可在 Player 生成后由任意系统调用。
|
||||
/// 释放 <paramref name="releasedArea"/> 的权限。
|
||||
/// 从优先级栈中移除该区域;若它是当前激活区域,则激活新栈顶(或 fallback)。
|
||||
/// </summary>
|
||||
public void ReleaseArea(CameraArea releasedArea, CameraArea fallback)
|
||||
{
|
||||
if (releasedArea == null) return;
|
||||
|
||||
bool wasActive = releasedArea == _currentArea;
|
||||
int removed = _activeZones.RemoveAll(e => e.area == releasedArea);
|
||||
if (removed == 0) return;
|
||||
|
||||
if (!wasActive) return;
|
||||
|
||||
// 回退到当前最优区域(触发集合 → 房间基线 → fallback)
|
||||
CameraArea next = GetEffectiveArea() ?? fallback;
|
||||
if (next != null && next != _currentArea)
|
||||
ActivateArea(next, instantCut: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前应激活的区域:<see cref="_activeZones"/> 中优先级最高的
|
||||
/// (同优先级取最近进入的),若触发集合为空则回退到 <see cref="_roomBaselineArea"/>。
|
||||
/// </summary>
|
||||
private CameraArea GetEffectiveArea()
|
||||
{
|
||||
CameraArea best = null;
|
||||
int bestPriority = -1;
|
||||
foreach (var (a, p) in _activeZones)
|
||||
if (p >= bestPriority) { bestPriority = p; best = a; }
|
||||
return best ?? _roomBaselineArea;
|
||||
}
|
||||
|
||||
private void ActivateArea(CameraArea area, bool instantCut = false)
|
||||
{
|
||||
_currentArea = area;
|
||||
|
||||
if (instantCut)
|
||||
{
|
||||
// 房间入口硬切:相机立即跳到新房间位置,无混合动画
|
||||
if (_brain != null)
|
||||
_brain.DefaultBlend = new CinemachineBlendDefinition
|
||||
{
|
||||
Style = CinemachineBlendDefinition.Styles.Cut,
|
||||
Time = 0f,
|
||||
};
|
||||
// 重置窥视偏移,避免旧房间的窥视状态残留
|
||||
_lookSystem?.ResetOffsets(snap: true); // 重置所有 VCam 扩展的内部状态,防止旧房间的速度/阻尼估算带入新房间
|
||||
ResetVCamExtensions(_vcamA);
|
||||
ResetVCamExtensions(_vcamB);
|
||||
if (area.HasDedicated) ResetVCamExtensions(area.DedicatedCamera); }
|
||||
else
|
||||
{
|
||||
ApplyBlendProfile(area.BlendProfile ?? _defaultBlendProfile);
|
||||
}
|
||||
|
||||
if (area.LensSize > 0f)
|
||||
SetLensSize(area.LensSize, area.LensSizeDuration);
|
||||
|
||||
if (area.HasDedicated)
|
||||
ActivateDedicated(area);
|
||||
else
|
||||
ActivateGlobalSlot(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 运行时为全局双 VCam 设置跟随目标。
|
||||
/// 若存在 <see cref="CameraLookSystem"/>,VCam 跟随系统输出的虚拟目标(含窥视偏移)。
|
||||
/// </summary>
|
||||
public void SetFollowTarget(Transform followTarget)
|
||||
{
|
||||
if (_vcamA != null) _vcamA.Follow = followTarget;
|
||||
if (_vcamB != null) _vcamB.Follow = followTarget;
|
||||
Transform actual = followTarget;
|
||||
if (_lookSystem != null)
|
||||
{
|
||||
_lookSystem.SetBaseTarget(followTarget);
|
||||
actual = _lookSystem.VirtualTarget;
|
||||
}
|
||||
_currentFollowTarget = actual; // 缓存供后续激活 VCam 时同步
|
||||
if (_vcamA != null) _vcamA.Follow = actual;
|
||||
if (_vcamB != null) _vcamB.Follow = actual;
|
||||
}
|
||||
|
||||
/// <summary>触发屏幕抖动。</summary>
|
||||
@@ -104,6 +254,56 @@ namespace BaseGames.Camera
|
||||
public void TriggerImpulse(float strength = 0.3f)
|
||||
=> TriggerImpulse(Vector3.down * strength);
|
||||
|
||||
/// <summary>
|
||||
/// 平滑过渡正交相机尺寸。<paramref name="duration"/> = 0 时瞬间切换。
|
||||
/// 区域进入时由 <see cref="CameraArea"/> 自动调用;游戏代码也可直接调用。
|
||||
/// </summary>
|
||||
public void SetLensSize(float orthographicSize, float duration = 0f)
|
||||
{
|
||||
if (_lensCoroutine != null) StopCoroutine(_lensCoroutine);
|
||||
if (duration <= 0f) { ApplyLensSizeToAll(orthographicSize); return; }
|
||||
_lensCoroutine = StartCoroutine(LensSizeCo(orthographicSize, duration));
|
||||
}
|
||||
|
||||
private Coroutine _lensCoroutine;
|
||||
|
||||
private void ApplyLensSizeToAll(float size)
|
||||
{
|
||||
SetVcamLens(_vcamA, size);
|
||||
SetVcamLens(_vcamB, size);
|
||||
if (_activeDedicatedCam != null) SetVcamLens(_activeDedicatedCam, size);
|
||||
}
|
||||
|
||||
private static void SetVcamLens(CinemachineCamera vcam, float size)
|
||||
{
|
||||
if (vcam == null) return;
|
||||
var lens = vcam.Lens;
|
||||
lens.OrthographicSize = size;
|
||||
vcam.Lens = lens;
|
||||
}
|
||||
|
||||
private IEnumerator LensSizeCo(float target, float duration)
|
||||
{
|
||||
CinemachineCamera active = GetActiveVcam();
|
||||
if (active == null) { _lensCoroutine = null; yield break; }
|
||||
float start = active.Lens.OrthographicSize;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
ApplyLensSizeToAll(Mathf.Lerp(start, target, elapsed / duration));
|
||||
yield return null;
|
||||
}
|
||||
ApplyLensSizeToAll(target);
|
||||
_lensCoroutine = null;
|
||||
}
|
||||
|
||||
private CinemachineCamera GetActiveVcam()
|
||||
{
|
||||
if (_activeDedicatedCam != null) return _activeDedicatedCam;
|
||||
return _activeSlot == 0 ? _vcamA : (_vcamB != null ? _vcamB : _vcamA);
|
||||
}
|
||||
|
||||
// ── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>激活区域的专有 VCam(高优先级)。</summary>
|
||||
@@ -141,34 +341,149 @@ namespace BaseGames.Camera
|
||||
// 首次调用:直接激活 VCamA(场景淡入阶段,无需混合动画)
|
||||
if (_activeSlot < 0)
|
||||
{
|
||||
var cam = _vcamA ?? _vcamB;
|
||||
var cam = _vcamA ?? _vcamB;
|
||||
var confiner = _vcamA != null ? _confinerA : _confinerB;
|
||||
ConfigureSlot(cam, confiner, area);
|
||||
SyncFollowToVCam(cam);
|
||||
cam.Priority = _globalActivePriority;
|
||||
_activeSlot = _vcamA != null ? 0 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ping-pong:配置非活跃槽 → 升级其优先级 → 降低活跃槽优先级
|
||||
bool nextIsA = _activeSlot != 0;
|
||||
var inactiveCam = nextIsA ? _vcamA : _vcamB;
|
||||
var activeCam = nextIsA ? _vcamB : _vcamA;
|
||||
// 只有一台 VCam 时:直接重新配置,不做优先级 ping-pong
|
||||
// (之前的 null 保护令 inactiveCam == activeCam,导致先升后降为 0 自毁)
|
||||
if (_vcamA == null || _vcamB == null)
|
||||
{
|
||||
var cam = _vcamA ?? _vcamB;
|
||||
var confiner = _vcamA != null ? _confinerA : _confinerB;
|
||||
ConfigureSlot(cam, confiner, area);
|
||||
SyncFollowToVCam(cam);
|
||||
cam.Priority = _globalActivePriority; // 保持激活,不改变 _activeSlot
|
||||
return;
|
||||
}
|
||||
|
||||
// 双 VCam ping-pong:配置非活跃槽 → 升级其优先级 → 降低活跃槽优先级
|
||||
bool nextIsA = _activeSlot != 0;
|
||||
var inactiveCam = nextIsA ? _vcamA : _vcamB;
|
||||
var activeCam = nextIsA ? _vcamB : _vcamA;
|
||||
var inactiveConfiner = nextIsA ? _confinerA : _confinerB;
|
||||
|
||||
// 只有一台 VCam 时降级处理(仍能工作,但无混合动画)
|
||||
if (inactiveCam == null) inactiveCam = activeCam;
|
||||
|
||||
ConfigureSlot(inactiveCam, inactiveConfiner, area);
|
||||
SyncFollowToVCam(inactiveCam); // 确保 Follow 正确(防止 SetFollowTarget 未被调用)
|
||||
inactiveCam.Priority = _globalActivePriority;
|
||||
activeCam.Priority = 0;
|
||||
activeCam.Priority = _standbyPriority; // 降到待机但仍 > 0,Brain 可在混合期间读取其状态
|
||||
_activeSlot = nextIsA ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将最后已知的 Follow 目标同步到指定 VCam(若其 Follow 尚未设置或已过期)。
|
||||
/// </summary>
|
||||
private void SyncFollowToVCam(CinemachineCamera vcam)
|
||||
{
|
||||
if (vcam == null || _currentFollowTarget == null) return;
|
||||
if (vcam.Follow != _currentFollowTarget)
|
||||
vcam.Follow = _currentFollowTarget;
|
||||
}
|
||||
|
||||
private static void ConfigureSlot(
|
||||
CinemachineCamera vcam, CinemachineConfiner2D confiner, CameraArea area)
|
||||
{
|
||||
// 1. Confiner
|
||||
if (confiner != null && area.ConfinerCollider != null)
|
||||
{
|
||||
confiner.BoundingShape2D = area.ConfinerCollider;
|
||||
// 限位多边形已在编辑器中预收缩(可视区域 - 视口半尺寸 = 相机中心运动范围)。
|
||||
// OversizeWindow.MaxWindowSize = 0.001f(极小正值):
|
||||
// 使 Cinemachine 将实际视口高度裁剪至 0.001,几乎不再对多边形额外收缩,
|
||||
// 从而以预收缩后的多边形直接作为相机中心约束边界。
|
||||
// 对于小于视口的房间(预收缩后多边形退化为点),仍正确固定相机于中心。
|
||||
confiner.OversizeWindow = new CinemachineConfiner2D.OversizeWindowSettings
|
||||
{
|
||||
Enabled = true,
|
||||
MaxWindowSize = 0.001f,
|
||||
Padding = 0.1f,
|
||||
};
|
||||
// BoundingShape2D 变更后必须刷新内部缓存路径,否则限位仍使用旧边界
|
||||
confiner.InvalidateLensCache();
|
||||
}
|
||||
else if (confiner != null && area.ConfinerCollider == null)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[CameraStateController] {area.name} 未绑定 ConfinerCollider!" +
|
||||
"请将子节点 AreaBoundary 的 PolygonCollider2D 拖入 CameraArea._confinerCollider 字段。");
|
||||
}
|
||||
|
||||
// 2. 跟随行为覆盖
|
||||
if (area.OverrideFollowBehaviour)
|
||||
{
|
||||
var composer = vcam.GetComponent<CinemachinePositionComposer>();
|
||||
if (composer != null)
|
||||
{
|
||||
// 屏幕位置(Y 偏下 → 玩家稍低于中心,上方更多视野)
|
||||
var comp = composer.Composition;
|
||||
comp.ScreenPosition = area.ScreenPosition;
|
||||
comp.DeadZone.Enabled = true;
|
||||
comp.DeadZone.Size = area.DeadZoneSize;
|
||||
composer.Composition = comp;
|
||||
|
||||
// 阻尼
|
||||
var d = composer.Damping;
|
||||
d.x = area.Damping.x;
|
||||
d.y = area.Damping.y;
|
||||
composer.Damping = d;
|
||||
|
||||
// 非对称 Y 阻尼:若扩展存在,将其按区域配置并清零 Composer Y 阻尼防止叠加
|
||||
var asymDamp = vcam.GetComponent<CameraAsymmetricDampingExtension>();
|
||||
if (asymDamp != null)
|
||||
{
|
||||
asymDamp.DampingDown = area.DampingDown;
|
||||
asymDamp.DampingUp = area.DampingUp;
|
||||
var yd = composer.Damping;
|
||||
yd.y = 0f;
|
||||
composer.Damping = yd;
|
||||
}
|
||||
|
||||
// 引领预测
|
||||
var lah = composer.Lookahead;
|
||||
lah.Enabled = area.LookaheadTime > 0f;
|
||||
lah.Time = area.LookaheadTime;
|
||||
lah.Smoothing = area.LookaheadSmoothing;
|
||||
lah.IgnoreY = true; // 平台跳跃中 Y 轴 Lookahead 会在起跳时猛拉镜头,应关闭
|
||||
composer.Lookahead = lah;
|
||||
|
||||
// 自适应 Lookahead:通知扩展当前区域配置的最大 Lookahead 值
|
||||
var adaptiveLah = vcam.GetComponent<CameraAdaptiveLookaheadExtension>();
|
||||
if (adaptiveLah != null)
|
||||
adaptiveLah.SetConfiguredMax(area.LookaheadTime);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 轴向约束
|
||||
var axisLock = vcam.GetComponent<CameraAxisLockExtension>();
|
||||
if (axisLock != null)
|
||||
{
|
||||
axisLock.LockX = area.LockHorizontal;
|
||||
axisLock.LockY = area.LockVertical;
|
||||
if (area.ConfinerCollider != null)
|
||||
{
|
||||
var center = area.ConfinerCollider.bounds.center;
|
||||
axisLock.LockedX = center.x;
|
||||
axisLock.LockedY = center.y;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 下坠视野偏置(无论是否覆写跟随行为,始终按区域配置)
|
||||
var fallBias = vcam.GetComponent<CameraFallBiasExtension>();
|
||||
if (fallBias != null)
|
||||
fallBias.SetConfiguredMax(area.DisableFallBias ? 0f : -1f);
|
||||
}
|
||||
|
||||
private static void ResetVCamExtensions(CinemachineCamera vcam)
|
||||
{
|
||||
if (vcam == null) return;
|
||||
vcam.GetComponent<CameraAsymmetricDampingExtension>()?.ResetState();
|
||||
vcam.GetComponent<CameraFallBiasExtension>()?.ResetState();
|
||||
vcam.GetComponent<CameraAdaptiveLookaheadExtension>()?.ResetState();
|
||||
}
|
||||
|
||||
private void ApplyBlendProfile(CameraBlendProfileSO profile)
|
||||
@@ -176,5 +491,118 @@ namespace BaseGames.Camera
|
||||
if (_brain != null && profile != null)
|
||||
_brain.DefaultBlend = profile.ToBlendDefinition();
|
||||
}
|
||||
|
||||
// ── 运行时调试覆盖层 ──────────────────────────────────────────────────
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
|
||||
[Header("调试")]
|
||||
[Tooltip("运行时在屏幕左上角显示当前相机区域信息。\n仅在 Editor 和 Development Build 中有效。")]
|
||||
[SerializeField] private bool _showDebugOverlay = false;
|
||||
|
||||
private GUIStyle _debugBoxStyle;
|
||||
private GUIStyle _debugTitleStyle;
|
||||
private GUIStyle _debugRowStyle;
|
||||
private GUIStyle _debugWarnStyle;
|
||||
|
||||
private void InitDebugStyles()
|
||||
{
|
||||
if (_debugBoxStyle != null) return;
|
||||
|
||||
var bg = new Texture2D(1, 1);
|
||||
bg.SetPixel(0, 0, new Color(0f, 0f, 0f, 0.72f));
|
||||
bg.Apply();
|
||||
|
||||
_debugBoxStyle = new GUIStyle(GUI.skin.box)
|
||||
{
|
||||
normal = { background = bg, textColor = Color.white },
|
||||
padding = new RectOffset(10, 10, 8, 8),
|
||||
alignment = TextAnchor.UpperLeft,
|
||||
fontSize = 12,
|
||||
};
|
||||
_debugTitleStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
normal = { textColor = new Color(1f, 0.85f, 0.25f) },
|
||||
fontStyle = FontStyle.Bold,
|
||||
fontSize = 13,
|
||||
};
|
||||
_debugRowStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
normal = { textColor = new Color(0.88f, 0.88f, 0.88f) },
|
||||
fontSize = 12,
|
||||
};
|
||||
_debugWarnStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
normal = { textColor = new Color(1f, 0.45f, 0.35f) },
|
||||
fontSize = 12,
|
||||
};
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (!Application.isPlaying || !_showDebugOverlay) return;
|
||||
InitDebugStyles();
|
||||
|
||||
float x = 12f, y = 12f, w = 320f;
|
||||
|
||||
// 计算高度(先收集内容)
|
||||
string areaName = _currentArea != null ? _currentArea.name : "<无>";
|
||||
string slotLabel = _activeSlot < 0 ? "未初始化"
|
||||
: _activeSlot == 0 ? "VCam A"
|
||||
: "VCam B";
|
||||
string followLabel = _currentFollowTarget != null
|
||||
? _currentFollowTarget.name
|
||||
: "<未设置>";
|
||||
|
||||
bool warnFollow = _currentFollowTarget == null;
|
||||
bool warnNoVCam = _vcamA == null && _vcamB == null;
|
||||
bool warnNoBrain = _brain == null;
|
||||
|
||||
// 区域状态(基线 + 触发区域集合)
|
||||
var zoneLines = new System.Collections.Generic.List<string>();
|
||||
string baselineName = _roomBaselineArea != null ? _roomBaselineArea.name : "<未设置>";
|
||||
string baselineMarker = (_currentArea == _roomBaselineArea && _activeZones.Count == 0) ? " ◄ 激活" : "";
|
||||
zoneLines.Add($" [基线] {baselineName}{baselineMarker}");
|
||||
for (int i = _activeZones.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var e = _activeZones[i];
|
||||
string marker = (e.area == _currentArea) ? " ◄ 激活" : "";
|
||||
zoneLines.Add($" [{e.priority}] {(e.area != null ? e.area.name : "null")}{marker}");
|
||||
}
|
||||
|
||||
int lineCount = 5 + zoneLines.Count + (warnFollow ? 1 : 0) + (warnNoVCam ? 1 : 0) + (warnNoBrain ? 1 : 0);
|
||||
float rowH = 19f;
|
||||
float h = 28f + lineCount * rowH + 8f;
|
||||
|
||||
GUI.Box(new Rect(x, y, w, h), GUIContent.none, _debugBoxStyle);
|
||||
float cy = y + 8f;
|
||||
|
||||
GUI.Label(new Rect(x + 8f, cy, w - 16f, 22f), "[ Camera State Controller ]", _debugTitleStyle);
|
||||
cy += 22f;
|
||||
|
||||
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"当前区域:{areaName}", _debugRowStyle); cy += rowH;
|
||||
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"活跃 VCam 槽:{slotLabel}", _debugRowStyle); cy += rowH;
|
||||
|
||||
string vcamALabel = _vcamA != null ? $"{_vcamA.name} (P={_vcamA.Priority})" : "<未绑定>";
|
||||
string vcamBLabel = _vcamB != null ? $"{_vcamB.name} (P={_vcamB.Priority})" : "<未绑定>";
|
||||
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"VCam A:{vcamALabel}", _debugRowStyle); cy += rowH;
|
||||
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"VCam B:{vcamBLabel}", _debugRowStyle); cy += rowH;
|
||||
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), $"Follow 目标:{followLabel}", warnFollow ? _debugWarnStyle : _debugRowStyle); cy += rowH;
|
||||
|
||||
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "区域状态(基线 + 触发区域):", _debugRowStyle); cy += rowH;
|
||||
foreach (var line in zoneLines)
|
||||
{
|
||||
GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), line, _debugRowStyle);
|
||||
cy += rowH;
|
||||
}
|
||||
|
||||
if (warnFollow)
|
||||
{ GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ Follow 目标未设置(检查 _onPlayerSpawned)", _debugWarnStyle); cy += rowH; }
|
||||
if (warnNoVCam)
|
||||
{ GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ VCam A/B 均未绑定", _debugWarnStyle); cy += rowH; }
|
||||
if (warnNoBrain)
|
||||
{ GUI.Label(new Rect(x + 8f, cy, w - 16f, rowH), "⚠ CinemachineBrain 未绑定", _debugWarnStyle); cy += rowH; }
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,173 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 相机区域切换触发器。玩家进入时通知 <see cref="CameraStateController"/> 切换到目标 <see cref="CameraArea"/>。
|
||||
/// 相机区域切换触发器。
|
||||
/// 当触发区域重叠时,玩家必须先离开当前所在的触发区域,才会切换到下一个区域,
|
||||
/// 而不是进入重叠区域时立即切换。
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[RequireComponent(typeof(BoxCollider2D))]
|
||||
[RequireComponent(typeof(PolygonCollider2D))]
|
||||
public class CameraTriggerZone : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private CameraArea _targetArea;
|
||||
|
||||
[Tooltip("玩家离开此触发区域时回退到的区域(留空则退出时不做处理)。\n" +
|
||||
"通常设为上级/相邻的包含区域,使玩家返回时相机自然过渡。")]
|
||||
[SerializeField] private CameraArea _exitFallbackArea;
|
||||
|
||||
[Tooltip("触发区域优先级。同时在多个触发区域内时,高优先级区域胜出。\n" +
|
||||
"相同优先级则后进入的胜出(推荐默认值 1)。")]
|
||||
[SerializeField] private int _priority = 1;
|
||||
|
||||
[SerializeField] private string _playerTag = "Player";
|
||||
|
||||
private BoxCollider2D _collider;
|
||||
private PolygonCollider2D _collider;
|
||||
private bool _isPlayerInside;
|
||||
|
||||
// ── 静态:跨实例共享触发状态 ──────────────────────────────────────────
|
||||
// 玩家当前物理上所在的所有触发区域(按进入顺序排列)
|
||||
private static readonly List<CameraTriggerZone> s_InsideZones = new();
|
||||
// 当前已向 ICameraService 发出 SwitchArea 请求的触发区域
|
||||
private static CameraTriggerZone s_ActiveZone;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_collider = GetComponent<BoxCollider2D>();
|
||||
_collider = GetComponent<PolygonCollider2D>();
|
||||
_collider.isTrigger = true;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (!Application.isPlaying) return;
|
||||
if (!_isPlayerInside) return;
|
||||
|
||||
_isPlayerInside = false;
|
||||
s_InsideZones.Remove(this);
|
||||
if (s_ActiveZone == this)
|
||||
Deactivate(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 若玩家出生时已在触发区域内,OnTriggerEnter2D 不会触发。
|
||||
/// 延迟一帧(确保 RoomController.Start 先完成基准区域设置)后主动检测。
|
||||
/// </summary>
|
||||
private IEnumerator Start()
|
||||
{
|
||||
if (!Application.isPlaying) yield break;
|
||||
|
||||
// 等一帧:让 RoomController.Start(priority=0)先建立基准区域,
|
||||
// 再以 _priority 叠加子区域,保证栈顺序正确。
|
||||
yield return null;
|
||||
|
||||
if (_targetArea == null) yield break;
|
||||
|
||||
GameObject player = GameObject.FindWithTag(_playerTag);
|
||||
if (player == null || !_collider.OverlapPoint(player.transform.position)) yield break;
|
||||
|
||||
// OnTriggerEnter2D 可能已先一步处理,避免重复加入
|
||||
if (!_isPlayerInside)
|
||||
{
|
||||
_isPlayerInside = true;
|
||||
s_InsideZones.Add(this);
|
||||
}
|
||||
|
||||
if (s_ActiveZone == null)
|
||||
Activate(this);
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!Application.isPlaying) return;
|
||||
if (!other.CompareTag(_playerTag)) return;
|
||||
if (_targetArea == null || _isPlayerInside) return;
|
||||
|
||||
var service = ServiceLocator.GetOrDefault<ICameraService>();
|
||||
if (service == null) return;
|
||||
_isPlayerInside = true;
|
||||
s_InsideZones.Add(this);
|
||||
|
||||
if (_targetArea != null)
|
||||
service.SwitchArea(_targetArea);
|
||||
// 没有激活的触发区域 → 立即切换
|
||||
// 已有激活的触发区域 → 等玩家离开后再接管(避免重叠区域间提前切换)
|
||||
if (s_ActiveZone == null)
|
||||
Activate(this);
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
if (!Application.isPlaying) return;
|
||||
if (!other.CompareTag(_playerTag)) return;
|
||||
if (!_isPlayerInside) return;
|
||||
|
||||
_isPlayerInside = false;
|
||||
s_InsideZones.Remove(this);
|
||||
|
||||
if (s_ActiveZone == this)
|
||||
Deactivate(this);
|
||||
}
|
||||
|
||||
// ── 静态辅助方法 ────────────────────────────────────────────────────────
|
||||
|
||||
private static void Activate(CameraTriggerZone zone)
|
||||
{
|
||||
s_ActiveZone = zone;
|
||||
ServiceLocator.GetOrDefault<ICameraService>()?.SwitchArea(zone._targetArea, zone._priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 离开 <paramref name="leaving"/> 时的处理:
|
||||
/// 若还有其他触发区域,先激活最优者再释放 leaving(避免短暂回退到房间基线);
|
||||
/// 否则直接释放并使用 <see cref="_exitFallbackArea"/>。
|
||||
/// </summary>
|
||||
private static void Deactivate(CameraTriggerZone leaving)
|
||||
{
|
||||
ICameraService svc = ServiceLocator.GetOrDefault<ICameraService>();
|
||||
if (s_InsideZones.Count > 0)
|
||||
{
|
||||
// 先激活下一个,再释放 leaving —— 此时 _currentArea 已更新为 next,
|
||||
// ReleaseArea(leaving) 中 wasActive=false,仅从 _activeZones 移除,不触发额外跳转
|
||||
CameraTriggerZone next = SelectBest();
|
||||
s_ActiveZone = next;
|
||||
svc?.SwitchArea(next._targetArea, next._priority);
|
||||
svc?.ReleaseArea(leaving._targetArea, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
s_ActiveZone = null;
|
||||
svc?.ReleaseArea(leaving._targetArea, leaving._exitFallbackArea);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>从 <see cref="s_InsideZones"/> 中选出优先级最高的区域。</summary>
|
||||
private static CameraTriggerZone SelectBest()
|
||||
{
|
||||
CameraTriggerZone best = s_InsideZones[0];
|
||||
for (int i = 1; i < s_InsideZones.Count; i++)
|
||||
if (s_InsideZones[i]._priority > best._priority)
|
||||
best = s_InsideZones[i];
|
||||
return best;
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (_collider == null) _collider = GetComponent<BoxCollider2D>();
|
||||
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.25f);
|
||||
if (_collider == null) _collider = GetComponent<PolygonCollider2D>();
|
||||
if (_collider == null || _collider.pathCount == 0) return;
|
||||
|
||||
var pts = new System.Collections.Generic.List<Vector2>();
|
||||
_collider.GetPath(0, pts);
|
||||
if (pts.Count < 2) return;
|
||||
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
Gizmos.DrawCube(_collider.offset, _collider.size);
|
||||
Vector2 off = _collider.offset;
|
||||
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.8f);
|
||||
Gizmos.DrawWireCube(_collider.offset, _collider.size);
|
||||
for (int i = 0; i < pts.Count; i++)
|
||||
{
|
||||
Vector2 a = pts[i] + off;
|
||||
Vector2 b = pts[(i + 1) % pts.Count] + off;
|
||||
Gizmos.DrawLine(new Vector3(a.x, a.y), new Vector3(b.x, b.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 相机服务接口。供 CameraTriggerZone 等调用,
|
||||
/// 通过 ServiceLocator.Get<ICameraService>() 访问,无需直接依赖 CameraStateController。
|
||||
/// 相机服务接口。通过 <c>ServiceLocator.GetOrDefault<ICameraService>()</c> 访问。
|
||||
/// </summary>
|
||||
public interface ICameraService
|
||||
{
|
||||
/// <summary>
|
||||
/// 切换到目标相机区域。
|
||||
/// 区域有专有 VCam 时激活它(高优先级);无专有 VCam 时由全局双 VCam 交替承接。
|
||||
/// <paramref name="priority"/> 用于触发区域优先级仲裁:
|
||||
/// 仅当 priority ≥ 当前激活优先级时才执行切换。
|
||||
/// 传 0(默认)时始终切换(适合 RoomController 初始化或场景加载)。
|
||||
/// <para>
|
||||
/// <paramref name="instantCut"/> = true 时使用即时切断混合(房间入口硬切:相机直接跳到目标位置、无过渡动画),
|
||||
/// 同时重置窥视偏移,避免旧房间的窥视偏移残留到新房间。
|
||||
/// 适合通过门传送后的首次相机初始化;区域内触发区域切换应保持默认 false。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
void SwitchArea(CameraArea targetArea);
|
||||
void SwitchArea(CameraArea area, int priority = 0, bool instantCut = false);
|
||||
|
||||
/// <summary>
|
||||
/// 释放 <paramref name="releasedArea"/>(通常由 CameraTriggerZone.OnTriggerExit 调用)。
|
||||
/// 若 <paramref name="releasedArea"/> 正是当前激活区域,切换到 <paramref name="fallback"/>;
|
||||
/// 否则无操作。
|
||||
/// </summary>
|
||||
void ReleaseArea(CameraArea releasedArea, CameraArea fallback);
|
||||
|
||||
/// <summary>为全局双 VCam 设置跟随目标(Player/CameraFollowTarget)。</summary>
|
||||
void SetFollowTarget(Transform followTarget);
|
||||
|
||||
/// <summary>触发屏幕抖动(指定速度矢量)。</summary>
|
||||
void TriggerImpulse(Vector3 velocity);
|
||||
|
||||
/// <summary>触发屏幕抖动(向下方向)。</summary>
|
||||
void TriggerImpulse(float strength = 0.3f);
|
||||
|
||||
/// <summary>
|
||||
/// 平滑过渡正交相机尺寸。<paramref name="duration"/> = 0 时瞬间切换。
|
||||
/// 适用于 Boss 战拉远、特殊演出室拉近等场景。
|
||||
/// </summary>
|
||||
void SetLensSize(float orthographicSize, float duration = 0f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 单房间虚拟相机。激活时提升优先级,停用时降为 0。
|
||||
/// 挂载在每个房间的 CinemachineCamera GameObject 上。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CinemachineCamera))]
|
||||
public class RoomCamera : MonoBehaviour
|
||||
{
|
||||
[Header("房间设置")]
|
||||
[SerializeField] private RoomVisibleArea _visibleArea;
|
||||
[SerializeField] private Vector2 _cameraOffset = Vector2.zero;
|
||||
[SerializeField] private CameraBlendProfileSO _blendProfile;
|
||||
[SerializeField] private int _activePriority = 15;
|
||||
|
||||
[Header("可视区域(透视相机)")]
|
||||
[Tooltip("摄像机应显示的最大可视矩形(世界坐标)。\n" +
|
||||
"在 Scene 视图中可直接拖拽四条边编辑,然后点击 Inspector 中的\n" +
|
||||
"「从可视区域更新限位区域」按钮将其换算为 CinemachineConfiner2D 所需的限位多边形。")]
|
||||
[SerializeField] private Rect _visibleBounds = new Rect(-12f, -6f, 24f, 12f);
|
||||
|
||||
[Tooltip("摄像机到场景平面(Z = 0)的垂直距离,用于透视视口尺寸计算。\n" +
|
||||
"留 0 时自动取 transform.position.z 的绝对值(推荐)。")]
|
||||
[SerializeField] private float _cameraDepth = 0f;
|
||||
|
||||
private CinemachineCamera _vcam;
|
||||
|
||||
private void Awake() => _vcam = GetComponent<CinemachineCamera>();
|
||||
private void OnEnable() => _vcam.Priority = _activePriority;
|
||||
private void OnDisable() => _vcam.Priority = 0;
|
||||
|
||||
public PolygonCollider2D ConfinerCollider => _visibleArea?.Collider;
|
||||
public Vector2 CameraOffset => _cameraOffset;
|
||||
public CameraBlendProfileSO BlendProfile => _blendProfile;
|
||||
public Rect VisibleBounds => _visibleBounds;
|
||||
|
||||
/// <summary>
|
||||
/// 摄像机到场景平面的有效深度。
|
||||
/// _cameraDepth > 0 时使用配置值,否则自动读取 |transform.position.z|,再兜底 10。
|
||||
/// </summary>
|
||||
public float CameraDepth
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cameraDepth > 0f) return _cameraDepth;
|
||||
float z = Mathf.Abs(transform.position.z);
|
||||
return z > 0.01f ? z : 10f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>在 CameraStateController 管理的激活流程中调用。</summary>
|
||||
public void Activate() => gameObject.SetActive(true);
|
||||
public void Deactivate() => gameObject.SetActive(false);
|
||||
|
||||
// ── Gizmo ──────────────────────────────────────────────────────────────
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
// 黄色:可视区域(设计意图——玩家在此房间内的最大可见范围)
|
||||
Vector3 center = new Vector3(_visibleBounds.center.x, _visibleBounds.center.y, 0f);
|
||||
Vector3 size = new Vector3(_visibleBounds.width, _visibleBounds.height, 0.01f);
|
||||
|
||||
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.10f);
|
||||
Gizmos.DrawCube(center, size);
|
||||
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.90f);
|
||||
Gizmos.DrawWireCube(center, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记房间的可见区域(多边形)。供 CinemachineConfiner2D 使用。
|
||||
/// [ExecuteAlways] 确保编辑器中碰撞体立即更新。
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[RequireComponent(typeof(PolygonCollider2D))]
|
||||
public class RoomVisibleArea : MonoBehaviour
|
||||
{
|
||||
private PolygonCollider2D _collider;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_collider = GetComponent<PolygonCollider2D>();
|
||||
_collider.isTrigger = true;
|
||||
}
|
||||
|
||||
public PolygonCollider2D Collider
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_collider == null) _collider = GetComponent<PolygonCollider2D>();
|
||||
return _collider;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ using UnityEngine;
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 致命陷阱(地刺、深渊等)——对齐空洞骑士地刺行为:
|
||||
/// 致命陷阱(地刺、深渊等)——触碰即造成伤害、可无视无敌帧的固定危险区域:
|
||||
///
|
||||
/// ① 玩家 HurtBox 触碰时,通过 HurtBox.ReceiveDamage() 造成一次伤害
|
||||
/// 并携带 IgnoreIFrame 标记(无视翻滚/受击无敌帧,与空洞骑士一致)。
|
||||
/// 并携带 IgnoreIFrame 标记(无视翻滚/受击无敌帧,保证陷阱的绝对威胁性)。
|
||||
/// ② 若伤害导致玩家死亡 → PlayerController.TakeDamage 已 Raise EVT_PlayerDied,
|
||||
/// 走完整死亡流程(死亡动画 → 重载场景)。
|
||||
/// ③ 若玩家存活 → 本组件 Raise EVT_PlayerDied,强制返回最近检查点,
|
||||
@@ -26,7 +26,7 @@ namespace BaseGames.Combat
|
||||
public class LethalTrap : MonoBehaviour
|
||||
{
|
||||
[Header("伤害")]
|
||||
[Tooltip("每次触碰造成的伤害量(对齐空洞骑士 = 1 格血)")]
|
||||
[Tooltip("每次触碰造成的伤害量(建议设为 1,确保任意陷阱触碰即扟一格血量)")]
|
||||
[SerializeField] private int _damage = 1;
|
||||
|
||||
[Header("检测")]
|
||||
|
||||
@@ -8,8 +8,17 @@ namespace BaseGames.Core.Assets
|
||||
public static class AddressKeys
|
||||
{
|
||||
// ── Scenes ──────────────────────────────────────────────────────
|
||||
public const string ScenePersistent = "Scene_Persistent";
|
||||
public const string SceneMainMenu = "Scene_MainMenu";
|
||||
/// <summary>Addressable key,用于 Addressables.LoadSceneAsync。</summary>
|
||||
public const string ScenePersistent = "Scene_Persistent";
|
||||
|
||||
/// <summary>
|
||||
/// Unity 场景名(与文件名一致),用于 SceneManager.LoadScene 和 GameBootstrap。
|
||||
/// 与 <see cref="ScenePersistent"/> 值相同,显式声明以区分两种使用场景。
|
||||
/// </summary>
|
||||
public const string ScenePersistentName = "Scene_Persistent";
|
||||
|
||||
/// <summary>Addressable key,用于 Addressables.LoadSceneAsync。</summary>
|
||||
public const string SceneMainMenu = "Scene_MainMenu";
|
||||
|
||||
// ── Player ──────────────────────────────────────────────────────
|
||||
public const string PrefabPlayer = "PLY_Player";
|
||||
|
||||
62
Assets/_Game/Scripts/Core/GameBootstrap.cs
Normal file
62
Assets/_Game/Scripts/Core/GameBootstrap.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using BaseGames.Core.Assets;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 运行时引导器:在任意场景进入 Play Mode 时,自动保证 Persistent 场景先于起始场景加载。
|
||||
///
|
||||
/// 解决的核心问题:
|
||||
/// 开发者从任意房间场景直接按 Play,GameManager / SceneService 等全局服务不存在。
|
||||
///
|
||||
/// 工作时机(BeforeSceneLoad):
|
||||
/// 此回调在 Unity 加载第一个场景的资产之前触发。
|
||||
/// 在这里调用 SceneManager.LoadScene(Additive) 会将 Persistent 场景加入加载队列,
|
||||
/// 使其 Awake(DefaultExecutionOrder -2000)早于起始场景的 Awake 执行,
|
||||
/// 所有服务在起始场景的第一个 Awake 之前已完成注册。
|
||||
///
|
||||
/// 前提:
|
||||
/// Scene_Persistent 必须已添加到 Build Settings(不需要是 Index 0)。
|
||||
/// Persistent 不应作为自动加载的 scene 0,避免与本脚本冲突导致双重加载。
|
||||
/// 推荐:将 Main Menu 场景作为 Build Index 0;Persistent 作为任意非 0 索引保留。
|
||||
///
|
||||
/// 编辑器 Edit Mode 的便利性由 PersistentSceneAutoLoader(Editor 程序集)负责,
|
||||
/// 本脚本只处理运行时(含发行版 Build)的加载保证。
|
||||
/// </summary>
|
||||
public static class GameBootstrap
|
||||
{
|
||||
// ── 防止同一 Domain 内重复引导 ────────────────────────────────────────
|
||||
// SubsystemRegistration 在每次 Domain Reload 时最早执行,用于重置静态状态。
|
||||
private static bool _bootstrapped;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetOnDomainReload() => _bootstrapped = false;
|
||||
|
||||
// ── 核心引导入口 ──────────────────────────────────────────────────────
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
private static void EnsurePersistentLoaded()
|
||||
{
|
||||
if (_bootstrapped) return;
|
||||
_bootstrapped = true;
|
||||
|
||||
// 若 Persistent 已在 Hierarchy 中(例如从 Persistent 场景本身按 Play),跳过。
|
||||
// 注意:BeforeSceneLoad 时 sceneCount 通常为 0(尚未加载任何场景),
|
||||
// 此检查主要应对极少数场景管理器提前初始化的情况。
|
||||
for (int i = 0; i < SceneManager.sceneCount; i++)
|
||||
{
|
||||
string name = SceneManager.GetSceneAt(i).name;
|
||||
if (name == AddressKeys.ScenePersistentName || name == "Persistent")
|
||||
return;
|
||||
}
|
||||
|
||||
// Additive 加载 Persistent(必须在 Build Settings 中注册)
|
||||
// 由于是 BeforeSceneLoad,此加载在起始场景资产加载前完成,
|
||||
// GameServiceRegistrar.Awake(-2000) 将早于起始场景所有 Awake 执行。
|
||||
#if UNITY_EDITOR
|
||||
Debug.Log("[GameBootstrap] 自动加载 Persistent 场景(BeforeSceneLoad)。");
|
||||
#endif
|
||||
SceneManager.LoadScene(AddressKeys.ScenePersistentName, LoadSceneMode.Additive);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/GameBootstrap.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/GameBootstrap.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7713d082a2fb06b4096d6c5c41150606
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -8,10 +8,19 @@ namespace BaseGames.Core
|
||||
/// <summary>
|
||||
/// 在 Awake 时(最早执行)向 ServiceLocator 注册所有服务。
|
||||
/// 挂载在 Persistent 场景的根 GameObject 上。
|
||||
///
|
||||
/// 重复加载保护:GameBootstrap(BeforeSceneLoad)与 Persistent 同时存在于 Build Settings
|
||||
/// 时可能导致双重加载。_registered 静态标志确保注册逻辑只执行一次,
|
||||
/// 第二个实例的 GameObject 会被立即销毁。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-2000)]
|
||||
public class GameServiceRegistrar : MonoBehaviour
|
||||
{
|
||||
// ── 重复加载保护(对应 GameBootstrap 的双重加载边界情况)────────────
|
||||
private static bool _registered;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetOnDomainReload() => _registered = false;
|
||||
[SerializeField] private DeathRespawnService _deathRespawnService;
|
||||
[SerializeField] private SceneService _sceneService;
|
||||
[SerializeField] private EventChannelRegistry _eventChannelRegistry;
|
||||
@@ -26,6 +35,10 @@ namespace BaseGames.Core
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 重复加载保护:若已有实例完成注册,销毁本对象(Persistent 双重加载的边界情况)
|
||||
if (_registered) { Destroy(gameObject); return; }
|
||||
_registered = true;
|
||||
|
||||
// 若 Inspector 已绑定主 AudioListener,直接使用,跳过全场景扫描
|
||||
if (_primaryListener != null)
|
||||
DisableDuplicateListenersInCurrentScenes();
|
||||
|
||||
@@ -9,14 +9,15 @@ namespace BaseGames.Editor
|
||||
/// <summary>
|
||||
/// CameraArea 自定义 Inspector + Scene GUI。
|
||||
///
|
||||
/// 功能与 <see cref="RoomCameraEditor"/> 一致:
|
||||
/// 功能:
|
||||
/// 1. Scene 视图中直接拖拽黄色矩形四条边,编辑「可视区域」(_visibleBounds)。
|
||||
/// 2. Inspector 按钮「从可视区域更新限位区域(透视)」:
|
||||
/// 根据 FOV 和相机深度计算 PolygonCollider2D 限位范围并写入。
|
||||
///
|
||||
/// FOV 优先级(降序):
|
||||
/// 专有 DedicatedCamera.Lens.FieldOfView
|
||||
/// → CameraStateController._vcamA(Persistent 场景)
|
||||
/// → CameraLensConfigSO.fieldOfView(单一来源,无跨场景依赖)
|
||||
/// → CameraStateController._vcamA(Persistent 场景已加载时)
|
||||
/// → Camera.main.fieldOfView
|
||||
/// → 60f(默认)
|
||||
/// </summary>
|
||||
@@ -24,51 +25,270 @@ namespace BaseGames.Editor
|
||||
public class CameraAreaEditor : UnityEditor.Editor
|
||||
{
|
||||
// ── 颜色常量 ──────────────────────────────────────────────────────────
|
||||
private static readonly Color kVisibleFill = new Color(1f, 0.85f, 0.15f, 0.08f);
|
||||
private static readonly Color kVisibleOutline = new Color(1f, 0.85f, 0.15f, 0.90f);
|
||||
private static readonly Color kConfinerColor = new Color(0.2f, 0.8f, 1.0f, 0.80f);
|
||||
private static readonly Color kVisibleFill = new Color(1.00f, 0.85f, 0.15f, 0.06f);
|
||||
private static readonly Color kVisibleOutline = new Color(1.00f, 0.85f, 0.15f, 1.00f);
|
||||
private static readonly Color kConfinerFill = new Color(0.20f, 0.75f, 1.00f, 0.08f);
|
||||
private static readonly Color kConfinerLine = new Color(0.20f, 0.75f, 1.00f, 0.85f);
|
||||
private static readonly Color kTriggerFill = new Color(0.30f, 1.00f, 0.50f, 0.07f);
|
||||
private static readonly Color kTriggerLine = new Color(0.30f, 1.00f, 0.50f, 0.85f);
|
||||
private static readonly Color kHeaderBg = new Color(0.18f, 0.18f, 0.23f, 1f);
|
||||
private static readonly Color kOk = new Color(0.30f, 0.82f, 0.30f, 1f);
|
||||
private static readonly Color kError = new Color(0.90f, 0.28f, 0.28f, 1f);
|
||||
private static readonly Color kMuted = new Color(0.55f, 0.55f, 0.60f, 1f);
|
||||
|
||||
// ── 折叠状态(每个 CameraArea 实例独立) ─────────────────────────────
|
||||
private bool _foldBase = true;
|
||||
private bool _foldFollow = true;
|
||||
private bool _foldLens = false;
|
||||
private bool _foldCamera = false;
|
||||
private bool _foldTools = false;
|
||||
|
||||
// ── 折叠标题样式缓存(深色背景 + 白色文字)────────────────────────────
|
||||
private static GUIStyle _foldoutHeaderStyle;
|
||||
|
||||
// ── Scene 视图叠加面板样式缓存 ────────────────────────────────────────
|
||||
private static GUIStyle _sceneOverlayBoldStyle;
|
||||
private static GUIStyle _sceneLabelStyle;
|
||||
private static GUIStyle _gizmoTagStyle;
|
||||
// ── 常显标签样式([DrawGizmo])─────────────────────────────────────
|
||||
private static GUIStyle _alwaysLabelShadowStyle;
|
||||
private static GUIStyle _alwaysLabelMainStyle;
|
||||
|
||||
// ══ Inspector ═════════════════════════════════════════════════════════
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
|
||||
EditorGUILayout.Space(8f);
|
||||
EditorGUILayout.LabelField("── 可视区域工具 ──", EditorStyles.boldLabel);
|
||||
|
||||
serializedObject.Update();
|
||||
CameraArea area = (CameraArea)target;
|
||||
|
||||
float vFOV = GetFOV(area);
|
||||
float aspect = GetAspect();
|
||||
float depth = area.CameraDepth;
|
||||
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
|
||||
float halfW = halfH * aspect;
|
||||
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
// ── 基础设置 ──────────────────────────────────────────────────────
|
||||
_foldBase = DrawFoldoutHeader("基础设置", _foldBase);
|
||||
if (_foldBase)
|
||||
{
|
||||
EditorGUILayout.FloatField("垂直 FOV(来源见工具提示)", vFOV);
|
||||
EditorGUILayout.FloatField("有效深度", depth);
|
||||
EditorGUILayout.FloatField("视口半高(世界单位)", halfH);
|
||||
EditorGUILayout.FloatField("视口半宽(世界单位)", halfW);
|
||||
using (new EditorGUI.IndentLevelScope())
|
||||
{
|
||||
var confinerProp = serializedObject.FindProperty("_confinerCollider");
|
||||
bool confinerOk = confinerProp.objectReferenceValue != null;
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
Color prev = GUI.color;
|
||||
GUI.color = confinerOk ? kOk : kError;
|
||||
GUILayout.Label(confinerOk ? "●" : "✗", GUILayout.Width(14f));
|
||||
GUI.color = prev;
|
||||
EditorGUILayout.PropertyField(confinerProp, new GUIContent("Confiner Collider"));
|
||||
}
|
||||
if (!confinerOk)
|
||||
EditorGUILayout.HelpBox("必须绑定子节点 PolygonCollider2D(AreaBoundary),否则 Cinemachine 无法限位。", MessageType.Error);
|
||||
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_visibleBounds"), new GUIContent("Visible Bounds(本地坐标)"));
|
||||
}
|
||||
}
|
||||
|
||||
bool canSync = area.ConfinerCollider != null;
|
||||
if (!canSync)
|
||||
EditorGUILayout.HelpBox("ConfinerCollider 未绑定,无法同步限位区域。", MessageType.Warning);
|
||||
EditorGUILayout.Space(2f);
|
||||
|
||||
using (new EditorGUI.DisabledScope(!canSync))
|
||||
// ── 跟随参数覆盖 ─────────────────────────────────────────────────
|
||||
var overrideProp = serializedObject.FindProperty("_overrideFollowBehaviour");
|
||||
bool overrides = overrideProp.boolValue;
|
||||
_foldFollow = DrawFoldoutHeader(
|
||||
overrides ? "跟随参数覆盖 ●" : "跟随参数覆盖 ○ (使用全局默认)", _foldFollow);
|
||||
if (_foldFollow)
|
||||
{
|
||||
if (GUILayout.Button("从可视区域更新限位区域(透视)", GUILayout.Height(28f)))
|
||||
SyncConfinerFromVisibleBounds(area, vFOV, aspect);
|
||||
using (new EditorGUI.IndentLevelScope())
|
||||
{
|
||||
EditorGUILayout.PropertyField(overrideProp, new GUIContent("Override Follow Behaviour"));
|
||||
if (overrides)
|
||||
{
|
||||
EditorGUILayout.Space(2f);
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_screenPosition"), new GUIContent("Screen Position"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_damping"), new GUIContent("Damping"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_dampingDown"), new GUIContent("Damping Down"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_dampingUp"), new GUIContent("Damping Up"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_deadZoneSize"), new GUIContent("Dead Zone Size"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lookaheadTime"), new GUIContent("Lookahead Time"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lookaheadSmoothing"),new GUIContent("Lookahead Smoothing"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lockHorizontal"), new GUIContent("Lock Horizontal"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lockVertical"), new GUIContent("Lock Vertical"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 图例说明 ─────────────────────────────────────────────────────
|
||||
EditorGUILayout.Space(4f);
|
||||
DrawLegend("■ 黄色矩形(Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
|
||||
DrawLegend("■ 蓝色多边形(Scene 视图)", kConfinerColor, "限位区域 — CinemachineConfiner2D 的运动边界");
|
||||
EditorGUILayout.Space(2f);
|
||||
|
||||
// ── 镜头 & 混合 ──────────────────────────────────────────────────
|
||||
_foldLens = DrawFoldoutHeader("镜头 & 混合配置", _foldLens);
|
||||
if (_foldLens)
|
||||
{
|
||||
using (new EditorGUI.IndentLevelScope())
|
||||
{
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lensSize"), new GUIContent("Lens Size"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_lensSizeDuration"), new GUIContent("Lens Size Duration"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_blendProfile"), new GUIContent("Blend Profile"));
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(2f);
|
||||
|
||||
// ── 专有相机(可选) ──────────────────────────────────────────────
|
||||
_foldCamera = DrawFoldoutHeader("专有相机(可选)", _foldCamera);
|
||||
if (_foldCamera)
|
||||
{
|
||||
using (new EditorGUI.IndentLevelScope())
|
||||
{
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_dedicatedCamera"), new GUIContent("Dedicated Camera"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("_dedicatedPriority"), new GUIContent("Dedicated Priority"));
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(2f);
|
||||
|
||||
// ── 可视区域工具 ──────────────────────────────────────────────────
|
||||
_foldTools = DrawFoldoutHeader("可视区域工具", _foldTools);
|
||||
if (_foldTools)
|
||||
{
|
||||
using (new EditorGUI.IndentLevelScope())
|
||||
{
|
||||
// ── 镜头配置 SO ─────────────────────────────────────────
|
||||
EditorGUILayout.PropertyField(
|
||||
serializedObject.FindProperty("_lensConfig"),
|
||||
new GUIContent("镜头配置 (SO)",
|
||||
"与 CameraStateController 引用同一个 CameraLensConfigSO,\n" +
|
||||
"保证限位计算 FOV 与运行时 VCam 一致。\n" +
|
||||
"SO 中 FOV 修改同时自动重新同步限位多边形。"));
|
||||
|
||||
float vFOV = GetFOV(area);
|
||||
float aspect = GetAspect();
|
||||
|
||||
// ── FOV 来源说明与过期警告 ──────────────────────────
|
||||
string fovNote;
|
||||
Color noteColor;
|
||||
if (area.DedicatedCamera != null)
|
||||
{
|
||||
fovNote = $"来源:专有 VCam ({area.DedicatedCamera.name}) FOV = {vFOV:F1}°";
|
||||
noteColor = kOk;
|
||||
}
|
||||
else if (area.LensConfig != null)
|
||||
{
|
||||
bool isStale = area.ConfinerCollider != null
|
||||
&& area.LastSyncFOV > 0f
|
||||
&& Mathf.Abs(area.LastSyncFOV - vFOV) > 0.05f;
|
||||
if (isStale)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
$"FOV 已从 {area.LastSyncFOV:F1}° 改为 {vFOV:F1}°,限位多边形需要重新同步。",
|
||||
MessageType.Warning);
|
||||
}
|
||||
fovNote = isStale
|
||||
? $"⚠ SO FOV 已从 {area.LastSyncFOV:F1}° 改为 {vFOV:F1}°,限位需重新同步"
|
||||
: $"来源:CameraLensConfigSO FOV = {vFOV:F1}°";
|
||||
noteColor = isStale ? new Color(1f, 0.7f, 0.1f) : kOk;
|
||||
}
|
||||
else
|
||||
{
|
||||
fovNote = $"⚠ 未绑定 LensConfig SO,使用备用来源 FOV = {vFOV:F1}°";
|
||||
noteColor = new Color(1f, 0.7f, 0.1f);
|
||||
EditorGUILayout.HelpBox("建议绑定 CameraLensConfigSO,保证跨场景 FOV 一致。", MessageType.Warning);
|
||||
}
|
||||
|
||||
Color prevC = GUI.color;
|
||||
GUI.color = noteColor;
|
||||
EditorGUILayout.LabelField(fovNote, EditorStyles.miniLabel);
|
||||
GUI.color = prevC;
|
||||
|
||||
EditorGUILayout.Space(4f);
|
||||
|
||||
// ── 只读推算值 ──────────────────────────────────────────
|
||||
float depth = area.CameraDepth;
|
||||
|
||||
// 深度来源说明
|
||||
string depthNote;
|
||||
Color depthColor;
|
||||
var depthProp = serializedObject.FindProperty("_cameraDepth");
|
||||
if (depthProp != null && depthProp.floatValue > 0f)
|
||||
{
|
||||
depthNote = $"来源:区域专有 _cameraDepth = {depth:F1}";
|
||||
depthColor = kOk;
|
||||
}
|
||||
else if (area.LensConfig != null)
|
||||
{
|
||||
depthNote = $"来源:CameraLensConfigSO.cameraDepth = {depth:F1}";
|
||||
depthColor = kOk;
|
||||
}
|
||||
else
|
||||
{
|
||||
depthNote = "⚠ 未绑定 LensConfig SO,CameraDepth = 0,限位同步无效";
|
||||
depthColor = new Color(1f, 0.3f, 0.3f);
|
||||
}
|
||||
{
|
||||
Color prevC2 = GUI.color;
|
||||
GUI.color = depthColor;
|
||||
EditorGUILayout.LabelField(depthNote, EditorStyles.miniLabel);
|
||||
GUI.color = prevC2;
|
||||
}
|
||||
if (area.LensConfig == null)
|
||||
EditorGUILayout.HelpBox("请绑定 CameraLensConfigSO 以提供 cameraDepth,否则限位多边形无法正确生成。", MessageType.Error);
|
||||
|
||||
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
|
||||
float halfW = halfH * aspect;
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
{
|
||||
EditorGUILayout.FloatField("有效深度", depth);
|
||||
EditorGUILayout.FloatField("视口半高(世界单位)", halfH);
|
||||
EditorGUILayout.FloatField("视口半宽(世界单位)", halfW);
|
||||
}
|
||||
|
||||
bool canSync = area.ConfinerCollider != null;
|
||||
if (!canSync)
|
||||
EditorGUILayout.HelpBox("ConfinerCollider 未绑定,无法同步限位区域。", MessageType.Warning);
|
||||
using (new EditorGUI.DisabledScope(!canSync))
|
||||
{
|
||||
if (GUILayout.Button("从可视区域更新限位区域(透视)", GUILayout.Height(26f)))
|
||||
SyncConfinerFromVisibleBounds(area, vFOV, aspect);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(4f);
|
||||
DrawLegend("■ 黄色矩形(Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
|
||||
DrawLegend("■ 蓝色多边形(Scene 视图)", kConfinerLine, "限位区域 — CinemachineConfiner2D 的运动边界");
|
||||
}
|
||||
}
|
||||
|
||||
if (serializedObject.hasModifiedProperties)
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
// ══ 全局 Gizmo(非选中时也显示)═══════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// 在 Scene 视图中始终显示 CameraArea 名称标签(可视区域内部居中)。
|
||||
/// 利用 [DrawGizmo] 实现非选中状态下也常显。
|
||||
/// </summary>
|
||||
[DrawGizmo(GizmoType.NotInSelectionHierarchy | GizmoType.InSelectionHierarchy | GizmoType.Active | GizmoType.Pickable)]
|
||||
private static void DrawAreaNameGizmo(CameraArea area, GizmoType gizmoType)
|
||||
{
|
||||
Rect worldR = area.VisibleBounds;
|
||||
if (worldR.width <= 0f || worldR.height <= 0f) return;
|
||||
|
||||
Vector3 center = new Vector3(worldR.center.x, worldR.center.y, 0f);
|
||||
string text = area.gameObject.name;
|
||||
float sz = HandleUtility.GetHandleSize(center) * 0.028f;
|
||||
|
||||
if (_alwaysLabelShadowStyle == null)
|
||||
_alwaysLabelShadowStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
normal = { textColor = new Color(0f, 0f, 0f, 0.85f) },
|
||||
fontSize = 14,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
};
|
||||
if (_alwaysLabelMainStyle == null)
|
||||
_alwaysLabelMainStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
normal = { textColor = Color.white },
|
||||
fontSize = 14,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
};
|
||||
|
||||
// 阴影偏移,增强居中标签对任意背景的对比度
|
||||
Handles.Label(center + new Vector3(sz, -sz, 0f), text, _alwaysLabelShadowStyle);
|
||||
Handles.Label(center, text, _alwaysLabelMainStyle);
|
||||
}
|
||||
// ══ Scene GUI ════════════════════════════════════════════════════════
|
||||
|
||||
private void OnSceneGUI()
|
||||
@@ -77,23 +297,41 @@ namespace BaseGames.Editor
|
||||
serializedObject.Update();
|
||||
|
||||
SerializedProperty boundsP = serializedObject.FindProperty("_visibleBounds");
|
||||
Rect r = boundsP.rectValue;
|
||||
Rect localR = boundsP.rectValue;
|
||||
// 本地坐标 → 世界坐标,可视区域随 CameraArea GameObject 一同移动
|
||||
Vector2 areaPos = area.transform.position;
|
||||
Rect r = new Rect(localR.x + areaPos.x, localR.y + areaPos.y, localR.width, localR.height);
|
||||
|
||||
// ── 绘制限位多边形(蓝色,参考用) ──────────────────────────────
|
||||
// ── 触发区域(绿色,只读) ─────────────────────────────────────────
|
||||
DrawTriggerZoneGizmos(area);
|
||||
|
||||
// ── 限位多边形(蓝色,只读) ─────────────────────────────────────
|
||||
DrawConfinerGizmo(area);
|
||||
|
||||
// ── 绘制可视区域填充 + 边框 ──────────────────────────────────────
|
||||
// ── 可视区域(黄色) + 尺寸标注 ──────────────────────────────
|
||||
DrawVisibleRect(r);
|
||||
DrawDimensionLabels(r);
|
||||
|
||||
// ── 四条边的拖拽 Handle ──────────────────────────────────────────
|
||||
// ── Handle 编辑(四角 + 四边中点 + 中心移动) ─────────────────────
|
||||
EditorGUI.BeginChangeCheck();
|
||||
EditRectEdges(ref r);
|
||||
EditRectHandles(ref r);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
Undo.RecordObject(area, "Edit Visible Bounds");
|
||||
boundsP.rectValue = r;
|
||||
if (area.ConfinerCollider != null)
|
||||
Undo.RecordObject(area.ConfinerCollider, "Sync Confiner");
|
||||
|
||||
// 世界坐标 → 本地坐标,存入序列化字段
|
||||
boundsP.rectValue = new Rect(r.x - areaPos.x, r.y - areaPos.y, r.width, r.height);
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
|
||||
// 拖拽时自动同步限位多边形(不输出日志)
|
||||
if (area.ConfinerCollider != null)
|
||||
SyncConfinerQuiet(area, GetFOV(area), GetAspect());
|
||||
}
|
||||
|
||||
// ── 叠加信息面板(屏幕空间) ───────────────────────────────────────
|
||||
DrawSceneInfoOverlay(area, r);
|
||||
}
|
||||
|
||||
// ══ 绘制辅助 ═════════════════════════════════════════════════════════
|
||||
@@ -108,13 +346,36 @@ namespace BaseGames.Editor
|
||||
new Vector3(r.xMax, r.yMin, 0f),
|
||||
};
|
||||
|
||||
Handles.DrawSolidRectangleWithOutline(corners, kVisibleFill, kVisibleOutline);
|
||||
// 半透明填充
|
||||
Handles.DrawSolidRectangleWithOutline(corners, kVisibleFill, Color.clear);
|
||||
|
||||
// 2.5px 抗锯齿粗轮廓
|
||||
Handles.color = kVisibleOutline;
|
||||
Handles.DrawAAPolyLine(2.5f,
|
||||
corners[0], corners[1], corners[2], corners[3], corners[0]);
|
||||
|
||||
// 四角小圆点(提示可交互的边界点)
|
||||
float dot = HandleUtility.GetHandleSize(r.center) * 0.04f;
|
||||
foreach (var c in corners)
|
||||
Handles.DrawSolidDisc(c, Vector3.back, dot);
|
||||
}
|
||||
|
||||
/// <summary>在 Scene 视图中标注可视区域的宽高。</summary>
|
||||
private static void DrawDimensionLabels(Rect r)
|
||||
{
|
||||
if (_sceneLabelStyle == null)
|
||||
_sceneLabelStyle = new GUIStyle(EditorStyles.miniLabel)
|
||||
{ normal = { textColor = new Color(1f, 0.85f, 0.15f, 1f) } };
|
||||
|
||||
// 宽度:底边中心正下方
|
||||
Handles.Label(
|
||||
new Vector3(r.xMin + 0.15f, r.yMax - 0.15f, 0f),
|
||||
"Visible Area",
|
||||
EditorStyles.miniLabel);
|
||||
new Vector3(r.center.x - 0.4f, r.yMin - 0.55f, 0f),
|
||||
$"← {r.width:F1} →", _sceneLabelStyle);
|
||||
|
||||
// 高度:右边中心右侧
|
||||
Handles.Label(
|
||||
new Vector3(r.xMax + 0.2f, r.center.y - 0.1f, 0f),
|
||||
$"{r.height:F1}", _sceneLabelStyle);
|
||||
}
|
||||
|
||||
private static void DrawConfinerGizmo(CameraArea area)
|
||||
@@ -123,66 +384,131 @@ namespace BaseGames.Editor
|
||||
if (poly == null || poly.pathCount == 0) return;
|
||||
|
||||
int ptCount = poly.GetTotalPointCount();
|
||||
if (ptCount < 2) return;
|
||||
if (ptCount < 3) return;
|
||||
|
||||
var pts2 = new System.Collections.Generic.List<Vector2>(ptCount);
|
||||
poly.GetPath(0, pts2);
|
||||
var pts3 = new Vector3[ptCount + 1];
|
||||
var pts3 = new Vector3[ptCount];
|
||||
for (int i = 0; i < ptCount; i++)
|
||||
pts3[i] = poly.transform.TransformPoint(pts2[i]);
|
||||
pts3[ptCount] = pts3[0];
|
||||
|
||||
Handles.color = kConfinerColor;
|
||||
Handles.DrawPolyLine(pts3);
|
||||
Handles.Label(
|
||||
(Vector3)poly.transform.TransformPoint(pts2[0]) + new Vector3(0.1f, 0.1f),
|
||||
"Confiner",
|
||||
EditorStyles.miniLabel);
|
||||
DrawPolyGizmo(pts3, kConfinerFill, kConfinerLine, 2.0f);
|
||||
|
||||
if (_gizmoTagStyle == null)
|
||||
_gizmoTagStyle = new GUIStyle(EditorStyles.miniLabel)
|
||||
{ normal = { textColor = Color.white }, fontStyle = FontStyle.Bold };
|
||||
|
||||
Handles.color = kConfinerLine;
|
||||
Handles.Label(pts3[0] + new Vector3(0.15f, 0.15f, 0f), "限位", _gizmoTagStyle);
|
||||
}
|
||||
|
||||
/// <summary>绘制四条边的滑动 Handle,允许用户直接拖拽修改可视区域。</summary>
|
||||
private static void EditRectEdges(ref Rect r)
|
||||
private static void DrawTriggerZoneGizmos(CameraArea area)
|
||||
{
|
||||
float hs = HandleUtility.GetHandleSize(r.center) * 0.10f;
|
||||
var zones = area.GetComponentsInChildren<CameraTriggerZone>(true);
|
||||
foreach (var zone in zones)
|
||||
{
|
||||
var poly = zone.GetComponent<PolygonCollider2D>();
|
||||
if (poly == null || poly.pathCount == 0) continue;
|
||||
|
||||
int ptCount = poly.GetTotalPointCount();
|
||||
if (ptCount < 3) continue;
|
||||
|
||||
var pts2 = new System.Collections.Generic.List<Vector2>(ptCount);
|
||||
poly.GetPath(0, pts2);
|
||||
var pts3 = new Vector3[ptCount];
|
||||
for (int i = 0; i < ptCount; i++)
|
||||
pts3[i] = poly.transform.TransformPoint(pts2[i]);
|
||||
|
||||
DrawPolyGizmo(pts3, kTriggerFill, kTriggerLine, 1.5f);
|
||||
|
||||
if (_gizmoTagStyle == null)
|
||||
_gizmoTagStyle = new GUIStyle(EditorStyles.miniLabel)
|
||||
{ normal = { textColor = Color.white }, fontStyle = FontStyle.Bold };
|
||||
|
||||
Handles.color = kTriggerLine;
|
||||
Handles.Label(pts3[0] + new Vector3(0.15f, 0.15f, 0f), "触发", _gizmoTagStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>绘制闭合多边形 Gizmo:半透明填充(凸多边形)+ 抗锯齿粗轮廓。</summary>
|
||||
private static void DrawPolyGizmo(Vector3[] pts, Color fill, Color line, float lineWidth)
|
||||
{
|
||||
// 凸多边形填充
|
||||
Handles.color = fill;
|
||||
Handles.DrawAAConvexPolygon(pts);
|
||||
|
||||
// 闭合轮廓线
|
||||
var closed = new Vector3[pts.Length + 1];
|
||||
System.Array.Copy(pts, closed, pts.Length);
|
||||
closed[pts.Length] = pts[0];
|
||||
Handles.color = line;
|
||||
Handles.DrawAAPolyLine(lineWidth, closed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 绘制可视区域的交互 Handle:4 个角点(对角缩放)+ 4 个边中点(单轴缩放)+ 中心点(整体移动)。
|
||||
/// </summary>
|
||||
private static void EditRectHandles(ref Rect r)
|
||||
{
|
||||
float hs = HandleUtility.GetHandleSize(r.center) * 0.09f;
|
||||
float hsDot = hs * 0.60f;
|
||||
|
||||
Handles.color = kVisibleOutline;
|
||||
|
||||
// 左边 —— 沿 X 轴滑动
|
||||
// ── 中心:整体移动 ─────────────────────────────────────────────────
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 lp = Handles.Slider(
|
||||
new Vector3(r.xMin, r.center.y, 0f),
|
||||
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
|
||||
Vector3 oldC = new Vector3(r.center.x, r.center.y, 0f);
|
||||
Vector3 newC = Handles.FreeMoveHandle(oldC, hs * 1.3f, Vector3.zero, Handles.CircleHandleCap);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
r.xMin = Mathf.Min(lp.x, r.xMax - 0.1f);
|
||||
r.position += new Vector2(newC.x - oldC.x, newC.y - oldC.y);
|
||||
|
||||
// 右边 —— 沿 X 轴滑动
|
||||
// ── 四角:对角缩放(方形 cap,易抓取) ────────────────────────────
|
||||
// 左下
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 rp = Handles.Slider(
|
||||
new Vector3(r.xMax, r.center.y, 0f),
|
||||
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
r.xMax = Mathf.Max(rp.x, r.xMin + 0.1f);
|
||||
Vector3 bl = Handles.FreeMoveHandle(new Vector3(r.xMin, r.yMin, 0f), hs, Vector3.zero, Handles.RectangleHandleCap);
|
||||
if (EditorGUI.EndChangeCheck()) { r.xMin = Mathf.Min(bl.x, r.xMax - 0.1f); r.yMin = Mathf.Min(bl.y, r.yMax - 0.1f); }
|
||||
|
||||
// 下边 —— 沿 Y 轴滑动
|
||||
// 右下
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 bp = Handles.Slider(
|
||||
new Vector3(r.center.x, r.yMin, 0f),
|
||||
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
r.yMin = Mathf.Min(bp.y, r.yMax - 0.1f);
|
||||
Vector3 br = Handles.FreeMoveHandle(new Vector3(r.xMax, r.yMin, 0f), hs, Vector3.zero, Handles.RectangleHandleCap);
|
||||
if (EditorGUI.EndChangeCheck()) { r.xMax = Mathf.Max(br.x, r.xMin + 0.1f); r.yMin = Mathf.Min(br.y, r.yMax - 0.1f); }
|
||||
|
||||
// 上边 —— 沿 Y 轴滑动
|
||||
// 左上
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 tp = Handles.Slider(
|
||||
new Vector3(r.center.x, r.yMax, 0f),
|
||||
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
r.yMax = Mathf.Max(tp.y, r.yMin + 0.1f);
|
||||
Vector3 tl = Handles.FreeMoveHandle(new Vector3(r.xMin, r.yMax, 0f), hs, Vector3.zero, Handles.RectangleHandleCap);
|
||||
if (EditorGUI.EndChangeCheck()) { r.xMin = Mathf.Min(tl.x, r.xMax - 0.1f); r.yMax = Mathf.Max(tl.y, r.yMin + 0.1f); }
|
||||
|
||||
// 右上
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 tr = Handles.FreeMoveHandle(new Vector3(r.xMax, r.yMax, 0f), hs, Vector3.zero, Handles.RectangleHandleCap);
|
||||
if (EditorGUI.EndChangeCheck()) { r.xMax = Mathf.Max(tr.x, r.xMin + 0.1f); r.yMax = Mathf.Max(tr.y, r.yMin + 0.1f); }
|
||||
|
||||
// ── 四边中点:单轴缩放(点 cap,视觉上比角小) ────────────────────
|
||||
// 左边
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 lp = Handles.Slider(new Vector3(r.xMin, r.center.y, 0f), Vector3.right, hsDot, Handles.DotHandleCap, EditorSnapSettings.move.x);
|
||||
if (EditorGUI.EndChangeCheck()) r.xMin = Mathf.Min(lp.x, r.xMax - 0.1f);
|
||||
|
||||
// 右边
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 rp = Handles.Slider(new Vector3(r.xMax, r.center.y, 0f), Vector3.right, hsDot, Handles.DotHandleCap, EditorSnapSettings.move.x);
|
||||
if (EditorGUI.EndChangeCheck()) r.xMax = Mathf.Max(rp.x, r.xMin + 0.1f);
|
||||
|
||||
// 下边
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 bp = Handles.Slider(new Vector3(r.center.x, r.yMin, 0f), Vector3.up, hsDot, Handles.DotHandleCap, EditorSnapSettings.move.y);
|
||||
if (EditorGUI.EndChangeCheck()) r.yMin = Mathf.Min(bp.y, r.yMax - 0.1f);
|
||||
|
||||
// 上边
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 tp = Handles.Slider(new Vector3(r.center.x, r.yMax, 0f), Vector3.up, hsDot, Handles.DotHandleCap, EditorSnapSettings.move.y);
|
||||
if (EditorGUI.EndChangeCheck()) r.yMax = Mathf.Max(tp.y, r.yMin + 0.1f);
|
||||
}
|
||||
|
||||
// ══ 透视同步逻辑 ══════════════════════════════════════════════════════
|
||||
|
||||
private static void SyncConfinerFromVisibleBounds(CameraArea area, float vFOV, float aspect)
|
||||
internal static void SyncConfinerFromVisibleBounds(CameraArea area, float vFOV, float aspect)
|
||||
{
|
||||
var poly = area.ConfinerCollider;
|
||||
if (poly == null)
|
||||
@@ -191,6 +517,75 @@ namespace BaseGames.Editor
|
||||
return;
|
||||
}
|
||||
|
||||
// VisibleBounds 已含 transform.position,为世界坐标。
|
||||
// 限位多边形 = 相机中心运动范围 = VisibleBounds 向内收缩视口半尺寸。
|
||||
// 运行时 ConfigureSlot 设置 OversizeWindow.MaxWindowSize ≈ 0,
|
||||
// 阻止 Cinemachine 再次收缩此多边形,确保边界精确匹配可视区域。
|
||||
Rect visible = area.VisibleBounds; // 世界坐标
|
||||
float depth = area.CameraDepth;
|
||||
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
|
||||
float halfW = halfH * aspect;
|
||||
|
||||
float xMin = visible.xMin + halfW;
|
||||
float xMax = visible.xMax - halfW;
|
||||
float yMin = visible.yMin + halfH;
|
||||
float yMax = visible.yMax - halfH;
|
||||
|
||||
// 小房间:视口大于可视区域时收缩至中心点,相机固定在可视区域中心
|
||||
const float kMinSize = 0.001f;
|
||||
if (xMin > xMax) { float cx = visible.center.x; xMin = cx - kMinSize * 0.5f; xMax = cx + kMinSize * 0.5f; }
|
||||
if (yMin > yMax) { float cy = visible.center.y; yMin = cy - kMinSize * 0.5f; yMax = cy + kMinSize * 0.5f; }
|
||||
|
||||
Transform polyT = poly.transform;
|
||||
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w);
|
||||
|
||||
Undo.RecordObject(poly, "Sync Confiner from Visible Bounds");
|
||||
// 顶点必须 CCW(逆时针):Clipper 对 CW 多边形(area<0)会取反 delta,
|
||||
// 导致 Confiner 向外膨胀而非向内收缩,相机完全不受限。
|
||||
// CCW 顺序:BL → BR → TR → TL
|
||||
poly.SetPath(0, new[]
|
||||
{
|
||||
Local(new Vector3(xMin, yMin, 0f)), // BL
|
||||
Local(new Vector3(xMax, yMin, 0f)), // BR
|
||||
Local(new Vector3(xMax, yMax, 0f)), // TR
|
||||
Local(new Vector3(xMin, yMax, 0f)), // TL
|
||||
});
|
||||
EditorUtility.SetDirty(poly);
|
||||
|
||||
// 记录本次同步所用的 FOV,供编辑器过期检测使用
|
||||
var areaSO = new SerializedObject(area);
|
||||
var lastFovProp = areaSO.FindProperty("_lastSyncFOV");
|
||||
if (lastFovProp != null)
|
||||
{
|
||||
lastFovProp.floatValue = vFOV;
|
||||
areaSO.ApplyModifiedPropertiesWithoutUndo();
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"[CameraAreaEditor] {area.name}:限位区域已同步。\n" +
|
||||
$" 可视区域:({visible.xMin:F2}, {visible.yMin:F2}) ~ ({visible.xMax:F2}, {visible.yMax:F2})\n" +
|
||||
$" FOV={vFOV:F1}° Depth={depth:F1} HalfView=({halfW:F2}, {halfH:F2})\n" +
|
||||
$" 限位区域(相机中心运动范围):({xMin:F2}, {yMin:F2}) ~ ({xMax:F2}, {yMax:F2})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 自动检测 FOV 和宽高比,将 <paramref name="area"/> 的限位多边形同步到可视区域。
|
||||
/// 可从其他编辑器工具(如 CameraAreaSetupTool)一键批量调用。
|
||||
/// </summary>
|
||||
internal static void SyncConfinerAuto(CameraArea area) =>
|
||||
SyncConfinerFromVisibleBounds(area, GetFOV(area), GetAspect());
|
||||
|
||||
/// <summary>
|
||||
/// 与 <see cref="SyncConfinerFromVisibleBounds"/> 逻辑相同,但不记录 Undo、不输出日志。
|
||||
/// 供拖拽 Handle 时每帧调用,避免 Undo 堆积和 Console 刷屏。
|
||||
/// 调用方须在调用前自行执行 Undo.RecordObject(poly)。
|
||||
/// </summary>
|
||||
private static void SyncConfinerQuiet(CameraArea area, float vFOV, float aspect)
|
||||
{
|
||||
var poly = area.ConfinerCollider;
|
||||
if (poly == null) return;
|
||||
|
||||
// VisibleBounds 已含 transform.position,为世界坐标。
|
||||
Rect visible = area.VisibleBounds;
|
||||
float depth = area.CameraDepth;
|
||||
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
|
||||
@@ -201,28 +596,68 @@ namespace BaseGames.Editor
|
||||
float yMin = visible.yMin + halfH;
|
||||
float yMax = visible.yMax - halfH;
|
||||
|
||||
// 房间小于单屏 → 相机锁定在可视区域中心
|
||||
if (xMin > xMax) { float cx = visible.center.x; xMin = xMax = cx; }
|
||||
if (yMin > yMax) { float cy = visible.center.y; yMin = yMax = cy; }
|
||||
const float kMinSize = 0.001f;
|
||||
if (xMin > xMax) { float cx = visible.center.x; xMin = cx - kMinSize * 0.5f; xMax = cx + kMinSize * 0.5f; }
|
||||
if (yMin > yMax) { float cy = visible.center.y; yMin = cy - kMinSize * 0.5f; yMax = cy + kMinSize * 0.5f; }
|
||||
|
||||
Transform polyT = poly.transform;
|
||||
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w);
|
||||
|
||||
Undo.RecordObject(poly, "Sync Confiner from Visible Bounds");
|
||||
// CCW 顺序:BL → BR → TR → TL(同 SyncConfinerFromVisibleBounds)
|
||||
poly.SetPath(0, new[]
|
||||
{
|
||||
Local(new Vector3(xMin, yMin, 0f)),
|
||||
Local(new Vector3(xMin, yMax, 0f)),
|
||||
Local(new Vector3(xMax, yMax, 0f)),
|
||||
Local(new Vector3(xMax, yMin, 0f)),
|
||||
Local(new Vector3(xMin, yMin, 0f)), // BL
|
||||
Local(new Vector3(xMax, yMin, 0f)), // BR
|
||||
Local(new Vector3(xMax, yMax, 0f)), // TR
|
||||
Local(new Vector3(xMin, yMax, 0f)), // TL
|
||||
});
|
||||
EditorUtility.SetDirty(poly);
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"[CameraAreaEditor] {area.name}:限位区域已同步。\n" +
|
||||
$" 可视区域:{visible}\n" +
|
||||
$" FOV={vFOV:F1}° Depth={depth:F1} HalfView=({halfW:F2}, {halfH:F2})\n" +
|
||||
$" 限位区域:({xMin:F2}, {yMin:F2}) ~ ({xMax:F2}, {yMax:F2})");
|
||||
/// <summary>在 Scene 视图左上角绘制叠加信息面板(屏幕空间)。</summary>
|
||||
private static void DrawSceneInfoOverlay(CameraArea area, Rect bounds)
|
||||
{
|
||||
Handles.BeginGUI();
|
||||
|
||||
bool hasConfiner = area.ConfinerCollider != null;
|
||||
float panelH = hasConfiner ? 82f : 62f;
|
||||
var panel = new Rect(8f, 8f, 192f, panelH);
|
||||
|
||||
// 半透明深色背景
|
||||
GUI.color = new Color(0f, 0f, 0f, 0.65f);
|
||||
GUI.DrawTexture(panel, Texture2D.whiteTexture);
|
||||
GUI.color = Color.white;
|
||||
|
||||
if (_sceneOverlayBoldStyle == null)
|
||||
_sceneOverlayBoldStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||
{ normal = { textColor = new Color(1f, 0.85f, 0.15f) }, fontSize = 11 };
|
||||
|
||||
if (_sceneLabelStyle == null)
|
||||
_sceneLabelStyle = new GUIStyle(EditorStyles.miniLabel)
|
||||
{ normal = { textColor = new Color(0.85f, 0.85f, 0.85f) } };
|
||||
|
||||
// 区域名称(白色加粗,顶部第一行)
|
||||
var nameStyle = new GUIStyle(EditorStyles.boldLabel)
|
||||
{ normal = { textColor = Color.white }, fontSize = 12 };
|
||||
GUI.Label(new Rect(panel.x + 6, panel.y + 4, panel.width - 12, 18),
|
||||
area.name, nameStyle);
|
||||
|
||||
GUI.Label(new Rect(panel.x + 6, panel.y + 25, panel.width - 12, 17),
|
||||
$"可视 {bounds.width:F1} × {bounds.height:F1}", _sceneOverlayBoldStyle);
|
||||
GUI.Label(new Rect(panel.x + 6, panel.y + 43, panel.width - 12, 14),
|
||||
$"中心 ({bounds.center.x:F1}, {bounds.center.y:F1})", _sceneLabelStyle);
|
||||
|
||||
if (hasConfiner)
|
||||
{
|
||||
if (GUI.Button(new Rect(panel.x + 6, panel.y + 61, panel.width - 12, 16),
|
||||
"↺ 同步限位区域", EditorStyles.miniButton))
|
||||
{
|
||||
Undo.RecordObject(area.ConfinerCollider, "Sync Confiner");
|
||||
SyncConfinerFromVisibleBounds(area, GetFOV(area), GetAspect());
|
||||
}
|
||||
}
|
||||
|
||||
Handles.EndGUI();
|
||||
}
|
||||
|
||||
// ══ 工具方法 ══════════════════════════════════════════════════════════
|
||||
@@ -232,11 +667,15 @@ namespace BaseGames.Editor
|
||||
/// </summary>
|
||||
private static float GetFOV(CameraArea area)
|
||||
{
|
||||
// 1. 区域专有 VCam
|
||||
// 1. 专有 VCam(同一场景,优先级最高)
|
||||
if (area.DedicatedCamera != null)
|
||||
return area.DedicatedCamera.Lens.FieldOfView;
|
||||
|
||||
// 2. Persistent 场景中的 CameraStateController._vcamA(通过反射读取私有字段)
|
||||
// 2. CameraLensConfigSO(单一来源,无跨场景依赖)
|
||||
if (area.LensConfig != null)
|
||||
return area.LensConfig.fieldOfView;
|
||||
|
||||
// 3. Persistent 场景已加载时,实时读取全局 VCamA(兆底)
|
||||
#pragma warning disable UNT0023 // FindObjectOfType 在编辑器工具中可接受
|
||||
var ctrl = Object.FindObjectOfType<CameraStateController>();
|
||||
#pragma warning restore UNT0023
|
||||
@@ -248,11 +687,11 @@ namespace BaseGames.Editor
|
||||
return vcamA.Lens.FieldOfView;
|
||||
}
|
||||
|
||||
// 3. Camera.main
|
||||
// 4. Camera.main
|
||||
if (UnityEngine.Camera.main != null)
|
||||
return UnityEngine.Camera.main.fieldOfView;
|
||||
|
||||
// 4. 默认
|
||||
// 5. 默认
|
||||
return 60f;
|
||||
}
|
||||
|
||||
@@ -273,5 +712,29 @@ namespace BaseGames.Editor
|
||||
EditorGUILayout.LabelField(new GUIContent(text, tooltip));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool DrawFoldoutHeader(string title, bool expanded)
|
||||
{
|
||||
if (_foldoutHeaderStyle == null)
|
||||
_foldoutHeaderStyle = new GUIStyle(EditorStyles.foldout)
|
||||
{
|
||||
fontStyle = FontStyle.Bold,
|
||||
normal = { textColor = Color.white },
|
||||
hover = { textColor = Color.white },
|
||||
active = { textColor = Color.white },
|
||||
focused = { textColor = Color.white },
|
||||
onNormal = { textColor = Color.white },
|
||||
onHover = { textColor = Color.white },
|
||||
onActive = { textColor = Color.white },
|
||||
onFocused = { textColor = Color.white },
|
||||
};
|
||||
|
||||
Rect r = EditorGUILayout.GetControlRect(false, 22f);
|
||||
EditorGUI.DrawRect(r, new Color(0.22f, 0.22f, 0.28f, 1f));
|
||||
bool newExpanded = EditorGUI.Foldout(
|
||||
new Rect(r.x + 4f, r.y + 3f, r.width - 4f, 18f),
|
||||
expanded, title, true, _foldoutHeaderStyle);
|
||||
return newExpanded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using BaseGames.Camera;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// CameraLensConfigSO 自定义 Inspector。
|
||||
///
|
||||
/// 功能:
|
||||
/// - 修改 <see cref="CameraLensConfigSO.fieldOfView"/> 后,
|
||||
/// 自动遍历所有已加载场景中引用该 SO 的 <see cref="CameraArea"/>,
|
||||
/// 重新同步限位多边形,避免 FOV 改动后各场景出现错位。
|
||||
/// - 提供手动批量同步按钮,用于已打开但尚未触发自动同步的情形。
|
||||
///
|
||||
/// 未打开的场景无法自动同步。建议工作流:
|
||||
/// 1. 修改 SO 中的 FOV。
|
||||
/// 2. 依次打开各 Room 场景并加载 — 每次打开场景后点击「同步所有已加载场景」,
|
||||
/// 或直接在 CameraArea Inspector「可视区域工具」区域点击同步按钮。
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(CameraLensConfigSO))]
|
||||
internal sealed class CameraLensConfigSOEditor : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
EditorGUI.BeginChangeCheck();
|
||||
DrawDefaultInspector();
|
||||
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
// FOV 发生变化时,立即重新同步所有已加载场景中的 CameraArea
|
||||
SyncAllLoadedCameraAreas((CameraLensConfigSO)target);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(8f);
|
||||
EditorGUILayout.HelpBox(
|
||||
"修改 FOV 后,编辑器会自动同步当前已打开场景中的 CameraArea 限位区域。\n" +
|
||||
"未打开的场景请手动打开后重新同步。",
|
||||
MessageType.Info);
|
||||
|
||||
if (GUILayout.Button("同步所有已加载场景的 CameraArea 限位区域", GUILayout.Height(26f)))
|
||||
SyncAllLoadedCameraAreas((CameraLensConfigSO)target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 遍历所有已加载场景,对引用了指定 SO 且拥有 ConfinerCollider 的 CameraArea 重新同步限位多边形。
|
||||
/// 由 <see cref="CameraLensConfigSOEditor"/> 的 OnInspectorGUI 以及外部批量工具调用。
|
||||
/// </summary>
|
||||
internal static void SyncAllLoadedCameraAreas(CameraLensConfigSO so)
|
||||
{
|
||||
float fov = so.fieldOfView;
|
||||
float aspect = UnityEngine.Camera.main != null
|
||||
? UnityEngine.Camera.main.aspect
|
||||
: 16f / 9f;
|
||||
|
||||
int count = 0;
|
||||
for (int i = 0; i < SceneManager.sceneCount; i++)
|
||||
{
|
||||
Scene scene = SceneManager.GetSceneAt(i);
|
||||
if (!scene.isLoaded) continue;
|
||||
|
||||
foreach (GameObject root in scene.GetRootGameObjects())
|
||||
{
|
||||
foreach (CameraArea area in root.GetComponentsInChildren<CameraArea>(true))
|
||||
{
|
||||
if (area.LensConfig != so) continue;
|
||||
if (area.ConfinerCollider == null) continue;
|
||||
|
||||
CameraAreaEditor.SyncConfinerFromVisibleBounds(area, fov, aspect);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
Debug.Log($"[CameraLensConfigSO] 已同步 {count} 个 CameraArea 的限位区域(FOV = {fov:F1}°)。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 48768cad22a696a4582e9dbc2c100194
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
439
Assets/_Game/Scripts/Editor/Camera/CameraZoneMigrationTool.cs
Normal file
439
Assets/_Game/Scripts/Editor/Camera/CameraZoneMigrationTool.cs
Normal file
@@ -0,0 +1,439 @@
|
||||
using System.Collections.Generic;
|
||||
using BaseGames.Camera;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 将旧格式相机区域批量迁移到新架构。
|
||||
///
|
||||
/// 旧格式:
|
||||
/// Zone_xxx(挂 BoxCollider2D,定义相机可视矩形)
|
||||
/// ├─ Zone_xxx_TriggerRegion
|
||||
/// │ ├─ Zone_xxx_TriggerRegion_Point_0 … (多边形顶点)
|
||||
/// └─ Zone_xxx_Confiner(挂 BoxCollider2D / PolygonCollider2D,定义限位边界)
|
||||
///
|
||||
/// 新格式:
|
||||
/// [新 CameraArea GO](CameraArea 组件,_visibleBounds = 本地 Rect)
|
||||
/// ├─ AreaBoundary(PolygonCollider2D,isTrigger=true,对应旧 Confiner)
|
||||
/// └─ TriggerZone(CameraTriggerZone + PolygonCollider2D,对应旧 TriggerRegion)
|
||||
///
|
||||
/// 菜单:BaseGames → Camera → 相机区域迁移工具
|
||||
/// </summary>
|
||||
public class CameraZoneMigrationTool : EditorWindow
|
||||
{
|
||||
[MenuItem("BaseGames/Camera/相机区域迁移工具", priority = 110)]
|
||||
public static void Open()
|
||||
{
|
||||
var win = GetWindow<CameraZoneMigrationTool>("相机区域迁移工具");
|
||||
win.minSize = new Vector2(500f, 440f);
|
||||
}
|
||||
|
||||
// ── 设置字段 ──────────────────────────────────────────────────────────
|
||||
private Transform _sourcesParent; // 旧 Zone_xxx 的父节点(通常名为 Zones)
|
||||
private Transform _targetParent; // 新对象放置位置(留空 = 与旧区域同级)
|
||||
private CameraLensConfigSO _lensConfig; // 绑定到新 CameraArea._lensConfig
|
||||
|
||||
|
||||
// ── 运行时状态 ────────────────────────────────────────────────────────
|
||||
private readonly List<ZoneEntry> _entries = new List<ZoneEntry>();
|
||||
private Vector2 _scroll;
|
||||
private bool _scanned;
|
||||
private int _lastMigratedCount;
|
||||
|
||||
// ── 单条目数据 ────────────────────────────────────────────────────────
|
||||
private class ZoneEntry
|
||||
{
|
||||
public GameObject ZoneObj;
|
||||
public BoxCollider2D VisibleBox; // Zone_xxx 上的 BoxCollider2D
|
||||
public List<Vector2> TriggerWorldPts = new List<Vector2>(); // TriggerRegion 各点世界坐标
|
||||
public Collider2D ConfinerCollider; // Zone_xxx_Confiner 上的碰撞体(可空)
|
||||
public bool AlreadyMigrated;
|
||||
public bool Selected = true;
|
||||
}
|
||||
|
||||
// ── 颜色 ─────────────────────────────────────────────────────────────
|
||||
private static readonly Color kOk = new Color(0.30f, 0.85f, 0.30f);
|
||||
private static readonly Color kWarning = new Color(1.00f, 0.75f, 0.10f);
|
||||
private static readonly Color kMuted = new Color(0.55f, 0.55f, 0.60f);
|
||||
|
||||
// ══ GUI ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("旧格式相机区域迁移工具", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"将旧架构(Zone_xxx + BoxCollider2D + TriggerRegion 点集 + Confiner)" +
|
||||
"批量转换为新架构(CameraArea + AreaBoundary + CameraTriggerZone)。\n" +
|
||||
"迁移结果可在 Scene 视图直接预览,原旧对象可选择禁用。",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
// ── 配置 ──────────────────────────────────────────────────────────
|
||||
_sourcesParent = (Transform)EditorGUILayout.ObjectField(
|
||||
new GUIContent("旧区域父节点 (Zones)", "包含所有 Zone_xxx 的父对象"),
|
||||
_sourcesParent, typeof(Transform), true);
|
||||
|
||||
_targetParent = (Transform)EditorGUILayout.ObjectField(
|
||||
new GUIContent("新区域父节点(留空 = 同级)", "生成的 CameraArea 放在此节点下;留空则与旧 Zone_xxx 同级"),
|
||||
_targetParent, typeof(Transform), true);
|
||||
|
||||
_lensConfig = (CameraLensConfigSO)EditorGUILayout.ObjectField(
|
||||
new GUIContent("镜头配置 SO", "赋给所有新 CameraArea._lensConfig;留空则不赋值"),
|
||||
_lensConfig, typeof(CameraLensConfigSO), false);
|
||||
|
||||
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
using (new EditorGUI.DisabledScope(_sourcesParent == null))
|
||||
{
|
||||
if (GUILayout.Button("扫描区域", GUILayout.Height(30)))
|
||||
ScanZones();
|
||||
}
|
||||
|
||||
if (!_scanned) return;
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
if (_entries.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"未检测到旧格式区域。\n条件:BoxCollider2D + 含 \"TriggerRegion\" 字样的子节点。",
|
||||
MessageType.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 统计行 ─────────────────────────────────────────────────────────
|
||||
int migrated = 0, pending = 0;
|
||||
foreach (var e in _entries) { if (e.AlreadyMigrated) migrated++; else if (e.Selected) pending++; }
|
||||
|
||||
EditorGUILayout.LabelField(
|
||||
$"共 {_entries.Count} 个区域 | 已迁移 {migrated} | 已选待迁移 {pending}",
|
||||
EditorStyles.miniBoldLabel);
|
||||
|
||||
EditorGUILayout.Space(2);
|
||||
|
||||
// ── 条目列表 ───────────────────────────────────────────────────────
|
||||
_scroll = EditorGUILayout.BeginScrollView(_scroll, GUILayout.MaxHeight(240));
|
||||
foreach (var entry in _entries)
|
||||
DrawEntryRow(entry);
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
// ── 批量迁移 ───────────────────────────────────────────────────────
|
||||
int selectedPending = _entries.FindAll(e => e.Selected && !e.AlreadyMigrated).Count;
|
||||
using (new EditorGUI.DisabledScope(selectedPending == 0))
|
||||
{
|
||||
if (GUILayout.Button($"迁移已选中区域({selectedPending} 个)", GUILayout.Height(36)))
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var e in _entries)
|
||||
if (e.Selected && !e.AlreadyMigrated) { MigrateZone(e); count++; }
|
||||
_lastMigratedCount = count;
|
||||
ScanZones();
|
||||
Debug.Log($"[迁移工具] 完成迁移 {count} 个相机区域。");
|
||||
}
|
||||
}
|
||||
|
||||
if (_lastMigratedCount > 0)
|
||||
EditorGUILayout.HelpBox(
|
||||
$"上次迁移完成 {_lastMigratedCount} 个。请在 Scene 视图确认后保存场景(Ctrl+S)。",
|
||||
MessageType.Info);
|
||||
}
|
||||
|
||||
private void DrawEntryRow(ZoneEntry entry)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox))
|
||||
{
|
||||
// 选择框(已迁移的不可再选)
|
||||
using (new EditorGUI.DisabledScope(entry.AlreadyMigrated))
|
||||
entry.Selected = EditorGUILayout.Toggle(entry.Selected, GUILayout.Width(16));
|
||||
|
||||
// 名称
|
||||
Color prev = GUI.color;
|
||||
GUI.color = entry.AlreadyMigrated ? kMuted : kOk;
|
||||
EditorGUILayout.LabelField(entry.ZoneObj.name, GUILayout.Width(170));
|
||||
GUI.color = prev;
|
||||
|
||||
// 可视框尺寸
|
||||
if (entry.VisibleBox != null)
|
||||
{
|
||||
Vector2 sz = entry.VisibleBox.size;
|
||||
EditorGUILayout.LabelField($"Box {sz.x:F0}×{sz.y:F0}", EditorStyles.miniLabel, GUILayout.Width(72));
|
||||
}
|
||||
else
|
||||
{
|
||||
GUI.color = kWarning;
|
||||
EditorGUILayout.LabelField("无 Box2D", EditorStyles.miniLabel, GUILayout.Width(72));
|
||||
GUI.color = prev;
|
||||
}
|
||||
|
||||
// 触发点数
|
||||
Color ptColor = entry.TriggerWorldPts.Count >= 3 ? kOk : kWarning;
|
||||
GUI.color = ptColor;
|
||||
EditorGUILayout.LabelField($"触发 {entry.TriggerWorldPts.Count}pt", EditorStyles.miniLabel, GUILayout.Width(54));
|
||||
GUI.color = prev;
|
||||
|
||||
// 限位来源
|
||||
string confLabel = entry.ConfinerCollider is PolygonCollider2D ? "Poly限位"
|
||||
: entry.ConfinerCollider is BoxCollider2D ? "Box限位"
|
||||
: "默认矩形";
|
||||
EditorGUILayout.LabelField(confLabel, EditorStyles.miniLabel, GUILayout.Width(54));
|
||||
|
||||
// 状态 / 单独迁移按钮
|
||||
if (entry.AlreadyMigrated)
|
||||
{
|
||||
GUI.color = kMuted;
|
||||
EditorGUILayout.LabelField("已迁移", EditorStyles.miniLabel, GUILayout.Width(46));
|
||||
GUI.color = prev;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GUILayout.Button("迁移", GUILayout.Width(44), GUILayout.Height(16)))
|
||||
{
|
||||
MigrateZone(entry);
|
||||
_lastMigratedCount = 1;
|
||||
ScanZones();
|
||||
}
|
||||
}
|
||||
|
||||
// Ping 旧对象
|
||||
if (GUILayout.Button("●", GUILayout.Width(20), GUILayout.Height(16)))
|
||||
EditorGUIUtility.PingObject(entry.ZoneObj);
|
||||
}
|
||||
}
|
||||
|
||||
// ══ 扫描 ══════════════════════════════════════════════════════════════
|
||||
|
||||
private void ScanZones()
|
||||
{
|
||||
_entries.Clear();
|
||||
_scanned = true;
|
||||
|
||||
if (_sourcesParent == null) return;
|
||||
|
||||
foreach (Transform child in _sourcesParent)
|
||||
{
|
||||
// 旧 Zone 的标识:子节点直属,且挂有 BoxCollider2D
|
||||
var box = child.GetComponent<BoxCollider2D>();
|
||||
if (box == null) continue;
|
||||
|
||||
var entry = new ZoneEntry { ZoneObj = child.gameObject, VisibleBox = box };
|
||||
|
||||
// 收集触发多边形顶点(TriggerRegion 子节点的各个点对象)
|
||||
Transform triggerRoot = FindChildContaining(child, "TriggerRegion");
|
||||
if (triggerRoot != null)
|
||||
foreach (Transform pt in triggerRoot)
|
||||
entry.TriggerWorldPts.Add((Vector2)pt.position);
|
||||
|
||||
// 读取限位碰撞体(Zone_xxx_Confiner 上的 Collider2D)
|
||||
Transform confinerT = FindChildContaining(child, "Confiner");
|
||||
if (confinerT != null)
|
||||
entry.ConfinerCollider = confinerT.GetComponent<Collider2D>();
|
||||
|
||||
// 是否已经完成迁移(自身或子节点含 CameraArea)
|
||||
entry.AlreadyMigrated =
|
||||
child.GetComponent<CameraArea>() != null ||
|
||||
child.GetComponentInChildren<CameraArea>(true) != null;
|
||||
|
||||
_entries.Add(entry);
|
||||
}
|
||||
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private static Transform FindChildContaining(Transform parent, string keyword)
|
||||
{
|
||||
foreach (Transform child in parent)
|
||||
if (child.name.IndexOf(keyword, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return child;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ══ 迁移 ══════════════════════════════════════════════════════════════
|
||||
|
||||
private void MigrateZone(ZoneEntry entry)
|
||||
{
|
||||
GameObject zoneGO = entry.ZoneObj;
|
||||
Transform parent = _targetParent != null ? _targetParent : zoneGO.transform.parent;
|
||||
Vector3 worldPos = zoneGO.transform.position;
|
||||
|
||||
// ── 1. 计算本地可视 Rect(相对于新 CameraArea 的世界位置)────────
|
||||
Rect localBounds;
|
||||
if (entry.VisibleBox != null)
|
||||
{
|
||||
// 注意:BoxCollider2D.bounds 在 inactive 对象上无效,必须手动计算
|
||||
Bounds wb = GetColliderWorldBounds(entry.VisibleBox);
|
||||
localBounds = new Rect(
|
||||
wb.min.x - worldPos.x,
|
||||
wb.min.y - worldPos.y,
|
||||
wb.size.x, wb.size.y);
|
||||
}
|
||||
else
|
||||
{
|
||||
localBounds = new Rect(-12f, -6f, 24f, 12f);
|
||||
}
|
||||
|
||||
// ── 2. 创建 CameraArea 根节点 ─────────────────────────────────────
|
||||
GameObject areaGO = new GameObject(zoneGO.name);
|
||||
Undo.RegisterCreatedObjectUndo(areaGO, "Migrate Camera Zone");
|
||||
areaGO.transform.SetParent(parent, worldPositionStays: false);
|
||||
areaGO.transform.position = worldPos;
|
||||
|
||||
// 紧跟旧对象之后(同级排列时保持顺序直观)
|
||||
if (parent == zoneGO.transform.parent)
|
||||
areaGO.transform.SetSiblingIndex(zoneGO.transform.GetSiblingIndex() + 1);
|
||||
|
||||
CameraArea area = areaGO.AddComponent<CameraArea>();
|
||||
|
||||
var soArea = new SerializedObject(area);
|
||||
soArea.FindProperty("_visibleBounds").rectValue = localBounds;
|
||||
if (_lensConfig != null)
|
||||
soArea.FindProperty("_lensConfig").objectReferenceValue = _lensConfig;
|
||||
soArea.ApplyModifiedProperties();
|
||||
|
||||
// ── 3. 创建 AreaBoundary(限位多边形,isTrigger = true)──────────
|
||||
GameObject boundaryGO = new GameObject($"{zoneGO.name}_AreaBoundary");
|
||||
Undo.RegisterCreatedObjectUndo(boundaryGO, "Migrate Camera Zone");
|
||||
boundaryGO.transform.SetParent(areaGO.transform, worldPositionStays: false);
|
||||
boundaryGO.transform.localPosition = Vector3.zero;
|
||||
|
||||
PolygonCollider2D confinerPoly = boundaryGO.AddComponent<PolygonCollider2D>();
|
||||
confinerPoly.isTrigger = true;
|
||||
confinerPoly.pathCount = 1;
|
||||
confinerPoly.SetPath(0, BuildConfinerPath(entry, worldPos, localBounds));
|
||||
|
||||
// 绑定 _confinerCollider
|
||||
soArea.Update();
|
||||
soArea.FindProperty("_confinerCollider").objectReferenceValue = confinerPoly;
|
||||
soArea.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(area);
|
||||
|
||||
// 绑定完成后立即按 FOV/Depth 公式同步限位多边形,
|
||||
// 确保与 LensConfig 参数一致(创建时不会自动同步)
|
||||
float syncFOV = _lensConfig != null ? _lensConfig.fieldOfView : 60f;
|
||||
float syncAspect = UnityEngine.Camera.main != null ? UnityEngine.Camera.main.aspect : 16f / 9f;
|
||||
CameraAreaEditor.SyncConfinerFromVisibleBounds(area, syncFOV, syncAspect);
|
||||
|
||||
// ── 4. 创建 TriggerZone(相机激活触发器)───────────────────────
|
||||
GameObject triggerGO = new GameObject($"{zoneGO.name}_TriggerZone");
|
||||
Undo.RegisterCreatedObjectUndo(triggerGO, "Migrate Camera Zone");
|
||||
triggerGO.transform.SetParent(areaGO.transform, worldPositionStays: false);
|
||||
triggerGO.transform.localPosition = Vector3.zero;
|
||||
|
||||
// AddComponent 会因 [RequireComponent] 自动添加 PolygonCollider2D
|
||||
CameraTriggerZone triggerComp = triggerGO.AddComponent<CameraTriggerZone>();
|
||||
PolygonCollider2D triggerPoly = triggerGO.GetComponent<PolygonCollider2D>();
|
||||
triggerPoly.isTrigger = true;
|
||||
triggerPoly.pathCount = 1;
|
||||
triggerPoly.SetPath(0, BuildTriggerPath(entry, worldPos, localBounds));
|
||||
|
||||
// _targetArea → 指向刚创建的 CameraArea
|
||||
var soTrigger = new SerializedObject(triggerComp);
|
||||
soTrigger.FindProperty("_targetArea").objectReferenceValue = area;
|
||||
soTrigger.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(triggerComp);
|
||||
|
||||
// ── 5. 处理旧对象 ──────────────────────────────────────────────
|
||||
// 先记录原始激活状态,再对旧对象做处理,避免 SetActive(false) 后误读
|
||||
bool wasActive = zoneGO.activeSelf;
|
||||
|
||||
|
||||
|
||||
// 同步旧区域的激活状态(旧 Zone_xxx 若为禁用,新对象同样禁用)
|
||||
if (!wasActive)
|
||||
areaGO.SetActive(false);
|
||||
|
||||
EditorUtility.SetDirty(areaGO);
|
||||
EditorSceneManager.MarkSceneDirty(zoneGO.scene);
|
||||
EditorGUIUtility.PingObject(areaGO);
|
||||
|
||||
Debug.Log($"[迁移工具] {zoneGO.name} → {areaGO.name} " +
|
||||
$"可视 {localBounds.width:F0}×{localBounds.height:F0} " +
|
||||
$"触发 {triggerPoly.GetTotalPointCount()} pt " +
|
||||
$"限位 {confinerPoly.GetTotalPointCount()} pt");
|
||||
}
|
||||
|
||||
// ── 限位多边形路径 ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 按优先级构建限位多边形路径(本地坐标,相对于新 CameraArea):
|
||||
/// 1. Zone_xxx_Confiner 上的 PolygonCollider2D → 直接转换
|
||||
/// 2. Zone_xxx_Confiner 上的 BoxCollider2D → 取 AABB 四角
|
||||
/// 3. 兜底 → 使用可视矩形
|
||||
/// </summary>
|
||||
private static Vector2[] BuildConfinerPath(ZoneEntry entry, Vector3 areaWorldPos, Rect fallback)
|
||||
{
|
||||
if (entry.ConfinerCollider is PolygonCollider2D poly && poly.pathCount > 0)
|
||||
{
|
||||
var pts = new List<Vector2>();
|
||||
poly.GetPath(0, pts);
|
||||
var result = new Vector2[pts.Count];
|
||||
for (int i = 0; i < pts.Count; i++)
|
||||
result[i] = (Vector2)poly.transform.TransformPoint(pts[i]) - (Vector2)areaWorldPos;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (entry.ConfinerCollider is BoxCollider2D box)
|
||||
{
|
||||
Bounds b = GetColliderWorldBounds(box);
|
||||
return new Vector2[]
|
||||
{
|
||||
new Vector2(b.min.x - areaWorldPos.x, b.min.y - areaWorldPos.y),
|
||||
new Vector2(b.min.x - areaWorldPos.x, b.max.y - areaWorldPos.y),
|
||||
new Vector2(b.max.x - areaWorldPos.x, b.max.y - areaWorldPos.y),
|
||||
new Vector2(b.max.x - areaWorldPos.x, b.min.y - areaWorldPos.y),
|
||||
};
|
||||
}
|
||||
|
||||
// 兜底:可视矩形
|
||||
return RectToPolygon(fallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建触发多边形路径(本地坐标,相对于新 CameraArea):
|
||||
/// 取 TriggerRegion 各点的世界坐标减去 areaWorldPos;
|
||||
/// 若点数不足 3 则兜底使用可视矩形。
|
||||
/// </summary>
|
||||
private static Vector2[] BuildTriggerPath(ZoneEntry entry, Vector3 areaWorldPos, Rect fallback)
|
||||
{
|
||||
if (entry.TriggerWorldPts.Count >= 3)
|
||||
{
|
||||
var path = new Vector2[entry.TriggerWorldPts.Count];
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
path[i] = entry.TriggerWorldPts[i] - (Vector2)areaWorldPos;
|
||||
return path;
|
||||
}
|
||||
return RectToPolygon(fallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动计算 BoxCollider2D 的世界 AABB,不依赖 .bounds(inactive 对象上 .bounds 无效)。
|
||||
/// </summary>
|
||||
private static Bounds GetColliderWorldBounds(BoxCollider2D box)
|
||||
{
|
||||
Vector2 worldCenter = (Vector2)box.transform.TransformPoint(box.offset);
|
||||
Vector2 worldSize = new Vector2(
|
||||
box.size.x * Mathf.Abs(box.transform.lossyScale.x),
|
||||
box.size.y * Mathf.Abs(box.transform.lossyScale.y));
|
||||
return new Bounds(worldCenter, worldSize);
|
||||
}
|
||||
|
||||
private static Vector2[] RectToPolygon(Rect r)
|
||||
{
|
||||
return new Vector2[]
|
||||
{
|
||||
new Vector2(r.xMin, r.yMin),
|
||||
new Vector2(r.xMin, r.yMax),
|
||||
new Vector2(r.xMax, r.yMax),
|
||||
new Vector2(r.xMax, r.yMin),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3578efbe9a4a182448fa721fcbe75853
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,268 +0,0 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
using BaseGames.Camera;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// RoomCamera 自定义 Inspector + Scene GUI。
|
||||
///
|
||||
/// 功能:
|
||||
/// 1. Scene 视图中直接拖拽黄色矩形的四条边,编辑「可视区域」(_visibleBounds)。
|
||||
/// 2. Inspector 按钮「从可视区域更新限位区域(透视)」:
|
||||
/// 根据 CinemachineCamera.Lens.FieldOfView 和摄像机深度,计算出
|
||||
/// CinemachineConfiner2D 所需的限位多边形并写入子节点 PolygonCollider2D。
|
||||
///
|
||||
/// 透视相机限位公式:
|
||||
/// halfH = depth × tan(vFOV / 2)
|
||||
/// halfW = halfH × aspectRatio
|
||||
/// confiner = visibleBounds inset by (halfW, halfH)
|
||||
/// → 相机视口边缘恰好与可视区域边框对齐。
|
||||
/// → 若房间小于单屏,限位收缩为中心点(相机固定居中)。
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(RoomCamera))]
|
||||
public class RoomCameraEditor : UnityEditor.Editor
|
||||
{
|
||||
// ── 颜色常量 ──────────────────────────────────────────────────────────
|
||||
private static readonly Color kVisibleFill = new Color(1f, 0.85f, 0.15f, 0.08f);
|
||||
private static readonly Color kVisibleOutline = new Color(1f, 0.85f, 0.15f, 0.90f);
|
||||
private static readonly Color kConfinerColor = new Color(0.2f, 0.8f, 1.0f, 0.80f);
|
||||
|
||||
// ══ Inspector ═════════════════════════════════════════════════════════
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
|
||||
EditorGUILayout.Space(8f);
|
||||
EditorGUILayout.LabelField("── 可视区域工具 ──", EditorStyles.boldLabel);
|
||||
|
||||
RoomCamera rc = (RoomCamera)target;
|
||||
var vcam = rc.GetComponent<CinemachineCamera>();
|
||||
var confiner = rc.GetComponent<CinemachineConfiner2D>();
|
||||
|
||||
// ── 透视参数预览 ─────────────────────────────────────────────────
|
||||
float vFOV = vcam != null ? vcam.Lens.FieldOfView : 60f;
|
||||
float aspect = GetAspect();
|
||||
float depth = rc.CameraDepth;
|
||||
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
|
||||
float halfW = halfH * aspect;
|
||||
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
{
|
||||
EditorGUILayout.FloatField("垂直 FOV(来自 Lens)", vFOV);
|
||||
EditorGUILayout.FloatField("有效深度", depth);
|
||||
EditorGUILayout.FloatField("视口半高(世界单位)", halfH);
|
||||
EditorGUILayout.FloatField("视口半宽(世界单位)", halfW);
|
||||
}
|
||||
|
||||
bool canSync = rc.ConfinerCollider != null;
|
||||
if (!canSync)
|
||||
EditorGUILayout.HelpBox("ConfinerCollider 未绑定(_visibleArea 为空),无法同步限位区域。", MessageType.Warning);
|
||||
|
||||
using (new EditorGUI.DisabledScope(!canSync))
|
||||
{
|
||||
if (GUILayout.Button("从可视区域更新限位区域(透视)", GUILayout.Height(28f)))
|
||||
SyncConfinerFromVisibleBounds(rc, vFOV, aspect);
|
||||
}
|
||||
|
||||
// ── 图例说明 ─────────────────────────────────────────────────────
|
||||
EditorGUILayout.Space(4f);
|
||||
DrawLegend("■ 黄色矩形(Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
|
||||
DrawLegend("■ 蓝色多边形(Scene 视图)", kConfinerColor, "限位区域 — CinemachineConfiner2D 的运动边界");
|
||||
}
|
||||
|
||||
// ══ Scene GUI ════════════════════════════════════════════════════════
|
||||
|
||||
private void OnSceneGUI()
|
||||
{
|
||||
RoomCamera rc = (RoomCamera)target;
|
||||
serializedObject.Update();
|
||||
|
||||
SerializedProperty boundsP = serializedObject.FindProperty("_visibleBounds");
|
||||
Rect r = boundsP.rectValue;
|
||||
|
||||
// ── 绘制限位多边形(蓝色,参考用) ──────────────────────────────
|
||||
DrawConfinerGizmo(rc);
|
||||
|
||||
// ── 绘制可视区域填充 + 边框 ──────────────────────────────────────
|
||||
DrawVisibleRect(r);
|
||||
|
||||
// ── 四条边的拖拽 Handle ──────────────────────────────────────────
|
||||
EditorGUI.BeginChangeCheck();
|
||||
EditRectEdges(ref r);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
Undo.RecordObject(rc, "Edit Visible Bounds");
|
||||
boundsP.rectValue = r;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
|
||||
// ══ 绘制辅助 ═════════════════════════════════════════════════════════
|
||||
|
||||
private static void DrawVisibleRect(Rect r)
|
||||
{
|
||||
Vector3[] corners =
|
||||
{
|
||||
new Vector3(r.xMin, r.yMin, 0f),
|
||||
new Vector3(r.xMin, r.yMax, 0f),
|
||||
new Vector3(r.xMax, r.yMax, 0f),
|
||||
new Vector3(r.xMax, r.yMin, 0f),
|
||||
};
|
||||
|
||||
Handles.DrawSolidRectangleWithOutline(corners, kVisibleFill, kVisibleOutline);
|
||||
|
||||
// 标签
|
||||
Handles.color = kVisibleOutline;
|
||||
Handles.Label(
|
||||
new Vector3(r.xMin + 0.15f, r.yMax - 0.15f, 0f),
|
||||
"Visible Area",
|
||||
EditorStyles.miniLabel);
|
||||
}
|
||||
|
||||
private static void DrawConfinerGizmo(RoomCamera rc)
|
||||
{
|
||||
var poly = rc.ConfinerCollider;
|
||||
if (poly == null || poly.pathCount == 0) return;
|
||||
|
||||
int ptCount = poly.GetTotalPointCount();
|
||||
if (ptCount < 2) return;
|
||||
|
||||
var pts2 = new System.Collections.Generic.List<Vector2>(ptCount);
|
||||
poly.GetPath(0, pts2);
|
||||
var pts3 = new Vector3[ptCount + 1];
|
||||
for (int i = 0; i < ptCount; i++)
|
||||
pts3[i] = poly.transform.TransformPoint(pts2[i]);
|
||||
pts3[ptCount] = pts3[0];
|
||||
|
||||
Handles.color = kConfinerColor;
|
||||
Handles.DrawPolyLine(pts3);
|
||||
Handles.Label(
|
||||
(Vector3)poly.transform.TransformPoint(pts2[0]) + new Vector3(0.1f, 0.1f),
|
||||
"Confiner",
|
||||
EditorStyles.miniLabel);
|
||||
}
|
||||
|
||||
/// <summary>绘制四条边的滑动 Handle,允许用户直接拖拽修改可视区域。</summary>
|
||||
private static void EditRectEdges(ref Rect r)
|
||||
{
|
||||
float hs = HandleUtility.GetHandleSize(r.center) * 0.10f;
|
||||
|
||||
Handles.color = kVisibleOutline;
|
||||
|
||||
// 左边 —— 沿 X 轴滑动
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 lp = Handles.Slider(
|
||||
new Vector3(r.xMin, r.center.y, 0f),
|
||||
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
// 保持 xMax 不变,xMin 向右最多到 xMax-0.1
|
||||
r.xMin = Mathf.Min(lp.x, r.xMax - 0.1f);
|
||||
}
|
||||
|
||||
// 右边 —— 沿 X 轴滑动
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 rp = Handles.Slider(
|
||||
new Vector3(r.xMax, r.center.y, 0f),
|
||||
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
r.xMax = Mathf.Max(rp.x, r.xMin + 0.1f);
|
||||
}
|
||||
|
||||
// 下边 —— 沿 Y 轴滑动
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 bp = Handles.Slider(
|
||||
new Vector3(r.center.x, r.yMin, 0f),
|
||||
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
r.yMin = Mathf.Min(bp.y, r.yMax - 0.1f);
|
||||
}
|
||||
|
||||
// 上边 —— 沿 Y 轴滑动
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 tp = Handles.Slider(
|
||||
new Vector3(r.center.x, r.yMax, 0f),
|
||||
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
r.yMax = Mathf.Max(tp.y, r.yMin + 0.1f);
|
||||
}
|
||||
}
|
||||
|
||||
// ══ 透视同步逻辑 ══════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// 根据可视区域矩形与透视参数计算限位多边形,写入 PolygonCollider2D。
|
||||
/// </summary>
|
||||
private static void SyncConfinerFromVisibleBounds(RoomCamera rc, float vFOV, float aspect)
|
||||
{
|
||||
var poly = rc.ConfinerCollider;
|
||||
if (poly == null)
|
||||
{
|
||||
Debug.LogWarning($"[RoomCameraEditor] {rc.name}:ConfinerCollider 未绑定,无法同步。");
|
||||
return;
|
||||
}
|
||||
|
||||
Rect visible = rc.VisibleBounds;
|
||||
float depth = rc.CameraDepth;
|
||||
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
|
||||
float halfW = halfH * aspect;
|
||||
|
||||
float xMin = visible.xMin + halfW;
|
||||
float xMax = visible.xMax - halfW;
|
||||
float yMin = visible.yMin + halfH;
|
||||
float yMax = visible.yMax - halfH;
|
||||
|
||||
// 房间小于单屏 → 相机锁定在可视区域中心
|
||||
if (xMin > xMax) { float cx = visible.center.x; xMin = xMax = cx; }
|
||||
if (yMin > yMax) { float cy = visible.center.y; yMin = yMax = cy; }
|
||||
|
||||
Transform polyT = poly.transform;
|
||||
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w);
|
||||
|
||||
Undo.RecordObject(poly, "Sync Confiner from Visible Bounds");
|
||||
poly.SetPath(0, new[]
|
||||
{
|
||||
Local(new Vector3(xMin, yMin, 0f)),
|
||||
Local(new Vector3(xMin, yMax, 0f)),
|
||||
Local(new Vector3(xMax, yMax, 0f)),
|
||||
Local(new Vector3(xMax, yMin, 0f)),
|
||||
});
|
||||
EditorUtility.SetDirty(poly);
|
||||
|
||||
Debug.Log(
|
||||
$"[RoomCameraEditor] {rc.name}:限位区域已同步。\n" +
|
||||
$" 可视区域:{visible}\n" +
|
||||
$" FOV={vFOV:F1}° Depth={depth:F1} HalfView=({halfW:F2}, {halfH:F2})\n" +
|
||||
$" 限位区域:({xMin:F2}, {yMin:F2}) ~ ({xMax:F2}, {yMax:F2})");
|
||||
}
|
||||
|
||||
// ══ 工具方法 ══════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// 获取 Game 视图宽高比。编辑器中优先用 Camera.main,否则回退到 16:9。
|
||||
/// </summary>
|
||||
private static float GetAspect()
|
||||
{
|
||||
if (UnityEngine.Camera.main != null) return UnityEngine.Camera.main.aspect;
|
||||
return 16f / 9f;
|
||||
}
|
||||
|
||||
private static void DrawLegend(string text, Color color, string tooltip)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
Color prev = GUI.color;
|
||||
GUI.color = color;
|
||||
GUILayout.Label("■", GUILayout.Width(14f));
|
||||
GUI.color = prev;
|
||||
EditorGUILayout.LabelField(new GUIContent(text, tooltip));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
109
Assets/_Game/Scripts/Editor/Scene/PersistentSceneAutoLoader.cs
Normal file
109
Assets/_Game/Scripts/Editor/Scene/PersistentSceneAutoLoader.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 编辑器 Edit Mode 辅助:打开任意场景时自动将 Persistent 场景 Additive 加入 Hierarchy。
|
||||
///
|
||||
/// 职责范围(仅限 Edit Mode):
|
||||
/// 让设计师在编辑房间场景时,Inspector 中可直接看到并配置 GameManager / SceneService
|
||||
/// 等 Persistent 场景内的组件,无需手动 Open Additive。
|
||||
///
|
||||
/// 运行时(Play Mode / 发行版 Build)的保证由 GameBootstrap(Runtime 程序集)负责,
|
||||
/// 本脚本与 Play Mode 状态无关,不监听 playModeStateChanged。
|
||||
///
|
||||
/// 菜单:BaseGames/Tools/Edit Mode: Auto-Open Persistent Scene
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class PersistentSceneAutoLoader
|
||||
{
|
||||
// ── 常量 ─────────────────────────────────────────────────────────────
|
||||
private const string MenuPath = "BaseGames/Tools/Edit Mode: Auto-Open Persistent Scene";
|
||||
private const string PrefKey = "BaseGames_EditAutoOpen_Persistent";
|
||||
private const string PersistentSceneName = "Scene_Persistent";
|
||||
|
||||
// ── 构造(Editor 启动时执行)──────────────────────────────────────────
|
||||
static PersistentSceneAutoLoader()
|
||||
{
|
||||
EditorSceneManager.sceneOpened += OnSceneOpened;
|
||||
|
||||
// 启动时补一次检查:Editor 已打开但 Persistent 不在 Hierarchy 的场景
|
||||
EditorApplication.delayCall += EnsurePersistentInHierarchyEditMode;
|
||||
}
|
||||
|
||||
// ── 菜单 ─────────────────────────────────────────────────────────────
|
||||
[MenuItem(MenuPath, validate = false, priority = 301)]
|
||||
private static void ToggleEnabled()
|
||||
{
|
||||
bool current = EditorPrefs.GetBool(PrefKey, true);
|
||||
EditorPrefs.SetBool(PrefKey, !current);
|
||||
}
|
||||
|
||||
[MenuItem(MenuPath, validate = true)]
|
||||
private static bool ToggleEnabledValidate()
|
||||
{
|
||||
Menu.SetChecked(MenuPath, EditorPrefs.GetBool(PrefKey, true));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 场景打开回调 ──────────────────────────────────────────────────────
|
||||
private static void OnSceneOpened(Scene scene, OpenSceneMode mode)
|
||||
{
|
||||
// 若是 Persistent 本身被打开,无需额外处理
|
||||
if (IsPersistentScene(scene.name)) return;
|
||||
|
||||
// Single 模式(替换当前场景)或 Additive 加入新场景时,确保 Persistent 也在 Hierarchy 中
|
||||
// 使用 delayCall 避免在场景加载中途调用 OpenScene
|
||||
EditorApplication.delayCall += EnsurePersistentInHierarchyEditMode;
|
||||
}
|
||||
|
||||
// ── 核心逻辑 ──────────────────────────────────────────────────────────
|
||||
private static void EnsurePersistentInHierarchyEditMode()
|
||||
{
|
||||
// 仅在 Edit Mode 执行(Play Mode 由 GameBootstrap 负责)
|
||||
if (Application.isPlaying) return;
|
||||
if (!EditorPrefs.GetBool(PrefKey, true)) return;
|
||||
|
||||
// 若 Persistent 已在 Hierarchy,无需操作
|
||||
for (int i = 0; i < SceneManager.sceneCount; i++)
|
||||
if (IsPersistentScene(SceneManager.GetSceneAt(i).name)) return;
|
||||
|
||||
// 查找并 Additive 打开 Persistent 场景
|
||||
string path = FindPersistentScenePath();
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[PersistentAutoLoader] 未找到 '{PersistentSceneName}' 场景。" +
|
||||
"请确认场景已添加到 Build Settings 或可在 Assets 中搜索到。");
|
||||
return;
|
||||
}
|
||||
|
||||
EditorSceneManager.OpenScene(path, OpenSceneMode.Additive);
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────────────────
|
||||
private static bool IsPersistentScene(string sceneName)
|
||||
=> sceneName == PersistentSceneName || sceneName == "Persistent";
|
||||
|
||||
private static string FindPersistentScenePath()
|
||||
{
|
||||
// 优先从 Build Settings 查找(保证与 GameBootstrap 使用同一文件)
|
||||
foreach (var buildScene in EditorBuildSettings.scenes)
|
||||
{
|
||||
if (!buildScene.enabled) continue;
|
||||
string name = System.IO.Path.GetFileNameWithoutExtension(buildScene.path);
|
||||
if (IsPersistentScene(name)) return buildScene.path;
|
||||
}
|
||||
|
||||
// 回退:在 AssetDatabase 中搜索
|
||||
string[] guids = AssetDatabase.FindAssets($"t:Scene {PersistentSceneName}");
|
||||
if (guids.Length > 0) return AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||
|
||||
guids = AssetDatabase.FindAssets("t:Scene Persistent");
|
||||
return guids.Length > 0 ? AssetDatabase.GUIDToAssetPath(guids[0]) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b5ab9e5f153fb148817239307245e00
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -385,46 +385,101 @@ namespace BaseGames.Editor
|
||||
MarkDirtyAndLog("Room Transition", go, report);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Scene/Place/Room Camera", priority = 140)]
|
||||
public static void PlaceRoomCamera()
|
||||
[MenuItem("BaseGames/Scene/Place/Camera Area", priority = 140)]
|
||||
public static void PlaceCameraArea() => PlaceCameraArea("CameraArea");
|
||||
|
||||
/// <param name="areaName">
|
||||
/// 生成的 CameraArea GameObject 名称。
|
||||
/// 子节点 AreaBoundary 和 TriggerZone 将以此为前缀命名(如 MyZone_AreaBoundary)。
|
||||
/// </param>
|
||||
/// <param name="parent">生成的 GameObject 所挂载的父节点(为 null 时放置于场景根节点)。</param>
|
||||
public static void PlaceCameraArea(string areaName, Transform parent = null)
|
||||
{
|
||||
var report = new List<string>();
|
||||
int undoGroup = Undo.GetCurrentGroup();
|
||||
Undo.SetCurrentGroupName("Place Camera Area (+ TriggerZone)");
|
||||
|
||||
GameObject go = new GameObject("RoomCamera");
|
||||
Undo.RegisterCreatedObjectUndo(go, "Place Room Camera");
|
||||
go.transform.position = GetDropPosition();
|
||||
Vector3 pos = GetDropPosition();
|
||||
|
||||
CinemachineCamera cinemachine = GetOrAddComponent<CinemachineCamera>(go);
|
||||
RoomCamera roomCamera = GetOrAddComponent<RoomCamera>(go);
|
||||
CinemachineConfiner2D confiner = GetOrAddComponent<CinemachineConfiner2D>(go);
|
||||
// ── CameraArea ─────────────────────────────────────────────────────
|
||||
GameObject go = new GameObject(areaName);
|
||||
Undo.RegisterCreatedObjectUndo(go, "Place Camera Area");
|
||||
go.transform.position = pos;
|
||||
if (parent != null)
|
||||
Undo.SetTransformParent(go.transform, parent, "Parent Camera Area");
|
||||
|
||||
// RoomBoundary child — defines the camera confinement area
|
||||
Transform boundaryT = GetOrCreateChild(go.transform, "RoomBoundary");
|
||||
CameraArea cameraArea = GetOrAddComponent<CameraArea>(go);
|
||||
|
||||
// AreaBoundary child — 提供 CinemachineConfiner2D 所需的限位多边形(isTrigger = true,仅作为相机约束边界)
|
||||
Transform boundaryT = GetOrCreateChild(go.transform, $"{areaName}_AreaBoundary");
|
||||
PolygonCollider2D boundaryCollider = GetOrAddComponent<PolygonCollider2D>(boundaryT.gameObject);
|
||||
boundaryCollider.isTrigger = true;
|
||||
boundaryCollider.pathCount = 1;
|
||||
// 顶点必须逆时针(CCW)排列:Cinemachine 底层 Clipper 库对 CW 多边形(area<0)会取反 delta,
|
||||
// 导致向外膨胀而非向内收缩,相机将不受限制地跑出边界。
|
||||
boundaryCollider.SetPath(0, new Vector2[]
|
||||
{
|
||||
new Vector2(-12f, -6f),
|
||||
new Vector2(-12f, 6f),
|
||||
new Vector2( 12f, 6f),
|
||||
new Vector2( 12f, -6f),
|
||||
new Vector2(-12f, -6f), // BL
|
||||
new Vector2( 12f, -6f), // BR
|
||||
new Vector2( 12f, 6f), // TR
|
||||
new Vector2(-12f, 6f), // TL
|
||||
});
|
||||
RoomVisibleArea visibleArea = GetOrAddComponent<RoomVisibleArea>(boundaryT.gameObject);
|
||||
|
||||
AssignReference(roomCamera, "_visibleArea", visibleArea, report);
|
||||
AssignReference(confiner, "m_BoundingShape2D", boundaryCollider, report);
|
||||
AssignReference(cameraArea, "_confinerCollider", boundaryCollider, report);
|
||||
|
||||
// Disable any Camera and AudioListener added by Cinemachine
|
||||
UnityEngine.Camera cam = go.GetComponent<UnityEngine.Camera>();
|
||||
if (cam != null) cam.enabled = false;
|
||||
AudioListener al = go.GetComponent<AudioListener>();
|
||||
if (al != null) { Undo.DestroyObjectImmediate(al); }
|
||||
// ── CameraTriggerZone(配对)─────────────────────────────────────────
|
||||
GameObject zoneGo = new GameObject($"{areaName}_TriggerZone");
|
||||
Undo.RegisterCreatedObjectUndo(zoneGo, "Place Camera Trigger Zone");
|
||||
zoneGo.transform.position = pos;
|
||||
SetLayer(zoneGo, "TriggerZone", report);
|
||||
|
||||
report.Add("将 Player/CameraFollowTarget Transform 拖入 CinemachineCamera.Follow 字段以跟随玩家(或使用 Room Camera Setup 工具批量赋值)。");
|
||||
report.Add("调整 RoomBoundary PolygonCollider2D 顶点以匹配房间边界。");
|
||||
PolygonCollider2D col = GetOrAddComponent<PolygonCollider2D>(zoneGo);
|
||||
col.isTrigger = true;
|
||||
// 默认矩形轮廓(CCW),与 AreaBoundary 默认尺寸一致(可在 Inspector 中编辑顶点调整为任意多边形)
|
||||
col.SetPath(0, new Vector2[]
|
||||
{
|
||||
new Vector2(-12f, -6f), // BL
|
||||
new Vector2( 12f, -6f), // BR
|
||||
new Vector2( 12f, 6f), // TR
|
||||
new Vector2(-12f, 6f), // TL
|
||||
});
|
||||
|
||||
CameraTriggerZone zone = GetOrAddComponent<CameraTriggerZone>(zoneGo);
|
||||
AssignReference(zone, "_targetArea", cameraArea, report);
|
||||
// TriggerZone 归入 CameraArea 节点,方便统一调整与查找
|
||||
Undo.SetTransformParent(zoneGo.transform, go.transform, "Parent TriggerZone to CameraArea");
|
||||
zoneGo.transform.localPosition = Vector3.zero;
|
||||
Undo.CollapseUndoOperations(undoGroup);
|
||||
|
||||
report.Add($"调整 {areaName}_AreaBoundary PolygonCollider2D 顶点以匹配区域边界。");
|
||||
report.Add($"调整 {areaName}_TriggerZone PolygonCollider2D 顶点以匹配入口走廊(支持任意多边形)。");
|
||||
|
||||
// ── 自动关联到同场景 RoomController(若其 _cameraArea 为空)────────
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
var roomControllers = Object.FindObjectsByType<RoomController>(FindObjectsSortMode.None);
|
||||
#else
|
||||
var roomControllers = Object.FindObjectsOfType<RoomController>();
|
||||
#endif
|
||||
bool autoAssigned = false;
|
||||
foreach (var rc in roomControllers)
|
||||
{
|
||||
// 仅使用反射检查,避免每次都覆盖已绑定的引用
|
||||
var fi = typeof(RoomController).GetField("_cameraArea",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
if (fi == null) continue;
|
||||
if (fi.GetValue(rc) != null) continue;
|
||||
|
||||
Undo.RecordObject(rc, "Auto-assign CameraArea to RoomController");
|
||||
fi.SetValue(rc, cameraArea);
|
||||
EditorUtility.SetDirty(rc);
|
||||
report.Add($"✅ 已自动将 {areaName} 关联到 {rc.gameObject.name}.RoomController._cameraArea。");
|
||||
autoAssigned = true;
|
||||
}
|
||||
if (!autoAssigned)
|
||||
report.Add("将此 CameraArea 拖入 RoomController._cameraArea 字段(未找到空 _cameraArea 的 RoomController)。");
|
||||
|
||||
Selection.activeGameObject = go;
|
||||
MarkDirtyAndLog("Room Camera", go, report);
|
||||
MarkDirtyAndLog($"Camera Area (+ TriggerZone): {areaName}", go, report);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Scene/Place/Ground Platform", priority = 150)]
|
||||
@@ -534,28 +589,6 @@ namespace BaseGames.Editor
|
||||
MarkDirtyAndLog("Nav Surface", go, report);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Scene/Place/Camera Trigger Zone", priority = 180)]
|
||||
public static void PlaceCameraTriggerZone()
|
||||
{
|
||||
var report = new List<string>();
|
||||
|
||||
GameObject go = new GameObject("CameraTriggerZone");
|
||||
Undo.RegisterCreatedObjectUndo(go, "Place Camera Trigger Zone");
|
||||
go.transform.position = GetDropPosition();
|
||||
SetLayer(go, "TriggerZone", report);
|
||||
|
||||
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
|
||||
col.isTrigger = true;
|
||||
col.size = new Vector2(2f, 2f);
|
||||
|
||||
GetOrAddComponent<CameraTriggerZone>(go);
|
||||
|
||||
report.Add("将目标 RoomCamera 拖入 CameraTriggerZone._targetCamera 字段。");
|
||||
|
||||
Selection.activeGameObject = go;
|
||||
MarkDirtyAndLog("Camera Trigger Zone", go, report);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Scene/Place/Obstacle (Static)", priority = 190)]
|
||||
public static void PlaceObstacle()
|
||||
{
|
||||
|
||||
@@ -86,6 +86,29 @@ namespace BaseGames.Editor
|
||||
CameraStateController cameraStateController = GetOrAddComponent<CameraStateController>(cameraStateGo);
|
||||
CinemachineImpulseSource impulseSource = GetOrAddComponent<CinemachineImpulseSource>(cameraStateGo);
|
||||
|
||||
// 垂直窥视系统:独立节点,CameraStateController 持引用
|
||||
GameObject lookSystemGo = GetOrCreateChild(camera, "CameraLookSystem").gameObject;
|
||||
CameraLookSystem lookSystem = GetOrAddComponent<CameraLookSystem>(lookSystemGo);
|
||||
|
||||
GameObject vcamAGo = GetOrCreateChild(camera, "VCamA").gameObject;
|
||||
CinemachineCamera vcamA = GetOrAddComponent<CinemachineCamera>(vcamAGo);
|
||||
GetOrAddComponent<CinemachineConfiner2D>(vcamAGo);
|
||||
GetOrAddComponent<CameraAxisLockExtension>(vcamAGo);
|
||||
GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamAGo);
|
||||
GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamAGo);
|
||||
// CinemachinePositionComposer:Body 阶段组件,必须存在;ConfigureSlot 依赖它写入所有相机跟随参数
|
||||
var composerA = GetOrAddComponent<CinemachinePositionComposer>(vcamAGo);
|
||||
ApplyComposerDefaults(composerA);
|
||||
|
||||
GameObject vcamBGo = GetOrCreateChild(camera, "VCamB").gameObject;
|
||||
CinemachineCamera vcamB = GetOrAddComponent<CinemachineCamera>(vcamBGo);
|
||||
GetOrAddComponent<CinemachineConfiner2D>(vcamBGo);
|
||||
GetOrAddComponent<CameraAxisLockExtension>(vcamBGo);
|
||||
GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamBGo);
|
||||
GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamBGo);
|
||||
var composerB = GetOrAddComponent<CinemachinePositionComposer>(vcamBGo);
|
||||
ApplyComposerDefaults(composerB);
|
||||
|
||||
GameObject uiRootGo = GetOrCreateChild(ui, "UIRoot").gameObject;
|
||||
UIManager uiManager = GetOrAddComponent<UIManager>(uiRootGo);
|
||||
|
||||
@@ -146,6 +169,11 @@ namespace BaseGames.Editor
|
||||
|
||||
AssignReference(cameraStateController, "_brain", brain);
|
||||
AssignReference(cameraStateController, "_impulseSource", impulseSource);
|
||||
AssignReference(cameraStateController, "_lookSystem", lookSystem);
|
||||
AssignReference(cameraStateController, "_vcamA", vcamA);
|
||||
AssignReference(cameraStateController, "_vcamB", vcamB);
|
||||
AssignAsset(cameraStateController, "_onPlayerSpawned", report, true, "EVT_PlayerSpawned");
|
||||
AssignAsset(cameraStateController, "_lensConfig", report, false, "CAM_LensConfig", "LensConfig", "CameraLensConfig");
|
||||
|
||||
AssignReference(uiManager, "_hudRoot", hudRootGo);
|
||||
AssignReference(uiManager, "_pauseMenuRoot", pauseRootGo);
|
||||
@@ -189,13 +217,12 @@ namespace BaseGames.Editor
|
||||
// ── [Camera] ───────────────────────────────────────────────────
|
||||
Transform cameraGroup = GetOrCreateChild(root.transform, "[Camera]");
|
||||
|
||||
GameObject roomCameraGo = GetOrCreateChild(cameraGroup, "RoomCamera").gameObject;
|
||||
CinemachineCamera cinemachineCamera = GetOrAddComponent<CinemachineCamera>(roomCameraGo);
|
||||
RoomCamera roomCamera = GetOrAddComponent<RoomCamera>(roomCameraGo);
|
||||
CinemachineConfiner2D confiner = GetOrAddComponent<CinemachineConfiner2D>(roomCameraGo);
|
||||
// CameraArea — 定义相机区域(限位 + 混合配置 + 可选专有 VCam)
|
||||
GameObject cameraAreaGo = GetOrCreateChild(cameraGroup, "CameraArea").gameObject;
|
||||
CameraArea cameraArea = GetOrAddComponent<CameraArea>(cameraAreaGo);
|
||||
|
||||
// RoomBoundary — defines visible area and confiner polygon
|
||||
Transform boundaryT = GetOrCreateChild(roomCameraGo.transform, "RoomBoundary");
|
||||
// AreaBoundary — 提供 CinemachineConfiner2D 所需的限位多边形
|
||||
Transform boundaryT = GetOrCreateChild(cameraAreaGo.transform, "AreaBoundary");
|
||||
PolygonCollider2D boundaryCollider = GetOrAddComponent<PolygonCollider2D>(boundaryT.gameObject);
|
||||
boundaryCollider.pathCount = 1;
|
||||
boundaryCollider.SetPath(0, new Vector2[]
|
||||
@@ -203,16 +230,8 @@ namespace BaseGames.Editor
|
||||
new Vector2(-12f, -6f), new Vector2(-12f, 6f),
|
||||
new Vector2( 12f, 6f), new Vector2( 12f, -6f),
|
||||
});
|
||||
RoomVisibleArea visibleArea = GetOrAddComponent<RoomVisibleArea>(boundaryT.gameObject);
|
||||
|
||||
AssignReference(roomCamera, "_visibleArea", visibleArea);
|
||||
AssignReference(confiner, "m_BoundingShape2D", boundaryCollider);
|
||||
|
||||
// Disable stray Camera / AudioListener components sometimes added by Cinemachine
|
||||
UnityEngine.Camera staleCam = roomCameraGo.GetComponent<UnityEngine.Camera>();
|
||||
if (staleCam != null) staleCam.enabled = false;
|
||||
AudioListener staleAl = roomCameraGo.GetComponent<AudioListener>();
|
||||
if (staleAl != null) { Undo.DestroyObjectImmediate(staleAl); }
|
||||
AssignReference(cameraArea, "_confinerCollider", boundaryCollider);
|
||||
|
||||
// ── [SpawnPoints] ──────────────────────────────────────────────
|
||||
Transform spawnGroup = GetOrCreateChild(root.transform, "[SpawnPoints]");
|
||||
@@ -250,7 +269,7 @@ namespace BaseGames.Editor
|
||||
GetOrCreateChild(root.transform, "[Transitions]");
|
||||
|
||||
// ── Wire RoomController ────────────────────────────────────────
|
||||
AssignReference(roomController, "_roomCamera", roomCamera);
|
||||
AssignReference(roomController, "_cameraArea", cameraArea);
|
||||
|
||||
SerializedObject roomSO = new SerializedObject(roomController);
|
||||
SerializedProperty spawnArrayProp = roomSO.FindProperty("_spawnPoints");
|
||||
@@ -263,8 +282,7 @@ namespace BaseGames.Editor
|
||||
|
||||
// ── Report ─────────────────────────────────────────────────────
|
||||
report.Add("在 RoomController._roomId 填写唯一房间 ID(如 \"Room_Forest_01\")。");
|
||||
report.Add("将 Player/CameraFollowTarget Transform 拖入 CinemachineCamera.Follow 字段以跟随玩家(或使用 BaseGames → Camera → Room Camera Setup 工具批量赋值)。");
|
||||
report.Add("调整 RoomBoundary PolygonCollider2D 顶点以匹配实际房间大小。");
|
||||
report.Add("调整 AreaBoundary PolygonCollider2D 顶点以匹配实际房间大小。");
|
||||
report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形,然后在 NavSurface Inspector 中点击 Bake。");
|
||||
report.Add("[Transitions] 子节点下使用 BaseGames/Scene/Place/Room Transition 添加过渡点。");
|
||||
|
||||
@@ -561,6 +579,34 @@ namespace BaseGames.Editor
|
||||
|
||||
Debug.LogWarning($"[SceneScaffoldTools] {scaffoldName} 完成,但仍有 {report.Count} 项需要手工确认:\n- {string.Join("\n- ", report)}", root);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 VCam 上的 CinemachinePositionComposer 写入初始默认展示参数。
|
||||
/// 这些値与 <see cref="CameraArea"/> 的默认値一致,确保脆架生成后 Scene 预览即有正确感觉。
|
||||
/// 运行时 CameraStateController.ConfigureSlot 会在每次 SwitchArea 时用 per-area 配置覆写。
|
||||
/// </summary>
|
||||
private static void ApplyComposerDefaults(CinemachinePositionComposer composer)
|
||||
{
|
||||
if (composer == null) return;
|
||||
|
||||
// 屏幕位置:玩家稍低于中心,上方有更多视野
|
||||
var comp = composer.Composition;
|
||||
comp.ScreenPosition = new Vector2(0f, -0.15f);
|
||||
comp.DeadZone.Enabled = true;
|
||||
comp.DeadZone.Size = new Vector2(0.15f, 0.05f);
|
||||
composer.Composition = comp;
|
||||
|
||||
// 阻尼:X 轻度缓冲,Y = 0(由 CameraAsymmetricDampingExtension 接管非对称 Y 阻尼)
|
||||
composer.Damping = new Vector3(0.5f, 0f, 0f);
|
||||
|
||||
// Lookahead:水平引领预测开启,IgnoreY = true(平台游戏 Y 轴不预测,避免起跳时镜头猛拉)
|
||||
var lah = composer.Lookahead;
|
||||
lah.Enabled = true;
|
||||
lah.Time = 0.28f;
|
||||
lah.Smoothing = 5f;
|
||||
lah.IgnoreY = true;
|
||||
composer.Lookahead = lah;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"references": [
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Combat",
|
||||
"BaseGames.Audio",
|
||||
"MoreMountains.Tools"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
|
||||
@@ -30,6 +30,9 @@ namespace BaseGames.Feedback
|
||||
[SerializeField] private MMF_Player _onJumpLaunch;
|
||||
[SerializeField] private MMF_Player _onFootstep;
|
||||
|
||||
[Header("脚步声材质检测")]
|
||||
[SerializeField] private BaseGames.Audio.FootstepSoundPlayer _footstepSoundPlayer;
|
||||
|
||||
[Header("命名预设(可选)")]
|
||||
[SerializeField] private NamedFeedback[] _namedPresets;
|
||||
|
||||
@@ -65,7 +68,11 @@ namespace BaseGames.Feedback
|
||||
public void PlayLandImpact() => _onLandImpact?.PlayFeedbacks();
|
||||
public void PlayAttackWhoosh() => _onAttackWhoosh?.PlayFeedbacks();
|
||||
public void PlayJumpLaunch() => _onJumpLaunch?.PlayFeedbacks();
|
||||
public void PlayFootstep() => _onFootstep?.PlayFeedbacks();
|
||||
public void PlayFootstep()
|
||||
{
|
||||
_onFootstep?.PlayFeedbacks();
|
||||
_footstepSoundPlayer?.Play();
|
||||
}
|
||||
|
||||
public void TriggerPreset(string presetId)
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace BaseGames.Player
|
||||
|
||||
// ── 能力强化 ──────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 无敌冲刺强化(类比空洞骑士暗影披风)。
|
||||
/// 无敌冲刺强化(解锁后冲刺前段获得无敌窗口)。
|
||||
/// 仅持有 Dash 时:冲刺无无敌帧。
|
||||
/// 解锁 InvincibleDash 后:冲刺期间完全无敌(地面 DashState + 空中 AerialDashState)。
|
||||
/// </summary>
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace BaseGames.Player
|
||||
/// 玩家物理移动组件。封装 Rigidbody2D 操作,提供跑动、跳跃、击退等接口。
|
||||
|
||||
/// </summary>
|
||||
// 执行顺序必须早于 PlayerController(-100),确保每帧 FixedUpdate
|
||||
// 开头能在状态机写入速度之前先应用"强制清零"标记。
|
||||
[DefaultExecutionOrder(-200)]
|
||||
[RequireComponent(typeof(Rigidbody2D))]
|
||||
public class PlayerMovement : MonoBehaviour
|
||||
{
|
||||
@@ -24,6 +27,10 @@ namespace BaseGames.Player
|
||||
private Rigidbody2D _rb;
|
||||
private float _coyoteTimer;
|
||||
private bool _isGrounded;
|
||||
// Update 中调用 ZeroHorizontalVelocity 后设置此标记;
|
||||
// 下一个 FixedUpdate(-200,先于状态机 -100)读取并清零,
|
||||
// 防止状态机用旧输入把速度重新写成非零值。
|
||||
private bool _pendingHorizontalZero;
|
||||
private bool _isWallLeft;
|
||||
private bool _isWallRight;
|
||||
private bool _onOneWayPlatform;
|
||||
@@ -48,12 +55,21 @@ namespace BaseGames.Player
|
||||
{
|
||||
Debug.Assert(_config != null, "[PlayerMovement] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this);
|
||||
_rb = GetComponent<Rigidbody2D>();
|
||||
// 关闭位置插值:若开启插值,渲染位置会在速度清零后仍追赶 1~2 渲染帧,产生视觉滑行。
|
||||
_rb.interpolation = RigidbodyInterpolation2D.None;
|
||||
if (_spriteRenderer == null)
|
||||
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
// 优先处理来自 Update 的强制清零请求(在状态机 OnStateFixedUpdate 之前执行)。
|
||||
if (_pendingHorizontalZero)
|
||||
{
|
||||
_rb.velocity = new Vector2(0f, _rb.velocity.y);
|
||||
_pendingHorizontalZero = false;
|
||||
}
|
||||
|
||||
CheckGrounded();
|
||||
CheckWalls();
|
||||
|
||||
@@ -64,12 +80,25 @@ namespace BaseGames.Player
|
||||
}
|
||||
|
||||
// ── 移动 ──────────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 直接赋予目标水平速度(按键即全速,松键即停,无加速过渡)。
|
||||
/// 地面状态每帧直接到达全速;空中调用时同样即时,但配合 ApplyAirDrag
|
||||
/// 在无输入时自然减速,保留跳出时的动量。
|
||||
/// </summary>
|
||||
public void Move(float speedX)
|
||||
{
|
||||
float target = speedX;
|
||||
float current = _rb.velocity.x;
|
||||
float accel = Mathf.Abs(speedX) > 0.01f ? _config.Acceleration : _config.Deceleration;
|
||||
float newX = Mathf.MoveTowards(current, target, accel * Time.fixedDeltaTime);
|
||||
_rb.velocity = new Vector2(speedX, _rb.velocity.y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 空中无输入时施加空气阻力:水平速度乘以 <paramref name="factor"/>,
|
||||
/// 低于阈值时归零,避免速度无限趋近 0。
|
||||
/// 在 FallState / JumpState / WallJumpState 的 OnStateFixedUpdate 中调用。
|
||||
/// </summary>
|
||||
public void ApplyAirDrag(float factor)
|
||||
{
|
||||
float newX = _rb.velocity.x * factor;
|
||||
if (Mathf.Abs(newX) < 0.05f) newX = 0f;
|
||||
_rb.velocity = new Vector2(newX, _rb.velocity.y);
|
||||
}
|
||||
|
||||
@@ -87,7 +116,7 @@ namespace BaseGames.Player
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 二段跳(Monarch Wings 等效)。覆盖当前垂直速度为 DoubleJumpForce。
|
||||
/// 二段跳。覆盖当前垂直速度为 DoubleJumpForce。
|
||||
/// FallState / JumpState 在检测到 HasAbility(DoubleJump) && AirJumpsLeft > 0 时调用。
|
||||
/// </summary>
|
||||
public void DoubleJump()
|
||||
@@ -105,7 +134,13 @@ namespace BaseGames.Player
|
||||
|
||||
// ── 速度控制 ──────────────────────────────────────────────────────────
|
||||
public void ZeroVelocity() => _rb.velocity = Vector2.zero;
|
||||
public void ZeroHorizontalVelocity() => _rb.velocity = new Vector2(0f, _rb.velocity.y);
|
||||
public void ZeroHorizontalVelocity()
|
||||
{
|
||||
_rb.velocity = new Vector2(0f, _rb.velocity.y);
|
||||
// 设置标记:下一个 FixedUpdate 开头再次强制清零,
|
||||
// 防止因读到旧输入而把速度重新写成非零值。
|
||||
_pendingHorizontalZero = true;
|
||||
}
|
||||
|
||||
// ── 朝向 ──────────────────────────────────────────────────────────────
|
||||
public void UpdateFacing()
|
||||
|
||||
@@ -7,40 +7,44 @@ namespace BaseGames.Player
|
||||
{
|
||||
[Header("地面移动")]
|
||||
public float RunSpeed = 7f;
|
||||
public float Acceleration = 50f;
|
||||
public float Deceleration = 80f;
|
||||
|
||||
[Header("跳跃(对齐空洞骑士手感)")]
|
||||
[Tooltip("一段跳初速度。HK 约 18-20,对应 ~4-5 格高度。")]
|
||||
[Header("空中移动")]
|
||||
[Tooltip("无水平输入时每个 FixedUpdate 帧水平速度的保留比例(0~1)。" +
|
||||
"0.92 ≈ 半衰期 0.17s(50Hz),松开方向键后空中动量自然衰减。")]
|
||||
[Range(0f, 1f)]
|
||||
public float AirDragFactor = 0.92f;
|
||||
|
||||
[Header("跳跃")]
|
||||
[Tooltip("一段跳初速度。推荐 18-20,对应 ~4-5 格高度。")]
|
||||
public float JumpForce = 19f;
|
||||
[Tooltip("按住跳跃键可保持的郊狼时间。HK ~0.12s。")]
|
||||
[Tooltip("按住跳跃键可保持的郊狼时间。推荐 0.12s。")]
|
||||
public float CoyoteTime = 0.12f;
|
||||
[Tooltip("下落阶段额外重力倍率。HK ~3.5,使下落比上升更快、手感更紧实。")]
|
||||
[Tooltip("下落阶段额外重力倍率。推荐 3.5,使下落比上升更快、手感更紧实。")]
|
||||
public float FallGravityMult = 3.5f;
|
||||
[Tooltip("最大下落速度(终端速度)。HK ~22。")]
|
||||
[Tooltip("最大下落速度(终端速度)。推荐 22。")]
|
||||
public float MaxFallSpeed = 22f;
|
||||
[Tooltip("松开跳跃键时速度保留比例(变高跳)。HK ~0.45,越小跳跃越低。")]
|
||||
[Tooltip("松开跳跃键时速度保留比例(变高跳)。推荐 0.45,越小跳跃越低。")]
|
||||
[Range(0f, 1f)]
|
||||
public float JumpCutMultiplier = 0.45f;
|
||||
|
||||
[Header("二段跳(Monarch Wings 等效)")]
|
||||
[Tooltip("二段跳初速度。设为与 JumpForce 相同可获得等高二段跳(HK 风格)。")]
|
||||
[Header("二段跳")]
|
||||
[Tooltip("二段跳初速度。设为与 JumpForce 相同可获得等高二段跳。")]
|
||||
public float DoubleJumpForce = 19f;
|
||||
|
||||
[Header("冲刺(对齐空洞骑士 Mothwing Cloak 手感)")]
|
||||
[Tooltip("冲刺速度(单位/秒)。HK ~25,在 0.35s 内约穿越 7-8 格。")]
|
||||
[Header("冲刺")]
|
||||
[Tooltip("冲刺速度(单位/秒)。推荐 25,在 0.35s 内约穿越 7-8 格。")]
|
||||
public float DashSpeed = 25f;
|
||||
[Tooltip("冲刺持续时长(秒)。HK ~0.35s。")]
|
||||
[Tooltip("冲刺持续时长(秒)。推荐 0.35s。")]
|
||||
public float DashDuration = 0.35f;
|
||||
[Tooltip("冲刺冷却时长(秒)。HK ~0.6s,落地后才可再次冲刺。")]
|
||||
[Tooltip("冲刺冷却时长(秒)。推荐 0.6s,落地后才可再次冲刺。")]
|
||||
public float DashCooldown = 0.6f;
|
||||
[Tooltip("每次腾空可使用的最大空中冲刺次数。HK = 1(Mothwing Cloak)。")]
|
||||
[Tooltip("每次腾空可使用的最大空中冲刺次数。通常设为 1(单次空中冲刺)。")]
|
||||
public int MaxAerialDashes = 1;
|
||||
|
||||
[Header("冲刺无敌帧(对齐空洞骑士:窗口 < 冲刺时长,且有独立 CD)")]
|
||||
[Tooltip("冲刺无敌窗口时长(秒)。仅为冲刺前段;窗口结束后即使仍在冲刺中也可受伤被打断(HK ~0.20s)。")]
|
||||
[Header("冲刺无敌帧(窗口 < 冲刺时长,且有独立 CD)")]
|
||||
[Tooltip("冲刺无敌窗口时长(秒)。仅为冲刺前段;窗口结束后即使仍在冲刺中也可受伤被打断(推荐 0.20s)。")]
|
||||
public float DashInvincibilityDuration = 0.20f;
|
||||
[Tooltip("无敌的独立冷却(秒)。CD 内再次冲刺不会获得无敌帧,防止连冲变相持续无敌(HK ~0.9s)。")]
|
||||
[Tooltip("无敌的独立冷却(秒)。CD 内再次冲刺不会获得无敌帧,防止连冲变相持续无敌(推荐 0.9s)。")]
|
||||
public float DashInvincibilityCooldown = 0.9f;
|
||||
|
||||
[Header("蹬墙 / 壁滑")]
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace BaseGames.Player.States
|
||||
/// <summary>
|
||||
/// 空中冲刺状态(架构 05_PlayerModule §12)。
|
||||
/// 与地面 DashState 独立,消耗 MaxAerialDashes 次数;
|
||||
/// 空中冲刺可向任意方向(使用移动输入方向,无输入则使用朝向)。
|
||||
/// 冲刺方向在进入时锁定为当前朝向(进入时锁定朝向,冲刺期间不可通过输入改变方向)。
|
||||
/// </summary>
|
||||
public class AerialDashState : PlayerStateBase
|
||||
{
|
||||
@@ -41,10 +41,9 @@ namespace BaseGames.Player.States
|
||||
dashState.ResetInvincibilityCooldown(Cfg.DashInvincibilityCooldown);
|
||||
}
|
||||
|
||||
// 关闭重力,施加冲刺速度(空中冲刺不改变垂直速度)
|
||||
// 关闭重力,施加冲刺速度(方向锁定为进入时朝向,不受输入影响)
|
||||
Move?.SetGravityScale(0f);
|
||||
float dir = Input.MoveInput.x != 0 ? Mathf.Sign(Input.MoveInput.x) : _facingDir;
|
||||
Move?.Dash(new Vector2(dir, 0f), Cfg.DashSpeed);
|
||||
Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed);
|
||||
|
||||
// 播放冲刺动画(复用地面冲刺动画)
|
||||
if (AnimCfg?.Dash != null) Anim?.Play(AnimCfg.Dash);
|
||||
@@ -54,6 +53,15 @@ namespace BaseGames.Player.States
|
||||
{
|
||||
_timer -= Time.deltaTime;
|
||||
if (_timer <= 0f)
|
||||
{
|
||||
Move?.SetGravityScale(Cfg.DefaultGravityScale);
|
||||
Owner.TransitionTo(Owner.GetState<FallState>());
|
||||
return;
|
||||
}
|
||||
|
||||
// 撞墙立即终止冲刺(碰到实体墙立即中止,避免压墙卡住)
|
||||
var wd = Owner.WallDetector;
|
||||
if (wd != null && wd.IsTouchingWall && wd.WallDirection == _facingDir)
|
||||
{
|
||||
Move?.SetGravityScale(Cfg.DefaultGravityScale);
|
||||
Owner.TransitionTo(Owner.GetState<FallState>());
|
||||
@@ -67,12 +75,9 @@ namespace BaseGames.Player.States
|
||||
|
||||
public override void OnStateFixedUpdate()
|
||||
{
|
||||
// 冲刺期间锁定速度
|
||||
// 冲刺期间保持锁定方向速度(与 DashState 一致,使用 _facingDir)
|
||||
if (_timer > 0f)
|
||||
{
|
||||
float dir = Input.MoveInput.x != 0 ? Mathf.Sign(Input.MoveInput.x) : _facingDir;
|
||||
Move?.Dash(new Vector2(dir, 0f), Cfg.DashSpeed);
|
||||
}
|
||||
Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed);
|
||||
}
|
||||
|
||||
/// <summary>着地时重置空中冲刺次数(由 PlayerController 在着地时调用)。</summary>
|
||||
|
||||
@@ -47,7 +47,7 @@ namespace BaseGames.Player.States
|
||||
// 无敌帧:
|
||||
// 条件 1:已解锁 InvincibleDash
|
||||
// 条件 2:无敌冷却已就绪(防止 spam 冲刺连序无敌)
|
||||
// 窗口时长 = DashInvincibilityDuration < DashDuration,冲刺后段无保护(对齐 HK)
|
||||
// 窗口时长 = DashInvincibilityDuration < DashDuration,冲刺后段无保护
|
||||
if (Stats != null && Stats.HasAbility(AbilityType.InvincibleDash) && CanGrantInvincibility)
|
||||
{
|
||||
Stats.BeginInvincibility(Cfg.DashInvincibilityDuration);
|
||||
@@ -66,6 +66,14 @@ namespace BaseGames.Player.States
|
||||
{
|
||||
_timer -= Time.deltaTime;
|
||||
if (_timer <= 0f)
|
||||
{
|
||||
EndDash();
|
||||
return;
|
||||
}
|
||||
|
||||
// 撞墙立即终止冲刺(碰到实体墙立即中止,避免压墙卡住)
|
||||
var wd = Owner.WallDetector;
|
||||
if (wd != null && wd.IsTouchingWall && wd.WallDirection == _facingDir)
|
||||
EndDash();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using UnityEngine;
|
||||
namespace BaseGames.Player.States
|
||||
{
|
||||
/// <summary>
|
||||
/// 下落状态(对齐空洞骑士手感)。
|
||||
/// 下落状态。
|
||||
/// - 郊狼跳:CoyoteTimer > 0 时按跳跃 → 一段跳(JumpState,使用 JumpForce)。
|
||||
/// - 二段跳:CoyoteTimer 耗尽后按跳跃且 AirJumpsLeft > 0 → JumpState(使用 DoubleJumpForce)。
|
||||
/// - 空中冲刺:HasAbility(AirDash) && HasAerialDash → AerialDashState。
|
||||
@@ -38,7 +38,7 @@ namespace BaseGames.Player.States
|
||||
_owner.TransitionTo(_owner.GetState<JumpState>());
|
||||
return;
|
||||
}
|
||||
// 无跳跃机会:输入已消耗,静默忽略(HK 相同行为)
|
||||
// 无跳跃机会:输入已消耗,静默忽略(无可用跳跃机会时静默消耗输入缓冲)
|
||||
}
|
||||
|
||||
// ── 空中冲刺────────────────────────────────────────────────────────
|
||||
@@ -64,14 +64,17 @@ namespace BaseGames.Player.States
|
||||
return;
|
||||
}
|
||||
|
||||
// 空中水平移动
|
||||
if (Mathf.Abs(Input.MoveInput.x) > 0.01f)
|
||||
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
|
||||
}
|
||||
|
||||
public override void OnStateFixedUpdate()
|
||||
{
|
||||
// 增强下落重力(FallGravityMult 对齐 HK:下落比上升更快)
|
||||
// 空中水平移动:有输入时立即覆盖至目标速度;无输入时施加空气阻力保留动量
|
||||
if (Mathf.Abs(Input.MoveInput.x) > 0.01f)
|
||||
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
|
||||
else
|
||||
Move.ApplyAirDrag(Cfg.AirDragFactor);
|
||||
|
||||
// 增强下落重力(FallGravityMult:下落比上升更快,手感更紧实)
|
||||
if (Move.Rb.velocity.y < 0f)
|
||||
{
|
||||
float extraGrav = Physics2D.gravity.y * (Cfg.FallGravityMult - 1f) * Time.fixedDeltaTime;
|
||||
|
||||
@@ -3,9 +3,9 @@ using UnityEngine;
|
||||
namespace BaseGames.Player.States
|
||||
{
|
||||
/// <summary>
|
||||
/// 跳跃状态(对齐空洞骑士手感)。
|
||||
/// 跳跃状态。
|
||||
/// - 一段跳 / 郊狼跳:OnStateEnter 时调用 Move.Jump()。
|
||||
/// - 二段跳(Monarch Wings 等效):上升或下落途中再按跳跃且 AirJumpsLeft > 0,
|
||||
/// - 二段跳(二段跳能力解锁后可用):上升或下落途中再按跳跃且 AirJumpsLeft > 0,
|
||||
/// 调用 Move.DoubleJump(),重播跳跃动画,不离开本状态(保持速度截断逻辑)。
|
||||
/// - 空中冲刺:上升途中按冲刺且 HasAbility(AirDash) → AerialDashState。
|
||||
/// - 变高跳:松开跳跃键触发 JumpCancelledEvent → CutJump()(系数 = JumpCutMultiplier)。
|
||||
@@ -59,7 +59,7 @@ namespace BaseGames.Player.States
|
||||
}
|
||||
}
|
||||
|
||||
// 二段跳:上升阶段即可触发(类比 HK Monarch Wings,随时可二段跳)
|
||||
// 二段跳:上升阶段即可触发(上升途中任意时刻可二段跳)
|
||||
if (Buffer.ConsumeJump() && Owner.AirJumpsLeft > 0)
|
||||
{
|
||||
Owner.UseAirJump();
|
||||
@@ -68,10 +68,15 @@ namespace BaseGames.Player.States
|
||||
if (AnimCfg?.Jump != null) Anim?.Play(AnimCfg.Jump);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 水平移动(HK 空中控制:与跑步同速)
|
||||
public override void OnStateFixedUpdate()
|
||||
{
|
||||
// 空中水平移动:有输入时立即覆盖至目标速度;无输入时施加空气阻力保留动量
|
||||
if (Mathf.Abs(Input.MoveInput.x) > 0.01f)
|
||||
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
|
||||
else
|
||||
Move.ApplyAirDrag(Cfg.AirDragFactor);
|
||||
}
|
||||
|
||||
public override void OnStateExit()
|
||||
|
||||
@@ -105,7 +105,8 @@ namespace BaseGames.Player.States
|
||||
// ── IPoiseSource 实现(架构 06_CombatModule §13)─────────────────────
|
||||
/// <summary>
|
||||
/// 玩家不拥有霸体,始终返回 <see cref="PoiseLevel.None"/>。
|
||||
/// 设计决策:类似 Hollow Knight,玩家依靠走位和弹反规避伤害,而非硬吃。
|
||||
/// 设计决策:玩家不拥有霸体,始终返回 <see cref="PoiseLevel.None"/>。
|
||||
/// 玩家依靠走位和弹反规避伤害而非硬吃,以保持战斗的负担感和张力。
|
||||
/// 若未来需要临时霸体(如特定技能动作),请通过独立的覆盖标记实现,
|
||||
/// 而非在此处引入状态,以保持接口语义清晰。
|
||||
/// </summary>
|
||||
|
||||
@@ -38,6 +38,7 @@ namespace BaseGames.Player.States
|
||||
}
|
||||
if (Mathf.Abs(Input.MoveInput.x) < 0.1f)
|
||||
{
|
||||
Move.ZeroHorizontalVelocity();
|
||||
_owner.TransitionTo(_owner.GetState<IdleState>());
|
||||
return;
|
||||
}
|
||||
@@ -45,7 +46,11 @@ namespace BaseGames.Player.States
|
||||
|
||||
public override void OnStateFixedUpdate()
|
||||
{
|
||||
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
|
||||
float inputX = Input.MoveInput.x;
|
||||
if (Mathf.Abs(inputX) > 0.1f)
|
||||
Move.Move(inputX * Cfg.RunSpeed);
|
||||
else
|
||||
Move.ZeroHorizontalVelocity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,14 +39,17 @@ namespace BaseGames.Player.States
|
||||
|
||||
public override void OnStateUpdate()
|
||||
{
|
||||
_inputLockTimer -= Time.deltaTime;
|
||||
|
||||
// 上升结束 → 下落
|
||||
if (!Move.IsRising)
|
||||
{
|
||||
Owner.TransitionTo(Owner.GetState<FallState>());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnStateFixedUpdate()
|
||||
{
|
||||
_inputLockTimer -= Time.fixedDeltaTime;
|
||||
|
||||
// 输入锁结束后允许水平控制
|
||||
if (_inputLockTimer <= 0f && Mathf.Abs(Input.MoveInput.x) > 0.01f)
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 房间控制器。挂在每个房间场景的 [RoomRoot] 下。
|
||||
/// Start 时切换摄像机到该房间的 RoomCamera,并提供出生点查询。
|
||||
/// Start 时切换摄像机到该房间的 CameraArea,并提供出生点查询。
|
||||
/// </summary>
|
||||
public class RoomController : MonoBehaviour
|
||||
{
|
||||
@@ -18,8 +18,25 @@ namespace BaseGames.World
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_cameraArea != null)
|
||||
ServiceLocator.GetOrDefault<ICameraService>()?.SwitchArea(_cameraArea);
|
||||
CameraArea area = _cameraArea;
|
||||
|
||||
// 未手动绑定时,自动在当前场景中查找(每个房间场景通常只有一个 CameraArea)
|
||||
if (area == null)
|
||||
{
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
area = Object.FindFirstObjectByType<CameraArea>();
|
||||
#else
|
||||
area = Object.FindObjectOfType<CameraArea>();
|
||||
#endif
|
||||
if (area != null)
|
||||
Debug.LogWarning($"[RoomController] {name}:_cameraArea 未绑定,自动找到 {area.name}。建议在 Inspector 中手动指定。");
|
||||
else
|
||||
Debug.LogError($"[RoomController] {name}:未找到 CameraArea,相机不会切换。");
|
||||
}
|
||||
|
||||
if (area != null)
|
||||
// instantCut = true:房间入口传送后相机硬切,无混合拖影
|
||||
ServiceLocator.GetOrDefault<ICameraService>()?.SwitchArea(area, 0, instantCut: true);
|
||||
}
|
||||
|
||||
/// <summary>通过 transitionId 查找对应的出生点。</summary>
|
||||
|
||||
@@ -43,16 +43,27 @@ Persistent.unity
|
||||
└── [Camera]
|
||||
└── CameraStateController ExecutionOrder = -100
|
||||
组件: CameraStateController
|
||||
│ _vcamA → VCamA(全局虚拟相机 A)
|
||||
│ _vcamB → VCamB(全局虚拟相机 B)
|
||||
│ _vcamA → VCamA(全局虚拟相机 A)
|
||||
│ _vcamB → VCamB(全局虚拟相机 B)
|
||||
│ _lookSystem → CameraLookSystem 节点
|
||||
组件: CinemachineBrain ← 实际渲染相机(随 Main Camera 放置)
|
||||
组件: CinemachineImpulseSource ← 屏幕抖动信号源
|
||||
├── CameraLookSystem ← 运行时由 SetFollowTarget() 赋值基准目标
|
||||
│ 组件: CameraLookSystem 双轴窥视偏移 + 速度门控;输出 VirtualTarget
|
||||
├── VCamA
|
||||
│ 组件: CinemachineCamera Follow = Player/CameraFollowTarget
|
||||
│ 组件: CinemachineCamera Follow = CameraLookSystem.VirtualTarget(运行时自动赋值)
|
||||
│ 组件: CinemachinePositionComposer Body 跟随组件;初始值与 CameraArea 默认值对齐
|
||||
│ 组件: CinemachineConfiner2D ← 由 CameraStateController 动态更新 BoundingShape2D
|
||||
│ 组件: CameraAxisLockExtension 锁定 X 或 Y 轴(竖井 / 走廊场景)
|
||||
│ 组件: CameraAsymmetricDampingExtension 非对称 Y 阻尼(下落快恢复,起跳慢追赶)
|
||||
│ 组件: CameraAdaptiveLookaheadExtension 速度自适应 Lookahead(移动快 → 预见更多)
|
||||
└── VCamB
|
||||
组件: CinemachineCamera Follow = Player/CameraFollowTarget
|
||||
组件: CinemachineCamera Follow = CameraLookSystem.VirtualTarget(运行时自动赋值)
|
||||
组件: CinemachinePositionComposer 同 VCamA
|
||||
组件: CinemachineConfiner2D ← 由 CameraStateController 动态更新 BoundingShape2D
|
||||
组件: CameraAxisLockExtension
|
||||
组件: CameraAsymmetricDampingExtension
|
||||
组件: CameraAdaptiveLookaheadExtension
|
||||
|
||||
Level_01.unity
|
||||
├── [CameraAreas]
|
||||
@@ -92,8 +103,9 @@ Level_01.unity
|
||||
| GameObject | 挂载组件 | 说明 |
|
||||
|-----------|---------|------|
|
||||
| `[CameraController]` | `CameraStateController`、`CinemachineBrain`、`CinemachineImpulseSource` | ExecutionOrder = -100 |
|
||||
| `VCamA` | `CinemachineCamera`、`CinemachineConfiner2D` | 全局虚拟相机 A;拖入 `CameraStateController._vcamA` |
|
||||
| `VCamB` | `CinemachineCamera`、`CinemachineConfiner2D` | 全局虚拟相机 B;拖入 `CameraStateController._vcamB` |
|
||||
| `CameraLookSystem` | `CameraLookSystem` | 双轴窥视偏移(垂直+水平)+ 速度门控;输出 `VirtualTarget` 供 VCam 跟随;拖入 `CameraStateController._lookSystem` |
|
||||
| `VCamA` | `CinemachineCamera`、**`CinemachinePositionComposer`**、`CinemachineConfiner2D`、`CameraAxisLockExtension`、`CameraAsymmetricDampingExtension`、`CameraAdaptiveLookaheadExtension` | 全局虚拟相机 A;**PositionComposer 是 Body 组件,必须存在**;拖入 `CameraStateController._vcamA` |
|
||||
| `VCamB` | `CinemachineCamera`、**`CinemachinePositionComposer`**、`CinemachineConfiner2D`、`CameraAxisLockExtension`、`CameraAsymmetricDampingExtension`、`CameraAdaptiveLookaheadExtension` | 全局虚拟相机 B;同 VCamA;拖入 `CameraStateController._vcamB` |
|
||||
|
||||
> **注意**:`CinemachineBrain` 须挂在附有 `Camera` 组件(Main Camera)的 GameObject 上,
|
||||
> 否则 Cinemachine 无法驱动视口渲染。两台全局 VCam 初始优先级均为 0,由 `CameraStateController` 在运行时动态管理。
|
||||
@@ -108,6 +120,7 @@ Level_01.unity
|
||||
| `_vcamA` (CinemachineCamera) | ● 已绑定 |
|
||||
| `_vcamB` (CinemachineCamera) | ● 已绑定 |
|
||||
| `_brain` (CinemachineBrain) | ● 已绑定 |
|
||||
| `_lookSystem` (CameraLookSystem) | ● 已绑定 |
|
||||
| `_impulseSource` (CinemachineImpulseSource) | ◌ 可选;用于屏幕抖动 |
|
||||
| `_defaultBlendProfile` (CameraBlendProfileSO) | ◌ 可选;未设置则无混合过渡 |
|
||||
|
||||
@@ -175,12 +188,18 @@ Level_01.unity
|
||||
|
||||
### 3.4 全局 VCam Follow 绑定
|
||||
|
||||
Persistent 场景中两台全局 VCam 的 `CinemachineCamera.Follow` 须指向 **Player 下的 `CameraFollowTarget` 子节点**,而非 Player 根节点本身。
|
||||
使用 `Place Player` 工具放置 Player 时,`CameraFollowTarget` 子节点会被自动创建(`localPosition = 0`)。
|
||||
Persistent 场景中两台全局 VCam 的 `CinemachineCamera.Follow` **不直接指向 Player**,
|
||||
而是指向 `CameraLookSystem` 组件在运行时生成的 **`[CameraLookTarget]` 虚拟目标节点**。
|
||||
该节点由 `CameraStateController.SetFollowTarget(Transform)` 在玩家注册时自动创建并赋值,
|
||||
其世界位置 = Player 基准目标位置 + 当前窥视偏移(`CameraLookSystem` 的双轴输出)。
|
||||
|
||||
**方法**:
|
||||
- **自动**:打开 **Camera Area Setup** 窗口 → CameraStateController 区域 → 点击 **为全局 VCam 赋值 Follow 目标**(场景中必须已有 tag=Player 对象)
|
||||
- **手动**:分别选中 `VCamA` / `VCamB` → CinemachineCamera 组件 → `Follow` 字段拖入 `Player/CameraFollowTarget` Transform
|
||||
> **不要在 Inspector 中手动把 VCam.Follow 拖到 Player 本身或 Player/CameraFollowTarget。**
|
||||
> `SetFollowTarget` 会在运行时覆盖,且直接指向 Player 会绕过窥视偏移计算。
|
||||
|
||||
**工具支持**:
|
||||
**Camera Area Setup** 窗口 → **为全局 VCam 赋值 Follow 目标** 按钮
|
||||
→ 该按钮仅用于调试回退(绕过 LookSystem,直指 Player/CameraFollowTarget)。
|
||||
正式流程请通过 `ICameraService.SetFollowTarget(playerTransform)` 注册,由系统自动处理。
|
||||
|
||||
---
|
||||
|
||||
@@ -249,17 +268,22 @@ Inspector 参数预览区实时显示 FOV(来源:专有 VCam → 全局 VCam
|
||||
|
||||
### 4.2 CameraConfigSO
|
||||
|
||||
**创建路径**:`Assets → Create → BaseGames → Camera → CameraConfig`
|
||||
> ⚠ **Legacy / 已废弃**:`CameraConfigSO` 是早期相机系统的配置资产,现已不再被 `CameraStateController` 读取或应用。
|
||||
> 当前架构直接通过 `CameraArea` 字段(`ScreenPosition`、`DeadZoneSize`、`LookaheadTime`、`DampingDown`、`DampingUp` 等)在 `ConfigureSlot` 时写入 Cinemachine 组件,无需此 SO。
|
||||
> 若项目中仍存在 `CameraConfigSO` 资产,可安全忽略或删除。
|
||||
|
||||
~~**创建路径**:`Assets → Create → BaseGames → Camera → CameraConfig`~~
|
||||
|
||||
| 字段 | 说明 | 典型值 |
|
||||
|------|------|--------|
|
||||
| `FollowDamping` | 跟随阻尼(越大越迟钝) | `0.15` |
|
||||
| `LookAheadTime` | 朝向预见时间(秒) | `0.3` |
|
||||
| `DeadZoneSize` | 死区尺寸(玩家在此范围内移动相机不动) | `(1, 0.5)` |
|
||||
| `SoftZoneSize` | 软区尺寸(慢速追赶) | `(2.5, 2)` |
|
||||
| `LookDownOffset` | 俯视偏移(负值向下) | `-1.5` |
|
||||
| `LookUpOffset` | 仰视偏移(正值向上) | `1.5` |
|
||||
| `DefaultImpulseStrength` | 默认震屏强度 | `0.3` |
|
||||
| `FollowDamping` | ~~跟随阻尼~~ | — |
|
||||
| `LookAheadTime` | ~~朝向预见时间~~ | — |
|
||||
| `DeadZoneSize` | ~~死区尺寸~~ | — |
|
||||
| `SoftZoneSize` | ~~软区尺寸~~ | — |
|
||||
| `LookDownOffset` | ~~俯视偏移~~ | — |
|
||||
| `LookUpOffset` | ~~仰视偏移~~ | — |
|
||||
| `DefaultImpulseStrength` | ~~默认震屏强度~~ | — |
|
||||
|
||||
|
||||
> `CameraConfigSO` 的配置值须由运行时的 `CameraStateController` 或相机系统读取并写入 Cinemachine 组件,具体写入逻辑取决于 `CameraStateController.ApplyConfig()` 的实现(如有扩展)。
|
||||
|
||||
@@ -285,7 +309,7 @@ Inspector 参数预览区实时显示 FOV(来源:专有 VCam → 全局 VCam
|
||||
| `✗`(红) | 缺失必填项 |
|
||||
| `◌`(黄) | 可选项未设置 |
|
||||
|
||||
检查项:`_vcamA`、`_vcamB`(必填)、`_brain`(必填)、`_impulseSource`(可选)、`_defaultBlendProfile`(可选)
|
||||
检查项:`_vcamA`、`_vcamB`(必填)、`_brain`(必填)、`_lookSystem`(必填)、`_impulseSource`(可选)、`_defaultBlendProfile`(可选)
|
||||
|
||||
底部按钮:**为全局 VCam 赋值 Follow 目标** → 查找 Player/CameraFollowTarget 并写入两台 VCam 的 Follow 字段。
|
||||
|
||||
@@ -461,7 +485,7 @@ Inspector 参数预览区实时显示 FOV(来源:专有 VCam → 全局 VCam
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| Game 视图相机不动(黑屏或固定位置) | 全局 VCam `Follow` 未绑定 | Camera Area Setup → 为全局 VCam 赋值 Follow 目标 |
|
||||
| Game 视图相机不动(黑屏或固定位置) | 全局 VCam `Follow` 未绑定 | `ICameraService.SetFollowTarget(player)` 注册玩家;或 Camera Area Setup → 为全局 VCam 赋值 Follow 目标 |
|
||||
| 相机追赶卡顿/震颤 | `CinemachineConfiner2D.BoundingShape2D` 未绑定或碰撞体顶点有误 | 确认 `CameraArea._confinerCollider` 已绑定,PolygonCollider2D 顶点数 ≥ 3 |
|
||||
| 进入区域后限位未更新(仍在旧区域限位内) | `CameraArea._confinerCollider` 为空,`ConfigureSlot` 跳过了更新 | 打开 Camera Area Setup 修复 `_confinerCollider` 绑定 |
|
||||
| 场景中有多个 `CinemachineBrain` | Persistent 场景外又添加了含 Camera 组件的对象 | 仅 Main Camera 上保留一个 Brain |
|
||||
@@ -470,3 +494,7 @@ Inspector 参数预览区实时显示 FOV(来源:专有 VCam → 全局 VCam
|
||||
| 触发器无响应(玩家穿越后相机不切) | `CameraTriggerZone._targetArea` 未绑定,或 `_playerTag` 不匹配 | 检查 `_targetArea` 是否已拖入 `CameraArea`;确认 Player Tag = "Player" |
|
||||
| `Camera Area Setup` 窗口列表为空 | 场景未保存或 DomainReload 后未刷新 | 点击窗口内 `↻ 刷新` 按钮 |
|
||||
| 专有 VCam 不切换 | `_dedicatedPriority` ≤ 全局激活优先级(默认 10) | 将 `_dedicatedPriority` 设置为 > 10(默认 20 已满足) |
|
||||
| Camera Area Setup 中 `_lookSystem` 红色 ✗ | `CameraStateController._lookSystem` 未绑定 | 将 Persistent 场景 `[Camera]/CameraLookSystem` 节点拖入该字段;或重新运行 SceneScaffoldTools |
|
||||
| 按住方向键相机偏移不出现(窥视无效) | `CameraLookSystem._baseTarget` 未设置 | 确认 `ICameraService.SetFollowTarget(player)` 在玩家 Awake/Start 后调用 |
|
||||
| 下落时相机跟随过慢(非对称阻尼异常) | `CameraAsymmetricDampingExtension` 未挂到 VCamA/VCamB,或 `DampingDown` 未被 `ConfigureSlot` 写入 | 确认两台 VCam 已挂载该扩展;检查 `CameraArea._dampingDown` > 0 |
|
||||
| 高速移动时 Lookahead 不变化 | `CameraAdaptiveLookaheadExtension` 未挂到 VCamA/VCamB,或 `SetConfiguredMax` 未被调用 | 确认两台 VCam 已挂载该扩展;检查 `ConfigureSlot` 代码路径正常执行 |
|
||||
|
||||
@@ -8,15 +8,12 @@ EditorBuildSettings:
|
||||
- enabled: 0
|
||||
path: Assets/Scenes/SampleScene.unity
|
||||
guid: 8c9cfa26abfee488c85f1582747f6a02
|
||||
- enabled: 1
|
||||
path: Assets/_Game/Scenes/Persistent.unity
|
||||
guid: 6372e5b8e07d7ae4eb37a184fc8e912d
|
||||
- enabled: 0
|
||||
path: Assets/_Game/Scenes/Testings/TestRoom.unity
|
||||
guid: 970b399c05a553947883805f7dad7770
|
||||
- enabled: 1
|
||||
path: Assets/_Game/Scenes/Testings/TestRoomA.unity
|
||||
guid: f11781b557031114087be5fab27f8dd4
|
||||
- enabled: 1
|
||||
path: Assets/_Game/Scenes/Persistent.unity
|
||||
guid: 6372e5b8e07d7ae4eb37a184fc8e912d
|
||||
m_configObjects:
|
||||
com.unity.addressableassets: {fileID: 11400000, guid: 5afba7e2911543d418af812de1160fb1, type: 2}
|
||||
com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 6d0341a640ba64043a8cd70f771b962d, type: 3}
|
||||
|
||||
Reference in New Issue
Block a user