refactor: unify video mode detection into getVideoMode()

This commit is contained in:
2026-06-12 19:10:51 +08:00
parent 32f7e34130
commit 8655e01c23
3 changed files with 133 additions and 3 deletions

119
AGENTS.md Normal file
View 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()` — 返回候选场景 IDEngine 用 `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

View File

@@ -1,6 +1,15 @@
type VideoEndCallback = () => 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 {
private elA: HTMLVideoElement | null = null
private elB: HTMLVideoElement | null = null
@@ -171,8 +180,9 @@ export class VideoManager {
}
resolveVideoUrl(scene: { videoUrl: string; streamingUrl?: Record<string, string> }, quality?: string): string {
const isElectron = typeof window !== 'undefined' && !!(window as any).__ELECTRON__
if (!isElectron && scene.streamingUrl) {
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
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useGameStore } from '@/stores/gameStore'
import { useI18n } from '@/composables/useI18n'
import { getVideoMode } from '@engine/core/VideoManager'
const store = useGameStore()
const { t, currentLang, setLang } = useI18n()
@@ -19,7 +20,7 @@ const qualityOptions = [
{ key: '标清 (480P)', label: '标清 (480P)', speed: '需要 0.8 Mbps' },
]
const isWeb = typeof window !== 'undefined' && !(window as any).__ELECTRON__
const isWeb = getVideoMode() !== 'local'
const langLabels: Record<string, string> = {
zh: '中文',