# 17 · 相机模块(Camera Module) > **命名空间** `BaseGames.Camera` > **程序集** `BaseGames.Camera`(`Assets/Scripts/Camera/`) > **依赖** Cinemachine 3 · `BaseGames.Core.Events` > **Design 来源** [02_CameraSystem](../Design/02_CameraSystem.md) --- ## 目录 1. [模块职责](#1-模块职责) 2. [场景结构与 Prefab 层级](#2-场景结构与-prefab-层级) 3. [CameraStateController](#3-camerastatecontroller) 4. [RoomVisibleArea](#4-roomvisiblearea) 5. [CameraTriggerZone](#5-cameratriggerzone) 6. [RoomCamera(可选)](#6-roomcamera可选) 7. [CameraConfigSO](#7-cameraconfigso) 8. [CameraBlendProfileSO](#8-camerablendprofileso) 9. [镜头震动集成](#9-镜头震动集成) 10. [Pixel Perfect 集成](#10-pixel-perfect-集成) 11. [事件频道](#11-事件频道) 12. [房间切换时序](#12-房间切换时序) --- ## 1. 模块职责 - 所有镜头行为由 **Cinemachine 3** 驱动,禁止手动操作 `Camera.transform` - **全局 A/B 双机交替复用**:避免 Confiner 跳变 - **触发区域驱动切换**:`CameraTriggerZone` Collider2D 触发镜头切换 - **房间专用相机可选**:挂载 `RoomCamera` 组件时自动优先 --- ## 2. 场景结构与 Prefab 层级 ### Persistent 场景(`CameraRig` Prefab) ``` [CameraRig] ├── Main Camera │ ├── Camera.cs │ ├── CinemachineBrain ← defaultBlend 通过代码动态赋值 │ ├── PixelPerfectCamera ← Unity 2D Pixel Perfect │ └── CinemachinePixelPerfect ← 消除亚像素抖动 │ ├── VCam_Global_A ← Priority 10(初始活跃) │ ├── CinemachineCamera │ ├── CinemachinePositionComposer ← 跟随玩家 + 偏移/阻尼 │ ├── CinemachineConfiner2D ← BoundingShape2D 动态更换 │ ├── CinemachineImpulseListener │ └── CinemachinePixelPerfect │ ├── VCam_Global_B ← Priority 9(待机) │ └── (结构同 A) │ ├── [SpecialCameras] ← 特殊状态相机(独立 GameObject 组) │ ├── VCam_Boss ← Priority 30(Boss 战激活) │ │ ├── CinemachineCamera(_lookaheadTime=0, _orthographicSize 由 ConfigSO 驱动) │ │ ├── CinemachineConfiner2D │ │ └── CinemachineImpulseListener │ ├── VCam_Death ← Priority 50(死亡时激活,慢速 ZoomIn) │ │ └── CinemachineCamera │ └── VCam_Cutscene ← Priority 40(CutsceneManager 注册后激活) │ └── CinemachineCamera(剧情用,位置由 Timeline/CutsceneManager 控制) │ └── CameraStateController.cs ← 全局相机控制器 ``` ### 房间场景(可选) ``` [VCam_Room_XXX] ← Priority 15(高于全局) ├── CinemachineCamera ├── CinemachineConfiner2D ├── CinemachineImpulseListener └── RoomCamera.cs ``` --- ## 3. CameraStateController ```csharp namespace BaseGames.Camera { /// /// 全局相机状态控制器,挂在 CameraRig GameObject 上。 /// 管理全局 A/B 双机切换和房间专用相机注册。 /// public class CameraStateController : MonoBehaviour { // ── Inspector References ─────────────────────────────── [SerializeField] CinemachineCamera _vcamA; [SerializeField] CinemachineCamera _vcamB; [SerializeField] CinemachineBrain _brain; [SerializeField] CameraConfigSO _defaultConfig; [Header("特殊状态相机")] [SerializeField] CinemachineCamera _vcamBoss; // Priority 30 [SerializeField] CinemachineCamera _vcamDeath; // Priority 50 [SerializeField] CinemachineCamera _vcamCutscene; // Priority 40 [Header("Event Channels")] [SerializeField] BoolEventChannelSO _onBossFightToggled; // true=开始 false=结束 [SerializeField] VoidEventChannelSO _onPlayerDied; [SerializeField] VoidEventChannelSO _onPlayerRespawned; [SerializeField] GameStateEventChannelSO _onGameStateChanged; // ── Runtime State ────────────────────────────────────── CinemachineCamera _activeCam; CinemachineCamera _inactiveCam; RoomCamera _currentRoomCam; // null = 使用全局双机 // ── Singleton ───────────────────────────────────────── public static CameraStateController Instance { get; private set; } void Awake() { Instance = this; _activeCam = _vcamA; _inactiveCam = _vcamB; } void OnEnable() { _onBossFightToggled.OnEventRaised += OnBossFightToggled; _onPlayerDied.OnEventRaised += OnPlayerDied; _onPlayerRespawned.OnEventRaised += OnPlayerRespawned; } void OnDisable() { _onBossFightToggled.OnEventRaised -= OnBossFightToggled; _onPlayerDied.OnEventRaised -= OnPlayerDied; _onPlayerRespawned.OnEventRaised -= OnPlayerRespawned; } private void OnBossFightToggled(bool started) { _vcamBoss.Priority = started ? 30 : 0; // 升/降优先级 if (started) _brain.DefaultBlend = new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 0.8f); else _brain.DefaultBlend = new CinemachineBlendDefinition( _defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration); } private void OnPlayerDied() { _vcamDeath.Priority = 50; // 死亡相机接管,1.0s EaseIn _brain.DefaultBlend = new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 1.0f); } private void OnPlayerRespawned() { _vcamDeath.Priority = 0; _brain.DefaultBlend = new CinemachineBlendDefinition( _defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration); } // ── 公共 API ────────────────────────────────────────── /// /// 切换到新房间(由 RoomTransition 调用)。 /// public void SwitchRoom(RoomCameraData data) { if (_currentRoomCam != null) return; // 房间专用相机接管时,全局切换延迟 // 1. 更新 inactive 机 var confiner = _inactiveCam.GetComponent(); confiner.BoundingShape2D = data.ConfinerCollider; _inactiveCam.GetCinemachineComponent() .TargetOffset = data.CameraOffset; // 2. 应用混合配置 if (data.BlendProfile != null) _brain.DefaultBlend = data.BlendProfile.ToBlendDefinition(); // 3. 升高 inactive 机优先级触发 Blend _inactiveCam.Priority = _activeCam.Priority + 1; // 4. Blend 完成后交换引用(通过 CinemachineBrain 回调) _brain.BlendFinished += OnBlendFinished; } void OnBlendFinished(ICinemachineCamera _) { _brain.BlendFinished -= OnBlendFinished; _activeCam.Priority = 9; (_activeCam, _inactiveCam) = (_inactiveCam, _activeCam); } /// /// 房间专用相机注册(由 RoomCamera.OnEnable 调用)。 /// public void RegisterRoomCamera(RoomCamera rc) { _currentRoomCam = rc; // 房间相机 Priority=15,自动接管全局相机 } /// /// 房间专用相机注销(由 RoomCamera.OnDisable 调用)。 /// public void UnregisterRoomCamera(RoomCamera rc) { if (_currentRoomCam == rc) _currentRoomCam = null; } /// /// 发布镜头震动冲量(由 IFeedbackPlayer 调用)。 /// public void TriggerImpulse(CameraShakePreset preset) { var impulse = _activeCam.GetComponent(); impulse.m_ImpulseDefinition.m_AmplitudeGain = preset.Amplitude; impulse.GenerateImpulse(Vector3.down * preset.Force); } } /// /// 房间切换时传入的相机参数包。 /// public struct RoomCameraData { public Collider2D ConfinerCollider; // 房间 RoomVisibleArea 的 Collider2D public Vector3 CameraOffset; // 镜头偏移(可用于房间内向上/向下偏移) public CameraBlendProfileSO BlendProfile; // null = 使用全局默认 } } ``` --- ## 4. RoomVisibleArea > **所见即所得原则**:设计者在 Scene 视图中绘制的矩形 = 玩家运行时实际能看到的总区域。 > 组件内部自动按 `confinerSize = roomSize − viewportSize` 公式将"房间可视区域"转换为 > Cinemachine Confiner2D 所需的"相机中心约束多边形",无需手动调整 Collider 顶点。 ```csharp // 路径: Assets/Scripts/Camera/RoomVisibleArea.cs namespace BaseGames.Camera { /// /// 定义一个房间的相机可视区域。 /// _roomSize = 玩家可见的总矩形(世界单位)。 /// Gizmo 在 Scene 视图中实时预览: /// 绿色线框 = 房间边界(_roomSize) /// 青色填充 = 相机视口(_viewportSize)= 运行时实际画面 /// 当 roomSize == viewportSize 时为固定相机(锁定不滚动)。 /// [ExecuteAlways] [RequireComponent(typeof(PolygonCollider2D))] public class RoomVisibleArea : MonoBehaviour { [Header("可视区域(所见即所得)")] [Tooltip("玩家能看到的总矩形(世界单位)。Scene 中绿色框即为此范围。")] [SerializeField] private Vector2 _roomSize = new(20f, 11.25f); // 默认一屏 320×180 / 16 PPU [Tooltip("相机视口尺寸(世界单位),需与 PixelPerfectCamera 设置一致。" + "建议由 CameraConfigSO 统一管理后注入,或手动填写。")] [SerializeField] private Vector2 _viewportSize = new(20f, 11.25f); // 320×180 / 16 PPU // ── 派生属性 ─────────────────────────────────────────────────── /// true = 单屏固定相机(房间不超过一个视口)。 public bool IsFixedCamera => _roomSize.x <= _viewportSize.x + 0.01f && _roomSize.y <= _viewportSize.y + 0.01f; /// Confiner2D 使用的 Collider(自动维护)。 public Collider2D Collider { get; private set; } // ── 生命周期 ─────────────────────────────────────────────────── private void Awake() => Collider = GetComponent(); private void OnValidate() => RebuildCollider(); // Inspector 修改时实时重建 // ── 核心:将"房间可视区"换算为"相机中心约束多边形" ────────────── private void RebuildCollider() { var col = GetComponent(); if (col == null) return; // Cinemachine Confiner2D 约束的是相机「中心」,不是相机边缘 // 因此约束区域 = 房间尺寸 - 相机视口尺寸(最小为零,即固定相机) var confiner = Vector2.Max(Vector2.zero, _roomSize - _viewportSize); var h = confiner * 0.5f; col.SetPath(0, new Vector2[] { new(-h.x, -h.y), new( h.x, -h.y), new( h.x, h.y), new(-h.x, h.y), }); } // ── Editor Gizmo(所见即所得可视化)────────────────────────────── #if UNITY_EDITOR private static readonly Color _roomColor = new(0.2f, 1f, 0.2f, 0.9f); // 绿 private static readonly Color _viewFill = new(0f, 0.8f, 1f, 0.08f); // 青色半透明填充 private static readonly Color _viewBorder = new(0f, 0.8f, 1f, 0.85f); // 青色边框 private static readonly Color _fixedColor = new(1f, 0.9f, 0f, 0.9f); // 黄色(固定相机) private void OnDrawGizmos() { var center = (Vector2)transform.position; // 1. 绘制房间边界(绿色) Gizmos.color = _roomColor; DrawWireRect(center, _roomSize); // 2. 绘制相机视口预览(青色)= 运行时玩家实际看到的画面大小 // 视口锚定在房间中心(如需偏移可在 CameraOffset 中调整) Gizmos.color = _viewFill; Gizmos.DrawCube(center, new Vector3(_viewportSize.x, _viewportSize.y, 0f)); Gizmos.color = IsFixedCamera ? _fixedColor : _viewBorder; DrawWireRect(center, _viewportSize); // 3. 固定相机标注:用黄色虚线框 + 标签区分 if (IsFixedCamera) { // 用黄色加粗边框覆盖提示"此房间为固定单屏相机" Gizmos.color = _fixedColor; DrawWireRect(center + Vector2.up * (_roomSize.y * 0.5f + 0.05f), new Vector2(_roomSize.x, 0.1f)); } } private static void DrawWireRect(Vector2 center, Vector2 size) { var h = size * 0.5f; var bl = center + new Vector2(-h.x, -h.y); var br = center + new Vector2( h.x, -h.y); var tr = center + new Vector2( h.x, h.y); var tl = center + new Vector2(-h.x, h.y); Gizmos.DrawLine(bl, br); Gizmos.DrawLine(br, tr); Gizmos.DrawLine(tr, tl); Gizmos.DrawLine(tl, bl); } #endif } } ``` ### RoomVisibleAreaEditor(Editor 脚本) ```csharp // 路径: Assets/Editor/Camera/RoomVisibleAreaEditor.cs // Scene 视图内直接拖动 8 个控制点调整房间大小,无需修改 Inspector 数字 #if UNITY_EDITOR using UnityEditor; namespace BaseGames.Camera.Editor { [CustomEditor(typeof(RoomVisibleArea))] public class RoomVisibleAreaEditor : UnityEditor.Editor { private SerializedProperty _roomSize; private SerializedProperty _viewportSize; private void OnEnable() { _roomSize = serializedObject.FindProperty("_roomSize"); _viewportSize = serializedObject.FindProperty("_viewportSize"); } private void OnSceneGUI() { var area = (RoomVisibleArea)target; var center = (Vector2)area.transform.position; var size = _roomSize.vector2Value; var h = size * 0.5f; EditorGUI.BeginChangeCheck(); Handles.color = new Color(0.2f, 1f, 0.2f, 0.9f); // 8 个控制点:四角 + 四边中点 Vector2 newSize = size; newSize = DragHandle(center + new Vector2( h.x, 0f ), size, center, HandleDir.Right); newSize = DragHandle(center + new Vector2(-h.x, 0f ), newSize, center, HandleDir.Left); newSize = DragHandle(center + new Vector2( 0f, h.y ), newSize, center, HandleDir.Up); newSize = DragHandle(center + new Vector2( 0f, -h.y ), newSize, center, HandleDir.Down); newSize = DragHandle(center + new Vector2( h.x, h.y ), newSize, center, HandleDir.TR); newSize = DragHandle(center + new Vector2(-h.x, h.y ), newSize, center, HandleDir.TL); newSize = DragHandle(center + new Vector2( h.x, -h.y ), newSize, center, HandleDir.BR); newSize = DragHandle(center + new Vector2(-h.x, -h.y ), newSize, center, HandleDir.BL); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(area, "Resize RoomVisibleArea"); _roomSize.vector2Value = Vector2.Max(newSize, _viewportSize.vector2Value); serializedObject.ApplyModifiedProperties(); } } private enum HandleDir { Right, Left, Up, Down, TR, TL, BR, BL } private Vector2 DragHandle(Vector2 worldPos, Vector2 currentSize, Vector2 center, HandleDir dir) { float size = HandleUtility.GetHandleSize(worldPos) * 0.12f; var newPos = (Vector2)Handles.FreeMoveHandle(worldPos, size, Vector3.zero, Handles.RectangleHandleCap); if (newPos == worldPos) return currentSize; var delta = newPos - worldPos; return dir switch { HandleDir.Right => currentSize + new Vector2( delta.x * 2, 0), HandleDir.Left => currentSize + new Vector2(-delta.x * 2, 0), HandleDir.Up => currentSize + new Vector2(0, delta.y * 2), HandleDir.Down => currentSize + new Vector2(0, -delta.y * 2), HandleDir.TR => currentSize + new Vector2( delta.x * 2, delta.y * 2), HandleDir.TL => currentSize + new Vector2(-delta.x * 2, delta.y * 2), HandleDir.BR => currentSize + new Vector2( delta.x * 2, -delta.y * 2), HandleDir.BL => currentSize + new Vector2(-delta.x * 2, -delta.y * 2), _ => currentSize }; } public override void OnInspectorGUI() { serializedObject.Update(); var area = (RoomVisibleArea)target; EditorGUILayout.PropertyField(_roomSize, new GUIContent("房间可视区域", "玩家能看到的总区域(世界单位)。Scene 中绿色框。")); EditorGUILayout.PropertyField(_viewportSize, new GUIContent("相机视口尺寸", "运行时相机实际画面尺寸(世界单位)。青色框。\n" + "= 参考分辨率 / PPU(例:320×180 / 16PPU = 20×11.25)")); EditorGUILayout.Space(4); var style = new GUIStyle(EditorStyles.helpBox) { richText = true }; string msg = area.IsFixedCamera ? "■ 固定相机:房间 = 单屏,相机锁定不滚动。" : $"■ 可滚动:" + $"滚动范围 {_roomSize.vector2Value - _viewportSize.vector2Value:F2} 世界单位。"; EditorGUILayout.LabelField(msg, style); serializedObject.ApplyModifiedProperties(); } } } #endif ``` ### Gizmo 图例 ``` Scene 视图中的显示效果: ┌──────────────────────────────────────┐ ← 绿色框:_roomSize(房间总可视区) │ │ │ ┌────────────────────┐ │ ← 青色框:_viewportSize(相机视口) │ │░░░░░░░░░░░░░░░░░░░░│ │ 青色填充 = 运行时玩家看到的画面 │ │░░░ 玩家实际画面 ░░░│ │ │ │░░░░░░░░░░░░░░░░░░░░│ │ │ └────────────────────┘ │ │ ← 滚动空间 → │ └──────────────────────────────────────┘ 当 roomSize == viewportSize(固定相机): ╔══════════════════════╗ ← 黄色框(双框叠合 = 锁定提示) ║░░░░░░░░░░░░░░░░░░░░░░║ ║░░ 房间 = 相机视口 ░░║ ╚══════════════════════╝ ``` ### 视口尺寸换算 | 参考分辨率 | PPU | viewportSize(宽 × 高,世界单位) | |-----------|-----|----------------------------------| | 320 × 180 | 16 | 20.00 × 11.25 | | 320 × 180 | 32 | 10.00 × 5.625 | | 640 × 360 | 16 | 40.00 × 22.50 | > `viewportSize = (referenceResolution / PPU)` > 建议在 `CameraConfigSO` 中暴露 `ViewportSizeInWorldUnits` 属性,让所有 `RoomVisibleArea` 实例引用同一个数值。 --- ## 5. CameraTriggerZone > **独立可编辑原则**:触发区域的形状与位置由自身 `_center`/`_size` 决定, > 与 `RoomVisibleArea` 完全解耦——一个房间可以有多个入口方向的触发线, > 同一个触发线也可以指向不同的目标房间(双向过渡)。 ```csharp // 路径: Assets/Scripts/Camera/CameraTriggerZone.cs namespace BaseGames.Camera { /// /// 独立的相机切换触发区域,与房间可视区域(RoomVisibleArea)解耦。 /// 形状由 _center/_size 控制,[ExecuteAlways] 实时同步至 BoxCollider2D。 /// Scene 视图中显示黄色矩形(触发范围)+ 青色箭头(指向目标房间)。 /// [ExecuteAlways] [RequireComponent(typeof(BoxCollider2D))] public class CameraTriggerZone : MonoBehaviour { // ── 触发区域形状(WYSIWYG,与房间区域无关)──────────────────────── [Header("触发区域(独立编辑)")] [Tooltip("触发区域中心(相对于 GameObject 原点的局部偏移)")] [SerializeField] private Vector2 _center = Vector2.zero; [Tooltip("触发区域尺寸(世界单位)。典型值:入口竖线 = (0.5, 4)")] [SerializeField] private Vector2 _size = new(0.5f, 4f); // ── 切换目标 ────────────────────────────────────────────────────── [Header("切换目标")] [Tooltip("玩家进入后切换至此房间的可视区域")] [SerializeField] private RoomVisibleArea _targetRoom; [Tooltip("相机偏移(可选,微调构图)")] [SerializeField] private Vector3 _cameraOffset; [Tooltip("混合配置覆盖(null = 使用全局默认)")] [SerializeField] private CameraBlendProfileSO _blendOverride; // ── 触发行为 ────────────────────────────────────────────────────── [Header("触发行为")] [Tooltip("true = 只触发一次(单向过渡);false = 玩家来回均触发(区段分割线)")] [SerializeField] private bool _triggerOnce = false; private bool _triggered; // ── 生命周期 ────────────────────────────────────────────────────── private void OnValidate() => SyncCollider(); private void SyncCollider() { var col = GetComponent(); if (col == null) return; col.isTrigger = true; col.offset = _center; col.size = _size; } private void OnTriggerEnter2D(Collider2D other) { if (!other.CompareTag("Player")) return; if (_triggerOnce && _triggered) return; if (_targetRoom == null) return; _triggered = true; CameraStateController.Instance.SwitchRoom(new RoomCameraData { ConfinerCollider = _targetRoom.Collider, CameraOffset = _cameraOffset, BlendProfile = _blendOverride, }); } // ── Editor Gizmo ────────────────────────────────────────────────── #if UNITY_EDITOR private static readonly Color _triggerColor = new(1f, 0.85f, 0f, 0.9f); // 黄 private static readonly Color _triggerFill = new(1f, 0.85f, 0f, 0.06f); private static readonly Color _arrowColor = new(0f, 0.8f, 1f, 0.9f); // 青 private static readonly Color _triggeredColor = new(0.5f,0.5f, 0.5f,0.5f); // 灰(已触发) private void OnDrawGizmos() { var worldCenter = (Vector2)transform.position + _center; // 触发矩形 bool fired = Application.isPlaying && _triggerOnce && _triggered; Gizmos.color = fired ? _triggeredColor : _triggerFill; Gizmos.DrawCube(worldCenter, new Vector3(_size.x, _size.y, 0f)); Gizmos.color = fired ? _triggeredColor : _triggerColor; DrawWireRect(worldCenter, _size); // 箭头:指向目标房间 if (_targetRoom != null) { Gizmos.color = _arrowColor; var dest = (Vector2)_targetRoom.transform.position; Gizmos.DrawLine(worldCenter, dest); Gizmos.DrawSphere(dest, 0.2f); } } private static void DrawWireRect(Vector2 center, Vector2 size) { var h = size * 0.5f; var bl = center + new Vector2(-h.x, -h.y); var br = center + new Vector2( h.x, -h.y); var tr = center + new Vector2( h.x, h.y); var tl = center + new Vector2(-h.x, h.y); Gizmos.DrawLine(bl, br); Gizmos.DrawLine(br, tr); Gizmos.DrawLine(tr, tl); Gizmos.DrawLine(tl, bl); } #endif } } ``` ### CameraTriggerZoneEditor(Editor 脚本) ```csharp // 路径: Assets/Editor/Camera/CameraTriggerZoneEditor.cs // Scene 内拖动 8 个控制点调整触发区域,支持整体平移(中心点) #if UNITY_EDITOR using UnityEditor; namespace BaseGames.Camera.Editor { [CustomEditor(typeof(CameraTriggerZone))] public class CameraTriggerZoneEditor : UnityEditor.Editor { private SerializedProperty _center; private SerializedProperty _size; private SerializedProperty _targetRoom; private void OnEnable() { _center = serializedObject.FindProperty("_center"); _size = serializedObject.FindProperty("_size"); _targetRoom = serializedObject.FindProperty("_targetRoom"); } private void OnSceneGUI() { var zone = (CameraTriggerZone)target; var worldPos = (Vector2)zone.transform.position; var center = worldPos + _center.vector2Value; var size = _size.vector2Value; var h = size * 0.5f; EditorGUI.BeginChangeCheck(); Handles.color = new Color(1f, 0.85f, 0f, 0.9f); // ① 中心点:整体平移 float dotSize = HandleUtility.GetHandleSize(center) * 0.12f; var newCenter = (Vector2)Handles.FreeMoveHandle( center, dotSize, Vector3.zero, Handles.CircleHandleCap); // ② 8 个边缘控制点:缩放 Vector2 newSize = size; newSize = DragEdge(center + new Vector2( h.x, 0 ), newSize, center, 1, 0); newSize = DragEdge(center + new Vector2(-h.x, 0 ), newSize, center, -1, 0); newSize = DragEdge(center + new Vector2( 0, h.y ), newSize, center, 0, 1); newSize = DragEdge(center + new Vector2( 0, -h.y ), newSize, center, 0, -1); newSize = DragCorner(center + new Vector2( h.x, h.y), newSize, center, 1, 1); newSize = DragCorner(center + new Vector2(-h.x, h.y), newSize, center, -1, 1); newSize = DragCorner(center + new Vector2( h.x, -h.y), newSize, center, 1, -1); newSize = DragCorner(center + new Vector2(-h.x, -h.y), newSize, center, -1, -1); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(zone, "Edit CameraTriggerZone"); _center.vector2Value = newCenter - worldPos; _size.vector2Value = Vector2.Max(newSize, new Vector2(0.1f, 0.1f)); serializedObject.ApplyModifiedProperties(); } } private Vector2 DragEdge(Vector2 wp, Vector2 size, Vector2 center, int signX, int signY) { float s = HandleUtility.GetHandleSize(wp) * 0.1f; var newWp = (Vector2)Handles.FreeMoveHandle(wp, s, Vector3.zero, Handles.RectangleHandleCap); if (newWp == wp) return size; var delta = newWp - wp; return size + new Vector2(signX * delta.x * 2, signY * delta.y * 2); } private Vector2 DragCorner(Vector2 wp, Vector2 size, Vector2 center, int signX, int signY) { float s = HandleUtility.GetHandleSize(wp) * 0.1f; var newWp = (Vector2)Handles.FreeMoveHandle(wp, s, Vector3.zero, Handles.RectangleHandleCap); if (newWp == wp) return size; var delta = newWp - wp; return size + new Vector2(signX * delta.x * 2, signY * delta.y * 2); } public override void OnInspectorGUI() { serializedObject.Update(); DrawDefaultInspector(); var zone = (CameraTriggerZone)target; if (_targetRoom.objectReferenceValue == null) { EditorGUILayout.HelpBox( "未指定目标房间(_targetRoom),触发后不会切换相机。", MessageType.Warning); } serializedObject.ApplyModifiedProperties(); } } } #endif ``` ### 独立编辑 vs 绑定房间的区别 ``` 旧设计(绑定房间): RoomA ──→ CameraTriggerZone(Collider 尺寸手动管理,无 WYSIWYG) └── 依赖 BoxCollider2D 自带编辑,无法直观预览触发与房间的关系 新设计(独立编辑): 场景层级示例: [Room_A] ├── RoomVisibleArea ← 绿色框:房间可视区域 │ [TriggerZone_A→B] ← 独立 GameObject,不挂在任何房间下 ├── CameraTriggerZone ← 黄色框:触发区域(可自由移动/缩放) │ ├── _targetRoom → RoomB.RoomVisibleArea │ └── _size = (0.5, 4) ← 入口竖线,覆盖通道高度 [TriggerZone_B→A] ← 反向触发(指向 RoomA,_triggerOnce=false) └── CameraTriggerZone 同一房间多个入口示例: TriggerZone_A_Left → RoomA (从左侧进入) TriggerZone_A_Right → RoomA (从右侧进入) TriggerZone_A_Down → RoomA (从上方坠入) ``` ### 常见尺寸约定 | 触发线类型 | `_size` 建议值 | 备注 | |-----------|:-------------:|------| | 竖向门洞(窄走廊)| `(0.5, 3.0)` | 宽 = 0.5 ≤ 玩家宽,高覆盖通道 | | 横向过渡(上下层)| `(8.0, 0.5)` | 宽覆盖平台,高 = 0.5 | | 大区域入口 | `(2.0, 4.0)` | 宽松触发,允许斜角进入 | --- ## 6. RoomCamera(可选) ```csharp namespace BaseGames.Camera { /// /// 挂在房间场景中的 VCam_Room_XXX 上。 /// 存在时 Priority=15,自动优先于全局双机。 /// public class RoomCamera : MonoBehaviour { [SerializeField] CinemachineCamera _vcam; [SerializeField] RoomVisibleArea _visibleArea; [SerializeField] CameraBlendProfileSO _enterBlend; // 可留空 void OnEnable() { _vcam.Priority = 15; CameraStateController.Instance.RegisterRoomCamera(this); } void OnDisable() { _vcam.Priority = 0; CameraStateController.Instance.UnregisterRoomCamera(this); } public CinemachineCamera Vcam => _vcam; public RoomVisibleArea VisibleArea => _visibleArea; public CameraBlendProfileSO EnterBlend => _enterBlend; } } ``` --- ## 7. CameraConfigSO ```csharp [CreateAssetMenu(menuName = "Camera/CameraConfig")] public class CameraConfigSO : ScriptableObject { [Header("探索跟随")] public Vector3 DefaultFollowOffset = new(0f, 1f, -10f); // 玩家偏移 [Range(0f, 5f)] public float HorizontalDamping = 0.5f; [Range(0f, 5f)] public float VerticalDamping = 0.3f; public float ExploreAheadDistance = 2.5f; // 向移动方向前瞻距离(世界单位) public float ExploreLookUpOffset = 1.5f; // 玩家向上按键时额外偏移(如查看头顶) public float ExploreLookDownOffset = 0.5f; // 玩家向下按键时额外偏移 [Header("战斗模式")] public float CombatZoomOut = 1.2f; // 战斗时镜头拉远倍数 public float CombatForwardOffset = 1.0f; // 朝敌人方向的额外偏移 [Header("Boss 战")] public float BossZoomLevel = 6.0f; // Boss 相机正交尺寸(世界单位半高) [Header("默认混合")] public float DefaultBlendDuration = 0.5f; public CinemachineBlendDefinition.Style DefaultBlendStyle = CinemachineBlendDefinition.Style.EaseInOut; [Header("Pixel Perfect")] public int ReferenceResolutionX = 480; // 参考分辨率宽 public int ReferenceResolutionY = 270; // 参考分辨率高 public int PixelsPerUnit = 32; // PPU,与 SpriteImportSettings 一致 public bool CropFrameX = false; public bool CropFrameY = false; public bool UpscaleRenderTexture = true; /// /// 正交尺寸公式:OrthographicSize = RefResHeight / (2 × PPU) /// 例:270 / (2 × 32) = 4.21875 /// public float OrthographicSize => (float)ReferenceResolutionY / (2f * PixelsPerUnit); /// /// 相机视口的世界单位尺寸(供 RoomVisibleArea 引用,保证所有房间使用一致的视口基准)。 /// = ReferenceResolution / PixelsPerUnit /// 例:480×270 / 32PPU = 15×8.4375 /// public Vector2 ViewportSizeInWorldUnits => new Vector2((float)ReferenceResolutionX / PixelsPerUnit, (float)ReferenceResolutionY / PixelsPerUnit); [Header("大房间滚动限制")] public bool EnableScrollClamp = false; public float MaxScrollSpeed = 8f; // 像素/秒 } ``` **资产路径**:`Assets/ScriptableObjects/Camera/Camera_Config.asset` --- ## 8. CameraBlendProfileSO ```csharp [CreateAssetMenu(menuName = "Camera/BlendProfile")] public class CameraBlendProfileSO : ScriptableObject { public float Duration = 0.5f; public CinemachineBlendDefinition.Style Style = CinemachineBlendDefinition.Style.EaseInOut; public CinemachineBlendDefinition ToBlendDefinition() => new CinemachineBlendDefinition(Style, Duration); } ``` **内置预设(`Assets/ScriptableObjects/Camera/Blends/`)**: | 资产名 | 用途 | 混合风格 | Duration | |--------|------|---------|:---:| | `Blend_Default.asset` | 正常房间切换 | EaseInOut | 0.5s | | `Blend_Instant.asset` | 传送点、剧情跳切 | Cut | 0s | | `Blend_Slow.asset` | Boss 房间进入 | EaseIn | 1.0s | | `Blend_BossExit.asset` | Boss 击败后退出 | EaseOut | 0.8s | | `Blend_Boss.asset` | Boss 战切换(OnBossFightToggled) | EaseIn | 0.8s | | `Blend_Death.asset` | 玩家死亡 | EaseIn | 1.0s | | `Blend_Cutscene.asset` | 进入剧情镜头(Cut 接管) | Cut | 0s | | `Blend_CutsceneExit.asset` | 剧情结束恢复探索 | EaseOut | 0.3s | --- ## 9. 镜头震动集成 `CinemachineImpulseSource` 挂在 `VCam_Global_A/B` 上,通过 `CameraStateController.TriggerImpulse()` 调用: ```csharp /// /// 镜头震动预设(数值来自 FeedbackConfigSO 中配置)。 /// public struct CameraShakePreset { public float Amplitude; // 震动幅度 public float Force; // 冲量强度 public float Duration; // Impulse Duration(在 CinemachineImpulseDefinition 设置) } ``` **与 Feel 集成**:`MMF_CinemachineImpulse` feedback 直接操作 `CinemachineImpulseSource`,不经过 `CameraStateController`(二者不冲突,Feel 仍走标准 Cinemachine 管线)。 --- ## 10. Pixel Perfect 集成 - `PixelPerfectCamera` 挂在 Main Camera,设置 `ReferenceResolutionX/Y = 320×180` - `CinemachinePixelPerfect` 扩展组件挂在每个 `CinemachineCamera` 上,使 Cinemachine 跟随位置对齐到像素网格 - 禁用 Anti-aliasing(与像素风格不兼容) --- ## 11. 接口调用(直接调用,不使用事件频道) Camera 模块采用 **直接调用** 而非事件频道: | 调用者 | 调用方法 | 被调用方 | |--------|---------|---------| | `CameraTriggerZone` | `CameraStateController.Instance.SwitchRoom(RoomCameraData)` | `CameraStateController` | | `IFeedbackPlayer`(`PlayerFeedback`、`EnemyFeedback`) | `CameraStateController.Instance.TriggerImpulse(CameraShakePreset)` | `CameraStateController` | | `RoomCamera.OnEnable` | `CameraStateController.Instance.RegisterRoomCamera(rc)` | `CameraStateController` | | `RoomCamera.OnDisable` | `CameraStateController.Instance.UnregisterRoomCamera(rc)` | `CameraStateController` | > **设计决策**:相机切换对延迟敏感(GC 压力影响帧率),使用 Singleton 直接调用而非 EventChannelSO。 --- ## 12. 房间切换时序 ``` 玩家穿过 CameraTriggerZone │ ▼ CameraStateController.SwitchRoom(RoomCameraData) │ ├─ 1. inactive 机更新 Confiner + Offset ├─ 2. brain.DefaultBlend ← BlendProfile ├─ 3. inactive.Priority = active.Priority + 1 │ → CinemachineBrain 自动 Blend └─ 4. BlendFinished → 交换 active/inactive 引用 active.Priority = 9 ``` **大房间(超出单屏)**:在同一房间内放置多个 `CameraTriggerZone` 指向相同 `RoomVisibleArea`,并调整 `CameraOffset` 实现子区域镜头引导,不切换 Confiner。