Files
tianshu-engine/ROADMAP.md
2026-06-09 17:21:54 +08:00

36 KiB
Raw Blame History

交互式电影游戏引擎 — Roadmap

技术栈

  • 框架: Vue 3 (Composition API + <script setup>)
  • 构建: Vite
  • 状态管理: Pinia
  • 可视化编辑器: Vue Flow
  • 存储: IndexedDB (Dexie.js)
  • 语言: TypeScript
  • 视频: 原生 <video> + A/B 双缓冲

项目结构

moviegame/
├── engine/                     # 框架无关的核心引擎(纯 TS
│   ├── core/
│   │   ├── Engine.ts           # 主循环,驱动各子系统
│   │   ├── SceneManager.ts     # 剧情节点图遍历
│   │   ├── VideoManager.ts     # A/B 双缓冲视频播放
│   │   └── StateManager.ts     # 全局状态、条件求值
│   ├── systems/
│   │   ├── ChoiceSystem.ts     # 选择 UI + 倒计时
│   │   ├── QTESystem.ts        # QTE 触发、判定、超时
│   │   └── SaveSystem.ts       # IndexedDB 存取
│   └── types.ts                # 场景数据类型定义
├── src/                        # Vue 应用(播放器端)
│   ├── components/
│   │   ├── GamePlayer.vue      # 主播放器(挂载 video 元素)
│   │   ├── ChoicePanel.vue     # 选项面板
│   │   ├── QTEOverlay.vue      # QTE 遮罩
│   │   ├── SaveLoadMenu.vue    # 存档界面
│   │   └── Subtitles.vue       # 字幕显示
│   ├── composables/
│   │   ├── useGameEngine.ts    # 引擎实例 + 生命周期
│   │   ├── useVideoPlayer.ts   # 视频状态响应式封装
│   │   └── useGameState.ts     # 状态响应式封装
│   ├── stores/
│   │   └── gameStore.ts        # 游戏全局状态Pinia
│   ├── App.vue
│   └── main.ts
├── editor/                     # Vue Flow 可视化编辑器(独立入口)
│   ├── components/
│   │   ├── SceneGraph.vue
│   │   ├── NodeEditor.vue
│   │   └── PreviewPanel.vue
│   ├── composables/
│   │   └── useGraphEditor.ts
│   └── App.vue
├── public/
│   ├── videos/                 # 视频资源
│   └── scenes/
│       └── demo.json           # 示例剧情数据
├── vite.config.ts
├── tsconfig.json
├── package.json
└── index.html

场景数据格式

// engine/types.ts

interface SceneNode {
  id: string;
  videoUrl: string;
  subtitleUrl?: string;
  choices?: Choice[];
  qte?: QTEDefinition;
  nextScene?: string;
  onEnter?: Effect[];
}

interface Choice {
  text: string;
  targetScene: string;
  conditions?: Condition[];
  effects?: Effect[];
  timeLimit?: number;          // 限时选择0=不限时
}

interface Condition {
  variable: string;
  op: '>' | '<' | '>=' | '<=' | '==' | '!=' | 'hasFlag';
  value: number | string | boolean;
}

interface Effect {
  type: 'set' | 'add' | 'toggleFlag' | 'triggerEvent';
  target: string;
  value?: number | string | boolean;
}

interface QTEDefinition {
  triggerTime: number;
  prompt: string;
  keys: string[];
  timeLimit: number;
  successScene: string;
  failScene: string;
  effects?: {
    success: Effect[];
    fail: Effect[];
  };
}

interface GameData {
  scenes: Record<string, SceneNode>;
  startScene: string;
  variables: Record<string, number>;
}

interface SaveData {
  slot: number;
  timestamp: number;
  currentScene: string;
  variables: Record<string, number>;
  flags: string[];
  history: ChoiceRecord[];
  thumbnail?: string;
}

实现路线

P0 MVP — 最小可玩原型3-5 天) 已完成 2026-06-07

目标:能播放一段视频 → 弹出选项 → 跳到下一段视频

  • 项目脚手架Vite + Vue3 + TypeScript + Pinia
  • engine/core/Engine.ts — 主循环骨架(加载场景 → 播放 → 等选择 → 切换)
  • engine/core/SceneManager.ts — 加载 JSON按 ID 查找场景节点
  • engine/core/VideoManager.ts — 单 video 元素播放,监听 ended 事件
  • engine/core/StateManager.ts — 变量存取、条件求值、效果执行
  • engine/types.ts — 类型定义
  • src/components/GamePlayer.vue — 挂载 video控制播放
  • src/components/ChoicePanel.vue — 渲染选择按钮,触发引擎切换
  • public/scenes/demo.json — 编写一段简单剧情7 个场景节点)
  • 验证:从 demo.json 加载场景,能走通 开始→选择→分支播放→结束 流程

P1 核心 — 无缝切换 + 条件分支 + 存档1-2 周) 已完成 2026-06-07

  • engine/core/VideoManager.ts 升级 — A/B 双缓冲预加载候选视频CSS 交叉淡化
  • engine/core/SceneManager.ts 升级 — 支持条件分支(根据 variables/flags 过滤选项)
  • engine/systems/SaveSystem.ts — Dexie.js IndexedDB 存取,多槽位
  • engine/systems/ChoiceSystem.ts — 限时选择倒计时,超时默认选择
  • src/components/SaveLoadMenu.vue — 存档/读档 UI
  • src/stores/gameStore.ts — Pinia 全局状态管理(含计时器、存档列表)
  • src/composables/useGameEngine.ts — 桥接层(双 video、存档、计时器
  • src/components/GamePlayer.vue — 双 video 元素 + 交叉淡化 CSS
  • src/components/ChoicePanel.vue — 倒计时进度条 + 计时文字
  • src/App.vue — 整合 SaveLoadMenu、双 video、计时器
  • 验证:条件分支走通,存档读档正常,视频切换交叉淡化

P2 进阶 — QTE + 字幕 + 多存档槽1 周) 已完成 2026-06-07

  • engine/systems/QTESystem.ts — QTE 触发、键盘监听(支持多键匹配)、超时判定
  • src/components/QTEOverlay.vue — SVG 倒计时环 + 按键提示 + 成功/失败动画
  • src/components/Subtitles.vue — WebVTT 解析 + 字幕同步渲染
  • engine/core/Engine.ts — 集成 QTEtimeupdate 检测 + 条件跳转 + 效果应用)
  • 多存档槽位 + 存档缩略图canvas 截图当前视频帧320x180 JPEG
  • engine/core/VideoManager.ts — 新增 getActiveVideoElement() 供截图
  • engine/systems/SaveSystem.ts — DB 版本升级 v2支持 thumbnail 字段)
  • src/components/SaveLoadMenu.vue — 存档缩略图预览
  • 完整事件总线sceneChange, choiceRequest, choiceTimer, choiceTimeout, videoEnd, qteTrigger, qteTimer, qteResult, gameEnd
  • 验证QTE 正常触发与判定ArrowLeft/ArrowRight/A/D 躲石块),字幕同步,存档缩略图正常

P3 编辑器 — 可视化剧情编辑2-3 周) 已完成 2026-06-07

  • 编辑器入口:独立 editor/index.html + editor/main.tsVite 多入口构建)
  • editor/components/SceneGraph.vue — Vue Flow 节点图(场景节点 + 分支/默认/QTE 连线)
  • editor/components/NodeEditor.vue — 右侧面板(视频/字幕路径、nextScene、选项增删改、QTE 参数编辑)
  • editor/components/PreviewPanel.vue — 嵌入播放器实时预览选中场景视频
  • editor/composables/useGraphEditor.ts — 图数据与 JSON 双向同步
  • JSON 导出/导入(文件下载 + 文件选择)
  • 工具栏:新建场景、导入 JSON、导出 JSON、加载示例、起始场景选择
  • vite.config.ts — 多页面构建main + editor
  • 验证:编辑器能产出合法 JSON引擎能正确加载并运行

P4 视频/图片热点 — 点击画面区域触发分支 已完成 2026-06-08

目标在视频或图片上定义可点击热区Hotspot玩家点击画面不同位置触发不同分支。 热区既可覆盖在静态图片上(调查/解谜场景),也可覆盖在播放中的视频上(根据时间轴淡入淡出)。

视频热点 vs 图片热点(架构统一,差异仅两点):

图片热点 视频热点
底层内容 <img> 元素 <video> 元素(已经在播)
热点出现时机 始终可见 按时间轴出现/消失(showAt/hideAt

场景数据设计:

{
  "id": "investigation",
  "type": "video",
  "videoUrl": "/videos/investigation.mp4",
  "subtitleUrl": "/subtitles/investigation.vtt",
  "hotspots": [
    {
      "id": "hs_desk",
      "label": "查看书桌",
      "targetScene": "desk_detail",
      "x": 0.15, "y": 0.30, "width": 0.25, "height": 0.35,
      "showAt": 2.0,
      "hideAt": 8.0,
      "conditions": [{ "variable": "investigation", "op": ">=", "value": 1 }],
      "effects": [{ "type": "setFlag", "target": "checked_desk" }]
    },
    {
      "id": "hs_window",
      "label": "靠近窗户",
      "targetScene": "window_look",
      "x": 0.70, "y": 0.10, "width": 0.20, "height": 0.40,
      "showAt": 5.0,
      "hideAt": 10.0
    },
    {
      "id": "hs_door",
      "label": "离开房间",
      "targetScene": "leave_room",
      "x": 0.30, "y": 0.60, "width": 0.40, "height": 0.30,
      "timeLimit": 15
    }
  ],
  "choices": [
    { "text": "放弃调查", "targetScene": "give_up" }
  ]
}

字段约定:

  • x/y/width/height — 热区坐标,使用相对比例0~1自适应屏幕尺寸
  • showAt/hideAt — 视频热点的时间轴(秒),未设置时热区始终可见(兼容图片场景和始终可见的视频热点)
  • hotspots 支持 conditions(条件显隐)、effects(点击后效果)、timeLimit(限时热区)
  • 热点场景仍可同时附带底部 choices(如"放弃调查"按钮)
  • type 字段区分 "video"(默认)和 "image"(静态图,此时 imageUrl 替代 videoUrl

实现清单:

  • engine/types.tsSceneNode.type 字段、Hotspot 接口(含 showAt/hideAt
  • src/components/HotspotLayer.vue — 通用热区覆盖层叠加在视频或图片之上render 热区矩形 + hover 高亮 + label 浮动提示
  • engine/core/Engine.ts — 视频模式下监听 timeupdate按时显隐热区点击热区触发分支跳转
  • editor/components/NodeEditor.vue — 场景类型切换(视频/图片)+ 热区列表编辑 + 时间轴参数showAt/hideAt
  • public/images/ — 示例图片目录
  • public/scenes/demo.json — 新增图片热点场景 investigation_site + 视频热点场景 corridor
  • 验证:图片热区点击触发、视频热区按时出现/消失、条件过滤、hover 高亮

P5 选择等待循环 — 单文件内时间锚点无缝循环 已完成 2026-06-08

目标:视频结束后画面不暂停,而是在同一文件内通过 loopStart/loopEnd 时间锚点实现无切换循环, 选项浮在循环画面之上。和《底特律变人》《The Dark Pictures Anthology》等商业游戏的做法一致。

为什么不用单独 loop 文件做 cross-fade

  • 任何文件切换(硬切或淡入)都会产生可感知的割裂感
  • 商业游戏的循环效果本质上就是同一帧内 video.currentTime = loopStart,完全透明
  • 同一文件内 seek 只在下一个 timeupdate 触发(~250ms但对 ≥2 秒的循环区间来说误差 <5%,肉眼无感

做法对比:

方案 体验
主视频 → cross-fade → loopVideo 文件 两画面重叠 300ms割裂
主视频 → 硬切 → loopVideo 文件 一帧黑/闪,依赖浏览器
同一文件内 loopStart/loopEnd seek 完全无缝AAA 游戏标准

场景数据设计:

{
  "id": "tense_moment",
  "videoUrl": "/videos/tense_full.mp4",
  "loopStart": 8.0,
  "loopEnd": 10.0,
  "choices": [
    { "text": "冒险救人", "targetScene": "rescue" },
    { "text": "悄悄离开", "targetScene": "flee" }
  ]
}

素材制作流程:导演剪辑时将主剧情段 + 循环段合成为一个 MP4 文件,比如:

0:00 ~ 8:00  正常剧情演绎
8:00 ~ 10:00 循环片段(角色呼吸、张望)── 循环起点 loopStart=8, loopEnd=10

工作流程:

┌─ 主视频正常播放0s → loopStart
│
├─ time >= loopStart → 标记"已到达循环区间"
│   └─ timeupdate 持续检测time >= loopEnd → video.currentTime = loopStart无任何过渡
│       └─ 无限循环中...
│           │
│           ├─ 用户选择 ──→ break loop → switchTo(nextScene)
│           │
│           ├─ 视频 ended → 自动触发循环区间
│           │
│           └─ 选项面板在循环开始时浮出
│
└─ 无 loopStart 的场景 → 保持现有行为(结束后暂停,等待选择)

关键设计细节:

  • 检测循环完全依赖 timeupdate 事件,无需 requestAnimationFrame 或额外定时器——浏览器 ~250ms 的 timeupdate 间隔对 ≥2s 的循环段误差可忽略
  • 循环中 A/B 预加载仍然工作inactive slot 加载第一个候选目标场景,用户选择后 cross-fade 过去
  • loopStart 既是触发选项显示的时机(视频到达此处时 emit choiceRequest)也是循环起点
  • loopEnd 为循环终点,到达后 seek 回 loopStart
  • 若只设 loopStart 不设 loopEnd,则循环区间为 loopStart → 视频结尾

实现清单:

  • engine/types.tsSceneNode.loopStart?: number, loopEnd?: number
  • engine/core/VideoManager.ts — 新增 seekTo(time) 方法
  • engine/core/Engine.tscheckLoop(time) 在 timeupdate 中检测循环区间;onVideoEnd 循环活跃时跳过;goToScene 重置 loopActive
  • public/scenes/demo.jsonstay 场景添加 loopStart=3, loopEnd=6, 循环中显示选项
  • public/videos/stay_loop.mp4 — 6s 测试视频0-3s 蓝色正文 + 3-6s 绿色循环段)
  • 验证:正文播放完毕 → 进入循环 → 选项浮现 → 画面无缝来回 → 选择后跳转

P6 独立背景音乐 + Ducking — 画面循环不打断 BGM 已完成 2026-06-08

目标:将 BGM 从视频中剥离,由独立 AudioSystem 驱动。视频循环/切换时 BGM 保持连贯,场景间交叉淡化衔接。 QTE 和选择面板出现时 BGM 自动闪避ducking以确保提示音不被淹没。

技术选型

  • Web Audio APIGainNode.exponentialRampToValueAtTime() 实现指数渐变听感均匀Wwise/FMOD/UE 同款做法)
  • MP3 — 全浏览器支持(含 Safari解码快。OGG 暂不采用Safari 不支持P14 短循环音效需要 OGG 时单独处理
  • 预加载fetch(url) → decodeAudioData() → 缓存 AudioBuffer,已解码 buffer 最多 3 个LRU 淘汰
  • 视频不自动静音videoMuted 字段由制作者手动设置,引擎不做自动静音

架构变更:

Engine
├── VideoManagerA/B 双缓冲,只管画面和视频内音轨)
│    └── loopStart/loopEnd 循环 → BGM 不受影响
└── AudioSystemWeb Audio API
     ├── AudioContext → GainNode(BGM) → destination
     │    └── 多个 BufferSourceNode新旧 BGM 交叉淡化,指数 ramp
     └── ducking 控制QTE/选择/热点触发 → GainNode ramp 降 → 事件结束 → ramp 恢复

BGM 切换策略:

goToScene(Scene B)
    ├── bgmUrl 相同?→ 什么都不做继续播bgmVolume 变化 → ramp 调整)
    ├── bgmUrl 为 null→ 当前 BGM 指数 fade outbgmCrossFade 秒)
    └── bgmUrl 不同?
        ├── fetch + decode BGM B若未缓存
        ├── AudioBufferSourceNode 播 BGM Bgain 从 0.001 ramp 到 bgmVolume
        ├── 同时 BGM A 的 gain ramp 到 0.001,耗时 bgmCrossFade 秒
        ├── ramp 完成后 stop BGM A 的 source释放
        └── 画面交叉淡化照常(画面和 BGM 各自独立过渡)

Ducking 自动闪避策略:

触发事件 duck 目标值 进入耗时 恢复耗时
QTE 触发 bgmDuckLevel × bgmVolume 0.3s bgmDuckFade
选择面板出现 bgmDuckLevel × bgmVolume bgmDuckFade bgmDuckFade
视频热点出现 bgmDuckLevel × bgmVolume bgmDuckFade bgmDuckFade

实现方式AudioSystem 内部维护一个"当前 duck 等级"计数器(允许多个事件重叠)。 GainNode 的 ramp 目标值 = Math.min(bgmVolume, bgmDuckLevel × bgmVolume)。 最后一个事件结束时恢复为 bgmVolume

场景数据设计:

{
  "id": "tense_moment",
  "videoUrl": "/videos/tense_no_bgm.mp4",
  "loopStart": 8.0,
  "loopEnd": 10.0,
  "bgmUrl": "/audio/tense_bgm.mp3",
  "bgmVolume": 0.8,
  "bgmCrossFade": 2.0,
  "bgmDuckLevel": 0.35,
  "bgmDuckFade": 0.5,
  "videoMuted": false,
  "choices": [...]
}

字段说明:

字段 类型 默认 说明
bgmUrl string null BGM 文件路径MP3null/falsy 表示静默并 fade out 当前 BGM
bgmVolume number 0.8 目标音量0~1
bgmCrossFade number 2.0 BGM 切换交叉淡化时长(秒)
bgmDuckLevel number 0.35 QTE/选择/热点时 duck 到 bgmVolume 的百分比
bgmDuckFade number 0.5 duck 进入和恢复的渐变时长(秒)
videoMuted bool false 制作者手动设置,引擎不自动静音

实现清单:

  • engine/systems/AudioSystem.ts — Web Audio APIfetch+decode 缓存、BufferSourceNode 创建、GainNode 指数 ramp 交叉淡化、同源继续/不同 crossFade/静音 fade out、ducking 事件接口
  • engine/core/Engine.ts — 集成 AudioSystemgoToScene 对比 bgmUrl 调度切换QTE/choice/hotspot 触发时调用 audioSystem.duckOn()/duckOff()
  • engine/types.tsSceneNodebgmUrlbgmVolumebgmCrossFadebgmDuckLevelbgmDuckFadevideoMuted
  • engine/core/VideoManager.ts — 根据 videoMuted 设置 <video>.muted(手工字段,不自动)
  • public/audio/ — BGM 测试 MP3calm_bgm.mp3, tense_bgm.mp3
  • public/scenes/demo.json — intro/stay/right_door 配置 BGM + cross-fade + ducking 示例
  • editor/components/NodeEditor.vue — BGM 字段编辑面板6 个字段)
  • 验证BGM 跨视频循环连续、场景切换交叉淡化、ducking 降/恢复、同源不中断、指数曲线听感均匀

远期功能(不纳入 P6

功能 说明
自适应 BGM 按 StateManager 变量值切换变奏(如 suspicion < 50 放安静版,>= 50 放紧张版)
水平分段编排 BGM 前奏/主体/变奏/尾奏自动串联
分层 Stems 多轨独立 GainNode 动态叠加,按变量增减层数
Stingers 短乐句事件音(发现线索的"叮"、惊悚弦乐刺音)
BGM 弧线 一条 BGM 覆盖多个连续场景而不被切换打断

P7 全屏模式 — 沉浸式浏览器体验 已完成 2026-06-08

目标:一键进入全屏播放模式,播放中自动隐藏 UI选项/菜单等浮层除外),提供 F11 级别的沉浸感。

实现清单:

  • src/composables/useFullscreen.ts — Fullscreen API 封装(toggle + isFullscreen + fullscreenchange 监听)
  • src/App.vue — 右上角全屏按钮,与"菜单"按钮并排;fullscreenchange 同步图标状态
  • FUTURE.md — 远期扩展笔记Pointer Lock、自动全屏、UI 自动隐藏、移动端适配等)

P8 章节选择 — 到达即解锁,主菜单+通关后跳转 已完成 2026-06-09

目标:玩家可从中途任意章节起始点重新开始。到达章节入口即解锁,主菜单和通关后均可进入章节选择界面。

核心规则:

规则 说明
解锁方式 到达即解锁 — goToScene 中检测当前场景所属章节,立即标记解锁并持久化到 IndexedDB
变量状态 跳转时套用该章的 defaultVariables,未定义时 fallback 到全局 variables;确保条件分支不锁死
入口 主菜单"章节选择"按钮 + 通关后"章节选择"按钮,两处均可进入

数据结构设计:

{
  "startScene": "intro",
  "variables": { "trust": 50, "courage": 0, "investigation": 0 },
  "chapters": [
    {
      "id": "ch1", "label": "第一章:醒来", "startScene": "intro",
      "thumbnail": "/images/ch1.jpg",
      "defaultVariables": { "trust": 50, "courage": 0, "investigation": 0 }
    },
    {
      "id": "ch2", "label": "第二章:调查", "startScene": "desk_detail",
      "thumbnail": "/images/ch2.jpg",
      "defaultVariables": { "trust": 60, "courage": 10, "investigation": 1 }
    },
    {
      "id": "ch3", "label": "第三章:终局", "startScene": "qte_success",
      "thumbnail": "/images/ch3.jpg",
      "defaultVariables": { "trust": 70, "courage": 20, "investigation": 2 }
    }
  ]
}

实现清单:

  • engine/types.tsGameData.chapters 字段、ChapterInfo 接口(含 defaultVariables
  • engine/systems/SaveSystem.ts — DB v3 新增 unlocks 表;unlockChapter/getUnlockedChapters
  • engine/core/SceneManager.tschapters 存储、getChapterBySceneId/getChapter 查询
  • engine/core/Engine.tsgoToScene 检测场景所属章节 → chapterUnlock 事件;startChapter 套用 defaultVariables 并重置 flags/history
  • src/components/ChapterSelect.vue — 章节选择 UI缩略图网格 + 标题 + 锁定/解锁
  • src/stores/gameStore.tschapters/unlockedChapterIds/showChapterSelect 状态
  • src/App.vue — 主菜单"章节选择"按钮 + 游戏结束"章节选择"按钮
  • public/scenes/demo.json — 3 章定义(含 defaultVariables 和 thumbnail
  • public/images/ch{1,2,3}.jpg — 章节缩略图
  • 验证TypeScript + Vite build 通过

P9 跳过已看 + 倍速播放 已完成 2026-06-09

目标:玩家重玩分支时可以跳过已看过的场景或加速播放,避免重复等待,鼓励多次探索不同路线。

核心规则:

决策 说明
判定粒度 IndexedDB 持久化已看场景列表SaveSystem watched 表),跨会话生效
跳过方式 画面右上角浮现"跳过"按钮,点击立即触发 skip
倍速方式 画面右上角"倍速"按钮,点击循环切换 1x → 2x → 4x → 1x
两者并存 跳过和倍速互不冲突,各用各的按钮
不可跳过 第一次看的场景不可跳(onVideoEnd 后才记入已看);skippable: false 可永久禁止

新增数据结构:

{
  "id": "tense_moment",
  "videoUrl": "/videos/tense.mp4",
  "skippable": false,
  "choices": [...]
}

skippable 默认 true。设为 false 时即使已看过也不显示跳过按钮。

跳过时的引擎行为:

用户点击跳过按钮
  → engine.skipCurrentScene()
    → videoManager.pause()
    → 直接触发 onVideoEnd(scene) 流程(弹出选项 / 自动跳转 / endGame
    → 和正常播放结束行为完全一致

实现清单:

  • engine/systems/SaveSystem.ts — DB v4 新增 watched 表;markWatched / isWatched / getWatchedSceneIds
  • engine/core/Engine.tsonVideoEnd 调用 onMarkWatched 回调;skipCurrentScene() 暂停视频并触发 ended 流程
  • engine/core/VideoManager.tssetPlaybackRate(rate) / getPlaybackRate() 封装原生 API
  • engine/types.tsSceneNode.skippable?: boolean
  • src/components/PlaybackBar.vue — 左上角跳过按钮(已看且 skippable 时显示)+ 倍速按钮(循环 1x/2x/4x
  • src/App.vue — 整合 PlaybackBarwatch currentScene 更新 canSkip
  • public/scenes/demo.jsonqte_success / qte_failskippable: false
  • 验证TypeScript + Vite build 通过

P10a 键盘导航 — 方向键+确认键驱动全流程 已完成 2026-06-09

目标:支持纯键盘操作整个游戏流程(选项选择、确认、菜单),方向键/WASD 移动高亮、Enter/Space 确认、Esc 菜单。 适配《底特律》《Telltale》级别的键盘交互体验。

核心设计(对标业界):

设计点 做法
输入范围 完整接管:选项导航、菜单导航、存档界面、章节选择
视觉反馈 自定义高亮(发光边框/变色),和鼠标 hover 共用样式
自动检测 检测到 keydown → 标记 inputMode='keyboard' → 显示焦点环;鼠标移动 → 恢复 inputMode='mouse'
QTE 本期不做 QTE 键位整合QTE 仍直接监听 keydown远期 P10b 处理

按键映射:

操作 按键
选项上移 ↑ / W
选项下移 ↓ / S
确认 Enter / Space
菜单 Esc
跳过 不变(按钮点击)
全屏 不变(按钮点击)

实现清单:

  • src/stores/gameStore.tsinputMode 状态mouse/keyboard+ setInputMode setter
  • src/App.vue — 全局 keydown 监听(方向键/Enter/Space/Tab → keyboard 模式Esc → 关闭菜单/章节mousemove → mouse 模式
  • src/components/ChoicePanel.vue — 选项出现时 auto-focus 第一项;↑↓ 键导航焦点Enter/Space 确认;:focus-visible 发光边框样式
  • src/components/ChapterSelect.vue — ←→ 键在章节卡片间导航跳过锁定章节Enter 选择Esc/Backspace 返回;:focus-visible 高亮
  • src/components/SaveLoadMenu.vue@keydown.escape 关闭菜单
  • 验证TypeScript + Vite build 通过

P10b 手柄导航(远期 P10b— 见 FUTURE.md

P11 完整 i18n — 字幕 + UI 国际化,自制 useI18n 已完成 2026-06-09

目标:字幕和完整 UI 文本(选项、按钮、标签)支持多语言切换。使用自制 useI18n() 组合式函数, 零依赖,通过静态 import JSON 翻译文件实现。语言切换入口在主菜单和游戏内顶部栏两处。

技术选型:自制 useI18n~25 行 TS不用 vue-i18n。

无需 npm 包,t(key) 从静态 import 的 JSON 中按路径查找翻译文本, currentLang 持久化到 localStorage跨会话保持。

架构分层:

UI 层 (Vue)
  ├── useI18n.ts         t(key), currentLang, setLang(lang)
  ├── LangSwitch.vue      "中文 / English" 按钮组
  ├── locales/zh.json     中文翻译UI + scene 文本 ~50行
  ├── locales/en.json     英文翻译UI + scene 文本 ~50行
  ├── 各组件 t('key')     按钮/提示/标签翻译
  └── Subtitles.vue       按 currentLang 加载 subtitles[lang]

数据层 (Engine / Scene JSON)
  ├── Choice.textKey       可选 i18n key缺省 fallback 到 text
  ├── SceneNode.subtitles  Record<lang, url> 字幕多语言 map
  └── 引擎不感知 i18n      纯数据传递,翻译在 composable 层完成

核心 composable 设计:

// src/composables/useI18n.ts
const messages = { zh, en }
const currentLang = ref(localStorage.getItem('lang') || 'zh')

export function useI18n() {
  function t(key: string): string { /* key = "ui.start" → messages[lang].ui.start */ }
  function setLang(lang: string) { /* localStorage + currentLang */ }
  return { t, currentLang, setLang }
}

Choice 翻译策略:

composable 在 choiceRequest 事件中调用 t(textKey) 翻译选项文字后存入 store。 textKey 未设置时 fallback 到 text(向后兼容,不要求每个 Choice 都加 key

engine.on('choiceRequest', (choiceList) => {
  const translated = choiceList.map(c => ({
    ...c,
    text: c.textKey ? i18n.t(c.textKey) : c.text,
  }))
  store.setChoices(translated)
})

场景数据变更:

{
  "id": "intro",
  "videoUrl": "/videos/intro.mp4",
  "subtitles": {
    "zh": "/subtitles/intro_zh.vtt",
    "en": "/subtitles/intro_en.vtt"
  },
  "choices": [
    {
      "text": "走向左边那扇发光的门",
      "textKey": "scene.intro.choice.left_door",
      "targetScene": "left_door"
    }
  ]
}

翻译文件结构:

// src/locales/zh.json
{
  "ui": {
    "start": "开始",
    "resume": "继续上次进度",
    "chapters": "章节选择",
    "menu": "菜单",
    "save": "保存",
    "load": "读取",
    "close": "关闭",
    "skip": "跳过",
    "fullscreen": "全屏",
    "exitFullscreen": "退出全屏",
    "gameEnd": "结束",
    "choose": "做出你的选择",
    "back": "返回",
    "autoSave": "自动存档",
    "empty": "空",
    "loading": "加载中..."
  },
  "scene": {
    "intro": {
      "choice": {
        "left_door": "走向左边那扇发光的门",
        "right_door": "走向右边那扇普通的门",
        "search": "搜索房间",
        "stay": "留在原地,什么也不做"
      }
    }
  }
}

实现清单:

  • src/composables/useI18n.tst(key), currentLang, setLang(lang), localStorage 持久化
  • src/locales/zh.json — 中文翻译UI ~20 项 + scene 选项文字 ~30 项)
  • src/locales/en.json — 英文翻译(同结构)
  • engine/types.tsChoice.textKey?: stringSceneNode.subtitles?: Record<string, string>
  • src/composables/useGameEngine.tschoiceRequestt(textKey) 翻译后存入 store
  • src/components/LangSwitch.vue — "中文 / English" 切换按钮组,调用 setLang
  • src/components/Subtitles.vueeffectiveUrl computed 优先 subtitles[lang]fallback subtitleUrl
  • src/App.vue — 主菜单 LangSwitch + 顶部栏按钮 t() 翻译
  • src/components/ChoicePanel.vuet('ui.choose') 替代硬编码提示文字
  • src/components/SaveLoadMenu.vue — 8 处文本用 t() 翻译
  • src/components/ChapterSelect.vue — 标题 + 返回按钮用 t() 翻译
  • src/components/PlaybackBar.vue — 跳过按钮用 t('ui.skip')
  • public/subtitles/*_en.vtt — 3 个英文版字幕文件intro/left_door/stay
  • public/scenes/demo.json — intro 场景配置 subtitles map + 4 个 choice 添加 textKey
  • 验证TypeScript + Vite build 通过

P12 场景过渡特效(已废弃)

P13 关键选择提示 — 选前标识 + 选后浮现 已完成 2026-06-09

目标让玩家感知哪些选择有重量。Choice 增加 prompt 字段, 同时驱动前置金色标识Detroit 风格选前感知和后置文字浮现Telltale 风格,选后提示)。

前后都做:

  • 前置 —prompt 的选项,按钮左边金色竖线 + 淡金边框,悬停发光。选前感知重要性。
  • 后置 — 选择确认后,画面中央浮现 prompt 文字2 秒淡出。
{
  "text": "与陌生人握手",
  "prompt": "陌生人会记住你的善意",
  "targetScene": "trust_ending"
}

一个字段驱动两个行为,零额外数据结构。

实现清单:

  • engine/types.tsChoice.prompt?: string
  • src/components/ChoicePanel.vue.has-prompt CSS金色左边框 + 淡金边框 + 悬停发光)+ 选后 prompt-toast 浮现 2s 淡出
  • public/scenes/demo.json — left_door + trust_ending 各一个 prompt 示例
  • 验证TypeScript + Vite build 通过

P14 成就系统 — 纯变量检测 + 单一检查点 + Toast 队列 已完成 2026-06-09

目标Steam 式成就系统,驱动重玩探索。所有成就通过变量检测,在 StateManager.apply 末尾单一检查点触发。

设计决策(对标 Detroit / Dark Pictures

决策 做法
检测方式 纯变量 + 单一检查点condition: { variable, op, value }StateManager.apply 末尾 achievementSystem.check(variables)
Toast 弹出 逐个队列 — 同时解锁多个成就时一个消失后下一个才弹出
图标 可选 URL — 有 icon 路径则显示缩略图,为空则不显示图标栏
入口 仅主菜单 — 不在游戏内 Esc 菜单,属于元游戏层

数据结构:

{
  "achievements": [
    {
      "id": "qte_master",
      "title": "反应达人",
      "description": "成功完成一次 QTE",
      "icon": "",
      "hidden": false,
      "condition": { "variable": "qte_succeeded", "op": ">=", "value": 1 }
    }
  ]
}

QTE 成功 / 到达隐藏结局 / 通关等"事件型"成就,通过在对应 effects 或 onEnter 中变量来驱动检测。

实现清单:

  • engine/types.tsGameData.achievementsAchievementDef { id, title, description, icon?, hidden, condition }
  • engine/systems/AchievementSystem.tscheck(variables) 遍历未解锁成就;onUnlock 回调toast 队列管理
  • engine/systems/SaveSystem.ts — DB v5 新增 achievements
  • engine/core/StateManager.tsapply 末尾 onAfterApply 回调
  • engine/core/Engine.tsstateManager.onAfterApply → achievementSystem.check
  • src/components/AchievementToast.vue — 底部弹窗滑入/滑出动画
  • src/components/AchievementPanel.vue — 成就列表(全部/已解锁/未解锁/隐藏)
  • src/stores/gameStore.ts — 成就定义/解锁/toast 状态
  • src/App.vue — 整合 AchievementToast + 主菜单"成就"入口
  • public/scenes/demo.json — 3 个成就 + QTE success 变量 set + alone_ending onEnter

P15 结局画廊 + 章节回顾 — 分支图可视化(待实现)

目标:通关后展示结局画廊 + 章节分支流程图。玩家直观看到"哪些结局未解锁、哪些分支未走过",驱动重玩。

结局画廊:

{
  "endings": [
    { "id": "trust_end", "label": "信任的伙伴", "sceneId": "trust_ending", "thumbnail": "/images/end_trust.jpg" },
    { "id": "alone_end", "label": "独行之路", "sceneId": "alone_ending", "thumbnail": "/images/end_alone.jpg" }
  ]
}

引擎 goToScene 到达结局场景时标记解锁。画廊缩略图网格:已解锁显示画面,未解锁 ? 剪影。

章节回顾:

基于 Vue Flow 节点图(复用 P3 编辑器),只读模式。追踪 visitedSceneIds: Set<string>goToScene 中记录。展示章节分支图,已走路径高亮,未走路径灰色虚线。

入口:游戏结束后展示 + 章节选择界面每章卡片下有"回顾"按钮。不打断游戏流程。

实现清单:

  • engine/types.tsGameData.endingsEndingDef
  • engine/systems/SaveSystem.ts — DB v5 新增 endings + visited
  • engine/core/Engine.tsgoToScene 标记结局 + 记录 visited scene
  • src/components/EndingGallery.vue — 结局缩略图网格 + 锁定/解锁
  • src/components/ChapterRecap.vue — Vue Flow 只读分支图 + 路径高亮
  • src/stores/gameStore.ts — 结局/visited 状态
  • src/App.vue — 主菜单"画廊"入口 + 游戏结束整合回顾
  • public/scenes/demo.json — endings 定义 + 结局缩略图
  • 验证:结局到达→解锁→画廊显示、章节回顾路径高亮正确、未解锁结局 ? 剪影

P16 平台化 — 云存档 + 可访问性 + 自适应码率 + 全局统计(待实现)

目标:面向分发和用户多样性的补全功能。含原 P15 + 原 P13d 全局统计。

依赖清单

{
  "dependencies": {
    "vue": "^3.4",
    "pinia": "^2.1",
    "@vue-flow/core": "^1.x",
    "@vue-flow/background": "^1.x",
    "@vue-flow/controls": "^1.x",
    "dexie": "^4.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0",
    "typescript": "^5.3",
    "vite": "^5.0",
    "vue-tsc": "^2.0"
  }
}

关键架构决策记录

  1. 引擎与 UI 分离: engine/ 下纯 TS 类,不 import Vue。UI 层通过 composables 桥接。
  2. A/B 双缓冲: 两个 <video> 元素轮换,一个播放时另一个预加载候选视频。
  3. JSON 驱动: 所有剧情数据放在 JSON 中,编辑器本质是 JSON 的可视化读写工具。
  4. IndexedDB 存档: 比 localStorage 容量大,可存储截屏缩略图。