Files
zeling_v2/Docs/Architecture/17_CameraModule.md
2026-05-08 11:04:00 +08:00

38 KiB
Raw Blame History

17 · 相机模块Camera Module

命名空间 BaseGames.Camera
程序集 BaseGames.CameraAssets/Scripts/Camera/
依赖 Cinemachine 3 · BaseGames.Core.Events
Design 来源 02_CameraSystem


目录

  1. 模块职责
  2. 场景结构与 Prefab 层级
  3. CameraStateController
  4. RoomVisibleArea
  5. CameraTriggerZone
  6. RoomCamera可选
  7. CameraConfigSO
  8. CameraBlendProfileSO
  9. 镜头震动集成
  10. Pixel Perfect 集成
  11. 事件频道
  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 30Boss 战激活)
│   │   ├── CinemachineCamera_lookaheadTime=0, _orthographicSize 由 ConfigSO 驱动)
│   │   ├── CinemachineConfiner2D
│   │   └── CinemachineImpulseListener
│   ├── VCam_Death                  ← Priority 50死亡时激活慢速 ZoomIn
│   │   └── CinemachineCamera
│   └── VCam_Cutscene               ← Priority 40CutsceneManager 注册后激活)
│       └── CinemachineCamera剧情用位置由 Timeline/CutsceneManager 控制)
│
└── CameraStateController.cs        ← 全局相机控制器

房间场景(可选)

[VCam_Room_XXX]                     ← Priority 15高于全局
├── CinemachineCamera
├── CinemachineConfiner2D
├── CinemachineImpulseListener
└── RoomCamera.cs

3. CameraStateController

namespace BaseGames.Camera
{
    /// <summary>
    /// 全局相机状态控制器,挂在 CameraRig GameObject 上。
    /// 管理全局 A/B 双机切换和房间专用相机注册。
    /// </summary>
    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 ──────────────────────────────────────────

        /// <summary>
        /// 切换到新房间(由 RoomTransition 调用)。
        /// </summary>
        public void SwitchRoom(RoomCameraData data)
        {
            if (_currentRoomCam != null) return; // 房间专用相机接管时,全局切换延迟

            // 1. 更新 inactive 机
            var confiner = _inactiveCam.GetComponent<CinemachineConfiner2D>();
            confiner.BoundingShape2D = data.ConfinerCollider;
            _inactiveCam.GetCinemachineComponent<CinemachinePositionComposer>()
                        .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);
        }

        /// <summary>
        /// 房间专用相机注册(由 RoomCamera.OnEnable 调用)。
        /// </summary>
        public void RegisterRoomCamera(RoomCamera rc)
        {
            _currentRoomCam = rc;
            // 房间相机 Priority=15自动接管全局相机
        }

        /// <summary>
        /// 房间专用相机注销(由 RoomCamera.OnDisable 调用)。
        /// </summary>
        public void UnregisterRoomCamera(RoomCamera rc)
        {
            if (_currentRoomCam == rc)
                _currentRoomCam = null;
        }

        /// <summary>
        /// 发布镜头震动冲量(由 IFeedbackPlayer 调用)。
        /// </summary>
        public void TriggerImpulse(CameraShakePreset preset)
        {
            var impulse = _activeCam.GetComponent<CinemachineImpulseSource>();
            impulse.m_ImpulseDefinition.m_AmplitudeGain = preset.Amplitude;
            impulse.GenerateImpulse(Vector3.down * preset.Force);
        }
    }

    /// <summary>
    /// 房间切换时传入的相机参数包。
    /// </summary>
    public struct RoomCameraData
    {
        public Collider2D           ConfinerCollider;  // 房间 RoomVisibleArea 的 Collider2D
        public Vector3              CameraOffset;      // 镜头偏移(可用于房间内向上/向下偏移)
        public CameraBlendProfileSO BlendProfile;      // null = 使用全局默认
    }
}

4. RoomVisibleArea

所见即所得原则:设计者在 Scene 视图中绘制的矩形 = 玩家运行时实际能看到的总区域。
组件内部自动按 confinerSize = roomSize viewportSize 公式将"房间可视区域"转换为
Cinemachine Confiner2D 所需的"相机中心约束多边形",无需手动调整 Collider 顶点。

// 路径: Assets/Scripts/Camera/RoomVisibleArea.cs
namespace BaseGames.Camera
{
    /// <summary>
    /// 定义一个房间的相机可视区域。
    /// _roomSize = 玩家可见的总矩形(世界单位)。
    /// Gizmo 在 Scene 视图中实时预览:
    ///   绿色线框 = 房间边界_roomSize
    ///   青色填充 = 相机视口_viewportSize= 运行时实际画面
    /// 当 roomSize == viewportSize 时为固定相机(锁定不滚动)。
    /// </summary>
    [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

        // ── 派生属性 ───────────────────────────────────────────────────
        /// <summary>true = 单屏固定相机(房间不超过一个视口)。</summary>
        public bool IsFixedCamera
            => _roomSize.x <= _viewportSize.x + 0.01f
            && _roomSize.y <= _viewportSize.y + 0.01f;

        /// <summary>Confiner2D 使用的 Collider自动维护。</summary>
        public Collider2D Collider { get; private set; }

        // ── 生命周期 ───────────────────────────────────────────────────
        private void Awake()    => Collider = GetComponent<PolygonCollider2D>();
        private void OnValidate() => RebuildCollider();   // Inspector 修改时实时重建

        // ── 核心:将"房间可视区"换算为"相机中心约束多边形" ──────────────
        private void RebuildCollider()
        {
            var col = GetComponent<PolygonCollider2D>();
            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
    }
}

RoomVisibleAreaEditorEditor 脚本)

// 路径: 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" +
                                               "= 参考分辨率 / PPU320×180 / 16PPU = 20×11.25"));

            EditorGUILayout.Space(4);
            var style = new GUIStyle(EditorStyles.helpBox) { richText = true };
            string msg = area.IsFixedCamera
                ? "<color=#FFD700>■ 固定相机</color>:房间 = 单屏,相机锁定不滚动。"
                : $"<color=#00CCFF>■ 可滚动</color>" +
                  $"滚动范围 {_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 完全解耦——一个房间可以有多个入口方向的触发线,
同一个触发线也可以指向不同的目标房间(双向过渡)。

// 路径: Assets/Scripts/Camera/CameraTriggerZone.cs
namespace BaseGames.Camera
{
    /// <summary>
    /// 独立的相机切换触发区域与房间可视区域RoomVisibleArea解耦。
    /// 形状由 _center/_size 控制,[ExecuteAlways] 实时同步至 BoxCollider2D。
    /// Scene 视图中显示黄色矩形(触发范围)+ 青色箭头(指向目标房间)。
    /// </summary>
    [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<BoxCollider2D>();
            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
    }
}

CameraTriggerZoneEditorEditor 脚本)

// 路径: 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 ──→ CameraTriggerZoneCollider 尺寸手动管理,无 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可选

namespace BaseGames.Camera
{
    /// <summary>
    /// 挂在房间场景中的 VCam_Room_XXX 上。
    /// 存在时 Priority=15自动优先于全局双机。
    /// </summary>
    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

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

    /// <summary>
    /// 正交尺寸公式OrthographicSize = RefResHeight / (2 × PPU)
    /// 例270 / (2 × 32) = 4.21875
    /// </summary>
    public float OrthographicSize
        => (float)ReferenceResolutionY / (2f * PixelsPerUnit);

    /// <summary>
    /// 相机视口的世界单位尺寸(供 RoomVisibleArea 引用,保证所有房间使用一致的视口基准)。
    /// = ReferenceResolution / PixelsPerUnit
    /// 例480×270 / 32PPU = 15×8.4375
    /// </summary>
    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

[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() 调用:

/// <summary>
/// 镜头震动预设(数值来自 FeedbackConfigSO 中配置)。
/// </summary>
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
IFeedbackPlayerPlayerFeedbackEnemyFeedback 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。