377 lines
10 KiB
Vue
377 lines
10 KiB
Vue
<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)
|
||
|
||
// Sidebar resizer 状态。
|
||
const sidebarWidth = ref(400)
|
||
const isResizing = ref(false)
|
||
const MIN_SIDEBAR_WIDTH = 300
|
||
|
||
function getMaxSidebarWidth() {
|
||
return Math.floor(window.innerWidth * 0.5)
|
||
}
|
||
|
||
function onResizerMouseDown(e: MouseEvent) {
|
||
e.preventDefault()
|
||
isResizing.value = true
|
||
document.addEventListener('mousemove', onResizerMouseMove)
|
||
document.addEventListener('mouseup', onResizerMouseUp)
|
||
}
|
||
|
||
function onResizerMouseMove(e: MouseEvent) {
|
||
if (!isResizing.value) return
|
||
const newWidth = window.innerWidth - e.clientX
|
||
const maxWidth = getMaxSidebarWidth()
|
||
sidebarWidth.value = Math.max(MIN_SIDEBAR_WIDTH, Math.min(newWidth, maxWidth))
|
||
}
|
||
|
||
function onResizerMouseUp() {
|
||
isResizing.value = false
|
||
document.removeEventListener('mousemove', onResizerMouseMove)
|
||
document.removeEventListener('mouseup', onResizerMouseUp)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 窗口 resize 时,确保 sidebar 宽度不超过最大值。
|
||
function onWindowResize() {
|
||
const maxWidth = getMaxSidebarWidth()
|
||
if (sidebarWidth.value > maxWidth) {
|
||
sidebarWidth.value = maxWidth
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('keydown', onKeydown)
|
||
window.addEventListener('resize', onWindowResize)
|
||
// Ensure backend language setting matches frontend preference
|
||
settingStore.syncBackend()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('keydown', onKeydown)
|
||
window.removeEventListener('resize', onWindowResize)
|
||
document.removeEventListener('mousemove', onResizerMouseMove)
|
||
document.removeEventListener('mouseup', onResizerMouseUp)
|
||
})
|
||
</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
|
||
:sidebar-width="sidebarWidth"
|
||
@avatarSelected="handleSelection"
|
||
@regionSelected="handleSelection"
|
||
/>
|
||
<InfoPanelContainer />
|
||
</div>
|
||
<div
|
||
class="sidebar-resizer"
|
||
:class="{ 'is-resizing': isResizing }"
|
||
@mousedown="onResizerMouseDown"
|
||
></div>
|
||
<aside class="sidebar" :style="{ width: sidebarWidth + 'px' }">
|
||
<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-resizer {
|
||
width: 4px;
|
||
background: transparent;
|
||
cursor: col-resize;
|
||
transition: background 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-resizer:hover,
|
||
.sidebar-resizer.is-resizing {
|
||
background: #555;
|
||
}
|
||
|
||
.sidebar {
|
||
background: #181818;
|
||
border-left: 1px solid #333;
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: 20;
|
||
flex-shrink: 0;
|
||
}
|
||
</style>
|