chore: initial commit
This commit is contained in:
441
Docs/Design/18_CutsceneSystem.md
Normal file
441
Docs/Design/18_CutsceneSystem.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# 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 (Image,fillAmount 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 Inspector(Play 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`
|
||||
Reference in New Issue
Block a user