Files
zeling_v2/Docs/Design/18_CutsceneSystem.md
2026-05-08 11:04:00 +08:00

442 lines
15 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.
# 18 · 过场动画系统Cutscene
> **命名空间** `BaseGames.Cutscene`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Core.Events` · `BaseGames.UI`(黑屏/字幕)· Unity Timeline · Cinemachine
---
## 目录
1. [系统总览](#1-系统总览)
2. [CutsceneManager](#2-cutscenemanager)
3. [CutsceneTrigger](#3-cutscenetrigger)
4. [过场动画 Timeline 规范](#4-过场动画-timeline-规范)
5. [Timeline Track 扩展](#5-timeline-track-扩展)
6. [跳过机制](#6-跳过机制)
7. [过场与对话系统集成](#7-过场与对话系统集成)
8. [输入锁定与 GameState](#8-输入锁定与-gamestate)
9. [保存/重播机制](#9-保存重播机制)
10. [事件频道](#10-事件频道)
11. [编辑器友好设计](#11-编辑器友好设计)
---
## 1. 系统总览
过场动画系统基于 **Unity Timeline**,提供序列化、可预览的过场演出。
```
过场系统职责:
├─ CutsceneManager → 全局过场状态机(播放/暂停/跳过)
├─ CutsceneTrigger → 场景中的触发器(位置/事件触发过场)
├─ Timeline 规范 → 统一的 Track 分层规范
├─ Timeline Tracks → 自定义 Signal Track发布 SO 事件频道)
├─ 跳过机制 → 长按跳过,带过场进度条
└─ GameState 集成 → 过场期间切换到 Cutscene 状态,锁定输入
```
**零耦合原则**:过场通过自定义 `SignalEmitterClip` 发布 SO 事件频道,过场动画与游戏逻辑互不直接引用。
---
## 2. CutsceneManager
`CutsceneManager` 常驻 Persistent 场景,管理过场播放状态。
```csharp
namespace BaseGames.Cutscene
{
public class CutsceneManager : MonoBehaviour
{
[Header("组件引用")]
[SerializeField] PlayableDirector _director; // 运行时绑定活跃 Timeline
[Header("事件频道")]
[SerializeField] CutsceneEventChannelSO _onCutsceneStarted; // 发布(传入 CutsceneSO
[SerializeField] VoidEventChannelSO _onCutsceneEnded; // 发布
[SerializeField] VoidEventChannelSO _onCutsceneSkipped; // 发布
[SerializeField] GameStateEventChannelSO _onGameStateChanged; // 发布
// 运行时状态
CutsceneSO _currentCutscene;
bool _isPlaying;
bool _skipHeld;
float _skipHoldTimer;
const float SKIP_HOLD_DURATION = 1.5f; // 长按 1.5s 跳过
public bool IsPlaying => _isPlaying;
// ─────────────── 公开 API ───────────────
public void Play(CutsceneSO cutscene)
{
if (_isPlaying) return;
_currentCutscene = cutscene;
_isPlaying = true;
// 1. 切换 GameState → Cutscene锁定玩家输入
_onGameStateChanged.Raise(GameState.Cutscene);
// 2. 绑定并播放 Timeline
_director.playableAsset = cutscene.timelineAsset;
BindObjects(cutscene);
_director.Play();
// 3. 发布事件UI/Audio 订阅)
_onCutsceneStarted.Raise(cutscene);
}
public void Skip()
{
if (!_isPlaying || !_currentCutscene.isSkippable) return;
_director.Stop();
FinishCutscene(skipped: true);
}
void Update()
{
if (!_isPlaying) return;
// 长按跳过检测
if (_skipHeld)
{
_skipHoldTimer += Time.unscaledDeltaTime;
_onSkipProgress?.Raise(_skipHoldTimer / SKIP_HOLD_DURATION); // 进度条更新
if (_skipHoldTimer >= SKIP_HOLD_DURATION)
Skip();
}
// Timeline 自然结束
if (_director.state == PlayState.Paused && _director.time >= _director.duration)
FinishCutscene(skipped: false);
}
public void OnSkipButtonDown() { _skipHeld = true; _skipHoldTimer = 0f; }
public void OnSkipButtonUp() { _skipHeld = false; _onSkipProgress?.Raise(0f); }
void FinishCutscene(bool skipped)
{
_isPlaying = false;
if (skipped)
_onCutsceneSkipped.Raise();
else
_onCutsceneEnded.Raise();
// 恢复 GameState
_onGameStateChanged.Raise(GameState.Gameplay);
// 标记为已播放(保存进度)
SaveManager.Instance.MarkCutscenePlayed(_currentCutscene.cutsceneId);
}
/// <summary>
/// 将 CutsceneSO 中声明的对象绑定到 Timeline Bindings如摄像机、NPC
/// </summary>
void BindObjects(CutsceneSO cutscene)
{
foreach (var binding in cutscene.bindings)
{
_director.SetGenericBinding(binding.track, binding.targetObject);
}
}
}
}
```
### CutsceneSO — 过场元数据
```csharp
[CreateAssetMenu(menuName = "Cutscene/CutsceneData")]
public class CutsceneSO : ScriptableObject
{
[Header("基础信息")]
public string cutsceneId; // 全局唯一 ID存入 SaveData 已播放列表
public string displayName; // 编辑器显示名称
public TimelineAsset timelineAsset; // 对应的 Timeline 资产
public bool isSkippable; // 是否可跳过(序章/结局通常不可跳过)
public bool playOnlyOnce; // 已看过后不再自动触发(可在菜单重播)
[Header("绑定声明")]
public CutsceneBinding[] bindings; // Track → 场景中具体对象的绑定表
[Header("缩略图")]
public Sprite thumbnail; // 用于"过场回放"菜单显示
}
[Serializable]
public struct CutsceneBinding
{
public PlayableAsset track; // Timeline Track 的 PlayableAsset 引用
public UnityEngine.Object targetObject; // 绑定目标CinemachineVirtualCamera / Transform 等)
}
```
---
## 3. CutsceneTrigger
`CutsceneTrigger` 放置在场景中,当满足触发条件时启动过场。
```csharp
public class CutsceneTrigger : MonoBehaviour
{
[Header("过场配置")]
[SerializeField] CutsceneSO _cutscene;
[SerializeField] TriggerCondition _condition; // OnEnter / OnEvent / OnInteract
[Header("事件触发OnEvent 模式)")]
[SerializeField] VoidEventChannelSO _listenChannel; // 监听此频道触发过场
[Header("过场管理器")]
[SerializeField] CutsceneManager _cutsceneManager;
bool _hasTriggered;
void OnEnable()
{
if (_condition == TriggerCondition.OnEvent && _listenChannel != null)
_listenChannel.OnEventRaised += TryTrigger;
}
void OnDisable()
{
if (_listenChannel != null)
_listenChannel.OnEventRaised -= TryTrigger;
}
void OnTriggerEnter2D(Collider2D other)
{
if (_condition != TriggerCondition.OnEnter) return;
if (!other.CompareTag("Player")) return;
TryTrigger();
}
void TryTrigger()
{
if (_hasTriggered) return;
if (_cutscene.playOnlyOnce && SaveManager.Instance.IsCutscenePlayed(_cutscene.cutsceneId)) return;
_hasTriggered = _cutscene.playOnlyOnce;
_cutsceneManager.Play(_cutscene);
}
}
public enum TriggerCondition { OnEnter, OnEvent, OnInteract }
```
---
## 4. 过场动画 Timeline 规范
每个过场 Timeline 遵循以下标准化 Track 分层:
```
CutsceneTimeline.playable
├── [00] Activation Track — Player ← 过场期间隐藏/显示玩家角色
├── [01] Activation Track — HUD ← 隐藏 HUD可选由 CutsceneSO.hideHUD 控制)
├── [02] Cinemachine Track — Cam_Cutscene ← 专用过场虚拟相机(不影响游戏相机状态机)
│ └── CinemachineShot Clips各镜头切换
├── [03] Animation Track — NPC_A ← NPC 角色动画Animator
├── [04] Animation Track — NPC_B
├── [05] Audio Track — BGM ← 背景音乐(可淡入淡出)
├── [06] Audio Track — SFX ← 音效时间点
├── [07] Signal Track — GameEvents ← 在关键帧发布 SO 事件频道(见 §5
└── [08] Control Track — SubTimelines可选← 嵌套子 Timeline
```
### 专用过场相机
每个过场使用独立的 `CinemachineVirtualCamera`(优先级在过场期间最高):
```
[VC_Cutscene_01]
├── CinemachineVirtualCamera
│ ├── Priority: 100过场期间高于游戏相机
│ └── FollowTarget: 空(固定视角)或临时目标
└── Timeline 结束时CutsceneManager 恢复游戏相机优先级)
```
---
## 5. Timeline Track 扩展
### SignalEmitterClip — SO 事件频道发射器
自定义 Signal Track Clip允许在 Timeline 的任意帧发布 SO 事件频道:
```csharp
[Serializable]
public class SignalEmitterClip : PlayableAsset, ITimelineClipAsset
{
public VoidEventChannelSO voidChannel;
public StringEventChannelSO stringChannel;
public string stringPayload;
public ClipCaps clipCaps => ClipCaps.None;
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
=> ScriptPlayable<SignalEmitterBehaviour>.Create(graph,
new SignalEmitterBehaviour { Clip = this });
}
public class SignalEmitterBehaviour : PlayableBehaviour
{
public SignalEmitterClip Clip;
bool _fired;
public override void OnBehaviourPlay(Playable playable, FrameData info)
{
if (_fired) return;
_fired = true;
Clip.voidChannel?.Raise();
if (!string.IsNullOrEmpty(Clip.stringPayload))
Clip.stringChannel?.Raise(Clip.stringPayload);
}
public override void OnBehaviourPause(Playable playable, FrameData info)
=> _fired = false; // 重置,支持 Timeline 预览反复播放
}
```
**使用示例**
-`t=5.0s` 发布 `OnBossFightStarted`:关闭 Boss 房间门、触发 Boss BGM
-`t=12.0s` 发布 `OnDialogueStart`:启动对话系统显示字幕
### SubtitleTrack — 字幕轨道
```csharp
[TrackClipType(typeof(SubtitleClip))]
[TrackBindingType(typeof(SubtitleUI))]
public class SubtitleTrack : TrackAsset { }
[Serializable]
public class SubtitleClip : PlayableAsset
{
[TextArea(2, 4)]
public string text;
public Color textColor = Color.white;
}
```
---
## 6. 跳过机制
### 跳过 UI
```
Canvas_Cutscene (Sorting Order: 50过场期间显示)
└── SkipPanel (右下角)
├── SkipLabel ("长按 X / Space 跳过")
└── SkipFillBar (ImagefillAmount 0→1随长按进度填充)
```
### 跳过流程
```
玩家长按 Skip 键
→ InputReader 持续发布 OnSkipHeld 频道
→ CutsceneManager.OnSkipButtonDown()
→ 每帧累计 _skipHoldTimer
→ 发布 OnSkipProgress(0~1) → SkipFillBar.fillAmount 更新
→ _skipHoldTimer >= 1.5s → CutsceneManager.Skip()
→ _director.Stop()
→ FinishCutscene(skipped: true)
→ 恢复 GameState.Gameplay
松开 Skip 键(未达到 1.5s
→ _skipHoldTimer 归零SkipFillBar 清空
```
---
## 7. 过场与对话系统集成
过场中的对话通过 **Signal Track → DialogueManager** 触发,与游戏内对话复用同一套系统:
```
Signal Track t=3.0s: Raise OnDialogueStart("DG_Cutscene_Intro")
DialogueManager.StartDialogue("DG_Cutscene_Intro")
├─ 暂停 Timeline_director.Pause()
├─ 显示对话气泡 / 全屏对话 UI取决于 DialogueGraphSO.displayMode
└─ 对话结束后 → 发布 OnDialogueEnded → CutsceneManager 恢复播放 (_director.Resume())
```
---
## 8. 输入锁定与 GameState
| 阶段 | GameState | 输入 ActionMap | 说明 |
|------|-----------|--------------|------|
| 正常游戏 | `Gameplay` | `Player` | 全功能输入 |
| 过场播放中 | `Cutscene` | `Cutscene` | 仅监听 Skip 键(长按)|
| 对话中(过场内)| `Dialogue` | `Dialogue` | 仅监听 Confirm/Skip |
| 过场结束后 | `Gameplay` | `Player` | 自动恢复 |
`InputReader``Cutscene` ActionMap 下只暴露 `OnSkipPressed` / `OnSkipReleased` 事件,玩家无法在过场中移动或攻击。
---
## 9. 保存/重播机制
### 过场播放记录
```json
"cutscenes": {
"playedIds": ["CS_Intro", "CS_Boss01_Entrance", "CS_MidgameReveal"]
}
```
- 每次 `FinishCutscene` 时,`SaveManager.MarkCutscenePlayed(id)` 追加到 `playedIds[]`
- `CutsceneSO.playOnlyOnce = true` 的过场,触发前检查是否已在列表中
### 过场回放剧情回顾菜单P2
- 主菜单 → 剧情回顾 → 列出所有 `CutsceneSO.playOnlyOnce = true` 且已播放的过场
- 点击缩略图:不加载游戏场景,直接在独立场景播放 Timeline无跳过限制
---
## 10. 事件频道
| 频道资产 | 类型 | 发布方 | 主要订阅方 |
|---------|------|--------|----------|
| `OnCutsceneStarted.asset` | `CutsceneEventChannelSO` | `CutsceneManager` | `HUD`(隐藏)、`AudioManager`(过场 BGM`InputReader`(切 ActionMap|
| `OnCutsceneEnded.asset` | `VoidEventChannelSO` | `CutsceneManager` | `HUD`(显示)、`AudioManager`(恢复 BGM`InputReader`(恢复 ActionMap|
| `OnCutsceneSkipped.asset` | `VoidEventChannelSO` | `CutsceneManager` | 同上(快速执行收尾逻辑)|
| `OnSkipProgress.asset` | `FloatEventChannelSO`0~1| `CutsceneManager` | `SkipFillBar`UI 填充)|
| `OnSubtitleShow.asset` | `SubtitleEventChannelSO` | `SubtitleTrack` | `SubtitleUI`(显示字幕)|
| `OnSubtitleHide.asset` | `VoidEventChannelSO` | `SubtitleTrack` | `SubtitleUI`(隐藏字幕)|
---
## 11. 编辑器友好设计
### CutsceneManager InspectorPlay Mode
```
┌─ CutsceneManager ──────────────────────────────────────────┐
│ State: Playing Cutscene: CS_Boss01_Entrance │
│ Progress: ████████░░░░ 8.2s / 15.0s │
│ Skippable: ✓ PlayOnlyOnce: ✓ │
│ ───────────────────────────────────────────────────── │
│ [立即跳过] [重新播放] [暂停/继续] │
└────────────────────────────────────────────────────────────┘
```
### CutsceneSO 自定义 Inspector
- 拖入 `timelineAsset` 后,自动检测 Timeline 中的绑定 Track并在 `bindings[]` 列表中预填槽位
- 缩略图:`[自动截图]` 按钮在 Play Mode 下截取 Timeline 第 1 帧作为缩略图
### CutsceneTrigger Gizmo
- Scene 视图中显示黄色边框 + 触发器尺寸
- 显示文字标注:`[过场名] 触发条件: OnEnter`
-`_cutscene == null`,显示红色警告 Gizmo`⚠ 缺少 CutsceneSO`