feat: electron desktop packaging, CDN asset migration, production docs, scene JSON spec

This commit is contained in:
2026-06-09 23:20:27 +08:00
parent 48fb89449a
commit 3a7dd2f405
35 changed files with 900 additions and 135 deletions

13
.gitignore vendored
View File

@@ -22,3 +22,16 @@ npm-debug.log*
# TypeScript
*.tsbuildinfo
# Game assets (large files)
public/videos/*
public/audio/*
public/images/*
# Keep example files
!public/videos/intro.mp4
!public/scenes/demo.json
!public/subtitles/intro.vtt
# Release output
release/

157
FUTURE.md
View File

@@ -184,4 +184,159 @@ AI工具也一样一个不懂电影游戏制作的人只靠AI是做不出
本引擎就是帮助懂电影游戏制作的人更快更高质量的完成产品AI是帮助引擎更容易使用达到降本增效的效果。
引擎帮助人降本增效AI帮助引擎更容易被使用和提升产出效率。
引擎帮助人降本增效AI帮助引擎更容易被使用和提升产出效率。
## 游戏内流图可视化 — Detroit 式分支图即时查看
基于已有 P15 的章节回顾 Vue Flow 节点图,升级为游戏内实时查看。
玩家在决策点暂停时,可快速预览当前章节的分支结构,已走路高亮,未解锁路径灰色。
**核心功能:**
- 暂停菜单中新增"查看分支图"按钮,点击后全屏展示当前章节流图
- 已到达场景节点绿色实心,当前场景节点带呼吸动画
- 未到达场景灰色虚线边框,提前未知路径用虚线表示
- 条件分支显示条件提示(如"需要 trust >= 80"),帮助玩家理解未解锁原因
- 点击已到达的节点可直接跳转到该场景重新开始(需玩家确认)
**数据依赖(已有基础):**
- `visitedSceneIds`P15 — `goToScene` 自动记录IndexedDB 持久化)
- 场景间边关系(从 `SceneManager.getScenes()` 解析 choices / nextScene / qte success/fail / hotspotsBFS 遍历)
- 章节起始场景P8 — `ChapterInfo.startScene`
- Vue Flow 组件P3 编辑器已有,只读渲染 + 高亮 + 动画)
**关键技术点:**
- 章节可达场景 BFS 遍历P15 ChapterRecap 已有实现)
- 节点 `visited` 高亮样式P15 已有)
- 节点跳转回游戏内场景 → 引擎已有 `startChapter → goToScene`,新增 `jumpToScene(sceneId)` 直接跳(不重置变量,作为"分歧点回退"
**实现要点:**
- [ ] `src/components/InGameFlowChart.vue` — 复用 Vue Flow只读模式 + 呼吸动画 + 节点点击跳转
- [ ] `src/stores/gameStore.ts``showFlowChart` 状态 + 当前章节场景图数据
- [ ] `src/App.vue` — 暂停菜单/ESC 菜单中增加"查看分支图"入口
- [ ] `engine/core/Engine.ts` — 支持从流图节点跳转到场景(`jumpToScene(sceneId)`
## 人物关系系统 — 角色好感度可视化
对标 Detroit 的角色关系 HUD。游戏变量中角色好感度值如 trust / friendship在决策后通过角色头像 +
关系变化值(+数值显示)+ 迷你进度条动画展示给玩家,增强叙事沉浸感和角色互动的反馈。
**核心功能:**
- JSON 中定义角色信息:头像 URL、名称标签、关联变量
- 变量值变化时 → HUD 弹出角色头像 + 变化值(如 `+10 信任`),短暂停留后淡出
- 暂停或按特定键可查看完整的角色好感度一览(个人资料 + 当前值 + 历史变化趋势)
**数据示例:**
```json
{
"characters": [
{ "id": "stranger", "name": "陌生人", "portrait": "/images/stranger.jpg", "variable": "trust" },
{ "id": "partner", "name": "伙伴", "portrait": "/images/partner.jpg", "variable": "courage" }
]
}
```
**技术架构:**
- `StateManager.apply` 前快照旧值 → apply 后计算变化 → emit `relationChange` 事件
- `RelationshipHUD.vue` 监听事件,显示浮动弹出(`+/- N`+ 头像 → 短暂停留后自动消失
- 完整角色一览面板通过暂停菜单按钮打开
**实现要点:**
- [ ] `engine/types.ts``GameData.characters: CharacterDef[]`
- [ ] `engine/core/StateManager.ts``apply` 前快照 `variables` → apply 后对比 → emit `relationChange`
- [ ] `src/components/RelationshipHUD.vue` — 底部/侧边角色头像条,值变化时弹出变化箭头动画
- [ ] `src/stores/gameStore.ts``changedCharacters` 列表(前/后变量值对比结果)
- [ ] `src/App.vue` — 整合 RelationshipHUD
## Engine/UI 分离架构 — 制作者定制 UI 能力
当前 `moviegame-starter` 分发的是完整构建产物(引擎 + UI 打包在一起),制作者只能改 JSON。
要给制作者更多 UI 定制能力,需要将引擎和 UI 拆分为独立的可替换层。
### 目标架构
```
moviegame-engine (npm 包 / CDN 脚本,闭源)
└── engine/core/*.ts → dist/engine.mjs
Engine, VideoManager, StateManager, SceneManager, AudioSystem ...
moviegame-ui (npm 包 / 源文件分发,开源)
└── src/components/*.vue 源码暴露
GamePlayer, ChoicePanel, Subtitles, QTEOverlay ...
└── src/composables/useGameEngine.ts 桥接引擎和 UI
moviegame-starter (制作者项目)
├── imports: moviegame-engine + moviegame-ui
├── public/ ← 视频、音频、JSON
├── src/App.vue ← 制作者自由修改
└── theme.css ← 制作者写 CSS
```
### 三层用户定制方案
| 技能等级 | 使用方式 | 修改什么 |
|----------|---------|---------|
| **零代码**(预计 90% 制作者) | clone starter → 只改 JSON + 换视频 | 不动代码 |
| **会 CSS**(预计 8% 制作者) | 改 `theme.css` — 颜色、字体、按钮样式 | 一行 `<link>` 引入 |
| **会 Vue**(预计 2% 制作者) | fork `moviegame-ui` → 改 `.vue` 组件 → `npm install` 自己版本 | 完全重做 UI |
### 方案 A: CSS 主题变量JSON 驱动)
`my_story.json` 顶层加 `theme` 字段,映射到 CSS 变量。复杂度低,但表达力有限。
```json
{
"theme": {
"primaryColor": "#ff6b35",
"bgColor": "#1a1a2e",
"fontSize": 16,
"subtitleColor": "#ffff00"
}
}
```
### 方案 B: 自定义 CSS 文件(推荐)
`dist/` 中放空 `theme.css`,引擎 `index.html``<link>` 引入。制作者填 CSS 覆盖默认样式。
一行代码解决,零侵入引擎,零复杂度。面向 98% 的定制需求。
```html
<!-- index.html -->
<link rel="stylesheet" href="/theme.css">
```
```css
/* theme.css — 制作者自由填 */
.choice-btn { border-radius: 20px; background: linear-gradient(...); }
.subtitles .sub-text { font-family: 'MyCustomFont', serif; }
```
### 方案 C: 开放 UI 源码Vue 组件替换)
`moviegame-ui` 发布 Vue `.vue` 源码包,制作者 `npm install` 后可直接修改组件。
适合有前端能力的高级制作者,可完全重做 UI如把选项列表改成对话轮盘
### engine 本身体积大小预估
若 engine 独立构建为 ESM 库:
- 当前 engine 代码共 6 个 TS 文件:`Engine.ts` (417 行)、`StateManager.ts` (102 行)、`VideoManager.ts` (188 行)、`SceneManager.ts` (53 行)、`AudioSystem.ts` (145 行)、`ChoiceSystem.ts` / `QTESystem.ts` / `AchievementSystem.ts` / `SaveSystem.ts`
- 总计约 1600 行 TS`tsc` 编译后约 15-20KB minified
- 不包含 Vue / Pinia / Dexie / Vue Flow — 这些属于 UI 层或按需引入
- 可作为极轻量 CDN 脚本分发:`<script src="moviegame-engine.min.js">`
### 编辑器拆分
编辑器同理:编辑器 UIVue Flow 节点图)属于 UI 层,数据层(`SceneManager.getScenes()`)属于 engine 层。
拆分后编辑器也引用同一个 `moviegame-engine` 包。
### 实施清单(远期)
- [ ] engine 独立 Vite build`vite build --config vite.engine.config.ts`)→ `dist/engine.mjs`
- [ ] engine 发布 npm`moviegame-engine``export { Engine, VideoManager, ... }`
- [ ] UI 层剥离Vue 组件 + composables 从 engine 项目中抽到独立目录
- [ ] `moviegame-ui` 发布 npm源码包不编译`.vue` 文件保留)
- [ ] starter 改造Vite + Vue + Pinia 脚手架,引入 engine + ui 两个 npm 包
- [ ] `theme.css` 空白模板 + 注释CSS 定制方案 B
- [ ] 编辑器重新引用 `moviegame-engine`

104
PRODUCTION.md Normal file
View File

@@ -0,0 +1,104 @@
# 生产级交付检查清单
从"能跑的 Demo"到"能交付用户的生产级引擎",需要补全以下内容。
---
## 一、性能与加载(玩家打开游戏的前 3 秒)
| # | 项目 | 说明 |
|---|------|------|
| 1.1 | 启动加载优化 | 骨架屏 + 进度条 + 首场景预加载优先级。替代当前串行 fetch JSON → 无 loading 进度 |
| 1.2 | 视频预加载策略 | LRU 缓存池3-5 个已解码视频),降低内存占用,提升预加载命中率 |
| 1.3 | 大场景 JSON 懒加载 | 章节级按需加载(只加载当前章节的场景定义和视频),避免整个 demo.json 一次性加载 |
| 1.4 | 资源压缩 | 视频 WebM/MP4 双编码 + Gzip/Brotli JSON |
| 1.5 | 首屏时间目标 | <200ms骨架屏 + 关键 CSS 内联 |
---
## 二、可靠性与错误处理
| # | 项目 | 说明 |
|---|------|------|
| 2.1 | 视频加载失败 | 超时检测 + 重试指数退避 3 + 错误画面 + 跳过按钮ROADMAP P18 |
| 2.2 | JSON 解析失败 | 格式验证 + 错误提示 + 降级到默认剧情 |
| 2.3 | IndexedDB 不可用 | 检测 + 降级到 memory-only 模式 + 提示用户 |
| 2.4 | 内存溢出 | 视频缓存上限 + 自动释放最久未用的 buffer |
| 2.5 | 浏览器兼容检测 | 检测 Web Audio API / Fullscreen API / `<video>` 支持不支持的显示提示 |
---
## 三、部署与分发
| # | 项目 | 说明 |
|---|------|------|
| 3.1 | 构建产物分析 | 代码分割 + tree-shaking 验证 |
| 3.2 | CDN 部署 | `public/videos/` 上传到 CDN`videoUrl` 支持绝对路径 |
| 3.3 | PWA 离线支持 | Service Worker 缓存核心资源离线也能玩 |
| 3.4 | 版本更新 | Service Worker 检测新版本 + 提示刷新 |
| 3.5 | 多环境配置 | 环境变量控制 JSON 路径CDN 域名 |
---
## 四、内容管线与制作效率
| # | 项目 | 说明 |
|---|------|------|
| 4.1 | JSON Schema 验证 | 编辑器 + 引擎加载时验证 JSON 结构合法性 |
| 4.2 | 视频编码规范脚本 | 自动化 ffmpeg 批处理统一编码参数多场景生成 |
| 4.3 | 字幕工具链 | Aegisub 兼容 + 时间轴自动校准 |
| 4.4 | 分支冲突检测 | 死路检测某些分支永不触发+ 循环检测 |
| 4.5 | 版本管理 | 场景 JSON 版本号 + 存档兼容性检测 + 迁移逻辑 |
---
## 五、分析
| # | 项目 | 说明 |
|---|------|------|
| 5.1 | 玩家行为统计 | 匿名收集中途退出位置热门分支平均通关时长 |
| 5.2 | 错误上报 | `window.onerror` + Promise rejection 捕获 + 上报日志服务 |
| 5.3 | A/B 测试 | 同一场景多个视频变体按用户分流 |
---
## 六、安全
| # | 项目 | 说明 |
|---|------|------|
| 6.1 | JSON 注入防护 | 禁止 eval所有外部数据只做 JSON.parse |
| 6.2 | XSS 检查 | 确认没有 `v-html` 使用点 |
| 6.3 | 存档篡改防护 | 存档加 HMAC 签名校验防止本地修改成就 |
---
## 七、测试
| # | 项目 | 说明 |
|---|------|------|
| 7.1 | 单元测试 | Engine / StateManager / VideoManager 核心逻辑测试 |
| 7.2 | 剧情路径遍历 | 自动遍历所有 choices 分支验证无死路 |
| 7.3 | 跨浏览器测试 | Chrome / Firefox / Safari 兼容矩阵 |
| 7.4 | 性能回归 | Lighthouse + 自定义首屏/切换延迟基线 |
---
## 八、文档
| # | 项目 | 说明 |
|---|------|------|
| 8.1 | API 文档 | Engine 公开方法文档 |
| 8.2 | 场景 JSON 规范 | 完整字段参考手册 |
| 8.3 | 制作指南 | 如何从零制作一个交互式电影 |
| 8.4 | 部署指南 | 如何部署到服务器 / CDN / PWA |
---
## 优先级建议
| 优先级 | 内容 |
|:--:|------|
| **P0 立即可做** | JSON Schema 验证 (4.1)、场景分章节懒加载 (1.3)、错误处理 (2.1-2.5) |
| **P1 短期** | PWA 离线 (3.3)、视频编码规范脚本 (4.2)、单元测试 (7.1) |
| **P2 中期** | CDN 部署 (3.2)、分析 (5.1-5.2)、存档 HMAC (6.3) |
| **P3 长期** | A/B 测试 (5.3)、多环境配置 (3.5)、跨浏览器测试 (7.3) |

169
README.md
View File

@@ -1,142 +1,115 @@
# 交互式电影游戏引擎
基于 Vue 3 + TypeScript 的浏览器端交互式电影游戏引擎。
克隆项目 → 放视频 → 写 JSON → 打包上线。
## 快速开始
```bash
git clone <repo-url> mygame
cd mygame
npm install
npm run dev
```
浏览器打开 `http://localhost:5173/` 即可体验示例剧情
浏览器打开 `http://localhost:5173/` 预览示例游戏
打开 `http://localhost:5173/editor/` 使用可视化编辑器。
## 如何使用
## 制作你的游戏
### 1. 编写剧情 JSON
### 1. 放视频
`public/scenes/` 目录下创建 JSON 文件,定义你的场景和分支:
把你的视频文件放到 `public/videos/` 目录。推荐 MP4 (H.264)1280×7202-5Mbps。
### 2. 编写剧情 JSON
编辑 `public/scenes/demo.json`(或创建新 JSON 文件,修改 `src/App.vue` 中的加载路径)。
```json
{
"startScene": "scene_1",
"variables": {
"trust": 50,
"courage": 0
},
"startScene": "intro",
"variables": { "trust": 50 },
"scenes": {
"scene_1": {
"id": "scene_1",
"videoUrl": "/videos/scene_1.mp4",
"intro": {
"id": "intro",
"videoUrl": "/videos/intro.mp4",
"choices": [
{
"text": "打开左边的门",
"targetScene": "scene_2a",
"effects": [
{ "type": "add", "target": "courage", "value": 10 }
]
},
{
"text": "打开右边的门",
"targetScene": "scene_2b"
}
{ "text": "帮助他", "targetScene": "help_end" },
{ "text": "离开", "targetScene": "leave_end" }
]
},
"scene_2a": {
"id": "scene_2a",
"videoUrl": "/videos/scene_2a.mp4",
"nextScene": "ending"
},
"scene_2b": {
"id": "scene_2b",
"videoUrl": "/videos/scene_2b.mp4",
"choices": []
},
"ending": {
"id": "ending",
"videoUrl": "/videos/ending.mp4",
"choices": []
}
"help_end": { "id": "help_end", "videoUrl": "/videos/help.mp4", "choices": [] },
"leave_end": { "id": "leave_end", "videoUrl": "/videos/leave.mp4", "choices": [] }
}
}
```
**字段说明:**
这 15 行 JSON 就是一个完整可玩游戏。
| 字段 | 说明 |
|------|------|
| `startScene` | 游戏开始的场景 ID |
| `variables` | 全局变量初始值(如好感度、勇气值) |
| `scenes` | 所有场景节点key 为场景 ID |
| `videoUrl` | 视频文件路径,放在 `public/videos/` 下 |
| `choices` | 选项列表,为空或不存在时视为结局 |
| `nextScene` | 没有选项时的默认下一场景(自动跳转) |
| `effects` | 选择后的效果,类型: `set`/`add`/`toggleFlag` |
| `conditions` | 选项的显示条件(`==`, `!=`, `>`, `<`, `>=`, `<=`, `hasFlag` |
### 3. 完整字段参考
### 2. 放置视频文件
详见 [场景 JSON 规范文档](docs/SCENE_JSON_SPEC.md)。引擎支持:
将视频文件放入 `public/videos/` 目录,推荐 MP4 (H.264) 格式。
- 分支叙事 / 条件分支 / QTE 快速反应事件 / 循环等待
- 独立 BGM + Ducking / 跳过已看 + 倍速 / 章节选择
- 成就系统 / 结局画廊 / 章节回顾 / 字幕多语言
- 关键选择提示 / 键盘全导航 / 可访问性设置
- 图片/视频热点交互 / 全屏模式
生成测试视频(需要 ffmpeg
### 4. 预览和调试
```bash
ffmpeg -f lavfi -i "color=c=0x1a1a2e:s=1280x720:d=3" \
-vf "drawtext=text='章节标题':fontcolor=white:fontsize=32:x=(w-text_w)/2:y=(h-text_h)/2" \
public/videos/scene_1.mp4
npm run dev # Vite 实时预览,改代码自动刷新
```
### 3. 修改加载的剧情文件
### 5. 打包上线
`src/App.vue` 中修改 `loadGame()` 的路径:
```ts
await loadGame('/scenes/your_story.json')
```bash
npm run pack:html # Web 版 → release/mygame.zip上传 itch.io
npm run pack:mac # macOS → release/MyGame-darwin-*/
npm run pack:win # Windows → release/MyGame-win32-x64/
```
Web 版上传 itch.io 选 "HTML" 类型。桌面版为可运行文件夹,双击即用。
## 自定义 UI
引擎的 UI 组件在 `src/components/` 目录中,可自由修改 Vue 组件、CSS 样式。
引擎核心 `engine/` 目录也可修改。
## 目录结构
```
moviegame/
├── engine/ # 框架无关的核心引擎(不依赖 Vue
│ ├── core/
│ ├── Engine.ts # 主循环
│ ├── SceneManager.ts # 场景管理
│ ├── VideoManager.ts # 视频播放控制
│ └── StateManager.ts # 状态/变量/条件系统
│ └── types.ts # 类型定义
├── src/ # Vue 播放
│ ├── components/
│ │ ├── GamePlayer.vue # 视频播放器
│ │ └── ChoicePanel.vue # 选项面板
│ ├── composables/
│ │ └── useGameEngine.ts # 引擎 ↔ Vue 桥接
│ ├── stores/
│ │ └── gameStore.ts # Pinia 状态
│ ├── App.vue
│ └── main.ts
mygame/
├── engine/ # 核心引擎(可自由修改
├── src/ # Vue UI 组件(可自由修改)
│ ├── components/ # GamePlayer、ChoicePanel 等
│ ├── composables/ # 桥接引擎 ↔ UI
│ ├── stores/ # Pinia 状态
├── locales/ # 多语言翻译
│ └── App.vue # 入口组件
├── editor/ # 可视化剧情编辑
├── public/
│ ├── scenes/demo.json # 示例剧情
── videos/ # 视频资源
├── ROADMAP.md # 开发路线
│ ├── videos/ # 放你的视频
── audio/ # 放你的 BGM
│ ├── images/ # 放缩略图、热点
│ ├── subtitles/ # 放字幕 .vtt
│ └── scenes/
│ └── demo.json # 你的剧情定义 ← 主要编辑这个
├── electron/ # 桌面打包配置
├── docs/ # JSON 规范文档
├── scripts/ # 打包脚本
└── package.json
```
## 当前状态
## 视频制作建议
**已完成 (P0 MVP)** 单视频播放 + 选项分支 + 状态系统 + JSON 驱动
**下一步 (P1)** A/B 双缓冲无缝切换、条件分支、IndexedDB 存档系统
详见 [ROADMAP.md](ROADMAP.md)。
## 命令
```bash
npm run dev # 启动开发服务器
npm run build # 构建生产版本
npm run preview # 预览构建结果
```
## 使用提示
场景虽然支持图片但是图片场景容易触发一些bug,所以建议只使用视频场景这样可以避免遇到没有测试覆盖的bug也能简化引擎的逻辑。
| 参数 | 建议值 |
|------|--------|
| 格式 | MP4 (H.264) |
| 分辨率 | 1280×720 或 1920×1080 |
| 帧率 | 25fps 或 30fps |
| 码率 | 2-5 Mbps |
| 循环段 | 正文段 + 循环段合成为一个文件,用 `loopStart`/`loopEnd` 标记区间 |
| 背景音乐 | 不要在视频中嵌入 BGM用独立 .mp3 + `bgmUrl` 字段 |
| 字幕 | WebVTT 格式(.vtt时间轴精确到毫秒 |

View File

@@ -925,6 +925,77 @@ QTE 成功 / 到达隐藏结局 / 通关等"事件型"成就,通过在对应 e
- [x] `src/App.vue` — 使用 `MainMenu` 替代散装 `start-overlay` 按钮 + `game-end-overlay`;移除 60 行旧 CSS
- [x] 验证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 可执行文件。
**新策略:废弃 `moviegame-starter`。** 制作者直接 clone `moviegame` 完整源码,
放视频 + 写 JSON + 改 Vue 组件,最大定制权限。
**命令设计:**
```bash
npm run dev # Vite 实时预览(已有)
npm run pack:html # 打包 Web 版 → release/mygame.zip
npm run pack:mac # 打包 macOS → release/MyGame-darwin-arm64/
npm run pack:win # 打包 Windows → release/MyGame-win32-x64/
```
**实施方案:**
| 工具 | 技术 | 说明 |
|------|------|------|
| Web 打包 | `vite build` + zip | 产物 `release/mygame.zip`,上传 itch.io 选 HTML 类型 |
| 桌面打包 | Electron + `@electron/packager` | 一行 CLI 命令,零配置。不用 `electron-builder`(不需要安装向导/代码签名/自动更新这些重武器) |
**选 electron-packager 而非 electron-builder**
- `electron-packager`:打包成可直接运行的文件夹(`.app` / `.exe`),一行命令。**刚好够用。**
- `electron-builder`:打包成安装器(`.dmg` 安装向导 / NSIS Setup附带代码签名、自动更新。**对我们过度。**
**Electron 最小结构:**
```
electron/
├── main.js # 全屏窗口 + 加载 dist/index.html10 行)
└── package.json # electron + @electron/packager 依赖
```
```js
// electron/main.js
const { app, BrowserWindow } = require('electron')
app.whenReady().then(() => {
new BrowserWindow({ fullscreen: true, autoHideMenuBar: true }).loadFile('dist/index.html')
})
app.on('window-all-closed', () => app.quit())
```
**实现清单:**
- [x] `scripts/pack-html.mjs``vite build` + JSON 验证 + zip → `release/mygame.zip`
- [x] `electron/main.js` — Electron 主进程全屏窗口3 行)
- [x] `electron/package.json``electron` + `@electron/packager` 依赖
- [x] `package.json` — 新增 `pack:html`/`pack:mac`/`pack:win` scripts
- [x] `README.md` — 面向制作者重写
- [x] `public/` — 清理 demo 视频,只保留 1 个示例intro.mp4 + demo.json + intro.vtt
- [x] `.gitignore` — 视频/音频/图片目录忽略release/ 忽略
- [x] 删除 `moviegame-starter` 整个目录
- [x] 验证TypeScript + Vite build 通过
## 依赖清单
```json

419
docs/SCENE_JSON_SPEC.md Normal file
View File

@@ -0,0 +1,419 @@
# 场景 JSON 规范文档
电影游戏引擎的场景数据全部定义在 JSON 文件中。本文档是完整字段参考手册。
---
## 目录
1. [顶层结构 (GameData)](#1-顶层结构-gamedata)
2. [场景节点 (SceneNode)](#2-场景节点-scenenode)
3. [选项 (Choice)](#3-选项-choice)
4. [图片/视频热点 (Hotspot)](#4-图片视频热点-hotspot)
5. [快速反应事件 (QTEDefinition)](#5-快速反应事件-qtedefinition)
6. [条件和效果 (Condition & Effect)](#6-条件和效果-condition--effect)
7. [背景音乐 (BGM 字段)](#7-背景音乐-bgm-字段)
8. [章节 (ChapterInfo)](#8-章节-chapterinfo)
9. [成就 (AchievementDef)](#9-成就-achievementdef)
10. [结局 (EndingDef)](#10-结局-endingdef)
11. [完整示例](#11-完整示例)
12. [验证规则](#12-验证规则)
---
## 1. 顶层结构 (GameData)
```json
{
"startScene": "intro",
"variables": { "trust": 50, "courage": 0 },
"scenes": { ... },
"chapters": [ ... ],
"achievements": [ ... ],
"endings": [ ... ]
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `startScene` | string | ✅ | 游戏开始的场景 ID |
| `variables` | `Record<string, number>` | ✅ | 全局变量初始值(如好感度、勇气值等,制作者自定义) |
| `scenes` | `Record<string, SceneNode>` | ✅ | 所有场景节点key 为场景唯一 ID |
| `chapters` | ChapterInfo[] | 否 | 章节定义,用于章节选择功能 |
| `achievements` | AchievementDef[] | 否 | 成就定义,满足条件时自动解锁 |
| `endings` | EndingDef[] | 否 | 结局定义,用于结局画廊 |
---
## 2. 场景节点 (SceneNode)
```json
{
"id": "intro",
"type": "video",
"videoUrl": "/videos/intro.mp4",
"imageUrl": "/images/room.jpg",
"subtitleUrl": "/subtitles/intro.vtt",
"subtitles": { "zh": "/subtitles/intro_zh.vtt", "en": "/subtitles/intro_en.vtt" },
"choices": [ ... ],
"hotspots": [ ... ],
"qte": { ... },
"nextScene": "auto_next",
"onEnter": [ { "type": "set", "target": "visited_room", "value": 1 } ],
"loopStart": 8.0,
"loopEnd": 10.0,
"bgmUrl": "/audio/tense.mp3",
"bgmVolume": 0.8,
"bgmCrossFade": 2.0,
"bgmDuckLevel": 0.35,
"bgmDuckFade": 0.5,
"videoMuted": false,
"skippable": true
}
```
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `id` | string | — | **唯一标识**。任意字符串,建议用有意义的英文名 |
| `type` | `"video"` \| `"image"` | `"video"` | `"image"` 时显示静态图片和热点,不播放视频 |
| `videoUrl` | string | `""` | 视频文件路径,推荐 `/videos/xxx.mp4` |
| `imageUrl` | string | — | 图片场景的图片路径 |
| `subtitleUrl` | string | — | 字幕 VTT 文件路径(单语言,向后兼容) |
| `subtitles` | `Record<string, string>` | — | 多语言字幕映射,如 `{"zh": "...", "en": "..."}`。优先级高于 `subtitleUrl` |
| `choices` | Choice[] | — | 选项列表。为空或不存在时场景播放完毕后自动结束(或走 `nextScene` |
| `hotspots` | Hotspot[] | — | 可点击热区。视频热点按 `showAt`/`hideAt` 时间轴显隐 |
| `qte` | QTEDefinition | — | QTE 事件定义 |
| `nextScene` | string | — | 无选项时的默认下一场景 ID自动跳转 |
| `onEnter` | Effect[] | — | 进入场景时执行的效果(如设置变量) |
| `loopStart` | number | — | 循环起点(秒)。到达后弹出选项并开始循环。需配合 `loopEnd` |
| `loopEnd` | number | — | 循环终点(秒)。播到时 seek 回 `loopStart` |
| `bgmUrl` | string | — | 背景音乐 MP3 路径。null/省略时静默 |
| `bgmVolume` | number | `0.8` | BGM 音量0~1 |
| `bgmCrossFade` | number | `2.0` | BGM 切换时的交叉淡化时长(秒) |
| `bgmDuckLevel` | number | `0.35` | QTE/选项出现时 BGM 降低到的百分比 |
| `bgmDuckFade` | number | `0.5` | Ducking 的渐变时长(秒) |
| `videoMuted` | boolean | `false` | 是否静音视频自带音轨 |
| `skippable` | boolean | `true` | `false` 时禁止跳过此场景(即使已看过) |
---
## 3. 选项 (Choice)
```json
{
"text": "帮助陌生人",
"textKey": "scene.intro.choice.help",
"prompt": "你的善意将被记住",
"targetScene": "help_ending",
"conditions": [
{ "variable": "trust", "op": ">=", "value": 50 }
],
"effects": [
{ "type": "add", "target": "trust", "value": 20 }
],
"timeLimit": 10
}
```
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `text` | string | — | 选项显示文字(默认语言) |
| `textKey` | string | — | 多语言 key。有值时用翻译文件查文字否则用 `text` |
| `prompt` | string | — | 选后浮现提示文字Telltale 式 "某人会记住这件事")。有值时按钮前置金色标识 |
| `targetScene` | string | — | 点击后跳转的目标场景 ID |
| `conditions` | Condition[] | — | 显示条件。所有条件都满足时选项才显示 |
| `effects` | Effect[] | — | 选择后的效果(修改变量值等) |
| `timeLimit` | number | — | 限时选择。0 或不设表示不限时 |
---
## 4. 图片/视频热点 (Hotspot)
```json
{
"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" } ],
"timeLimit": 10
}
```
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `id` | string | — | 热区唯一标识 |
| `label` | string | — | 鼠标悬停时显示的提示文字 |
| `targetScene` | string | — | 点击后跳转的目标场景 ID |
| `x` | number | — | 热区左上角 X 坐标,**相对比例0~1**,自适应屏幕 |
| `y` | number | — | 热区左上角 Y 坐标,**相对比例0~1** |
| `width` | number | — | 热区宽度,**相对比例0~1** |
| `height` | number | — | 热区高度,**相对比例0~1** |
| `showAt` | number | — | 视频模式下的出现时间(秒)。未设时始终显示 |
| `hideAt` | number | — | 视频模式下的消失时间(秒)。未设时始终显示 |
| `conditions` | Condition[] | — | 显示条件 |
| `effects` | Effect[] | — | 点击后的效果 |
| `timeLimit` | number | — | 限时热区(秒)。超时后热区消失,不触发跳转 |
---
## 5. 快速反应事件 (QTEDefinition)
```json
{
"triggerTime": 2.5,
"prompt": "躲避飞来的石块!",
"keys": ["ArrowLeft", "ArrowRight", "a", "d"],
"timeLimit": 3.0,
"successScene": "qte_win",
"failScene": "qte_lose",
"effects": {
"success": [ { "type": "add", "target": "courage", "value": 10 } ],
"fail": [ { "type": "add", "target": "health", "value": -20 } ]
}
}
```
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `triggerTime` | number | — | QTE 触发时间(视频播到第几秒时触发) |
| `prompt` | string | — | QTE 提示文字 |
| `keys` | string[] | — | 需按下的键,大小写不敏感。如 `["Space"]` |
| `timeLimit` | number | — | 限时(秒) |
| `successScene` | string | — | 成功时跳转的场景 ID |
| `failScene` | string | — | 失败/超时时跳转的场景 ID |
| `effects` | object | — | 成功/失败各自的效果(`effects.success``effects.fail` |
---
## 6. 条件和效果 (Condition & Effect)
### Condition条件
```json
{ "variable": "trust", "op": ">=", "value": 80 }
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `variable` | string | 变量名 |
| `op` | string | 比较操作符:`>` / `<` / `>=` / `<=` / `==` / `!=` |
| `value` | number | 比较值 |
> **注意:** 条件中变量的值都是 number 类型。布尔值用 `== 1` 表示 true`== 0` 表示 false。
### Effect效果
```json
{ "type": "set", "target": "trust", "value": 80 }
{ "type": "add", "target": "courage", "value": 10 }
```
| `type` | 说明 |
|--------|------|
| `set` | 设置变量为目标值 |
| `add` | 变量增加指定值(负数为减) |
| 字段 | 类型 | 说明 |
|------|------|------|
| `target` | string | 变量名(与 Condition 的 `variable` 对应) |
| `value` | number | 值 |
---
## 7. 背景音乐 (BGM 字段)
BGM 字段定义在 SceneNode 中,由独立 AudioSystem 驱动,不受视频循环/切换影响。
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `bgmUrl` | string | — | BGM MP3 路径。**两个相邻场景的 `bgmUrl` 相同时 → BGM 不中断继续播放。** 不同时 → 交叉淡化切换。null/省略时 → fade out |
| `bgmVolume` | number | `0.8` | BGM 音量0~1 |
| `bgmCrossFade` | number | `2.0` | 交叉淡化时长(秒) |
| `bgmDuckLevel` | number | `0.35` | QTE 触发 / 选项出现 / 热点出现时 BGM 自动降到 `bgmVolume × bgmDuckLevel` |
| `bgmDuckFade` | number | `0.5` | Ducking 渐变时长(秒) |
| `videoMuted` | boolean | `false` | 是否静音视频自带音轨。引擎不自动设置,需制作者手动指定 |
**制作建议:** BGM 使用独立的 .mp3 文件,视频导出时不要嵌入背景音乐。这样 BGM 在场景切换和画面循环时保持连贯。
---
## 8. 章节 (ChapterInfo)
```json
{
"id": "ch1",
"label": "第一章:相遇",
"startScene": "intro",
"thumbnail": "/images/ch1.jpg",
"defaultVariables": { "trust": 50, "courage": 0 }
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `id` | string | ✅ | 章节唯一标识 |
| `label` | string | ✅ | 章节显示名称(支持多语言) |
| `startScene` | string | ✅ | 章节起始场景 ID。**玩家到达此场景时 → 章节自动解锁** |
| `thumbnail` | string | 否 | 章节缩略图路径320×180 推荐) |
| `defaultVariables` | `Record<string, number>` | 否 | 从章节入口跳转时套用的变量初始值。未设时 fallback 到全局 `variables` |
---
## 9. 成就 (AchievementDef)
```json
{
"id": "helper",
"title": "善良的人",
"description": "选择帮助陌生人",
"icon": "/images/ach_helper.png",
"hidden": false,
"condition": { "variable": "trust", "op": ">=", "value": 70 }
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `id` | string | ✅ | 成就唯一标识 |
| `title` | string | ✅ | 成就标题 |
| `description` | string | ✅ | 成就描述 |
| `icon` | string | 否 | 成就图标路径(可选) |
| `hidden` | boolean | 否 | `true` 时未解锁不显示标题和描述(显示 ??? |
| `condition` | Condition | ✅ | 解锁条件。变量满足时自动解锁并弹出 toast |
---
## 10. 结局 (EndingDef)
```json
{
"id": "help_end",
"label": "伸出援手",
"sceneId": "help_ending",
"thumbnail": "/images/end_help.jpg"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `id` | string | ✅ | 结局唯一标识 |
| `label` | string | ✅ | 结局显示名称 |
| `sceneId` | string | ✅ | 结局场景 ID。**玩家到达此场景时 → 结局自动标记已解锁** |
| `thumbnail` | string | 否 | 结局缩略图路径320×180 推荐) |
---
## 11. 完整示例
以下示例覆盖所有功能:
```json
{
"startScene": "intro",
"variables": { "trust": 50, "courage": 0, "investigation": 0 },
"scenes": {
"intro": {
"id": "intro",
"videoUrl": "/videos/intro.mp4",
"subtitles": { "zh": "/subtitles/intro_zh.vtt", "en": "/subtitles/intro_en.vtt" },
"bgmUrl": "/audio/calm.mp3",
"bgmVolume": 0.6,
"loopStart": 5.0,
"loopEnd": 8.0,
"choices": [
{
"text": "帮助陌生人",
"textKey": "scene.intro.choice.help",
"prompt": "你的善意将被记住",
"targetScene": "help_ending",
"effects": [{ "type": "add", "target": "trust", "value": 20 }]
},
{
"text": "调查房间",
"textKey": "scene.intro.choice.investigate",
"targetScene": "crime_scene"
}
]
},
"crime_scene": {
"id": "crime_scene",
"type": "image",
"imageUrl": "/images/crime_scene.jpg",
"hotspots": [
{
"id": "hs_desk",
"label": "查看书桌",
"targetScene": "desk_detail",
"x": 0.15, "y": 0.30, "width": 0.25, "height": 0.35,
"effects": [{ "type": "add", "target": "investigation", "value": 1 }]
}
],
"choices": [
{ "text": "离开房间", "targetScene": "corridor" }
]
},
"corridor": {
"id": "corridor",
"videoUrl": "/videos/corridor.mp4",
"bgmUrl": "/audio/tense.mp3",
"bgmVolume": 0.7,
"bgmCrossFade": 1.5,
"qte": {
"triggerTime": 2.0,
"prompt": "躲避飞来的石块!",
"keys": ["Space"],
"timeLimit": 3.0,
"successScene": "qte_win",
"failScene": "qte_lose",
"effects": {
"success": [{ "type": "add", "target": "courage", "value": 10 }],
"fail": [{ "type": "add", "target": "trust", "value": -10 }]
}
}
},
"help_ending": {
"id": "help_ending",
"videoUrl": "/videos/help.mp4",
"bgmUrl": "/audio/calm.mp3",
"bgmVolume": 0.6,
"onEnter": [{ "type": "set", "target": "completed_game", "value": 1 }],
"choices": []
}
},
"chapters": [
{
"id": "ch1", "label": "第一章", "startScene": "intro",
"thumbnail": "/images/ch1.jpg",
"defaultVariables": { "trust": 50, "courage": 0, "investigation": 0 }
}
],
"achievements": [
{
"id": "helper", "title": "善良的人", "description": "选择帮助陌生人",
"hidden": false,
"condition": { "variable": "trust", "op": ">=", "value": 70 }
}
],
"endings": [
{ "id": "help_end", "label": "伸出援手", "sceneId": "help_ending", "thumbnail": "/images/end_help.jpg" }
]
}
```
---
## 12. 验证规则
| 规则 | 说明 |
|------|------|
| **ID 唯一性** | `scenes` 的 key、`Choice.targetScene``Hotspot.targetScene``QTEDefinition.successScene`/`failScene``ChapterInfo.startScene``EndingDef.sceneId` 必须引用存在的场景 ID |
| **循环引用** | 场景 A 的 targetScene 指向 A 自身 → 允许(重玩同一场景) |
| **死路检测** | 有 `conditions` 的 choice 如果条件永远无法满足 → 该分支永久不可达(建议制作者验证) |
| **videoUrl 与 imageUrl** | `type: "image"` 时可省略 `videoUrl``type: "video"`(默认)时必须提供 `videoUrl` |
| **loopStart / loopEnd** | 两者必须同时出现或同时不出现。`loopStart < loopEnd` |
| **JSON 格式** | 严格 JSON不支持注释、尾逗号。用编辑器导出可保证格式正确 |

13
electron/main.js Normal file
View File

@@ -0,0 +1,13 @@
const { app, BrowserWindow } = require('electron')
const path = require('path')
app.whenReady().then(() => {
const win = new BrowserWindow({
fullscreen: true,
autoHideMenuBar: true,
webPreferences: { nodeIntegration: false, contextIsolation: true }
})
win.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
})
app.on('window-all-closed', () => app.quit())

13
electron/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "mygame-electron",
"private": true,
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"devDependencies": {
"electron": "^30.0.0",
"@electron/packager": "^18.0.0"
}
}

View File

@@ -6,14 +6,15 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"pack:html": "node scripts/pack-html.mjs",
"pack:mac": "vite build && cd electron && npx @electron/packager . MyGame --platform=darwin --arch=arm64,x64 --out ../release",
"pack:win": "vite build && cd electron && npx @electron/packager . MyGame --platform=win32 --arch=x64 --out ../release"
},
"dependencies": {
"@types/dagre": "^0.7.54",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"dagre": "^0.8.5",
"dexie": "^4.4.3",
"pinia": "^2.1.0",
"vue": "^3.4.0"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,7 +0,0 @@
WEBVTT
00:00.000 --> 00:02.000
You wake up in a strange room
00:02.500 --> 00:03.000
Two doors stand before you. You must choose.

View File

@@ -1,4 +0,0 @@
WEBVTT
00:00.000 --> 00:05.000
你走进了一个凌乱的房间,仔细观察四周...

View File

@@ -1,7 +0,0 @@
WEBVTT
00:00.000 --> 00:02.500
你走进了发光的门,来到一个明亮的大厅
00:02.500 --> 00:03.000
一位陌生人向你伸出了手

View File

@@ -1,7 +0,0 @@
WEBVTT
00:00.000 --> 00:02.500
You walk through the glowing door into a bright hall
00:02.500 --> 00:03.000
A stranger extends their hand toward you

View File

@@ -1,4 +0,0 @@
WEBVTT
00:00.000 --> 00:02.500
你选择留在原地,时间缓缓流逝...

View File

@@ -1,4 +0,0 @@
WEBVTT
00:00.000 --> 00:03.000
You choose to stay where you are. Time passes slowly...

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

36
scripts/pack-html.mjs Normal file
View File

@@ -0,0 +1,36 @@
// Web 打包脚本vite build → 验证 JSON → zip → release/mygame.zip
import { execSync } from 'child_process'
import { existsSync, readFileSync, mkdirSync, rmSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = join(__dirname, '..')
const dist = join(root, 'dist')
const release = join(root, 'release')
const jsonPath = join(dist, 'scenes', 'my_story.json')
console.log('🔨 Building...')
execSync('npx vite build', { cwd: root, stdio: 'inherit' })
if (existsSync(jsonPath)) {
try {
JSON.parse(readFileSync(jsonPath, 'utf-8'))
console.log('✅ JSON 格式验证通过')
} catch (err) {
console.error('❌ JSON 解析失败:', err.message)
process.exit(1)
}
} else {
console.log('⚠️ 未找到 my_story.json跳过验证')
}
rmSync(release, { recursive: true, force: true })
mkdirSync(release, { recursive: true })
const zipName = 'mygame'
console.log('📦 打包中...')
execSync(`zip -r ${join(release, zipName)}.zip .`, { cwd: dist, stdio: 'inherit' })
console.log(`\n✅ 打包完成 → ${join(release, zipName)}.zip`)
console.log('📤 将此 zip 上传到 itch.io → 选择 "HTML" 类型 → 发布')