38 KiB
17 · 相机模块(Camera Module)
命名空间
BaseGames.Camera
程序集BaseGames.Camera(Assets/Scripts/Camera/)
依赖 Cinemachine 3 ·BaseGames.Core.Events
Design 来源 02_CameraSystem
目录
- 模块职责
- 场景结构与 Prefab 层级
- CameraStateController
- RoomVisibleArea
- CameraTriggerZone
- RoomCamera(可选)
- CameraConfigSO
- CameraBlendProfileSO
- 镜头震动集成
- Pixel Perfect 集成
- 事件频道
- 房间切换时序
1. 模块职责
- 所有镜头行为由 Cinemachine 3 驱动,禁止手动操作
Camera.transform - 全局 A/B 双机交替复用:避免 Confiner 跳变
- 触发区域驱动切换:
CameraTriggerZoneCollider2D 触发镜头切换 - 房间专用相机可选:挂载
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
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
}
}
RoomVisibleAreaEditor(Editor 脚本)
// 路径: 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
? "<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
}
}
CameraTriggerZoneEditor(Editor 脚本)
// 路径: 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(可选)
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×180CinemachinePixelPerfect扩展组件挂在每个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。