922 lines
38 KiB
Markdown
922 lines
38 KiB
Markdown
# 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
|
||
{
|
||
/// <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
|
||
}
|
||
}
|
||
```
|
||
|
||
### 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
|
||
? "<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
|
||
}
|
||
}
|
||
```
|
||
|
||
### 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
|
||
{
|
||
/// <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。
|