mirror of
https://github.com/setube/ogame-vue-ts.git
synced 2026-05-12 16:05:12 +08:00
feat: 新增战报弹窗与舰队模拟器,重构UI组件
新增 BattleReportDialog、SpyReportDialog、NumberWithTooltip 等组件,完善舰队模拟器功能。重构并引入 Sheet、Sidebar、Tooltip、Skeleton 等 UI 组件,优化界面结构。实现 battle.worker 支持战斗计算,增加 universeStore、fleetStorageLogic 等核心逻辑,完善多语言与类型定义。
This commit is contained in:
611
src/App.vue
611
src/App.vue
@@ -1,150 +1,131 @@
|
||||
<template>
|
||||
<div class="flex h-screen bg-background overflow-hidden">
|
||||
<!-- 遮罩层(移动端) -->
|
||||
<div v-if="!gameStore.sidebarCollapsed" class="fixed inset-0 bg-black/50 z-30 lg:hidden" @click="toggleSidebar" />
|
||||
|
||||
<!-- 侧边导航栏 -->
|
||||
<aside
|
||||
:class="[
|
||||
'border-r bg-card flex flex-col transition-all duration-300 ease-in-out shadow-lg z-40',
|
||||
'fixed lg:relative h-full',
|
||||
gameStore.sidebarCollapsed ? '-translate-x-full lg:translate-x-0 lg:w-16' : 'translate-x-0 w-64'
|
||||
]"
|
||||
>
|
||||
<SidebarProvider :open="sidebarOpen" @update:open="sidebarOpen = $event">
|
||||
<Sidebar collapsible="icon">
|
||||
<!-- Logo -->
|
||||
<div class="p-4 border-b flex items-center justify-center">
|
||||
<h1 v-if="!gameStore.sidebarCollapsed" class="text-xl font-bold flex items-center gap-2">
|
||||
<span class="text-2xl">
|
||||
<img src="@/assets/logo.svg" class="w-10" />
|
||||
</span>
|
||||
{{ pkg.title }}
|
||||
</h1>
|
||||
<span v-else class="text-2xl">
|
||||
<img src="@/assets/logo.svg" class="w-10" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 星球信息 -->
|
||||
<div v-if="planet && !gameStore.sidebarCollapsed" class="p-4 border-b">
|
||||
<div class="text-sm space-y-2">
|
||||
<div>
|
||||
<p class="font-semibold mb-1">
|
||||
{{ planet.name }}
|
||||
<Badge v-if="planet.isMoon" variant="secondary" class="ml-1 text-xs">{{ t('planet.moon') }}</Badge>
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
[{{ planet.position.galaxy }}:{{ planet.position.system }}:{{ planet.position.position }}]
|
||||
</p>
|
||||
</div>
|
||||
<!-- 玩家积分显示 -->
|
||||
<div class="bg-muted/50 rounded-lg p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">{{ t('player.points') }}</span>
|
||||
<span class="text-sm font-bold text-primary">{{ formatNumber(gameStore.player.points) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 月球切换按钮 -->
|
||||
<div v-if="hasMoon || planet.isMoon" class="flex gap-1">
|
||||
<Button v-if="planet.isMoon" @click="switchToParentPlanet" variant="outline" size="sm" class="w-full text-xs h-7">
|
||||
{{ t('planet.backToPlanet') }}
|
||||
</Button>
|
||||
<Button v-else-if="moon" @click="switchToMoon" variant="outline" size="sm" class="w-full text-xs h-7">
|
||||
{{ t('planet.switchToMoon') }}
|
||||
</Button>
|
||||
</div>
|
||||
<SidebarHeader class="border-b">
|
||||
<div class="flex items-center justify-center p-4 group-data-[collapsible=icon]:p-2">
|
||||
<img src="@/assets/logo.svg" class="w-10 group-data-[collapsible=icon]:w-8" />
|
||||
<h1 class="text-xl font-bold ml-2 group-data-[collapsible=icon]:hidden">{{ pkg.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="flex-1 p-2 space-y-1 overflow-y-auto">
|
||||
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" v-slot="{ isActive: routeActive }">
|
||||
<Button
|
||||
:variant="routeActive ? 'secondary' : 'ghost'"
|
||||
:class="['w-full transition-all', gameStore.sidebarCollapsed ? 'justify-center px-0' : 'justify-start']"
|
||||
:title="gameStore.sidebarCollapsed ? item.name.value : undefined"
|
||||
>
|
||||
<component :is="item.icon" :class="['h-4 w-4', !gameStore.sidebarCollapsed && 'mr-3']" />
|
||||
<span v-if="!gameStore.sidebarCollapsed">{{ item.name.value }}</span>
|
||||
</Button>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- 语言切换 -->
|
||||
<div class="p-2 border-t">
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" class="w-full" size="sm">
|
||||
<Languages class="h-4 w-4" />
|
||||
<span v-if="!gameStore.sidebarCollapsed" class="ml-2">{{ localeNames[gameStore.locale] }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" :align="gameStore.sidebarCollapsed ? 'start' : 'center'">
|
||||
<div class="space-y-1">
|
||||
<Button
|
||||
v-for="locale in locales"
|
||||
:key="locale"
|
||||
@click="gameStore.locale = locale"
|
||||
:variant="gameStore.locale === locale ? 'secondary' : 'ghost'"
|
||||
class="w-full justify-start"
|
||||
size="sm"
|
||||
>
|
||||
{{ localeNames[locale] }}
|
||||
<SidebarContent>
|
||||
<!-- 星球信息 -->
|
||||
<SidebarGroup v-if="planet" class="border-b group-data-[collapsible=icon]:hidden">
|
||||
<div class="px-4 py-3 space-y-2 text-sm">
|
||||
<div>
|
||||
<p class="font-semibold mb-1">
|
||||
{{ planet.name }}
|
||||
<Badge v-if="planet.isMoon" variant="secondary" class="ml-1 text-xs">{{ t('planet.moon') }}</Badge>
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
[{{ planet.position.galaxy }}:{{ planet.position.system }}:{{ planet.position.position }}]
|
||||
</p>
|
||||
</div>
|
||||
<!-- 玩家积分显示 -->
|
||||
<div class="bg-muted/50 rounded-lg p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">{{ t('player.points') }}</span>
|
||||
<span class="text-sm font-bold text-primary">{{ formatNumber(gameStore.player.points) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 月球切换按钮 -->
|
||||
<div v-if="hasMoon || planet.isMoon" class="flex gap-1">
|
||||
<Button v-if="planet.isMoon" @click="switchToParentPlanet" variant="outline" size="sm" class="w-full text-xs h-7">
|
||||
{{ t('planet.backToPlanet') }}
|
||||
</Button>
|
||||
<Button v-else-if="moon" @click="switchToMoon" variant="outline" size="sm" class="w-full text-xs h-7">
|
||||
{{ t('planet.switchToMoon') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
|
||||
<!-- 夜间模式切换 -->
|
||||
<div class="p-2 border-t">
|
||||
<Button @click="isDark = !isDark" variant="ghost" class="w-full" size="sm">
|
||||
<Sun v-if="isDark" class="h-4 w-4" />
|
||||
<Moon v-else class="h-4 w-4" />
|
||||
<span v-if="!gameStore.sidebarCollapsed" class="ml-2">{{ isDark ? t('sidebar.lightMode') : t('sidebar.darkMode') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-2 border-t">
|
||||
<Button @click="toggleSidebar" variant="ghost" class="w-full" size="sm">
|
||||
<ChevronLeft v-if="!gameStore.sidebarCollapsed" class="h-4 w-4" />
|
||||
<ChevronRight v-else class="h-4 w-4" />
|
||||
<span v-if="!gameStore.sidebarCollapsed" class="ml-2">{{ t('sidebar.collapse') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- 导航菜单 -->
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in navItems" :key="item.path">
|
||||
<SidebarMenuButton as-child :is-active="$route.path === item.path" :tooltip="item.name.value">
|
||||
<RouterLink :to="item.path">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.name.value }}</span>
|
||||
<!-- 未读消息数量 -->
|
||||
<SidebarMenuBadge v-if="item.path === '/messages' && unreadMessagesCount > 0">
|
||||
{{ unreadMessagesCount }}
|
||||
</SidebarMenuBadge>
|
||||
</RouterLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<!-- 底部设置 -->
|
||||
<SidebarFooter class="border-t">
|
||||
<SidebarMenu>
|
||||
<!-- 语言切换 -->
|
||||
<SidebarMenuItem>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="localeNames[gameStore.locale]">
|
||||
<Languages />
|
||||
<span>{{ localeNames[gameStore.locale] }}</span>
|
||||
</SidebarMenuButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" side="right" align="end">
|
||||
<div class="space-y-1">
|
||||
<Button
|
||||
v-for="locale in locales"
|
||||
:key="locale"
|
||||
@click="gameStore.locale = locale"
|
||||
:variant="gameStore.locale === locale ? 'secondary' : 'ghost'"
|
||||
class="w-full justify-start"
|
||||
size="sm"
|
||||
>
|
||||
{{ localeNames[locale] }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<!-- 夜间模式切换 -->
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton @click="isDark = !isDark" :tooltip="isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')">
|
||||
<Sun v-if="isDark" />
|
||||
<Moon v-else />
|
||||
<span>{{ isDark ? t('sidebar.lightMode') : t('sidebar.darkMode') }}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<!-- 折叠按钮 -->
|
||||
<SidebarMenuItem class="hidden sm:inline">
|
||||
<SidebarMenuButton @click="toggleSidebar" :tooltip="sidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')">
|
||||
<ChevronsLeft class="group-data-[state=collapsed]:rotate-180 transition-transform" />
|
||||
<span>{{ t('sidebar.collapse') }}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- 顶部资源栏 -->
|
||||
<header v-if="planet" class="bg-card border-b px-4 sm:px-6 py-4.5 shadow-md">
|
||||
<div class="flex items-center justify-between gap-3 sm:gap-6">
|
||||
<!-- 汉堡菜单(移动端)- 左侧占位 -->
|
||||
<div class="lg:flex-1">
|
||||
<Button @click="toggleSidebar" variant="ghost" size="icon" class="lg:hidden h-8 w-8">
|
||||
<component :is="gameStore.sidebarCollapsed ? Menu : X" class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<SidebarInset>
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<!-- 顶部资源栏 -->
|
||||
<header v-if="planet" class="bg-card border-b px-4 sm:px-6 py-6.5 shadow-md">
|
||||
<div class="flex items-center justify-between gap-3 sm:gap-6">
|
||||
<!-- 汉堡菜单(移动端)- 左侧占位 -->
|
||||
<div class="lg:flex-1">
|
||||
<SidebarTrigger class="lg:hidden" />
|
||||
</div>
|
||||
|
||||
<!-- 资源显示 - PC端居中 -->
|
||||
<div class="flex items-center gap-3 sm:gap-6 flex-1 lg:flex-none overflow-x-auto lg:justify-center">
|
||||
<div v-for="resourceType in resourceTypes" :key="resourceType.key" class="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
|
||||
<ResourceIcon :type="resourceType.key" size="md" />
|
||||
<div class="min-w-0">
|
||||
<!-- 电量显示 -->
|
||||
<template v-if="resourceType.key === 'energy'">
|
||||
<p
|
||||
class="text-xs sm:text-sm font-medium truncate"
|
||||
:class="
|
||||
planet.resources[resourceType.key] >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
"
|
||||
>
|
||||
{{ formatNumber(planet.resources[resourceType.key]) }}
|
||||
</p>
|
||||
<p class="text-[10px] sm:text-xs text-muted-foreground truncate">
|
||||
{{ formatNumber(energyProduction || 0) }} / {{ formatNumber(energyConsumption || 0) }}
|
||||
</p>
|
||||
</template>
|
||||
<!-- 其他资源显示 -->
|
||||
<template v-else>
|
||||
<!-- 资源显示 - PC端居中 -->
|
||||
<div class="flex items-center gap-3 sm:gap-6 flex-1 lg:flex-none overflow-x-auto lg:justify-center">
|
||||
<div v-for="resourceType in resourceTypes" :key="resourceType.key" class="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
|
||||
<ResourceIcon :type="resourceType.key" size="md" />
|
||||
<div class="min-w-0">
|
||||
<!-- 所有资源统一显示:当前值/容量 -->
|
||||
<p
|
||||
class="text-xs sm:text-sm font-medium truncate"
|
||||
:class="getResourceColor(planet.resources[resourceType.key], capacity?.[resourceType.key] || Infinity)"
|
||||
@@ -152,105 +133,116 @@
|
||||
{{ formatNumber(planet.resources[resourceType.key]) }} / {{ formatNumber(capacity?.[resourceType.key] || 0) }}
|
||||
</p>
|
||||
<p class="text-[10px] sm:text-xs text-muted-foreground truncate">
|
||||
+{{ formatNumber(production?.[resourceType.key] || 0) }}/{{ t('resources.perHour') }}
|
||||
+{{ formatNumber(Math.round((production?.[resourceType.key] || 0) / 60)) }}/{{ t('resources.perMinute') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧状态 - 右侧占位 -->
|
||||
<div class="flex items-center gap-2 sm:gap-4 flex-shrink-0 lg:flex-1 lg:justify-end">
|
||||
<!-- 建造队列状态 -->
|
||||
<div v-if="planet.buildQueue.length > 0" class="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span class="text-muted-foreground hidden sm:inline">{{ t('queue.building') }}</span>
|
||||
</div>
|
||||
<div v-if="gameStore.player.researchQueue.length > 0" class="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm">
|
||||
<div class="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
<span class="text-muted-foreground hidden sm:inline">{{ t('queue.researching') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 右侧状态 - 右侧占位 -->
|
||||
<div class="flex items-center gap-2 sm:gap-4 flex-shrink-0 lg:flex-1 lg:justify-end">
|
||||
<!-- 建造队列状态 -->
|
||||
<div v-if="planet.buildQueue.length > 0" class="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span class="text-muted-foreground hidden sm:inline">{{ t('queue.building') }}</span>
|
||||
<!-- 建造队列 -->
|
||||
<div
|
||||
v-if="planet && (planet.buildQueue.length > 0 || gameStore.player.researchQueue.length > 0)"
|
||||
class="bg-card border-b px-4 sm:px-6 py-4.5"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- 建造队列 -->
|
||||
<div v-for="item in planet.buildQueue" :key="item.id" class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-xs sm:text-sm gap-2">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500 animate-pulse flex-shrink-0" />
|
||||
<span class="font-medium truncate">{{ getItemName(item) }}</span>
|
||||
<span class="text-muted-foreground hidden sm:inline flex-shrink-0 text-[10px] sm:text-xs">
|
||||
<template v-if="item.type === 'ship' || item.type === 'defense'">
|
||||
→ {{ t('queue.quantity') }} {{ item.quantity }}
|
||||
</template>
|
||||
<template v-else>→ {{ t('queue.level') }} {{ item.targetLevel }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<span class="text-muted-foreground text-[10px] sm:text-xs whitespace-nowrap">
|
||||
{{ formatTime(getRemainingTime(item)) }}
|
||||
</span>
|
||||
<Button
|
||||
@click="handleCancelBuild(item.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs"
|
||||
>
|
||||
{{ t('queue.cancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress :model-value="getQueueProgress(item)" class="h-1.5" />
|
||||
</div>
|
||||
<div v-if="gameStore.player.researchQueue.length > 0" class="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm">
|
||||
<div class="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
<span class="text-muted-foreground hidden sm:inline">{{ t('queue.researching') }}</span>
|
||||
<!-- 研究队列 -->
|
||||
<div v-for="item in gameStore.player.researchQueue" :key="item.id" class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-xs sm:text-sm gap-2">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1">
|
||||
<div class="h-2 w-2 rounded-full bg-blue-500 animate-pulse flex-shrink-0" />
|
||||
<span class="font-medium truncate">{{ getItemName(item) }}</span>
|
||||
<span class="text-muted-foreground hidden sm:inline flex-shrink-0 text-[10px] sm:text-xs">
|
||||
→ {{ t('queue.level') }} {{ item.targetLevel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<span class="text-muted-foreground text-[10px] sm:text-xs whitespace-nowrap">
|
||||
{{ formatTime(getRemainingTime(item)) }}
|
||||
</span>
|
||||
<Button
|
||||
@click="handleCancelResearch(item.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs"
|
||||
>
|
||||
{{ t('queue.cancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress :model-value="getQueueProgress(item)" class="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 建造队列 -->
|
||||
<div
|
||||
v-if="planet && (planet.buildQueue.length > 0 || gameStore.player.researchQueue.length > 0)"
|
||||
class="bg-card border-b px-4 sm:px-6 py-4.5"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- 建造队列 -->
|
||||
<div v-for="item in planet.buildQueue" :key="item.id" class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-xs sm:text-sm gap-2">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500 animate-pulse flex-shrink-0" />
|
||||
<span class="font-medium truncate">{{ getItemName(item) }}</span>
|
||||
<span class="text-muted-foreground hidden sm:inline flex-shrink-0 text-[10px] sm:text-xs">
|
||||
→ {{ t('queue.level') }} {{ item.targetLevel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<span class="text-muted-foreground text-[10px] sm:text-xs whitespace-nowrap">{{ formatTime(getRemainingTime(item)) }}</span>
|
||||
<Button
|
||||
@click="handleCancelBuild(item.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs"
|
||||
>
|
||||
{{ t('queue.cancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress :model-value="getQueueProgress(item)" class="h-1.5" />
|
||||
<!-- 内容区域 -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="animate-fade-in">
|
||||
<RouterView />
|
||||
</div>
|
||||
<!-- 研究队列 -->
|
||||
<div v-for="item in gameStore.player.researchQueue" :key="item.id" class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-xs sm:text-sm gap-2">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1">
|
||||
<div class="h-2 w-2 rounded-full bg-blue-500 animate-pulse flex-shrink-0" />
|
||||
<span class="font-medium truncate">{{ getItemName(item) }}</span>
|
||||
<span class="text-muted-foreground hidden sm:inline flex-shrink-0 text-[10px] sm:text-xs">
|
||||
→ {{ t('queue.level') }} {{ item.targetLevel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<span class="text-muted-foreground text-[10px] sm:text-xs whitespace-nowrap">{{ formatTime(getRemainingTime(item)) }}</span>
|
||||
<Button
|
||||
@click="handleCancelResearch(item.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs"
|
||||
>
|
||||
{{ t('queue.cancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress :model-value="getQueueProgress(item)" class="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="animate-fade-in">
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
|
||||
<!-- 确认对话框 -->
|
||||
<ConfirmDialog ref="confirmDialog" />
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<DetailDialog />
|
||||
</div>
|
||||
|
||||
<!-- Toast 通知 -->
|
||||
<Sonner position="top-center" />
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed, ref } from 'vue'
|
||||
import { RouterView, RouterLink } from 'vue-router'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useUniverseStore } from '@/stores/universeStore'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { localeNames, detectBrowserLocale, type Locale } from '@/locales'
|
||||
@@ -258,12 +250,26 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger
|
||||
} from '@/components/ui/sidebar'
|
||||
import ResourceIcon from '@/components/ResourceIcon.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DetailDialog from '@/components/DetailDialog.vue'
|
||||
import { BuildingType, TechnologyType, ShipType, DefenseType, MissionType } from '@/types/game'
|
||||
import Sonner from '@/components/ui/sonner/Sonner.vue'
|
||||
import { MissionType } from '@/types/game'
|
||||
import type { BuildQueueItem, FleetMission } from '@/types/game'
|
||||
import { BUILDINGS, TECHNOLOGIES, SHIPS, DEFENSES } from '@/config/gameConfig'
|
||||
import { formatNumber, formatTime, getResourceColor } from '@/utils/format'
|
||||
import {
|
||||
Moon,
|
||||
@@ -276,18 +282,15 @@
|
||||
Shield,
|
||||
Mail,
|
||||
Globe,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
X,
|
||||
Users,
|
||||
Swords,
|
||||
Languages,
|
||||
Settings
|
||||
Settings,
|
||||
Wrench,
|
||||
ChevronsLeft
|
||||
} from 'lucide-vue-next'
|
||||
import * as gameLogic from '@/logic/gameLogic'
|
||||
import * as planetLogic from '@/logic/planetLogic'
|
||||
import * as publicLogic from '@/logic/publicLogic'
|
||||
import * as officerLogic from '@/logic/officerLogic'
|
||||
import * as buildingValidation from '@/logic/buildingValidation'
|
||||
import * as resourceLogic from '@/logic/resourceLogic'
|
||||
@@ -295,8 +298,13 @@
|
||||
import * as fleetLogic from '@/logic/fleetLogic'
|
||||
import * as shipLogic from '@/logic/shipLogic'
|
||||
import pkg from '../package.json'
|
||||
import { migrateGameData } from '@/utils/migration'
|
||||
|
||||
// 执行数据迁移(在 store 初始化之前)
|
||||
migrateGameData()
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const universeStore = useUniverseStore()
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const confirmDialog = ref<InstanceType<typeof ConfirmDialog> | null>(null)
|
||||
@@ -304,15 +312,21 @@
|
||||
// 所有可用的语言选项
|
||||
const locales: Locale[] = ['zh-CN', 'zh-TW', 'en', 'de', 'ru', 'ko', 'ja']
|
||||
|
||||
const initGame = () => {
|
||||
// 侧边栏状态(不持久化,根据屏幕尺寸初始化)
|
||||
// PC端(≥1024px)默认打开,移动端默认关闭
|
||||
const sidebarOpen = ref(window.innerWidth >= 1024)
|
||||
|
||||
const initGame = async () => {
|
||||
const shouldInit = gameLogic.shouldInitializeGame(gameStore.player.planets)
|
||||
if (!shouldInit) {
|
||||
const now = Date.now()
|
||||
gameLogic.updatePlanetsLastUpdate(gameStore.player.planets, now)
|
||||
|
||||
// 计算离线收益(直接同步计算)
|
||||
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, now)
|
||||
gameStore.player.planets.forEach(planet => {
|
||||
const key = gameLogic.generatePositionKey(planet.position.galaxy, planet.position.system, planet.position.position)
|
||||
gameStore.universePlanets[key] = planet
|
||||
resourceLogic.updatePlanetResources(planet, now, bonuses)
|
||||
})
|
||||
|
||||
generateNPCPlanets()
|
||||
return
|
||||
}
|
||||
@@ -320,8 +334,6 @@
|
||||
const initialPlanet = planetLogic.createInitialPlanet(gameStore.player.id, t('planet.homePlanet'))
|
||||
gameStore.player.planets = [initialPlanet]
|
||||
gameStore.currentPlanetId = initialPlanet.id
|
||||
const key = gameLogic.generatePositionKey(initialPlanet.position.galaxy, initialPlanet.position.system, initialPlanet.position.position)
|
||||
gameStore.universePlanets[key] = initialPlanet
|
||||
}
|
||||
|
||||
const generateNPCPlanets = () => {
|
||||
@@ -329,9 +341,9 @@
|
||||
for (let i = 0; i < npcCount; i++) {
|
||||
const position = gameLogic.generateRandomPosition()
|
||||
const key = gameLogic.generatePositionKey(position.galaxy, position.system, position.position)
|
||||
if (gameStore.universePlanets[key]) continue
|
||||
if (universeStore.planets[key]) continue
|
||||
const npcPlanet = planetLogic.createNPCPlanet(i, position, t('planet.planetPrefix'))
|
||||
gameStore.universePlanets[key] = npcPlanet
|
||||
universeStore.planets[key] = npcPlanet
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,9 +351,12 @@
|
||||
if (gameStore.isPaused) return
|
||||
const now = Date.now()
|
||||
gameStore.gameTime = now
|
||||
// 检查军官过期
|
||||
gameLogic.checkOfficersExpiration(gameStore.player.officers, now)
|
||||
// 处理游戏更新(建造队列、研究队列等)
|
||||
const result = gameLogic.processGameUpdate(gameStore.player, now)
|
||||
gameStore.player.researchQueue = result.updatedResearchQueue
|
||||
// 处理舰队任务
|
||||
gameStore.player.fleetMissions.forEach(mission => {
|
||||
if (mission.status === 'outbound' && now >= mission.arrivalTime) {
|
||||
processMissionArrival(mission)
|
||||
@@ -351,27 +366,41 @@
|
||||
})
|
||||
}
|
||||
|
||||
const processMissionArrival = (mission: FleetMission) => {
|
||||
const targetPlanet = gameStore.player.planets.find(
|
||||
p =>
|
||||
p.position.galaxy === mission.targetPosition.galaxy &&
|
||||
p.position.system === mission.targetPosition.system &&
|
||||
p.position.position === mission.targetPosition.position
|
||||
const processMissionArrival = async (mission: FleetMission) => {
|
||||
// 从宇宙星球地图中查找目标星球
|
||||
const targetKey = gameLogic.generatePositionKey(
|
||||
mission.targetPosition.galaxy,
|
||||
mission.targetPosition.system,
|
||||
mission.targetPosition.position
|
||||
)
|
||||
// 先从玩家星球中查找,再从宇宙地图中查找
|
||||
const targetPlanet =
|
||||
gameStore.player.planets.find(
|
||||
p =>
|
||||
p.position.galaxy === mission.targetPosition.galaxy &&
|
||||
p.position.system === mission.targetPosition.system &&
|
||||
p.position.position === mission.targetPosition.position
|
||||
) || universeStore.planets[targetKey]
|
||||
|
||||
if (mission.missionType === MissionType.Transport) {
|
||||
fleetLogic.processTransportArrival(mission, targetPlanet)
|
||||
} else if (mission.missionType === MissionType.Attack) {
|
||||
const attackResult = fleetLogic.processAttackArrival(mission, targetPlanet, gameStore.player, null, gameStore.player.planets)
|
||||
const attackResult = await fleetLogic.processAttackArrival(mission, targetPlanet, gameStore.player, null, gameStore.player.planets)
|
||||
if (attackResult) {
|
||||
gameStore.player.battleReports.push(attackResult.battleResult)
|
||||
if (attackResult.moon) {
|
||||
gameStore.player.planets.push(attackResult.moon)
|
||||
}
|
||||
if (attackResult.debrisField) {
|
||||
// 将残骸场添加到游戏状态
|
||||
universeStore.debrisFields[attackResult.debrisField.id] = attackResult.debrisField
|
||||
}
|
||||
}
|
||||
} else if (mission.missionType === MissionType.Colonize) {
|
||||
const newPlanet = fleetLogic.processColonizeArrival(mission, targetPlanet, gameStore.player.id, t('planet.colonyPrefix'))
|
||||
if (newPlanet) gameStore.player.planets.push(newPlanet)
|
||||
if (newPlanet) {
|
||||
gameStore.player.planets.push(newPlanet)
|
||||
}
|
||||
} else if (mission.missionType === MissionType.Spy) {
|
||||
const spyReport = fleetLogic.processSpyArrival(mission, targetPlanet, gameStore.player.id)
|
||||
if (spyReport) gameStore.player.spyReports.push(spyReport)
|
||||
@@ -382,6 +411,42 @@
|
||||
if (missionIndex > -1) gameStore.player.fleetMissions.splice(missionIndex, 1)
|
||||
return
|
||||
}
|
||||
} else if (mission.missionType === MissionType.Recycle) {
|
||||
// 处理回收任务
|
||||
const debrisId = `debris_${mission.targetPosition.galaxy}_${mission.targetPosition.system}_${mission.targetPosition.position}`
|
||||
const debrisField = universeStore.debrisFields[debrisId]
|
||||
const recycleResult = fleetLogic.processRecycleArrival(mission, debrisField)
|
||||
if (recycleResult && debrisField) {
|
||||
if (recycleResult.remainingDebris && (recycleResult.remainingDebris.metal > 0 || recycleResult.remainingDebris.crystal > 0)) {
|
||||
// 更新残骸场
|
||||
universeStore.debrisFields[debrisId] = {
|
||||
id: debrisField.id,
|
||||
position: debrisField.position,
|
||||
resources: recycleResult.remainingDebris,
|
||||
createdAt: debrisField.createdAt,
|
||||
expiresAt: debrisField.expiresAt
|
||||
}
|
||||
} else {
|
||||
// 残骸场已被完全收集,删除
|
||||
delete universeStore.debrisFields[debrisId]
|
||||
}
|
||||
}
|
||||
} else if (mission.missionType === MissionType.Destroy) {
|
||||
// 处理行星毁灭任务
|
||||
const destroyResult = fleetLogic.processDestroyArrival(mission, targetPlanet, gameStore.player)
|
||||
if (destroyResult && destroyResult.success && destroyResult.planetId) {
|
||||
// 星球被摧毁
|
||||
// 从玩家星球列表中移除(如果是玩家的星球)
|
||||
const planetIndex = gameStore.player.planets.findIndex(p => p.id === destroyResult.planetId)
|
||||
if (planetIndex > -1) {
|
||||
gameStore.player.planets.splice(planetIndex, 1)
|
||||
} else {
|
||||
// 不是玩家星球,从宇宙地图中移除
|
||||
delete universeStore.planets[targetKey]
|
||||
}
|
||||
|
||||
// TODO: 可以添加战斗报告或摧毁报告来通知玩家结果
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,27 +459,31 @@
|
||||
if (missionIndex > -1) gameStore.player.fleetMissions.splice(missionIndex, 1)
|
||||
}
|
||||
|
||||
// 游戏循环定时器
|
||||
let gameLoop: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 清理定时器
|
||||
onUnmounted(() => {
|
||||
if (gameLoop) clearInterval(gameLoop)
|
||||
})
|
||||
|
||||
// 初始化游戏
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// 如果是首次访问(没有星球数据),使用浏览器语言自动检测
|
||||
const isFirstVisit = gameStore.player.planets.length === 0
|
||||
if (isFirstVisit) {
|
||||
gameStore.locale = detectBrowserLocale()
|
||||
}
|
||||
|
||||
initGame()
|
||||
|
||||
await initGame()
|
||||
// 启动游戏循环
|
||||
const gameLoop = setInterval(() => {
|
||||
gameLoop = setInterval(() => {
|
||||
updateGame()
|
||||
}, 1000) // 每秒更新一次
|
||||
|
||||
// 清理定时器
|
||||
onUnmounted(() => {
|
||||
clearInterval(gameLoop)
|
||||
})
|
||||
}, 1000) // 每1秒更新一次
|
||||
})
|
||||
|
||||
// 定义 planet computed(需要在 watch 之前定义)
|
||||
const planet = computed(() => gameStore.currentPlanet)
|
||||
|
||||
const navItems = [
|
||||
{ name: computed(() => t('nav.overview')), path: '/', icon: Home },
|
||||
{ name: computed(() => t('nav.buildings')), path: '/buildings', icon: Building2 },
|
||||
@@ -426,23 +495,35 @@
|
||||
{ name: computed(() => t('nav.simulator')), path: '/battle-simulator', icon: Swords },
|
||||
{ name: computed(() => t('nav.galaxy')), path: '/galaxy', icon: Globe },
|
||||
{ name: computed(() => t('nav.messages')), path: '/messages', icon: Mail },
|
||||
{ name: computed(() => t('nav.settings')), path: '/settings', icon: Settings }
|
||||
{ name: computed(() => t('nav.settings')), path: '/settings', icon: Settings },
|
||||
// GM菜单仅在开发模式下显示
|
||||
...(import.meta.env.DEV ? [{ name: computed(() => t('nav.gm')), path: '/gm', icon: Wrench }] : [])
|
||||
]
|
||||
|
||||
const planet = computed(() => gameStore.currentPlanet)
|
||||
const production = computed(() => (planet.value ? publicLogic.getResourceProduction(planet.value, gameStore.player.officers) : null))
|
||||
const capacity = computed(() => (planet.value ? publicLogic.getResourceCapacity(planet.value, gameStore.player.officers) : null))
|
||||
|
||||
// 电量产出和消耗
|
||||
const energyProduction = computed(() => {
|
||||
if (!planet.value) return 0
|
||||
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, Date.now())
|
||||
return resourceLogic.calculateEnergyProduction(planet.value, { energyProductionBonus: bonuses.energyProductionBonus })
|
||||
// 使用直接计算,不再缓存
|
||||
const production = computed(() => {
|
||||
if (!planet.value) return null
|
||||
const now = Date.now()
|
||||
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, now)
|
||||
return resourceLogic.calculateResourceProduction(planet.value, {
|
||||
resourceProductionBonus: bonuses.resourceProductionBonus,
|
||||
darkMatterProductionBonus: bonuses.darkMatterProductionBonus,
|
||||
energyProductionBonus: bonuses.energyProductionBonus
|
||||
})
|
||||
})
|
||||
|
||||
const energyConsumption = computed(() => {
|
||||
if (!planet.value) return 0
|
||||
return resourceLogic.calculateEnergyConsumption(planet.value)
|
||||
const capacity = computed(() => {
|
||||
if (!planet.value) return null
|
||||
const now = Date.now()
|
||||
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, now)
|
||||
return resourceLogic.calculateResourceCapacity(planet.value, bonuses.storageCapacityBonus)
|
||||
})
|
||||
|
||||
// 未读消息数量
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const unreadBattles = gameStore.player.battleReports.filter(r => !r.read).length
|
||||
const unreadSpies = gameStore.player.spyReports.filter(r => !r.read).length
|
||||
return unreadBattles + unreadSpies
|
||||
})
|
||||
|
||||
// 资源类型配置
|
||||
@@ -477,20 +558,20 @@
|
||||
|
||||
// 切换侧边栏
|
||||
const toggleSidebar = () => {
|
||||
gameStore.sidebarCollapsed = !gameStore.sidebarCollapsed
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
// 获取队列项的名称
|
||||
const getItemName = (item: BuildQueueItem): string => {
|
||||
if (item.type === 'building' || item.type === 'demolish') {
|
||||
const buildingName = BUILDINGS[item.itemType as BuildingType]?.name || item.itemType
|
||||
const buildingName = t(`buildings.${item.itemType}`)
|
||||
return item.type === 'demolish' ? `${t('buildingsView.demolish')} - ${buildingName}` : buildingName
|
||||
} else if (item.type === 'technology') {
|
||||
return TECHNOLOGIES[item.itemType as TechnologyType]?.name || item.itemType
|
||||
return t(`technologies.${item.itemType}`)
|
||||
} else if (item.type === 'ship') {
|
||||
return SHIPS[item.itemType as ShipType]?.name || item.itemType
|
||||
return t(`ships.${item.itemType}`)
|
||||
} else if (item.type === 'defense') {
|
||||
return DEFENSES[item.itemType as DefenseType]?.name || item.itemType
|
||||
return t(`defenses.${item.itemType}`)
|
||||
}
|
||||
return item.itemType
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user