Files
cultivation-world-simulator/web/src/App.vue
4thfever e1091fdf5a Feat/i18n (#92)
* feat: add vue-i18n

* feat: add vue-i18n

* feat: add vue-i18n

* feat: add language class

* add: en templates and configs

* add: en names

* refactor: name gender id and sect id

* feat(i18n): add gettext infrastructure for dynamic text translation (#81)

* feat(i18n): add gettext infrastructure for dynamic text translation

- Add src/i18n/ module with t() translation function
- Add .po/.mo files for zh_CN and en_US locales
- Update LanguageManager to reload translations on language change
- Add comprehensive tests (14 tests, all passing)
- Add implementation spec at docs/specs/i18n-dynamic-text.md

Phase 1 of i18n dynamic text implementation.

* feat(i18n): expand .po files with comprehensive translation entries

Add translation messages for:
- Battle result messages (fatal/non-fatal outcomes)
- Fortune event messages (item discovery, cultivation gains)
- Misfortune event messages (losses, damage, regression)
- Death reason messages
- Item exchange messages (equip, sell, discard)
- Single choice context and option labels
- Common labels (weapon, auxiliary, technique, elixir)

Both zh_CN and en_US locales updated with matching entries.

* test: add .po file integrity tests

* feat: i18n for actions

* feat: i18n for effects

* feat: i18n for gathering

* feat: i18n for classes

* feat: i18n for classes

* feat: i18n for classes

* feat: i18n for classes

* fix bugs

* fix bugs

* fix bugs

* fix bugs

* fix bugs

* fix bugs

* fix bugs

* fix bugs

* update csv

* update world info

* update prompt

* update prompt

* fix bug

* fix bug

* fix bug

* fix bug

* fix bug

* fix bug

* fix bug

* fix bug

* fix bug

* update

* update

* update

* update

* update

* update

* update

---------

Co-authored-by: Zihao Xu <xzhseh@gmail.com>
2026-01-24 13:47:23 +08:00

317 lines
8.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, onUnmounted, watch, ref } from 'vue'
import { NConfigProvider, darkTheme, NMessageProvider } from 'naive-ui'
import { systemApi } from './api/modules/system'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// Components
import SplashLayer from './components/SplashLayer.vue'
import GameCanvas from './components/game/GameCanvas.vue'
import InfoPanelContainer from './components/game/panels/info/InfoPanelContainer.vue'
import StatusBar from './components/layout/StatusBar.vue'
import EventPanel from './components/panels/EventPanel.vue'
import SystemMenu from './components/SystemMenu.vue'
import LoadingOverlay from './components/LoadingOverlay.vue'
// Composables
import { useGameInit } from './composables/useGameInit'
import { useGameControl } from './composables/useGameControl'
// Stores
import { useUiStore } from './stores/ui'
import { useSettingStore } from './stores/setting'
const uiStore = useUiStore()
const settingStore = useSettingStore()
const showSplash = ref(true)
const openedFromSplash = ref(false)
// 1. 游戏初始化逻辑
const {
initStatus,
gameInitialized,
showLoading,
} = useGameInit()
// 2. 游戏控制逻辑
// 依赖 gameInitialized 状态来决定是否允许暂停等
const {
showMenu,
isManualPaused,
menuDefaultTab,
canCloseMenu,
handleKeydown: controlHandleKeydown,
performStartupCheck,
handleLLMReady,
handleMenuClose,
toggleManualPause
} = useGameControl(gameInitialized)
// 3. 胶水逻辑:连接 Init 和 Control
// 当检测到 idle 状态时,执行启动检查
watch(initStatus, (newVal, oldVal) => {
// Idle check
if (newVal?.status === 'idle' && oldVal?.status !== 'idle') {
if (!showMenu.value) {
performStartupCheck()
}
}
// Ready check (原逻辑: stopPolling 由 useGameInit 处理, 这里只负责 UI)
if (oldVal?.status !== 'ready' && newVal?.status === 'ready') {
showMenu.value = false
}
})
// 自动取消暂停:当游戏初始化完成后,自动开始运行
watch(gameInitialized, (val) => {
if (val) {
// 如果游戏已初始化完成(可能是刷新页面后恢复),确保关闭 Splash
if (showSplash.value) {
showSplash.value = false
}
// 设置前端状态并恢复后端
isManualPaused.value = false
systemApi.resumeGame().catch(console.error)
openedFromSplash.value = false // 游戏开始,清除 Splash 来源标记
}
})
// 事件处理
function onKeydown(e: KeyboardEvent) {
if (showLoading.value) return
if (showSplash.value) return
controlHandleKeydown(e)
}
function handleSelection(target: { type: 'avatar' | 'region'; id: string; name?: string }) {
uiStore.select(target.type, target.id)
}
async function handleSplashAction(key: string) {
if (key === 'exit') {
try {
await systemApi.shutdown()
window.close()
document.body.innerHTML = `<div style="color:white; display:flex; justify-content:center; align-items:center; height:100vh; background:black; font-size:24px;">${t('game.controls.closed_msg')}</div>`
} catch (e) {
console.error('Shutdown failed', e)
}
return
}
openedFromSplash.value = true // 标记来源
// 关闭 Splash
showSplash.value = false
// 根据按键跳转到对应 Tab
if (key === 'start') {
performStartupCheck()
} else if (key === 'load') {
menuDefaultTab.value = 'load'
showMenu.value = true
}
}
function handleMenuCloseWrapper() {
// 如果是从 Splash 打开的,关闭菜单时应回到 Splash
if (openedFromSplash.value) {
showMenu.value = false
showSplash.value = true
// 保持 openedFromSplash 为 true 或 false?
// 如果回到 Splash下次点击 Start 又是重新流程。
// 这里不需要重置,因为下次点击 handleSplashAction 会再次设置。
} else {
// 正常游戏内关闭
handleMenuClose()
}
}
async function handleReturnToMain() {
try {
await systemApi.resetGame()
// 关闭菜单
showMenu.value = false
// 显示 Splash
showSplash.value = true
// 重置来源标记虽然显示Splash后点击按钮会重新设置但这里为了逻辑清晰先重置
openedFromSplash.value = false
} catch (e) {
console.error('Reset game failed', e)
}
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
// Ensure backend language setting matches frontend preference
settingStore.syncBackend()
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<n-config-provider :theme="darkTheme">
<n-message-provider>
<SplashLayer
v-if="showSplash"
@action="handleSplashAction"
/>
<!-- Loading Overlay - 盖在游戏上面 -->
<LoadingOverlay
v-if="!showSplash && showLoading"
:status="initStatus"
/>
<!-- Game UI - 始终渲染 -->
<div v-if="!showSplash" class="app-layout">
<StatusBar />
<div class="main-content">
<div class="map-container">
<!-- 顶部控制栏 -->
<div class="top-controls">
<!-- 暂停/播放按钮 -->
<button class="control-btn pause-toggle" @click="toggleManualPause" :title="isManualPaused ? t('game.controls.resume') : t('game.controls.pause')">
<!-- 播放图标 (当暂停时显示) -->
<svg v-if="isManualPaused" viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M8 5v14l11-7z"/>
</svg>
<!-- 暂停图标 (当播放时显示) -->
<svg v-else viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</button>
<!-- 菜单按钮 -->
<button class="control-btn menu-toggle" @click="showMenu = true">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/>
</svg>
</button>
</div>
<!-- 暂停状态提示 -->
<div v-if="isManualPaused" class="pause-indicator">
<div class="pause-text">{{ t('game.controls.paused') }}</div>
</div>
<GameCanvas
@avatarSelected="handleSelection"
@regionSelected="handleSelection"
/>
<InfoPanelContainer />
</div>
<aside class="sidebar">
<EventPanel />
</aside>
</div>
<SystemMenu
:visible="showMenu"
:default-tab="menuDefaultTab"
:game-initialized="gameInitialized"
:closable="canCloseMenu"
@close="handleMenuCloseWrapper"
@llm-ready="handleLLMReady"
@return-to-main="handleReturnToMain"
@exit-game="() => handleSplashAction('exit')"
/>
</div>
</n-message-provider>
</n-config-provider>
</template>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
background: #000;
color: #eee;
overflow: hidden;
position: relative;
}
.main-content {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.map-container {
flex: 1;
position: relative;
background: #111;
overflow: hidden;
}
.top-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 100;
display: flex;
gap: 10px;
}
.control-btn {
background: rgba(0,0,0,0.5);
border: 1px solid #444;
color: #ddd;
width: 40px;
height: 40px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-btn:hover {
background: rgba(0,0,0,0.8);
border-color: #666;
color: #fff;
}
.pause-indicator {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 90;
pointer-events: none;
}
.pause-text {
background: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
letter-spacing: 2px;
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(4px);
}
.sidebar {
width: 400px;
background: #181818;
border-left: 1px solid #333;
display: flex;
flex-direction: column;
z-index: 20;
}
</style>