feat: i18n system, lang switch component, english subtitles, UI improvements, roadmap update

This commit is contained in:
2026-06-09 15:40:51 +08:00
parent 6b4114af43
commit 59aed77199
16 changed files with 410 additions and 33 deletions

View File

@@ -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>

View File

@@ -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"

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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) => {

View 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
View 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
View 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": "回头"
}
}
}
}