feat: i18n system, lang switch component, english subtitles, UI improvements, roadmap update
This commit is contained in:
127
ROADMAP.md
127
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<lang, url> 字幕多语言 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<lang, url>`,`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<string, string>`
|
||||
- [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 场景过渡特效(待实现)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface SceneNode {
|
||||
videoUrl: string
|
||||
imageUrl?: string
|
||||
subtitleUrl?: string
|
||||
subtitles?: Record<string, string>
|
||||
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[]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
7
public/subtitles/intro_en.vtt
Normal file
7
public/subtitles/intro_en.vtt
Normal file
@@ -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.
|
||||
7
public/subtitles/left_door_en.vtt
Normal file
7
public/subtitles/left_door_en.vtt
Normal file
@@ -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
|
||||
4
public/subtitles/stay_en.vtt
Normal file
4
public/subtitles/stay_en.vtt
Normal file
@@ -0,0 +1,4 @@
|
||||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:03.000
|
||||
You choose to stay where you are. Time passes slowly...
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import type { ChapterInfo } from '@engine/types'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const props = defineProps<{
|
||||
chapters: ChapterInfo[]
|
||||
@@ -12,6 +13,7 @@ const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const focusIdx = ref(0)
|
||||
const cardRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
|
||||
@@ -60,7 +62,7 @@ function onKeydown(e: KeyboardEvent, index: number) {
|
||||
<template>
|
||||
<div class="chapter-overlay" @keydown="(e: KeyboardEvent) => { if (e.key === 'Escape' || e.key === 'Backspace') { e.preventDefault(); emit('back'); } }">
|
||||
<div class="chapter-panel">
|
||||
<h2 class="chapter-title">章节选择</h2>
|
||||
<h2 class="chapter-title">{{ t('ui.chapters') }}</h2>
|
||||
|
||||
<div class="chapter-grid">
|
||||
<div
|
||||
@@ -82,7 +84,7 @@ function onKeydown(e: KeyboardEvent, index: number) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="back-btn" @click="emit('back')">返回 (Esc)</button>
|
||||
<button class="back-btn" @click="emit('back')">{{ t('ui.back') }} (Esc)</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import type { Choice } from '@engine/types'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const props = defineProps<{
|
||||
choices: Choice[]
|
||||
@@ -12,6 +13,7 @@ const emit = defineEmits<{
|
||||
choose: [index: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const focusIndex = ref(0)
|
||||
const btnRefs = ref<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
@@ -62,7 +64,7 @@ function onKeydown(e: KeyboardEvent, index: number) {
|
||||
></div>
|
||||
<span class="timer-text">{{ timerRemaining.toFixed(1) }}s</span>
|
||||
</div>
|
||||
<div class="choice-prompt">做出你的选择</div>
|
||||
<div class="choice-prompt">{{ t('ui.choose') }}</div>
|
||||
<div class="choice-list">
|
||||
<button
|
||||
v-for="(choice, index) in choices"
|
||||
|
||||
41
src/components/LangSwitch.vue
Normal file
41
src/components/LangSwitch.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const { currentLang, setLang, t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lang-switch">
|
||||
<button
|
||||
:class="['lang-btn', { active: currentLang === 'zh' }]"
|
||||
@click="setLang('zh')"
|
||||
>中文</button>
|
||||
<button
|
||||
:class="['lang-btn', { active: currentLang === 'en' }]"
|
||||
@click="setLang('en')"
|
||||
>English</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lang-switch {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.lang-btn:first-child { border-radius: 3px 0 0 3px; }
|
||||
.lang-btn:last-child { border-radius: 0 3px 3px 0; }
|
||||
|
||||
.lang-btn:hover { color: #ccc; background: rgba(255, 255, 255, 0.1); }
|
||||
.lang-btn.active { color: #fff; background: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.25); }
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
canSkip: boolean
|
||||
@@ -29,7 +32,7 @@ onMounted(() => updateLabel(props.currentSpeed))
|
||||
|
||||
<template>
|
||||
<div class="playback-bar">
|
||||
<button v-if="canSkip" class="pb-btn skip-btn" @click="emit('skip')">跳过</button>
|
||||
<button v-if="canSkip" class="pb-btn skip-btn" @click="emit('skip')">{{ t('ui.skip') }}</button>
|
||||
<button class="pb-btn speed-btn" @click="toggleSpeed">{{ speedLabel }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { SlotInfo } from '@/stores/gameStore'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
saves: SlotInfo[]
|
||||
@@ -17,7 +20,7 @@ const maxSlots = 5
|
||||
<template>
|
||||
<div class="save-overlay" @click.self="emit('close')" @keydown.escape="emit('close')">
|
||||
<div class="save-panel">
|
||||
<h2 class="save-title">存档 / 读档</h2>
|
||||
<h2 class="save-title">{{ t('ui.save') }} / {{ t('ui.load') }}</h2>
|
||||
|
||||
<div class="slot-list">
|
||||
<div class="save-slot auto-save-slot">
|
||||
@@ -30,11 +33,11 @@ const maxSlots = 5
|
||||
<span v-else class="thumb-empty">自动</span>
|
||||
</div>
|
||||
<div class="slot-meta">
|
||||
<div class="slot-label auto-save-label">自动存档</div>
|
||||
<div class="slot-label auto-save-label">{{ t('ui.autoSave') }}</div>
|
||||
<div class="slot-info" v-if="saves.find(s => s.slot === 0)">
|
||||
{{ saves.find(s => s.slot === 0)!.sceneLabel }}
|
||||
</div>
|
||||
<div class="slot-info empty" v-else>暂无自动存档</div>
|
||||
<div class="slot-info empty" v-else>{{ t('ui.noAutoSave') }}</div>
|
||||
</div>
|
||||
<div class="slot-actions">
|
||||
<button
|
||||
@@ -42,7 +45,7 @@ const maxSlots = 5
|
||||
:disabled="!saves.find(s => s.slot === 0)"
|
||||
@click="emit('load', 0)"
|
||||
>
|
||||
读取
|
||||
{{ t('ui.load') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,30 +61,30 @@ const maxSlots = 5
|
||||
:src="saves.find(s => s.slot === slot)!.thumbnail"
|
||||
class="thumb-img"
|
||||
/>
|
||||
<span v-else class="thumb-empty">空</span>
|
||||
<span v-else class="thumb-empty">{{ t('ui.empty') }}</span>
|
||||
</div>
|
||||
<div class="slot-meta">
|
||||
<div class="slot-label">存档 {{ slot }}</div>
|
||||
<div class="slot-label">{{ t('ui.save') }} {{ slot }}</div>
|
||||
<div class="slot-info" v-if="saves.find(s => s.slot === slot)">
|
||||
{{ saves.find(s => s.slot === slot)!.sceneLabel }}
|
||||
</div>
|
||||
<div class="slot-info empty" v-else>空</div>
|
||||
<div class="slot-info empty" v-else>{{ t('ui.empty') }}</div>
|
||||
</div>
|
||||
<div class="slot-actions">
|
||||
<button class="slot-btn save-btn" @click="emit('save', slot)">保存</button>
|
||||
<button class="slot-btn save-btn" @click="emit('save', slot)">{{ t('ui.save') }}</button>
|
||||
<button
|
||||
class="slot-btn load-btn"
|
||||
:disabled="!saves.find(s => s.slot === slot)"
|
||||
@click="emit('load', slot)"
|
||||
>
|
||||
读取
|
||||
{{ t('ui.load') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-hint">游戏会在每次场景切换时自动保存到槽位 0</div>
|
||||
<button class="close-btn" @click="emit('close')">关闭</button>
|
||||
<div class="save-hint">{{ t('ui.autoSaveHint') }}</div>
|
||||
<button class="close-btn" @click="emit('close')">{{ t('ui.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
|
||||
interface SubCue {
|
||||
start: number
|
||||
@@ -10,20 +10,25 @@ interface SubCue {
|
||||
const props = defineProps<{
|
||||
currentTime: number
|
||||
subtitleUrl: string | null
|
||||
subtitles?: Record<string, string> | null
|
||||
lang?: string
|
||||
}>()
|
||||
|
||||
const cues = ref<SubCue[]>([])
|
||||
const currentText = ref('')
|
||||
const loadedUrl = ref('')
|
||||
|
||||
watch(() => props.subtitleUrl, async (url) => {
|
||||
const effectiveUrl = computed(() => {
|
||||
if (props.lang && props.subtitles?.[props.lang]) return props.subtitles[props.lang]
|
||||
return props.subtitleUrl
|
||||
})
|
||||
|
||||
watch(effectiveUrl, async (url) => {
|
||||
if (!url) {
|
||||
cues.value = []
|
||||
currentText.value = ''
|
||||
loadedUrl.value = ''
|
||||
return
|
||||
}
|
||||
if (url === loadedUrl.value) return
|
||||
loadedUrl.value = url
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Engine } from '@engine/core/Engine'
|
||||
import { SaveSystem } from '@engine/systems/SaveSystem'
|
||||
import type { GameData } from '@engine/types'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVideoElement | null]) {
|
||||
const engine = new Engine()
|
||||
const saveSystem = new SaveSystem()
|
||||
const store = useGameStore()
|
||||
const { t } = useI18n()
|
||||
let lastThumbnail: string | undefined
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -34,7 +36,11 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
||||
})
|
||||
|
||||
engine.on('choiceRequest', (choiceList) => {
|
||||
store.setChoices(choiceList)
|
||||
const translated = choiceList.map((c: any) => ({
|
||||
...c,
|
||||
text: c.textKey ? t(c.textKey) : c.text,
|
||||
}))
|
||||
store.setChoices(translated)
|
||||
})
|
||||
|
||||
engine.on('choiceTimer', (timerState) => {
|
||||
|
||||
28
src/composables/useI18n.ts
Normal file
28
src/composables/useI18n.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ref } from 'vue'
|
||||
import zh from '@/locales/zh.json'
|
||||
import en from '@/locales/en.json'
|
||||
|
||||
const messages = { zh, en } as const
|
||||
type Lang = 'zh' | 'en'
|
||||
|
||||
const currentLang = ref<Lang>(
|
||||
(localStorage.getItem('lang') as Lang) || 'zh'
|
||||
)
|
||||
|
||||
export function useI18n() {
|
||||
function t(key: string): string {
|
||||
const parts = key.split('.')
|
||||
let result: any = messages[currentLang.value]
|
||||
for (const p of parts) {
|
||||
result = result?.[p]
|
||||
}
|
||||
return typeof result === 'string' ? result : key
|
||||
}
|
||||
|
||||
function setLang(lang: Lang) {
|
||||
currentLang.value = lang
|
||||
localStorage.setItem('lang', lang)
|
||||
}
|
||||
|
||||
return { t, currentLang, setLang }
|
||||
}
|
||||
79
src/locales/en.json
Normal file
79
src/locales/en.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"ui": {
|
||||
"start": "New Game",
|
||||
"resume": "Continue",
|
||||
"chapters": "Chapters",
|
||||
"menu": "Menu",
|
||||
"save": "Save",
|
||||
"load": "Load",
|
||||
"close": "Close",
|
||||
"skip": "Skip",
|
||||
"fullscreen": "Fullscreen",
|
||||
"exitFullscreen": "Exit Fullscreen",
|
||||
"gameEnd": "The End",
|
||||
"choose": "Make your choice",
|
||||
"back": "Back",
|
||||
"autoSave": "Auto Save",
|
||||
"empty": "Empty",
|
||||
"loading": "Loading...",
|
||||
"speed": "Speed",
|
||||
"noAutoSave": "No auto save yet",
|
||||
"autoSaveHint": "Game auto-saves to slot 0 at each scene change"
|
||||
},
|
||||
"scene": {
|
||||
"intro": {
|
||||
"choice": {
|
||||
"left_door": "Walk toward the glowing door on the left",
|
||||
"right_door": "Walk toward the ordinary door on the right",
|
||||
"search": "Search the room carefully",
|
||||
"stay": "Stay where you are, do nothing"
|
||||
}
|
||||
},
|
||||
"left_door": {
|
||||
"choice": {
|
||||
"handshake": "Shake hands with the stranger",
|
||||
"reject": "Refuse to shake, stay alert"
|
||||
}
|
||||
},
|
||||
"right_door": {
|
||||
"choice": {
|
||||
"continue": "Keep moving forward",
|
||||
"back": "Turn back"
|
||||
}
|
||||
},
|
||||
"trust_ending": {
|
||||
"choice": {
|
||||
"journey": "Embark on a journey of trust (requires trust >= 80)",
|
||||
"leave": "Leave this place"
|
||||
}
|
||||
},
|
||||
"investigation_site": {
|
||||
"choice": {
|
||||
"leave": "Leave the room"
|
||||
}
|
||||
},
|
||||
"desk_detail": {
|
||||
"choice": {
|
||||
"return": "Return to the crime scene",
|
||||
"leave": "Leave"
|
||||
}
|
||||
},
|
||||
"stay": {
|
||||
"choice": {
|
||||
"stand": "Stand up and leave"
|
||||
}
|
||||
},
|
||||
"qte_success": {
|
||||
"choice": {
|
||||
"continue": "Keep moving forward",
|
||||
"back": "Turn back"
|
||||
}
|
||||
},
|
||||
"qte_fail": {
|
||||
"choice": {
|
||||
"continue": "Keep moving forward",
|
||||
"back": "Turn back"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/locales/zh.json
Normal file
79
src/locales/zh.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"ui": {
|
||||
"start": "开始游戏",
|
||||
"resume": "继续上次进度",
|
||||
"chapters": "章节选择",
|
||||
"menu": "菜单",
|
||||
"save": "保存",
|
||||
"load": "读取",
|
||||
"close": "关闭",
|
||||
"skip": "跳过",
|
||||
"fullscreen": "全屏",
|
||||
"exitFullscreen": "退出全屏",
|
||||
"gameEnd": "游戏结束",
|
||||
"choose": "做出你的选择",
|
||||
"back": "返回",
|
||||
"autoSave": "自动存档",
|
||||
"empty": "空",
|
||||
"loading": "加载中...",
|
||||
"speed": "倍速",
|
||||
"noAutoSave": "暂无自动存档",
|
||||
"autoSaveHint": "游戏会在每次场景切换时自动保存到槽位 0"
|
||||
},
|
||||
"scene": {
|
||||
"intro": {
|
||||
"choice": {
|
||||
"left_door": "走向左边那扇发光的门",
|
||||
"right_door": "走向右边那扇普通的门",
|
||||
"search": "仔细搜索房间",
|
||||
"stay": "留在原地,什么也不做"
|
||||
}
|
||||
},
|
||||
"left_door": {
|
||||
"choice": {
|
||||
"handshake": "与陌生人握手",
|
||||
"reject": "拒绝握手,保持警惕"
|
||||
}
|
||||
},
|
||||
"right_door": {
|
||||
"choice": {
|
||||
"continue": "继续前进",
|
||||
"back": "回头"
|
||||
}
|
||||
},
|
||||
"trust_ending": {
|
||||
"choice": {
|
||||
"journey": "开启信任的旅程(需要 trust >= 80)",
|
||||
"leave": "离开这里"
|
||||
}
|
||||
},
|
||||
"investigation_site": {
|
||||
"choice": {
|
||||
"leave": "离开房间"
|
||||
}
|
||||
},
|
||||
"desk_detail": {
|
||||
"choice": {
|
||||
"return": "返回调查现场",
|
||||
"leave": "离开"
|
||||
}
|
||||
},
|
||||
"stay": {
|
||||
"choice": {
|
||||
"stand": "站起来离开"
|
||||
}
|
||||
},
|
||||
"qte_success": {
|
||||
"choice": {
|
||||
"continue": "继续前进",
|
||||
"back": "回头"
|
||||
}
|
||||
},
|
||||
"qte_fail": {
|
||||
"choice": {
|
||||
"continue": "继续前进",
|
||||
"back": "回头"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user