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

922 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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
```csharp
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 顶点。
```csharp
// 路径: 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 脚本)
```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" +
"= 参考分辨率 / 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` 完全解耦——一个房间可以有多个入口方向的触发线,
> 同一个触发线也可以指向不同的目标房间(双向过渡)。
```csharp
// 路径: 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 脚本)
```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 ──→ 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可选
```csharp
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
```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;
/// <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
```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
/// <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` |
| `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。