From 86a0aebdc881564be9c0f4362d64a913dbf76d2a Mon Sep 17 00:00:00 2001 From: cocos02 Date: Wed, 10 Jun 2026 12:17:52 +0800 Subject: [PATCH] feat: configurable locales path per story, dynamic language switching from story data --- engine/types.ts | 6 ++++++ public/{ => demo}/locales/en.json | 0 public/{ => demo}/locales/ja.json | 0 public/{ => demo}/locales/zh.json | 0 public/scenes/demo.json | 4 ++++ src/App.vue | 6 +++++- src/components/LangSwitch.vue | 33 +++++++++++++++++++------------ src/composables/useGameEngine.ts | 1 + src/composables/useI18n.ts | 33 +++++++++++++++++++------------ src/stores/gameStore.ts | 9 ++++++++- 10 files changed, 64 insertions(+), 28 deletions(-) rename public/{ => demo}/locales/en.json (100%) rename public/{ => demo}/locales/ja.json (100%) rename public/{ => demo}/locales/zh.json (100%) diff --git a/engine/types.ts b/engine/types.ts index 66b74f7..726c2fa 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -96,11 +96,17 @@ export interface EndingDef { thumbnail?: string } +export interface LocalesConfig { + path: string + languages: string[] +} + export interface GameData { scenes: Record startScene: string variables: Record assetBase?: string + locales?: LocalesConfig chapters?: ChapterInfo[] achievements?: AchievementDef[] endings?: EndingDef[] diff --git a/public/locales/en.json b/public/demo/locales/en.json similarity index 100% rename from public/locales/en.json rename to public/demo/locales/en.json diff --git a/public/locales/ja.json b/public/demo/locales/ja.json similarity index 100% rename from public/locales/ja.json rename to public/demo/locales/ja.json diff --git a/public/locales/zh.json b/public/demo/locales/zh.json similarity index 100% rename from public/locales/zh.json rename to public/demo/locales/zh.json diff --git a/public/scenes/demo.json b/public/scenes/demo.json index d5e268f..9e5f647 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -1,5 +1,9 @@ { "assetBase": "demo/", + "locales": { + "path": "locales/", + "languages": ["zh", "en", "ja"] + }, "startScene": "intro", "variables": { "trust": 50, diff --git a/src/App.vue b/src/App.vue index 0d6c56f..bb66df2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -17,7 +17,7 @@ import AccessibilitySettings from '@/components/AccessibilitySettings.vue' import { useGameEngine } from '@/composables/useGameEngine' import { useGameStore } from '@/stores/gameStore' import { useFullscreen } from '@/composables/useFullscreen' -import { useI18n } from '@/composables/useI18n' +import { useI18n, initStoryLocales } from '@/composables/useI18n' const store = useGameStore() const { t, currentLang } = useI18n() @@ -62,6 +62,10 @@ async function resolveScenePath(): Promise { async function init() { const scenePath = await resolveScenePath() await loadGame(scenePath) + const lc = store.storyLocales + if (lc.path) { + await initStoryLocales(lc.path) + } loading.value = false hasAutoSave.value = (await saveSystem.load(0)) !== null } diff --git a/src/components/LangSwitch.vue b/src/components/LangSwitch.vue index ad694dd..1c2d189 100644 --- a/src/components/LangSwitch.vue +++ b/src/components/LangSwitch.vue @@ -1,23 +1,30 @@ diff --git a/src/composables/useGameEngine.ts b/src/composables/useGameEngine.ts index e086cf9..ae7c65d 100644 --- a/src/composables/useGameEngine.ts +++ b/src/composables/useGameEngine.ts @@ -161,6 +161,7 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide engine.achievementSystem.init(data.achievements || [], achieved) store.setEndings(data.endings || []) + store.setStoryLocales(data.locales) const visitedIds = await saveSystem.getVisitedSceneIds() store.setVisitedSceneIds(visitedIds) diff --git a/src/composables/useI18n.ts b/src/composables/useI18n.ts index d386d69..3201c41 100644 --- a/src/composables/useI18n.ts +++ b/src/composables/useI18n.ts @@ -4,20 +4,28 @@ import enUI from '@/locales/en.json' import jaUI from '@/locales/ja.json' const uiMessages = { zh: zhUI, en: enUI, ja: jaUI } as const -type Lang = 'zh' | 'en' | 'ja' - -const currentLang = ref( - (localStorage.getItem('lang') as Lang) || 'zh', -) +type Lang = string +const currentLang = ref(localStorage.getItem('lang') || 'zh') const storyMessages = ref>>({}) -const storyLoading = new Set() +const storyLoading = new Set() +let localesPath = '' -async function loadStoryMessages(lang: Lang) { - if (storyMessages.value[lang] || storyLoading.has(lang)) return +function resolveLocalePath(lang: string): string { + 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) try { - const resp = await fetch(`/locales/${lang}.json`) + const resp = await fetch(resolveLocalePath(lang)) if (resp.ok) { storyMessages.value = { ...storyMessages.value, @@ -31,8 +39,6 @@ async function loadStoryMessages(lang: Lang) { } } -loadStoryMessages(currentLang.value) - function t(key: string): string { const parts = key.split('.') @@ -42,7 +48,8 @@ function t(key: string): string { } 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) { fallback = fallback?.[p] } @@ -51,7 +58,7 @@ function t(key: string): string { return key } -async function setLang(lang: Lang) { +async function setLang(lang: string) { await loadStoryMessages(lang) currentLang.value = lang localStorage.setItem('lang', lang) diff --git a/src/stores/gameStore.ts b/src/stores/gameStore.ts index db64e65..f17449d 100644 --- a/src/stores/gameStore.ts +++ b/src/stores/gameStore.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' 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 { slot: number @@ -36,6 +36,7 @@ export const useGameStore = defineStore('game', () => { const showEndingGallery = ref(false) const endings = ref([]) const visitedSceneIds = ref>(new Set()) + const storyLocales = ref({ path: '', languages: ['zh'] }) const subFontSize = ref(Number(localStorage.getItem('subFontSize') || 20)) const subBgAlpha = ref(Number(localStorage.getItem('subBgAlpha') || 0)) @@ -180,6 +181,10 @@ export const useGameStore = defineStore('game', () => { 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 setSubBgAlpha(v: number) { subBgAlpha.value = v; localStorage.setItem('subBgAlpha', 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, inputMode, showAchievements, achievementDefs, unlockedAchievementIds, toastAchievementId, showEndingGallery, endings, visitedSceneIds, + storyLocales, subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled, showSettings, setScene, setChoices, clearChoices, setGameEnded, @@ -217,6 +223,7 @@ export const useGameStore = defineStore('game', () => { setShowAchievements, setAchievementDefs, setUnlockedAchievementIds, addUnlockedAchievement, clearToastAchievement, setEndings, setShowEndingGallery, setVisitedSceneIds, addVisitedSceneId, + setStoryLocales, setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled, setShowSettings, dump,