Compare commits
172 Commits
23478f0065
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f2e6d5605 | |||
| 298f0930ad | |||
| e1512c66d4 | |||
| 21a088bd99 | |||
| 4cca832641 | |||
| b8a5d55e8c | |||
| bef04efb1e | |||
| 7e896d53b8 | |||
| a40cc8874a | |||
| 6bd61ae522 | |||
| 0616f4a702 | |||
| 9a990274d6 | |||
| 897522ed5a | |||
| f14390a69c | |||
| a21652b1ca | |||
| c1f7be1507 | |||
| 395c55b6b0 | |||
| 5f717ac3b6 | |||
| b1ea2e6474 | |||
| 7b3ad95549 | |||
| f346f8d568 | |||
| c2a9fcdb2e | |||
| 78208cd4b1 | |||
| 525fa5ef8f | |||
| a34f3cf240 | |||
| 119b8201bb | |||
| 0aac429908 | |||
| 33357650c7 | |||
| 80b361813e | |||
| f8af9e608d | |||
| 7c80fc431c | |||
| 34e11a4f52 | |||
| f741f73e11 | |||
| 94e0ea9c24 | |||
| b45ad8bbc3 | |||
| 8b90ba0501 | |||
| 35ddef9dcc | |||
| 61fd5dbc2d | |||
| c6afeb2691 | |||
| 59f6956b50 | |||
| 48da10147b | |||
| ed462b1bee | |||
| 1619c9db8b | |||
| 681efe1d92 | |||
| 5cf0461e55 | |||
| 669a652ec7 | |||
| 0c59e54a2a | |||
| 8c736d5c08 | |||
| 51b71d07e7 | |||
| 94c55d3597 | |||
| a681e371ae | |||
| 82bfae0e1b | |||
| 271c909398 | |||
| e0331ab5a7 | |||
| 73fade1b94 | |||
| 920f0ee9f3 | |||
| c75db2886f | |||
| 02a82e9801 | |||
| b61d08a0ca | |||
| c46c4efd6c | |||
| 544f548275 | |||
| d0e901bd1f | |||
| 4d066b53bf | |||
| d2b1d88ce3 | |||
| 199ab1153b | |||
| 92966331d3 | |||
| d373cb8fc0 | |||
| 57118d3bfe | |||
| cf3060b7fe | |||
| a9929666a5 | |||
| 5a0252d0ea | |||
| a3379430cd | |||
| 16293eb11c | |||
| e949a84171 | |||
| db4f06883d | |||
| 7a802cdb02 | |||
| 5a4acfc6bb | |||
| 6a4ff7a328 | |||
| e7af4a8659 | |||
| 453b2c68d2 | |||
| 5ff8e2b669 | |||
| 8655e01c23 | |||
| 32f7e34130 | |||
| 320502a7c3 | |||
| 47230b4a66 | |||
| 503496ea0e | |||
| d46a2194f4 | |||
| a465009086 | |||
| b62af5b7de | |||
| 08f4bf3648 | |||
| b6231e4efd | |||
| 6575b0be0f | |||
| 18bf98aa16 | |||
| 6a6414510e | |||
| 0177af3416 | |||
| ff379ff56e | |||
| c024b306bf | |||
| 68f601db8c | |||
| 741b067567 | |||
| 4d75326189 | |||
| ee0861ff6d | |||
| 8e8a0b5d99 | |||
| 04285162c9 | |||
| 5e81c42c40 | |||
| ff6771b44b | |||
| 22a767ab07 | |||
| 182feacd94 | |||
| 41f929951d | |||
| 5da5a79272 | |||
| 29aa386eeb | |||
| 8c08f4026e | |||
| 1499476301 | |||
| c8eb433083 | |||
| faca71ea6e | |||
| ddf73de519 | |||
| 97ebe1c8ca | |||
| 215a8db829 | |||
| 8d5f6e175b | |||
| 951da731b3 | |||
| 9baa7b5ab3 | |||
| ac0a6e2cd6 | |||
| 2638c4fa9d | |||
| 8fc4199354 | |||
| d680da11c8 | |||
| 0379548a29 | |||
| 6417a9de43 | |||
| f204555066 | |||
| bca5e07cdd | |||
| ab511b27ba | |||
| 337221ba87 | |||
| 73ac54fe95 | |||
| ae7721d70e | |||
| 76fa19c372 | |||
| 6fbfdc3332 | |||
| d4e8073f75 | |||
| 2b3fc97c70 | |||
| 686b1b45ea | |||
|
|
99f80147c7 | ||
| 03c4ee3a65 | |||
| 879501ccb5 | |||
| 2f9f9a4117 | |||
| e51b5e234e | |||
| d057beb82d | |||
| dfddd6e527 | |||
| 332afa7eee | |||
| 0a1147c2fe | |||
| d2dae38f05 | |||
| 0dec8a2376 | |||
| 7c1c734572 | |||
| 4c20c6444b | |||
| 642e18fb5e | |||
| bf4b09f4e0 | |||
| e7ed52d89b | |||
| ea01e61fde | |||
| 76581d0326 | |||
|
|
68312c6137 | ||
| bb289f5438 | |||
| 5eac0f23a8 | |||
| 8f6138f97e | |||
| 86a0aebdc8 | |||
| 4cf2263c78 | |||
| 0071a34acf | |||
| 25aafb034f | |||
| 3a46c2c6c8 | |||
| 4258bc4034 | |||
| b38969b108 | |||
| 9409c893b3 | |||
|
|
082bb4ac48 | ||
| 937e709dca | |||
| 76477050d3 | |||
|
|
3bed3386e4 | ||
|
|
0e5fcfbb15 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,4 +23,4 @@ npm-debug.log*
|
|||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
video
|
video
|
||||||
release
|
release
|
||||||
|
|||||||
3
.npmrc
Normal file
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
59
.opencode/skills/moviegame/SKILL.md
Normal file
59
.opencode/skills/moviegame/SKILL.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: moviegame
|
||||||
|
description: Edit JSON-driven interactive movie game config files. Validates scene references, variables, and structure.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Movie Game Engine Skill
|
||||||
|
|
||||||
|
你正在编辑一个 JSON 驱动的交互电影游戏配置文件。先读文件、理解现有结构,再精确修改。
|
||||||
|
|
||||||
|
## 关键参考文件(需要时自行读取)
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| `engine/types.ts` | 全部类型定义:SceneNode、Choice、Condition、Effect、QTEDefinition、GameData 等 |
|
||||||
|
| `docs/ARCHITECTURE.md` | 架构约束:A/B 双缓冲、JSON 驱动、事件系统 |
|
||||||
|
| `AGENTS.md` | 项目开发约定 |
|
||||||
|
|
||||||
|
当前编辑的故事配置文件路径由对话上下文提供(如 `public/scenes/demo.json`)。
|
||||||
|
|
||||||
|
## 核心操作规则(必须遵守)
|
||||||
|
|
||||||
|
1. **key === id**:`scenes` 对象 key 必须等于 `SceneNode.id`,始终一致。新增场景时两者设为相同值
|
||||||
|
2. **变量先声明后使用**:新增 effects/conditions 引用新变量名 → 必须先在 `GameData.variables` 中追加声明(初始值建议 0)
|
||||||
|
3. **删除清理**:删除场景时,必须清理所有其他场景中对它的引用:`choices[].targetScene`、`nextScene`、`qte.successScene`、`qte.failScene`、`hotspots[].targetScene`
|
||||||
|
4. **引用有效**:所有 `targetScene`、`nextScene`(string 形式)、`successScene`、`failScene`、`startScene` 必须指向 `scenes` 中存在的 key
|
||||||
|
5. **保持结构**:修改场景时保留所有已有字段,除非明确要求删除
|
||||||
|
|
||||||
|
## 变量使用位置(全部)
|
||||||
|
|
||||||
|
下列所有位置引用的变量名都必须在 `GameData.variables` 中声明:
|
||||||
|
|
||||||
|
| 位置 | 字段 |
|
||||||
|
|------|------|
|
||||||
|
| `Choice.effects` | `Effect.target` |
|
||||||
|
| `Choice.conditions` | `Condition.variable` |
|
||||||
|
| `nextScene (Choice[])` 每个路由的 `effects` | `Effect.target` |
|
||||||
|
| `nextScene (Choice[])` 每个路由的 `conditions` | `Condition.variable` |
|
||||||
|
| `SceneNode.onEnter` | `Effect.target` |
|
||||||
|
| `QTEDefinition.effects.success` | `Effect.target` |
|
||||||
|
| `QTEDefinition.effects.fail` | `Effect.target` |
|
||||||
|
| `Hotspot.effects` | `Effect.target` |
|
||||||
|
| `Hotspot.conditions` | `Condition.variable` |
|
||||||
|
| `AchievementDef.condition` | `Condition.variable` |
|
||||||
|
| `BattleHUDStat` | `variable` |
|
||||||
|
| `BattleResultStat` | `variable` |
|
||||||
|
| `ChapterInfo.defaultVariables` | keys |
|
||||||
|
|
||||||
|
**Effect 格式**:`{ "type": "set", "target": "变量名", "value": 5 }` 或 `{ "type": "add", "target": "变量名", "delta": 1 }`
|
||||||
|
**Condition 格式**:`{ "variable": "变量名", "op": ">=", "value": 3 }`(op 支持 > < >= <= == !=)
|
||||||
|
|
||||||
|
## 场景引用关系
|
||||||
|
|
||||||
|
- `Choice.targetScene` — 选项跳转目标
|
||||||
|
- `nextScene` — string = 直接跳转;Choice[] = 条件路由(逐条 evaluate conditions,第一条匹配的生效)
|
||||||
|
- `QTEDefinition.successScene` / `failScene` — QTE 成功/失败跳转
|
||||||
|
- `Hotspot.targetScene` — 热点跳转
|
||||||
|
- `ChapterInfo.startScene` — 章节起始场景
|
||||||
|
- `EndingDef.sceneId` — 结局关联场景
|
||||||
|
- `GameData.startScene` — 游戏起始场景
|
||||||
119
AGENTS.md
Normal file
119
AGENTS.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# 引擎开发约定与参考
|
||||||
|
|
||||||
|
本文档记录交互电影游戏引擎的开发规范、架构约定和工作流程,供后续开发对话中引用。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **先讨论再执行** | 功能实现前必须先讨论方案,确认后再写代码 |
|
||||||
|
| **先更新 ROADMAP 再实现** | 新功能先写入 ROADMAP 的 P 条目,附实现清单,再逐项完成 |
|
||||||
|
| **不自动提交** | 代码写完不要 `git commit` / `git push`,等用户检查通过后再操作 |
|
||||||
|
| **验证方式** | `npx vue-tsc --noEmit` + `npx vite build` 通过视为基本验证 |
|
||||||
|
| **生成测试数据** | 新功能要生成配套的示例视频 / 音频 / JSON 数据 |
|
||||||
|
|
||||||
|
## 架构原则
|
||||||
|
|
||||||
|
| 原则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **引擎与 UI 分离** | `engine/` 下纯 TS 类,不 import Vue。UI 层通过 composables 桥接 |
|
||||||
|
| **A/B 双缓冲** | 两个 `<video>` 元素轮换,一个播放时另一个预加载候选场景 |
|
||||||
|
| **JSON 驱动** | 所有剧情数据放在 JSON 中,编辑器本质是 JSON 的可视化读写工具 |
|
||||||
|
| **IndexedDB 存档** | 比 localStorage 容量大,可存储截屏缩略图。多槽位支持 |
|
||||||
|
| **故事图与玩家树** | 创作端 JSON 是有向图,展示端 BFS 投影为树(汇聚节点复制展示) |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
moviegame/
|
||||||
|
├── engine/ # 框架无关的核心引擎(纯 TS)
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── Engine.ts # 主循环,驱动各子系统
|
||||||
|
│ │ ├── SceneManager.ts # 剧情节点图遍历、章节管理
|
||||||
|
│ │ ├── VideoManager.ts # A/B 双缓冲视频播放 + 流媒体质量选择
|
||||||
|
│ │ └── StateManager.ts # 全局状态、变量条件求值、效果执行
|
||||||
|
│ ├── systems/
|
||||||
|
│ │ ├── ChoiceSystem.ts # 限时选择 + 倒计时
|
||||||
|
│ │ ├── QTESystem.ts # QTE 触发、键盘监听、超时判定
|
||||||
|
│ │ ├── AudioSystem.ts # Web Audio API BGM + Ducking
|
||||||
|
│ │ ├── AchievementSystem.ts # 纯变量成就检测 + 解锁
|
||||||
|
│ │ └── SaveSystem.ts # IndexedDB 多表持久化
|
||||||
|
│ └── types.ts # 全部类型定义
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Vue 组件——玩家端 UI + 编辑器 UI
|
||||||
|
│ ├── composables/ # 引擎 ↔ UI 桥接
|
||||||
|
│ ├── stores/ # Pinia 全局状态
|
||||||
|
│ └── locales/ # UI 文本翻译(静态 import,构建时打包)
|
||||||
|
├── editor/ # Vue Flow 可视化编辑器(独立入口)
|
||||||
|
├── electron/ # Electron 桌面应用打包
|
||||||
|
├── public/
|
||||||
|
│ └── demo/ # 示例素材——按场景分目录,每场景含视频、字幕、缩略图
|
||||||
|
│ └── locales/ # 故事文本翻译(动态 fetch 加载,制作者维护)
|
||||||
|
├── docs/
|
||||||
|
│ ├── SCENE_JSON_SPEC.md # 场景 JSON 完整字段参考
|
||||||
|
│ └── ARCHITECTURE.md # 关键架构决策记录
|
||||||
|
├── scripts/ # 构建与打包脚本
|
||||||
|
├── ROADMAP.md # 待实现功能清单
|
||||||
|
└── CHANGELOG.md # 功能更新日志
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码约定
|
||||||
|
|
||||||
|
| 规则 | 示例 |
|
||||||
|
|------|------|
|
||||||
|
| 不要写注释 | 代码自解释,不添加多余的中英文注释 |
|
||||||
|
| Engine 事件驱动 | `this.emit('sceneChange', scene)` → composable 响应 |
|
||||||
|
| composable 桥接 | `useGameEngine.ts` 是 Engine 和 Vue store 的中间层 |
|
||||||
|
| i18n 双文件分层 | `src/locales/` 存 UI 文本,`public/demo/locales/` 存故事文本。`useI18n.t()` 先查故事消息,fallback UI 消息 |
|
||||||
|
| 选择翻译在 composable 中 | `choiceRequest` 事件触发时 composable 调用 `t(textKey)` 翻译后存入 store |
|
||||||
|
| 引擎不感知 i18n | `Choice.textKey` 和 `Hotspot.labelKey` 是数据层字段,翻译完全在 Vue 层完成 |
|
||||||
|
| demo 素材按场景分目录 | `public/demo/<scene_id>/<file>` |
|
||||||
|
| demo 的 assetBase | `"assetBase": "demo/"` — 所有资源路径以此为前缀 |
|
||||||
|
|
||||||
|
## 菜单系统
|
||||||
|
|
||||||
|
| 菜单 | 设计 |
|
||||||
|
|------|------|
|
||||||
|
| **主菜单 (MainMenu.vue)** | 竖排单列,开始游戏最大、继续次之、底部小字装饰行(故事进度 · 成就 · 设置) |
|
||||||
|
| **暂停菜单 (PauseMenu.vue)** | ESC 弹出的全屏暂停,包含继续 / 存档 / 设置 / 返回主菜单 |
|
||||||
|
| **设置面板 (AccessibilitySettings.vue)** | 语言 + 画质(仅 Web) + 字幕 + QTE 辅助 + 防误触 + 可暂停 |
|
||||||
|
| **游戏内顶栏** | 精简:跳过(条件显示) · 倍速(条件显示) · 全屏 · ≡菜单 |
|
||||||
|
|
||||||
|
## 视频播放模式
|
||||||
|
|
||||||
|
| 模式 | 检测方式 | 使用的 URL 字段 | 切换方式 |
|
||||||
|
|------|---------|---------------|---------|
|
||||||
|
| **Electron 桌面** | `window.__ELECTRON__ === true` | `videoUrl`(本地 MP4) | 不切换,单文件 |
|
||||||
|
| **Web 浏览器** | `__ELECTRON__` undefined | `streamingUrl[quality]`(CDN HLS) | 设置面板手动选择超清/高清/标清 |
|
||||||
|
| **画质切换** | 仅在视频播放中切换(`video.ended === false`),结束后不切换等待下一场景 |
|
||||||
|
|
||||||
|
关键方法:
|
||||||
|
|
||||||
|
- `VideoManager.resolveVideoUrl(scene, quality)` — 环境检测 + URL 选择
|
||||||
|
- `VideoManager.switchQuality(src, seekTime)` — 实时切换画质
|
||||||
|
- `SceneManager.getCandidateSceneIds()` — 返回候选场景 ID,Engine 用 `resolveVideoUrl` 解析为 URL
|
||||||
|
- 预加载也走 `resolveVideoUrl`,Web 模式禁止预加载本地 MP4
|
||||||
|
|
||||||
|
## 场景 JSON 约定
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 单文件包含全部场景 | 不拆分章节为独立 JSON 文件 |
|
||||||
|
| 章节用 `startScene` 标记 | 无 `endScene`,BFS 遍历可达场景作为章节范围 |
|
||||||
|
| 影像场景标记 | `"type": "image"`, `videoUrl` 为空 |
|
||||||
|
| QTE 配置 | `effects.success` / `effects.fail` 中附加变量修改,供成就检测 |
|
||||||
|
| 结局归属 | 通过 BFS 自动推导 `ending.sceneId` 属于哪个章节,不显式声明 `chapterId` |
|
||||||
|
|
||||||
|
## 成就系统
|
||||||
|
|
||||||
|
- 纯变量检测,在 `StateManager.apply` 末尾 `onAfterApply` 单一检查点触发
|
||||||
|
- 事件型成就改写为变量型:QTE 成功 → effects 中 `set: qte_succeeded, value: 1`
|
||||||
|
- 解锁时有 toast 弹出、持久化写入 IndexedDB `achievements` 表
|
||||||
|
|
||||||
|
## 流媒体 / HLS 约定
|
||||||
|
|
||||||
|
- 每场景三档 HLS,目录结构:`public/demo/<scene>/<quality>p/index.m3u8` + `seg_000.ts`
|
||||||
|
- `demo.json` 中每场景配 `streamingUrl: { "超清 (1080P)": "...", "高清 (720P)": "...", "标清 (480P)": "..." }`
|
||||||
|
- 生成命令:`ffmpeg -i source.mp4 -c:v libx264 -b:v <bitrate>k -c:a aac -b:a 128k -hls_time 2 -hls_segment_filename <dir>/seg_%03d.ts <dir>/index.m3u8`
|
||||||
|
- `pack-html.cjs` 跳过 `videos/` 目录(Web 版使用 CDN 流媒体)
|
||||||
|
- `pack-mac` / `pack-win` 保留完整视频文件(桌面版使用本地 MP4)
|
||||||
52
CHANGELOG.md
Normal file
52
CHANGELOG.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 更新日志
|
||||||
|
|
||||||
|
## 2026-06-09
|
||||||
|
|
||||||
|
| P | 功能 | 状态 |
|
||||||
|
|---|------|:--:|
|
||||||
|
| P17 | 主菜单统一化 — 游戏入口整理 | ✅ |
|
||||||
|
| P16 | 可访问性设置 — 字幕 + QTE 辅助 + 防误触 + 暂停 | ✅ |
|
||||||
|
| P15 | 结局画廊 + 章节回顾 — 列表 + 完成度百分比 + 条件提示 | ✅ |
|
||||||
|
| P14 | 成就系统 — 纯变量检测 + 单一检查点 + Toast 队列 | ✅ |
|
||||||
|
| P13 | 关键选择提示 — 选前标识 + 选后浮现 | ✅ |
|
||||||
|
| P12 | ~~场景过渡特效~~ | 废弃 |
|
||||||
|
| P11 | 完整 i18n — 字幕 + UI 国际化,自制 useI18n | ✅ |
|
||||||
|
| P10a | 键盘导航 — 方向键+确认键驱动全流程 | ✅ |
|
||||||
|
| P9 | 跳过已看 + 倍速播放 | ✅ |
|
||||||
|
| P8 | 章节选择 — 到达即解锁,主菜单+通关后跳转 | ✅ |
|
||||||
|
|
||||||
|
## 2026-06-10
|
||||||
|
|
||||||
|
| P | 功能 | 状态 |
|
||||||
|
|---|------|:--:|
|
||||||
|
| P23 | 玩家树可视化 — 缩进树取代平铺列表 | ✅ |
|
||||||
|
| P22 | 故事进度总览 — 章节选择 + 画廊合并 | ✅ |
|
||||||
|
| P21 | 菜单系统重构 — 主菜单 + 暂停菜单 + 设置 + 游戏内顶栏 | ✅ |
|
||||||
|
| P20 | 开场流程 — 启动视频 + 菜单背景视频 | ✅ |
|
||||||
|
| P19 | 制作者工具链 — HTML / macOS / Windows 打包 | ✅ |
|
||||||
|
|
||||||
|
## 2026-06-08
|
||||||
|
|
||||||
|
| P | 功能 | 状态 |
|
||||||
|
|---|------|:--:|
|
||||||
|
| P7 | 全屏模式 — 沉浸式浏览器体验 | ✅ |
|
||||||
|
| P6 | 独立背景音乐 + Ducking — 画面循环不打断 BGM | ✅ |
|
||||||
|
| P5 | 选择等待循环 — 单文件内时间锚点无缝循环 | ✅ |
|
||||||
|
| P4 | 视频/图片热点 — 点击画面区域触发分支 | ✅ |
|
||||||
|
|
||||||
|
## 2026-06-07
|
||||||
|
|
||||||
|
| P | 功能 | 状态 |
|
||||||
|
|---|------|:--:|
|
||||||
|
| P3 | 编辑器 — 可视化剧情编辑 | ✅ |
|
||||||
|
| P2 | QTE + 字幕 + 多存档槽 | ✅ |
|
||||||
|
| P1 | 无缝切换 + 条件分支 + 存档 | ✅ |
|
||||||
|
| P0 | MVP — 最小可玩原型 | ✅ |
|
||||||
|
|
||||||
|
## 废弃项
|
||||||
|
|
||||||
|
| P | 功能 | 原因 |
|
||||||
|
|---|------|------|
|
||||||
|
| P12 | 场景过渡特效 | 引擎 A/B cross-fade 已覆盖技术缓冲需求,艺术转场由剪辑师处理 |
|
||||||
|
| ~~P16~~ | 自适应码率 | 离线应用模式不需要,移入 FUTURE.md |
|
||||||
|
| P14 | 沉浸感提升 | 功能拆分到其他 P 或远期 |
|
||||||
152
README.md
152
README.md
@@ -1,7 +1,10 @@
|
|||||||
# 交互式电影游戏引擎
|
# <img src="public/logo.svg" alt="天书" height="36" style="vertical-align: middle"> 天书 TianShu
|
||||||
|
|
||||||
|
> 天书不晦,落笔成途。
|
||||||
|
> *剧本由你执笔,命运自有分岔。*
|
||||||
|
|
||||||
基于 Vue 3 + TypeScript 的浏览器端交互式电影游戏引擎。
|
基于 Vue 3 + TypeScript 的浏览器端交互式电影游戏引擎。
|
||||||
**零代码门槛**:你只需要会剪视频和写 JSON,不需要前端知识。
|
**零代码门槛**:你只需要会剪视频和写 JSON。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -12,33 +15,35 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
打开 `http://localhost:5173/`,你会看到示例剧情。
|
打开 `http://localhost:5173/` 看示例剧情。
|
||||||
|
打开 `http://localhost:5173/editor/` 使用可视化剧情编辑器。
|
||||||
|
|
||||||
## 制作你的游戏
|
## 制作你的游戏
|
||||||
|
|
||||||
### 1. 准备素材
|
### 1. 准备素材
|
||||||
|
|
||||||
| 素材 | 目录 | 格式要求 |
|
按场景组织素材,每个场景一个文件夹:
|
||||||
|------|------|---------|
|
|
||||||
| 视频文件 | `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。
|
```
|
||||||
|
public/my_story/
|
||||||
|
scene_1/video.mp4 ← 视频文件 (MP4 H.264, 1280×720)
|
||||||
|
scene_2/video.mp4
|
||||||
|
shared/bgm.mp3 ← 跨场景共享素材
|
||||||
|
```
|
||||||
|
|
||||||
### 2. 编写剧情 JSON
|
### 2. 写剧情 JSON
|
||||||
|
|
||||||
编辑 `public/scenes/demo.json`,定义你的场景和分支。**最小可玩示例只需 20 行 JSON**:
|
创建 `public/scenes/my_story.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"startScene": "intro",
|
"assetBase": "my_story/",
|
||||||
|
"startScene": "scene_1",
|
||||||
"variables": { "trust": 50 },
|
"variables": { "trust": 50 },
|
||||||
"scenes": {
|
"scenes": {
|
||||||
"intro": {
|
"scene_1": {
|
||||||
"id": "intro",
|
"id": "scene_1",
|
||||||
"videoUrl": "/videos/intro.mp4",
|
"videoUrl": "scene_1/video.mp4",
|
||||||
"choices": [
|
"choices": [
|
||||||
{ "text": "帮助他", "targetScene": "help_end" },
|
{ "text": "帮助他", "targetScene": "help_end" },
|
||||||
{ "text": "离开", "targetScene": "leave_end" }
|
{ "text": "离开", "targetScene": "leave_end" }
|
||||||
@@ -46,105 +51,100 @@ npm run dev
|
|||||||
},
|
},
|
||||||
"help_end": {
|
"help_end": {
|
||||||
"id": "help_end",
|
"id": "help_end",
|
||||||
"videoUrl": "/videos/help.mp4",
|
"videoUrl": "scene_2/video.mp4",
|
||||||
"choices": []
|
"choices": []
|
||||||
},
|
},
|
||||||
"leave_end": {
|
"leave_end": {
|
||||||
"id": "leave_end",
|
"id": "leave_end",
|
||||||
"videoUrl": "/videos/leave.mp4",
|
"videoUrl": "shared/video.mp4",
|
||||||
"choices": []
|
"choices": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
完整字段参考见 **[docs/SCENE_JSON_SPEC.md](docs/SCENE_JSON_SPEC.md)**。
|
### 3. 运行
|
||||||
|
|
||||||
### 3. 实时预览
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Vite 会启动热重载服务器,修改 JSON 或视频后自动刷新。
|
浏览器打开 `http://localhost:5173/?scene=my_story.json`。
|
||||||
|
|
||||||
### 4. 可视化编辑剧情
|
|
||||||
|
|
||||||
浏览器打开 `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 交叉淡化 |
|
| 视频分支播放 | A/B 双缓冲无缝切换,300ms 交叉淡化 |
|
||||||
| 选择系统 | 限时选择、条件分支(根据变量显示/隐藏选项) |
|
| 选择系统 | 限时选择、条件分支(根据变量显示/隐藏) |
|
||||||
| QTE 快速反应事件 | 视频中插入限时按键挑战,成功/失败跳转不同场景 |
|
| 选择提示 (Prompt) | 重要选项前置金色标识 + 选后浮现提示文字 |
|
||||||
| 图片/视频热点 | 点击画面区域触发分支,视频热点按时间轴显隐 |
|
| QTE 快速反应事件 | 视频中限时按键挑战,成功/失败导向不同分支 |
|
||||||
| 循环等待 | 视频结束后自动循环指定片段,保持画面动态 |
|
| 图片/视频热点 | 像素级精准定位,视频热点按时间显隐 |
|
||||||
| 独立 BGM | 背景音乐独立驱动,场景切换时交叉淡化,不受画面循环影响 |
|
| 循环等待 | 指定片段反复播放直到玩家做选择 |
|
||||||
| BGM Ducking | 选择/QTE/热点出现时 BGM 自动降低音量 |
|
| 独立 BGM | 背景音乐独立轨道,场景切换交叉淡化 |
|
||||||
| 字幕系统 | WebVTT 解析,多语言字幕切换 |
|
| BGM Ducking | 选择/QTE/热点出现时 BGM 自动压低 |
|
||||||
| 章节系统 | 分章节管理剧情,到达即解锁,可跳转 |
|
| 字幕系统 | WebVTT 解析,多语言字幕一键切换 |
|
||||||
| 成就系统 | 变量满足条件时自动解锁,底部弹窗提示 |
|
| 多语言 i18n | UI + 数据层双语体系,支持动态扩展语言 |
|
||||||
| 结局画廊 | 所有结局缩略图展示,已解锁/未解锁状态 |
|
| 章节系统 | 分章节管理,到达即解锁 |
|
||||||
| 章节回顾 | 每章完成度百分比 + 未解锁分支条件提示 |
|
| 成就系统 | 条件触发自动解锁,底部 Toast 弹窗 |
|
||||||
| 关键选择提示 | 重要选项前置金色标识 + 选后浮现提示文字 |
|
| 故事进度总览 | 章节完成度 + 结局解锁状态 + 条件提示 |
|
||||||
| 跳过已看 + 倍速 | 已看场景可跳过,1x/2x/4x 倍速播放 |
|
|
||||||
| 全屏模式 | 一键全屏沉浸式浏览器体验 |
|
|
||||||
| 键盘导航 | 方向键选选项,Esc 菜单,Space 暂停 |
|
|
||||||
| 多语言 i18n | UI + 字幕支持中英文切换 |
|
|
||||||
| 可访问性 | 字幕字号/背景、QTE 时限放宽/按键简化、防误触延迟 |
|
|
||||||
| 存档系统 | IndexedDB 多槽位,跨会话持久化 |
|
| 存档系统 | IndexedDB 多槽位,跨会话持久化 |
|
||||||
|
| 开场视频 + 菜单背景 | 电影级启动流程 |
|
||||||
|
| 暂停菜单 | Esc 呼出暂停面板:继续/存档/设置/主菜单 |
|
||||||
|
| 可访问性 | 字幕字号/背景、QTE 时限放宽/按键简化、防误触 |
|
||||||
|
| 跳过/倍速 | 已看场景可跳过,1x/2x/4x 倍速 |
|
||||||
|
| 全屏模式 | 一键全屏沉浸式体验 |
|
||||||
|
| 键盘导航 | 方向键选选项,Esc 暂停/菜单,P 暂停 |
|
||||||
|
| 自动隐藏 UI | 3s 无操作隐藏顶栏和光标 |
|
||||||
|
| Electron 打包 | macOS / Windows 桌面应用 |
|
||||||
|
|
||||||
|
## 文档索引
|
||||||
|
|
||||||
|
| 文档 | 内容 | 适合 |
|
||||||
|
|------|------|------|
|
||||||
|
| [**快速开始**](docs/guide/QUICK_START.md) | 5 分钟做一个游戏 | 新手 |
|
||||||
|
| [**JSON 字段参考**](docs/guide/SCENE_JSON_SPEC.md) | 全部字段 + 类型说明 | 必读 |
|
||||||
|
| [**分支叙事**](docs/guide/BRANCHING.md) | 选项/条件/变量/效果/Prompt | 进阶 |
|
||||||
|
| [**交互指南**](docs/guide/INTERACTIONS.md) | QTE / Hotspot / Loop | 进阶 |
|
||||||
|
| [**国际化**](docs/guide/I18N.md) | 多语言 UI + 数据层 | 进阶 |
|
||||||
|
| [**打包发布**](docs/guide/PUBLISHING.md) | Web + macOS + Windows | 分发 |
|
||||||
|
| [**编辑器**](editor/README.md) | 可视化剧情编辑 | 可选 |
|
||||||
|
| [**开发路线图**](ROADMAP.md) | 已实现/待实现功能 | 参考 |
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
mygame/
|
mygame/
|
||||||
├── engine/ # 引擎核心(纯 TS,不依赖 Vue)
|
├── engine/ # 引擎核心(纯 TS,不依赖 Vue)
|
||||||
│ ├── core/ # Engine / VideoManager / StateManager / SceneManager
|
│ ├── core/ # Engine / VideoManager / SceneManager
|
||||||
│ ├── systems/ # QTE / Choice / Audio / Achievement / Save
|
│ ├── systems/ # QTE / Choice / Audio / Achievement / Save
|
||||||
│ └── types.ts # 类型定义
|
│ └── types.ts
|
||||||
├── src/ # Vue UI 层
|
├── src/ # Vue UI 层
|
||||||
│ ├── components/ # 所有界面组件
|
│ ├── components/ # 所有界面组件
|
||||||
│ ├── composables/ # 引擎 ↔ UI 桥接
|
│ ├── composables/ # 引擎 ↔ UI 桥接
|
||||||
│ ├── stores/ # Pinia 状态管理
|
│ ├── stores/ # Pinia 状态
|
||||||
│ └── locales/ # 翻译文件(zh.json / en.json)
|
│ └── locales/ # UI 翻译(开发者维护)
|
||||||
├── editor/ # 可视化剧情编辑器
|
├── editor/ # 可视化剧情编辑器
|
||||||
├── electron/ # 桌面应用打包(Electron)
|
├── electron/ # Electron 桌面打包
|
||||||
├── public/ # 你的素材
|
├── public/ # 你的素材(唯一需要编辑的目录)
|
||||||
│ ├── videos/ # 视频文件(.mp4)
|
│ ├── demo/ # 示例数据(参考用)
|
||||||
│ ├── audio/ # 背景音乐(.mp3)
|
│ │ ├── scenes/demo.json
|
||||||
│ ├── images/ # 缩略图
|
│ │ └── locales/{zh,en,ja}.json
|
||||||
│ ├── subtitles/ # 字幕(.vtt)
|
│ ├── your_story/ # 你的游戏
|
||||||
│ └── scenes/demo.json # 剧情定义
|
│ │ ├── scene_1/video.mp4
|
||||||
|
│ │ └── locales/zh.json
|
||||||
|
│ └── scenes/config.json # 默认加载哪个 JSON
|
||||||
├── docs/ # 文档
|
├── docs/ # 文档
|
||||||
│ └── SCENE_JSON_SPEC.md # JSON 完整字段参考
|
│ ├── guide/ # 游戏制作引导
|
||||||
|
│ └── electron/ # 打包配置
|
||||||
├── scripts/ # 打包脚本
|
├── scripts/ # 打包脚本
|
||||||
└── ROADMAP.md # 开发路线图
|
└── ROADMAP.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## 视频制作建议
|
## 命令
|
||||||
|
|
||||||
| 参数 | 建议值 |
|
|
||||||
|------|--------|
|
|
||||||
| 视频不包含 BGM | 背景音乐用独立 .mp3 文件 + `bgmUrl` 字段,画面循环时 BGM 不中断 |
|
|
||||||
| 循环片段 | 正文段 + 循环段合成为一个文件,用 `loopStart`/`loopEnd` 标记 |
|
|
||||||
| 字幕 | WebVTT 格式,时间轴精确到毫秒 |
|
|
||||||
|
|
||||||
## 命令参考
|
|
||||||
|
|
||||||
```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:html # 打包 Web 版
|
||||||
|
|||||||
1286
ROADMAP.md
1286
ROADMAP.md
File diff suppressed because it is too large
Load Diff
43
docs/ARCHITECTURE.md
Normal file
43
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# 架构决策记录
|
||||||
|
|
||||||
|
## 1. 引擎与 UI 分离
|
||||||
|
|
||||||
|
`engine/` 下纯 TS 类,不 import Vue。UI 层通过 composables 桥接。
|
||||||
|
|
||||||
|
## 2. A/B 双缓冲
|
||||||
|
|
||||||
|
两个 `<video>` 元素轮换,一个播放时另一个预加载候选视频。300ms CSS opacity 交叉淡化。
|
||||||
|
|
||||||
|
## 3. JSON 驱动
|
||||||
|
|
||||||
|
所有剧情数据放在 JSON 中,编辑器本质是 JSON 的可视化读写工具。
|
||||||
|
|
||||||
|
## 4. IndexedDB 存档
|
||||||
|
|
||||||
|
比 localStorage 容量大,可存储截屏缩略图。多槽位支持,跨会话持久化。
|
||||||
|
|
||||||
|
## 5. 故事图与玩家树
|
||||||
|
|
||||||
|
创作端的 JSON 是有向图(Graph),玩家端展示的是严格树(Tree)。
|
||||||
|
|
||||||
|
- 数据层:故事图 + 玩家存档
|
||||||
|
- 渲染层:`buildPlayerTree()` 将图投影为树
|
||||||
|
- 汇聚节点在树上复制展示,每条路径独立
|
||||||
|
- 回环用 `pathSet` 精确剪枝 + `depth > 10` 兜底
|
||||||
|
|
||||||
|
详见 [ROADMAP.md P23](../ROADMAP.md#p23-玩家树可视化--缩进树取代平铺列表)
|
||||||
|
|
||||||
|
## 6. 成就 — 纯变量单一检查点
|
||||||
|
|
||||||
|
所有成就通过变量检测,在 `StateManager.apply` 末尾单一检查点触发。事件型成就改写为变量型(QTE 成功 = 在 effects 中 `set` 标记变量)。
|
||||||
|
|
||||||
|
## 7. i18n — 双文件分层
|
||||||
|
|
||||||
|
- `src/locales/` — UI 文本(静态 import,构建时打包)
|
||||||
|
- `public/locales/` — 故事文本(fetch 动态加载)
|
||||||
|
|
||||||
|
`useI18n.t()` 优先查故事消息,fallback 到 UI 消息。
|
||||||
|
|
||||||
|
## 8. 章节 — 单文件共享场景
|
||||||
|
|
||||||
|
所有场景在一个 JSON 中,章节用 `startScene` 标记边界。场景可跨章引用以保持叙事灵活性,但建议制作者保持各章 BFS 独立。
|
||||||
105
docs/DESIGN_PRINCIPLES.md
Normal file
105
docs/DESIGN_PRINCIPLES.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# 天书 TianShu — UI 设计原则
|
||||||
|
|
||||||
|
> "画面为王,UI 为仆。让玩家忘记这是软件,记住这是故事。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 内容优先(Content First)
|
||||||
|
|
||||||
|
画面是主角,UI 是配角。
|
||||||
|
|
||||||
|
| 规则 | 实践 | 反例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **画面满铺** | 视频/流程图占屏幕 100%,UI 叠加不遮挡 | 在画面四周加边框或 logo |
|
||||||
|
| **信息分区** | 核心内容 → 满铺;操作按钮 → 边角;统计数据 → 底栏 | 面板居中、内容四周留大片空白 |
|
||||||
|
| **面积比** | 画面 : 底栏 : 边角按钮 ≈ 8 : 1.5 : 0.5 | 一个面板占 60% 屏幕面积 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 最少 UI(Minimal UI)
|
||||||
|
|
||||||
|
能删的按钮一定要删。
|
||||||
|
|
||||||
|
| 规则 | 实践 | 反例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **一个操作一个入口** | 关闭弹窗只用 `← 返回` 或 Esc,不加 ✕ | 同时有返回按钮 + ✕ 按钮 + 点击遮罩关闭 |
|
||||||
|
| **不对称优于对称** | `[← 返回] 标题______________` | `[返回]___标题居中___[✕]` |
|
||||||
|
| **物理优先** | 用 Esc / 点击遮罩 / 滚轮操作,减少可见按钮 | 面板底部放一排操作按钮 |
|
||||||
|
| **选项 ≤ 3 个** | 任何时刻最多 3 个选择(参考 Netflix Bandersnatch) | 满屏 6-8 个选项 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 一致性(Consistency)
|
||||||
|
|
||||||
|
同一件事,同一个地方,同一种颜色。
|
||||||
|
|
||||||
|
| 规则 | 实践 |
|
||||||
|
|------|------|
|
||||||
|
| **同功能同位置** | 所有弹窗关闭方式统一为左上角 `← 返回` |
|
||||||
|
| **同操作同交互** | 卡片 hover 缩放/发光效果全项目统一 |
|
||||||
|
| **颜色语义统一** | `#c9a84c` 金色 = 已完成/已解锁/可交互;灰色 = 不可用/未解锁 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 渐进展示(Progressive Disclosure)
|
||||||
|
|
||||||
|
信息不是一次性堆满,而是层层展开。
|
||||||
|
|
||||||
|
| 规则 | 实践 | 反例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **先见森林后见树木** | 进入页面先看到核心(流程图),滚到底才现进度条,点击才展开详情 | StoryGallery 一屏展示全部信息 |
|
||||||
|
| **折叠优先** | 次要数据收进折叠区域,点击展开 | 平铺所有统计数据 |
|
||||||
|
| **面板宽度 ≤ 60%** | 除流程图满铺外,其他面板不超过屏幕 60% | 全宽半透明面板 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 氛围不打断(Immersive)
|
||||||
|
|
||||||
|
过渡要柔,打断要轻。
|
||||||
|
|
||||||
|
| 规则 | 实践 | 反例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **背景不空** | 弹窗背景用半透明模糊,让上一层内容微微可见 | 纯 `#000` 或 `rgba(0,0,0,0.9)` 遮罩 |
|
||||||
|
| **过渡不硬切** | 面板显隐用 `opacity 0.2-0.3s` 淡出淡入 | `v-if` 瞬间闪现/消失 |
|
||||||
|
| **光标不突兀** | 无操作 3s 后 `cursor: none`,动则恢复 | 播放全程显示鼠标指针 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 反馈灵敏(Responsive)
|
||||||
|
|
||||||
|
每个操作都要有响应,每个状态都要有表达。
|
||||||
|
|
||||||
|
| 规则 | 实践 | 反例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **点击三态** | hover(色变/缩放)→ active(按下)→ focus-visible(键盘焦点) | 只有 flat 按钮无 hover |
|
||||||
|
| **加载有状态** | 异步操作(加载/存档)有转圈或进度条 | 静默等待无反馈 |
|
||||||
|
| **错误有提示** | toast 短暂浮现提示,不阻断操作 | `alert()` 弹窗 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计检查清单
|
||||||
|
|
||||||
|
每次新增 UI 或修改布局时,逐条自查:
|
||||||
|
|
||||||
|
- [ ] 主要信息是否占据最大面积?(内容优先 #1)
|
||||||
|
- [ ] 每个按钮都是必需的吗?有没有可以删的?(最少 UI #2)
|
||||||
|
- [ ] 操作入口是否唯一?关闭方式是否与项目其他位置一致?(一致性 #3)
|
||||||
|
- [ ] 信息是否分层展示而非一屏堆满?(渐进展示 #4)
|
||||||
|
- [ ] 背景有没有透出底层内容?过渡是否柔和?(氛围 #5)
|
||||||
|
- [ ] hover / active / focus-visible 三态是否齐全?加载失败有没有提示?(反馈 #6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 业界参考
|
||||||
|
|
||||||
|
| 来源 | 精髓 |
|
||||||
|
|------|------|
|
||||||
|
| **Netflix Bandersnatch** | 选项 ≤3 个、10 秒自动选择、播放时零 HUD |
|
||||||
|
| **Apple tvOS HIG** | Focus Engine、声音反馈、间接操控 |
|
||||||
|
| **PlayStation UX** | Safe Area(95% 区域)、字体 ≥ 24px |
|
||||||
|
| **Quantic Dream (Detroit)** | 叙境式 UI(毛玻璃 + 电影感)、Diegetic UI |
|
||||||
|
| **Supergiant (Hades)** | 极简 HUD、对话面板 ≤ 40% 屏幕 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> "在游戏里,每一个像素都是借来的。从画面借的,迟早要还。" —— 佚名
|
||||||
234
docs/E17_AI_ASSISTANT_PROPOSAL.md
Normal file
234
docs/E17_AI_ASSISTANT_PROPOSAL.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# E17: AI 编码助手 — opencode + DeepSeek 集成
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
编辑器内嵌 AI 对话面板,使用 opencode Agent + DeepSeek 后端,支持两种模式:
|
||||||
|
- **JSON 模式** — 修改场景配置,填充 textarea 供用户审查后接受
|
||||||
|
- **代码模式** — 直接修改 `src/` 目录下的 Vue 组件和 CSS,Vite HMR 实时预览
|
||||||
|
|
||||||
|
## 完整架构
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器 (Editor)
|
||||||
|
├── AIPanel.vue 用户输入自然语言
|
||||||
|
├── NodeEditor.vue 接收 AI 返回的 JSON,[接受]/[撤销]
|
||||||
|
└── App.vue 管理 AI 面板状态
|
||||||
|
|
||||||
|
↓ POST /api/ai { sessionId, userMessage, apiKey, mode, nodeId? }
|
||||||
|
|
||||||
|
Vite 中间件(零状态)
|
||||||
|
└── spawn('node_modules/.bin/opencode', ['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage])
|
||||||
|
├── fullMessage = modePrefix + userMessage
|
||||||
|
│ JSON模式: "JSON模式:只返回修改后的 JSON 文本,不要写任何文件。需求:..." + nodeJson
|
||||||
|
│ 代码模式: "代码模式:直接修改 src/ 下的源码文件并保存。需求:..."
|
||||||
|
├── opencode 自身管理会话上下文(--session 复用已有会话)
|
||||||
|
├── 自动读取项目文件构建 prompt → 调 DeepSeek API
|
||||||
|
└── 返回 stdout
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON 模式交互时序
|
||||||
|
|
||||||
|
```
|
||||||
|
用户 编辑器 Vite 中间件 opencode DeepSeek
|
||||||
|
│ │ │ │ │
|
||||||
|
│ 选中节点+输入需求 │ │ │ │
|
||||||
|
│─────────────────────→│ │ │ │
|
||||||
|
│ │ POST /api/ai │ │ │
|
||||||
|
│ │──────────────────────→│ │ │
|
||||||
|
│ │ │ spawn opencode │ │
|
||||||
|
│ │ │───────────────────→│ │
|
||||||
|
│ │ │ │ 读 story.json + │
|
||||||
|
│ │ │ │ SPEC.md + 用户需求 │
|
||||||
|
│ │ │ │────────────────────→│
|
||||||
|
│ │ │ │ 返回 JSON │
|
||||||
|
│ │ │ │←────────────────────│
|
||||||
|
│ │ │ stdout: JSON │ │
|
||||||
|
│ │ │←───────────────────│ │
|
||||||
|
│ │ 200 { result } │ │ │
|
||||||
|
│ │←──────────────────────│ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ 显示 [接受] [撤销] │ │ │ │
|
||||||
|
│─────→ textarea 填充 │ │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ 点击 [接受] │ │ │ │
|
||||||
|
│─────────────────────→│ │ │ │
|
||||||
|
│ │ JSON.parse → update │ │ │
|
||||||
|
│ │ → autoSave 写磁盘 │ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码模式交互时序
|
||||||
|
|
||||||
|
```
|
||||||
|
用户 编辑器 Vite 中间件 opencode DeepSeek
|
||||||
|
│ │ │ │ │
|
||||||
|
│ 输入 UI 修改需求 │ │ │ │
|
||||||
|
│─────────────────────→│ │ │ │
|
||||||
|
│ │ POST /api/ai │ │ │
|
||||||
|
│ │──────────────────────→│ │ │
|
||||||
|
│ │ │ spawn opencode │ │
|
||||||
|
│ │ │───────────────────→│ │
|
||||||
|
│ │ │ │ 读 src/components/ │
|
||||||
|
│ │ │ │ 改 Vue/CSS 文件 │
|
||||||
|
│ │ │ │────────────────────→│
|
||||||
|
│ │ │ │ 写文件到 src/ │
|
||||||
|
│ │ │ │←────────────────────│
|
||||||
|
│ │ │ stdout: done │ │
|
||||||
|
│ │ │←───────────────────│ │
|
||||||
|
│ │ 200 { result } │ │ │
|
||||||
|
│ │←──────────────────────│ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ Vite HMR 检测变化 │ │ │ │
|
||||||
|
│←─────────────────────│ │ │ │
|
||||||
|
│ 浏览器热更新预览 │ │ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键设计决策
|
||||||
|
|
||||||
|
| 决策 | 做法 | 原因 |
|
||||||
|
|------|------|------|
|
||||||
|
| **AI 后端** | opencode Agent + DeepSeek | 唯一覆盖 JSON + 代码双模式;自动读取项目文件构建上下文 |
|
||||||
|
| **opencode 安装** | npm 包 `opencode-ai`,作为 `devDependencies`。`npm install` 后 `node_modules/.bin/opencode` 即可用 | clone 即用,无需手动全局安装 |
|
||||||
|
| **API Key 存储** | 编辑器设置中输入,存 localStorage,每次请求传给 `/api/ai` | 不硬编码,创作者自行管理 |
|
||||||
|
| **API Key 传输** | 通过 POST body 传到 Vite 中间件,不暴露在浏览器网络日志 | XSS 只能获取 localStorage,看不到服务端日志 |
|
||||||
|
| **JSON 编辑** | AI 返回 JSON 填充 textarea,用户审查后 [接受] 才保存 | 保留创作者对故事数据的最终控制权 |
|
||||||
|
| **代码编辑** | opencode 直接写 `src/` 目录,Vite HMR 秒级刷新 | 代码修改即时可视化,不需要手动刷新 |
|
||||||
|
| **上下文构建** | opencode 读取项目文件自成上下文 | 不需要手动拼系统 prompt |
|
||||||
|
| **模式切换** | AIPanel 当前模式标签:选中节点 → "JSON 模式";未选中节点 → "代码模式"。手动可切换 | 减少用户认知负担,大部分场景自动判断正确 |
|
||||||
|
| **DeepSeek 模型** | `--model deepseek`,通过 opencode providers 配置 Key | opencode 自身管理模型路由 |
|
||||||
|
|
||||||
|
## 会话管理
|
||||||
|
|
||||||
|
opencode 原生支持会话:`opencode run --session <id>` 复用已有上下文,`opencode session list` 列出历史。
|
||||||
|
|
||||||
|
### 架构原则:前端持 sessionId,中间件无状态
|
||||||
|
|
||||||
|
```
|
||||||
|
AIPanel (Vue)
|
||||||
|
├── sessionId = localStorage.getItem('editor_ai_session') || crypto.randomUUID()
|
||||||
|
├── 每次请求 POST /api/ai 带上 sessionId
|
||||||
|
├── "新建对话" → 新 UUID → 覆盖 localStorage
|
||||||
|
└── 历史列表 → GET /api/ai/sessions → opencode session list → 前端渲染
|
||||||
|
|
||||||
|
Vite 中间件 — 零状态
|
||||||
|
└── spawn('opencode', ['run', '--session', sessionId, '--format', 'json', userMessage])
|
||||||
|
└── 不存任何 sessionId,不维护子进程
|
||||||
|
```
|
||||||
|
|
||||||
|
### 会话操作
|
||||||
|
|
||||||
|
| 操作 | opencode CLI | 触发方式 |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| 首次对话 | `opencode run --session <新UUID> --model deepseek --format json "..."` | AIPanel 检测 localStorage 无 sessionId |
|
||||||
|
| 继续对话 | `opencode run --session <已有UUID> --model deepseek --format json "..."` | 带上同一 sessionId |
|
||||||
|
| 新建对话 | 前端生成新 UUID → 旧会话仍存在于 opencode 内部 DB | AIPanel "新建对话" 按钮 |
|
||||||
|
| 历史列表 | `opencode session list` | AIPanel 顶部下拉(可选) |
|
||||||
|
|
||||||
|
### 为什么不用长活子进程
|
||||||
|
|
||||||
|
| | 长活子进程 + stdin/stdout | **独立进程 + --session** |
|
||||||
|
|------|:--:|:--:|
|
||||||
|
| 进程管理 | 需管道通信、心跳检测 | `spawn` 等待 exit,0 行管理代码 |
|
||||||
|
| 中间件状态 | 需维护 sessionId→process Map | 零状态 |
|
||||||
|
| dev server 重启 | 会话丢失 | localStorage 持久化,重启可恢复 |
|
||||||
|
| 稳定性 | 子进程可能 crash | 每次独立进程,天然隔离 |
|
||||||
|
| 延迟 | ~1-3s(管道通信) | ~5s(启动 Node + 加载会话),对编辑器 AI 场景可接受 |
|
||||||
|
|
||||||
|
## 安全设计
|
||||||
|
|
||||||
|
| 层级 | 措施 |
|
||||||
|
|------|------|
|
||||||
|
| **API Key** | AES 加密存 localStorage?→ 不用,Key 本身是服务端 API 凭证,浏览器存储已是业界实践(VS Code Copilot、Cursor 同理) |
|
||||||
|
| **传输** | POST body 中传递,HTTPS 加密。仅 `/api/ai` 路由可读取,上游不打印日志 |
|
||||||
|
| **文件写入** | opencode 通过 Vite 中间件 spawn,中间件做路径白名单:JSON 模式只允许 `public/scenes/*.json`;代码模式只允许 `src/**/*.vue`、`src/**/*.ts`、`src/locales/*.json` |
|
||||||
|
| **请求频率** | 中间件加 3s 内去重(同一 mode+userMessage 不重复 spawn) |
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
| 错误场景 | 处理 |
|
||||||
|
|----------|------|
|
||||||
|
| DeepSeek API 超时(15s) | 中间件 kill 子进程,返回 `504 { error: "timeout" }` |
|
||||||
|
| opencode 进程崩溃(exit code ≠ 0) | 中间件返回 `500 { error: "opencode exited with code " + code }` |
|
||||||
|
| DeepSeek API 余额不足 | 中间件返回 `402 { error: "insufficient_quota" }` |
|
||||||
|
| opencode 返回非 JSON(代码模式) | 直接视为成功(stdout 文本无关紧要) |
|
||||||
|
| opencode 返回非 JSON(JSON 模式) | 中间件尝试用正则提取 JSON 块,无法提取则返回 `500` 给前端 |
|
||||||
|
|
||||||
|
## 文件改动清单
|
||||||
|
|
||||||
|
| 文件 | 职能 |
|
||||||
|
|------|------|
|
||||||
|
| `package.json` | 新增 `opencode-ai` devDependency(`"opencode-ai": "^1.17"`) |
|
||||||
|
| `vite.config.ts` | 新增 `POST /api/ai` 中间件:接收 sessionId/userMessage/apiKey/mode,构造 modePrefix + spawn(`opencode`, `['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage]`);`GET /api/ai/sessions` → `opencode session list` |
|
||||||
|
| `editor/composables/useAI.ts` | **新建** — `sendAIRequest(sessionId, userMessage, mode)` → fetch(`/api/ai`);`listSessions()` → fetch(`/api/ai/sessions`) |
|
||||||
|
| `editor/components/AIPanel.vue` | **新建** — 消息历史 + `<input>` + loading + "正在生成..." + **"新建对话"按钮** + 会话列表下拉 |
|
||||||
|
| `editor/stores/editorStore.ts` | 新增 `deepseekKey`(localStorage)、`showAIPanel`、`aiResult`、`sessionId` |
|
||||||
|
| `editor/App.vue` | 新增 "AI 助手" 按钮 + AIPanel 组件 + 设置面板中 API Key 输入栏 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### T1: JSON 模式 — 正常流程
|
||||||
|
|
||||||
|
| 步骤 | 预期结果 |
|
||||||
|
|------|---------|
|
||||||
|
| 选中一个场景节点 | NodeEditor 显示该场景 JSON |
|
||||||
|
| 打开 AIPanel,输入"给这个场景添加一个 QTE" | 显示 loading 状态 |
|
||||||
|
| opencode 返回修改后 JSON | NodeEditor textarea 被填充,出现 [接受] [撤销] |
|
||||||
|
| 点击 [接受] | JSON.parse 成功,store.updateScene 执行,autoSave 写磁盘 |
|
||||||
|
| 点击 [撤销] | textarea 回滚到 AI 修改前的值 |
|
||||||
|
|
||||||
|
### T2: JSON 模式 — opencode 返回无效 JSON
|
||||||
|
|
||||||
|
| 步骤 | 预期结果 |
|
||||||
|
|------|---------|
|
||||||
|
| AI 返回非法 JSON | 中间件返回 `500 { error }` |
|
||||||
|
| AIPanel 显示红色错误提示 | "AI 返回格式异常,请重试",textarea 不被填充 |
|
||||||
|
|
||||||
|
### T3: 代码模式 — 正常流程
|
||||||
|
|
||||||
|
| 步骤 | 预期结果 |
|
||||||
|
|------|---------|
|
||||||
|
| 未选中任何节点 | AIPanel 显示 "代码模式" 标签 |
|
||||||
|
| 输入"把按钮改成圆角 20px" | 显示 loading |
|
||||||
|
| opencode 修改 `src/components/ChoicePanel.vue` 的 CSS | 文件写磁盘成功 |
|
||||||
|
| Vite HMR 触发 | 预览窗按钮圆角即时可见 |
|
||||||
|
|
||||||
|
### T4: 会话管理
|
||||||
|
|
||||||
|
| 步骤 | 预期结果 |
|
||||||
|
|------|---------|
|
||||||
|
| 首次对话 | localStorage 无 `editor_ai_session` → AIPanel 自动生成 UUID |
|
||||||
|
| 第二次对话 | 带上同一 sessionId → opencode `--session <id>` 恢复上下文 |
|
||||||
|
| 点击 "新建对话" | 新 UUID 写入 localStorage |
|
||||||
|
| 刷新页面 | sessionId 不丢失,历史列表可查 |
|
||||||
|
|
||||||
|
### T5: 错误处理
|
||||||
|
|
||||||
|
| 步骤 | 预期结果 |
|
||||||
|
|------|---------|
|
||||||
|
| DeepSeek Key 无效 | AIPanel 显示 "API 认证失败,请检查 Key" |
|
||||||
|
| opencode 进程被 kill | 中间件 `child.on('exit', 137)` → 500 |
|
||||||
|
| 请求 15s 无响应 | 中间件 kill 子进程 → 504 |
|
||||||
|
| 同一 userMessage 3s 内发送两次 | 去重,不重复 spawn |
|
||||||
|
|
||||||
|
### T6: 路径白名单
|
||||||
|
|
||||||
|
| 步骤 | 预期结果 |
|
||||||
|
|------|---------|
|
||||||
|
| 代码模式让 AI 修改 `/etc/passwd` | 中间件拒绝或 opencode 写盘失败 |
|
||||||
|
| JSON 模式让 AI 修改 `../secret.json` | 路径不在 `public/scenes/*.json` 范围内, 被拒绝 |
|
||||||
|
|
||||||
|
### T7: API Key 管理
|
||||||
|
|
||||||
|
| 步骤 | 预期结果 |
|
||||||
|
|------|---------|
|
||||||
|
| 未填 Key 就发送请求 | AIPanel 提示 "请先在设置中输入 DeepSeek API Key" |
|
||||||
|
| 填入 Key → 发送请求 | Key 从 localStorage 读取,POST body 传给中间件 |
|
||||||
|
| 刷新页面 | Key 仍存在 |
|
||||||
|
|
||||||
|
### T8: NodeEditor 覆盖保护
|
||||||
|
|
||||||
|
| 步骤 | 预期结果 |
|
||||||
|
|------|---------|
|
||||||
|
| 用户正在手动编辑 textarea → AI 返回结果 | AI 结果填充到 textarea(覆盖手动编辑),可 [撤销] |
|
||||||
|
| AI 返回后用户手动再编辑 → 失焦 | 手动编辑内容通过 blur 保存(不是 AI 内容) |
|
||||||
336
docs/E18_VERSION_AND_DIFF_PROPOSAL.md
Normal file
336
docs/E18_VERSION_AND_DIFF_PROPOSAL.md
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
# E18: 版本回滚 & AI 修改标记
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
为编辑器的 AI 助手增加两个生产级功能:
|
||||||
|
1. **版本回滚** — AI 修改前自动存档,错误时可恢复到任意历史版本
|
||||||
|
2. **修改标记** — AI 修改后在 SceneGraph 节点上显示角标(新增/修改/删除),视觉可感知变更
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、版本回滚
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户发起 AI 请求
|
||||||
|
→ buildMessage → POST /api/ai
|
||||||
|
→ AIPanel.send() 在调用 sendAIRequest 前:
|
||||||
|
editorStore.saveVersion("AI 修改前")
|
||||||
|
→ AI 修改完成 → reloadFromDisk() → gameData 刷新
|
||||||
|
|
||||||
|
用户发现改错了
|
||||||
|
→ AIPanel 版本下拉 → 选择历史版本
|
||||||
|
→ editorStore.restoreVersion(index)
|
||||||
|
→ gameData = version.gameData → autoSave()
|
||||||
|
→ SceneGraph + NodeEditor 重绘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `editor/stores/editorStore.ts` | 新增 `versions` ref、`saveVersion(label)`、`restoreVersion(idx)` |
|
||||||
|
| `editor/db/editorDB.ts` | **新建** — IndexedDB 封装:`put`/`get`/`clear`,按 `sourcePath` 分组 |
|
||||||
|
| `editor/components/AIPanel.vue` | 新增版本下拉 UI(在聊天区顶部),send() 前调用 `saveVersion()` |
|
||||||
|
|
||||||
|
### 版本数据结构
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface EditorVersion {
|
||||||
|
id: string // crypto.randomUUID()
|
||||||
|
sourcePath: string // "/scenes/demo.json"
|
||||||
|
timestamp: number // Date.now()
|
||||||
|
label: string // "AI 修改前" / "初始版本"
|
||||||
|
gameData: GameData // 完整游戏数据快照
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IndexedDB 设计
|
||||||
|
|
||||||
|
```
|
||||||
|
数据库: editor_versions
|
||||||
|
表: versions
|
||||||
|
索引: sourcePath (查询同一文件的所有版本)
|
||||||
|
索引: timestamp (排序)
|
||||||
|
```
|
||||||
|
|
||||||
|
保留策略:同一 `sourcePath` 最多 20 个版本,超出删除最旧的。
|
||||||
|
|
||||||
|
### editorDB.ts API
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 保存版本
|
||||||
|
putVersion(v: EditorVersion): Promise<void>
|
||||||
|
|
||||||
|
// 获取某文件的所有版本(按时间倒序)
|
||||||
|
getVersions(sourcePath: string): Promise<EditorVersion[]>
|
||||||
|
|
||||||
|
// 清除某文件的所有版本(切换文件时)
|
||||||
|
clearVersions(sourcePath: string): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 版本 UI 设计
|
||||||
|
|
||||||
|
```
|
||||||
|
AIPanel 顶部
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 历史版本: [AI 修改前 ▼] │ ← 下拉列表,默认隐藏
|
||||||
|
│ 2026-06-15 14:30 AI 修改前 │
|
||||||
|
│ 2026-06-15 14:28 初始版本 │ ← restoreVersion 后刷新全部
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复后逻辑:
|
||||||
|
1. `gameData = version.gameData`
|
||||||
|
2. `autoSave()` 写回磁盘
|
||||||
|
3. `selectedNodeId = null`(重置选中)
|
||||||
|
4. AIPanel 聊天区显示 "已恢复到 'AI 修改前'"
|
||||||
|
5. 不清除该版本快照(用户可能想在不同版本间多次切换)
|
||||||
|
|
||||||
|
### saveVersion 触发时机
|
||||||
|
|
||||||
|
| 时机 | 标签 |
|
||||||
|
|------|------|
|
||||||
|
| AI 模式 `send()` 调用前(`reloadFromDisk` 之前) | `"AI 修改前"` |
|
||||||
|
| `App.vue` 首次加载 demo.json 后 | `"初始版本"` |
|
||||||
|
|
||||||
|
不自动在手动编辑时保存版本(用户手动点"保存版本"按钮即可,可选后续添加)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、AI 修改标记
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
reloadFromDisk() 前:
|
||||||
|
记下 preGameData = gameData(深拷贝)
|
||||||
|
|
||||||
|
reloadFromDisk() 后:
|
||||||
|
diff = computeDiff(preGameData, newGameData)
|
||||||
|
aiChanges = { added, modified, deleted }
|
||||||
|
|
||||||
|
SceneGraph:
|
||||||
|
渲染时根据 aiChanges 给对应节点加角标
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据结构
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AIDiff {
|
||||||
|
added: string[] // 新 gameData 中有,旧 gameData 中没有的 scene ID
|
||||||
|
modified: string[] // 相同 ID,JSON.stringify 不同的 scene ID
|
||||||
|
deleted: string[] // 旧 gameData 中有,新 gameData 中没有的 scene ID
|
||||||
|
globalFields: string[] // 变更的全局配置字段名,例如 ["title", "assetBase"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### diff 计算逻辑(editorStore.reloadFromDisk 内)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { scenes: oldScenes, ...oldGlobal } = oldGameData
|
||||||
|
const { scenes: newScenes, ...newGlobal } = newGameData
|
||||||
|
const diff: AIDiff = { added: [], modified: [], deleted: [], globalFields: [] }
|
||||||
|
|
||||||
|
for (const id of Object.keys(newScenes)) {
|
||||||
|
if (!oldScenes[id]) diff.added.push(id)
|
||||||
|
else if (JSON.stringify(oldScenes[id]) !== JSON.stringify(newScenes[id])) diff.modified.push(id)
|
||||||
|
}
|
||||||
|
for (const id of Object.keys(oldScenes)) {
|
||||||
|
if (!newScenes[id]) diff.deleted.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys({ ...oldGlobal, ...newGlobal })) {
|
||||||
|
if (JSON.stringify(oldGlobal[key]) !== JSON.stringify(newGlobal[key])) {
|
||||||
|
diff.globalFields.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时检测全局配置变更(startScene、title 等),记录到 `globalFields`。
|
||||||
|
|
||||||
|
### 角标视觉效果
|
||||||
|
|
||||||
|
Vue Flow 自定义节点模板(SceneGraph.vue 已有 `CustomNode` 组件):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐
|
||||||
|
│ ● NEW │ ← 绿色角标 (纯 CSS 色块)
|
||||||
|
│ scene_A │
|
||||||
|
└──────────────┘
|
||||||
|
|
||||||
|
┌──────────────┐
|
||||||
|
│ ● MOD │ ← 橙色角标
|
||||||
|
│ scene_B │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
CSS 实现:在节点元素右上角添加绝对定位的 `<span class="diff-badge">`。
|
||||||
|
|
||||||
|
### 全局配置变更标记
|
||||||
|
|
||||||
|
当 `aiChanges.globalFields` 非空时,在 `NodeEditor` 的"全局配置"标题区展示:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 全局配置 ● 已修改 3 字段 │ ← 橙色圆点 + 变更字段数
|
||||||
|
│ ╭──────────────────╮ │
|
||||||
|
│ │ title: "xxx" │ │ ← 鼠标 hover 时弹出 tooltip
|
||||||
|
│ │ startScene: "yyy" │ │ 列出具体变更的字段名
|
||||||
|
│ │ assetBase: "zzz" │ │
|
||||||
|
│ ╰──────────────────╯ │
|
||||||
|
│ { │
|
||||||
|
│ "title": "xxx", ← 正常 JSON textarea 不变
|
||||||
|
│ ... │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**不变更项占位**:用逗号分隔字段名,如 `已修改 3 字段`;hover 列出所有字段名。
|
||||||
|
|
||||||
|
**清除**:用户 blur JSON textarea 后标记消失(手工编辑即为最终确认)。
|
||||||
|
|
||||||
|
### 标记清除时机
|
||||||
|
|
||||||
|
| 条件 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 用户手动编辑任意节点后 blur 保存 | 清除 `aiChanges`,所有标记消失 |
|
||||||
|
| 用户手动编辑全局配置后 blur 保存 | 清除全局配置标记 |
|
||||||
|
| AIPanel 点击"清除高亮"按钮 | 手动清除所有标记 |
|
||||||
|
| 新一轮 AI 修改 | 覆盖旧 diff(新 diff 替换旧 diff) |
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `editor/stores/editorStore.ts` | 新增 `aiChanges` ref、diff 计算逻辑、标记清除 |
|
||||||
|
| `editor/components/SceneGraph.vue` | watch `aiChanges`、自定义节点模板加角标 |
|
||||||
|
| `editor/components/NodeEditor.vue` | 全局配置标题加变更提示 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、完整交互时序
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入需求 "添加一个新场景 scene_battle"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AIPanel.send()
|
||||||
|
① saveVersion("AI 修改前") ← 快照
|
||||||
|
② preGameData = clone gameData ← 记下旧值
|
||||||
|
③ sendAIRequest(buildMessage(msg), ...)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
opencode 读文件 → 新增 scene_battle → 写盘 → 回复 "已创建 scene_battle"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AIPanel 收到 result
|
||||||
|
④ 聊天显示 result
|
||||||
|
⑤ store.reloadFromDisk()
|
||||||
|
├─ fetch → 新 gameData
|
||||||
|
├─ diff(oldGameData, newGameData) → aiChanges = { added: ["scene_battle"], modified: [], deleted: [], globalFields: [] }
|
||||||
|
├─ gameData = newGameData
|
||||||
|
└─ selectedNodeId = null
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SceneGraph 检测到 aiChanges 非空
|
||||||
|
→ 新增节点 scene_battle 渲染时显示绿色 "NEW" 角标
|
||||||
|
→ 已有节点无角标
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
用户手动编辑任一节点后 blur
|
||||||
|
→ store.clearAIMarkers()
|
||||||
|
→ 所有角标消失
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、改动清单
|
||||||
|
|
||||||
|
### editor/db/editorDB.ts(新建)
|
||||||
|
|
||||||
|
| API | 说明 |
|
||||||
|
|-----|------|
|
||||||
|
| `initDB()` | 打开/创建 `editor_versions` 数据库 |
|
||||||
|
| `putVersion(v)` | 存入版本快照 |
|
||||||
|
| `getVersions(sourcePath)` | 获取某文件所有版本(按时间倒序,最多 20) |
|
||||||
|
| `clearVersions(sourcePath)` | 清除某文件的版本(切换文件/删除旧版本时) |
|
||||||
|
|
||||||
|
### editor/stores/editorStore.ts
|
||||||
|
|
||||||
|
| 新增 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `versions: Ref<EditorVersion[]>` | 当前文件的所有历史版本 |
|
||||||
|
| `aiChanges: Ref<AIDiff>` | 上次 AI 修改的 diff 结果 |
|
||||||
|
| `saveVersion(label)` | 深拷贝 gameData → 存 IndexedDB → 更新 versions |
|
||||||
|
| `restoreVersion(idx)` | gameData = versions[idx].gameData → autoSave |
|
||||||
|
| `reloadFromDisk()` 增强 | diff 计算 + 设置 aiChanges |
|
||||||
|
| `clearAIMarkers()` | aiChanges = null |
|
||||||
|
| `loadVersions()` | 加载当前 sourcePath 的版本列表 |
|
||||||
|
|
||||||
|
### editor/components/SceneGraph.vue
|
||||||
|
|
||||||
|
| 新增 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `watch(aiChanges)` | 重新渲染时按 aiChanges 给节点加 badge |
|
||||||
|
| CSS `.diff-badge` / `.diff-badge-added` / `.diff-badge-modified` / `.diff-badge-deleted` | 角标样式 |
|
||||||
|
|
||||||
|
### editor/components/AIPanel.vue
|
||||||
|
|
||||||
|
| 新增 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 版本下拉 UI | `<select v-model="selectedVersion">` + 恢复按钮 |
|
||||||
|
| `send()` 前调 `saveVersion("AI 修改前")` | 在 `loading = true` 前 |
|
||||||
|
| "清除高亮" 按钮 | 调用 `store.clearAIMarkers()` |
|
||||||
|
|
||||||
|
### editor/components/NodeEditor.vue
|
||||||
|
|
||||||
|
| 新增 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 全局配置标题加标记 | `全局配置 ● 已修改 N 字段` + hover tooltip 显示字段名,当 `aiChanges.globalFields` 非空 |
|
||||||
|
| `onBlur()` 时调 `clearAIMarkers()` | 编辑后清除标记 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、非侵入性保证
|
||||||
|
|
||||||
|
| 项 | 保障 |
|
||||||
|
|----|------|
|
||||||
|
| IndexedDB 故障容忍 | try/catch 包裹所有操作,失败不影响主流程 |
|
||||||
|
| 版本存储增长 | 20 个上限 + 切换文件时清空旧文件的版本 |
|
||||||
|
| SceneGraph 性能 | diff 比较仅用 `Object.keys` + `JSON.stringify`,百级节点 < 2ms |
|
||||||
|
| 不影响手动编辑 | `clearAIMarkers()` 在 blur 时调用,角标不留存 |
|
||||||
|
| 不影响 Code 模式 | 仅在 JSON 模式 `reloadFromDisk()` 时做 diff |
|
||||||
|
| 不使用 emoji | CSS 纯色圆点 + 文字实现角标 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、测试用例
|
||||||
|
|
||||||
|
### V1:版本自动保存
|
||||||
|
| 步骤 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| 编辑器中加载 demo.json | 自动创建版本 "初始版本" |
|
||||||
|
| 打开 AIPanel,JSON 模式发送"添加场景" | 自动创建版本 "AI 修改前" |
|
||||||
|
| 打开版本下拉 | 显示两个版本 |
|
||||||
|
|
||||||
|
### V2:版本恢复
|
||||||
|
| 步骤 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| AI 修改后选择 "AI 修改前" 版本 | 点击恢复 |
|
||||||
|
| 编辑器内容回到修改前 | SceneGraph + NodeEditor 刷新为旧内容 |
|
||||||
|
|
||||||
|
### V3:修改标记
|
||||||
|
| 步骤 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| AI 新增 scene_battle | SceneGraph 中该节点显示绿色 "NEW" 角标 |
|
||||||
|
| AI 修改 scene_start | 该节点显示橙色 "MOD" 角标 |
|
||||||
|
| AI 修改 title + startScene | NodeEditor 全局配置标题显示 "● 已修改 2 字段",hover 列出 title/startScene |
|
||||||
|
| 用户手动编辑任一节点后 blur | 所有角标消失 |
|
||||||
|
| 用户手动编辑全局配置后 blur | 全局配置标记消失 |
|
||||||
|
|
||||||
|
### V4:非侵入
|
||||||
|
| 步骤 | 预期 |
|
||||||
|
|------|------|
|
||||||
|
| 不通过 AI 直接手动编辑 JSON | 不触发版本保存,不显示角标 |
|
||||||
|
| 浏览器不支持 IndexedDB | 版本功能静默失效,不影响编辑 |
|
||||||
82
docs/EDITOR_ROADMAP.md
Normal file
82
docs/EDITOR_ROADMAP.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 编辑器 Roadmap
|
||||||
|
|
||||||
|
编辑器核心定位:**图谱可视化 + 实时预览测试**。场景字段由创作者在 VS Code 中直接写 JSON,编辑器不做 GUI 表单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
|
||||||
|
| 功能 | 完成 |
|
||||||
|
|------|:--:|
|
||||||
|
| 场景节点图(Vue Flow) | ✅ |
|
||||||
|
| JSON 导入/导出 | ✅ |
|
||||||
|
| 视频预览 | ✅ |
|
||||||
|
| Pinia 状态管理(editorStore) | ✅ |
|
||||||
|
| 纯函数数据层(GraphService) | ✅ |
|
||||||
|
| 图谱/预览切换 | ✅ |
|
||||||
|
| 节点右键测试(新标签页) | ✅ |
|
||||||
|
| 节点拖拽位置记忆 | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E10: 内嵌快速测试 ✅ 部分完成
|
||||||
|
|
||||||
|
- [x] 节点右键菜单 → "从此场景开始测试"
|
||||||
|
- [x] 新标签页打开游戏
|
||||||
|
- [x] Engine 支持 `?startScene=` URL 参数
|
||||||
|
- [ ] PreviewPanel 内嵌 iframe 游戏播放器(远期)
|
||||||
|
|
||||||
|
## E11: 场景列表 + 搜索
|
||||||
|
|
||||||
|
- [ ] 左侧可折叠场景列表面板(`SceneList.vue`)
|
||||||
|
- [ ] 按名称搜索/筛选场景节点
|
||||||
|
- [ ] 点击列表项 → 画布跳转到对应节点并选中
|
||||||
|
|
||||||
|
## E12: JSON 校验器
|
||||||
|
|
||||||
|
- [ ] 导出/保存前实时检查
|
||||||
|
- [ ] 引用完整性:targetScene 指向不存在的场景 ID
|
||||||
|
- [ ] 死路检测:无 choices / 无 nextScene / 无 qte / 无 hotspots
|
||||||
|
- [ ] 变量引用:conditions 中的 variable 未在 `variables` 声明
|
||||||
|
|
||||||
|
## E13: 撤销/重做
|
||||||
|
|
||||||
|
- [ ] 操作历史栈(add/delete/update/move node & edge)
|
||||||
|
- [ ] Ctrl+Z 撤销 / Ctrl+Shift+Z 重做
|
||||||
|
|
||||||
|
## E16: NodeEditor → JSON 编辑器 ✅ 已完成
|
||||||
|
|
||||||
|
- [x] 删除现有 GUI 表单,改为 `<textarea>` + 等宽字体 + 深色背景
|
||||||
|
- [x] 初始值 `JSON.stringify(scene, null, 2)`
|
||||||
|
- [x] `@blur` → JSON.parse → `store.updateScene(id, parsed)`
|
||||||
|
- [x] 解析失败 → 红色边框 + 错误提示
|
||||||
|
|
||||||
|
## E17: AI 编码助手 — opencode + DeepSeek 集成
|
||||||
|
|
||||||
|
目标:编辑器内嵌 AI 对话面板。JSON 模式填充 textarea 供用户审查后接受;代码模式直接修改 Vue 组件/CSS,Vite HMR 实时预览。
|
||||||
|
|
||||||
|
详见 [E17_AI_ASSISTANT_PROPOSAL.md](../E17_AI_ASSISTANT_PROPOSAL.md)
|
||||||
|
|
||||||
|
- [x] `vite.config.ts` — `POST /api/ai` 中间件(spawn opencode)
|
||||||
|
- [x] `editor/composables/useAI.ts` — DeepSeek API 调用封装
|
||||||
|
- [x] `editor/components/AIPanel.vue` — AI 对话面板
|
||||||
|
- [x] `editor/components/NodeEditor.vue` — `aiResult` prop + [接受]/[撤销]
|
||||||
|
- [x] `editor/stores/editorStore.ts` — `deepseekKey` + `showAIPanel` + `aiResult`
|
||||||
|
- [x] `editor/App.vue` — "AI 助手" 按钮 + API Key 设置
|
||||||
|
|
||||||
|
**优先级建议:**
|
||||||
|
|
||||||
|
| 优先级 | 编号 | 说明 |
|
||||||
|
|:--:|------|------|
|
||||||
|
| **P0** | E17 | AI 编码助手 — DeepSeek 集成 ✅ 核心已完成 |
|
||||||
|
| **P0** | E16 | JSON 编辑器 ✅ 已完成 |
|
||||||
|
| **P0** | E10 | 内嵌快速测试 |
|
||||||
|
| **P1** | E12 | JSON 校验 |
|
||||||
|
| **P1** | E11 | 场景列表搜索 |
|
||||||
|
| **P2** | E13 | 撤销/重做 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 废弃项
|
||||||
|
|
||||||
|
E1~E9、E14 废弃。编辑器不再做 NodeEditor GUI 表单,场景字段由创作者在 JSON 中直接编写。
|
||||||
84
docs/MIRROR_QUICK_REFERENCE.md
Normal file
84
docs/MIRROR_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Electron 镜像源快速参考
|
||||||
|
|
||||||
|
## 🚀 快速解决
|
||||||
|
|
||||||
|
### 已配置完成 ✅
|
||||||
|
|
||||||
|
项目已经配置了镜像源,直接打包即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack:win # Windows
|
||||||
|
npm run pack:mac # Mac
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 配置位置
|
||||||
|
|
||||||
|
### 1. 项目根目录 `.npmrc`
|
||||||
|
```ini
|
||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. electron 目录 `.npmrc`
|
||||||
|
```ini
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. electron/package.json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"pack:win": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 验证配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm config get electron_mirror
|
||||||
|
# 应该输出: https://npmmirror.com/mirrors/electron/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ 故障排查
|
||||||
|
|
||||||
|
### 打包失败?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 清除缓存
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
# 2. 重新安装依赖
|
||||||
|
cd electron
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 3. 重新打包
|
||||||
|
npm run pack:win
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
echo %ELECTRON_MIRROR%
|
||||||
|
|
||||||
|
# Mac/Linux
|
||||||
|
echo $ELECTRON_MIRROR
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 可用镜像源
|
||||||
|
|
||||||
|
| 镜像源 | 地址 |
|
||||||
|
|--------|------|
|
||||||
|
| 淘宝 | `https://npmmirror.com/mirrors/electron/` |
|
||||||
|
| 华为云 | `https://mirrors.huaweicloud.com/electron/` |
|
||||||
|
| 腾讯云 | `https://mirrors.cloud.tencent.com/npm/electron/` |
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [完整指南](./electron-mirror-setup.md)
|
||||||
|
- [Electron 官方文档](https://www.electronjs.org/docs)
|
||||||
104
docs/QUICK_REFERENCE.md
Normal file
104
docs/QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Electron 打包快速参考
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
npm install
|
||||||
|
cd electron && npm install express
|
||||||
|
|
||||||
|
# 2. 开发
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 3. 构建
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 4. 打包
|
||||||
|
npm run pack:win # Windows
|
||||||
|
npm run pack:mac # Mac
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 核心特性
|
||||||
|
|
||||||
|
| 特性 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| ✅ 零源码修改 | 项目代码无需改动 |
|
||||||
|
| ✅ 自动端口查找 | 9527-9999 自动切换 |
|
||||||
|
| ✅ 本地服务器 | Express 提供静态文件 |
|
||||||
|
| ✅ 跨平台兼容 | 浏览器和 Electron 都能用 |
|
||||||
|
|
||||||
|
## 🔧 配置文件
|
||||||
|
|
||||||
|
### electron/server.js
|
||||||
|
```javascript
|
||||||
|
const START_PORT = 9527 // 起始端口
|
||||||
|
const MAX_PORT = 9999 // 最大端口
|
||||||
|
```
|
||||||
|
|
||||||
|
### electron/main.js
|
||||||
|
```javascript
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
fullscreen: true,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/ # 构建输出
|
||||||
|
electron/
|
||||||
|
├── main.js # 主进程
|
||||||
|
├── server.js # 本地服务器
|
||||||
|
└── package.json # Electron 依赖
|
||||||
|
release/ # 打包输出
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 工作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
开发: Vite (localhost:5173) → 浏览器 ✅
|
||||||
|
打包: Express (localhost:9527) → Electron ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
| 问题 | 解决方案 |
|
||||||
|
|------|---------|
|
||||||
|
| 白屏 | 检查 dist 是否复制到 electron |
|
||||||
|
| 端口冲突 | 系统自动处理,无需干预 |
|
||||||
|
| 资源加载失败 | 使用绝对路径 `/scenes/...` |
|
||||||
|
| 开发者工具 | 删除 `openDevTools()` 关闭 |
|
||||||
|
|
||||||
|
## 📊 端口查找
|
||||||
|
|
||||||
|
```
|
||||||
|
9527 → 可用 ✅
|
||||||
|
9528 → 被占用,跳过
|
||||||
|
9529 → 可用 ✅
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 安全建议
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false, // ✅ 禁用 Node
|
||||||
|
contextIsolation: true // ✅ 隔离上下文
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 注意事项
|
||||||
|
|
||||||
|
1. 只监听 `127.0.0.1`,不暴露到外网
|
||||||
|
2. 应用退出时自动关闭服务器
|
||||||
|
3. 端口范围耗尽会抛出错误
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [完整指南](./electron-packaging-guide.md)
|
||||||
|
- [端口查找](./electron-port-finder.md)
|
||||||
284
docs/electron/mirror-setup.md
Normal file
284
docs/electron/mirror-setup.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Electron 镜像源配置指南
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
打包 Electron 应用时,如果未开启翻墙,会出现 "fetch failed" 错误,这是因为 Electron 和相关工具需要从国外的 GitHub releases 下载二进制文件。
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
### 方案 1:使用 npm 配置(推荐)
|
||||||
|
|
||||||
|
#### 1.1 全局配置
|
||||||
|
|
||||||
|
在项目根目录创建 `.npmrc` 文件:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 Electron 目录配置
|
||||||
|
|
||||||
|
在 `electron/` 目录创建 `.npmrc` 文件:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案 2:使用环境变量(已配置)
|
||||||
|
|
||||||
|
#### 2.1 Windows
|
||||||
|
|
||||||
|
在 `electron/package.json` 中已经配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"pack:win": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ node ../scripts/prepare-electron.cjs && npx @electron/packager . MyGame --platform=win32 --arch=x64 --out=../release --overwrite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Mac/Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案 3:使用批处理脚本
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
使用 `scripts/pack-win.bat`:
|
||||||
|
|
||||||
|
```batch
|
||||||
|
@echo off
|
||||||
|
set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
|
set ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
call npm run pack:win
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mac/Linux
|
||||||
|
|
||||||
|
使用 `scripts/pack-mac.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
npm run pack:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案 4:使用系统环境变量
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 临时设置(当前会话)
|
||||||
|
set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
|
set ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
|
||||||
|
# 永久设置
|
||||||
|
setx ELECTRON_MIRROR https://npmmirror.com/mirrors/electron/
|
||||||
|
setx ELECTRON_BUILDER_BINARIES_MIRROR https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mac/Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 临时设置(当前会话)
|
||||||
|
export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
|
||||||
|
# 永久设置(添加到 ~/.bashrc 或 ~/.zshrc)
|
||||||
|
echo 'export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/' >> ~/.bashrc
|
||||||
|
echo 'export ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证配置
|
||||||
|
|
||||||
|
### 检查 npm 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm config get electron_mirror
|
||||||
|
npm config get electron_builder_binaries_mirror
|
||||||
|
```
|
||||||
|
|
||||||
|
应该输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://npmmirror.com/mirrors/electron/
|
||||||
|
https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查环境变量
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
echo %ELECTRON_MIRROR%
|
||||||
|
echo %ELECTRON_BUILDER_BINARIES_MIRROR%
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mac/Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo $ELECTRON_MIRROR
|
||||||
|
echo $ELECTRON_BUILDER_BINARIES_MIRROR
|
||||||
|
```
|
||||||
|
|
||||||
|
## 可用的镜像源
|
||||||
|
|
||||||
|
### 淘宝镜像(推荐)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 华为云镜像
|
||||||
|
|
||||||
|
```ini
|
||||||
|
electron_mirror=https://mirrors.huaweicloud.com/electron/
|
||||||
|
electron_builder_binaries_mirror=https://mirrors.huaweicloud.com/electron-builder-binaries/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 腾讯云镜像
|
||||||
|
|
||||||
|
```ini
|
||||||
|
electron_mirror=https://mirrors.cloud.tencent.com/npm/electron/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 配置后仍然失败
|
||||||
|
|
||||||
|
**A:** 检查以下几点:
|
||||||
|
|
||||||
|
1. 确认配置已生效
|
||||||
|
2. 清除 npm 缓存:`npm cache clean --force`
|
||||||
|
3. 删除 node_modules 重新安装:`rm -rf node_modules && npm install`
|
||||||
|
4. 检查网络连接
|
||||||
|
|
||||||
|
### Q2: 某些包仍然需要翻墙
|
||||||
|
|
||||||
|
**A:** 可能是其他依赖包的问题,可以:
|
||||||
|
|
||||||
|
1. 检查具体的错误信息
|
||||||
|
2. 使用 `.npmrc` 配置其他镜像源
|
||||||
|
3. 手动下载相关包
|
||||||
|
|
||||||
|
### Q3: 下载速度慢
|
||||||
|
|
||||||
|
**A:** 可以尝试:
|
||||||
|
|
||||||
|
1. 更换其他镜像源
|
||||||
|
2. 使用 CDN 加速
|
||||||
|
3. 使用代理
|
||||||
|
|
||||||
|
### Q4: 如何恢复默认配置
|
||||||
|
|
||||||
|
**A:** 删除或注释掉 `.npmrc` 中的相关配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm config delete electron_mirror
|
||||||
|
npm config delete electron_builder_binaries_mirror
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整的打包流程
|
||||||
|
|
||||||
|
### 1. 首次配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
cd electron
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 配置镜像源(已在 .npmrc 中配置)
|
||||||
|
# 无需额外操作
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 日常打包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
npm run pack:win
|
||||||
|
|
||||||
|
# Mac
|
||||||
|
npm run pack:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 故障排查
|
||||||
|
|
||||||
|
如果打包失败:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 检查配置
|
||||||
|
npm config get electron_mirror
|
||||||
|
|
||||||
|
# 2. 清除缓存
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
# 3. 重新安装依赖
|
||||||
|
cd electron
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 4. 重新打包
|
||||||
|
npm run pack:win
|
||||||
|
```
|
||||||
|
|
||||||
|
## 其他工具的镜像配置
|
||||||
|
|
||||||
|
### yarn
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn config set electron_mirror https://npmmirror.com/mirrors/electron/
|
||||||
|
yarn config set electron_builder_binaries_mirror https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
```
|
||||||
|
|
||||||
|
### pnpm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm config set electron_mirror https://npmmirror.com/mirrors/electron/
|
||||||
|
pnpm config set electron_builder_binaries_mirror https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
### 1. 使用缓存
|
||||||
|
|
||||||
|
Electron 下载的二进制文件会被缓存,后续打包会更快:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看缓存位置
|
||||||
|
npm config get cache
|
||||||
|
|
||||||
|
# Windows: C:\Users\<username>\AppData\Roaming\npm-cache
|
||||||
|
# Mac: ~/.npm
|
||||||
|
# Linux: ~/.npm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 离线模式
|
||||||
|
|
||||||
|
如果已经下载过 Electron,可以设置离线模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ELECTRON_BUILDER_CACHE=~/.electron
|
||||||
|
```
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
通过配置镜像源,你可以在不翻墙的情况下成功打包 Electron 应用。推荐使用:
|
||||||
|
|
||||||
|
1. **`.npmrc` 配置** - 最简单,一次配置永久生效
|
||||||
|
2. **环境变量** - 灵活,可以针对不同项目配置
|
||||||
|
3. **批处理脚本** - 方便,可以一键打包
|
||||||
|
|
||||||
|
现在你可以愉快地打包 Electron 应用了!🎉
|
||||||
269
docs/electron/packaging-guide.md
Normal file
269
docs/electron/packaging-guide.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# Electron 打包完整指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本指南介绍如何将 Vue/HTML 项目打包成 Electron 应用,采用**本地服务器方案**,无需修改源码。
|
||||||
|
|
||||||
|
## 核心优势
|
||||||
|
|
||||||
|
✅ **零源码修改** - 项目代码完全不需要改动
|
||||||
|
✅ **自动端口查找** - 自动处理端口冲突(9527-9999)
|
||||||
|
✅ **跨平台兼容** - 浏览器和 Electron 都能正常工作
|
||||||
|
✅ **易于维护** - 打包逻辑集中在 Electron 目录
|
||||||
|
✅ **性能优秀** - 本地服务器响应快速
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
branch-engine/
|
||||||
|
├── dist/ # 构建输出目录
|
||||||
|
├── electron/ # Electron 相关文件
|
||||||
|
│ ├── main.js # 主进程入口
|
||||||
|
│ ├── server.js # 本地服务器
|
||||||
|
│ └── package.json # Electron 依赖
|
||||||
|
├── scripts/
|
||||||
|
│ ├── prepare-electron.cjs # 打包前准备
|
||||||
|
│ └── pack-html.cjs # HTML 打包
|
||||||
|
├── src/ # Vue 源码
|
||||||
|
├── editor/ # 编辑器
|
||||||
|
└── package.json # 主项目配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
↓
|
||||||
|
Vite 开发服务器 (localhost:5173)
|
||||||
|
↓
|
||||||
|
浏览器访问 → 正常工作 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### 打包环境
|
||||||
|
```
|
||||||
|
npm run pack:win
|
||||||
|
↓
|
||||||
|
1. npm run build (生成 dist)
|
||||||
|
2. 复制 dist 到 electron
|
||||||
|
3. 启动 Express 服务器 (localhost:9527-9999)
|
||||||
|
4. Electron 加载 http://127.0.0.1:PORT ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
## 详细步骤
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 主项目依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Electron 依赖
|
||||||
|
cd electron
|
||||||
|
npm install express
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建 Vue 项目
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 打包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 打包 Windows 版本
|
||||||
|
npm run pack:win
|
||||||
|
|
||||||
|
# 打包 Mac 版本
|
||||||
|
npm run pack:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 运行
|
||||||
|
|
||||||
|
打包完成后,在 `release/` 目录下找到生成的应用:
|
||||||
|
- Windows: `release/MyGame-win32-x64/MyGame.exe`
|
||||||
|
- Mac: `release/MyGame-mas-x64/MyGame.app`
|
||||||
|
|
||||||
|
## 核心文件说明
|
||||||
|
|
||||||
|
### electron/main.js
|
||||||
|
|
||||||
|
主进程入口,负责:
|
||||||
|
- 启动本地服务器
|
||||||
|
- 创建浏览器窗口
|
||||||
|
- 加载应用
|
||||||
|
- 处理应用生命周期
|
||||||
|
|
||||||
|
### electron/server.js
|
||||||
|
|
||||||
|
本地服务器,负责:
|
||||||
|
- 自动查找可用端口(9527-9999)
|
||||||
|
- 提供静态文件服务
|
||||||
|
- 处理资源请求
|
||||||
|
|
||||||
|
### scripts/prepare-electron.cjs
|
||||||
|
|
||||||
|
打包前准备脚本,负责:
|
||||||
|
- 将 dist 目录复制到 electron
|
||||||
|
- 验证构建文件
|
||||||
|
|
||||||
|
## 端口自动查找
|
||||||
|
|
||||||
|
### 功能特性
|
||||||
|
- 起始端口:9527
|
||||||
|
- 最大端口:9999
|
||||||
|
- 自动检测:逐个检测端口可用性
|
||||||
|
- 冲突处理:自动跳过被占用的端口
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
#### 正常情况
|
||||||
|
```
|
||||||
|
✅ Local server running at http://127.0.0.1:9527
|
||||||
|
🚀 Loading app from: http://127.0.0.1:9527/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 端口冲突
|
||||||
|
```
|
||||||
|
🔒 Port 9527 is blocked
|
||||||
|
✅ Local server running at http://127.0.0.1:9528
|
||||||
|
🚀 Loading app from: http://127.0.0.1:9528/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置选项
|
||||||
|
|
||||||
|
### 修改端口范围
|
||||||
|
|
||||||
|
编辑 `electron/server.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const START_PORT = 9527 // 起始端口
|
||||||
|
const MAX_PORT = 9999 // 最大端口
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改窗口配置
|
||||||
|
|
||||||
|
编辑 `electron/main.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
fullscreen: true, // 全屏模式
|
||||||
|
autoHideMenuBar: true, // 隐藏菜单栏
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false, // 禁用 node 集成
|
||||||
|
contextIsolation: true // 启用上下文隔离
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 打包后白屏
|
||||||
|
**A:** 检查 dist 目录是否正确复制到 electron 目录。
|
||||||
|
|
||||||
|
### Q2: 端口被占用
|
||||||
|
**A:** 系统会自动查找下一个可用端口,无需手动处理。
|
||||||
|
|
||||||
|
### Q3: 资源加载失败
|
||||||
|
**A:** 确保使用绝对路径(如 `/scenes/config.json`),本地服务器会正确处理。
|
||||||
|
|
||||||
|
### Q4: 开发者工具如何关闭
|
||||||
|
**A:** 删除 `electron/main.js` 中的 `win.webContents.openDevTools()` 行。
|
||||||
|
|
||||||
|
### Q5: 如何添加应用图标
|
||||||
|
**A:** 在打包配置中添加图标文件路径。
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
### 1. 减小包体积
|
||||||
|
```bash
|
||||||
|
# 使用 electron-builder 替代 electron-packager
|
||||||
|
npm install --save-dev electron-builder
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启用代码压缩
|
||||||
|
在 `vite.config.ts` 中配置:
|
||||||
|
```typescript
|
||||||
|
build: {
|
||||||
|
minify: 'terser',
|
||||||
|
terserOptions: {
|
||||||
|
compress: {
|
||||||
|
drop_console: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 优化资源加载
|
||||||
|
- 使用 CDN 加载第三方库
|
||||||
|
- 启用 gzip 压缩
|
||||||
|
- 懒加载非关键资源
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
1. **禁用 Node 集成**
|
||||||
|
```javascript
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **只监听本地地址**
|
||||||
|
```javascript
|
||||||
|
server.listen(PORT, '127.0.0.1')
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **验证用户输入**
|
||||||
|
- 检查 URL 参数
|
||||||
|
- 验证文件路径
|
||||||
|
- 防止 XSS 攻击
|
||||||
|
|
||||||
|
## 调试技巧
|
||||||
|
|
||||||
|
### 查看服务器日志
|
||||||
|
```javascript
|
||||||
|
console.log('Server started on port:', PORT)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看网络请求
|
||||||
|
打开开发者工具 → Network 标签,查看所有请求。
|
||||||
|
|
||||||
|
### 查看控制台错误
|
||||||
|
打开开发者工具 → Console 标签,查看错误信息。
|
||||||
|
|
||||||
|
## 部署建议
|
||||||
|
|
||||||
|
### 1. 代码签名
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
electron-builder --win --x64 --publish never
|
||||||
|
|
||||||
|
# Mac
|
||||||
|
electron-builder --mac --x64 --publish never
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 自动更新
|
||||||
|
使用 `electron-updater` 实现自动更新功能。
|
||||||
|
|
||||||
|
### 3. 安装包配置
|
||||||
|
在 `electron-builder.yml` 中配置安装选项。
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
本方案采用**本地服务器 + 自动端口查找**的方式,完美解决了 Electron 打包中的路径问题,同时保持了源码的纯净性。这是一个简单、优雅、可维护的解决方案。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [Electron 官方文档](https://www.electronjs.org/docs)
|
||||||
|
- [Express 文档](https://expressjs.com/)
|
||||||
|
- [Vite 文档](https://vitejs.dev/)
|
||||||
107
docs/electron/port-finder.md
Normal file
107
docs/electron/port-finder.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Electron 端口自动查找功能
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
Electron 应用启动时会自动在 9527-9999 端口范围内寻找可用端口,避免端口冲突问题。
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
### 1. 端口检测
|
||||||
|
使用 Node.js 内置的 `net` 模块检测端口是否可用:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function isPortAvailable(port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.listen(port, '127.0.0.1', () => {
|
||||||
|
server.once('close', () => resolve(true))
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
server.on('error', () => resolve(false))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 端口查找
|
||||||
|
从 9527 开始向上查找,直到找到可用端口:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function findAvailablePort(startPort, maxPort) {
|
||||||
|
for (let port = startPort; port <= maxPort; port++) {
|
||||||
|
const available = await isPortAvailable(port)
|
||||||
|
if (available) {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No available port found between ${startPort} and ${maxPort}`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 服务器启动
|
||||||
|
找到可用端口后启动 Express 服务器:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const serverInfo = await startServer()
|
||||||
|
const { server, PORT } = serverInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置参数
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `START_PORT` | 9527 | 起始端口 |
|
||||||
|
| `MAX_PORT` | 9999 | 最大端口 |
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 正常情况
|
||||||
|
```
|
||||||
|
✅ Local server running at http://127.0.0.1:9527
|
||||||
|
🚀 Loading app from: http://127.0.0.1:9527/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### 端口冲突情况
|
||||||
|
```
|
||||||
|
🔒 Port 9527 is blocked
|
||||||
|
✅ Local server running at http://127.0.0.1:9528
|
||||||
|
🚀 Loading app from: http://127.0.0.1:9528/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## 优势
|
||||||
|
|
||||||
|
1. **自动处理冲突** - 无需手动指定端口
|
||||||
|
2. **范围可控** - 限制在 9527-9999 范围内
|
||||||
|
3. **快速响应** - 端口检测速度快
|
||||||
|
4. **零依赖** - 使用 Node.js 内置模块
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **端口范围** - 如果 9527-9999 都被占用,会抛出错误
|
||||||
|
2. **监听地址** - 只监听 `127.0.0.1`,不暴露到外网
|
||||||
|
3. **服务器关闭** - 应用退出时会自动关闭服务器
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
### 测试端口查找
|
||||||
|
```bash
|
||||||
|
node scripts/test-port-finder.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试端口冲突
|
||||||
|
```bash
|
||||||
|
node scripts/test-port-conflict.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 端口范围耗尽
|
||||||
|
如果出现 "No available port found" 错误:
|
||||||
|
1. 检查是否有其他应用占用了大量端口
|
||||||
|
2. 增加 `MAX_PORT` 的值
|
||||||
|
3. 关闭不必要的应用释放端口
|
||||||
|
|
||||||
|
### 服务器启动失败
|
||||||
|
如果服务器启动失败:
|
||||||
|
1. 检查 dist 目录是否存在
|
||||||
|
2. 检查文件权限
|
||||||
|
3. 查看控制台错误信息
|
||||||
237
docs/electron/window-controls.md
Normal file
237
docs/electron/window-controls.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# Electron 窗口控制指南
|
||||||
|
|
||||||
|
## 窗口功能
|
||||||
|
|
||||||
|
### 基本控制
|
||||||
|
|
||||||
|
| 功能 | 操作 |
|
||||||
|
|------|------|
|
||||||
|
| 调整大小 | 拖动窗口边缘 |
|
||||||
|
| 最小化 | 点击窗口右上角的 `-` 按钮 |
|
||||||
|
| 最大化 | 点击窗口右上角的 `□` 按钮 |
|
||||||
|
| 还原 | 再次点击最大化按钮 |
|
||||||
|
| 关闭 | 点击窗口右上角的 `×` 按钮 |
|
||||||
|
|
||||||
|
### 快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 功能 |
|
||||||
|
|--------|------|
|
||||||
|
| `F11` | 切换全屏模式 |
|
||||||
|
| `Alt + F4` | 退出应用 |
|
||||||
|
| `Alt + F4` (Mac) | `Cmd + Q` |
|
||||||
|
|
||||||
|
## 窗口配置
|
||||||
|
|
||||||
|
### 当前设置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
width: 1280, // 初始宽度
|
||||||
|
height: 720, // 初始高度
|
||||||
|
minWidth: 800, // 最小宽度
|
||||||
|
minHeight: 600, // 最小高度
|
||||||
|
resizable: true, // 允许调整大小
|
||||||
|
maximizable: true, // 允许最大化
|
||||||
|
minimizable: true, // 允许最小化
|
||||||
|
closable: true, // 允许关闭
|
||||||
|
autoHideMenuBar: false // 显示菜单栏
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义窗口大小
|
||||||
|
|
||||||
|
如果你想修改默认窗口大小,编辑 `electron/main.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1920, // 修改为你想要的宽度
|
||||||
|
height: 1080, // 修改为你想要的高度
|
||||||
|
minWidth: 1024, // 修改为你想要的最小宽度
|
||||||
|
minHeight: 768, // 修改为你想要的最小高度
|
||||||
|
// ... 其他配置
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动时最大化
|
||||||
|
|
||||||
|
如果你希望应用启动时自动最大化,修改 `electron/main.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
// ... 其他配置
|
||||||
|
})
|
||||||
|
|
||||||
|
// 在创建窗口后添加
|
||||||
|
win.maximize()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动时全屏
|
||||||
|
|
||||||
|
如果你希望应用启动时全屏,修改 `electron/main.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
// ... 其他配置
|
||||||
|
fullscreen: true // 启用全屏
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 菜单栏
|
||||||
|
|
||||||
|
应用会显示 Electron 默认菜单栏,包含以下菜单:
|
||||||
|
|
||||||
|
### File 菜单
|
||||||
|
- **New Window** - 打开新窗口
|
||||||
|
- **Close** - 关闭当前窗口
|
||||||
|
- **Quit** - 退出应用
|
||||||
|
|
||||||
|
### Edit 菜单
|
||||||
|
- **Undo** - 撤销
|
||||||
|
- **Redo** - 重做
|
||||||
|
- **Cut** - 剪切
|
||||||
|
- **Copy** - 复制
|
||||||
|
- **Paste** - 粘贴
|
||||||
|
- **Select All** - 全选
|
||||||
|
|
||||||
|
### View 菜单
|
||||||
|
- **Reload** - 重新加载页面
|
||||||
|
- **Force Reload** - 强制重新加载
|
||||||
|
- **Toggle Developer Tools** - 切换开发者工具
|
||||||
|
- **Toggle Full Screen** - 切换全屏
|
||||||
|
- **Zoom In** - 放大
|
||||||
|
- **Zoom Out** - 缩小
|
||||||
|
- **Reset Zoom** - 重置缩放
|
||||||
|
|
||||||
|
### Window 菜单
|
||||||
|
- **Minimize** - 最小化
|
||||||
|
- **Zoom** - 最大化/还原
|
||||||
|
- **Front** - 置顶
|
||||||
|
|
||||||
|
## 隐藏菜单栏
|
||||||
|
|
||||||
|
如果你想隐藏菜单栏,修改 `electron/main.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
// ... 其他配置
|
||||||
|
autoHideMenuBar: true // 隐藏菜单栏
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义菜单栏
|
||||||
|
|
||||||
|
如果你想创建自定义菜单,在 `electron/main.js` 中添加:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { Menu } = require('electron')
|
||||||
|
|
||||||
|
const template = [
|
||||||
|
{
|
||||||
|
label: '游戏',
|
||||||
|
submenu: [
|
||||||
|
{ label: '新游戏', click: () => { /* 新游戏逻辑 */ } },
|
||||||
|
{ label: '继续游戏', click: () => { /* 继续游戏逻辑 */ } },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: '退出', click: () => app.quit() }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '设置',
|
||||||
|
submenu: [
|
||||||
|
{ label: '全屏', click: () => { win.setFullScreen(!win.isFullScreen()) } },
|
||||||
|
{ label: '开发者工具', click: () => { win.webContents.toggleDevTools() } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate(template)
|
||||||
|
Menu.setApplicationMenu(menu)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 窗口状态保存
|
||||||
|
|
||||||
|
如果你想记住窗口的大小和位置,可以使用以下代码:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const Store = require('electron-store')
|
||||||
|
const store = new Store()
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
// 获取保存的窗口状态
|
||||||
|
const windowState = store.get('windowState', {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
x: undefined,
|
||||||
|
y: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: windowState.width,
|
||||||
|
height: windowState.height,
|
||||||
|
x: windowState.x,
|
||||||
|
y: windowState.y,
|
||||||
|
// ... 其他配置
|
||||||
|
})
|
||||||
|
|
||||||
|
// 保存窗口状态
|
||||||
|
win.on('close', () => {
|
||||||
|
const bounds = win.getBounds()
|
||||||
|
store.set('windowState', {
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 边框窗口
|
||||||
|
|
||||||
|
如果你想要无边框窗口,修改 `electron/main.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
frame: false, // 无边框
|
||||||
|
transparent: true, // 透明背景
|
||||||
|
// ... 其他配置
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:无边框窗口需要你自己实现窗口控制按钮。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 如何在应用内切换全屏?
|
||||||
|
A: 按 `F11` 键或通过菜单栏 `View` → `Toggle Full Screen`
|
||||||
|
|
||||||
|
### Q: 如何隐藏开发者工具?
|
||||||
|
A: 删除 `electron/main.js` 中的 `win.webContents.openDevTools()` 行
|
||||||
|
|
||||||
|
### Q: 如何设置应用图标?
|
||||||
|
A: 在 `electron/main.js` 中设置 `icon` 选项
|
||||||
|
|
||||||
|
### Q: 如何防止用户调整窗口大小?
|
||||||
|
A: 设置 `resizable: false`
|
||||||
|
|
||||||
|
### Q: 如何设置窗口最小尺寸?
|
||||||
|
A: 设置 `minWidth` 和 `minHeight` 选项
|
||||||
|
|
||||||
|
## 性能建议
|
||||||
|
|
||||||
|
1. **合理的初始大小** - 设置合适的默认窗口大小
|
||||||
|
2. **最小尺寸限制** - 防止窗口过小导致内容无法显示
|
||||||
|
3. **响应式设计** - 确保应用在不同窗口尺寸下都能正常工作
|
||||||
|
4. **窗口状态保存** - 保存用户的首选窗口大小和位置
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
现在你的应用具有完整的窗口控制功能:
|
||||||
|
- ✅ 可调整大小
|
||||||
|
- ✅ 可最小化
|
||||||
|
- ✅ 可最大化
|
||||||
|
- ✅ 可关闭
|
||||||
|
- ✅ 快捷键支持
|
||||||
|
- ✅ 菜单栏支持
|
||||||
|
|
||||||
|
用户可以根据自己的喜好自由调整窗口大小和全屏模式!
|
||||||
112
docs/guide/BATTLE_SYSTEM.md
Normal file
112
docs/guide/BATTLE_SYSTEM.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# 战斗系统
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
P31 新增。分为两个独立功能:**战斗 HUD**(战斗中显示)和**战斗结算**(胜利后弹出)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BattleHUD — 战斗中显示的角色属性
|
||||||
|
|
||||||
|
配置在 `SceneNode.battleHUD`。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"battleHUD": [
|
||||||
|
{
|
||||||
|
"label": "主角",
|
||||||
|
"labelKey": "battle.hud.player",
|
||||||
|
"portrait": "images/player.jpg",
|
||||||
|
"stats": [
|
||||||
|
{ "variable": "player_hp", "label": "HP", "labelKey": "stat.hp", "max": 100 },
|
||||||
|
{ "variable": "player_mp", "label": "MP", "labelKey": "stat.mp", "max": 50 },
|
||||||
|
{ "variable": "score", "label": "连击", "labelKey": "stat.combo" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| stat 字段 | 说明 |
|
||||||
|
|-----------|------|
|
||||||
|
| `variable` | 变量名,值随 effect 实时变化 |
|
||||||
|
| `label` | 显示标签(回退值) |
|
||||||
|
| `labelKey` | i18n key |
|
||||||
|
| `max` | 最大值,有则为进度条,无则为纯数字 |
|
||||||
|
| `style` | 强制 `"bar"` 或 `"number"`。没配时根据有无 `max` 自动 |
|
||||||
|
|
||||||
|
进度条颜色根据百分比自动变化:100-50% 绿色 / 50-25% 橙色 / 25-0% 红色。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BattleResult — 胜利结算面板
|
||||||
|
|
||||||
|
配置在 `SceneNode.battleResult`。视频播放完后自动弹出。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"battleResult": {
|
||||||
|
"title": "击退成功!",
|
||||||
|
"titleKey": "battle.result.victory",
|
||||||
|
"stats": [
|
||||||
|
{ "label": "勇气", "labelKey": "stat.courage", "variable": "courage", "max": 100 },
|
||||||
|
{ "label": "QTE 成功", "labelKey": "stat.qte_succeeded", "variable": "qte_succeeded" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `title` | 结算标题(回退值) |
|
||||||
|
| `titleKey` | 标题 i18n key |
|
||||||
|
| `stats` | 统计项数组。`variable` / `label` / `labelKey` / `max` |
|
||||||
|
|
||||||
|
### 关键规则
|
||||||
|
|
||||||
|
- 战败场景**不配** `battleResult`——战败直接走视频叙事
|
||||||
|
- 结算面板关闭后自动跳转到 `nextScene` 或第一项 `choice` 目标
|
||||||
|
- stats 数量随意增减,面板自动适配
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整模板
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "combat",
|
||||||
|
"videoUrl": "combat/video.mp4",
|
||||||
|
"battleHUD": [ ... ],
|
||||||
|
"qte": {
|
||||||
|
"successScene": "combat_router",
|
||||||
|
"failScene": "defeat",
|
||||||
|
"effects": {
|
||||||
|
"success": [{ "type": "add", "target": "enemy_hp", "value": -25 }],
|
||||||
|
"fail": [{ "type": "add", "target": "player_hp", "value": -20 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nextScene": "combat_router"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "combat_router",
|
||||||
|
"keyMoment": false,
|
||||||
|
"nextScene": [
|
||||||
|
{ "conditions": [{ "variable": "enemy_hp", "op": "<=", "value": 0 }], "targetScene": "victory" },
|
||||||
|
{ "conditions": [{ "variable": "player_hp", "op": "<=", "value": 0 }], "targetScene": "defeat" },
|
||||||
|
{ "targetScene": "combat" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "victory",
|
||||||
|
"videoUrl": "victory/video.mp4",
|
||||||
|
"battleResult": { "title": "胜利!", "stats": [ ... ] },
|
||||||
|
"nextScene": "next_chapter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "defeat",
|
||||||
|
"videoUrl": "defeat/video.mp4",
|
||||||
|
"keyMoment": true,
|
||||||
|
"onEnter": [{ "type": "set", "target": "player_dead", "value": 1 }],
|
||||||
|
"nextScene": "game_over"
|
||||||
|
}
|
||||||
|
```
|
||||||
138
docs/guide/BRANCHING.md
Normal file
138
docs/guide/BRANCHING.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# 分支叙事指南
|
||||||
|
|
||||||
|
## 基本分支
|
||||||
|
|
||||||
|
最简单的分支:一个场景 → 多个选项 → 不同目标场景。
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scene_1": {
|
||||||
|
"id": "scene_1",
|
||||||
|
"videoUrl": "scene_1/video.mp4",
|
||||||
|
"choices": [
|
||||||
|
{ "text": "帮助他", "targetScene": "help" },
|
||||||
|
{ "text": "离开", "targetScene": "leave" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 条件分支
|
||||||
|
|
||||||
|
选项可以根据变量条件显示/隐藏:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"text": "高信任路线",
|
||||||
|
"targetScene": "trust_path",
|
||||||
|
"conditions": [
|
||||||
|
{ "variable": "trust", "op": ">=", "value": 80 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "text": "普通路线", "targetScene": "normal_path" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`op` 支持:`>`, `<`, `>=`, `<=`, `==`, `!=`。
|
||||||
|
|
||||||
|
## 变量与效果
|
||||||
|
|
||||||
|
全局变量在 JSON 顶层定义初始值:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"variables": { "trust": 50, "courage": 0, "investigation": 0 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
选项选择后应用效果:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "与陌生人握手",
|
||||||
|
"targetScene": "trust_ending",
|
||||||
|
"effects": [
|
||||||
|
{ "type": "add", "target": "trust", "value": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
效果类型:
|
||||||
|
- `"set"` — 设置变量为指定值
|
||||||
|
- `"add"` — 增加(负数=减少)
|
||||||
|
|
||||||
|
## 场景进入效果
|
||||||
|
|
||||||
|
进入场景时自动触发:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"ending": {
|
||||||
|
"id": "ending",
|
||||||
|
"videoUrl": "ending/video.mp4",
|
||||||
|
"onEnter": [
|
||||||
|
{ "type": "set", "target": "completed_game", "value": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 限时选择
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "快速决定!",
|
||||||
|
"targetScene": "timeout_scene",
|
||||||
|
"timeLimit": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
10 秒内不选,自动选这个选项。`timeLimit: 0` 或省略 = 不限时。
|
||||||
|
|
||||||
|
## 默认跳转
|
||||||
|
|
||||||
|
无选项或有选项但都不满足条件时,自动跳转:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scene": {
|
||||||
|
"id": "scene",
|
||||||
|
"videoUrl": "scene/video.mp4",
|
||||||
|
"choices": [], // 无选项时自动跳
|
||||||
|
"nextScene": "auto_next"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
优先级:choices > nextScene > 什么都没配(游戏结束)。
|
||||||
|
|
||||||
|
## 关键选择提示(Prompt)
|
||||||
|
|
||||||
|
重要选项可以配置前置金色标识 + 选后浮现提示:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "与陌生人握手",
|
||||||
|
"textKey": "left_door.choice.handshake",
|
||||||
|
"prompt": "陌生人会记住你的善意",
|
||||||
|
"promptKey": "left_door.prompt.handshake",
|
||||||
|
"targetScene": "trust_ending"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 前置:选项按钮左侧显示金色竖线 + 淡金边框
|
||||||
|
- 后置:选择确认后,画面中央浮现 prompt 文字,2 秒淡出
|
||||||
|
|
||||||
|
`promptKey` 支持 i18n,配置方法见 [国际化指南](I18N.md)。
|
||||||
|
|
||||||
|
## 多国语言选项
|
||||||
|
|
||||||
|
选项文案支持中/英/日等多语言,使用 `textKey` 机制:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "继续前进",
|
||||||
|
"textKey": "qte_success.choice.continue",
|
||||||
|
"targetScene": "continue_ending"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `text` 是回退值(翻译找不到时使用)
|
||||||
|
- `textKey` 指向 `public/locales/zh.json` 中的翻译
|
||||||
|
|
||||||
|
详细配置见 [国际化指南](I18N.md)。
|
||||||
36
docs/guide/CONDITIONAL_ROUTING.md
Normal file
36
docs/guide/CONDITIONAL_ROUTING.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 条件路由
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
P25 新增。`nextScene` 从单一场景 ID 扩展为**条件路由数组**。
|
||||||
|
第一个满足 `conditions` 的场景自动跳转,末尾无条件项作为默认兜底。
|
||||||
|
|
||||||
|
## 基本用法
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "combat_router",
|
||||||
|
"keyMoment": false,
|
||||||
|
"nextScene": [
|
||||||
|
{ "conditions": [{ "variable": "enemy_hp", "op": "<=", "value": 0 }], "targetScene": "victory" },
|
||||||
|
{ "conditions": [{ "variable": "player_hp", "op": "<=", "value": 0 }], "targetScene": "defeat" },
|
||||||
|
{ "targetScene": "combat" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解读:** 敌人 HP ≤ 0 → 胜利 / 自己 HP ≤ 0 → 战败 / 否则 → 回到战斗场景循环。
|
||||||
|
|
||||||
|
## 配合 QTE 使用
|
||||||
|
|
||||||
|
```
|
||||||
|
QTE 成功 → effects: enemy_hp -= 25
|
||||||
|
→ successScene = "combat_router"
|
||||||
|
├── enemy_hp <= 0 → victory 场景
|
||||||
|
├── player_hp <= 0 → defeat 场景
|
||||||
|
└── 否则 → 回到 QTE 场景(循环)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 向后兼容
|
||||||
|
|
||||||
|
旧的 `"nextScene": "alone_ending"` 写法**完全不受影响**。引擎自动区分字符串和数组。
|
||||||
15
docs/guide/CREATORS_GUIDE.md
Normal file
15
docs/guide/CREATORS_GUIDE.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 创作者指南
|
||||||
|
|
||||||
|
本文档集涵盖所有剧作和功能配置的教程。
|
||||||
|
|
||||||
|
| 文档 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| [QUICK_START.md](QUICK_START.md) | 快速上手——5 分钟跑通第一个场景 |
|
||||||
|
| [SCENE_JSON_SPEC.md](SCENE_JSON_SPEC.md) | 所有字段的完整参考手册 |
|
||||||
|
| [BRANCHING.md](BRANCHING.md) | 基本分支、条件分支、隐藏选项 |
|
||||||
|
| [CONDITIONAL_ROUTING.md](CONDITIONAL_ROUTING.md) | `nextScene` 数组——条件路由(P25) |
|
||||||
|
| [KEY_MOMENTS.md](KEY_MOMENTS.md) | StoryGallery 关键节点过滤(P26) |
|
||||||
|
| [BATTLE_SYSTEM.md](BATTLE_SYSTEM.md) | 战斗 HUD + 战斗结算面板(P31) |
|
||||||
|
| [INTERACTIONS.md](INTERACTIONS.md) | QTE、热点、循环等待 |
|
||||||
|
| [I18N.md](I18N.md) | 多语言字幕和 UI 国际化 |
|
||||||
|
| [PUBLISHING.md](PUBLISHING.md) | 打包发布——Web zip / macOS / Windows |
|
||||||
159
docs/guide/I18N.md
Normal file
159
docs/guide/I18N.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# 国际化指南(i18n)
|
||||||
|
|
||||||
|
## 双源体系
|
||||||
|
|
||||||
|
| 类别 | 位置 | 加载 | 维护者 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| **UI 文案**(按钮、菜单、设置) | `src/locales/{zh,en,ja}.json` | `import` 打包进 bundle | 引擎开发者 |
|
||||||
|
| **故事文案**(选项、提示、章节名) | `public/demo/locales/{zh,en,ja}.json` | `fetch()` 动态加载 | 故事创作者 |
|
||||||
|
|
||||||
|
**核心理念:** 游戏制作者只需编辑 `public/` 下的 JSON,刷新页面即生效,不需要重新打包。
|
||||||
|
|
||||||
|
## 数据层 i18n(故事文案)
|
||||||
|
|
||||||
|
### 架构
|
||||||
|
|
||||||
|
```json
|
||||||
|
// public/scenes/demo.json
|
||||||
|
{
|
||||||
|
"locales": { "path": "locales/", "languages": ["zh", "en", "ja"] },
|
||||||
|
"scenes": {
|
||||||
|
"intro": {
|
||||||
|
"choices": [
|
||||||
|
{ "text": "走向左边那扇发光的门", "textKey": "intro.choice.left_door", ... }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// public/demo/locales/zh.json
|
||||||
|
{
|
||||||
|
"intro": {
|
||||||
|
"choice": {
|
||||||
|
"left_door": "走向左边那扇发光的门"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// public/demo/locales/en.json
|
||||||
|
{
|
||||||
|
"intro": {
|
||||||
|
"choice": {
|
||||||
|
"left_door": "Walk toward the glowing door on the left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 所有支持 i18n 的数据字段
|
||||||
|
|
||||||
|
| 数据对象 | 字段 | key 字段 | 示例 key |
|
||||||
|
|---------|------|---------|----------|
|
||||||
|
| Choice | `text` | `textKey` | `"intro.choice.left_door"` |
|
||||||
|
| Choice | `prompt` | `promptKey` | `"left_door.prompt.handshake"` |
|
||||||
|
| QTE | `prompt` | `promptKey` | `"right_door.qte.dodge"` |
|
||||||
|
| Hotspot | `label` | `labelKey` | `"investigation_site.hotspot.desk"` |
|
||||||
|
| Chapter | `label` | `labelKey` | `"chapter.ch1"` |
|
||||||
|
| Ending | `label` | `labelKey` | `"ending.trust_end"` |
|
||||||
|
| Achievement | `title` | `titleKey` | `"achievement.qte_master.title"` |
|
||||||
|
| Achievement | `description` | `descKey` | `"achievement.qte_master.desc"` |
|
||||||
|
|
||||||
|
**规律:** 每个可国际化的数据字段都有对应的 `xxxKey` 版本。引擎先查 key,找不到时回退原始字段值。
|
||||||
|
|
||||||
|
### 回退逻辑
|
||||||
|
|
||||||
|
```
|
||||||
|
t('intro.choice.left_door')
|
||||||
|
→ 查 public/locales/{lang}.json
|
||||||
|
→ 找到了 → 返回翻译文本
|
||||||
|
→ 找不到 → 返回 choice.text("走向左边那扇发光的门")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Locale JSON 结构示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"intro": {
|
||||||
|
"choice": {
|
||||||
|
"left_door": "...",
|
||||||
|
"right_door": "...",
|
||||||
|
"search": "...",
|
||||||
|
"stay": "..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"left_door": {
|
||||||
|
"choice": {
|
||||||
|
"handshake": "...",
|
||||||
|
"reject": "..."
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"handshake": "..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right_door": {
|
||||||
|
"qte": { "dodge": "..." }
|
||||||
|
},
|
||||||
|
"chapter": {
|
||||||
|
"ch1": "第一章:醒来",
|
||||||
|
"ch2": "第二章:调查"
|
||||||
|
},
|
||||||
|
"ending": {
|
||||||
|
"trust_end": "信任的伙伴",
|
||||||
|
"alone_end": "独行之路"
|
||||||
|
},
|
||||||
|
"achievement": {
|
||||||
|
"qte_master": {
|
||||||
|
"title": "反应达人",
|
||||||
|
"desc": "成功完成一次 QTE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新增语言的方法
|
||||||
|
|
||||||
|
### 1. 注册到引擎
|
||||||
|
|
||||||
|
`src/locales/` 新增语言文件(复制 `en.json` 翻译):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp src/locales/en.json src/locales/ko.json
|
||||||
|
# 编辑 src/locales/ko.json → 翻译 UI 文案
|
||||||
|
# 编辑 src/composables/useI18n.ts → 新增 `import ko from '@/locales/ko.json'` + 加入 `uiMessages`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 注册到故事数据
|
||||||
|
|
||||||
|
`public/demo/locales/` 新增语言文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp public/demo/locales/en.json public/demo/locales/ko.json
|
||||||
|
# 编辑 → 翻译故事文案
|
||||||
|
```
|
||||||
|
|
||||||
|
`demo.json` 的 `locales.languages` 数组加 `"ko"`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"locales": { "path": "locales/", "languages": ["zh", "en", "ja", "ko"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 刷新页面
|
||||||
|
|
||||||
|
语言切换按钮自动新增韩语选项,无需重新构建。
|
||||||
|
|
||||||
|
## 配置语言目录
|
||||||
|
|
||||||
|
所有 locale 文件放在 `assetBase` 指定的基础路径下。JSON 中配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assetBase": "my_story/",
|
||||||
|
"locales": { "path": "lang/", "languages": ["zh", "en"] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
则语言文件路径为 `my_story/lang/zh.json`。
|
||||||
123
docs/guide/INTERACTIONS.md
Normal file
123
docs/guide/INTERACTIONS.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# 交互指南 — QTE / Hotspot / Loop
|
||||||
|
|
||||||
|
## QTE 快速反应事件
|
||||||
|
|
||||||
|
在视频播放到特定时间点时弹出按键提示,玩家在倒计时内按下指定按键。
|
||||||
|
|
||||||
|
```json
|
||||||
|
"qte": {
|
||||||
|
"triggerTime": 1.0,
|
||||||
|
"prompt": "躲避飞来的石块!",
|
||||||
|
"promptKey": "right_door.qte.dodge",
|
||||||
|
"keys": ["ArrowLeft", "ArrowRight", "a", "d"],
|
||||||
|
"timeLimit": 3.0,
|
||||||
|
"successScene": "qte_success",
|
||||||
|
"failScene": "qte_fail",
|
||||||
|
"effects": {
|
||||||
|
"success": [{ "type": "add", "target": "courage", "value": 15 }],
|
||||||
|
"fail": [{ "type": "add", "target": "trust", "value": -20 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `triggerTime` | 触发时间(秒) |
|
||||||
|
| `prompt` / `promptKey` | 提示文字 / i18n key |
|
||||||
|
| `keys` | 有效按键(键盘 key 名,不区分大小写) |
|
||||||
|
| `timeLimit` | 倒计时(秒) |
|
||||||
|
| `successScene` / `failScene` | 成功/失败目标场景 |
|
||||||
|
| `effects` | 成功/失败分别触发效果 |
|
||||||
|
|
||||||
|
**注意:** QTE 是模态交互。视频播放到 QTE 触发时暂停场景流程,QTE 期间视频结束事件被忽略。
|
||||||
|
|
||||||
|
**可访问性:** 玩家可在设置中开启"QTE 按键简化"(所有 QTE 统一为空格键)和"QTE 时限放宽 ×1.5"。
|
||||||
|
|
||||||
|
**禁止跳过:** QTE 场景建议设 `"skippable": false`,防止玩家跳过 QTE。
|
||||||
|
|
||||||
|
## 图片/视频热点(Hotspot)
|
||||||
|
|
||||||
|
在画面中划定可点击区域,玩家点击后触发分支。
|
||||||
|
|
||||||
|
### 图片热点
|
||||||
|
|
||||||
|
```json
|
||||||
|
"investigation_site": {
|
||||||
|
"type": "image",
|
||||||
|
"imageUrl": "investigation_site/scene.jpg",
|
||||||
|
"contentSize": { "w": 1280, "h": 720 },
|
||||||
|
"hotspots": [
|
||||||
|
{
|
||||||
|
"id": "hs_desk",
|
||||||
|
"label": "查看书桌",
|
||||||
|
"labelKey": "investigation_site.hotspot.desk",
|
||||||
|
"targetScene": "desk_detail",
|
||||||
|
"x": 154, "y": 144, "width": 230, "height": 101
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 视频热点(按时间显隐)
|
||||||
|
|
||||||
|
```json
|
||||||
|
"corridor": {
|
||||||
|
"videoUrl": "corridor/video.mp4",
|
||||||
|
"contentSize": { "w": 1280, "h": 720 },
|
||||||
|
"hotspots": [
|
||||||
|
{
|
||||||
|
"id": "hs_left",
|
||||||
|
"label": "走向左边通道",
|
||||||
|
"targetScene": "left_door",
|
||||||
|
"x": 26, "y": 216, "width": 384, "height": 324,
|
||||||
|
"showAt": 1.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hs_right",
|
||||||
|
"label": "走向右边通道",
|
||||||
|
"targetScene": "alone_ending",
|
||||||
|
"x": 870, "y": 216, "width": 384, "height": 324,
|
||||||
|
"showAt": 5.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `showAt` | 视频播放到此秒数后才显示热点(可选) |
|
||||||
|
| `hideAt` | 视频播放到此秒数后隐藏热点(可选) |
|
||||||
|
| `conditions` | 条件满足时才显示(可选) |
|
||||||
|
|
||||||
|
### 坐标系统
|
||||||
|
|
||||||
|
Hotspot 坐标使用**绝对像素**,基于 `contentSize` 指定的基准分辨率:
|
||||||
|
|
||||||
|
- `x, y` — 左上角像素坐标
|
||||||
|
- `width, height` — 热点区域像素尺寸
|
||||||
|
|
||||||
|
引擎自动处理 `object-fit: contain` 的黑边偏移和屏幕缩放。
|
||||||
|
|
||||||
|
**制作建议:** 在 Photoshop 中测量坐标,直接写入 JSON。
|
||||||
|
|
||||||
|
## 循环等待(Loop)
|
||||||
|
|
||||||
|
视频播放到指定区间后自动循环,适合"等待玩家做决定"的桥段。
|
||||||
|
|
||||||
|
```json
|
||||||
|
"stay": {
|
||||||
|
"videoUrl": "stay/loop.mp4",
|
||||||
|
"loopStart": 3.0,
|
||||||
|
"loopEnd": 6.0,
|
||||||
|
"choices": [
|
||||||
|
{ "text": "站起来离开", "targetScene": "alone_ending" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 视频 0-3s 正常播放(正文段)
|
||||||
|
- 到 3s 时循环开始,选项面板同时弹出
|
||||||
|
- 视频在 3.0-6.0s 之间反复播放,直到玩家做出选择
|
||||||
|
- BGM 不受循环影响(独立音频轨道)
|
||||||
|
|
||||||
|
**视频制作技巧:** 正文段和循环段合成为一个文件。循环段首尾画面应自然衔接。
|
||||||
31
docs/guide/KEY_MOMENTS.md
Normal file
31
docs/guide/KEY_MOMENTS.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 关键节点过滤
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
P26 新增。StoryGallery 的章节回顾默认只展示**剧情关键节点**,过滤掉过渡/路由/中间场景。
|
||||||
|
|
||||||
|
## 自动判断规则
|
||||||
|
|
||||||
|
| 场景类型 | 是否展示 |
|
||||||
|
|----------|:--:|
|
||||||
|
| 章节起始场景(`chapters[].startScene`) | ✅ |
|
||||||
|
| 有 `choices` 的分支点 | ✅ |
|
||||||
|
| 结局场景(`endings[].sceneId`) | ✅ |
|
||||||
|
| QTE 场景 | ❌ |
|
||||||
|
| 路由/过渡场景 | ❌ |
|
||||||
|
|
||||||
|
## 手动覆盖:`keyMoment` 字段
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "combat_router",
|
||||||
|
"keyMoment": false,
|
||||||
|
"nextScene": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 值 | 行为 |
|
||||||
|
|:--:|------|
|
||||||
|
| `true` | 强制展示 |
|
||||||
|
| `false` | 强制隐藏 |
|
||||||
|
| 未设置 | 自动判断 |
|
||||||
116
docs/guide/PUBLISHING.md
Normal file
116
docs/guide/PUBLISHING.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# 打包发布指南
|
||||||
|
|
||||||
|
## 准备工作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 先构建,自动校验 JSON 合法性
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物在 `dist/`,是纯静态文件,可直接部署到任意 HTTP 服务器。
|
||||||
|
|
||||||
|
## Web 版发布
|
||||||
|
|
||||||
|
### 一键打包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack:html
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 `release/mygame.zip`,上传到任意平台:
|
||||||
|
|
||||||
|
| 平台 | 上传方式 |
|
||||||
|
|------|---------|
|
||||||
|
| **itch.io** | 直接上传 zip |
|
||||||
|
| **Netlify** | 拖拽 `dist/` 文件夹到 Drop |
|
||||||
|
| **GitHub Pages** | 推送 `dist/` 到 `gh-pages` 分支 |
|
||||||
|
| **自有服务器** | 上传 `dist/` 到任意静态文件服务(Nginx, Apache, Caddy) |
|
||||||
|
|
||||||
|
### 域名/CDN
|
||||||
|
|
||||||
|
如果把素材放到 CDN,只需改一行:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assetBase": "https://cdn.example.com/mygame/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
所有 `videoUrl: "scene_1/video.mp4"` 自动拼为 `https://cdn.example.com/mygame/scene_1/video.mp4`。
|
||||||
|
|
||||||
|
## 桌面版发布
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 `release/MyGame-darwin-arm64/`。将整个文件夹打包为 `.dmg` 或直接分发文件夹。用户双击 `MyGame.app` 即可运行。
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack:win
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 `release/MyGame-win32-x64/`。运行 `MyGame.exe`。
|
||||||
|
|
||||||
|
## 桌面版命令行参数
|
||||||
|
|
||||||
|
打包后的应用支持 `--scene` 参数指定剧情文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
./MyGame.app/Contents/MacOS/MyGame --scene=./scenes/my_story.json
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
MyGame.exe --scene=./scenes/my_story.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 素材管理
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
所有素材放在 `public/` 下,Vite 自动 serve。
|
||||||
|
|
||||||
|
### 生产发布
|
||||||
|
|
||||||
|
- 视频/音频通常较大(几百 MB),建议单独分发或放 CDN
|
||||||
|
- `.gitignore` 中已排除 `public/videos/`,视频不提交到 Git
|
||||||
|
- 打包脚本自动复制 `public/` 到 `dist/`,无需手动处理
|
||||||
|
|
||||||
|
### 目录规范
|
||||||
|
|
||||||
|
```
|
||||||
|
public/demo/ ← 示例数据
|
||||||
|
scenes/demo.json
|
||||||
|
locales/{zh,en,ja}.json
|
||||||
|
intro/video.mp4
|
||||||
|
shared/bgm.mp3
|
||||||
|
|
||||||
|
public/your_story/ ← 你的游戏(复制此结构)
|
||||||
|
scenes/main.json
|
||||||
|
locales/{zh,en}.json
|
||||||
|
scene_1/video.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 构建时提示 "JSON 不合法"
|
||||||
|
|
||||||
|
检查 JSON 文件是否有多余逗号(最后一项不能有逗号)。
|
||||||
|
|
||||||
|
### Q: 打包后视频不加载
|
||||||
|
|
||||||
|
检查 `assetBase` 配置,确保路径拼接正确。开发模式 `assetBase: ""`,发布到 CDN 时改为完整 URL。
|
||||||
|
|
||||||
|
### Q: 打包体积太大
|
||||||
|
|
||||||
|
- 视频是最大的文件,建议单独制作低码率预览版(2Mbps)用于小体积分发
|
||||||
|
- 音频用 MP3 128kbps 即可
|
||||||
|
- 缩略图 JPG 质量 60% 足够
|
||||||
|
|
||||||
|
### Q: 如何制作安装包(.dmg / .exe 安装程序)
|
||||||
|
|
||||||
|
参考 `docs/electron/packaging-guide.md`。
|
||||||
87
docs/guide/QUICK_START.md
Normal file
87
docs/guide/QUICK_START.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 快速开始 — 5 分钟制作你的第一个交互式电影游戏
|
||||||
|
|
||||||
|
## 1. 准备视频
|
||||||
|
|
||||||
|
用任何方式制作三个短视频(MP4 H.264):
|
||||||
|
|
||||||
|
```
|
||||||
|
my_story/
|
||||||
|
scene_1/video.mp4 ← 开场视频
|
||||||
|
scene_2/video.mp4 ← 分支 A 视频
|
||||||
|
ending/video.mp4 ← 结局视频
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐参数:1280×720,30fps,2-5Mbps。
|
||||||
|
|
||||||
|
## 2. 写剧情 JSON
|
||||||
|
|
||||||
|
创建 `public/scenes/my_story.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assetBase": "my_story/",
|
||||||
|
"startScene": "scene_1",
|
||||||
|
"variables": {},
|
||||||
|
"scenes": {
|
||||||
|
"scene_1": {
|
||||||
|
"id": "scene_1",
|
||||||
|
"videoUrl": "scene_1/video.mp4",
|
||||||
|
"choices": [
|
||||||
|
{ "text": "选择 A", "targetScene": "scene_2" },
|
||||||
|
{ "text": "选择 B", "targetScene": "ending" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scene_2": {
|
||||||
|
"id": "scene_2",
|
||||||
|
"videoUrl": "scene_2/video.mp4",
|
||||||
|
"nextScene": "ending"
|
||||||
|
},
|
||||||
|
"ending": {
|
||||||
|
"id": "ending",
|
||||||
|
"videoUrl": "ending/video.mp4",
|
||||||
|
"choices": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 `http://localhost:5173/?scene=my_story.json`
|
||||||
|
|
||||||
|
## 4. 发生了什么
|
||||||
|
|
||||||
|
- 场景 1 播放 → 视频结束后弹出两个选项
|
||||||
|
- 选 A → 场景 2 → 自动跳转到 ending
|
||||||
|
- 选 B → 直接到 ending
|
||||||
|
- ending 无选项 → 游戏结束,返回主菜单
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 想加限定条件的选择?→ [分支叙事指南](BRANCHING.md)
|
||||||
|
- 想加 QTE 或热点?→ [交互指南](INTERACTIONS.md)
|
||||||
|
- 想加背景音乐或字幕?→ [场景 JSON 参考](SCENE_JSON_SPEC.md)
|
||||||
|
- 想支持多语言?→ [国际化指南](I18N.md)
|
||||||
|
- 想打包发布?→ [发布指南](PUBLISHING.md)
|
||||||
|
|
||||||
|
## 素材组织建议
|
||||||
|
|
||||||
|
```
|
||||||
|
my_story/
|
||||||
|
scene_1/video.mp4 ← 每个场景一个文件夹
|
||||||
|
scene_2/video.mp4
|
||||||
|
ending/video.mp4
|
||||||
|
shared/ ← 跨场景共享素材
|
||||||
|
bgm.mp3
|
||||||
|
thumb.jpg
|
||||||
|
locales/ ← 多语言翻译文件(可选)
|
||||||
|
zh.json
|
||||||
|
en.json
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON 中所有路径都是相对于素材根目录的相对路径,配合 `assetBase` 前缀使用。
|
||||||
354
docs/guide/SCENE_JSON_SPEC.md
Normal file
354
docs/guide/SCENE_JSON_SPEC.md
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
# Scene JSON 完整字段参考
|
||||||
|
|
||||||
|
## 顶层结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assetBase": "",
|
||||||
|
"locales": { "path": "locales/", "languages": ["zh", "en"] },
|
||||||
|
"startScene": "intro",
|
||||||
|
"variables": { "trust": 50, "courage": 0 },
|
||||||
|
"introVideo": "__intro__/logo.mp4",
|
||||||
|
"menuVideo": "__intro__/menu_bg.mp4",
|
||||||
|
"scenes": { ... },
|
||||||
|
"chapters": [ ... ],
|
||||||
|
"achievements": [ ... ],
|
||||||
|
"endings": [ ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必需 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `assetBase` | string | 否 | 所有资源路径前缀,默认 `""`。设 `"demo/"` 后 `videoUrl: "intro/video.mp4"` 自动拼成 `demo/intro/video.mp4`。改 CDN 只需改这一行 |
|
||||||
|
| `locales` | object | 否 | 多语言配置。`path` 为 locale 文件目录(相对于 `assetBase`),`languages` 为支持的语言列表 |
|
||||||
|
| `startScene` | string | 是 | 开始场景的 ID |
|
||||||
|
| `variables` | object | 否 | 全局变量初始值 |
|
||||||
|
| `introVideo` | string | 否 | 开场视频路径 |
|
||||||
|
| `menuVideo` | string | 否 | 主菜单背景视频路径(自动循环播放) |
|
||||||
|
| `scenes` | object | 是 | 所有场景的集合,key 为场景 ID |
|
||||||
|
| `chapters` | array | 否 | 章节列表 |
|
||||||
|
| `achievements` | array | 否 | 成就列表 |
|
||||||
|
| `endings` | array | 否 | 结局列表 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SceneNode
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SceneNode {
|
||||||
|
id: string
|
||||||
|
type?: 'video' | 'image'
|
||||||
|
videoUrl: string
|
||||||
|
streamingUrl?: Record<string, string>
|
||||||
|
imageUrl?: string
|
||||||
|
thumbnail?: string
|
||||||
|
contentSize?: { w: number; h: number }
|
||||||
|
subtitleUrl?: string
|
||||||
|
subtitles?: Record<string, string>
|
||||||
|
choices?: Choice[]
|
||||||
|
hotspots?: Hotspot[]
|
||||||
|
qte?: QTEDefinition
|
||||||
|
nextScene?: string | Choice[]
|
||||||
|
onEnter?: Effect[]
|
||||||
|
loopStart?: number
|
||||||
|
loopEnd?: number
|
||||||
|
bgmUrl?: string
|
||||||
|
bgmVolume?: number
|
||||||
|
bgmCrossFade?: number
|
||||||
|
bgmDuckLevel?: number
|
||||||
|
bgmDuckFade?: number
|
||||||
|
videoMuted?: boolean
|
||||||
|
skippable?: boolean
|
||||||
|
keyMoment?: boolean
|
||||||
|
battleHUD?: BattleHUDEntry[]
|
||||||
|
battleResult?: BattleResultDef
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | string | 场景唯一标识 |
|
||||||
|
| `type` | string | `"video"` (默认) 或 `"image"`。image 类型展示图片+热点,不播放视频 |
|
||||||
|
| `videoUrl` | string | 视频文件路径(本地 MP4,桌面版使用)。image 场景可为空 |
|
||||||
|
| `streamingUrl` | object | Web 版使用的 CDN 流媒体路径。三档画质:`{ "超清 (1080P)": "...", "高清 (720P)": "...", "标清 (480P)": "..." }`。引擎根据 `getVideoMode()` 自动选择环境 |
|
||||||
|
| `imageUrl` | string | 图片场景的图片路径 |
|
||||||
|
| `thumbnail` | string | 场景缩略图路径 |
|
||||||
|
| `contentSize` | object | 内容基准分辨率 `{ w: 1280, h: 720 }`,Hotspot 坐标基于此计算。用于 object-fit: contain 的偏移补偿 |
|
||||||
|
| `subtitleUrl` | string | 回退字幕路径。优先使用 `subtitles` |
|
||||||
|
| `subtitles` | object | 多语言字幕 `{ "zh": "...", "en": "..." }`。语言切换时自动选择 |
|
||||||
|
| `choices` | Choice[] | 选项列表 |
|
||||||
|
| `hotspots` | Hotspot[] | 可点击热点区域 |
|
||||||
|
| `qte` | QTEDefinition | QTE 定义 |
|
||||||
|
| `nextScene` | string \| Choice[] | 无选项时的默认跳转。字符串为单一场景 ID;数组为条件路由,遍历第一个满足 conditions 的跳转。末尾无条件项作为默认目标 |
|
||||||
|
| `onEnter` | Effect[] | 进入场景时触发的效果 |
|
||||||
|
| `loopStart` | number | 循环起始时间(秒) |
|
||||||
|
| `loopEnd` | number | 循环结束时间(秒)。视频播放到 loopEnd 时跳回 loopStart |
|
||||||
|
| `bgmUrl` | string | 背景音乐路径 |
|
||||||
|
| `bgmVolume` | number | 背景音乐音量(0-1),默认 0.8 |
|
||||||
|
| `bgmCrossFade` | number | 背景音乐交叉淡化时长(秒),默认 2.0 |
|
||||||
|
| `bgmDuckLevel` | number | BGM Duck 压低比例(0-1),默认 0.35 |
|
||||||
|
| `bgmDuckFade` | number | BGM Duck 过渡时长(秒),默认 0.5 |
|
||||||
|
| `videoMuted` | boolean | 视频静音(配合独立 BGM 使用) |
|
||||||
|
| `skippable` | boolean | `false` = 禁止跳过(用于 QTE 等关键场景) |
|
||||||
|
| `keyMoment` | boolean | StoryGallery 中是否展示为关键节点。`true`=强制展示,`false`=强制隐藏,未设置时自动判断(章节起始/有 choice/是结局) |
|
||||||
|
| `battleHUD` | BattleHUDEntry[] | 战斗场景中显示的角色属性 HUD。每个 entry 为一个角色(头像 + stats 列表) |
|
||||||
|
| `battleResult` | BattleResultDef | 胜利结算面板。视频结束后弹出,展示战斗统计数据。战败场景不配置此字段 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Choice
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Choice {
|
||||||
|
text: string
|
||||||
|
textKey?: string
|
||||||
|
prompt?: string
|
||||||
|
promptKey?: string
|
||||||
|
targetScene: string
|
||||||
|
conditions?: Condition[]
|
||||||
|
effects?: Effect[]
|
||||||
|
timeLimit?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `text` | 选项文本(回退值) |
|
||||||
|
| `textKey` | i18n key,优先于 `text`。如 `"intro.choice.left_door"` |
|
||||||
|
| `prompt` | 选择后浮现的提示文字(回退值) |
|
||||||
|
| `promptKey` | prompt 的 i18n key。如 `"left_door.prompt.handshake"`。优先于 `prompt` |
|
||||||
|
| `targetScene` | 目标场景 ID |
|
||||||
|
| `conditions` | 显示条件,不满足的选项隐藏 |
|
||||||
|
| `effects` | 选择后触发的效果 |
|
||||||
|
| `timeLimit` | 限时秒数,0=不限时 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hotspot
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Hotspot {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
labelKey?: string
|
||||||
|
targetScene: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
showAt?: number
|
||||||
|
hideAt?: number
|
||||||
|
conditions?: Condition[]
|
||||||
|
effects?: Effect[]
|
||||||
|
timeLimit?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `id` | 热点唯一标识 |
|
||||||
|
| `label` | 显示标签(回退值) |
|
||||||
|
| `labelKey` | i18n key |
|
||||||
|
| `x, y` | 左上角坐标(像素,基于 contentSize) |
|
||||||
|
| `width, height` | 尺寸(像素) |
|
||||||
|
| `showAt` | 视频时间(秒),此时间后显示。不设则始终可见 |
|
||||||
|
| `hideAt` | 视频时间(秒),此时间后隐藏 |
|
||||||
|
| `targetScene` | 点击后跳转的场景 |
|
||||||
|
| `conditions` | 显示条件 |
|
||||||
|
| `effects` | 点击后触发的效果 |
|
||||||
|
| `timeLimit` | 限时热点(秒),超时后热点消失 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## QTEDefinition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface QTEDefinition {
|
||||||
|
triggerTime: number
|
||||||
|
prompt: string
|
||||||
|
promptKey?: string
|
||||||
|
keys: string[]
|
||||||
|
timeLimit: number
|
||||||
|
successScene: string
|
||||||
|
failScene: string
|
||||||
|
effects?: {
|
||||||
|
success: Effect[]
|
||||||
|
fail: Effect[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `triggerTime` | 触发时间(秒),视频播放到此时间弹出 QTE |
|
||||||
|
| `prompt` | 提示文字(回退值) |
|
||||||
|
| `promptKey` | i18n key。如 `"right_door.qte.dodge"`。优先于 `prompt` |
|
||||||
|
| `keys` | 有效按键列表,如 `["ArrowLeft", "ArrowRight", "a", "d"]` |
|
||||||
|
| `timeLimit` | 限时秒数 |
|
||||||
|
| `successScene` | 成功跳转场景 |
|
||||||
|
| `failScene` | 失败/超时跳转场景 |
|
||||||
|
| `effects` | 成功/失败分别触发效果 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 其他类型
|
||||||
|
|
||||||
|
### Condition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Condition {
|
||||||
|
variable: string
|
||||||
|
op: '>' | '<' | '>=' | '<=' | '==' | '!='
|
||||||
|
value: number | string | boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Effect
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Effect {
|
||||||
|
type: 'set' | 'add'
|
||||||
|
target: string
|
||||||
|
value?: number | string | boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `set` — 设置变量值
|
||||||
|
- `add` — 变量增加/减少
|
||||||
|
|
||||||
|
### ChapterInfo
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ChapterInfo {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
labelKey?: string
|
||||||
|
startScene: string
|
||||||
|
thumbnail?: string
|
||||||
|
defaultVariables?: Record<string, number>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `id` | 章节 ID |
|
||||||
|
| `label` | 章节名称(回退值) |
|
||||||
|
| `labelKey` | i18n key,优先于 `label` |
|
||||||
|
| `startScene` | 起始场景 ID。玩家到达此场景时章节自动解锁 |
|
||||||
|
| `thumbnail` | 缩略图路径 |
|
||||||
|
| `defaultVariables` | 从章节选择界面进入时的默认变量值。未设时 fallback 到全局 `variables` |
|
||||||
|
|
||||||
|
### AchievementDef
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AchievementDef {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
titleKey?: string
|
||||||
|
description: string
|
||||||
|
descKey?: string
|
||||||
|
icon?: string
|
||||||
|
hidden?: boolean
|
||||||
|
condition: Condition
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `id` | 成就唯一 ID |
|
||||||
|
| `title` | 成就标题(回退值) |
|
||||||
|
| `titleKey` | i18n key,优先于 `title` |
|
||||||
|
| `description` | 成就描述(回退值) |
|
||||||
|
| `descKey` | i18n key,优先于 `description` |
|
||||||
|
| `icon` | 图标路径 |
|
||||||
|
| `hidden` | `true` 时未解锁不显示标题和描述(显示 ???) |
|
||||||
|
| `condition` | 解锁条件。变量满足时自动解锁并弹出 toast |
|
||||||
|
|
||||||
|
### EndingDef
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EndingDef {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
labelKey?: string
|
||||||
|
sceneId: string
|
||||||
|
chapterId?: string
|
||||||
|
thumbnail?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `id` | 结局唯一 ID |
|
||||||
|
| `label` | 结局名称(回退值) |
|
||||||
|
| `labelKey` | i18n key,优先于 `label` |
|
||||||
|
| `sceneId` | 结局场景 ID。玩家到达此场景时结局自动标记已解锁 |
|
||||||
|
| `chapterId` | 结局归属章节 ID(可选,BFS 自动推导) |
|
||||||
|
| `thumbnail` | 缩略图路径 |
|
||||||
|
|
||||||
|
### LocalesConfig
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface LocalesConfig {
|
||||||
|
path: string
|
||||||
|
languages: string[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `path` | 语言文件目录(相对于 `assetBase`),如 `"locales/"` |
|
||||||
|
| `languages` | 支持的语言列表,如 `["zh", "en", "ja"]` |
|
||||||
|
|
||||||
|
### BattleHUDEntry / BattleHUDStat
|
||||||
|
|
||||||
|
战斗场景中显示的角色属性 HUD。配置在 `SceneNode.battleHUD`。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BattleHUDEntry {
|
||||||
|
label: string
|
||||||
|
labelKey?: string
|
||||||
|
portrait?: string
|
||||||
|
stats: BattleHUDStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BattleHUDStat {
|
||||||
|
variable: string
|
||||||
|
label: string
|
||||||
|
labelKey?: string
|
||||||
|
max?: number
|
||||||
|
style?: 'bar' | 'number'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `label` | 角色名称(回退值) |
|
||||||
|
| `labelKey` | 角色名称 i18n key |
|
||||||
|
| `portrait` | 角色头像路径 |
|
||||||
|
| `stats` | 属性数组。`variable`/`label`/`labelKey`/`max`/`style`。`style` 缺省时根据有无 `max` 自动判断(有则为 bar,无则为 number) |
|
||||||
|
|
||||||
|
### BattleResultDef / BattleResultStat
|
||||||
|
|
||||||
|
战斗胜利结算面板。配置在 `SceneNode.battleResult`。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BattleResultDef {
|
||||||
|
title: string
|
||||||
|
titleKey?: string
|
||||||
|
stats: BattleResultStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BattleResultStat {
|
||||||
|
label: string
|
||||||
|
labelKey?: string
|
||||||
|
variable: string
|
||||||
|
max?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `title` | 结算标题(回退值) |
|
||||||
|
| `titleKey` | 标题 i18n key |
|
||||||
|
| `stats` | 统计项数组。`variable`/`label`/`labelKey`/`max` |
|
||||||
141
docs/mirror-setup-summary.md
Normal file
141
docs/mirror-setup-summary.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# ✅ Electron 镜像源配置完成
|
||||||
|
|
||||||
|
## 🎉 问题已解决
|
||||||
|
|
||||||
|
你的项目已经成功配置了国内镜像源,**无需翻墙即可打包**!
|
||||||
|
|
||||||
|
## 📋 配置清单
|
||||||
|
|
||||||
|
### ✅ 已配置的文件
|
||||||
|
|
||||||
|
1. **项目根目录 `.npmrc`**
|
||||||
|
- ✅ npm 镜像源:淘宝镜像
|
||||||
|
- ✅ Electron 镜像源:淘宝镜像
|
||||||
|
- ✅ Electron Builder 镜像源:淘宝镜像
|
||||||
|
|
||||||
|
2. **electron 目录 `.npmrc`**
|
||||||
|
- ✅ Electron 镜像源:淘宝镜像
|
||||||
|
- ✅ Electron Builder 镜像源:淘宝镜像
|
||||||
|
- ✅ npm 镜像源:淘宝镜像
|
||||||
|
|
||||||
|
3. **electron/package.json**
|
||||||
|
- ✅ 安装了 `cross-env` 包
|
||||||
|
- ✅ 打包脚本中设置了环境变量
|
||||||
|
|
||||||
|
4. **辅助脚本**
|
||||||
|
- ✅ `scripts/verify-mirrors.cjs` - 验证镜像源配置
|
||||||
|
- ✅ `scripts/pack-win.bat` - Windows 打包脚本
|
||||||
|
- ✅ `scripts/pack-mac.sh` - Mac 打包脚本
|
||||||
|
|
||||||
|
## 🔍 验证结果
|
||||||
|
|
||||||
|
```bash
|
||||||
|
✅ npm 配置:
|
||||||
|
registry: https://registry.npmmirror.com
|
||||||
|
electron_mirror: https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror: https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
|
||||||
|
✅ 已配置国内镜像源,无需翻墙即可打包!
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 现在可以直接打包
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack:win
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mac
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pack:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 工作原理
|
||||||
|
|
||||||
|
### 之前(需要翻墙)
|
||||||
|
```
|
||||||
|
npm run pack:win
|
||||||
|
↓
|
||||||
|
下载 Electron 二进制文件
|
||||||
|
↓
|
||||||
|
访问 GitHub releases (github.com)
|
||||||
|
↓
|
||||||
|
❌ 失败:fetch failed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 现在(无需翻墙)
|
||||||
|
```
|
||||||
|
npm run pack:win
|
||||||
|
↓
|
||||||
|
下载 Electron 二进制文件
|
||||||
|
↓
|
||||||
|
访问淘宝镜像 (npmmirror.com)
|
||||||
|
↓
|
||||||
|
✅ 成功:打包完成
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ 配置详情
|
||||||
|
|
||||||
|
### 镜像源地址
|
||||||
|
|
||||||
|
| 资源 | 原地址 | 镜像地址 |
|
||||||
|
|------|--------|---------|
|
||||||
|
| Electron | github.com/electron/electron/releases | npmmirror.com/mirrors/electron/ |
|
||||||
|
| Electron Builder | github.com/electron-userland/electron-builder-binaries | npmmirror.com/mirrors/electron-builder-binaries/ |
|
||||||
|
| npm | registry.npmjs.org | registry.npmmirror.com |
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
打包时会自动设置以下环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 如果仍然失败
|
||||||
|
|
||||||
|
### 1. 清除缓存
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm cache clean --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 重新安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd electron
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 验证配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/verify-mirrors.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 检查网络
|
||||||
|
|
||||||
|
确保能访问:
|
||||||
|
- https://npmmirror.com
|
||||||
|
- https://mirrors.huaweicloud.com
|
||||||
|
- https://mirrors.cloud.tencent.com
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [完整配置指南](./electron-mirror-setup.md)
|
||||||
|
- [快速参考](./MIRROR_QUICK_REFERENCE.md)
|
||||||
|
|
||||||
|
## 🎯 总结
|
||||||
|
|
||||||
|
通过配置国内镜像源,你现在已经可以:
|
||||||
|
|
||||||
|
✅ **无需翻墙** - 直接打包 Electron 应用
|
||||||
|
✅ **快速下载** - 使用国内 CDN,速度快
|
||||||
|
✅ **稳定可靠** - 镜像源稳定,不易中断
|
||||||
|
✅ **一次配置** - 配置一次,永久生效
|
||||||
|
|
||||||
|
现在你可以愉快地打包 Electron 应用了!🎉
|
||||||
7
docs/使用经验.md
Normal file
7
docs/使用经验.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
带选择的场景,用单独的一个场景和视频表示。配置循环0.1到视频长度-0.1.效果就是进入视频就会出现选择而且还能循环播放视频
|
||||||
|
|
||||||
|
|
||||||
|
用户进攻是否成功由qte决定 用户防守是否成功由qte决定
|
||||||
|
战斗 |<- 用户进攻回合 用户进攻回合 ->| |<- 用户防守回合 用户防守回合 ->|
|
||||||
|
ready -> 用户选择进攻qte -> qte_success -> 播放玩家进攻敌人不防守动画 -> 用户选择防守qte -> qte_sucess -> 用户防守敌人进攻 -> 回到 用户选择进攻qte节点
|
||||||
|
-> qte_fail -> 播放玩家进攻敌人防守动画 -> 用户选择防守qte -> qte_fail -> 用户不防守敌人进攻 ->
|
||||||
290
editor/App.vue
290
editor/App.vue
@@ -1,72 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import type { GameData, SceneNode } from '@engine/types'
|
import type { GameData } from '@engine/types'
|
||||||
|
import { resolveAsset } from '@engine/utils'
|
||||||
import { useGraphEditor } from './composables/useGraphEditor'
|
import { useGraphEditor } from './composables/useGraphEditor'
|
||||||
|
import { useEditorStore } from './stores/editorStore'
|
||||||
import SceneGraph from './components/SceneGraph.vue'
|
import SceneGraph from './components/SceneGraph.vue'
|
||||||
import NodeEditor from './components/NodeEditor.vue'
|
import NodeEditor from './components/NodeEditor.vue'
|
||||||
import PreviewPanel from './components/PreviewPanel.vue'
|
import PreviewPanel from './components/PreviewPanel.vue'
|
||||||
|
import AIPanel from './components/AIPanel.vue'
|
||||||
|
|
||||||
|
const store = useEditorStore()
|
||||||
const editor = useGraphEditor()
|
const editor = useGraphEditor()
|
||||||
const dirty = ref(false)
|
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const showPreview = ref(false)
|
||||||
const selectedScene = computed<SceneNode | null>(() => {
|
|
||||||
if (!editor.selectedNodeId.value) return null
|
|
||||||
return editor.gameData.value.scenes[editor.selectedNodeId.value] ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const previewVideoUrl = computed(() => {
|
const previewVideoUrl = computed(() => {
|
||||||
const url = selectedScene.value?.videoUrl
|
const url = store.selectedScene?.videoUrl
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
return url.startsWith('/') ? url : '/' + url
|
const resolved = resolveAsset(store.gameData.assetBase || '', url)
|
||||||
|
return resolved.startsWith('/') ? resolved : '/' + resolved
|
||||||
})
|
})
|
||||||
|
|
||||||
function newNode() {
|
function newNode() {
|
||||||
const id = editor.addScene()
|
const id = store.addScene()
|
||||||
editor.selectedNodeId.value = id
|
store.selectedNodeId = id
|
||||||
dirty.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delNode(id: string) {
|
function delNode(id: string) {
|
||||||
editor.deleteScene(id)
|
store.deleteScene(id)
|
||||||
dirty.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUpdateScene(id: string, partial: any) {
|
|
||||||
editor.updateScene(id, partial)
|
|
||||||
dirty.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAddChoice(sourceId: string) {
|
|
||||||
editor.addChoice(sourceId)
|
|
||||||
dirty.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUpdateChoice(sourceId: string, index: number, partial: any) {
|
|
||||||
editor.updateChoice(sourceId, index, partial)
|
|
||||||
dirty.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDeleteChoice(sourceId: string, index: number) {
|
|
||||||
editor.deleteChoice(sourceId, index)
|
|
||||||
dirty.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAddEdge(source: string, target: string) {
|
|
||||||
const scene = editor.gameData.value.scenes[source]
|
|
||||||
if (!scene) return
|
|
||||||
const newChoices = [...(scene.choices || []), { text: `${source} → ${target}`, targetScene: target }]
|
|
||||||
editor.gameData.value = {
|
|
||||||
...editor.gameData.value,
|
|
||||||
scenes: { ...editor.gameData.value.scenes, [source]: { ...scene, choices: newChoices } },
|
|
||||||
}
|
|
||||||
dirty.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportJSON() {
|
function exportJSON() {
|
||||||
const data = editor.exportJSON()
|
const data = store.exportJSON()
|
||||||
const json = JSON.stringify(data, null, 2)
|
const json = JSON.stringify(data, null, 2)
|
||||||
const blob = new Blob([json], { type: 'application/json' })
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@@ -75,21 +42,25 @@ function exportJSON() {
|
|||||||
a.download = 'scenes.json'
|
a.download = 'scenes.json'
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
dirty.value = false
|
store.dirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function importJSON() {
|
function importJSON() {
|
||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testScene(id: string) {
|
||||||
|
window.open('/?scene=' + store.sourcePath + '&startScene=' + id, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
async function onFileSelected(e: Event) {
|
async function onFileSelected(e: Event) {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(await file.text()) as GameData
|
const data = JSON.parse(await file.text()) as GameData
|
||||||
if (!data.scenes || typeof data.scenes !== 'object') throw new Error('无效的场景数据')
|
if (!data.scenes || typeof data.scenes !== 'object') throw new Error('无效的场景数据')
|
||||||
editor.loadJSON(data)
|
store.loadJSON(data)
|
||||||
dirty.value = false
|
store.setSourcePath('/scenes/' + file.name)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = '导入失败:' + err.message
|
error.value = '导入失败:' + err.message
|
||||||
setTimeout(() => (error.value = ''), 3000)
|
setTimeout(() => (error.value = ''), 3000)
|
||||||
@@ -101,8 +72,7 @@ async function loadDemo() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
const resp = await fetch('/scenes/demo.json')
|
const resp = await fetch('/scenes/demo.json')
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status)
|
if (!resp.ok) throw new Error('HTTP ' + resp.status)
|
||||||
editor.loadJSON(await resp.json())
|
store.loadJSON(await resp.json())
|
||||||
dirty.value = false
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = '加载示例失败:' + err.message
|
error.value = '加载示例失败:' + err.message
|
||||||
} finally {
|
} finally {
|
||||||
@@ -110,9 +80,24 @@ async function loadDemo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
async function restoreOrLoad() {
|
||||||
loadDemo()
|
const lastSource = localStorage.getItem('editor_last_source')
|
||||||
})
|
if (lastSource) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const resp = await fetch(lastSource)
|
||||||
|
if (resp.ok) {
|
||||||
|
store.loadJSON(await resp.json())
|
||||||
|
store.setSourcePath(lastSource)
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
await loadDemo()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => restoreOrLoad())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -124,173 +109,82 @@ onMounted(() => {
|
|||||||
<button @click="importJSON" class="secondary">导入 JSON</button>
|
<button @click="importJSON" class="secondary">导入 JSON</button>
|
||||||
<button @click="exportJSON" class="secondary">导出 JSON</button>
|
<button @click="exportJSON" class="secondary">导出 JSON</button>
|
||||||
<button @click="loadDemo" class="secondary">加载示例</button>
|
<button @click="loadDemo" class="secondary">加载示例</button>
|
||||||
<span v-if="dirty" class="dirty-indicator">● 未保存</span>
|
<button @click="showPreview = !showPreview" :class="{ secondary: true, active: showPreview }">
|
||||||
|
{{ showPreview ? '📐 图谱' : '🎬 预览' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="toolbar-start">
|
<select v-if="store.versions.length > 0" class="toolbar-version-select" @change="store.restoreVersion(($event.target as HTMLSelectElement).selectedIndex - 1)">
|
||||||
起始场景:
|
<option disabled selected>版本</option>
|
||||||
<select
|
<option v-for="v in store.versions" :key="v.timestamp">
|
||||||
:value="editor.startSceneId.value"
|
{{ new Date(v.timestamp).toLocaleString('zh-CN') }} {{ v.label }}
|
||||||
@change="editor.startSceneId.value = ($event.target as HTMLSelectElement).value; dirty = true"
|
</option>
|
||||||
class="start-select"
|
</select>
|
||||||
>
|
|
||||||
<option value="">-- 选择 --</option>
|
|
||||||
<option v-for="n in editor.sceneNodes.value" :key="n.id" :value="n.id">{{ n.label }}</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-bar">{{ error }}</div>
|
<div v-if="error" class="error-bar">{{ error }}</div>
|
||||||
|
|
||||||
<div class="editor-main">
|
<div class="editor-main">
|
||||||
<SceneGraph
|
<div class="graph-area">
|
||||||
v-if="!loading"
|
<SceneGraph
|
||||||
class="graph-area"
|
v-if="!loading && !showPreview"
|
||||||
:scene-nodes="editor.sceneNodes.value"
|
:scene-nodes="editor.sceneNodes.value"
|
||||||
:scene-edges="editor.sceneEdges.value"
|
:scene-edges="editor.sceneEdges.value"
|
||||||
:start-scene="editor.startSceneId.value"
|
:start-scene="store.startSceneId"
|
||||||
:selected-node-id="editor.selectedNodeId.value"
|
:selected-node-id="store.selectedNodeId"
|
||||||
@select-node="editor.selectedNodeId.value = $event"
|
@select-node="store.selectedNodeId = $event"
|
||||||
@add-edge="onAddEdge"
|
@add-edge="editor.onAddEdge"
|
||||||
/>
|
@test-scene="testScene"
|
||||||
|
@clear-selection="store.selectedNodeId = null"
|
||||||
<div v-else class="graph-area loading-hint">加载剧情数据…</div>
|
/>
|
||||||
|
<div v-else-if="loading" class="loading-hint">加载剧情数据…</div>
|
||||||
|
<PreviewPanel v-if="showPreview" :video-url="previewVideoUrl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<NodeEditor
|
<NodeEditor
|
||||||
:scene="selectedScene"
|
:scene="store.selectedScene"
|
||||||
:scene-list="editor.sceneList.value"
|
:scene-list="editor.sceneList.value"
|
||||||
@update="onUpdateScene"
|
|
||||||
@add-choice="onAddChoice"
|
|
||||||
@update-choice="onUpdateChoice"
|
|
||||||
@delete-choice="onDeleteChoice"
|
|
||||||
@delete-scene="delNode"
|
@delete-scene="delNode"
|
||||||
@close="editor.selectedNodeId.value = null"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PreviewPanel :video-url="previewVideoUrl" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AIPanel v-if="store.showAIPanel" />
|
||||||
|
|
||||||
<input ref="fileInputRef" type="file" accept=".json" style="display:none" @change="onFileSelected" />
|
<input ref="fileInputRef" type="file" accept=".json" style="display:none" @change="onFileSelected" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
* {
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
margin: 0;
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a16; color: #ccc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||||
padding: 0;
|
#editor-app { width: 100%; height: 100%; }
|
||||||
box-sizing: border-box;
|
.graph-area .preview-panel { width: 100%; border-left: none; }
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #0a0a16;
|
|
||||||
color: #ccc;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
#editor-app {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.editor-layout {
|
.editor-layout { width: 100%; height: 100%; display: flex; flex-direction: column; position: relative; }
|
||||||
width: 100%;
|
.toolbar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #111122; border-bottom: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; }
|
||||||
height: 100%;
|
.toolbar-title { font-size: 15px; font-weight: 600; color: #ddd; letter-spacing: 1px; margin-right: 16px; }
|
||||||
display: flex;
|
.toolbar-actions { display: flex; gap: 8px; }
|
||||||
flex-direction: column;
|
.toolbar-actions button { padding: 6px 14px; font-size: 12px; color: #ddd; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); border-radius: 4px; cursor: pointer; transition: background 0.15s; }
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: #111122;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ddd;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-actions button {
|
|
||||||
padding: 6px 14px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #ddd;
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
border: 1px solid rgba(255,255,255,0.15);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-actions button:hover { background: rgba(255,255,255,0.14); }
|
.toolbar-actions button:hover { background: rgba(255,255,255,0.14); }
|
||||||
|
.toolbar-actions button.secondary { color: #8cf; border-color: rgba(100,200,255,0.2); background: rgba(100,200,255,0.06); }
|
||||||
|
.toolbar-actions button.active { background: rgba(100,200,255,0.18); color: #fff; }
|
||||||
|
.error-bar { padding: 8px 16px; background: #4a1a1a; color: #f88; font-size: 13px; flex-shrink: 0; }
|
||||||
|
.editor-main { flex: 1; display: flex; overflow: hidden; position: relative; }
|
||||||
|
.graph-area { flex: 1; }
|
||||||
|
.loading-hint { display: flex; align-items: center; justify-content: center; height: 100%; color: #555; font-size: 14px; }
|
||||||
|
|
||||||
.toolbar-actions button.secondary {
|
.toolbar-version-select {
|
||||||
color: #8cf;
|
padding: 4px 10px;
|
||||||
border-color: rgba(100,200,255,0.2);
|
|
||||||
background: rgba(100,200,255,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-start {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #777;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.start-select {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dirty-indicator {
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #ff9800;
|
max-width: 200px;
|
||||||
}
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
.error-bar {
|
border-radius: 4px;
|
||||||
padding: 8px 16px;
|
color: #aaa;
|
||||||
background: #4a1a1a;
|
outline: none;
|
||||||
color: #f88;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.graph-area {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-hint {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #555;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
.toolbar-version-select:hover { border-color: rgba(255,255,255,0.2); color: #ccc; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
393
editor/components/AIPanel.vue
Normal file
393
editor/components/AIPanel.vue
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||||
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
|
import { sendAIRequest } from '../composables/useAI'
|
||||||
|
|
||||||
|
const store = useEditorStore()
|
||||||
|
const inputText = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const messages = ref<{ role: string; content: string }[]>([])
|
||||||
|
const chatRef = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const mode = ref<'json' | 'code'>('json')
|
||||||
|
|
||||||
|
const lastMsg = computed(() => {
|
||||||
|
const m = messages.value[messages.value.length - 1]
|
||||||
|
if (!m) return ''
|
||||||
|
const text = m.role === 'user' ? m.content : m.content.substring(0, 60)
|
||||||
|
return text.length > 60 ? text + '...' : text
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleMode() {
|
||||||
|
mode.value = mode.value === 'json' ? 'code' : 'json'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessage(userInput: string): string {
|
||||||
|
if (mode.value !== 'json') return `需求: ${userInput}`
|
||||||
|
|
||||||
|
let ctx = `修改文件 ${store.sourcePath}`
|
||||||
|
if (store.selectedScene) ctx += `,针对场景节点 ${store.selectedScene.id}`
|
||||||
|
ctx += `。需求: ${userInput}`
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const userInput = inputText.value.trim()
|
||||||
|
if (!userInput || loading.value) return
|
||||||
|
inputText.value = ''
|
||||||
|
errorMsg.value = ''
|
||||||
|
|
||||||
|
if (!store.deepseekKey) {
|
||||||
|
errorMsg.value = '请先在设置中输入 DeepSeek API Key'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullMessage = buildMessage(userInput)
|
||||||
|
messages.value.push({ role: 'user', content: userInput })
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
if (mode.value === 'json') {
|
||||||
|
await store.saveVersion('AI 修改前')
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldGameData = mode.value === 'json' ? JSON.parse(JSON.stringify(store.gameData)) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result, sessionId: newSid } = await sendAIRequest(fullMessage, mode.value, store.deepseekKey, store.aiSessionId || undefined)
|
||||||
|
if (newSid) store.setAISessionId(newSid)
|
||||||
|
|
||||||
|
if (mode.value === 'json') {
|
||||||
|
messages.value.push({ role: 'assistant', content: result || '已完成' })
|
||||||
|
await store.reloadFromDisk(oldGameData)
|
||||||
|
} else {
|
||||||
|
messages.value.push({ role: 'assistant', content: result || '已完成' })
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMsg.value = e.message || '请求失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
await nextTick()
|
||||||
|
chatRef.value?.scrollTo({ top: chatRef.value.scrollHeight })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function newSession() {
|
||||||
|
store.clearAISession()
|
||||||
|
messages.value = []
|
||||||
|
errorMsg.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadVersions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ai-panel" v-if="store.showAIPanel">
|
||||||
|
<Transition name="slide-up">
|
||||||
|
<div v-if="!store.aiCollapsed" class="ai-expanded">
|
||||||
|
<div class="ai-exp-header">
|
||||||
|
<span class="ai-exp-title">AI 助手</span>
|
||||||
|
<div class="ai-exp-actions">
|
||||||
|
<button class="ai-mode-btn" @click="toggleMode" :title="mode === 'json' ? '切换到代码模式' : '切换到 JSON 模式'">
|
||||||
|
{{ mode === 'json' ? '📋 JSON' : '💻 代码' }}
|
||||||
|
</button>
|
||||||
|
<button class="ai-new-btn" @click="newSession" title="新建对话">+</button>
|
||||||
|
<button v-if="store.aiChanges" class="ai-clear-btn" @click="store.clearAIMarkers()">清除高亮</button>
|
||||||
|
<button class="ai-collapse-btn" @click="store.aiCollapsed = true">▼ 折叠</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-chat" ref="chatRef">
|
||||||
|
<div v-if="!store.deepseekKey" class="ai-key-setup">
|
||||||
|
<span class="key-label">DeepSeek API Key</span>
|
||||||
|
<input
|
||||||
|
class="key-input"
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-..."
|
||||||
|
:value="store.deepseekKey"
|
||||||
|
@input="store.setDeepseekKey(($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<div class="key-hint">Key 保存在浏览器本地,不会上传到编辑器服务器</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="messages.length === 0 && store.deepseekKey" class="ai-empty">
|
||||||
|
输入你的需求,AI 将根据引擎规范修改配置
|
||||||
|
</div>
|
||||||
|
<div v-for="(m, i) in messages" :key="i" class="ai-msg" :class="m.role">
|
||||||
|
<span class="ai-role">{{ m.role === 'user' ? '👤' : '🤖' }}</span>
|
||||||
|
<span class="ai-content">{{ m.content }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="ai-msg assistant">
|
||||||
|
<span class="ai-role">🤖</span>
|
||||||
|
<span class="ai-content loading-dots">正在生成<span class="dots">...</span></span>
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMsg" class="ai-error">{{ errorMsg }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-input-area">
|
||||||
|
<input
|
||||||
|
v-model="inputText"
|
||||||
|
class="ai-input"
|
||||||
|
placeholder="输入你的需求..."
|
||||||
|
:disabled="loading"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
<button class="ai-send-btn" @click="send" :disabled="loading">发送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<div v-if="store.aiCollapsed" class="ai-collapsed-bar" @click="store.aiCollapsed = false">
|
||||||
|
<span class="ai-bar-icon">🤖</span>
|
||||||
|
<span class="ai-bar-title">AI 助手</span>
|
||||||
|
<span class="ai-bar-divider">·</span>
|
||||||
|
<span class="ai-bar-msg">{{ messages.length > 0 ? lastMsg : '点击开始对话' }}</span>
|
||||||
|
<span class="ai-bar-arrow">▲</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-panel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-collapsed-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #161633;
|
||||||
|
border-top: 1px solid rgba(100,200,255,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ai-collapsed-bar:hover {
|
||||||
|
background: #1a1a3a;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-bar-icon { font-size: 13px; }
|
||||||
|
.ai-bar-title { font-weight: 500; color: #8cf; }
|
||||||
|
.ai-bar-divider { color: rgba(255,255,255,0.15); }
|
||||||
|
.ai-bar-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #777; }
|
||||||
|
.ai-bar-arrow { color: #555; font-size: 10px; }
|
||||||
|
.ai-collapsed-bar:hover .ai-bar-arrow { color: #8cf; }
|
||||||
|
|
||||||
|
.ai-expanded {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 40vh;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(20, 20, 44, 0.98);
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
box-shadow: 0 -6px 24px rgba(0,0,0,0.5);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-exp-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-exp-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-exp-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-mode-btn {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8cf;
|
||||||
|
background: rgba(100,200,255,0.08);
|
||||||
|
border: 1px solid rgba(100,200,255,0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ai-mode-btn:hover { background: rgba(100,200,255,0.15); }
|
||||||
|
|
||||||
|
.ai-new-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
color: #888;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ai-new-btn:hover { color: #ddd; border-color: rgba(255,255,255,0.2); }
|
||||||
|
|
||||||
|
.ai-clear-btn {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ccc;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ai-clear-btn:hover { color: #fff; border-color: rgba(255,255,255,0.2); }
|
||||||
|
|
||||||
|
.ai-collapse-btn {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ai-collapse-btn:hover { color: #ddd; }
|
||||||
|
|
||||||
|
.ai-chat {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-empty {
|
||||||
|
color: #555;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-role {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg.user .ai-content {
|
||||||
|
color: #8cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg.assistant .ai-content {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots .dots {
|
||||||
|
animation: dotPulse 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dotPulse {
|
||||||
|
0%, 20% { opacity: 0; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
80%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e74c3c;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(231,76,60,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px 10px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ddd;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.ai-input:focus { border-color: rgba(255,255,255,0.2); }
|
||||||
|
|
||||||
|
.ai-send-btn {
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(100,200,255,0.15);
|
||||||
|
border: 1px solid rgba(100,200,255,0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ai-send-btn:hover { background: rgba(100,200,255,0.25); }
|
||||||
|
.ai-send-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.ai-key-setup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-input {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ddd;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.key-input:focus { border-color: rgba(100,200,255,0.3); }
|
||||||
|
|
||||||
|
.key-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: max-height 0.25s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.slide-up-enter-from,
|
||||||
|
.slide-up-leave-to {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import type { SceneNode, Choice } from '@engine/types'
|
import type { SceneNode } from '@engine/types'
|
||||||
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
scene: SceneNode | null
|
scene: SceneNode | null
|
||||||
@@ -8,214 +9,148 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
update: [id: string, partial: Partial<SceneNode>]
|
|
||||||
addChoice: [sourceId: string]
|
|
||||||
updateChoice: [sourceId: string, index: number, partial: Partial<Choice>]
|
|
||||||
deleteChoice: [sourceId: string, index: number]
|
|
||||||
deleteScene: [id: string]
|
deleteScene: [id: string]
|
||||||
close: []
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const localVideo = ref('')
|
const store = useEditorStore()
|
||||||
const localSubtitle = ref('')
|
const jsonText = ref('')
|
||||||
const localNextScene = ref('')
|
const errorMsg = ref('')
|
||||||
const editingQTE = ref(false)
|
const saved = ref(false)
|
||||||
const localQteTime = ref(1)
|
const globalTooltip = ref('')
|
||||||
const localQtePrompt = ref('')
|
|
||||||
const localQteKeys = ref('')
|
|
||||||
const localQteLimit = ref(3)
|
|
||||||
const localQteSuccess = ref('')
|
|
||||||
const localQteFail = ref('')
|
|
||||||
|
|
||||||
watch(() => props.scene, (s) => {
|
watch(() => [props.scene, store.gameData] as const, () => {
|
||||||
if (!s) return
|
errorMsg.value = ''
|
||||||
localVideo.value = s.videoUrl || ''
|
saved.value = false
|
||||||
localSubtitle.value = s.subtitleUrl || ''
|
if (props.scene) {
|
||||||
localNextScene.value = s.nextScene || ''
|
jsonText.value = JSON.stringify(props.scene, null, 2)
|
||||||
if (s.qte) {
|
} else if (Object.keys(store.gameData.scenes).length > 0) {
|
||||||
editingQTE.value = true
|
const { scenes, ...rest } = store.gameData
|
||||||
localQteTime.value = s.qte.triggerTime
|
jsonText.value = JSON.stringify(rest, null, 2)
|
||||||
localQtePrompt.value = s.qte.prompt
|
|
||||||
localQteKeys.value = s.qte.keys.join(', ')
|
|
||||||
localQteLimit.value = s.qte.timeLimit
|
|
||||||
localQteSuccess.value = s.qte.successScene
|
|
||||||
localQteFail.value = s.qte.failScene
|
|
||||||
} else {
|
} else {
|
||||||
editingQTE.value = false
|
jsonText.value = ''
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true, deep: true })
|
||||||
|
|
||||||
function saveVideo() {
|
function onBlur() {
|
||||||
if (!props.scene) return
|
store.clearAIMarkers()
|
||||||
emit('update', props.scene.id, { videoUrl: localVideo.value })
|
errorMsg.value = ''
|
||||||
}
|
try {
|
||||||
|
const parsed = JSON.parse(jsonText.value)
|
||||||
function saveSubtitle() {
|
if (props.scene) {
|
||||||
if (!props.scene) return
|
store.updateScene(props.scene.id, parsed)
|
||||||
emit('update', props.scene.id, { subtitleUrl: localSubtitle.value })
|
} else {
|
||||||
}
|
const { scenes } = store.gameData
|
||||||
|
store.gameData = { ...parsed, scenes }
|
||||||
function saveNextScene() {
|
store.startSceneId = parsed.startScene || store.startSceneId
|
||||||
if (!props.scene) return
|
store.autoSave()
|
||||||
emit('update', props.scene.id, { nextScene: localNextScene.value })
|
}
|
||||||
}
|
saved.value = true
|
||||||
|
setTimeout(() => { saved.value = false }, 2000)
|
||||||
function saveQTE() {
|
} catch (e: any) {
|
||||||
if (!props.scene) return
|
errorMsg.value = e.message
|
||||||
emit('update', props.scene.id, {
|
}
|
||||||
qte: editingQTE.value ? {
|
|
||||||
triggerTime: localQteTime.value,
|
|
||||||
prompt: localQtePrompt.value,
|
|
||||||
keys: localQteKeys.value.split(',').map((k) => k.trim()).filter(Boolean),
|
|
||||||
timeLimit: localQteLimit.value,
|
|
||||||
successScene: localQteSuccess.value,
|
|
||||||
failScene: localQteFail.value,
|
|
||||||
} : undefined,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="node-editor" v-if="scene">
|
<div class="node-editor">
|
||||||
<div class="editor-header">
|
<Transition name="slide-right">
|
||||||
<h3>{{ scene.id }}</h3>
|
<div v-if="!store.nodeEditorCollapsed" class="ne-expanded">
|
||||||
<div class="header-actions">
|
<div class="editor-header">
|
||||||
<button class="icon-btn danger" @click="emit('deleteScene', scene.id)" title="删除场景">🗑</button>
|
<div class="header-left">
|
||||||
<button class="icon-btn" @click="emit('close')" title="关闭">✕</button>
|
<h3>{{ scene ? scene.id : '全局配置' }}</h3>
|
||||||
|
<button v-if="scene" class="icon-btn danger" @click="emit('deleteScene', scene.id)" title="删除场景">🗑</button>
|
||||||
|
<span v-if="!scene && store.aiChanges?.globalFields?.length" class="global-diff-tag" :title="store.aiChanges.globalFields.join(', ')">● 已修改 {{ store.aiChanges.globalFields.length }} 字段</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span v-if="saved" class="saved-hint">已保存</span>
|
||||||
|
<span v-if="errorMsg" class="error-hint">JSON 错误: {{ errorMsg }}</span>
|
||||||
|
<button class="icon-btn" @click="store.nodeEditorCollapsed = true" title="折叠">▶</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
class="json-area"
|
||||||
|
:class="{ error: errorMsg }"
|
||||||
|
:value="jsonText"
|
||||||
|
@input="jsonText = ($event.target as HTMLTextAreaElement).value"
|
||||||
|
@blur="onBlur"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<div v-if="store.nodeEditorCollapsed" class="ne-collapsed-bar" @click="store.nodeEditorCollapsed = false">
|
||||||
|
<span class="ne-bar-arrow">◀</span>
|
||||||
|
<span class="ne-bar-label">{{ scene?.id || '全局' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-body">
|
|
||||||
<div class="field-group">
|
|
||||||
<label>视频路径</label>
|
|
||||||
<div class="field-row">
|
|
||||||
<input v-model="localVideo" @blur="saveVideo" placeholder="/videos/scene.mp4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label>字幕路径</label>
|
|
||||||
<div class="field-row">
|
|
||||||
<input v-model="localSubtitle" @blur="saveSubtitle" placeholder="/subtitles/scene.vtt" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label>默认下一场景 (nextScene)</label>
|
|
||||||
<select v-model="localNextScene" @change="saveNextScene">
|
|
||||||
<option value="">-- 无 --</option>
|
|
||||||
<option v-for="s in sceneList.filter(s => s.id !== scene.id)" :key="s.id" :value="s.id">
|
|
||||||
{{ s.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="qte-toggle">
|
|
||||||
<input type="checkbox" v-model="editingQTE" @change="saveQTE" />
|
|
||||||
QTE 快速反应事件
|
|
||||||
</label>
|
|
||||||
<div v-if="editingQTE" class="qte-fields">
|
|
||||||
<div class="qte-row">
|
|
||||||
<span>触发时间 (秒)</span>
|
|
||||||
<input type="number" v-model.number="localQteTime" @change="saveQTE" min="0" step="0.5" />
|
|
||||||
</div>
|
|
||||||
<div class="qte-row">
|
|
||||||
<span>提示文字</span>
|
|
||||||
<input v-model="localQtePrompt" @blur="saveQTE" placeholder="按下空格键!" />
|
|
||||||
</div>
|
|
||||||
<div class="qte-row">
|
|
||||||
<span>按键 (逗号分隔)</span>
|
|
||||||
<input v-model="localQteKeys" @blur="saveQTE" placeholder="Space, ArrowUp" />
|
|
||||||
</div>
|
|
||||||
<div class="qte-row">
|
|
||||||
<span>限时 (秒)</span>
|
|
||||||
<input type="number" v-model.number="localQteLimit" @change="saveQTE" min="1" step="0.5" />
|
|
||||||
</div>
|
|
||||||
<div class="qte-row">
|
|
||||||
<span>成功场景</span>
|
|
||||||
<select v-model="localQteSuccess" @change="saveQTE">
|
|
||||||
<option value="">-- 选择 --</option>
|
|
||||||
<option v-for="s in sceneList.filter(s => s.id !== scene.id)" :key="s.id" :value="s.id">
|
|
||||||
{{ s.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="qte-row">
|
|
||||||
<span>失败场景</span>
|
|
||||||
<select v-model="localQteFail" @change="saveQTE">
|
|
||||||
<option value="">-- 选择 --</option>
|
|
||||||
<option v-for="s in sceneList.filter(s => s.id !== scene.id)" :key="s.id" :value="s.id">
|
|
||||||
{{ s.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group choices-section">
|
|
||||||
<label>选项列表</label>
|
|
||||||
<button class="add-btn" @click="emit('addChoice', scene.id)">+ 添加选项</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(choice, index) in (scene.choices || [])"
|
|
||||||
:key="index"
|
|
||||||
class="choice-item"
|
|
||||||
>
|
|
||||||
<div class="choice-header">
|
|
||||||
<span>选项 {{ index + 1 }}</span>
|
|
||||||
<button class="icon-btn danger small" @click="emit('deleteChoice', scene.id, index)">×</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
:value="choice.text"
|
|
||||||
@blur="emit('updateChoice', scene.id, index, { text: ($event.target as HTMLInputElement).value })"
|
|
||||||
placeholder="选项文字"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
:value="choice.targetScene"
|
|
||||||
@change="emit('updateChoice', scene.id, index, { targetScene: ($event.target as HTMLSelectElement).value })"
|
|
||||||
>
|
|
||||||
<option value="">-- 目标场景 --</option>
|
|
||||||
<option v-for="s in sceneList.filter(s => s.id !== scene.id)" :key="s.id" :value="s.id">
|
|
||||||
{{ s.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<div class="choice-extra">
|
|
||||||
<label class="inline-label">限时(秒, 0=不限)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
:value="choice.timeLimit ?? 0"
|
|
||||||
@change="emit('updateChoice', scene.id, index, { timeLimit: +($event.target as HTMLInputElement).value })"
|
|
||||||
min="0" step="1"
|
|
||||||
class="time-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="node-editor empty-state" v-else>
|
|
||||||
<p>点击左侧画布中的节点来编辑</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.node-editor {
|
.node-editor {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ne-expanded {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
height: 100%;
|
|
||||||
background: #141428;
|
background: #141428;
|
||||||
border-left: 1px solid rgba(255,255,255,0.08);
|
border-left: 1px solid rgba(255,255,255,0.08);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.ne-collapsed-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 36px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
gap: 6px;
|
||||||
|
background: #161633;
|
||||||
|
border-left: 1px solid rgba(100,200,255,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ne-collapsed-bar:hover {
|
||||||
|
background: #1a1a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ne-bar-arrow {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ne-collapsed-bar:hover .ne-bar-arrow {
|
||||||
|
color: #8cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ne-bar-label {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8cf;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #555;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-header {
|
.editor-header {
|
||||||
@@ -224,6 +159,7 @@ function saveQTE() {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-header h3 {
|
.editor-header h3 {
|
||||||
@@ -232,9 +168,32 @@ function saveQTE() {
|
|||||||
color: #ddd;
|
color: #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #e74c3c;
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
@@ -246,140 +205,50 @@ function saveQTE() {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn:hover { color: #ddd; border-color: rgba(255,255,255,0.25); }
|
.icon-btn:hover { color: #ddd; border-color: rgba(255,255,255,0.25); }
|
||||||
.icon-btn.danger:hover { color: #e74c3c; border-color: #e74c3c; }
|
.icon-btn.danger:hover { color: #e74c3c; border-color: #e74c3c; }
|
||||||
.icon-btn.small { width: 22px; height: 22px; font-size: 11px; }
|
|
||||||
|
|
||||||
.editor-body {
|
.json-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
padding: 14px 16px;
|
||||||
padding: 12px 16px;
|
background: #0a0a1a;
|
||||||
display: flex;
|
color: #ccc;
|
||||||
flex-direction: column;
|
border: none;
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #888;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #ddd;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||||
|
|
||||||
input:focus, select:focus {
|
|
||||||
border-color: rgba(255,255,255,0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qte-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qte-toggle input[type="checkbox"] {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qte-fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
background: rgba(255,255,255,0.03);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qte-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qte-row span {
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #777;
|
line-height: 1.5;
|
||||||
white-space: nowrap;
|
resize: none;
|
||||||
min-width: 80px;
|
tab-size: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qte-row input, .qte-row select {
|
.json-area.error {
|
||||||
flex: 1;
|
border-left: 3px solid #e74c3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choices-section {
|
.global-diff-tag {
|
||||||
border-top: 1px solid rgba(255,255,255,0.06);
|
display: inline-block;
|
||||||
padding-top: 12px;
|
margin-left: 10px;
|
||||||
}
|
padding: 1px 8px;
|
||||||
|
|
||||||
.add-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8cf;
|
|
||||||
background: rgba(100,200,255,0.08);
|
|
||||||
border: 1px solid rgba(100,200,255,0.2);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn:hover { background: rgba(100,200,255,0.15); }
|
|
||||||
|
|
||||||
.choice-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
background: rgba(255,255,255,0.02);
|
|
||||||
border: 1px solid rgba(255,255,255,0.05);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choice-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #666;
|
font-weight: 400;
|
||||||
|
color: #ffe0b2;
|
||||||
|
background: rgba(230, 81, 0, 0.15);
|
||||||
|
border: 1px solid rgba(230, 81, 0, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choice-extra {
|
.slide-right-enter-active,
|
||||||
display: flex;
|
.slide-right-leave-active {
|
||||||
align-items: center;
|
transition: max-width 0.25s ease, opacity 0.2s ease;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
.slide-right-enter-from,
|
||||||
.inline-label {
|
.slide-right-leave-to {
|
||||||
font-size: 11px;
|
max-width: 0;
|
||||||
color: #666;
|
opacity: 0;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-input {
|
|
||||||
width: 60px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ watch(() => props.videoUrl, async (url) => {
|
|||||||
videoRef.value.src = url
|
videoRef.value.src = url
|
||||||
playing.value = false
|
playing.value = false
|
||||||
paused.value = false
|
paused.value = false
|
||||||
})
|
}, { immediate: true })
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
if (!videoRef.value) return
|
if (!videoRef.value) return
|
||||||
@@ -31,7 +31,6 @@ function togglePlay() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="preview-panel">
|
<div class="preview-panel">
|
||||||
<div class="preview-header">预览</div>
|
|
||||||
<div class="preview-video" v-if="videoUrl">
|
<div class="preview-video" v-if="videoUrl">
|
||||||
<video ref="videoRef" preload="auto" />
|
<video ref="videoRef" preload="auto" />
|
||||||
<div class="preview-controls">
|
<div class="preview-controls">
|
||||||
@@ -52,19 +51,14 @@ function togglePlay() {
|
|||||||
border-left: 1px solid rgba(255,255,255,0.08);
|
border-left: 1px solid rgba(255,255,255,0.08);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
.preview-header {
|
|
||||||
padding: 14px 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #888;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-video {
|
.preview-video {
|
||||||
padding: 12px;
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
max-height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -72,6 +66,8 @@ function togglePlay() {
|
|||||||
|
|
||||||
.preview-video video {
|
.preview-video video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
background: #000;
|
background: #000;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { VueFlow, useVueFlow, SmoothStepEdge } from '@vue-flow/core'
|
import { VueFlow, useVueFlow, SmoothStepEdge, Handle, Position } from '@vue-flow/core'
|
||||||
import { Background } from '@vue-flow/background'
|
import { Background } from '@vue-flow/background'
|
||||||
import { Controls } from '@vue-flow/controls'
|
import { Controls } from '@vue-flow/controls'
|
||||||
import '@vue-flow/core/dist/style.css'
|
import '@vue-flow/core/dist/style.css'
|
||||||
import '@vue-flow/controls/dist/style.css'
|
import '@vue-flow/controls/dist/style.css'
|
||||||
import '@vue-flow/core/dist/theme-default.css'
|
import '@vue-flow/core/dist/theme-default.css'
|
||||||
import type { Connection } from '@vue-flow/core'
|
import type { Connection } from '@vue-flow/core'
|
||||||
import { computePositions } from '../composables/useLayout'
|
import { computePositions, savePosition } from '../composables/useLayout'
|
||||||
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sceneNodes: { id: string; label: string }[]
|
sceneNodes: { id: string; label: string }[]
|
||||||
@@ -19,21 +20,39 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
selectNode: [id: string]
|
selectNode: [id: string]
|
||||||
addEdge: [source: string, target: string]
|
addEdge: [source: string, target: string]
|
||||||
|
testScene: [id: string]
|
||||||
|
clearSelection: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const store = useEditorStore()
|
||||||
|
|
||||||
const nodes = ref<any[]>([])
|
const nodes = ref<any[]>([])
|
||||||
const edges = ref<any[]>([])
|
const edges = ref<any[]>([])
|
||||||
const { onNodeClick, onConnect, fitView } = useVueFlow()
|
const { onNodeClick, onConnect, onNodeContextMenu, onNodeDragStop, onPaneClick, fitView } = useVueFlow()
|
||||||
|
const ctxMenuVisible = ref(false)
|
||||||
|
const ctxMenuX = ref(0)
|
||||||
|
const ctxMenuY = ref(0)
|
||||||
|
const ctxMenuNodeId = ref('')
|
||||||
|
|
||||||
function makeNodes() {
|
function makeNodes() {
|
||||||
const positions = computePositions(props.sceneNodes, props.sceneEdges, props.startScene)
|
const positions = computePositions(props.sceneNodes, props.sceneEdges, props.startScene)
|
||||||
|
const changes = store.aiChanges
|
||||||
return props.sceneNodes.map((n) => {
|
return props.sceneNodes.map((n) => {
|
||||||
const pos = positions.get(n.id) ?? { x: 0, y: 0 }
|
const pos = positions.get(n.id) ?? { x: 0, y: 0 }
|
||||||
|
let badge = ''
|
||||||
|
if (changes) {
|
||||||
|
if (changes.added.includes(n.id)) badge = 'NEW'
|
||||||
|
else if (changes.modified.includes(n.id)) badge = 'MOD'
|
||||||
|
else if (changes.deleted.includes(n.id)) badge = 'DEL'
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: 'default',
|
type: 'default',
|
||||||
position: pos,
|
position: pos,
|
||||||
data: { label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label },
|
data: {
|
||||||
|
label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label,
|
||||||
|
badge,
|
||||||
|
},
|
||||||
style: n.id === props.startScene
|
style: n.id === props.startScene
|
||||||
? { background: '#1b5e20', color: '#fff', borderColor: '#388e3c' }
|
? { background: '#1b5e20', color: '#fff', borderColor: '#388e3c' }
|
||||||
: n.id === props.selectedNodeId
|
: n.id === props.selectedNodeId
|
||||||
@@ -93,7 +112,7 @@ function inlineRebuild() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId] as const,
|
() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId, store.aiChanges] as const,
|
||||||
() => {
|
() => {
|
||||||
const nc = props.sceneNodes.length
|
const nc = props.sceneNodes.length
|
||||||
const ec = props.sceneEdges.length
|
const ec = props.sceneEdges.length
|
||||||
@@ -108,6 +127,30 @@ watch(
|
|||||||
|
|
||||||
onNodeClick((ev) => emit('selectNode', ev.node.id))
|
onNodeClick((ev) => emit('selectNode', ev.node.id))
|
||||||
|
|
||||||
|
onNodeContextMenu((ev) => {
|
||||||
|
ev.event.preventDefault()
|
||||||
|
ctxMenuNodeId.value = ev.node.id
|
||||||
|
ctxMenuX.value = ev.event.clientX
|
||||||
|
ctxMenuY.value = ev.event.clientY
|
||||||
|
ctxMenuVisible.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
function testFromHere() {
|
||||||
|
emit('testScene', ctxMenuNodeId.value)
|
||||||
|
ctxMenuVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
ctxMenuVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeDragStop((ev) => {
|
||||||
|
const pos = ev.node.position
|
||||||
|
savePosition(ev.node.id, Math.round(pos.x), Math.round(pos.y))
|
||||||
|
})
|
||||||
|
|
||||||
|
onPaneClick(() => emit('clearSelection'))
|
||||||
|
|
||||||
onConnect((conn: Connection) => {
|
onConnect((conn: Connection) => {
|
||||||
if (conn.source && conn.target) emit('addEdge', conn.source, conn.target)
|
if (conn.source && conn.target) emit('addEdge', conn.source, conn.target)
|
||||||
})
|
})
|
||||||
@@ -122,9 +165,27 @@ onConnect((conn: Connection) => {
|
|||||||
:min-zoom="0.2"
|
:min-zoom="0.2"
|
||||||
:max-zoom="2"
|
:max-zoom="2"
|
||||||
>
|
>
|
||||||
|
<template #node-default="nodeProps">
|
||||||
|
<div class="custom-node">
|
||||||
|
<Handle type="target" :position="Position.Left" />
|
||||||
|
<div class="custom-node-label">{{ nodeProps.data.label }}</div>
|
||||||
|
<span v-if="nodeProps.data.badge" class="diff-badge" :class="`diff-badge-${nodeProps.data.badge}`">
|
||||||
|
{{ nodeProps.data.badge }}
|
||||||
|
</span>
|
||||||
|
<Handle type="source" :position="Position.Right" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<Background :gap="20" />
|
<Background :gap="20" />
|
||||||
<Controls />
|
<Controls />
|
||||||
</VueFlow>
|
</VueFlow>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="ctxMenuVisible" class="ctx-menu-overlay" @click="closeMenu">
|
||||||
|
<div class="ctx-menu" :style="{ left: ctxMenuX + 'px', top: ctxMenuY + 'px' }">
|
||||||
|
<button class="ctx-item" @click="testFromHere">🎬 从此场景开始测试</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -134,4 +195,79 @@ onConnect((conn: Connection) => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: #0d0d1a;
|
background: #0d0d1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ctx-menu-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-menu {
|
||||||
|
position: absolute;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 160px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ccc;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item:hover {
|
||||||
|
background: rgba(100,200,255,0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-node {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 30px 8px 12px;
|
||||||
|
min-width: 120px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-node-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-badge-NEW {
|
||||||
|
background: #2e7d32;
|
||||||
|
color: #c8e6c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-badge-MOD {
|
||||||
|
background: #e65100;
|
||||||
|
color: #ffe0b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-badge-DEL {
|
||||||
|
background: #c62828;
|
||||||
|
color: #ffcdd2;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
18
editor/composables/useAI.ts
Normal file
18
editor/composables/useAI.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export async function sendAIRequest(userMessage: string, mode: string, apiKey: string, sessionId?: string): Promise<{ result: string; sessionId: string }> {
|
||||||
|
const resp = await fetch('/api/ai', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userMessage, apiKey, mode, ...(sessionId ? { sessionId } : {}) }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ error: 'request failed' }))
|
||||||
|
throw new Error(err.error || 'request failed')
|
||||||
|
}
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessions(): Promise<any[]> {
|
||||||
|
const resp = await fetch('/api/ai/sessions')
|
||||||
|
if (!resp.ok) return []
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
@@ -1,178 +1,42 @@
|
|||||||
import { ref, computed, shallowRef, triggerRef } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { GameData, SceneNode, Choice } from '@engine/types'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
|
import { computeEdges, computeSceneNodes } from '../services/GraphService'
|
||||||
|
|
||||||
export function useGraphEditor() {
|
export function useGraphEditor() {
|
||||||
const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
|
const store = useEditorStore()
|
||||||
const selectedNodeId = ref<string | null>(null)
|
|
||||||
const startSceneId = ref('')
|
|
||||||
|
|
||||||
const sceneList = computed(() =>
|
const sceneNodes = computed(() => computeSceneNodes(store.gameData))
|
||||||
Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })),
|
const sceneEdges = computed(() => computeEdges(store.gameData))
|
||||||
)
|
const sceneList = computed(() => computeSceneNodes(store.gameData))
|
||||||
|
|
||||||
const sceneNodes = computed(() =>
|
function onAddEdge(source: string, target: string) {
|
||||||
Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })),
|
const scene = store.gameData.scenes[source]
|
||||||
)
|
|
||||||
|
|
||||||
const sceneEdges = computed(() => {
|
|
||||||
const result: { id: string; source: string; target: string; label?: string }[] = []
|
|
||||||
for (const [id, scene] of Object.entries(gameData.value.scenes)) {
|
|
||||||
if (scene.choices) {
|
|
||||||
let ci = 0
|
|
||||||
for (const c of scene.choices) {
|
|
||||||
if (c.targetScene) {
|
|
||||||
result.push({ id: `${id}_choice_${ci}`, source: id, target: c.targetScene, label: c.text.slice(0, 10) })
|
|
||||||
}
|
|
||||||
ci++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (scene.nextScene) {
|
|
||||||
result.push({ id: `${id}_next`, source: id, target: scene.nextScene, label: '\u2192 \u9ed8\u8ba4' })
|
|
||||||
}
|
|
||||||
if (scene.qte) {
|
|
||||||
if (scene.qte.successScene)
|
|
||||||
result.push({ id: `${id}_qte_s`, source: id, target: scene.qte.successScene, label: 'QTE\u6210\u529f' })
|
|
||||||
if (scene.qte.failScene)
|
|
||||||
result.push({ id: `${id}_qte_f`, source: id, target: scene.qte.failScene, label: 'QTE\u5931\u8d25' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedScene = computed(() => {
|
|
||||||
if (!selectedNodeId.value) return null
|
|
||||||
return gameData.value.scenes[selectedNodeId.value] ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
function trigger() {
|
|
||||||
triggerRef(gameData)
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadJSON(json: GameData) {
|
|
||||||
gameData.value = JSON.parse(JSON.stringify(json))
|
|
||||||
trigger()
|
|
||||||
startSceneId.value = json.startScene || ''
|
|
||||||
selectedNodeId.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportJSON(): GameData {
|
|
||||||
return JSON.parse(JSON.stringify({ ...gameData.value, startScene: startSceneId.value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateId(): string {
|
|
||||||
let i = Object.keys(gameData.value.scenes).length + 1
|
|
||||||
while (gameData.value.scenes[`scene_${i}`]) i++
|
|
||||||
return `scene_${i}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function addScene(): string {
|
|
||||||
const id = generateId()
|
|
||||||
gameData.value = {
|
|
||||||
...gameData.value,
|
|
||||||
scenes: {
|
|
||||||
...gameData.value.scenes,
|
|
||||||
[id]: {
|
|
||||||
id,
|
|
||||||
videoUrl: '',
|
|
||||||
choices: [],
|
|
||||||
nextScene: '',
|
|
||||||
subtitleUrl: '',
|
|
||||||
onEnter: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
trigger()
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteScene(id: string) {
|
|
||||||
if (startSceneId.value === id) return
|
|
||||||
const nextScenes = { ...gameData.value.scenes }
|
|
||||||
delete nextScenes[id]
|
|
||||||
for (const key of Object.keys(nextScenes)) {
|
|
||||||
const s = nextScenes[key]
|
|
||||||
if (s.choices)
|
|
||||||
nextScenes[key] = { ...s, choices: s.choices.filter((c) => c.targetScene !== id) }
|
|
||||||
if (s.nextScene === id) nextScenes[key] = { ...nextScenes[key], nextScene: '' }
|
|
||||||
if (s.qte) {
|
|
||||||
const qte = { ...s.qte }
|
|
||||||
let changed = false
|
|
||||||
if (qte.successScene === id) { qte.successScene = ''; changed = true }
|
|
||||||
if (qte.failScene === id) { qte.failScene = ''; changed = true }
|
|
||||||
if (changed) nextScenes[key] = { ...nextScenes[key], qte }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gameData.value = { ...gameData.value, scenes: nextScenes }
|
|
||||||
trigger()
|
|
||||||
if (selectedNodeId.value === id) selectedNodeId.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateScene(id: string, partial: Partial<SceneNode>) {
|
|
||||||
const scene = gameData.value.scenes[id]
|
|
||||||
if (!scene) return
|
if (!scene) return
|
||||||
gameData.value = {
|
const newChoices = [...(scene.choices || []), { text: `${source} → ${target}`, targetScene: target }]
|
||||||
...gameData.value,
|
store.gameData = {
|
||||||
scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } },
|
...store.gameData,
|
||||||
|
scenes: { ...store.gameData.scenes, [source]: { ...scene, choices: newChoices } },
|
||||||
}
|
}
|
||||||
trigger()
|
store.markDirty()
|
||||||
}
|
store.autoSave()
|
||||||
|
|
||||||
function addChoice(sourceId: string) {
|
|
||||||
const scene = gameData.value.scenes[sourceId]
|
|
||||||
if (!scene) return
|
|
||||||
gameData.value = {
|
|
||||||
...gameData.value,
|
|
||||||
scenes: {
|
|
||||||
...gameData.value.scenes,
|
|
||||||
[sourceId]: {
|
|
||||||
...scene,
|
|
||||||
choices: [...(scene.choices || []), { text: '\u65b0\u9009\u9879', targetScene: '' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
trigger()
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) {
|
|
||||||
const scene = gameData.value.scenes[sourceId]
|
|
||||||
if (!scene?.choices) return
|
|
||||||
const newChoices = scene.choices.map((c, i) => (i === index ? { ...c, ...partial } : c))
|
|
||||||
gameData.value = {
|
|
||||||
...gameData.value,
|
|
||||||
scenes: { ...gameData.value.scenes, [sourceId]: { ...scene, choices: newChoices } },
|
|
||||||
}
|
|
||||||
trigger()
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteChoice(sourceId: string, index: number) {
|
|
||||||
const scene = gameData.value.scenes[sourceId]
|
|
||||||
if (!scene?.choices) return
|
|
||||||
gameData.value = {
|
|
||||||
...gameData.value,
|
|
||||||
scenes: {
|
|
||||||
...gameData.value.scenes,
|
|
||||||
[sourceId]: { ...scene, choices: scene.choices.filter((_, i) => i !== index) },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
trigger()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gameData,
|
gameData: store.gameData,
|
||||||
selectedNodeId,
|
selectedNodeId: store.selectedNodeId,
|
||||||
selectedScene,
|
selectedScene: store.selectedScene,
|
||||||
sceneList,
|
startSceneId: store.startSceneId,
|
||||||
sceneNodes,
|
sceneNodes,
|
||||||
sceneEdges,
|
sceneEdges,
|
||||||
startSceneId,
|
sceneList,
|
||||||
loadJSON,
|
loadJSON: store.loadJSON,
|
||||||
exportJSON,
|
exportJSON: store.exportJSON,
|
||||||
addScene,
|
updateScene: store.updateScene,
|
||||||
deleteScene,
|
addChoice: store.addChoice,
|
||||||
updateScene,
|
updateChoice: store.updateChoice,
|
||||||
addChoice,
|
deleteChoice: store.deleteChoice,
|
||||||
updateChoice,
|
addScene: store.addScene,
|
||||||
deleteChoice,
|
deleteScene: store.deleteScene,
|
||||||
generateId,
|
onAddEdge,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,27 +12,54 @@ interface EdgeInfo {
|
|||||||
|
|
||||||
const NODE_W = 180
|
const NODE_W = 180
|
||||||
const NODE_H = 60
|
const NODE_H = 60
|
||||||
|
const POSITIONS_KEY = 'editor_positions'
|
||||||
|
|
||||||
|
export function loadSavedPositions(): Record<string, { x: number; y: number }> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(POSITIONS_KEY) || '{}')
|
||||||
|
} catch { return {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePosition(nodeId: string, x: number, y: number) {
|
||||||
|
const saved = loadSavedPositions()
|
||||||
|
saved[nodeId] = { x, y }
|
||||||
|
localStorage.setItem(POSITIONS_KEY, JSON.stringify(saved))
|
||||||
|
}
|
||||||
|
|
||||||
export function computePositions(
|
export function computePositions(
|
||||||
nodes: NodeInfo[],
|
nodes: NodeInfo[],
|
||||||
edges: EdgeInfo[],
|
edges: EdgeInfo[],
|
||||||
_startScene: string,
|
_startScene: string,
|
||||||
): Map<string, { x: number; y: number }> {
|
): Map<string, { x: number; y: number }> {
|
||||||
|
const saved = loadSavedPositions()
|
||||||
|
const result = new Map<string, { x: number; y: number }>()
|
||||||
|
|
||||||
|
const unsaved: NodeInfo[] = []
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (saved[n.id]) {
|
||||||
|
result.set(n.id, saved[n.id])
|
||||||
|
} else {
|
||||||
|
unsaved.push(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unsaved.length === 0) return result
|
||||||
|
|
||||||
const g = new dagre.graphlib.Graph()
|
const g = new dagre.graphlib.Graph()
|
||||||
g.setGraph({
|
g.setGraph({
|
||||||
rankdir: 'LR',
|
rankdir: 'LR',
|
||||||
nodesep: 50,
|
nodesep: 100,
|
||||||
ranksep: 240,
|
ranksep: 200,
|
||||||
marginx: 60,
|
marginx: 60,
|
||||||
marginy: 60,
|
marginy: 60,
|
||||||
})
|
})
|
||||||
g.setDefaultEdgeLabel(() => ({}))
|
g.setDefaultEdgeLabel(() => ({}))
|
||||||
|
|
||||||
for (const n of nodes) {
|
for (const n of unsaved) {
|
||||||
g.setNode(n.id, { width: NODE_W, height: NODE_H })
|
g.setNode(n.id, { width: NODE_W, height: NODE_H })
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeIds = new Set(nodes.map((n) => n.id))
|
const nodeIds = new Set(unsaved.map((n) => n.id))
|
||||||
for (const e of edges) {
|
for (const e of edges) {
|
||||||
if (nodeIds.has(e.source) && nodeIds.has(e.target)) {
|
if (nodeIds.has(e.source) && nodeIds.has(e.target)) {
|
||||||
g.setEdge(e.source, e.target)
|
g.setEdge(e.source, e.target)
|
||||||
@@ -41,13 +68,12 @@ export function computePositions(
|
|||||||
|
|
||||||
dagre.layout(g)
|
dagre.layout(g)
|
||||||
|
|
||||||
const positions = new Map<string, { x: number; y: number }>()
|
for (const n of unsaved) {
|
||||||
for (const n of nodes) {
|
|
||||||
const node = g.node(n.id)
|
const node = g.node(n.id)
|
||||||
if (node) {
|
if (node) {
|
||||||
positions.set(n.id, { x: node.x - NODE_W / 2, y: node.y - NODE_H / 2 })
|
result.set(n.id, { x: node.x - NODE_W / 2, y: node.y - NODE_H / 2 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return positions
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
57
editor/db/editorDB.ts
Normal file
57
editor/db/editorDB.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Dexie, { type Table } from 'dexie'
|
||||||
|
import type { GameData } from '@engine/types'
|
||||||
|
|
||||||
|
export interface VersionRecord {
|
||||||
|
id?: number
|
||||||
|
sourcePath: string
|
||||||
|
timestamp: number
|
||||||
|
label: string
|
||||||
|
gameData: GameData
|
||||||
|
}
|
||||||
|
|
||||||
|
class EditorDB extends Dexie {
|
||||||
|
versions!: Table<VersionRecord, number>
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('EditorVersions')
|
||||||
|
this.version(1).stores({
|
||||||
|
versions: '++id, sourcePath, timestamp',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new EditorDB()
|
||||||
|
|
||||||
|
export async function putVersion(record: VersionRecord): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.versions.add(record)
|
||||||
|
const all = await db.versions
|
||||||
|
.where('sourcePath')
|
||||||
|
.equals(record.sourcePath)
|
||||||
|
.reverse()
|
||||||
|
.sortBy('timestamp')
|
||||||
|
if (all.length > 20) {
|
||||||
|
const toDelete = all.slice(20)
|
||||||
|
await db.versions.bulkDelete(toDelete.map((v) => v.id!))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersions(sourcePath: string): Promise<VersionRecord[]> {
|
||||||
|
try {
|
||||||
|
return await db.versions
|
||||||
|
.where('sourcePath')
|
||||||
|
.equals(sourcePath)
|
||||||
|
.reverse()
|
||||||
|
.sortBy('timestamp')
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearVersions(sourcePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const records = await db.versions.where('sourcePath').equals(sourcePath).toArray()
|
||||||
|
await db.versions.bulkDelete(records.map((v) => v.id!))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -4,10 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>剧情编辑器 — 交互式电影游戏</title>
|
<title>剧情编辑器 — 交互式电影游戏</title>
|
||||||
<style>
|
<link rel="stylesheet" href="./style.css" />
|
||||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #0a0a16; }
|
|
||||||
#editor-app { width: 100%; height: 100%; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="editor-app"></div>
|
<div id="editor-app"></div>
|
||||||
|
|||||||
46
editor/services/GraphService.ts
Normal file
46
editor/services/GraphService.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { GameData } from '@engine/types'
|
||||||
|
|
||||||
|
export function computeEdges(gameData: GameData): { id: string; source: string; target: string; label?: string }[] {
|
||||||
|
const result: { id: string; source: string; target: string; label?: string }[] = []
|
||||||
|
for (const [id, scene] of Object.entries(gameData.scenes)) {
|
||||||
|
if (scene.choices) {
|
||||||
|
let ci = 0
|
||||||
|
for (const c of scene.choices) {
|
||||||
|
if (c.targetScene) {
|
||||||
|
result.push({ id: `${id}_choice_${ci}`, source: id, target: c.targetScene, label: c.text.slice(0, 10) })
|
||||||
|
}
|
||||||
|
ci++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scene.nextScene) {
|
||||||
|
if (Array.isArray(scene.nextScene)) {
|
||||||
|
for (let ri = 0; ri < scene.nextScene.length; ri++) {
|
||||||
|
const r = scene.nextScene[ri]
|
||||||
|
if (r.targetScene) {
|
||||||
|
result.push({ id: `${id}_next_${ri}`, source: id, target: r.targetScene, label: r.conditions?.length ? '→ 条件' : '→ 默认' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push({ id: `${id}_next`, source: id, target: scene.nextScene, label: '→' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scene.qte) {
|
||||||
|
if (scene.qte.successScene)
|
||||||
|
result.push({ id: `${id}_qte_s`, source: id, target: scene.qte.successScene, label: 'QTE成功' })
|
||||||
|
if (scene.qte.failScene)
|
||||||
|
result.push({ id: `${id}_qte_f`, source: id, target: scene.qte.failScene, label: 'QTE失败' })
|
||||||
|
}
|
||||||
|
if (scene.hotspots) {
|
||||||
|
for (const h of scene.hotspots) {
|
||||||
|
if (h.targetScene) {
|
||||||
|
result.push({ id: `${id}_hs_${h.id}`, source: id, target: h.targetScene, label: h.label.slice(0, 10) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSceneNodes(gameData: GameData): { id: string; label: string }[] {
|
||||||
|
return Object.values(gameData.scenes).map(s => ({ id: s.id, label: s.id }))
|
||||||
|
}
|
||||||
266
editor/stores/editorStore.ts
Normal file
266
editor/stores/editorStore.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { shallowRef, ref, computed, triggerRef } from 'vue'
|
||||||
|
import type { GameData, SceneNode, Choice } from '@engine/types'
|
||||||
|
import { putVersion, getVersions } from '../db/editorDB'
|
||||||
|
|
||||||
|
export interface AIDiff {
|
||||||
|
added: string[]
|
||||||
|
modified: string[]
|
||||||
|
deleted: string[]
|
||||||
|
globalFields: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorVersion {
|
||||||
|
id?: number
|
||||||
|
sourcePath: string
|
||||||
|
timestamp: number
|
||||||
|
label: string
|
||||||
|
gameData: GameData
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorStore = defineStore('editor', () => {
|
||||||
|
const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
|
||||||
|
const selectedNodeId = ref<string | null>(null)
|
||||||
|
const startSceneId = ref('')
|
||||||
|
const dirty = ref(false)
|
||||||
|
const sourcePath = ref('/scenes/demo.json')
|
||||||
|
const deepseekKey = ref(localStorage.getItem('deepseek_key') || '')
|
||||||
|
const showAIPanel = ref(true)
|
||||||
|
const aiCollapsed = ref(true)
|
||||||
|
const nodeEditorCollapsed = ref(true)
|
||||||
|
const aiSessionId = ref('')
|
||||||
|
const aiChanges = ref<AIDiff | null>(null)
|
||||||
|
const versions = ref<EditorVersion[]>([])
|
||||||
|
|
||||||
|
const selectedScene = computed(() => {
|
||||||
|
if (!selectedNodeId.value) return null
|
||||||
|
return gameData.value.scenes[selectedNodeId.value] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
function markDirty() { dirty.value = true }
|
||||||
|
|
||||||
|
function loadJSON(json: GameData) {
|
||||||
|
gameData.value = JSON.parse(JSON.stringify(json))
|
||||||
|
triggerRef(gameData)
|
||||||
|
startSceneId.value = json.startScene || ''
|
||||||
|
selectedNodeId.value = null
|
||||||
|
dirty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportJSON(): GameData {
|
||||||
|
return JSON.parse(JSON.stringify({ ...gameData.value, startScene: startSceneId.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
let i = Object.keys(gameData.value.scenes).length + 1
|
||||||
|
while (gameData.value.scenes[`scene_${i}`]) i++
|
||||||
|
return `scene_${i}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function addScene(): string {
|
||||||
|
const id = generateId()
|
||||||
|
gameData.value = {
|
||||||
|
...gameData.value,
|
||||||
|
scenes: {
|
||||||
|
...gameData.value.scenes,
|
||||||
|
[id]: { id, videoUrl: '', choices: [], nextScene: '', subtitleUrl: '', onEnter: [] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
triggerRef(gameData)
|
||||||
|
dirty.value = true
|
||||||
|
autoSave()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteScene(id: string) {
|
||||||
|
if (startSceneId.value === id) return
|
||||||
|
const nextScenes = { ...gameData.value.scenes }
|
||||||
|
delete nextScenes[id]
|
||||||
|
for (const key of Object.keys(nextScenes)) {
|
||||||
|
const s = nextScenes[key]
|
||||||
|
if (s.choices) nextScenes[key] = { ...s, choices: s.choices.filter((c) => c.targetScene !== id) }
|
||||||
|
if (Array.isArray(s.nextScene)) {
|
||||||
|
nextScenes[key] = { ...s, nextScene: s.nextScene.filter((r) => r.targetScene !== id) }
|
||||||
|
} else if (s.nextScene === id) {
|
||||||
|
nextScenes[key] = { ...nextScenes[key], nextScene: '' }
|
||||||
|
}
|
||||||
|
if (s.qte) {
|
||||||
|
const qte = { ...s.qte }
|
||||||
|
let changed = false
|
||||||
|
if (qte.successScene === id) { qte.successScene = ''; changed = true }
|
||||||
|
if (qte.failScene === id) { qte.failScene = ''; changed = true }
|
||||||
|
if (changed) nextScenes[key] = { ...nextScenes[key], qte }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gameData.value = { ...gameData.value, scenes: nextScenes }
|
||||||
|
triggerRef(gameData)
|
||||||
|
dirty.value = true
|
||||||
|
if (selectedNodeId.value === id) selectedNodeId.value = null
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScene(id: string, partial: Partial<SceneNode>) {
|
||||||
|
const scene = gameData.value.scenes[id]
|
||||||
|
if (!scene) return
|
||||||
|
gameData.value = {
|
||||||
|
...gameData.value,
|
||||||
|
scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } },
|
||||||
|
}
|
||||||
|
triggerRef(gameData)
|
||||||
|
dirty.value = true
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChoice(sourceId: string) {
|
||||||
|
const scene = gameData.value.scenes[sourceId]
|
||||||
|
if (!scene) return
|
||||||
|
gameData.value = {
|
||||||
|
...gameData.value,
|
||||||
|
scenes: {
|
||||||
|
...gameData.value.scenes,
|
||||||
|
[sourceId]: { ...scene, choices: [...(scene.choices || []), { text: '\u65b0\u9009\u9879', targetScene: '' }] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
triggerRef(gameData)
|
||||||
|
dirty.value = true
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) {
|
||||||
|
const scene = gameData.value.scenes[sourceId]
|
||||||
|
if (!scene?.choices) return
|
||||||
|
const newChoices = scene.choices.map((c, i) => (i === index ? { ...c, ...partial } : c))
|
||||||
|
gameData.value = {
|
||||||
|
...gameData.value,
|
||||||
|
scenes: { ...gameData.value.scenes, [sourceId]: { ...scene, choices: newChoices } },
|
||||||
|
}
|
||||||
|
triggerRef(gameData)
|
||||||
|
dirty.value = true
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteChoice(sourceId: string, index: number) {
|
||||||
|
const scene = gameData.value.scenes[sourceId]
|
||||||
|
if (!scene?.choices) return
|
||||||
|
gameData.value = {
|
||||||
|
...gameData.value,
|
||||||
|
scenes: {
|
||||||
|
...gameData.value.scenes,
|
||||||
|
[sourceId]: { ...scene, choices: scene.choices.filter((_, i) => i !== index) },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
triggerRef(gameData)
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSourcePath(p: string) { sourcePath.value = p; localStorage.setItem('editor_last_source', p) }
|
||||||
|
|
||||||
|
function setDeepseekKey(k: string) { deepseekKey.value = k; localStorage.setItem('deepseek_key', k) }
|
||||||
|
|
||||||
|
function setAISessionId(id: string) { aiSessionId.value = id; localStorage.setItem('editor_ai_session', id) }
|
||||||
|
|
||||||
|
function clearAISession() { aiSessionId.value = ''; localStorage.removeItem('editor_ai_session') }
|
||||||
|
|
||||||
|
let saveVersionTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let lastSavedContent = ''
|
||||||
|
|
||||||
|
function debouncedSaveVersion() {
|
||||||
|
const current = JSON.stringify(gameData.value)
|
||||||
|
if (current === lastSavedContent) {
|
||||||
|
console.log('[版本] 内容相同,跳过')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (saveVersionTimer) { console.log('[版本] 重置 3s 计时器'); clearTimeout(saveVersionTimer) }
|
||||||
|
else console.log('[版本] 启动 3s 计时器')
|
||||||
|
saveVersionTimer = setTimeout(async () => {
|
||||||
|
console.log('[版本] 保存到 IndexedDB')
|
||||||
|
lastSavedContent = current
|
||||||
|
await saveVersion('手动编辑')
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoSave() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: sourcePath.value, data: gameData.value }),
|
||||||
|
})
|
||||||
|
} catch { /* dev server not running */ }
|
||||||
|
debouncedSaveVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveVersion(label: string) {
|
||||||
|
try {
|
||||||
|
await putVersion({
|
||||||
|
sourcePath: sourcePath.value,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
label,
|
||||||
|
gameData: JSON.parse(JSON.stringify(gameData.value)),
|
||||||
|
})
|
||||||
|
await loadVersions()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreVersion(idx: number) {
|
||||||
|
const v = versions.value[idx]
|
||||||
|
if (!v) return
|
||||||
|
gameData.value = v.gameData
|
||||||
|
startSceneId.value = v.gameData.startScene || ''
|
||||||
|
selectedNodeId.value = null
|
||||||
|
aiChanges.value = null
|
||||||
|
dirty.value = false
|
||||||
|
lastSavedContent = JSON.stringify(v.gameData)
|
||||||
|
if (saveVersionTimer) { clearTimeout(saveVersionTimer); saveVersionTimer = null }
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
try {
|
||||||
|
versions.value = await getVersions(sourcePath.value)
|
||||||
|
} catch {
|
||||||
|
versions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAIMarkers() {
|
||||||
|
aiChanges.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadFromDisk(oldGameData?: GameData) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(sourcePath.value)
|
||||||
|
const newData = await resp.json()
|
||||||
|
if (oldGameData) {
|
||||||
|
const { scenes: newScenes, ...newGlobal } = newData
|
||||||
|
const { scenes: oldScenes, ...oldGlobal } = oldGameData
|
||||||
|
const diff: AIDiff = { added: [], modified: [], deleted: [], globalFields: [] }
|
||||||
|
for (const id of Object.keys(newScenes)) {
|
||||||
|
if (!oldScenes[id]) diff.added.push(id)
|
||||||
|
else if (JSON.stringify(oldScenes[id]) !== JSON.stringify(newScenes[id])) diff.modified.push(id)
|
||||||
|
}
|
||||||
|
for (const id of Object.keys(oldScenes)) {
|
||||||
|
if (!newScenes[id]) diff.deleted.push(id)
|
||||||
|
}
|
||||||
|
for (const key of Object.keys({ ...(oldGlobal as any), ...(newGlobal as any) })) {
|
||||||
|
if (JSON.stringify((oldGlobal as any)[key]) !== JSON.stringify((newGlobal as any)[key])) {
|
||||||
|
diff.globalFields.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aiChanges.value = diff
|
||||||
|
}
|
||||||
|
gameData.value = newData
|
||||||
|
selectedNodeId.value = null
|
||||||
|
clearAISession()
|
||||||
|
} catch { /* failed to reload */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameData, selectedNodeId, selectedScene, startSceneId, dirty, sourcePath,
|
||||||
|
deepseekKey, showAIPanel, aiSessionId, aiCollapsed, nodeEditorCollapsed, aiChanges, versions,
|
||||||
|
markDirty, loadJSON, exportJSON, addScene, deleteScene,
|
||||||
|
updateScene, addChoice, updateChoice, deleteChoice, generateId,
|
||||||
|
setSourcePath, setDeepseekKey, setAISessionId, clearAISession, autoSave, reloadFromDisk,
|
||||||
|
saveVersion, restoreVersion, loadVersions, clearAIMarkers,
|
||||||
|
}
|
||||||
|
})
|
||||||
12
editor/style.css
Normal file
12
editor/style.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0a0a16;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
11
electron/.npmrc
Normal file
11
electron/.npmrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Electron 镜像源配置
|
||||||
|
# 解决打包时需要翻墙的问题
|
||||||
|
|
||||||
|
# Electron 镜像
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
|
||||||
|
# Electron Builder 镜像
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
|
||||||
|
# npm 镜像
|
||||||
|
registry=https://registry.npmmirror.com
|
||||||
@@ -1,16 +1,85 @@
|
|||||||
const { app, BrowserWindow } = require('electron')
|
const { app, BrowserWindow, globalShortcut } = require('electron')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const { startServer } = require('./server')
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
let server = null
|
||||||
const sceneArg = process.argv.find(a => a.startsWith('--scene='))
|
let PORT = null
|
||||||
const query = sceneArg ? { scene: sceneArg.split('=')[1] } : {}
|
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
app.whenReady().then(async () => {
|
||||||
fullscreen: true,
|
try {
|
||||||
autoHideMenuBar: true,
|
// 启动本地服务器
|
||||||
webPreferences: { nodeIntegration: false }
|
const serverInfo = await startServer()
|
||||||
})
|
server = serverInfo.server
|
||||||
win.loadFile(path.join(__dirname, '..', 'dist', 'index.html'), { query })
|
PORT = serverInfo.PORT
|
||||||
|
|
||||||
|
const sceneArg = process.argv.find(a => a.startsWith('--scene='))
|
||||||
|
const query = sceneArg ? { scene: sceneArg.split('=')[1] } : {}
|
||||||
|
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
resizable: true,
|
||||||
|
maximizable: true,
|
||||||
|
minimizable: true,
|
||||||
|
closable: true,
|
||||||
|
autoHideMenuBar: false,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
preload: path.join(__dirname, 'preload.js')
|
||||||
|
},
|
||||||
|
icon: path.join(__dirname, '..', 'public', 'icon.png') // 应用图标
|
||||||
|
})
|
||||||
|
|
||||||
|
// 注册全屏切换快捷键 (F11 或 Command+F)
|
||||||
|
globalShortcut.register('F11', () => {
|
||||||
|
win.setFullScreen(!win.isFullScreen())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 注册退出快捷键 (Alt+F4 或 Command+Q)
|
||||||
|
globalShortcut.register('Alt+F4', () => {
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建查询参数字符串
|
||||||
|
const queryString = new URLSearchParams(query).toString()
|
||||||
|
const url = queryString ? `http://127.0.0.1:${PORT}/index.html?${queryString}` : `http://127.0.0.1:${PORT}/index.html`
|
||||||
|
|
||||||
|
console.log('🚀 Loading app from:', url)
|
||||||
|
win.loadURL(url)
|
||||||
|
|
||||||
|
// 打开开发者工具,方便调试(生产环境可以注释掉)
|
||||||
|
// win.webContents.openDevTools()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to start app:', error)
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('window-all-closed', () => app.quit())
|
app.on('window-all-closed', () => {
|
||||||
|
// 取消注册所有快捷键
|
||||||
|
globalShortcut.unregisterAll()
|
||||||
|
if (server) {
|
||||||
|
server.close() // 关闭服务器
|
||||||
|
}
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
// 取消注册所有快捷键
|
||||||
|
globalShortcut.unregisterAll()
|
||||||
|
if (server) {
|
||||||
|
server.close() // 确保服务器被关闭
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 防止应用在 Mac 上关闭窗口后退出
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
// 重新创建窗口的逻辑
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
1557
electron/package-lock.json
generated
Normal file
1557
electron/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,15 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"pack:mac": "npx @electron/packager . MyGame --platform=mas --arch=arm64,x64 --out=../release --overwrite",
|
"pack:mac": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ node ../scripts/prepare-electron.cjs && 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"
|
"pack:win": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ node ../scripts/prepare-electron.cjs && npx @electron/packager . MyGame --platform=win32 --arch=x64 --out=../release --overwrite"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^30.0.0",
|
"@electron/packager": "^20.0.1",
|
||||||
"@electron/packager": "^18.0.0"
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^42.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
electron/preload.js
Normal file
3
electron/preload.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const { contextBridge } = require('electron')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('__ELECTRON__', true)
|
||||||
65
electron/server.js
Normal file
65
electron/server.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const path = require('path')
|
||||||
|
const net = require('net')
|
||||||
|
|
||||||
|
const appHttp = express()
|
||||||
|
const START_PORT = 9527
|
||||||
|
const MAX_PORT = 9999
|
||||||
|
|
||||||
|
// 检查端口是否可用
|
||||||
|
function isPortAvailable(port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.listen(port, '127.0.0.1', () => {
|
||||||
|
server.once('close', () => resolve(true))
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
server.on('error', () => resolve(false))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 寻找可用端口
|
||||||
|
async function findAvailablePort(startPort, maxPort) {
|
||||||
|
for (let port = startPort; port <= maxPort; port++) {
|
||||||
|
const available = await isPortAvailable(port)
|
||||||
|
if (available) {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No available port found between ${startPort} and ${maxPort}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 dist 目录路径
|
||||||
|
let distPath
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
// 开发环境
|
||||||
|
distPath = path.join(__dirname, '..', 'dist')
|
||||||
|
} else {
|
||||||
|
// 生产环境(打包后)
|
||||||
|
distPath = path.join(__dirname, 'dist')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Serving static files from:', distPath)
|
||||||
|
|
||||||
|
// 提供静态文件
|
||||||
|
appHttp.use(express.static(distPath))
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
async function startServer() {
|
||||||
|
try {
|
||||||
|
const PORT = await findAvailablePort(START_PORT, MAX_PORT)
|
||||||
|
|
||||||
|
const server = appHttp.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`✅ Local server running at http://127.0.0.1:${PORT}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 导出服务器实例和端口,以便在需要时关闭
|
||||||
|
return { server, PORT }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to start server:', error.message)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出启动函数
|
||||||
|
module.exports = { startServer }
|
||||||
@@ -51,9 +51,9 @@ export class Engine {
|
|||||||
this.audioSystem = new AudioSystem()
|
this.audioSystem = new AudioSystem()
|
||||||
this.achievementSystem = new AchievementSystem()
|
this.achievementSystem = new AchievementSystem()
|
||||||
|
|
||||||
this.stateManager.onAfterApply = (vars) => {
|
this.stateManager.onAfterApply.add((vars) => {
|
||||||
this.achievementSystem.check(vars)
|
this.achievementSystem.check(vars)
|
||||||
}
|
})
|
||||||
|
|
||||||
this.videoManager.onTimeUpdate(this.onTimeUpdate)
|
this.videoManager.onTimeUpdate(this.onTimeUpdate)
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ export class Engine {
|
|||||||
this.goToScene(startScene)
|
this.goToScene(startScene)
|
||||||
}
|
}
|
||||||
|
|
||||||
private goToScene(scene: SceneNode) {
|
public goToScene(scene: SceneNode) {
|
||||||
this.currentScene = scene
|
this.currentScene = scene
|
||||||
|
|
||||||
const chapter = this.sceneManager.getChapterBySceneId(scene.id)
|
const chapter = this.sceneManager.getChapterBySceneId(scene.id)
|
||||||
@@ -89,6 +89,10 @@ export class Engine {
|
|||||||
|
|
||||||
this.onMarkVisited?.(scene.id)
|
this.onMarkVisited?.(scene.id)
|
||||||
|
|
||||||
|
if (scene.onEnter) {
|
||||||
|
this.stateManager.apply(scene.onEnter)
|
||||||
|
}
|
||||||
|
|
||||||
this.qteTriggered = false
|
this.qteTriggered = false
|
||||||
this.qteResolved = false
|
this.qteResolved = false
|
||||||
this.loopActive = false
|
this.loopActive = false
|
||||||
@@ -107,10 +111,14 @@ export class Engine {
|
|||||||
this.emit('sceneChange', scene)
|
this.emit('sceneChange', scene)
|
||||||
this.checkHotspotTime(scene, 0)
|
this.checkHotspotTime(scene, 0)
|
||||||
|
|
||||||
const preloadUrls = this.sceneManager.getCandidateUrls(
|
const candidateIds = this.sceneManager.getCandidateSceneIds(
|
||||||
scene,
|
scene,
|
||||||
(conds) => conds ? this.stateManager.evaluate(conds) : true
|
(conds) => conds ? this.stateManager.evaluate(conds) : true
|
||||||
)
|
)
|
||||||
|
const preloadUrls = candidateIds
|
||||||
|
.map(id => this.sceneManager.getScene(id))
|
||||||
|
.filter((s) => !!s && s.type !== 'image')
|
||||||
|
.map(s => this.videoManager.resolveVideoUrl(s!, this.videoManager.streamingQuality))
|
||||||
|
|
||||||
this.videoManager.onEnd(() => {
|
this.videoManager.onEnd(() => {
|
||||||
if (!this.qteTriggered || this.qteResolved) {
|
if (!this.qteTriggered || this.qteResolved) {
|
||||||
@@ -132,9 +140,9 @@ export class Engine {
|
|||||||
|
|
||||||
if (this.isInitialScene) {
|
if (this.isInitialScene) {
|
||||||
this.isInitialScene = false
|
this.isInitialScene = false
|
||||||
this.videoManager.playInitial(scene.videoUrl, preloadUrls)
|
this.videoManager.playInitial(this.videoManager.resolveVideoUrl(scene, this.videoManager.streamingQuality), preloadUrls)
|
||||||
} else {
|
} else {
|
||||||
this.videoManager.switchTo(scene.videoUrl, preloadUrls)
|
this.videoManager.switchTo(this.videoManager.resolveVideoUrl(scene, this.videoManager.streamingQuality), preloadUrls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +282,11 @@ export class Engine {
|
|||||||
|
|
||||||
if (this.loopActive) return
|
if (this.loopActive) return
|
||||||
|
|
||||||
|
if (scene.battleResult) {
|
||||||
|
this.emit('battleResultRequest', scene.battleResult)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const validChoices = this.getValidChoices(scene)
|
const validChoices = this.getValidChoices(scene)
|
||||||
|
|
||||||
if (validChoices.length > 0) {
|
if (validChoices.length > 0) {
|
||||||
@@ -292,11 +305,26 @@ export class Engine {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else if (scene.nextScene) {
|
} else if (scene.nextScene) {
|
||||||
const next = this.sceneManager.getScene(scene.nextScene)
|
if (Array.isArray(scene.nextScene)) {
|
||||||
if (next) {
|
for (const route of scene.nextScene) {
|
||||||
this.goToScene(next)
|
if (!route.conditions || this.stateManager.evaluate(route.conditions)) {
|
||||||
} else {
|
const next = this.sceneManager.getScene(route.targetScene)
|
||||||
|
if (next) {
|
||||||
|
this.goToScene(next)
|
||||||
|
} else {
|
||||||
|
this.endGame()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
this.endGame()
|
this.endGame()
|
||||||
|
} else {
|
||||||
|
const next = this.sceneManager.getScene(scene.nextScene)
|
||||||
|
if (next) {
|
||||||
|
this.goToScene(next)
|
||||||
|
} else {
|
||||||
|
this.endGame()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (scene.hotspots?.length) {
|
} else if (scene.hotspots?.length) {
|
||||||
return
|
return
|
||||||
@@ -357,17 +385,30 @@ export class Engine {
|
|||||||
const scene = this.sceneManager.getScene(chapter.startScene)
|
const scene = this.sceneManager.getScene(chapter.startScene)
|
||||||
if (!scene) return
|
if (!scene) return
|
||||||
|
|
||||||
const defaultVars = chapter.defaultVariables
|
this.initChapterState(chapter)
|
||||||
if (defaultVars) {
|
this.ended = false
|
||||||
this.stateManager.variables = { ...defaultVars }
|
this.isInitialScene = false
|
||||||
} else {
|
this.goToScene(scene)
|
||||||
this.stateManager.init(this.sceneManager.chapters.length > 0
|
}
|
||||||
? {} // from chapters, use the chapter's defaultVariables or empty
|
|
||||||
: {})
|
private initChapterState(chapter: { defaultVariables?: Record<string, number> }) {
|
||||||
|
if (chapter.defaultVariables) {
|
||||||
|
for (const [key, val] of Object.entries(chapter.defaultVariables)) {
|
||||||
|
this.stateManager.variables[key] = val
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.stateManager.flags = new Set()
|
this.stateManager.flags = new Set()
|
||||||
this.stateManager.history = []
|
this.stateManager.history = []
|
||||||
|
}
|
||||||
|
|
||||||
|
startAtScene(chapterId: string, sceneId: string) {
|
||||||
|
const chapter = this.sceneManager.getChapter(chapterId)
|
||||||
|
if (!chapter) return
|
||||||
|
|
||||||
|
const scene = this.sceneManager.getScene(sceneId)
|
||||||
|
if (!scene) return
|
||||||
|
|
||||||
|
this.initChapterState(chapter)
|
||||||
this.ended = false
|
this.ended = false
|
||||||
this.isInitialScene = false
|
this.isInitialScene = false
|
||||||
this.goToScene(scene)
|
this.goToScene(scene)
|
||||||
|
|||||||
@@ -50,16 +50,19 @@ export class SceneManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scene.nextScene && !targets.includes(scene.nextScene)) {
|
if (scene.nextScene) {
|
||||||
targets.push(scene.nextScene)
|
const nextIds = Array.isArray(scene.nextScene)
|
||||||
|
? scene.nextScene.map(r => r.targetScene)
|
||||||
|
: [scene.nextScene]
|
||||||
|
for (const id of nextIds) {
|
||||||
|
if (!targets.includes(id)) targets.push(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return targets
|
return targets
|
||||||
}
|
}
|
||||||
|
|
||||||
getCandidateUrls(scene: SceneNode, evaluateCondition: (conds?: Condition[]) => boolean): string[] {
|
getCandidateSceneIds(scene: SceneNode, evaluateCondition: (conds?: Condition[]) => boolean): string[] {
|
||||||
return this.getCandidateTargetIds(scene, evaluateCondition)
|
return this.getCandidateTargetIds(scene, evaluateCondition)
|
||||||
.map(id => this.scenes[id]?.videoUrl)
|
|
||||||
.filter((url): url is string => !!url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export class StateManager {
|
|||||||
variables: Record<string, number> = {}
|
variables: Record<string, number> = {}
|
||||||
flags: Set<string> = new Set()
|
flags: Set<string> = new Set()
|
||||||
history: ChoiceRecord[] = []
|
history: ChoiceRecord[] = []
|
||||||
onAfterApply: ((variables: Record<string, number>) => void) | null = null
|
onAfterApply: Set<((variables: Record<string, number>) => void)> = new Set()
|
||||||
|
|
||||||
init(initialVars: Record<string, number>) {
|
init(initialVars: Record<string, number>) {
|
||||||
this.variables = { ...initialVars }
|
this.variables = { ...initialVars }
|
||||||
@@ -73,7 +73,7 @@ export class StateManager {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.onAfterApply?.(this.variables)
|
this.onAfterApply.forEach((cb) => cb(this.variables))
|
||||||
}
|
}
|
||||||
|
|
||||||
recordChoice(choice: ChoiceRecord) {
|
recordChoice(choice: ChoiceRecord) {
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
type VideoEndCallback = () => void
|
type VideoEndCallback = () => void
|
||||||
type TimeUpdateCallback = (time: number) => void
|
type TimeUpdateCallback = (time: number) => void
|
||||||
|
|
||||||
|
export function getVideoMode(): 'auto' | 'local' | 'streaming' {
|
||||||
|
const params = typeof URLSearchParams !== 'undefined' ? new URLSearchParams(location.search) : { get: () => null }
|
||||||
|
const override = params.get('videoMode')
|
||||||
|
if (override === 'local') return 'local'
|
||||||
|
if (override === 'streaming') return 'streaming'
|
||||||
|
if (typeof window !== 'undefined' && (window as any).__ELECTRON__) return 'local'
|
||||||
|
return 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
export class VideoManager {
|
export class VideoManager {
|
||||||
private elA: HTMLVideoElement | null = null
|
private elA: HTMLVideoElement | null = null
|
||||||
private elB: HTMLVideoElement | null = null
|
private elB: HTMLVideoElement | null = null
|
||||||
@@ -12,6 +21,7 @@ export class VideoManager {
|
|||||||
private preloaded: Map<'A' | 'B', string> = new Map()
|
private preloaded: Map<'A' | 'B', string> = new Map()
|
||||||
private switching = false
|
private switching = false
|
||||||
private sceneVideo: HTMLVideoElement | null = null
|
private sceneVideo: HTMLVideoElement | null = null
|
||||||
|
streamingQuality = ''
|
||||||
|
|
||||||
private get active(): HTMLVideoElement {
|
private get active(): HTMLVideoElement {
|
||||||
return this.activeSlot === 'A' ? this.elA! : this.elB!
|
return this.activeSlot === 'A' ? this.elA! : this.elB!
|
||||||
@@ -169,6 +179,32 @@ export class VideoManager {
|
|||||||
if (this.elB) this.elB.muted = muted
|
if (this.elB) this.elB.muted = muted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveVideoUrl(scene: { videoUrl: string; streamingUrl?: Record<string, string> }, quality?: string): string {
|
||||||
|
const mode = getVideoMode()
|
||||||
|
if (mode === 'local') return scene.videoUrl
|
||||||
|
if (scene.streamingUrl) {
|
||||||
|
const key = quality || Object.keys(scene.streamingUrl)[0]
|
||||||
|
return scene.streamingUrl[key] || scene.videoUrl
|
||||||
|
}
|
||||||
|
return scene.videoUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
switchQuality(src: string, seekTime: number) {
|
||||||
|
const active = this.active
|
||||||
|
this.currentSrc = src
|
||||||
|
active.src = src
|
||||||
|
active.load()
|
||||||
|
this.preloaded.set(this.keyOf(active), src)
|
||||||
|
this.waitReady(active).then(() => {
|
||||||
|
active.currentTime = seekTime
|
||||||
|
active.play().catch(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private keyOf(el: HTMLVideoElement): 'A' | 'B' {
|
||||||
|
return el === this.elA ? 'A' : 'B'
|
||||||
|
}
|
||||||
|
|
||||||
onEnd(cb: VideoEndCallback) {
|
onEnd(cb: VideoEndCallback) {
|
||||||
this.onEndCallback = cb
|
this.onEndCallback = cb
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ export interface SceneNode {
|
|||||||
type?: 'video' | 'image'
|
type?: 'video' | 'image'
|
||||||
videoUrl: string
|
videoUrl: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
|
thumbnail?: string
|
||||||
|
contentSize?: { w: number; h: number }
|
||||||
subtitleUrl?: string
|
subtitleUrl?: string
|
||||||
subtitles?: Record<string, string>
|
subtitles?: Record<string, string>
|
||||||
choices?: Choice[]
|
choices?: Choice[]
|
||||||
hotspots?: Hotspot[]
|
hotspots?: Hotspot[]
|
||||||
qte?: QTEDefinition
|
qte?: QTEDefinition
|
||||||
nextScene?: string
|
nextScene?: string | Choice[]
|
||||||
onEnter?: Effect[]
|
onEnter?: Effect[]
|
||||||
loopStart?: number
|
loopStart?: number
|
||||||
loopEnd?: number
|
loopEnd?: number
|
||||||
@@ -19,12 +21,17 @@ export interface SceneNode {
|
|||||||
bgmDuckFade?: number
|
bgmDuckFade?: number
|
||||||
videoMuted?: boolean
|
videoMuted?: boolean
|
||||||
skippable?: boolean
|
skippable?: boolean
|
||||||
|
streamingUrl?: Record<string, string>
|
||||||
|
keyMoment?: boolean
|
||||||
|
battleHUD?: BattleHUDEntry[]
|
||||||
|
battleResult?: BattleResultDef
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Choice {
|
export interface Choice {
|
||||||
text: string
|
text: string
|
||||||
textKey?: string
|
textKey?: string
|
||||||
prompt?: string
|
prompt?: string
|
||||||
|
promptKey?: string
|
||||||
targetScene: string
|
targetScene: string
|
||||||
conditions?: Condition[]
|
conditions?: Condition[]
|
||||||
effects?: Effect[]
|
effects?: Effect[]
|
||||||
@@ -34,6 +41,7 @@ export interface Choice {
|
|||||||
export interface Hotspot {
|
export interface Hotspot {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
labelKey?: string
|
||||||
targetScene: string
|
targetScene: string
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
@@ -48,12 +56,12 @@ export interface Hotspot {
|
|||||||
|
|
||||||
export interface Condition {
|
export interface Condition {
|
||||||
variable: string
|
variable: string
|
||||||
op: '>' | '<' | '>=' | '<=' | '==' | '!=' | 'hasFlag'
|
op: '>' | '<' | '>=' | '<=' | '==' | '!=' | 'hasFlag' // @deprecated hasFlag will be removed
|
||||||
value: number | string | boolean
|
value: number | string | boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Effect {
|
export interface Effect {
|
||||||
type: 'set' | 'add' | 'toggleFlag' | 'triggerEvent'
|
type: 'set' | 'add' | 'toggleFlag' | 'triggerEvent' // @deprecated toggleFlag will be removed
|
||||||
target: string
|
target: string
|
||||||
value?: number | string | boolean
|
value?: number | string | boolean
|
||||||
}
|
}
|
||||||
@@ -61,6 +69,7 @@ export interface Effect {
|
|||||||
export interface QTEDefinition {
|
export interface QTEDefinition {
|
||||||
triggerTime: number
|
triggerTime: number
|
||||||
prompt: string
|
prompt: string
|
||||||
|
promptKey?: string
|
||||||
keys: string[]
|
keys: string[]
|
||||||
timeLimit: number
|
timeLimit: number
|
||||||
successScene: string
|
successScene: string
|
||||||
@@ -74,6 +83,7 @@ export interface QTEDefinition {
|
|||||||
export interface ChapterInfo {
|
export interface ChapterInfo {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
labelKey?: string
|
||||||
startScene: string
|
startScene: string
|
||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
defaultVariables?: Record<string, number>
|
defaultVariables?: Record<string, number>
|
||||||
@@ -82,7 +92,9 @@ export interface ChapterInfo {
|
|||||||
export interface AchievementDef {
|
export interface AchievementDef {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
titleKey?: string
|
||||||
description: string
|
description: string
|
||||||
|
descKey?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
condition: Condition
|
condition: Condition
|
||||||
@@ -91,18 +103,28 @@ export interface AchievementDef {
|
|||||||
export interface EndingDef {
|
export interface EndingDef {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
labelKey?: string
|
||||||
sceneId: string
|
sceneId: string
|
||||||
chapterId?: string
|
chapterId?: string
|
||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalesConfig {
|
||||||
|
path: string
|
||||||
|
languages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameData {
|
export interface GameData {
|
||||||
scenes: Record<string, SceneNode>
|
scenes: Record<string, SceneNode>
|
||||||
startScene: string
|
startScene: string
|
||||||
variables: Record<string, number>
|
variables: Record<string, number>
|
||||||
|
assetBase?: string
|
||||||
|
locales?: LocalesConfig
|
||||||
chapters?: ChapterInfo[]
|
chapters?: ChapterInfo[]
|
||||||
achievements?: AchievementDef[]
|
achievements?: AchievementDef[]
|
||||||
endings?: EndingDef[]
|
endings?: EndingDef[]
|
||||||
|
introVideo?: string
|
||||||
|
menuVideo?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChoiceRecord {
|
export interface ChoiceRecord {
|
||||||
@@ -135,3 +157,43 @@ export type EngineEvent =
|
|||||||
| 'hotspotUpdate'
|
| 'hotspotUpdate'
|
||||||
| 'chapterUnlock'
|
| 'chapterUnlock'
|
||||||
| 'achievementUnlock'
|
| 'achievementUnlock'
|
||||||
|
| 'battleResultRequest'
|
||||||
|
|
||||||
|
export interface PlayerTreeNode {
|
||||||
|
sceneId: string
|
||||||
|
label: string
|
||||||
|
visited: boolean
|
||||||
|
locked: boolean
|
||||||
|
lockHint?: string
|
||||||
|
children: PlayerTreeNode[]
|
||||||
|
isGateway?: boolean
|
||||||
|
gatewayChapterId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleHUDStat {
|
||||||
|
variable: string
|
||||||
|
label: string
|
||||||
|
labelKey?: string
|
||||||
|
max?: number
|
||||||
|
style?: 'bar' | 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleHUDEntry {
|
||||||
|
label: string
|
||||||
|
labelKey?: string
|
||||||
|
portrait?: string
|
||||||
|
stats: BattleHUDStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleResultStat {
|
||||||
|
label: string
|
||||||
|
labelKey?: string
|
||||||
|
variable: string
|
||||||
|
max?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleResultDef {
|
||||||
|
title: string
|
||||||
|
titleKey?: string
|
||||||
|
stats: BattleResultStat[]
|
||||||
|
}
|
||||||
|
|||||||
6
engine/utils.ts
Normal file
6
engine/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function resolveAsset(base: string, path: string): string {
|
||||||
|
if (!path || path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:')) return path
|
||||||
|
const b = base.endsWith('/') ? base.slice(0, -1) : base
|
||||||
|
const p = path.startsWith('/') ? path : '/' + path
|
||||||
|
return b + p
|
||||||
|
}
|
||||||
201
package-lock.json
generated
201
package-lock.json
generated
@@ -19,6 +19,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
|
"opencode-ai": "^1.17.6",
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0",
|
||||||
"vue-tsc": "^2.1.0"
|
"vue-tsc": "^2.1.0"
|
||||||
@@ -1015,6 +1017,16 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adm-zip": {
|
||||||
|
"version": "0.5.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
||||||
|
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/alien-signals": {
|
"node_modules/alien-signals": {
|
||||||
"version": "1.0.13",
|
"version": "1.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
|
||||||
@@ -1293,6 +1305,195 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opencode-ai": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-ai/-/opencode-ai-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-5oMjuqlVL78JhvXshwp2NCXCI+CHr24wWi7/5aI0CZoHnI44qTqssWOBUW59dPTpRGSOQmXTDTOOsPVYW38JPg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64",
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"os": [
|
||||||
|
"darwin",
|
||||||
|
"linux",
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"opencode": "bin/opencode.exe"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"opencode-darwin-arm64": "1.17.7",
|
||||||
|
"opencode-darwin-x64": "1.17.7",
|
||||||
|
"opencode-darwin-x64-baseline": "1.17.7",
|
||||||
|
"opencode-linux-arm64": "1.17.7",
|
||||||
|
"opencode-linux-arm64-musl": "1.17.7",
|
||||||
|
"opencode-linux-x64": "1.17.7",
|
||||||
|
"opencode-linux-x64-baseline": "1.17.7",
|
||||||
|
"opencode-linux-x64-baseline-musl": "1.17.7",
|
||||||
|
"opencode-linux-x64-musl": "1.17.7",
|
||||||
|
"opencode-windows-arm64": "1.17.7",
|
||||||
|
"opencode-windows-x64": "1.17.7",
|
||||||
|
"opencode-windows-x64-baseline": "1.17.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/opencode-darwin-arm64": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-darwin-arm64/-/opencode-darwin-arm64-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-/uoZpJvnxY1jtRXAASQTIn0goya61M1RJhX0Zx2RwO+sdnrfvYYX6p7iL82Rl+Sp+TAS8y2NBvN+p/OLAnxsqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-darwin-x64": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-darwin-x64/-/opencode-darwin-x64-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-v60XhJae1eKn/Kjhy2PLOY+ss7peSox8ILZFz7fwBzRgz4q61gIo1vM9WzXQ6Vt+5Oj8etYbPl3xmt1XDLZtEA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-darwin-x64-baseline": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-nvaY4qQgS9ZSkCvw9+DOrQQeycbW8/AEcD/Q0suleMuUgFIqfrsVa4cdsmYrUh3BH+y2NRaVgMSIfU9gPqXAKA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-linux-arm64": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-linux-arm64/-/opencode-linux-arm64-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-HTH5Z5V7xiAD+/nYn9wQYwM/LDokBZu8Ig+npwJrhGWzZGM+lChbv2+griYyRoaNEZ+zRsCvwyv96TTfb6nWDQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-linux-arm64-musl": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-bIySXi+XNLHL5m8lS1ljL5XZzQ0iMdf/X6KKaqHbDZQ3E0Qu7ERIHZjohx8S7htvCdPztuzeSr2udS0emumf6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-linux-x64": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-linux-x64/-/opencode-linux-x64-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-UIxkdA/8281EHbHYVr5PSD+eVoMdlyfkmXiZp3u9duttsMHdf1F6lw0XjYmDRBCPp8zQM1D1RLCABuRA/kUX+Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-linux-x64-baseline": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-fj62eWDQSygxS3Q5S3Gm4VYOsHrlTnje46bXoWc9IXfFNwFHAyL+izKzpU1SePCpCCEdsjy//nCKOnqN4PPK5g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-linux-x64-baseline-musl": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-kTuZpRxMOzKt+ztp6yb+cSN8L69UYWN1x7Na4egX2d26IU0xK+RlXE9HjHzF/EjsmSBMc3DR8MvKUVldQ2XdbA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-linux-x64-musl": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-iKUBKzVD1ybMmAy3KW6cjfst18+glp3Fgtd7POGEAaQO7cWzwSmOnFHN3uCAi/wYrfrvYd4zXxHsFP0yMJRUmA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-windows-arm64": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-windows-arm64/-/opencode-windows-arm64-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-BVlfloqHrjPhpDvbm3u1vQuEn063lbT3lcT7HBLmHpvwJd6FJutjPpm5/3xYxSusmXRGL7bmMZ3v1KPOeY0tBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-windows-x64": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-windows-x64/-/opencode-windows-x64-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-MAykQj6ouoZ2rMt+q8ujBTnc4sD86WfLnPom28CTD+KgmI1s2D2qka7J6DjpK6A3j8PpkC0bEBIqVBciVgY6EA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/opencode-windows-x64-baseline": {
|
||||||
|
"version": "1.17.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.17.7.tgz",
|
||||||
|
"integrity": "sha512-Gui/cezrLsLEZb1rUwNoKGXIiuZA0FsaGN3L/qR3/Qpce3e+hhqfqLHQXqlh0PFU1dSRKVRF8ukwWVx+2G5CPQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
|
"opencode-ai": "^1.17.6",
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0",
|
||||||
"vue-tsc": "^2.1.0"
|
"vue-tsc": "^2.1.0"
|
||||||
|
|||||||
18
public/CLAUDE.md
Normal file
18
public/CLAUDE.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ⚠️ 游戏制作者边界
|
||||||
|
|
||||||
|
你可以修改的目录:
|
||||||
|
|
||||||
|
✅ public/ — 游戏内容
|
||||||
|
- scenes/*.json — 剧情 JSON 定义
|
||||||
|
- scenes/config.json — 配置文件(指定加载哪个场景文件)
|
||||||
|
- <素材目录>/ — 视频、音频、图片等素材
|
||||||
|
- <素材目录>/locales/ — 多语言故事翻译(制作者维护)
|
||||||
|
|
||||||
|
✅ electron/ — 打包配置(可调整自己的打包内容和参数)
|
||||||
|
|
||||||
|
❌ 绝对不能修改:
|
||||||
|
- engine/ src/ editor/ — 引擎和 UI 层源代码
|
||||||
|
- package.json tsconfig.json vite.config.ts — 构建配置
|
||||||
|
- docs/ — 项目文档
|
||||||
|
|
||||||
|
如有引擎功能需求,请联系引擎开发者。
|
||||||
18
public/CODEX.md
Normal file
18
public/CODEX.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ⚠️ 游戏制作者边界
|
||||||
|
|
||||||
|
你可以修改的目录:
|
||||||
|
|
||||||
|
✅ public/ — 游戏内容
|
||||||
|
- scenes/*.json — 剧情 JSON 定义
|
||||||
|
- scenes/config.json — 配置文件(指定加载哪个场景文件)
|
||||||
|
- <素材目录>/ — 视频、音频、图片等素材
|
||||||
|
- <素材目录>/locales/ — 多语言故事翻译(制作者维护)
|
||||||
|
|
||||||
|
✅ electron/ — 打包配置(可调整自己的打包内容和参数)
|
||||||
|
|
||||||
|
❌ 绝对不能修改:
|
||||||
|
- engine/ src/ editor/ — 引擎和 UI 层源代码
|
||||||
|
- package.json tsconfig.json vite.config.ts — 构建配置
|
||||||
|
- docs/ — 项目文档
|
||||||
|
|
||||||
|
如有引擎功能需求,请联系引擎开发者。
|
||||||
22
public/README_CREATOR.md
Normal file
22
public/README_CREATOR.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 游戏制作者工作区
|
||||||
|
|
||||||
|
`public/` 是你的主工作区,`electron/` 可调整打包配置。
|
||||||
|
|
||||||
|
## 快速导航
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 新手入门 | [docs/guide/QUICK_START.md](../docs/guide/QUICK_START.md) |
|
||||||
|
| JSON 字段参考 | [docs/guide/SCENE_JSON_SPEC.md](../docs/guide/SCENE_JSON_SPEC.md) |
|
||||||
|
| 分支叙事 | [docs/guide/BRANCHING.md](../docs/guide/BRANCHING.md) |
|
||||||
|
| 交互指南 | [docs/guide/INTERACTIONS.md](../docs/guide/INTERACTIONS.md) |
|
||||||
|
| 国际化 | [docs/guide/I18N.md](../docs/guide/I18N.md) |
|
||||||
|
| 打包发布 | [docs/guide/PUBLISHING.md](../docs/guide/PUBLISHING.md) |
|
||||||
|
|
||||||
|
## 你需要编辑的文件
|
||||||
|
|
||||||
|
- `scenes/your_story.json` — 你的剧情 JSON
|
||||||
|
- `scenes/config.json` — 配置文件(设置默认加载的场景文件)
|
||||||
|
- `<素材目录>/scene_name/video.mp4` — 场景视频
|
||||||
|
- `<素材目录>/locales/zh.json` — 多语言故事翻译
|
||||||
|
- `electron/` — 打包配置(可选)
|
||||||
BIN
public/demo/__intro__/intro_logo.mp4
Normal file
BIN
public/demo/__intro__/intro_logo.mp4
Normal file
Binary file not shown.
BIN
public/demo/__intro__/menu_bg.mp4
Normal file
BIN
public/demo/__intro__/menu_bg.mp4
Normal file
Binary file not shown.
7
public/demo/alone_ending/1080p/index.m3u8
Normal file
7
public/demo/alone_ending/1080p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:3
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:3.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/alone_ending/1080p/seg_000.ts
Normal file
BIN
public/demo/alone_ending/1080p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/alone_ending/480p/index.m3u8
Normal file
7
public/demo/alone_ending/480p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:3
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:3.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/alone_ending/480p/seg_000.ts
Normal file
BIN
public/demo/alone_ending/480p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/alone_ending/720p/index.m3u8
Normal file
7
public/demo/alone_ending/720p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:3
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:3.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/alone_ending/720p/seg_000.ts
Normal file
BIN
public/demo/alone_ending/720p/seg_000.ts
Normal file
Binary file not shown.
BIN
public/demo/alone_ending/thumb.jpg
Normal file
BIN
public/demo/alone_ending/thumb.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
7
public/demo/corridor/1080p/index.m3u8
Normal file
7
public/demo/corridor/1080p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:8
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:8.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/corridor/1080p/seg_000.ts
Normal file
BIN
public/demo/corridor/1080p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/corridor/480p/index.m3u8
Normal file
7
public/demo/corridor/480p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:8
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:8.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/corridor/480p/seg_000.ts
Normal file
BIN
public/demo/corridor/480p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/corridor/720p/index.m3u8
Normal file
7
public/demo/corridor/720p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:8
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:8.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/corridor/720p/seg_000.ts
Normal file
BIN
public/demo/corridor/720p/seg_000.ts
Normal file
Binary file not shown.
BIN
public/demo/corridor/thumb.jpg
Normal file
BIN
public/demo/corridor/thumb.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
7
public/demo/intro/1080p/index.m3u8
Normal file
7
public/demo/intro/1080p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:6
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:6.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/intro/1080p/seg_000.ts
Normal file
BIN
public/demo/intro/1080p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/intro/480p/index.m3u8
Normal file
7
public/demo/intro/480p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:6
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:6.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/intro/480p/seg_000.ts
Normal file
BIN
public/demo/intro/480p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/intro/720p/index.m3u8
Normal file
7
public/demo/intro/720p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:6
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:6.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/intro/720p/seg_000.ts
Normal file
BIN
public/demo/intro/720p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/intro/intro_ja.vtt
Normal file
7
public/demo/intro/intro_ja.vtt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00.000 --> 00:02.000
|
||||||
|
見知らぬ部屋で目覚めた
|
||||||
|
|
||||||
|
00:02.500 --> 00:03.000
|
||||||
|
目の前に二つのドアがある。選ばなければならない。
|
||||||
BIN
public/demo/intro/thumb.jpg
Normal file
BIN
public/demo/intro/thumb.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
4
public/demo/investigation_site/investigation_en.vtt
Normal file
4
public/demo/investigation_site/investigation_en.vtt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00.000 --> 00:05.000
|
||||||
|
You step into a messy room, carefully looking around...
|
||||||
4
public/demo/investigation_site/investigation_ja.vtt
Normal file
4
public/demo/investigation_site/investigation_ja.vtt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00.000 --> 00:05.000
|
||||||
|
散らかった部屋に入り、周りを注意深く観察する…
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
7
public/demo/left_door/1080p/index.m3u8
Normal file
7
public/demo/left_door/1080p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:3
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:3.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/left_door/1080p/seg_000.ts
Normal file
BIN
public/demo/left_door/1080p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/left_door/480p/index.m3u8
Normal file
7
public/demo/left_door/480p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:3
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:3.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/left_door/480p/seg_000.ts
Normal file
BIN
public/demo/left_door/480p/seg_000.ts
Normal file
Binary file not shown.
7
public/demo/left_door/720p/index.m3u8
Normal file
7
public/demo/left_door/720p/index.m3u8
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-TARGETDURATION:3
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXTINF:3.000000,
|
||||||
|
seg_000.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
BIN
public/demo/left_door/720p/seg_000.ts
Normal file
BIN
public/demo/left_door/720p/seg_000.ts
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user