diff --git a/ROADMAP.md b/ROADMAP.md index 0b55ac1..3ed9d9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -568,11 +568,63 @@ GainNode 的 ramp 目标值 = `Math.min(bgmVolume, bgmDuckLevel × bgmVolume)` ### P10b 手柄导航(远期 P10b)— 见 FUTURE.md -### P11 多语言字幕(待实现) +### P11 完整 i18n — 字幕 + UI 国际化,自制 useI18n ✅ 已完成 2026-06-09 -目标:支持多语言字幕切换,UI 文本国际化,同一个场景可有中/英/日等多个字幕文件。 +目标:字幕和完整 UI 文本(选项、按钮、标签)支持多语言切换。使用自制 `useI18n()` 组合式函数, +零依赖,通过静态 import JSON 翻译文件实现。语言切换入口在主菜单和游戏内顶部栏两处。 -**数据结构设计:** +**技术选型:自制 useI18n(~25 行 TS),不用 vue-i18n。** + +无需 npm 包,`t(key)` 从静态 import 的 JSON 中按路径查找翻译文本, +`currentLang` 持久化到 localStorage,跨会话保持。 + +**架构分层:** + +``` +UI 层 (Vue) + ├── useI18n.ts t(key), currentLang, setLang(lang) + ├── LangSwitch.vue "中文 / English" 按钮组 + ├── locales/zh.json 中文翻译(UI + scene 文本 ~50行) + ├── locales/en.json 英文翻译(UI + scene 文本 ~50行) + ├── 各组件 t('key') 按钮/提示/标签翻译 + └── Subtitles.vue 按 currentLang 加载 subtitles[lang] + +数据层 (Engine / Scene JSON) + ├── Choice.textKey 可选 i18n key,缺省 fallback 到 text + ├── SceneNode.subtitles Record 字幕多语言 map + └── 引擎不感知 i18n 纯数据传递,翻译在 composable 层完成 +``` + +**核心 composable 设计:** + +```typescript +// src/composables/useI18n.ts +const messages = { zh, en } +const currentLang = ref(localStorage.getItem('lang') || 'zh') + +export function useI18n() { + function t(key: string): string { /* key = "ui.start" → messages[lang].ui.start */ } + function setLang(lang: string) { /* localStorage + currentLang */ } + return { t, currentLang, setLang } +} +``` + +**Choice 翻译策略:** + +composable 在 `choiceRequest` 事件中调用 `t(textKey)` 翻译选项文字后存入 store。 +`textKey` 未设置时 fallback 到 `text`(向后兼容,不要求每个 Choice 都加 key)。 + +```typescript +engine.on('choiceRequest', (choiceList) => { + const translated = choiceList.map(c => ({ + ...c, + text: c.textKey ? i18n.t(c.textKey) : c.text, + })) + store.setChoices(translated) +}) +``` + +**场景数据变更:** ```json { @@ -580,22 +632,71 @@ GainNode 的 ramp 目标值 = `Math.min(bgmVolume, bgmDuckLevel × bgmVolume)` "videoUrl": "/videos/intro.mp4", "subtitles": { "zh": "/subtitles/intro_zh.vtt", - "en": "/subtitles/intro_en.vtt", - "ja": "/subtitles/intro_ja.vtt" + "en": "/subtitles/intro_en.vtt" }, - "choices": [...] + "choices": [ + { + "text": "走向左边那扇发光的门", + "textKey": "scene.intro.choice.left_door", + "targetScene": "left_door" + } + ] +} +``` + +**翻译文件结构:** + +```json +// src/locales/zh.json +{ + "ui": { + "start": "开始", + "resume": "继续上次进度", + "chapters": "章节选择", + "menu": "菜单", + "save": "保存", + "load": "读取", + "close": "关闭", + "skip": "跳过", + "fullscreen": "全屏", + "exitFullscreen": "退出全屏", + "gameEnd": "结束", + "choose": "做出你的选择", + "back": "返回", + "autoSave": "自动存档", + "empty": "空", + "loading": "加载中..." + }, + "scene": { + "intro": { + "choice": { + "left_door": "走向左边那扇发光的门", + "right_door": "走向右边那扇普通的门", + "search": "搜索房间", + "stay": "留在原地,什么也不做" + } + } + } } ``` **实现清单:** -- [ ] `engine/types.ts` — `SceneNode.subtitles` 改为 `Record`,`Choice.text` 支持多语言 key -- [ ] `engine/systems/I18nSystem.ts` — 语言切换、当前语言持久化到 localStorage -- [ ] `src/components/Subtitles.vue` — 监听语言切换,动态加载对应 VTT -- [ ] `src/components/ChoicePanel.vue` — 选项文字支持 i18n 映射 -- [ ] `src/components/LanguageSwitch.vue` — 语言选择下拉菜单(顶部或菜单中) -- [ ] `public/scenes/demo.json` — 中英双语字幕示例 -- [ ] 验证:语言切换后字幕/UI 即时更新、刷新保持语言偏好 +- [x] `src/composables/useI18n.ts` — `t(key)`, `currentLang`, `setLang(lang)`, localStorage 持久化 +- [x] `src/locales/zh.json` — 中文翻译(UI ~20 项 + scene 选项文字 ~30 项) +- [x] `src/locales/en.json` — 英文翻译(同结构) +- [x] `engine/types.ts` — `Choice.textKey?: string`;`SceneNode.subtitles?: Record` +- [x] `src/composables/useGameEngine.ts` — `choiceRequest` 中 `t(textKey)` 翻译后存入 store +- [x] `src/components/LangSwitch.vue` — "中文 / English" 切换按钮组,调用 `setLang` +- [x] `src/components/Subtitles.vue` — `effectiveUrl` computed 优先 `subtitles[lang]`,fallback `subtitleUrl` +- [x] `src/App.vue` — 主菜单 LangSwitch + 顶部栏按钮 `t()` 翻译 +- [x] `src/components/ChoicePanel.vue` — `t('ui.choose')` 替代硬编码提示文字 +- [x] `src/components/SaveLoadMenu.vue` — 8 处文本用 `t()` 翻译 +- [x] `src/components/ChapterSelect.vue` — 标题 + 返回按钮用 `t()` 翻译 +- [x] `src/components/PlaybackBar.vue` — 跳过按钮用 `t('ui.skip')` +- [x] `public/subtitles/*_en.vtt` — 3 个英文版字幕文件(intro/left_door/stay) +- [x] `public/scenes/demo.json` — intro 场景配置 `subtitles` map + 4 个 choice 添加 `textKey` +- [x] 验证:TypeScript + Vite build 通过 ### P12 场景过渡特效(待实现) diff --git a/engine/types.ts b/engine/types.ts index 7fd7c71..3634474 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -4,6 +4,7 @@ export interface SceneNode { videoUrl: string imageUrl?: string subtitleUrl?: string + subtitles?: Record choices?: Choice[] hotspots?: Hotspot[] qte?: QTEDefinition @@ -22,6 +23,7 @@ export interface SceneNode { export interface Choice { text: string + textKey?: string targetScene: string conditions?: Condition[] effects?: Effect[] diff --git a/public/scenes/demo.json b/public/scenes/demo.json index 90718b9..15c7700 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -33,6 +33,10 @@ "id": "intro", "videoUrl": "/videos/intro.mp4", "subtitleUrl": "/subtitles/intro.vtt", + "subtitles": { + "zh": "/subtitles/intro.vtt", + "en": "/subtitles/intro_en.vtt" + }, "bgmUrl": "/audio/calm_bgm.mp3", "bgmVolume": 0.6, "bgmCrossFade": 1.5, @@ -40,6 +44,7 @@ "choices": [ { "text": "走向左边那扇发光的门", + "textKey": "scene.intro.choice.left_door", "targetScene": "left_door", "effects": [ { "type": "add", "target": "courage", "value": 10 } @@ -47,6 +52,7 @@ }, { "text": "走向右边那扇普通的门", + "textKey": "scene.intro.choice.right_door", "targetScene": "right_door", "effects": [ { "type": "add", "target": "courage", "value": -5 } @@ -54,10 +60,12 @@ }, { "text": "仔细搜索房间", + "textKey": "scene.intro.choice.search", "targetScene": "investigation_site" }, { "text": "留在原地,什么也不做", + "textKey": "scene.intro.choice.stay", "targetScene": "stay" } ] diff --git a/public/subtitles/intro_en.vtt b/public/subtitles/intro_en.vtt new file mode 100644 index 0000000..fceb329 --- /dev/null +++ b/public/subtitles/intro_en.vtt @@ -0,0 +1,7 @@ +WEBVTT + +00:00.000 --> 00:02.000 +You wake up in a strange room + +00:02.500 --> 00:03.000 +Two doors stand before you. You must choose. diff --git a/public/subtitles/left_door_en.vtt b/public/subtitles/left_door_en.vtt new file mode 100644 index 0000000..01513d7 --- /dev/null +++ b/public/subtitles/left_door_en.vtt @@ -0,0 +1,7 @@ +WEBVTT + +00:00.000 --> 00:02.500 +You walk through the glowing door into a bright hall + +00:02.500 --> 00:03.000 +A stranger extends their hand toward you diff --git a/public/subtitles/stay_en.vtt b/public/subtitles/stay_en.vtt new file mode 100644 index 0000000..c44fb77 --- /dev/null +++ b/public/subtitles/stay_en.vtt @@ -0,0 +1,4 @@ +WEBVTT + +00:00.000 --> 00:03.000 +You choose to stay where you are. Time passes slowly... diff --git a/src/components/ChapterSelect.vue b/src/components/ChapterSelect.vue index 7541d91..5156af4 100644 --- a/src/components/ChapterSelect.vue +++ b/src/components/ChapterSelect.vue @@ -1,6 +1,7 @@ + + + + diff --git a/src/components/PlaybackBar.vue b/src/components/PlaybackBar.vue index 1cd07b0..ff6215c 100644 --- a/src/components/PlaybackBar.vue +++ b/src/components/PlaybackBar.vue @@ -1,5 +1,8 @@