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

15 KiB
Raw Permalink Blame History

18 · 过场动画系统Cutscene

命名空间 BaseGames.Cutscene
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.UI(黑屏/字幕)· Unity Timeline · Cinemachine


目录

  1. 系统总览
  2. CutsceneManager
  3. CutsceneTrigger
  4. 过场动画 Timeline 规范
  5. Timeline Track 扩展
  6. 跳过机制
  7. 过场与对话系统集成
  8. 输入锁定与 GameState
  9. 保存/重播机制
  10. 事件频道
  11. 编辑器友好设计

1. 系统总览

过场动画系统基于 Unity Timeline,提供序列化、可预览的过场演出。

过场系统职责:
  ├─ CutsceneManager    → 全局过场状态机(播放/暂停/跳过)
  ├─ CutsceneTrigger    → 场景中的触发器(位置/事件触发过场)
  ├─ Timeline 规范      → 统一的 Track 分层规范
  ├─ Timeline Tracks    → 自定义 Signal Track发布 SO 事件频道)
  ├─ 跳过机制           → 长按跳过,带过场进度条
  └─ GameState 集成     → 过场期间切换到 Cutscene 状态,锁定输入

零耦合原则:过场通过自定义 SignalEmitterClip 发布 SO 事件频道,过场动画与游戏逻辑互不直接引用。


2. CutsceneManager

CutsceneManager 常驻 Persistent 场景,管理过场播放状态。

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 — 过场元数据

[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 放置在场景中,当满足触发条件时启动过场。

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 事件频道:

[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 — 字幕轨道

[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 自动恢复

InputReaderCutscene ActionMap 下只暴露 OnSkipPressed / OnSkipReleased 事件,玩家无法在过场中移动或攻击。


9. 保存/重播机制

过场播放记录

"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(过场 BGMInputReader(切 ActionMap
OnCutsceneEnded.asset VoidEventChannelSO CutsceneManager HUD(显示)、AudioManager(恢复 BGMInputReader(恢复 ActionMap
OnCutsceneSkipped.asset VoidEventChannelSO CutsceneManager 同上(快速执行收尾逻辑)
OnSkipProgress.asset FloatEventChannelSO0~1 CutsceneManager SkipFillBarUI 填充)
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