53 KiB
交互式电影游戏引擎 — 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— 存档/读档 UIsrc/stores/gameStore.ts— Pinia 全局状态管理(含计时器、存档列表)src/composables/useGameEngine.ts— 桥接层(双 video、存档、计时器)src/components/GamePlayer.vue— 双 video 元素 + 交叉淡化 CSSsrc/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— 集成 QTE(timeupdate 检测 + 条件跳转 + 效果应用)- 多存档槽位 + 存档缩略图(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.ts(Vite 多入口构建) 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.ts—SceneNode.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%,肉眼无感
做法对比:
| 方案 | 体验 |
|---|---|
| 两画面重叠 300ms,割裂 | |
| 一帧黑/闪,依赖浏览器 | |
同一文件内 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既是触发选项显示的时机(视频到达此处时 emitchoiceRequest)也是循环起点loopEnd为循环终点,到达后 seek 回loopStart- 若只设
loopStart不设loopEnd,则循环区间为loopStart → 视频结尾
实现清单:
engine/types.ts—SceneNode.loopStart?: number,loopEnd?: numberengine/core/VideoManager.ts— 新增seekTo(time)方法engine/core/Engine.ts—checkLoop(time)在 timeupdate 中检测循环区间;onVideoEnd循环活跃时跳过;goToScene重置loopActivepublic/scenes/demo.json—stay场景添加 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 API —
GainNode.exponentialRampToValueAtTime()实现指数渐变(听感均匀,Wwise/FMOD/UE 同款做法) - MP3 — 全浏览器支持(含 Safari),解码快。OGG 暂不采用(Safari 不支持),P14 短循环音效需要 OGG 时单独处理
- 预加载 —
fetch(url) → decodeAudioData() → 缓存 AudioBuffer,已解码 buffer 最多 3 个,LRU 淘汰 - 视频不自动静音 —
videoMuted字段由制作者手动设置,引擎不做自动静音
架构变更:
Engine
├── VideoManager(A/B 双缓冲,只管画面和视频内音轨)
│ └── loopStart/loopEnd 循环 → BGM 不受影响
└── AudioSystem(Web 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 out(bgmCrossFade 秒)
└── bgmUrl 不同?
├── fetch + decode BGM B(若未缓存)
├── AudioBufferSourceNode 播 BGM B,gain 从 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 文件路径(MP3),null/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 API:fetch+decode 缓存、BufferSourceNode 创建、GainNode 指数 ramp 交叉淡化、同源继续/不同 crossFade/静音 fade out、ducking 事件接口engine/core/Engine.ts— 集成 AudioSystem;goToScene对比bgmUrl调度切换;QTE/choice/hotspot 触发时调用audioSystem.duckOn()/duckOff()engine/types.ts—SceneNode加bgmUrl、bgmVolume、bgmCrossFade、bgmDuckLevel、bgmDuckFade、videoMutedengine/core/VideoManager.ts— 根据videoMuted设置<video>.muted(手工字段,不自动)public/audio/— BGM 测试 MP3(calm_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.ts—GameData.chapters字段、ChapterInfo接口(含defaultVariables)engine/systems/SaveSystem.ts— DB v3 新增unlocks表;unlockChapter/getUnlockedChaptersengine/core/SceneManager.ts—chapters存储、getChapterBySceneId/getChapter查询engine/core/Engine.ts—goToScene检测场景所属章节 →chapterUnlock事件;startChapter套用defaultVariables并重置 flags/historysrc/components/ChapterSelect.vue— 章节选择 UI:缩略图网格 + 标题 + 锁定/解锁src/stores/gameStore.ts—chapters/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/getWatchedSceneIdsengine/core/Engine.ts—onVideoEnd调用onMarkWatched回调;skipCurrentScene()暂停视频并触发 ended 流程engine/core/VideoManager.ts—setPlaybackRate(rate)/getPlaybackRate()封装原生 APIengine/types.ts—SceneNode.skippable?: booleansrc/components/PlaybackBar.vue— 左上角跳过按钮(已看且 skippable 时显示)+ 倍速按钮(循环 1x/2x/4x)src/App.vue— 整合 PlaybackBar;watchcurrentScene 更新 canSkippublic/scenes/demo.json—qte_success/qte_fail设skippable: 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.ts—inputMode状态(mouse/keyboard)+setInputModesettersrc/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.ts—t(key),currentLang,setLang(lang), localStorage 持久化src/locales/zh.json— 中文翻译(UI ~20 项 + scene 选项文字 ~30 项)src/locales/en.json— 英文翻译(同结构)engine/types.ts—Choice.textKey?: string;SceneNode.subtitles?: Record<string, string>src/composables/useGameEngine.ts—choiceRequest中t(textKey)翻译后存入 storesrc/components/LangSwitch.vue— "中文 / English" 切换按钮组,调用setLangsrc/components/Subtitles.vue—effectiveUrlcomputed 优先subtitles[lang],fallbacksubtitleUrlsrc/App.vue— 主菜单 LangSwitch + 顶部栏按钮t()翻译src/components/ChoicePanel.vue—t('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 场景配置subtitlesmap + 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.ts—Choice.prompt?: stringsrc/components/ChoicePanel.vue—.has-promptCSS(金色左边框 + 淡金边框 + 悬停发光)+ 选后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.ts—GameData.achievements、AchievementDef { id, title, description, icon?, hidden, condition }engine/systems/AchievementSystem.ts—check(variables)遍历未解锁成就;onUnlock回调;toast 队列管理engine/systems/SaveSystem.ts— DB v5 新增achievements表engine/core/StateManager.ts—apply末尾onAfterApply回调engine/core/Engine.ts—stateManager.onAfterApply → achievementSystem.checksrc/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 结局画廊 + 章节回顾 — 列表 + 完成度百分比 + 条件提示 ✅ 已完成 2026-06-09
目标:通关后展示结局画廊和章节场景清单,包含完成度百分比和未解锁分支的条件提示, 驱动重玩探索。
设计决策:
| 决策 | 做法 |
|---|---|
| 结局存储 | 共用 visitedSceneIds,不独立建表。endings 数组声明哪些场景是结局,画廊查 visitedSceneIds.has(sceneId) |
| 路径追踪 | 节点级 visitedSceneIds: Set<string> — goToScene 中 visited.add(scene.id) |
| 回顾 UI | 列表模式:BFS 遍历可达场景 → visited/unvisited 列表 + 完成度百分比 + 条件提示 |
关于分支图(未实施): 原计划使用 Vue Flow 只读完整分支图(绿色实心高亮 visited 节点、灰色虚线边框 unvisited 节点),但经评估后确认:分支数一旦增多(多父同子、回边循环),自动布局的流程图会变成"意大利面",玩家理解成本反而高于简单列表。列表版已满足 P15 的核心目标——知道哪些场景到过、漏了什么、怎么解锁。
业界对比与未来路线:
方案 做法 清晰度 成本 Detroit 手绘图 美术逐章手工设计 InfoGraphic 极高 极高 Life is Strange 时间线 只画 keyMoment标记的关键节点,Dagre 自动排成水平时间线,跳过琐碎小场景高 中 Telltale 纯统计 片尾弹出 "52% 玩家选了救 Doug",不画任何图 N/A 极低 当前列表版 BFS 遍历全场景 + visited/unvisited 标记 中 已实现 未来升级路线(Life is Strange 时间线方案):
当需要可视化展示时,采用 Dagre 时间线方案而非全分支图:
SceneNode.keyMoment?: boolean— 场景标记为"关键节点",只有标记了的场景才出现在时间线图中- Dagre
rankdir: 'LR'从左到右水平排列,仅展示关键节点的 choices / nextScene / QTE 跳转- visited 节点绿色实心,unvisited 节点灰色虚线,条件提示小锁
- 普通小场景不展示,避免线路交织
- 回边用虚线半透明处理
结局画廊:
{
"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 到达场景时自动记录 visited。画廊检查 visitedSceneIds.has(ending.sceneId)。
章节回顾(当前列表版):
完成度百分比: 每章展示 "3/7 (43%) 场景已发现"。
统计该章 startScene 可达的场景数作为分母,visited 中属于本章的场景数作为分子。
条件提示: 未到达节点显示小锁图标 + 条件提示:
⬜ secret_ending 🔒 trust >= 80
引擎读取该边对应 choice/hotspot 的 conditions,展示第一个未满足的条件。
实现清单:
engine/types.ts—GameData.endings、EndingDef { id, label, sceneId, chapterId?, thumbnail? }engine/core/Engine.ts—goToScene中onMarkVisited(scene.id)回调engine/core/SceneManager.ts—getScenes()公开 scenes 数据engine/systems/SaveSystem.ts— DB v6 新增visited表src/components/EndingGallery.vue— 结局缩略图网格 + 锁定/解锁 + 点击打开章节回顾src/components/ChapterRecap.vue— BFS 遍历可达场景 + 完成度进度条 + visited/unvisited 列表 + 条件提示src/stores/gameStore.ts— endings/visitedSceneIds/showEndingGallery 状态src/App.vue— 主菜单"画廊"入口 + EndingGallery/ChapterRecap 组件public/scenes/demo.json— 3 个 endings +end_*.jpg缩略图 +chapterId关联public/images/end_{trust,alone,continue}.jpg— 结局缩略图- 验证:TypeScript + Vite build 通过
- 未来:Dagre 关键节点时间线图(
SceneNode.keyMoment?: boolean)
P16 可访问性设置 — 字幕 + QTE 辅助 + 防误触 + 暂停 ✅ 已完成 2026-06-09
目标:让不同身体条件的玩家都能舒适游戏。保留 6 个高价值设置和交互改进。
设置项:
| 设置 | 默认 | 说明 |
|---|---|---|
| 字幕字号 | 20px | 20/24/28/32px 可选,全局统一,所有场景生效 |
| 字幕背景透明度 | 0 | 0/0.3/0.5/0.7/0.9 可选,0=无背景(电影字幕风格) |
| QTE 时限放宽 | 关 | 开启后所有 QTE 时限 × 1.5 |
| QTE 按键简化 | 关 | 开启后所有 QTE 映射为空格键 |
| 防误触延迟 | 开 | 选项出现后 0.5 秒内不接受点击/按键确认,防止连续按跳过误选 |
| 可暂停 | 开 | Space 键暂停/恢复,画面冻结 + 半透明遮罩。非 ESC 菜单式覆盖 |
入口: 主菜单"设置"按钮 + 游戏内 ESC 菜单"设置"按钮,两处均可进入。
设置项存 localStorage,QTE 参数通过 Engine API 传入 QTESystem。暂停为引擎级功能。
实现清单:
src/stores/gameStore.ts— 6 个设置项状态 + localStorage 读写src/components/AccessibilitySettings.vue— 设置面板 UI(下拉 + 开关 + 滑块)src/components/Subtitles.vue—:style绑定store.subFontSize/store.subBgAlphasrc/components/ChoicePanel.vue— 防误触延迟(选项出现后 0.5spointer-events: none)engine/systems/QTESystem.ts—timeLimitMultiplier和singleKeyMode参数src/App.vue— 主菜单 + 游戏内"设置"按钮;Space 暂停/恢复带遮罩;QTE 参数传入引擎- 验证:TypeScript + Vite build 通过
P17 主菜单统一化 — 游戏入口整理 ✅ 已完成 2026-06-09
目标:将当前散落在 start-overlay 中的 7 个按钮整合到一个 MainMenu.vue 统一组件中,
游戏结束后不再只用"游戏结束"大字,而是显示同样的入口栏。
设计决策: 全局统计面板废弃。P14 成就系统和 P15 章节回顾已覆盖玩家行为追踪需求, 业界交互式电影游戏(Detroit / Dark Pictures / Telltale)也不做全局数字统计面板。
菜单结构:
┌──────────────────────────────┐
│ [中文] [English] │ ← 语言切换
│ │
│ [开始游戏] [继续上次进度] │
│ │
│ [章节] [成就] [画廊] [设置] │
│ │
└──────────────────────────────┘
游戏结束后展示同样的按钮栏 + 标题"游戏结束"。
实现清单:
src/components/MainMenu.vue— 统一主菜单组件:两行按钮(开始/继续 + 章节/成就/画廊/设置)+ 语言切换 + 游戏结束标题模式src/App.vue— 使用MainMenu替代散装start-overlay按钮 +game-end-overlay;移除 60 行旧 CSS- 验证:TypeScript + Vite build 通过
P18 视频加载失败恢复(待实现)
目标:视频加载失败时显示错误画面 + 重试/跳过按钮,不再 play().catch(() => {}) 静默黑屏。
改动点:
engine/core/VideoManager.ts—play/switchTo增加错误回调(onerror事件 +play()reject); 超时检测(5 秒未canplay视为失败);重试逻辑(最多 3 次,指数退避 1s/2s/4s)src/components/VideoErrorOverlay.vue— 错误画面:警告图标 + "视频加载失败" + [重试] [跳过此场景] 按钮src/stores/gameStore.ts—videoError状态({ sceneId: string, message: string, retryCount: number })src/App.vue— 整合 VideoErrorOverlay;跳过逻辑重试当前场景或调用engine.skipCurrentScene()强制跳过- 验证:断网播放 → 错误画面 → 重试恢复 → 跳过进入下一场景
P19 制作者工具链 — HTML / macOS / Windows 打包 ✅ 已完成 2026-06-09
目标:制作者 clone moviegame 源码后可直接开发。npm run dev 已有 Vite HMR 实时预览,
提供三行命令打包为 Web zip、macOS 可执行应用、Windows 可执行应用。
命令设计:
npm run dev # Vite 实时预览(已有)
npm run pack:html # 打包 Web 版 → release/mygame.zip → 上传 itch.io (HTML)
npm run pack:mac # 打包 macOS → release/MyGame-darwin-arm64/ 可执行文件夹
npm run pack:win # 打包 Windows → release/MyGame-win32-x64/ 可执行文件夹
实施方案:
| 工具 | 技术 | 选择原因 |
|---|---|---|
| Web 打包 | vite build + zip dist/ |
Vite 标准做法,产物直接上传 itch.io |
| 桌面打包 | Electron + @electron/packager |
比 electron-builder 简单:一行 CLI,零配置,不需安装向导。产出的可执行文件夹本地可直接双击运行,也可以直接分发 |
Electron 包装结构:
electron/
├── main.js # Electron 主进程,全屏 + 加载 dist/index.html
└── package.json # electron + @electron/packager 依赖
核心逻辑:
// electron/main.js
const { app, BrowserWindow } = require('electron')
app.whenReady().then(() => {
new BrowserWindow({
fullscreen: true,
autoHideMenuBar: true,
webPreferences: { nodeIntegration: false }
}).loadFile('dist/index.html')
})
app.on('window-all-closed', () => app.quit())
# pack:mac
npx @electron/packager . MyGame --platform=mas --arch=arm64,x64 --out=release
# pack:win
npx @electron/packager . MyGame --platform=win32 --arch=x64 --out=release
实现清单:
scripts/pack-html.cjs—vite build+ JSON 验证 + 复制 public 资源 + zip 打包electron/main.js— Electron 主进程,全屏窗口 + 加载 distelectron/package.json—electron+@electron/packager依赖 + pack scriptspackage.json— 新增pack:html/pack:mac/pack:winscriptsREADME.md— 面向制作者重写入门指南public/videos/— 只保留 1 个示例视频- 删除
moviegame-starter目录 - 验证:
pack:html生成 release/mygame.zip
P20 开场流程 — 启动视频 + 菜单背景视频 ✅ 已完成 2026-06-10
目标:对标行业标准,游戏启动时先播放开场视频,结束后淡入主菜单。 菜单背景支持循环视频(如 Detroit 飘雪的城市夜景),按钮叠加其上。
设计决策:
| 决策 | 做法 |
|---|---|
| 跳过逻辑 | 复用 P9 watched 表。开场视频用虚拟场景 ID __intro__。首次播放不可跳过,播放结束后 markWatched('__intro__')。后续启动 isWatched('__intro__') 为 true → 显示跳过按钮 |
| 跳过行为 | 和 P9 一致 — 停止视频 → 进入菜单 |
| 菜单背景 | menuVideo 循环播放,MainMenu 叠加其上 |
| 每次启动都播 | 不区分首次/再次,由 P9 跳过逻辑控制是否可跳 |
场景数据设计:
{
"startScene": "intro",
"introVideo": "/videos/studio_logo.mp4",
"menuVideo": "/videos/menu_bg.mp4",
"scenes": { ... }
}
工作流:
打开页面
├── 有 introVideo → 全屏播开场视频
│ ├── watched? → 显示跳过按钮(和 P9 完全一致的交互)
│ └── ended / skip → markWatched('__intro__') → 进入菜单
└── 无 introVideo → 直接显示菜单
主菜单
├── 有 menuVideo → 背景循环播 menuVideo
│ ├── [开始游戏] [继续] ... 按钮叠加在视频上
│ ├── 开始游戏 → 停止 menuVideo → 进入第一场景
│ └── 游戏结束 → 恢复 menuVideo 循环
└── 无 menuVideo → 纯黑背景
实现改动:
| 文件 | 改动 |
|---|---|
engine/types.ts |
GameData 加 introVideo?: string、menuVideo?: string |
src/App.vue |
加载后判断 introVideo → 播开场 → ended/跳过 → MainMenu;MainMenu 背景用 menuVideo 循环 |
实现清单:
engine/types.ts—GameData.introVideo?/GameData.menuVideo?src/composables/useGameEngine.ts—applyAssetBase处理 introVideo/menuVideo;loadGame写入 storesrc/stores/gameStore.ts—introVideo/menuVideo状态 + settersrc/App.vue— 开场视频全屏播放 + P9 跳过逻辑(__intro__watched)+ 菜单背景视频循环public/demo/videos/intro_logo.mp4+menu_bg.mp4— 示例视频public/scenes/demo.json— 配置introVideo/menuVideo- 验证:TypeScript + Vite build 通过
P21 菜单系统重构 — 主菜单 + 暂停菜单 + 设置 + 游戏内顶栏 ✅ 已完成 2026-06-10
目标:按照行业标准(Detroit / Dark Pictures / Telltale)重新整理四个菜单系统的设计和交互。
现状 vs 业界差距:
| 菜单 | 现状 | 业界标准 |
|---|---|---|
| 主菜单 | 两行横排 7 个按钮,LangSwitch 悬浮 | 竖排单列 4-5 项,语言在设置里,背景视频可见 |
| 游戏内顶栏 | 左侧 LangSwitch + PlaybackBar,右侧 3 个按钮 = 共 5 项 | 几乎无 HUD,仅角落菜单图标 |
| ESC 行为 | 直接打开 SaveLoadMenu | 打开暂停菜单(存档是子项) |
| 设置面板 | AccessibilitySettings 独立面板 | 设置中分类展示,语言在设置里 |
四个菜单系统重新规划:
1. 主菜单(MainMenu.vue 重设计)
(menuVideo 循环播放作为背景,半透 overlay)
[ 新游戏 ] ← 竖排,最大
[ 继续 ] ← 可选
章节选择 · 成就 · 画廊 · 设置 ← 底部小字装饰行
- 改为竖排单列,视觉层次分明
- LangSwitch 移除,语言挪到设置面板
- 半透明 overlay,menuVideo 背景可透
2. 游戏内顶栏(App.vue 精简)
左:跳过(条件) · 倍速(条<><E69DA1><EFBFBD>) 右:全屏 · [ ≡ ]
- LangSwitch 移除
- 设置按钮移除,统一在 ≡ 暂停菜单内
- ≡ 按钮基于现有的"菜单"/"设置"入口替代
3. ESC 暂停菜单(新建 PauseMenu.vue)
(当前视频暂停,半透明遮罩覆盖)
[ 继续游戏 ] ← 或按 Space/Esc
[ 存档 / 读档 ]
[ 设置 ]
[ 返回主菜单 ]
- 不再直接打开 SaveLoadMenu
- 第一个 Esc 打开暂停菜单,再按 Esc 继续
4. 设置面板(AccessibilitySettings.vue 升级)
语言:中文 ▼ English ← 顶部新增
字幕字号:20 ▼
字幕背景透明:0 ▼
QTE 时限放宽:关/开
QTE 按键简化:关/开
防误触延迟:开/关
可暂停:开/关
- 语言选择下拉移到这里
实现清单:
src/components/PauseMenu.vue— 新增 — ESC 暂停菜单:继续/存档/设置/返回主菜单 + 按 Esc 继续提示src/components/MainMenu.vue— 竖排单列设计:开始最大、继续次之、底部装饰行、移除 LangSwitchsrc/components/AccessibilitySettings.vue— 顶部新增语言选择下拉(中文/English)src/App.vue— 顶栏精简到跳过/倍速/全屏/≡;ESC 打开 PauseMenu 替代直接开 SaveLoadMenu;移除 LangSwitchsrc/locales/zh.json+en.json— 新增 8 个 PauseMenu 专用翻译 key- 验证:TypeScript + Vite build 通过
P22 故事进度总览 — 章节选择 + 画廊合并(待实现)
目标:对标 Detroit: Become Human,将章节选择和结局画廊合并为"故事进度总览"一张视图。
现状 vs 业界对比:
| 当前 | 业界标准(Detroit 等) | |
|---|---|---|
| 章节选择 | ChapterSelect.vue — 独立卡片选择器 |
章节卡片内嵌结局标签 |
| 结局画廊 | EndingGallery.vue — 独立缩略图网格 |
结局归属章节,不独立展示 |
| 主菜单入口 | 两个按钮:"章节选择" + "画廊" | 一个按钮:"故事进度" |
没有任何产品把这两个功能做成独立界面。结局天然属于章节,合并后:
- 一张卡片 = 章节缩略图 + 完成度百分比 + 解锁结局标签 + 开始按钮
- 减少操作步骤,一眼看完全局
- 视觉层级匹配叙事层级
合并后界面:
┌──────────────────────────────────────────┐
│ 故事进度总览 │
├──────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 第一章 │ │ 第二章 │ │ 第三章 │ │
│ │ [缩略图] │ │ [缩略图] │ │ [缩略图] │ │
│ │████ 43% │ │███░ 67% │ │███░ 80% │ │
│ │ │ │ │ │ │ │
│ │ 结局: │ │ 结局: │ │ 结局: │ │
│ │ ✅ 信任 │ │ — │ │ ✅ 继续 │ │
│ │ ✅ 独行 │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ [▶ 开始] │ │ [▶ 开始] │ │ [▶ 开始] │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ 点击卡片主体 → 章节回顾 (ChapterRecap) │
│ 点击 ▶ 开始 → 进入该章节 │
│ [返回] │
└──────────────────────────────────────────┘
数据依赖: 无额外字段。chapters + endings(按 chapterId 归属)+ visitedSceneIds + BFS 完成度计算三条已足够。
实现清单:
src/components/StoryGallery.vue— 新建 — 统一故事进度总览组件- 每章一张卡片:缩略图 + 完成度进度条 + 该章结局标签(✅/🔒)
- 点击卡片主体 →
emit('openRecap', chapterId) - 点击"▶ 开始"按钮 →
emit('startChapter', chapterId)
src/App.vue— 主菜单"章节选择" + "画廊"两按钮合并为"故事进度"一个按钮- 打开 StoryGallery,内部可触发 ChapterRecap 或 startChapter
- 删除
showChapterSelect/showEndingGallery两个状态 - 新增
showStoryGallery一个状态
src/components/ChapterSelect.vue— 删除src/components/EndingGallery.vue— 删除src/components/MainMenu.vue— 移除 chapters/gallery 两个 emit,合并为一个storyemit- 验证:TypeScript + Vite build 通过
{
"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"
}
}
关键架构决策记录
- 引擎与 UI 分离:
engine/下纯 TS 类,不 import Vue。UI 层通过 composables 桥接。 - A/B 双缓冲: 两个
<video>元素轮换,一个播放时另一个预加载候选视频。 - JSON 驱动: 所有剧情数据放在 JSON 中,编辑器本质是 JSON 的可视化读写工具。
- IndexedDB 存档: 比 localStorage 容量大,可存储截屏缩略图。