feat: configurable locales path per story, dynamic language switching from story data
This commit is contained in:
@@ -96,11 +96,17 @@ export interface EndingDef {
|
|||||||
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
|
assetBase?: string
|
||||||
|
locales?: LocalesConfig
|
||||||
chapters?: ChapterInfo[]
|
chapters?: ChapterInfo[]
|
||||||
achievements?: AchievementDef[]
|
achievements?: AchievementDef[]
|
||||||
endings?: EndingDef[]
|
endings?: EndingDef[]
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"assetBase": "demo/",
|
"assetBase": "demo/",
|
||||||
|
"locales": {
|
||||||
|
"path": "locales/",
|
||||||
|
"languages": ["zh", "en", "ja"]
|
||||||
|
},
|
||||||
"startScene": "intro",
|
"startScene": "intro",
|
||||||
"variables": {
|
"variables": {
|
||||||
"trust": 50,
|
"trust": 50,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import AccessibilitySettings from '@/components/AccessibilitySettings.vue'
|
|||||||
import { useGameEngine } from '@/composables/useGameEngine'
|
import { useGameEngine } from '@/composables/useGameEngine'
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
import { useFullscreen } from '@/composables/useFullscreen'
|
import { useFullscreen } from '@/composables/useFullscreen'
|
||||||
import { useI18n } from '@/composables/useI18n'
|
import { useI18n, initStoryLocales } from '@/composables/useI18n'
|
||||||
|
|
||||||
const store = useGameStore()
|
const store = useGameStore()
|
||||||
const { t, currentLang } = useI18n()
|
const { t, currentLang } = useI18n()
|
||||||
@@ -62,6 +62,10 @@ async function resolveScenePath(): Promise<string> {
|
|||||||
async function init() {
|
async function init() {
|
||||||
const scenePath = await resolveScenePath()
|
const scenePath = await resolveScenePath()
|
||||||
await loadGame(scenePath)
|
await loadGame(scenePath)
|
||||||
|
const lc = store.storyLocales
|
||||||
|
if (lc.path) {
|
||||||
|
await initStoryLocales(lc.path)
|
||||||
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
hasAutoSave.value = (await saveSystem.load(0)) !== null
|
hasAutoSave.value = (await saveSystem.load(0)) !== null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from '@/composables/useI18n'
|
import { useI18n } from '@/composables/useI18n'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
|
||||||
const { currentLang, setLang, t } = useI18n()
|
const { currentLang, setLang } = useI18n()
|
||||||
|
const store = useGameStore()
|
||||||
|
|
||||||
|
const langLabels: Record<string, string> = {
|
||||||
|
zh: '中文',
|
||||||
|
en: 'English',
|
||||||
|
ja: '日本語',
|
||||||
|
ko: '한국어',
|
||||||
|
fr: 'Français',
|
||||||
|
de: 'Deutsch',
|
||||||
|
es: 'Español',
|
||||||
|
pt: 'Português',
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="lang-switch">
|
<div class="lang-switch" v-if="store.storyLocales.languages.length > 1">
|
||||||
<button
|
<button
|
||||||
:class="['lang-btn', { active: currentLang === 'zh' }]"
|
v-for="lang in store.storyLocales.languages"
|
||||||
@click="setLang('zh')"
|
:key="lang"
|
||||||
>中文</button>
|
:class="['lang-btn', { active: currentLang === lang }]"
|
||||||
<button
|
@click="setLang(lang)"
|
||||||
:class="['lang-btn', { active: currentLang === 'en' }]"
|
>{{ langLabels[lang] || lang.toUpperCase() }}</button>
|
||||||
@click="setLang('en')"
|
|
||||||
>English</button>
|
|
||||||
<button
|
|
||||||
:class="['lang-btn', { active: currentLang === 'ja' }]"
|
|
||||||
@click="setLang('ja')"
|
|
||||||
>日本語</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
|||||||
engine.achievementSystem.init(data.achievements || [], achieved)
|
engine.achievementSystem.init(data.achievements || [], achieved)
|
||||||
|
|
||||||
store.setEndings(data.endings || [])
|
store.setEndings(data.endings || [])
|
||||||
|
store.setStoryLocales(data.locales)
|
||||||
|
|
||||||
const visitedIds = await saveSystem.getVisitedSceneIds()
|
const visitedIds = await saveSystem.getVisitedSceneIds()
|
||||||
store.setVisitedSceneIds(visitedIds)
|
store.setVisitedSceneIds(visitedIds)
|
||||||
|
|||||||
@@ -4,20 +4,28 @@ import enUI from '@/locales/en.json'
|
|||||||
import jaUI from '@/locales/ja.json'
|
import jaUI from '@/locales/ja.json'
|
||||||
|
|
||||||
const uiMessages = { zh: zhUI, en: enUI, ja: jaUI } as const
|
const uiMessages = { zh: zhUI, en: enUI, ja: jaUI } as const
|
||||||
type Lang = 'zh' | 'en' | 'ja'
|
type Lang = string
|
||||||
|
|
||||||
const currentLang = ref<Lang>(
|
|
||||||
(localStorage.getItem('lang') as Lang) || 'zh',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
const currentLang = ref<Lang>(localStorage.getItem('lang') || 'zh')
|
||||||
const storyMessages = ref<Record<string, Record<string, any>>>({})
|
const storyMessages = ref<Record<string, Record<string, any>>>({})
|
||||||
const storyLoading = new Set<Lang>()
|
const storyLoading = new Set<string>()
|
||||||
|
let localesPath = ''
|
||||||
|
|
||||||
async function loadStoryMessages(lang: Lang) {
|
function resolveLocalePath(lang: string): string {
|
||||||
if (storyMessages.value[lang] || storyLoading.has(lang)) return
|
const base = localesPath.endsWith('/') ? localesPath : localesPath + '/'
|
||||||
|
return base + lang + '.json'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initStoryLocales(path: string, lang?: string) {
|
||||||
|
localesPath = path
|
||||||
|
return loadStoryMessages(lang || currentLang.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStoryMessages(lang: string) {
|
||||||
|
if (!localesPath || storyMessages.value[lang] || storyLoading.has(lang)) return
|
||||||
storyLoading.add(lang)
|
storyLoading.add(lang)
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/locales/${lang}.json`)
|
const resp = await fetch(resolveLocalePath(lang))
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
storyMessages.value = {
|
storyMessages.value = {
|
||||||
...storyMessages.value,
|
...storyMessages.value,
|
||||||
@@ -31,8 +39,6 @@ async function loadStoryMessages(lang: Lang) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadStoryMessages(currentLang.value)
|
|
||||||
|
|
||||||
function t(key: string): string {
|
function t(key: string): string {
|
||||||
const parts = key.split('.')
|
const parts = key.split('.')
|
||||||
|
|
||||||
@@ -42,7 +48,8 @@ function t(key: string): string {
|
|||||||
}
|
}
|
||||||
if (typeof result === 'string') return result
|
if (typeof result === 'string') return result
|
||||||
|
|
||||||
let fallback: any = uiMessages[currentLang.value]
|
const uiLang = currentLang.value as keyof typeof uiMessages
|
||||||
|
let fallback: any = (uiMessages as any)[uiLang] || uiMessages.zh
|
||||||
for (const p of parts) {
|
for (const p of parts) {
|
||||||
fallback = fallback?.[p]
|
fallback = fallback?.[p]
|
||||||
}
|
}
|
||||||
@@ -51,7 +58,7 @@ function t(key: string): string {
|
|||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setLang(lang: Lang) {
|
async function setLang(lang: string) {
|
||||||
await loadStoryMessages(lang)
|
await loadStoryMessages(lang)
|
||||||
currentLang.value = lang
|
currentLang.value = lang
|
||||||
localStorage.setItem('lang', lang)
|
localStorage.setItem('lang', lang)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, shallowRef } from 'vue'
|
import { ref, shallowRef } from 'vue'
|
||||||
import type { SceneNode, Choice, QTEDefinition, Hotspot, ChapterInfo, AchievementDef, EndingDef } from '@engine/types'
|
import type { SceneNode, Choice, QTEDefinition, Hotspot, ChapterInfo, AchievementDef, EndingDef, LocalesConfig } from '@engine/types'
|
||||||
|
|
||||||
export interface SlotInfo {
|
export interface SlotInfo {
|
||||||
slot: number
|
slot: number
|
||||||
@@ -36,6 +36,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
const showEndingGallery = ref(false)
|
const showEndingGallery = ref(false)
|
||||||
const endings = ref<EndingDef[]>([])
|
const endings = ref<EndingDef[]>([])
|
||||||
const visitedSceneIds = ref<Set<string>>(new Set())
|
const visitedSceneIds = ref<Set<string>>(new Set())
|
||||||
|
const storyLocales = ref<LocalesConfig>({ path: '', languages: ['zh'] })
|
||||||
|
|
||||||
const subFontSize = ref(Number(localStorage.getItem('subFontSize') || 20))
|
const subFontSize = ref(Number(localStorage.getItem('subFontSize') || 20))
|
||||||
const subBgAlpha = ref(Number(localStorage.getItem('subBgAlpha') || 0))
|
const subBgAlpha = ref(Number(localStorage.getItem('subBgAlpha') || 0))
|
||||||
@@ -180,6 +181,10 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
visitedSceneIds.value = new Set(visitedSceneIds.value)
|
visitedSceneIds.value = new Set(visitedSceneIds.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStoryLocales(locales: LocalesConfig | undefined) {
|
||||||
|
if (locales) storyLocales.value = locales
|
||||||
|
}
|
||||||
|
|
||||||
function setSubFontSize(v: number) { subFontSize.value = v; localStorage.setItem('subFontSize', String(v)) }
|
function setSubFontSize(v: number) { subFontSize.value = v; localStorage.setItem('subFontSize', String(v)) }
|
||||||
function setSubBgAlpha(v: number) { subBgAlpha.value = v; localStorage.setItem('subBgAlpha', String(v)) }
|
function setSubBgAlpha(v: number) { subBgAlpha.value = v; localStorage.setItem('subBgAlpha', String(v)) }
|
||||||
function setQteTimeRelax(v: boolean) { qteTimeRelax.value = v; localStorage.setItem('qteTimeRelax', String(v)) }
|
function setQteTimeRelax(v: boolean) { qteTimeRelax.value = v; localStorage.setItem('qteTimeRelax', String(v)) }
|
||||||
@@ -206,6 +211,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
hotspots, isImageScene, showChapterSelect, chapters, unlockedChapterIds,
|
hotspots, isImageScene, showChapterSelect, chapters, unlockedChapterIds,
|
||||||
inputMode, showAchievements, achievementDefs, unlockedAchievementIds,
|
inputMode, showAchievements, achievementDefs, unlockedAchievementIds,
|
||||||
toastAchievementId, showEndingGallery, endings, visitedSceneIds,
|
toastAchievementId, showEndingGallery, endings, visitedSceneIds,
|
||||||
|
storyLocales,
|
||||||
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
|
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
|
||||||
showSettings,
|
showSettings,
|
||||||
setScene, setChoices, clearChoices, setGameEnded,
|
setScene, setChoices, clearChoices, setGameEnded,
|
||||||
@@ -217,6 +223,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
setShowAchievements, setAchievementDefs, setUnlockedAchievementIds,
|
setShowAchievements, setAchievementDefs, setUnlockedAchievementIds,
|
||||||
addUnlockedAchievement, clearToastAchievement,
|
addUnlockedAchievement, clearToastAchievement,
|
||||||
setEndings, setShowEndingGallery, setVisitedSceneIds, addVisitedSceneId,
|
setEndings, setShowEndingGallery, setVisitedSceneIds, addVisitedSceneId,
|
||||||
|
setStoryLocales,
|
||||||
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
|
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
|
||||||
setShowSettings,
|
setShowSettings,
|
||||||
dump,
|
dump,
|
||||||
|
|||||||
Reference in New Issue
Block a user