摄像机区域的优化

This commit is contained in:
2026-05-17 07:56:12 +08:00
parent f264329751
commit d25f237e76
62 changed files with 25774 additions and 5450 deletions

View 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`(泛指游戏类型)
- `手感``风格`(描述本项目自身功能的通用词)

View File

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

View 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

View File

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

View File

@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e825a1ad33662d41819655575a49941, type: 3}
m_Name: CameraLensConfig
m_EditorClassIdentifier:
fieldOfView: 10
cameraDepth: 64

View 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

View 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}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 38a94d79d28868442a2120e31405ec0a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View 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
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 38af2eabab7039c4a919181e4c507d12
guid: 1a74182d14151114ea7691f3e7183583
MonoImporter:
externalObjects: {}
serializedVersion: 2

View 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 = 静止时完全无 Lookahead0.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;
}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: b358a30ac16c6a34fb673ede0a288e48
guid: a12cbb2380ff137459b7ba80d492733f
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@@ -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;
@@ -45,23 +111,42 @@ namespace BaseGames.Camera
// ── 公开属性 ──────────────────────────────────────────────────────────
public PolygonCollider2D ConfinerCollider => _confinerCollider;
public CameraLensConfigSO LensConfig => _lensConfig;
public float LastSyncFOV => _lastSyncFOV;
public CameraBlendProfileSO BlendProfile => _blendProfile;
public Rect VisibleBounds => _visibleBounds;
/// <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 &gt; 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,8 +154,10 @@ namespace BaseGames.Camera
private void OnDrawGizmosSelected()
{
// 黄色:可视区域
Vector3 center = new Vector3(_visibleBounds.center.x, _visibleBounds.center.y, 0f);
// 黄色:可视区域(本地坐标 + 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);
@@ -78,7 +165,22 @@ namespace BaseGames.Camera
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);

View File

@@ -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 是 CinemachinePositionComposerDamping.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;
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: e733a7cb718909842b12f5994eb841c4
guid: cb5a7225ab133e74b81d1f0ae22ccc77
MonoImporter:
externalObjects: {}
serializedVersion: 2

View 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;
}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: af7e12583264b8c4da8dcd69df274793
guid: 7e2e7849ca8d76f438c4b2899c9fb421
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@@ -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;
}
}

View 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;
}
}
}
}

View File

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

View 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;
}
}

View File

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

View 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);
}
}
}

View File

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

View File

@@ -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("全局双 VCamPersistent 场景中放置两台通用虚拟相机)")]
[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 = A1 = 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 → 配置非活跃全局 VCamping-pong 切换优先级触发混合。</item>
/// </list>
/// 切换到目标相机区域。<paramref name="priority"/> &lt; 当前激活优先级时忽略。
/// <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>
@@ -144,31 +344,146 @@ namespace BaseGames.Camera
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配置非活跃槽 → 升级其优先级 → 降低活跃槽优先级
// 只有一台 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; // 降到待机但仍 > 0Brain 可在混合期间读取其状态
_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
}
}

View File

@@ -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.Startpriority=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));
}
}
}
}

View File

@@ -1,15 +1,45 @@
using UnityEngine;
namespace BaseGames.Camera
{
/// <summary>
/// 相机服务接口。供 CameraTriggerZone 等调用,
/// 通过 ServiceLocator.Get&lt;ICameraService&gt;() 访问,无需直接依赖 CameraStateController。
/// 相机服务接口。通过 <c>ServiceLocator.GetOrDefault&lt;ICameraService&gt;()</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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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("检测")]

View File

@@ -8,7 +8,16 @@ namespace BaseGames.Core.Assets
public static class AddressKeys
{
// ── Scenes ──────────────────────────────────────────────────────
/// <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 ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,62 @@
using UnityEngine;
using UnityEngine.SceneManagement;
using BaseGames.Core.Assets;
namespace BaseGames.Core
{
/// <summary>
/// 运行时引导器:在任意场景进入 Play Mode 时,自动保证 Persistent 场景先于起始场景加载。
///
/// 解决的核心问题:
/// 开发者从任意房间场景直接按 PlayGameManager / SceneService 等全局服务不存在。
///
/// 工作时机BeforeSceneLoad
/// 此回调在 Unity 加载第一个场景的资产之前触发。
/// 在这里调用 SceneManager.LoadScene(Additive) 会将 Persistent 场景加入加载队列,
/// 使其 AwakeDefaultExecutionOrder -2000早于起始场景的 Awake 执行,
/// 所有服务在起始场景的第一个 Awake 之前已完成注册。
///
/// 前提:
/// Scene_Persistent 必须已添加到 Build Settings不需要是 Index 0
/// Persistent 不应作为自动加载的 scene 0避免与本脚本冲突导致双重加载。
/// 推荐:将 Main Menu 场景作为 Build Index 0Persistent 作为任意非 0 索引保留。
///
/// 编辑器 Edit Mode 的便利性由 PersistentSceneAutoLoaderEditor 程序集)负责,
/// 本脚本只处理运行时(含发行版 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);
}
}
}

View File

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

View File

@@ -8,10 +8,19 @@ namespace BaseGames.Core
/// <summary>
/// 在 Awake 时(最早执行)向 ServiceLocator 注册所有服务。
/// 挂载在 Persistent 场景的根 GameObject 上。
///
/// 重复加载保护GameBootstrapBeforeSceneLoad与 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();

View File

@@ -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._vcamAPersistent 场景
/// → CameraLensConfigSO.fieldOfView单一来源无跨场景依赖
/// → CameraStateController._vcamAPersistent 场景已加载时)
/// → Camera.main.fieldOfView
/// → 60f默认
/// </summary>
@@ -24,30 +25,212 @@ 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;
// ── 基础设置 ──────────────────────────────────────────────────────
_foldBase = DrawFoldoutHeader("基础设置", _foldBase);
if (_foldBase)
{
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("必须绑定子节点 PolygonCollider2DAreaBoundary否则 Cinemachine 无法限位。", MessageType.Error);
EditorGUILayout.PropertyField(serializedObject.FindProperty("_visibleBounds"), new GUIContent("Visible Bounds本地坐标"));
}
}
EditorGUILayout.Space(2f);
// ── 跟随参数覆盖 ─────────────────────────────────────────────────
var overrideProp = serializedObject.FindProperty("_overrideFollowBehaviour");
bool overrides = overrideProp.boolValue;
_foldFollow = DrawFoldoutHeader(
overrides ? "跟随参数覆盖 ●" : "跟随参数覆盖 ○ (使用全局默认)", _foldFollow);
if (_foldFollow)
{
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(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 SOCameraDepth = 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("垂直 FOV来源见工具提示", vFOV);
EditorGUILayout.FloatField("有效深度", depth);
EditorGUILayout.FloatField("视口半高(世界单位)", halfH);
EditorGUILayout.FloatField("视口半宽(世界单位)", halfW);
@@ -56,19 +239,56 @@ namespace BaseGames.Editor
bool canSync = area.ConfinerCollider != null;
if (!canSync)
EditorGUILayout.HelpBox("ConfinerCollider 未绑定,无法同步限位区域。", MessageType.Warning);
using (new EditorGUI.DisabledScope(!canSync))
{
if (GUILayout.Button("从可视区域更新限位区域(透视)", GUILayout.Height(28f)))
if (GUILayout.Button("从可视区域更新限位区域(透视)", GUILayout.Height(26f)))
SyncConfinerFromVisibleBounds(area, vFOV, aspect);
}
// ── 图例说明 ─────────────────────────────────────────────────────
EditorGUILayout.Space(4f);
DrawLegend("■ 黄色矩形Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
DrawLegend("■ 蓝色多边形Scene 视图)", kConfinerColor, "限位区域 — CinemachineConfiner2D 的运动边界");
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>
/// 绘制可视区域的交互 Handle4 个角点(对角缩放)+ 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 → TLSyncConfinerFromVisibleBounds
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;
}
}
}

View File

@@ -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}°)。");
}
}
}

View File

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

View 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
/// ├─ AreaBoundaryPolygonCollider2DisTrigger=true对应旧 Confiner
/// └─ TriggerZoneCameraTriggerZone + 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不依赖 .boundsinactive 对象上 .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),
};
}
}
}

View File

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

View File

@@ -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

View 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的保证由 GameBootstrapRuntime 程序集)负责,
/// 本脚本与 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;
}
}
}

View File

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

View File

@@ -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()
{

View File

@@ -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);
// CinemachinePositionComposerBody 阶段组件必须存在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;
}
}
}

View File

@@ -10,6 +10,7 @@
"references": [
"BaseGames.Core.Events",
"BaseGames.Combat",
"BaseGames.Audio",
"MoreMountains.Tools"
],
"autoReferenced": true,

View File

@@ -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)
{

View File

@@ -42,7 +42,7 @@ namespace BaseGames.Player
// ── 能力强化 ──────────────────────────────────────────────────────────
/// <summary>
/// 无敌冲刺强化(类比空洞骑士暗影披风)。
/// 无敌冲刺强化(解锁后冲刺前段获得无敌窗口)。
/// 仅持有 Dash 时:冲刺无无敌帧。
/// 解锁 InvincibleDash 后:冲刺期间完全无敌(地面 DashState + 空中 AerialDashState
/// </summary>

View File

@@ -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()

View File

@@ -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.17s50Hz松开方向键后空中动量自然衰减。")]
[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 = 1Mothwing 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("蹬墙 / 壁滑")]

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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()

View File

@@ -105,7 +105,8 @@ namespace BaseGames.Player.States
// ── IPoiseSource 实现(架构 06_CombatModule §13─────────────────────
/// <summary>
/// 玩家不拥有霸体,始终返回 <see cref="PoiseLevel.None"/>。
/// 设计决策:类似 Hollow Knight玩家依靠走位和弹反规避伤害而非硬吃
/// 设计决策:玩家不拥有霸体,始终返回 <see cref="PoiseLevel.None"/>
/// 玩家依靠走位和弹反规避伤害而非硬吃,以保持战斗的负担感和张力。
/// 若未来需要临时霸体(如特定技能动作),请通过独立的覆盖标记实现,
/// 而非在此处引入状态,以保持接口语义清晰。
/// </summary>

View File

@@ -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();
}
}
}

View File

@@ -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)

View File

@@ -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>

View File

@@ -45,14 +45,25 @@ Persistent.unity
组件: CameraStateController
│ _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` 代码路径正常执行 |

View File

@@ -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}