From 25d73f5443339e88e0ac95c0fb6a630daa0e9498 Mon Sep 17 00:00:00 2001 From: cocos02 Date: Tue, 9 Jun 2026 23:53:32 +0800 Subject: [PATCH] feat: electron packaging, build scripts, gitignore and docs update --- .gitignore | 2 + README.md | 197 ++++++++++++++++++++++-------------------- ROADMAP.md | 78 +++++++++++++++++ electron/main.js | 13 +++ electron/package.json | 14 +++ package.json | 5 +- scripts/pack-html.cjs | 45 ++++++++++ 7 files changed, 260 insertions(+), 94 deletions(-) create mode 100644 electron/main.js create mode 100644 electron/package.json create mode 100644 scripts/pack-html.cjs diff --git a/.gitignore b/.gitignore index 000003a..ffbbab6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ npm-debug.log* # TypeScript *.tsbuildinfo +video +release \ No newline at end of file diff --git a/README.md b/README.md index e0dc1af..5572888 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,153 @@ # 交互式电影游戏引擎 基于 Vue 3 + TypeScript 的浏览器端交互式电影游戏引擎。 +**零代码门槛**:你只需要会剪视频和写 JSON,不需要前端知识。 ## 快速开始 ```bash +git clone mygame +cd mygame npm install 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 { - "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", + "help_end": { + "id": "help_end", + "videoUrl": "/videos/help.mp4", "choices": [] }, - "ending": { - "id": "ending", - "videoUrl": "/videos/ending.mp4", + "leave_end": { + "id": "leave_end", + "videoUrl": "/videos/leave.mp4", "choices": [] } } } ``` -**字段说明:** +完整字段参考见 **[docs/SCENE_JSON_SPEC.md](docs/SCENE_JSON_SPEC.md)**。 -| 字段 | 说明 | -|------|------| -| `startScene` | 游戏开始的场景 ID | -| `variables` | 全局变量初始值(如好感度、勇气值) | -| `scenes` | 所有场景节点,key 为场景 ID | -| `videoUrl` | 视频文件路径,放在 `public/videos/` 下 | -| `choices` | 选项列表,为空或不存在时视为结局 | -| `nextScene` | 没有选项时的默认下一场景(自动跳转) | -| `effects` | 选择后的效果,类型: `set`/`add`/`toggleFlag` | -| `conditions` | 选项的显示条件(`==`, `!=`, `>`, `<`, `>=`, `<=`, `hasFlag`) | - -### 2. 放置视频文件 - -将视频文件放入 `public/videos/` 目录,推荐 MP4 (H.264) 格式。 - -生成测试视频(需要 ffmpeg): +### 3. 实时预览 ```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 ``` -### 3. 修改加载的剧情文件 +Vite 会启动热重载服务器,修改 JSON 或视频后自动刷新。 -在 `src/App.vue` 中修改 `loadGame()` 的路径: +### 4. 可视化编辑剧情 -```ts -await loadGame('/scenes/your_story.json') +浏览器打开 `http://localhost:5173/editor/`,可以用节点图编辑器拖拽编辑场景分支。 + +## 打包发布 + +```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/ -├── engine/ # 框架无关的核心引擎(不依赖 Vue) -│ ├── core/ -│ │ ├── Engine.ts # 主循环 -│ │ ├── SceneManager.ts # 场景管理 -│ │ ├── VideoManager.ts # 视频播放控制 -│ │ └── StateManager.ts # 状态/变量/条件系统 +mygame/ +├── engine/ # 引擎核心(纯 TS,不依赖 Vue) +│ ├── core/ # Engine / VideoManager / StateManager / SceneManager +│ ├── systems/ # QTE / Choice / Audio / Achievement / Save │ └── types.ts # 类型定义 -├── src/ # Vue 播放器 -│ ├── components/ -│ │ ├── GamePlayer.vue # 视频播放器 -│ │ └── ChoicePanel.vue # 选项面板 -│ ├── composables/ -│ │ └── useGameEngine.ts # 引擎 ↔ Vue 桥接 -│ ├── stores/ -│ │ └── gameStore.ts # Pinia 状态 -│ ├── App.vue -│ └── main.ts -├── public/ -│ ├── scenes/demo.json # 示例剧情 -│ └── videos/ # 视频资源 -├── ROADMAP.md # 开发路线图 -└── package.json +├── src/ # Vue UI 层 +│ ├── components/ # 所有界面组件 +│ ├── composables/ # 引擎 ↔ UI 桥接 +│ ├── stores/ # Pinia 状态管理 +│ └── locales/ # 翻译文件(zh.json / en.json) +├── editor/ # 可视化剧情编辑器 +├── electron/ # 桌面应用打包(Electron) +├── public/ # 你的素材 +│ ├── videos/ # 视频文件(.mp4) +│ ├── audio/ # 背景音乐(.mp3) +│ ├── images/ # 缩略图 +│ ├── subtitles/ # 字幕(.vtt) +│ └── scenes/demo.json # 剧情定义 +├── docs/ # 文档 +│ └── 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 -npm run dev # 启动开发服务器 -npm run build # 构建生产版本 -npm run preview # 预览构建结果 +npm run dev # 启动开发服务器(实时预览) +npm run build # 构建生产版本 +npm run preview # 预览构建结果 +npm run pack:html # 打包 Web 版 +npm run pack:mac # 打包 macOS 桌面应用 +npm run pack:win # 打包 Windows 桌面应用 ``` - -## 使用提示 -场景虽然支持图片,但是图片场景容易触发一些bug,所以建议只使用视频场景,这样可以避免遇到没有测试覆盖的bug,也能简化引擎的逻辑。 \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index dd2bbe5..25f0d8e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -925,6 +925,84 @@ 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 可执行应用。 + +**命令设计:** + +```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 diff --git a/electron/main.js b/electron/main.js new file mode 100644 index 0000000..84e1a45 --- /dev/null +++ b/electron/main.js @@ -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()) diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000..e6dab79 --- /dev/null +++ b/electron/package.json @@ -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" + } +} diff --git a/package.json b/package.json index 693e32b..187f4ef 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "scripts": { "dev": "vite", "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": { "@types/dagre": "^0.7.54", diff --git a/scripts/pack-html.cjs b/scripts/pack-html.cjs new file mode 100644 index 0000000..eedbcff --- /dev/null +++ b/scripts/pack-html.cjs @@ -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`)