feat: electron desktop packaging, CDN asset migration, production docs, scene JSON spec
13
.gitignore
vendored
@@ -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
@@ -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 / hotspots,BFS 遍历)
|
||||
- 章节起始场景(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">`
|
||||
|
||||
### 编辑器拆分
|
||||
|
||||
编辑器同理:编辑器 UI(Vue 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
@@ -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
@@ -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×720,2-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),时间轴精确到毫秒 |
|
||||
|
||||
71
ROADMAP.md
@@ -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.html(10 行)
|
||||
└── 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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -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.
|
||||
@@ -1,4 +0,0 @@
|
||||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:05.000
|
||||
你走进了一个凌乱的房间,仔细观察四周...
|
||||
@@ -1,7 +0,0 @@
|
||||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:02.500
|
||||
你走进了发光的门,来到一个明亮的大厅
|
||||
|
||||
00:02.500 --> 00:03.000
|
||||
一位陌生人向你伸出了手
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:02.500
|
||||
你选择留在原地,时间缓缓流逝...
|
||||
@@ -1,4 +0,0 @@
|
||||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:03.000
|
||||
You choose to stay where you are. Time passes slowly...
|
||||
36
scripts/pack-html.mjs
Normal 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" 类型 → 发布')
|
||||