feat: electron packaging, build scripts, gitignore and docs update
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ npm-debug.log*
|
|||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
video
|
||||||
|
release
|
||||||
193
README.md
193
README.md
@@ -1,142 +1,153 @@
|
|||||||
# 交互式电影游戏引擎
|
# 交互式电影游戏引擎
|
||||||
|
|
||||||
基于 Vue 3 + TypeScript 的浏览器端交互式电影游戏引擎。
|
基于 Vue 3 + TypeScript 的浏览器端交互式电影游戏引擎。
|
||||||
|
**零代码门槛**:你只需要会剪视频和写 JSON,不需要前端知识。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone <repo-url> mygame
|
||||||
|
cd mygame
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
浏览器打开 `http://localhost:5173/` 即可体验示例剧情。
|
打开 `http://localhost:5173/`,你会看到示例剧情。
|
||||||
|
|
||||||
## 如何使用
|
## 制作你的游戏
|
||||||
|
|
||||||
### 1. 编写剧情 JSON
|
### 1. 准备素材
|
||||||
|
|
||||||
在 `public/scenes/` 目录下创建 JSON 文件,定义你的场景和分支:
|
| 素材 | 目录 | 格式要求 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 视频文件 | `public/videos/` | MP4 (H.264),1280×720,30fps,2-5Mbps |
|
||||||
|
| 背景音乐 | `public/audio/` | MP3 |
|
||||||
|
| 缩略图 | `public/images/` | JPG/PNG,320×180 |
|
||||||
|
| 字幕 | `public/subtitles/` | WebVTT (.vtt) |
|
||||||
|
|
||||||
|
> `public/videos/` 已在 `.gitignore` 中,视频文件不需要提交到 Git。
|
||||||
|
|
||||||
|
### 2. 编写剧情 JSON
|
||||||
|
|
||||||
|
编辑 `public/scenes/demo.json`,定义你的场景和分支。**最小可玩示例只需 20 行 JSON**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"startScene": "scene_1",
|
"startScene": "intro",
|
||||||
"variables": {
|
"variables": { "trust": 50 },
|
||||||
"trust": 50,
|
|
||||||
"courage": 0
|
|
||||||
},
|
|
||||||
"scenes": {
|
"scenes": {
|
||||||
"scene_1": {
|
"intro": {
|
||||||
"id": "scene_1",
|
"id": "intro",
|
||||||
"videoUrl": "/videos/scene_1.mp4",
|
"videoUrl": "/videos/intro.mp4",
|
||||||
"choices": [
|
"choices": [
|
||||||
{
|
{ "text": "帮助他", "targetScene": "help_end" },
|
||||||
"text": "打开左边的门",
|
{ "text": "离开", "targetScene": "leave_end" }
|
||||||
"targetScene": "scene_2a",
|
|
||||||
"effects": [
|
|
||||||
{ "type": "add", "target": "courage", "value": 10 }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
"help_end": {
|
||||||
"text": "打开右边的门",
|
"id": "help_end",
|
||||||
"targetScene": "scene_2b"
|
"videoUrl": "/videos/help.mp4",
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"scene_2a": {
|
|
||||||
"id": "scene_2a",
|
|
||||||
"videoUrl": "/videos/scene_2a.mp4",
|
|
||||||
"nextScene": "ending"
|
|
||||||
},
|
|
||||||
"scene_2b": {
|
|
||||||
"id": "scene_2b",
|
|
||||||
"videoUrl": "/videos/scene_2b.mp4",
|
|
||||||
"choices": []
|
"choices": []
|
||||||
},
|
},
|
||||||
"ending": {
|
"leave_end": {
|
||||||
"id": "ending",
|
"id": "leave_end",
|
||||||
"videoUrl": "/videos/ending.mp4",
|
"videoUrl": "/videos/leave.mp4",
|
||||||
"choices": []
|
"choices": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**字段说明:**
|
完整字段参考见 **[docs/SCENE_JSON_SPEC.md](docs/SCENE_JSON_SPEC.md)**。
|
||||||
|
|
||||||
| 字段 | 说明 |
|
### 3. 实时预览
|
||||||
|------|------|
|
|
||||||
| `startScene` | 游戏开始的场景 ID |
|
|
||||||
| `variables` | 全局变量初始值(如好感度、勇气值) |
|
|
||||||
| `scenes` | 所有场景节点,key 为场景 ID |
|
|
||||||
| `videoUrl` | 视频文件路径,放在 `public/videos/` 下 |
|
|
||||||
| `choices` | 选项列表,为空或不存在时视为结局 |
|
|
||||||
| `nextScene` | 没有选项时的默认下一场景(自动跳转) |
|
|
||||||
| `effects` | 选择后的效果,类型: `set`/`add`/`toggleFlag` |
|
|
||||||
| `conditions` | 选项的显示条件(`==`, `!=`, `>`, `<`, `>=`, `<=`, `hasFlag`) |
|
|
||||||
|
|
||||||
### 2. 放置视频文件
|
|
||||||
|
|
||||||
将视频文件放入 `public/videos/` 目录,推荐 MP4 (H.264) 格式。
|
|
||||||
|
|
||||||
生成测试视频(需要 ffmpeg):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ffmpeg -f lavfi -i "color=c=0x1a1a2e:s=1280x720:d=3" \
|
npm run dev
|
||||||
-vf "drawtext=text='章节标题':fontcolor=white:fontsize=32:x=(w-text_w)/2:y=(h-text_h)/2" \
|
|
||||||
public/videos/scene_1.mp4
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 修改加载的剧情文件
|
Vite 会启动热重载服务器,修改 JSON 或视频后自动刷新。
|
||||||
|
|
||||||
在 `src/App.vue` 中修改 `loadGame()` 的路径:
|
### 4. 可视化编辑剧情
|
||||||
|
|
||||||
```ts
|
浏览器打开 `http://localhost:5173/editor/`,可以用节点图编辑器拖拽编辑场景分支。
|
||||||
await loadGame('/scenes/your_story.json')
|
|
||||||
|
## 打包发布
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack:html # Web 版 → release/mygame.zip → 上传 itch.io / Netlify
|
||||||
|
npm run pack:mac # macOS → release/MyGame-darwin-arm64/
|
||||||
|
npm run pack:win # Windows → release/MyGame-win32-x64/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
打包前会自动验证 JSON 合法性。
|
||||||
|
|
||||||
|
## 引擎功能
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 视频分支播放 | A/B 双缓冲无缝切换,300ms 交叉淡化 |
|
||||||
|
| 选择系统 | 限时选择、条件分支(根据变量显示/隐藏选项) |
|
||||||
|
| QTE 快速反应事件 | 视频中插入限时按键挑战,成功/失败跳转不同场景 |
|
||||||
|
| 图片/视频热点 | 点击画面区域触发分支,视频热点按时间轴显隐 |
|
||||||
|
| 循环等待 | 视频结束后自动循环指定片段,保持画面动态 |
|
||||||
|
| 独立 BGM | 背景音乐独立驱动,场景切换时交叉淡化,不受画面循环影响 |
|
||||||
|
| BGM Ducking | 选择/QTE/热点出现时 BGM 自动降低音量 |
|
||||||
|
| 字幕系统 | WebVTT 解析,多语言字幕切换 |
|
||||||
|
| 章节系统 | 分章节管理剧情,到达即解锁,可跳转 |
|
||||||
|
| 成就系统 | 变量满足条件时自动解锁,底部弹窗提示 |
|
||||||
|
| 结局画廊 | 所有结局缩略图展示,已解锁/未解锁状态 |
|
||||||
|
| 章节回顾 | 每章完成度百分比 + 未解锁分支条件提示 |
|
||||||
|
| 关键选择提示 | 重要选项前置金色标识 + 选后浮现提示文字 |
|
||||||
|
| 跳过已看 + 倍速 | 已看场景可跳过,1x/2x/4x 倍速播放 |
|
||||||
|
| 全屏模式 | 一键全屏沉浸式浏览器体验 |
|
||||||
|
| 键盘导航 | 方向键选选项,Esc 菜单,Space 暂停 |
|
||||||
|
| 多语言 i18n | UI + 字幕支持中英文切换 |
|
||||||
|
| 可访问性 | 字幕字号/背景、QTE 时限放宽/按键简化、防误触延迟 |
|
||||||
|
| 存档系统 | IndexedDB 多槽位,跨会话持久化 |
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
moviegame/
|
mygame/
|
||||||
├── engine/ # 框架无关的核心引擎(不依赖 Vue)
|
├── engine/ # 引擎核心(纯 TS,不依赖 Vue)
|
||||||
│ ├── core/
|
│ ├── core/ # Engine / VideoManager / StateManager / SceneManager
|
||||||
│ │ ├── Engine.ts # 主循环
|
│ ├── systems/ # QTE / Choice / Audio / Achievement / Save
|
||||||
│ │ ├── SceneManager.ts # 场景管理
|
|
||||||
│ │ ├── VideoManager.ts # 视频播放控制
|
|
||||||
│ │ └── StateManager.ts # 状态/变量/条件系统
|
|
||||||
│ └── types.ts # 类型定义
|
│ └── types.ts # 类型定义
|
||||||
├── src/ # Vue 播放器
|
├── src/ # Vue UI 层
|
||||||
│ ├── components/
|
│ ├── components/ # 所有界面组件
|
||||||
│ │ ├── GamePlayer.vue # 视频播放器
|
│ ├── composables/ # 引擎 ↔ UI 桥接
|
||||||
│ │ └── ChoicePanel.vue # 选项面板
|
│ ├── stores/ # Pinia 状态管理
|
||||||
│ ├── composables/
|
│ └── locales/ # 翻译文件(zh.json / en.json)
|
||||||
│ │ └── useGameEngine.ts # 引擎 ↔ Vue 桥接
|
├── editor/ # 可视化剧情编辑器
|
||||||
│ ├── stores/
|
├── electron/ # 桌面应用打包(Electron)
|
||||||
│ │ └── gameStore.ts # Pinia 状态
|
├── public/ # 你的素材
|
||||||
│ ├── App.vue
|
│ ├── videos/ # 视频文件(.mp4)
|
||||||
│ └── main.ts
|
│ ├── audio/ # 背景音乐(.mp3)
|
||||||
├── public/
|
│ ├── images/ # 缩略图
|
||||||
│ ├── scenes/demo.json # 示例剧情
|
│ ├── subtitles/ # 字幕(.vtt)
|
||||||
│ └── videos/ # 视频资源
|
│ └── scenes/demo.json # 剧情定义
|
||||||
├── ROADMAP.md # 开发路线图
|
├── docs/ # 文档
|
||||||
└── package.json
|
│ └── SCENE_JSON_SPEC.md # JSON 完整字段参考
|
||||||
|
├── scripts/ # 打包脚本
|
||||||
|
└── ROADMAP.md # 开发路线图
|
||||||
```
|
```
|
||||||
|
|
||||||
## 当前状态
|
## 视频制作建议
|
||||||
|
|
||||||
**已完成 (P0 MVP):** 单视频播放 + 选项分支 + 状态系统 + JSON 驱动
|
| 参数 | 建议值 |
|
||||||
|
|------|--------|
|
||||||
|
| 视频不包含 BGM | 背景音乐用独立 .mp3 文件 + `bgmUrl` 字段,画面循环时 BGM 不中断 |
|
||||||
|
| 循环片段 | 正文段 + 循环段合成为一个文件,用 `loopStart`/`loopEnd` 标记 |
|
||||||
|
| 字幕 | WebVTT 格式,时间轴精确到毫秒 |
|
||||||
|
|
||||||
**下一步 (P1):** A/B 双缓冲无缝切换、条件分支、IndexedDB 存档系统
|
## 命令参考
|
||||||
|
|
||||||
详见 [ROADMAP.md](ROADMAP.md)。
|
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # 启动开发服务器
|
npm run dev # 启动开发服务器(实时预览)
|
||||||
npm run build # 构建生产版本
|
npm run build # 构建生产版本
|
||||||
npm run preview # 预览构建结果
|
npm run preview # 预览构建结果
|
||||||
|
npm run pack:html # 打包 Web 版
|
||||||
|
npm run pack:mac # 打包 macOS 桌面应用
|
||||||
|
npm run pack:win # 打包 Windows 桌面应用
|
||||||
```
|
```
|
||||||
|
|
||||||
## 使用提示
|
|
||||||
场景虽然支持图片,但是图片场景容易触发一些bug,所以建议只使用视频场景,这样可以避免遇到没有测试覆盖的bug,也能简化引擎的逻辑。
|
|
||||||
78
ROADMAP.md
78
ROADMAP.md
@@ -925,6 +925,84 @@ QTE 成功 / 到达隐藏结局 / 通关等"事件型"成就,通过在对应 e
|
|||||||
- [x] `src/App.vue` — 使用 `MainMenu` 替代散装 `start-overlay` 按钮 + `game-end-overlay`;移除 60 行旧 CSS
|
- [x] `src/App.vue` — 使用 `MainMenu` 替代散装 `start-overlay` 按钮 + `game-end-overlay`;移除 60 行旧 CSS
|
||||||
- [x] 验证:TypeScript + Vite build 通过
|
- [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 可执行应用。
|
||||||
|
|
||||||
|
**命令设计:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 依赖
|
||||||
|
```
|
||||||
|
|
||||||
|
核心逻辑:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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())
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
**实现清单:**
|
||||||
|
|
||||||
|
- [x] `scripts/pack-html.cjs` — `vite build` + JSON 验证 + 复制 public 资源 + zip 打包
|
||||||
|
- [x] `electron/main.js` — Electron 主进程,全屏窗口 + 加载 dist
|
||||||
|
- [x] `electron/package.json` — `electron` + `@electron/packager` 依赖 + pack scripts
|
||||||
|
- [x] `package.json` — 新增 `pack:html`/`pack:mac`/`pack:win` scripts
|
||||||
|
- [x] `README.md` — 面向制作者重写入门指南
|
||||||
|
- [x] `public/videos/` — 只保留 1 个示例视频
|
||||||
|
- [x] 删除 `moviegame-starter` 目录
|
||||||
|
- [x] 验证:`pack:html` 生成 release/mygame.zip
|
||||||
|
|
||||||
## 依赖清单
|
## 依赖清单
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
13
electron/main.js
Normal file
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 }
|
||||||
|
})
|
||||||
|
win.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => app.quit())
|
||||||
14
electron/package.json
Normal file
14
electron/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "mygame-electron",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"pack:mac": "npx @electron/packager . MyGame --platform=mas --arch=arm64,x64 --out=../release --overwrite",
|
||||||
|
"pack:win": "npx @electron/packager . MyGame --platform=win32 --arch=x64 --out=../release --overwrite"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^30.0.0",
|
||||||
|
"@electron/packager": "^18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"pack:html": "node scripts/pack-html.cjs",
|
||||||
|
"pack:mac": "npm run build && cd electron && npm run pack:mac",
|
||||||
|
"pack:win": "npm run build && cd electron && npm run pack:win"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/dagre": "^0.7.54",
|
"@types/dagre": "^0.7.54",
|
||||||
|
|||||||
45
scripts/pack-html.cjs
Normal file
45
scripts/pack-html.cjs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const { execSync } = require('child_process')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const root = path.join(__dirname, '..')
|
||||||
|
const dist = path.join(root, 'dist')
|
||||||
|
const release = path.join(root, 'release')
|
||||||
|
const jsonPath = path.join(root, 'public', 'scenes', 'demo.json')
|
||||||
|
|
||||||
|
// 1. Verify JSON exists and is valid
|
||||||
|
if (!fs.existsSync(jsonPath)) {
|
||||||
|
console.error('❌ public/scenes/demo.json not found')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
||||||
|
console.log('✅ JSON valid')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ JSON parse error:', e.message)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build
|
||||||
|
console.log('🔨 Building...')
|
||||||
|
execSync('npx vite build', { cwd: root, stdio: 'inherit' })
|
||||||
|
|
||||||
|
// 3. Copy public assets into dist
|
||||||
|
console.log('📁 Copying assets...')
|
||||||
|
;['videos', 'audio', 'images', 'scenes', 'subtitles'].forEach((dir) => {
|
||||||
|
const src = path.join(root, 'public', dir)
|
||||||
|
const dest = path.join(dist, dir)
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
execSync(`cp -r "${src}" "${dest}"`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Zip
|
||||||
|
console.log('📦 Creating zip...')
|
||||||
|
fs.rmSync(release, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(release, { recursive: true })
|
||||||
|
|
||||||
|
const outName = 'mygame'
|
||||||
|
execSync(`cd "${dist}" && zip -r "${path.join(release, outName)}.zip" .`)
|
||||||
|
console.log(`✅ Packaged: release/${outName}.zip`)
|
||||||
Reference in New Issue
Block a user