# 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); } /// /// 将 CutsceneSO 中声明的对象绑定到 Timeline Bindings(如摄像机、NPC)。 /// 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.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`