feat: 新增战报弹窗与舰队模拟器,重构UI组件

新增 BattleReportDialog、SpyReportDialog、NumberWithTooltip 等组件,完善舰队模拟器功能。重构并引入 Sheet、Sidebar、Tooltip、Skeleton 等 UI 组件,优化界面结构。实现 battle.worker 支持战斗计算,增加 universeStore、fleetStorageLogic 等核心逻辑,完善多语言与类型定义。
This commit is contained in:
谦君
2025-12-13 11:14:23 +08:00
parent 8637e50115
commit 731d79673b
160 changed files with 6302 additions and 1931 deletions

View File

@@ -140,242 +140,24 @@
</div>
<!-- 战斗结果对话框 -->
<Dialog v-model:open="showResultDialog">
<DialogContent class="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Trophy class="h-5 w-5" />
{{ t('simulatorView.battleResult') }}
</DialogTitle>
</DialogHeader>
<div v-if="simulationResult" class="space-y-4">
<!-- 胜利者 -->
<div class="text-center p-4 rounded-lg" :class="getWinnerStyle(simulationResult.winner)">
<p class="text-lg font-bold">
{{
simulationResult.winner === 'attacker'
? t('simulatorView.attackerVictory')
: simulationResult.winner === 'defender'
? t('simulatorView.defenderVictory')
: t('simulatorView.draw')
}}
</p>
<p class="text-sm mt-1">{{ t('simulatorView.afterRounds').replace('{rounds}', String(battleRounds)) }}</p>
</div>
<!-- 损失对比 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 攻击方损失 -->
<div class="space-y-2">
<p class="text-sm font-medium text-red-600 dark:text-red-400">{{ t('simulatorView.attackerLosses') }}</p>
<div class="p-3 bg-muted rounded-lg space-y-1 text-xs">
<div v-for="(count, shipType) in simulationResult.attackerLosses" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<p v-if="Object.keys(simulationResult.attackerLosses).length === 0" class="text-muted-foreground">
{{ t('simulatorView.noLosses') }}
</p>
</div>
</div>
<!-- 防守方损失 -->
<div class="space-y-2">
<p class="text-sm font-medium text-red-600 dark:text-red-400">{{ t('simulatorView.defenderLosses') }}</p>
<div class="p-3 bg-muted rounded-lg space-y-1 text-xs">
<div v-for="(count, shipType) in simulationResult.defenderLosses.fleet" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<div v-for="(count, defenseType) in simulationResult.defenderLosses.defense" :key="defenseType">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<p
v-if="
Object.keys(simulationResult.defenderLosses.fleet).length === 0 &&
Object.keys(simulationResult.defenderLosses.defense).length === 0
"
class="text-muted-foreground"
>
{{ t('simulatorView.noLosses') }}
</p>
</div>
</div>
</div>
<!-- 剩余单位 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 攻击方剩余 -->
<div class="space-y-2">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">{{ t('simulatorView.attackerRemaining') }}</p>
<div class="p-3 bg-muted rounded-lg space-y-1 text-xs">
<div v-for="(count, shipType) in attackerRemaining" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<p v-if="Object.keys(attackerRemaining).length === 0" class="text-muted-foreground">
{{ t('simulatorView.allDestroyed') }}
</p>
</div>
</div>
<!-- 防守方剩余 -->
<div class="space-y-2">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">{{ t('simulatorView.defenderRemaining') }}</p>
<div class="p-3 bg-muted rounded-lg space-y-1 text-xs">
<div v-for="(count, shipType) in defenderRemaining.fleet" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<div v-for="(count, defenseType) in defenderRemaining.defense" :key="defenseType">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<p
v-if="Object.keys(defenderRemaining.fleet).length === 0 && Object.keys(defenderRemaining.defense).length === 0"
class="text-muted-foreground"
>
{{ t('simulatorView.allDestroyed') }}
</p>
</div>
</div>
</div>
<!-- 战利品和残骸 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 掠夺资源 -->
<div
v-if="plunder.metal > 0 || plunder.crystal > 0 || plunder.deuterium > 0"
class="p-3 bg-green-50 dark:bg-green-950 rounded-lg"
>
<p class="text-sm font-medium mb-2 text-green-600 dark:text-green-400">{{ t('simulatorView.plunderableResources') }}</p>
<div class="flex flex-wrap gap-3 text-xs">
<span v-if="plunder.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(plunder.metal) }}
</span>
<span v-if="plunder.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(plunder.crystal) }}
</span>
<span v-if="plunder.deuterium > 0" class="flex items-center gap-1">
<ResourceIcon type="deuterium" size="sm" />
{{ formatNumber(plunder.deuterium) }}
</span>
</div>
</div>
<!-- 残骸场 -->
<div v-if="debrisField.metal > 0 || debrisField.crystal > 0" class="p-3 bg-muted rounded-lg">
<p class="text-sm font-medium mb-2">{{ t('simulatorView.debrisField') }}</p>
<div class="flex flex-wrap gap-3 text-xs">
<span v-if="debrisField.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(debrisField.metal) }}
</span>
<span v-if="debrisField.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(debrisField.crystal) }}
</span>
</div>
<!-- 月球生成概率 -->
<p v-if="moonChance > 0" class="text-xs text-muted-foreground mt-2">{{ t('simulatorView.moonChance') }}: {{ moonChance }}%</p>
</div>
</div>
<!-- 回合详情 -->
<div class="space-y-2">
<Button @click="showRoundDetails = !showRoundDetails" variant="outline" size="sm" class="w-full">
{{ showRoundDetails ? t('simulatorView.hideRoundDetails') : t('simulatorView.showRoundDetails') }}
</Button>
<div v-if="showRoundDetails" class="relative pl-6 space-y-4">
<!-- 时间线 -->
<div class="absolute left-2 top-0 bottom-0 w-0.5 bg-border" />
<div v-for="detail in roundDetails" :key="detail.round" class="relative">
<!-- 时间线节点 -->
<div class="absolute -left-6 top-3 w-4 h-4 rounded-full bg-primary border-2 border-background" />
<!-- 回合内容卡片 -->
<div class="border rounded-lg p-3 bg-card hover:shadow-md transition-shadow">
<div class="flex items-center justify-between mb-3">
<p class="text-sm font-semibold">{{ t('simulatorView.round').replace('{round}', String(detail.round)) }}</p>
<div class="flex gap-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1" :title="t('simulatorView.attackerRemainingPower')">
<Sword class="h-3 w-3" />
{{ formatNumber(detail.attackerRemainingPower) }}
</span>
<span class="flex items-center gap-1" :title="t('simulatorView.defenderRemainingPower')">
<Shield class="h-3 w-3" />
{{ formatNumber(detail.defenderRemainingPower) }}
</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- 攻击方本回合损失 -->
<div class="bg-red-50 dark:bg-red-950/20 rounded p-2">
<p class="text-xs font-medium text-red-600 dark:text-red-400 mb-1.5">{{ t('simulatorView.attackerLosses') }}</p>
<div class="text-xs space-y-0.5">
<div v-for="(count, shipType) in detail.attackerLosses" :key="shipType" class="flex justify-between">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}</span>
<span class="font-medium">-{{ count }}</span>
</div>
<p v-if="Object.keys(detail.attackerLosses).length === 0" class="text-muted-foreground italic">
{{ t('simulatorView.noLosses') }}
</p>
</div>
</div>
<!-- 防守方本回合损失 -->
<div class="bg-blue-50 dark:bg-blue-950/20 rounded p-2">
<p class="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1.5">{{ t('simulatorView.defenderLosses') }}</p>
<div class="text-xs space-y-0.5">
<div v-for="(count, shipType) in detail.defenderLosses.fleet" :key="shipType" class="flex justify-between">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}</span>
<span class="font-medium">-{{ count }}</span>
</div>
<div v-for="(count, defenseType) in detail.defenderLosses.defense" :key="defenseType" class="flex justify-between">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}</span>
<span class="font-medium">-{{ count }}</span>
</div>
<p
v-if="
Object.keys(detail.defenderLosses.fleet).length === 0 && Object.keys(detail.defenderLosses.defense).length === 0
"
class="text-muted-foreground italic"
>
{{ t('simulatorView.noLosses') }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<BattleReportDialog v-model:open="showResultDialog" :report="simulationResult" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, toRaw } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ShipType, DefenseType } from '@/types/game'
import type { Fleet, BattleResult, Resources } from '@/types/game'
import { simulateBattle, calculatePlunder, calculateDebrisField } from '@/utils/battleSimulator'
import type { Fleet, BattleResult } from '@/types/game'
import { workerManager } from '@/workers/workerManager'
import ResourceIcon from '@/components/ResourceIcon.vue'
import { formatNumber } from '@/utils/format'
import { Sword, Shield, Zap, RotateCcw, Trophy } from 'lucide-vue-next'
import BattleReportDialog from '@/components/BattleReportDialog.vue'
import { Sword, Shield, Zap, RotateCcw } from 'lucide-vue-next'
import * as planetLogic from '@/logic/planetLogic'
const { t } = useI18n()
@@ -444,78 +226,45 @@
// 模拟结果
const simulationResult = ref<BattleResult | null>(null)
const battleRounds = ref<number>(0)
const attackerRemaining = ref<Partial<Fleet>>({})
const defenderRemaining = ref<{ fleet: Partial<Fleet>; defense: Partial<Record<DefenseType, number>> }>({
fleet: {},
defense: {}
})
const roundDetails = ref<
Array<{
round: number
attackerLosses: Partial<Fleet>
defenderLosses: {
fleet: Partial<Fleet>
defense: Partial<Record<DefenseType, number>>
}
attackerRemainingPower: number
defenderRemainingPower: number
}>
>([])
const showRoundDetails = ref<boolean>(false)
const showResultDialog = ref<boolean>(false)
// 计算掠夺资源
const plunder = computed(() => {
if (!simulationResult.value || simulationResult.value.winner !== 'attacker') {
return { metal: 0, crystal: 0, deuterium: 0, darkMatter: 0, energy: 0 }
}
return calculatePlunder(defenderResources.value, attackerFleet.value)
})
// 计算残骸场
const debrisField = computed(() => {
if (!simulationResult.value) {
return { metal: 0, crystal: 0, deuterium: 0, darkMatter: 0, energy: 0 }
}
return calculateDebrisField(simulationResult.value.attackerLosses, simulationResult.value.defenderLosses)
})
const calculateMoonChance = (debrisField: Resources): number => {
return planetLogic.calculateMoonChance(debrisField)
}
// 计算月球生成概率
const moonChance = computed(() => {
if (!debrisField.value) return 0
return calculateMoonChance(debrisField.value)
})
// 运行模拟
const runSimulation = () => {
// 运行模拟(使用 Web Worker 进行计算)
const runSimulation = async () => {
// 使用 toRaw 将 Vue 响应式对象转换为普通对象,以便传递给 Worker
const attackerSide = {
ships: attackerFleet.value,
ships: toRaw(attackerFleet.value),
weaponTech: attackerTech.value.weapon,
shieldTech: attackerTech.value.shield,
armorTech: attackerTech.value.armor
}
const defenderSide = {
ships: defenderFleet.value,
defense: defenderDefense.value,
ships: toRaw(defenderFleet.value),
defense: toRaw(defenderDefense.value),
weaponTech: defenderTech.value.weapon,
shieldTech: defenderTech.value.shield,
armorTech: defenderTech.value.armor
}
const result = simulateBattle(attackerSide, defenderSide)
// 使用 Worker 执行战斗模拟
const result = await workerManager.simulateBattle({
attacker: attackerSide,
defender: defenderSide
})
// 保存回合数和剩余单位
battleRounds.value = result.rounds
attackerRemaining.value = result.attackerRemaining
defenderRemaining.value = result.defenderRemaining
roundDetails.value = result.roundDetails
showRoundDetails.value = false
// 计算掠夺和残骸场
const plunder =
result.winner === 'attacker'
? await workerManager.calculatePlunder({
defenderResources: toRaw(defenderResources.value),
attackerFleet: result.attackerRemaining
})
: { metal: 0, crystal: 0, deuterium: 0, darkMatter: 0, energy: 0 }
const debrisField = await workerManager.calculateDebris({
attackerLosses: result.attackerLosses,
defenderLosses: result.defenderLosses
})
const moonChance = planetLogic.calculateMoonChance(debrisField) / 100 // 转换为 0-1 范围
simulationResult.value = {
id: `sim_${Date.now()}`,
@@ -530,8 +279,13 @@
attackerLosses: result.attackerLosses,
defenderLosses: result.defenderLosses,
winner: result.winner,
plunder: plunder.value,
debrisField: debrisField.value
plunder,
debrisField,
rounds: result.rounds,
attackerRemaining: result.attackerRemaining,
defenderRemaining: result.defenderRemaining,
roundDetails: result.roundDetails,
moonChance
}
// 显示结果对话框
@@ -552,18 +306,6 @@
attackerTech.value = { weapon: 0, shield: 0, armor: 0 }
defenderTech.value = { weapon: 0, shield: 0, armor: 0 }
simulationResult.value = null
battleRounds.value = 0
attackerRemaining.value = {}
defenderRemaining.value = { fleet: {}, defense: {} }
roundDetails.value = []
showRoundDetails.value = false
showResultDialog.value = false
}
// 获取胜利者样式
const getWinnerStyle = (winner: string) => {
if (winner === 'attacker') return 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300'
if (winner === 'defender') return 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300'
return 'bg-gray-50 dark:bg-gray-950 text-gray-700 dark:text-gray-300'
}
</script>

View File

@@ -11,7 +11,10 @@
</div>
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<Card v-for="buildingType in availableBuildings" :key="buildingType">
<Card v-for="buildingType in availableBuildings" :key="buildingType" class="relative">
<!-- 前置条件遮罩 -->
<CardUnlockOverlay :requirements="BUILDINGS[buildingType].requirements" :currentLevel="getBuildingLevel(buildingType)" />
<CardHeader>
<div class="flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
@@ -89,7 +92,7 @@
<!-- 升级按钮 -->
<Button @click="handleUpgrade(buildingType)" :disabled="!canUpgrade(buildingType)" class="w-full">
{{ t('buildingsView.upgrade') }}
{{ getUpgradeButtonText(buildingType) }}
</Button>
<!-- 拆除按钮 -->
@@ -128,22 +131,24 @@
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { computed, ref } from 'vue'
import { BuildingType } from '@/types/game'
import { BuildingType, TechnologyType } from '@/types/game'
import type { Resources, Planet } from '@/types/game'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import ResourceIcon from '@/components/ResourceIcon.vue'
import CardUnlockOverlay from '@/components/CardUnlockOverlay.vue'
import AlertDialog from '@/components/AlertDialog.vue'
import { Clock, Grid3x3 } from 'lucide-vue-next'
import { formatNumber, formatTime, getResourceCostColor } from '@/utils/format'
import * as buildingLogic from '@/logic/buildingLogic'
import * as buildingValidation from '@/logic/buildingValidation'
import * as publicLogic from '@/logic/publicLogic'
const gameStore = useGameStore()
const detailDialog = useDetailDialogStore()
const { t } = useI18n()
const { BUILDINGS } = useGameConfig()
const { BUILDINGS, TECHNOLOGIES } = useGameConfig()
const planet = computed(() => gameStore.currentPlanet)
const alertDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
@@ -182,6 +187,15 @@
// 升级建筑
const handleUpgrade = (buildingType: BuildingType) => {
// 检查前置条件
if (!checkUpgradeRequirements(buildingType)) {
alertDialog.value?.show({
title: t('common.requirementsNotMet'),
message: getRequirementsList(buildingType)
})
return
}
const success = upgradeBuilding(buildingType)
if (!success) {
alertDialog.value?.show({
@@ -196,12 +210,98 @@
return planet.value?.buildings[buildingType] || 0
}
// 检查升级前置条件是否满足
const checkUpgradeRequirements = (buildingType: BuildingType): boolean => {
if (!planet.value) return false
const config = BUILDINGS.value[buildingType]
const currentLevel = getBuildingLevel(buildingType)
const targetLevel = currentLevel + 1
// 获取目标等级的所有前置条件(包括等级门槛)
const requirements = publicLogic.getLevelRequirements(config, targetLevel)
if (!requirements || Object.keys(requirements).length === 0) return true
return publicLogic.checkRequirements(planet.value, gameStore.player.technologies, requirements)
}
// 获取升级按钮文本
const getUpgradeButtonText = (buildingType: BuildingType): string => {
if (!planet.value) return t('buildingsView.upgrade')
const config = BUILDINGS.value[buildingType]
const currentLevel = getBuildingLevel(buildingType)
// 检查是否达到等级上限
if (config.maxLevel !== undefined && currentLevel >= config.maxLevel) {
return t('buildingsView.maxLevelReached') // "等级已满"
}
if (planet.value.buildQueue.length > 0) return t('buildingsView.upgrade')
// 检查前置条件
if (!checkUpgradeRequirements(buildingType)) {
return t('buildingsView.requirementsNotMet')
}
return t('buildingsView.upgrade')
}
// 获取前置条件列表文本
const getRequirementsList = (buildingType: BuildingType): string => {
const config = BUILDINGS.value[buildingType]
const currentLevel = getBuildingLevel(buildingType)
const targetLevel = currentLevel + 1
// 获取目标等级的所有前置条件(包括等级门槛)
const requirements = publicLogic.getLevelRequirements(config, targetLevel)
if (!requirements || !planet.value) return ''
const lines: string[] = []
for (const [key, requiredLevel] of Object.entries(requirements)) {
// 检查是否为建筑类型
if (Object.values(BuildingType).includes(key as BuildingType)) {
const bt = key as BuildingType
const currentLevel = planet.value.buildings[bt] || 0
const name = BUILDINGS.value[bt]?.name || bt
const status = currentLevel >= requiredLevel ? '✓' : '✗'
lines.push(`${status} ${name}: Lv ${requiredLevel} (${t('common.current')}: Lv ${currentLevel})`)
}
// 检查是否为科技类型
else if (Object.values(TechnologyType).includes(key as TechnologyType)) {
const tt = key as TechnologyType
const currentLevel = gameStore.player.technologies[tt] || 0
const name = TECHNOLOGIES.value[tt]?.name || tt
const status = currentLevel >= requiredLevel ? '✓' : '✗'
lines.push(`${status} ${name}: Lv ${requiredLevel} (${t('common.current')}: Lv ${currentLevel})`)
}
}
return lines.join('\n')
}
// 检查是否可以升级
const canUpgrade = (buildingType: BuildingType): boolean => {
if (!planet.value) return false
const config = BUILDINGS.value[buildingType]
const currentLevel = getBuildingLevel(buildingType)
// 检查是否达到等级上限
if (config.maxLevel !== undefined && currentLevel >= config.maxLevel) {
return false
}
if (planet.value.buildQueue.length > 0) return false
const currentLevel = getBuildingLevel(buildingType)
// 检查前置条件
const validation = buildingValidation.validateBuildingUpgrade(
planet.value,
buildingType,
gameStore.player.technologies,
gameStore.player.officers
)
if (!validation.valid) return false
const cost = getBuildingCost(buildingType, currentLevel + 1)
return (

View File

@@ -183,7 +183,8 @@
[DefenseType.IonCannon]: 0,
[DefenseType.PlasmaTurret]: 0,
[DefenseType.SmallShieldDome]: 0,
[DefenseType.LargeShieldDome]: 0
[DefenseType.LargeShieldDome]: 0,
[DefenseType.PlanetaryShield]: 0
})
// 判断是否为护盾罩

View File

@@ -296,9 +296,10 @@
<script setup lang="ts">
import { useGameStore } from '@/stores/gameStore'
import { useUniverseStore } from '@/stores/universeStore'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { computed, ref, onMounted } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ShipType, MissionType, BuildingType } from '@/types/game'
import type { Fleet, Resources } from '@/types/game'
@@ -311,7 +312,7 @@
import ResourceIcon from '@/components/ResourceIcon.vue'
import AlertDialog from '@/components/AlertDialog.vue'
import UnlockRequirement from '@/components/UnlockRequirement.vue'
import { Sword, Package, Rocket as RocketIcon, Eye, Users } from 'lucide-vue-next'
import { Sword, Package, Rocket as RocketIcon, Eye, Users, Recycle, Skull } from 'lucide-vue-next'
import { formatNumber, formatTime } from '@/utils/format'
import * as shipValidation from '@/logic/shipValidation'
import * as fleetLogic from '@/logic/fleetLogic'
@@ -322,11 +323,16 @@
const route = useRoute()
const router = useRouter()
const gameStore = useGameStore()
const universeStore = useUniverseStore()
const { t } = useI18n()
const { SHIPS } = useGameConfig()
const planet = computed(() => gameStore.currentPlanet)
const alertDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
// 当前时间(响应式)
const currentTime = ref(Date.now())
let timeInterval: number | null = null
// 计算最大舰队任务槽位
const maxFleetMissions = computed(() => {
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, Date.now())
@@ -345,7 +351,9 @@
[ShipType.LargeCargo]: 0,
[ShipType.ColonyShip]: 0,
[ShipType.Recycler]: 0,
[ShipType.EspionageProbe]: 0
[ShipType.EspionageProbe]: 0,
[ShipType.DarkMatterHarvester]: 0,
[ShipType.Deathstar]: 0
})
// 目标坐标
@@ -359,6 +367,11 @@
// 从 URL query 参数初始化
onMounted(() => {
// 启动定时器更新当前时间
timeInterval = window.setInterval(() => {
currentTime.value = Date.now()
}, 1000) // 每秒更新一次
const { galaxy, system, position, mission } = route.query
// 如果有参数,填充数据
@@ -385,13 +398,22 @@
}
})
// 清理定时器
onUnmounted(() => {
if (timeInterval) {
clearInterval(timeInterval)
}
})
// 可用任务类型
const availableMissions = computed(() => [
{ type: MissionType.Attack, name: t('fleetView.attackMission'), icon: Sword },
{ type: MissionType.Transport, name: t('fleetView.transport'), icon: Package },
{ type: MissionType.Colonize, name: t('fleetView.colonize'), icon: RocketIcon },
{ type: MissionType.Spy, name: t('fleetView.spy'), icon: Eye },
{ type: MissionType.Deploy, name: t('fleetView.deploy'), icon: Users }
{ type: MissionType.Deploy, name: t('fleetView.deploy'), icon: Users },
{ type: MissionType.Recycle, name: t('fleetView.recycle'), icon: Recycle },
{ type: MissionType.Destroy, name: t('fleetView.destroy'), icon: Skull }
])
// 获取任务名称
@@ -439,24 +461,53 @@
}
// 检查是否可以派遣
const canSendFleet = (): boolean => {
const canSendFleet = (): { valid: boolean; errorKey?: string } => {
// 检查是否选择了舰船
const hasShips = Object.values(selectedFleet.value).some(count => count > 0)
if (!hasShips) return false
if (!hasShips) return { valid: false, errorKey: 'fleetView.noShipsSelected' }
// 检查是否派遣到自己的星球
if (planet.value) {
const isSamePlanet =
targetPosition.value.galaxy === planet.value.position.galaxy &&
targetPosition.value.system === planet.value.position.system &&
targetPosition.value.position === planet.value.position.position
if (isSamePlanet) {
return { valid: false, errorKey: 'fleetView.cannotSendToOwnPlanet' }
}
}
// 检查载货量
if (selectedMission.value === MissionType.Transport) {
if (getTotalCargo() > getTotalCargoCapacity()) return false
if (getTotalCargo() > getTotalCargoCapacity()) {
return { valid: false, errorKey: 'fleetView.cargoExceedsCapacity' }
}
}
// 检查殖民船
if (selectedMission.value === MissionType.Colonize) {
if (!selectedFleet.value[ShipType.ColonyShip] || (selectedFleet.value[ShipType.ColonyShip] ?? 0) < 1) {
return false
return { valid: false, errorKey: 'fleetView.noColonyShip' }
}
}
return true
// 检查回收任务是否有残骸
if (selectedMission.value === MissionType.Recycle) {
const debrisId = `debris_${targetPosition.value.galaxy}_${targetPosition.value.system}_${targetPosition.value.position}`
const debrisField = universeStore.debrisFields[debrisId]
if (!debrisField || (debrisField.resources.metal === 0 && debrisField.resources.crystal === 0)) {
return { valid: false, errorKey: 'fleetView.noDebrisAtTarget' }
}
}
// 检查毁灭任务是否有死星
if (selectedMission.value === MissionType.Destroy) {
if (!selectedFleet.value[ShipType.Deathstar] || (selectedFleet.value[ShipType.Deathstar] ?? 0) < 1) {
return { valid: false, errorKey: 'fleetView.noDeathstar' }
}
}
return { valid: true }
}
const sendFleet = (
@@ -498,6 +549,16 @@
const handleSendFleet = () => {
if (!planet.value) return
// 验证是否可以派遣
const validation = canSendFleet()
if (!validation.valid) {
alertDialog.value?.show({
title: t('fleetView.sendFailed'),
message: validation.errorKey ? t(validation.errorKey) : t('fleetView.sendFailedMessage')
})
return
}
// 过滤出实际选择的舰船
const fleet: Partial<Fleet> = {}
for (const [shipType, count] of Object.entries(selectedFleet.value)) {
@@ -547,23 +608,23 @@
// 获取任务剩余时间
const getRemainingTime = (mission: any): number => {
const now = Date.now()
const now = currentTime.value
const targetTime = mission.status === 'outbound' ? mission.arrivalTime : mission.returnTime
return Math.max(0, (targetTime - now) / 1000)
}
// 获取任务进度
const getMissionProgress = (mission: any): number => {
const now = Date.now()
const now = currentTime.value
if (mission.status === 'outbound') {
const total = mission.arrivalTime - mission.departureTime
const elapsed = now - mission.departureTime
return Math.min(100, (elapsed / total) * 100)
return Math.max(0, Math.min(100, (elapsed / total) * 100))
} else {
const departTime = mission.arrivalTime
const total = mission.returnTime - departTime
const elapsed = now - departTime
return Math.min(100, (elapsed / total) * 100)
return Math.max(0, Math.min(100, (elapsed / total) * 100))
}
}
</script>

291
src/views/GMView.vue Normal file
View File

@@ -0,0 +1,291 @@
<template>
<div class="container mx-auto p-4 sm:p-6 space-y-4 sm:space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl sm:text-3xl font-bold">{{ t('gmView.title') }}</h1>
<Badge variant="destructive">{{ t('gmView.adminOnly') }}</Badge>
</div>
<!-- 星球选择 -->
<Card>
<CardHeader>
<CardTitle>{{ t('gmView.selectPlanet') }}</CardTitle>
</CardHeader>
<CardContent>
<Select v-model="selectedPlanetId">
<SelectTrigger>
<SelectValue :placeholder="t('gmView.choosePlanet')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="planet in gameStore.player.planets" :key="planet.id" :value="planet.id">
{{ planet.name }} ({{ planet.position.galaxy }}:{{ planet.position.system }}:{{ planet.position.position }})
</SelectItem>
</SelectContent>
</Select>
</CardContent>
</Card>
<!-- 标签切换 -->
<div v-if="selectedPlanet" class="flex flex-wrap gap-2 border-b">
<Button @click="activeTab = 'resources'" :variant="activeTab === 'resources' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.resources') }}
</Button>
<Button @click="activeTab = 'buildings'" :variant="activeTab === 'buildings' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.buildings') }}
</Button>
<Button @click="activeTab = 'research'" :variant="activeTab === 'research' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.research') }}
</Button>
<Button @click="activeTab = 'ships'" :variant="activeTab === 'ships' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.ships') }}
</Button>
<Button @click="activeTab = 'defense'" :variant="activeTab === 'defense' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.defense') }}
</Button>
<Button @click="activeTab = 'officers'" :variant="activeTab === 'officers' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.officers') }}
</Button>
</div>
<!-- 资源 -->
<div v-if="selectedPlanet && activeTab === 'resources'" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>{{ t('gmView.modifyResources') }}</CardTitle>
<CardDescription>{{ t('gmView.resourcesDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div v-for="resource in resourceTypes" :key="resource" class="space-y-2">
<Label>{{ t(`resources.${resource}`) }}</Label>
<div class="flex gap-2">
<Input v-model.number="selectedPlanet.resources[resource]" type="number" min="0" class="flex-1" />
<Button @click="setResourceAmount(resource, 1000000)" variant="outline" size="sm">+1M</Button>
<Button @click="setResourceAmount(resource, 10000000)" variant="outline" size="sm">+10M</Button>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- 建筑 -->
<div v-if="selectedPlanet && activeTab === 'buildings'" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>{{ t('gmView.modifyBuildings') }}</CardTitle>
<CardDescription>{{ t('gmView.buildingsDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="building in buildingTypes" :key="building" class="space-y-2">
<Label>{{ BUILDINGS[building].name }}</Label>
<div class="flex gap-2">
<Input v-model.number="selectedPlanet.buildings[building]" type="number" min="0" max="100" class="flex-1" />
<Button @click="setBuildingLevel(building, 10)" variant="outline" size="sm">Lv 10</Button>
<Button @click="setBuildingLevel(building, 30)" variant="outline" size="sm">Lv 30</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- 科技 -->
<div v-if="activeTab === 'research'" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>{{ t('gmView.modifyResearch') }}</CardTitle>
<CardDescription>{{ t('gmView.researchDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="tech in technologyTypes" :key="tech" class="space-y-2">
<Label>{{ TECHNOLOGIES[tech].name }}</Label>
<div class="flex gap-2">
<Input v-model.number="gameStore.player.technologies[tech]" type="number" min="0" max="50" class="flex-1" />
<Button @click="setTechnologyLevel(tech, 10)" variant="outline" size="sm">Lv 10</Button>
<Button @click="setTechnologyLevel(tech, 20)" variant="outline" size="sm">Lv 20</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- 舰船 -->
<div v-if="selectedPlanet && activeTab === 'ships'" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>{{ t('gmView.modifyShips') }}</CardTitle>
<CardDescription>{{ t('gmView.shipsDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="ship in shipTypes" :key="ship" class="space-y-2">
<Label>{{ SHIPS[ship].name }}</Label>
<div class="flex gap-2">
<Input v-model.number="selectedPlanet.fleet[ship]" type="number" min="0" class="flex-1" />
<Button @click="setShipCount(ship, 100)" variant="outline" size="sm">+100</Button>
<Button @click="setShipCount(ship, 1000)" variant="outline" size="sm">+1K</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- 防御 -->
<div v-if="selectedPlanet && activeTab === 'defense'" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>{{ t('gmView.modifyDefense') }}</CardTitle>
<CardDescription>{{ t('gmView.defenseDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="defense in defenseTypes" :key="defense" class="space-y-2">
<Label>{{ DEFENSES[defense].name }}</Label>
<div class="flex gap-2">
<Input v-model.number="selectedPlanet.defense[defense]" type="number" min="0" class="flex-1" />
<Button @click="setDefenseCount(defense, 100)" variant="outline" size="sm">+100</Button>
<Button @click="setDefenseCount(defense, 1000)" variant="outline" size="sm">+1K</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- 军官 -->
<div v-if="activeTab === 'officers'" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>{{ t('gmView.modifyOfficers') }}</CardTitle>
<CardDescription>{{ t('gmView.officersDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="officer in officerTypes" :key="officer" class="space-y-2">
<Label>{{ OFFICERS[officer].name }}</Label>
<div class="flex gap-2">
<Input v-model.number="officerDays[officer]" type="number" min="0" :placeholder="t('gmView.days')" class="flex-1" />
<Button @click="setOfficerDays(officer, 7)" variant="outline" size="sm">7{{ t('gmView.days') }}</Button>
<Button @click="setOfficerDays(officer, 30)" variant="outline" size="sm">30{{ t('gmView.days') }}</Button>
<Button @click="setOfficerDays(officer, 365)" variant="outline" size="sm">365{{ t('gmView.days') }}</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- 危险操作 -->
<Card class="border-destructive">
<CardHeader>
<CardTitle class="text-destructive">{{ t('gmView.dangerZone') }}</CardTitle>
<CardDescription>{{ t('gmView.dangerZoneDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-2">
<Button @click="resetGame" variant="destructive" class="w-full">{{ t('gmView.resetGame') }}</Button>
</CardContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useGameStore } from '@/stores/gameStore'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { BuildingType, TechnologyType, ShipType, DefenseType, OfficerType } from '@/types/game'
const gameStore = useGameStore()
const { t } = useI18n()
const { BUILDINGS, TECHNOLOGIES, SHIPS, DEFENSES, OFFICERS } = useGameConfig()
const selectedPlanetId = ref<string>(gameStore.player.planets[0]?.id || '')
const activeTab = ref<'resources' | 'buildings' | 'research' | 'ships' | 'defense' | 'officers'>('resources')
const officerDays = ref<Record<OfficerType, number>>({} as Record<OfficerType, number>)
// 初始化军官天数显示
Object.values(OfficerType).forEach(officer => {
const officerData = gameStore.player.officers[officer]
if (officerData && officerData.expiresAt) {
const daysLeft = Math.ceil((officerData.expiresAt - Date.now()) / (1000 * 60 * 60 * 24))
officerDays.value[officer] = Math.max(0, daysLeft)
} else {
officerDays.value[officer] = 0
}
})
const selectedPlanet = computed(() => {
return gameStore.player.planets.find(p => p.id === selectedPlanetId.value)
})
const resourceTypes = ['metal', 'crystal', 'deuterium', 'darkMatter'] as const
const buildingTypes = Object.values(BuildingType)
const technologyTypes = Object.values(TechnologyType)
const shipTypes = Object.values(ShipType)
const defenseTypes = Object.values(DefenseType)
const officerTypes = Object.values(OfficerType)
const setResourceAmount = (resource: string, amount: number) => {
if (selectedPlanet.value) {
selectedPlanet.value.resources[resource as keyof typeof selectedPlanet.value.resources] += amount
}
}
const setBuildingLevel = (building: BuildingType, level: number) => {
if (selectedPlanet.value) {
selectedPlanet.value.buildings[building] = level
}
}
const setTechnologyLevel = (tech: TechnologyType, level: number) => {
gameStore.player.technologies[tech] = level
}
const setShipCount = (ship: ShipType, count: number) => {
if (selectedPlanet.value) {
selectedPlanet.value.fleet[ship] = (selectedPlanet.value.fleet[ship] || 0) + count
}
}
const setDefenseCount = (defense: DefenseType, count: number) => {
if (selectedPlanet.value) {
selectedPlanet.value.defense[defense] = (selectedPlanet.value.defense[defense] || 0) + count
}
}
const setOfficerDays = (officer: OfficerType, days: number) => {
officerDays.value[officer] = days
const now = Date.now()
const expiresAt = now + days * 24 * 60 * 60 * 1000
if (!gameStore.player.officers[officer]) {
gameStore.player.officers[officer] = {
type: officer,
active: true,
hiredAt: now,
expiresAt: expiresAt
}
} else {
gameStore.player.officers[officer].expiresAt = expiresAt
gameStore.player.officers[officer].active = true
if (!gameStore.player.officers[officer].hiredAt) {
gameStore.player.officers[officer].hiredAt = now
}
}
}
const resetGame = () => {
if (confirm(t('gmView.resetGameConfirm'))) {
localStorage.clear()
location.reload()
}
}
</script>

View File

@@ -94,50 +94,79 @@
</p>
</div>
<div v-else class="text-sm text-muted-foreground">{{ t('galaxyView.emptySlot') }}</div>
<!-- 残骸场信息 -->
<div v-if="getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)" class="mt-2 p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded text-xs">
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-400 font-medium mb-1">
<span>{{ t('galaxyView.debrisField') }}</span>
</div>
<div class="flex gap-3 text-xs">
<span class="flex items-center gap-1">
<span class="text-muted-foreground">{{ t('resources.metal') }}:</span>
<span class="font-medium">{{ formatNumber(getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)!.resources.metal) }}</span>
</span>
<span class="flex items-center gap-1">
<span class="text-muted-foreground">{{ t('resources.crystal') }}:</span>
<span class="font-medium">{{ formatNumber(getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)!.resources.crystal) }}</span>
</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-1 sm:gap-2 flex-shrink-0">
<Button
v-if="slot.planet && !isMyPlanet(slot.planet)"
@click="showPlanetActions(slot.planet, 'spy')"
variant="outline"
size="sm"
class="h-8 w-8 p-0"
:title="t('galaxyView.scout')"
>
<Eye class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
v-if="slot.planet && !isMyPlanet(slot.planet)"
@click="showPlanetActions(slot.planet, 'attack')"
variant="outline"
size="sm"
class="h-8 w-8 p-0"
:title="t('galaxyView.attack')"
>
<Sword class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
v-if="!slot.planet"
@click="showPlanetActions(null, 'colonize', slot.position)"
variant="outline"
size="sm"
class="h-8 w-8 p-0"
:title="t('galaxyView.colonize')"
>
<Rocket class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
v-if="slot.planet && isMyPlanet(slot.planet)"
@click="switchToPlanet(slot.planet.id)"
variant="outline"
size="sm"
class="h-8 w-8 p-0"
:title="t('galaxyView.switch')"
>
<Home class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<TooltipProvider :delay-duration="300">
<Tooltip v-if="slot.planet && !isMyPlanet(slot.planet)">
<TooltipTrigger as-child>
<Button @click="showPlanetActions(slot.planet, 'spy')" variant="outline" size="sm" class="h-8 w-8 p-0">
<Eye class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('galaxyView.scout') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip v-if="slot.planet && !isMyPlanet(slot.planet)">
<TooltipTrigger as-child>
<Button @click="showPlanetActions(slot.planet, 'attack')" variant="outline" size="sm" class="h-8 w-8 p-0">
<Sword class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('galaxyView.attack') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip v-if="!slot.planet">
<TooltipTrigger as-child>
<Button @click="showPlanetActions(null, 'colonize', slot.position)" variant="outline" size="sm" class="h-8 w-8 p-0">
<Rocket class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('galaxyView.colonize') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip v-if="slot.planet && isMyPlanet(slot.planet)">
<TooltipTrigger as-child>
<Button @click="switchToPlanet(slot.planet.id)" variant="outline" size="sm" class="h-8 w-8 p-0">
<Home class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('galaxyView.switch') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip v-if="getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)">
<TooltipTrigger as-child>
<Button @click="showPlanetActions(slot.planet, 'recycle', slot.position)" variant="outline" size="sm" class="h-8 w-8 p-0">
<Recycle class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('galaxyView.recycle') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
@@ -151,20 +180,24 @@
<script setup lang="ts">
import { useGameStore } from '@/stores/gameStore'
import { useUniverseStore } from '@/stores/universeStore'
import { useI18n } from '@/composables/useI18n'
import { ref, onMounted } from 'vue'
import type { Planet } from '@/types/game'
import type { Planet, DebrisField } from '@/types/game'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import AlertDialog from '@/components/AlertDialog.vue'
import { Home, Eye, Sword, Rocket } from 'lucide-vue-next'
import { Home, Eye, Sword, Rocket, Recycle } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import * as gameLogic from '@/logic/gameLogic'
import { formatNumber } from '@/utils/format'
const gameStore = useGameStore()
const universeStore = useUniverseStore()
const router = useRouter()
const { t } = useI18n()
const actionDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
@@ -191,11 +224,22 @@
const positions = gameLogic.generateSystemPositions(galaxy, system)
return positions.map(pos => {
const key = gameLogic.generatePositionKey(galaxy, system, pos.position)
const planet = gameStore.universePlanets[key] || null
// 先从玩家星球中查找,再从宇宙地图中查找
const planet = gameStore.player.planets.find(p =>
p.position.galaxy === galaxy &&
p.position.system === system &&
p.position.position === pos.position
) || universeStore.planets[key] || null
return { position: pos.position, planet }
})
}
// 获取指定位置的残骸场
const getDebrisFieldAt = (galaxy: number, system: number, position: number): DebrisField | null => {
const debrisId = `debris_${galaxy}_${system}_${position}`
return universeStore.debrisFields[debrisId] || null
}
// 加载星系
const loadSystem = () => {
currentGalaxy.value = selectedGalaxy.value
@@ -223,11 +267,11 @@
// 切换到指定星球
const switchToPlanet = (planetId: string) => {
gameStore.currentPlanetId = planetId
router.push('/overview')
router.push('/')
}
// 显示星球操作
const showPlanetActions = (planet: Planet | null, action: 'spy' | 'attack' | 'colonize', position?: number) => {
const showPlanetActions = (planet: Planet | null, action: 'spy' | 'attack' | 'colonize' | 'recycle', position?: number) => {
const targetPos = planet ? planet.position : { galaxy: currentGalaxy.value, system: currentSystem.value, position: position! }
const coordinates = `${targetPos.galaxy}:${targetPos.system}:${targetPos.position}`
@@ -242,6 +286,9 @@
} else if (action === 'colonize') {
title = t('galaxyView.colonizePlanetTitle')
message = t('galaxyView.colonizePlanetMessage').replace('{coordinates}', coordinates)
} else if (action === 'recycle') {
title = t('galaxyView.recyclePlanetTitle')
message = t('galaxyView.recyclePlanetMessage').replace('{coordinates}', coordinates)
}
actionDialog.value?.show({

View File

@@ -1,239 +1,119 @@
<template>
<div class="container mx-auto p-4 sm:p-6 space-y-4 sm:space-y-6">
<div class="container mx-auto p-4 sm:p-6 space-y-6">
<h1 class="text-2xl sm:text-3xl font-bold">{{ t('messagesView.title') }}</h1>
<!-- 标签切换 -->
<div class="flex gap-2 border-b">
<Button @click="activeTab = 'battles'" :variant="activeTab === 'battles' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('messagesView.battleReports') }}
<Badge v-if="gameStore.player.battleReports.length > 0" variant="secondary" class="ml-1">
{{ gameStore.player.battleReports.length }}
</Badge>
<Sword class="h-4 w-4 mr-2" />
{{ t('messagesView.battles') }}
<Badge v-if="unreadBattles > 0" variant="destructive" class="ml-2">{{ unreadBattles }}</Badge>
</Button>
<Button @click="activeTab = 'spy'" :variant="activeTab === 'spy' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('messagesView.spyReports') }}
<Badge v-if="gameStore.player.spyReports.length > 0" variant="secondary" class="ml-1">
{{ gameStore.player.spyReports.length }}
</Badge>
<Eye class="h-4 w-4 mr-2" />
{{ t('messagesView.spy') }}
<Badge v-if="unreadSpyReports > 0" variant="destructive" class="ml-2">{{ unreadSpyReports }}</Badge>
</Button>
</div>
<!-- 战斗报告 -->
<div v-if="activeTab === 'battles'" class="space-y-4">
<!-- 战斗报告列表 -->
<div v-if="activeTab === 'battles'" class="space-y-2">
<Card v-if="gameStore.player.battleReports.length === 0">
<CardContent class="py-8 text-center text-muted-foreground">{{ t('messagesView.noBattleReports') }}</CardContent>
</Card>
<Card v-for="report in sortedBattleReports" :key="report.id">
<CardHeader>
<div class="flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<Card
v-for="report in sortedBattleReports"
:key="report.id"
@click="openBattleReport(report)"
class="cursor-pointer hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Sword class="h-4 w-4 flex-shrink-0" />
<CardTitle class="text-base sm:text-lg">{{ t('messagesView.battleReport') }}</CardTitle>
<CardDescription class="text-xs sm:text-sm">
{{ formatDate(report.timestamp) }}
</CardDescription>
<Badge v-if="!report.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
<Badge
:variant="report.winner === 'attacker' ? 'default' : report.winner === 'defender' ? 'destructive' : 'secondary'"
class="text-xs"
>
{{ report.winner === 'attacker' ? t('messagesView.victory') : report.winner === 'defender' ? t('messagesView.defeat') : t('messagesView.draw') }}
</Badge>
</div>
<Badge :variant="report.winner === 'attacker' ? 'default' : report.winner === 'defender' ? 'destructive' : 'secondary'">
{{ report.winner === 'attacker' ? t('messagesView.victory') : report.winner === 'defender' ? t('messagesView.defeat') : t('messagesView.draw') }}
</Badge>
<Button @click.stop="deleteBattleReport(report.id)" variant="ghost" size="icon" class="h-8 w-8 flex-shrink-0">
<X class="h-4 w-4" />
</Button>
</div>
<CardDescription class="text-xs sm:text-sm">
{{ formatDate(report.timestamp) }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- 攻击方舰队 -->
<div>
<p class="text-sm font-medium mb-2">{{ t('messagesView.attackerFleet') }}:</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<div v-for="(count, shipType) in report.attackerFleet" :key="shipType" class="text-xs sm:text-sm">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-1 font-medium">{{ count }}</span>
</div>
</div>
</div>
<!-- 防守方舰队 -->
<div>
<p class="text-sm font-medium mb-2">{{ t('messagesView.defenderFleet') }}:</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<div v-for="(count, shipType) in report.defenderFleet" :key="shipType" class="text-xs sm:text-sm">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-1 font-medium">{{ count }}</span>
</div>
</div>
</div>
<!-- 防守方防御 -->
<div v-if="hasDefense(report.defenderDefense)">
<p class="text-sm font-medium mb-2">{{ t('messagesView.defenderDefense') }}:</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<div v-for="(count, defenseType) in report.defenderDefense" :key="defenseType" class="text-xs sm:text-sm">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}:</span>
<span class="ml-1 font-medium">{{ count }}</span>
</div>
</div>
</div>
<!-- 损失 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="p-3 bg-muted rounded-lg">
<p class="text-sm font-medium mb-2 text-red-600">{{ t('messagesView.attackerLosses') }}:</p>
<div class="space-y-1 text-xs">
<div v-for="(count, shipType) in report.attackerLosses" :key="shipType">{{ SHIPS[shipType].name }}: {{ count }}</div>
<p v-if="Object.keys(report.attackerLosses).length === 0" class="text-muted-foreground">{{ t('messagesView.noLosses') }}</p>
</div>
</div>
<div class="p-3 bg-muted rounded-lg">
<p class="text-sm font-medium mb-2 text-red-600">{{ t('messagesView.defenderLosses') }}:</p>
<div class="space-y-1 text-xs">
<div v-for="(count, shipType) in report.defenderLosses.fleet" :key="shipType">{{ SHIPS[shipType].name }}: {{ count }}</div>
<div v-for="(count, defenseType) in report.defenderLosses.defense" :key="defenseType">
{{ DEFENSES[defenseType].name }}: {{ count }}
</div>
<p
v-if="Object.keys(report.defenderLosses.fleet).length === 0 && Object.keys(report.defenderLosses.defense).length === 0"
class="text-muted-foreground"
>
{{ t('messagesView.noLosses') }}
</p>
</div>
</div>
</div>
<!-- 掠夺资源 -->
<div
v-if="report.plunder.metal > 0 || report.plunder.crystal > 0 || report.plunder.deuterium > 0"
class="p-3 bg-green-50 dark:bg-green-950 rounded-lg"
>
<p class="text-sm font-medium mb-2 text-green-600">{{ t('messagesView.plunder') }}:</p>
<div class="flex flex-wrap gap-3 text-xs sm:text-sm">
<span v-if="report.plunder.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(report.plunder.metal) }}
</span>
<span v-if="report.plunder.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(report.plunder.crystal) }}
</span>
<span v-if="report.plunder.deuterium > 0" class="flex items-center gap-1">
<ResourceIcon type="deuterium" size="sm" />
{{ formatNumber(report.plunder.deuterium) }}
</span>
</div>
</div>
<!-- 残骸场 -->
<div v-if="report.debrisField.metal > 0 || report.debrisField.crystal > 0" class="p-3 bg-muted rounded-lg">
<p class="text-sm font-medium mb-2">{{ t('messagesView.debrisField') }}:</p>
<div class="flex flex-wrap gap-3 text-xs sm:text-sm">
<span v-if="report.debrisField.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(report.debrisField.metal) }}
</span>
<span v-if="report.debrisField.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(report.debrisField.crystal) }}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- 间谍报告 -->
<div v-if="activeTab === 'spy'" class="space-y-4">
<!-- 间谍报告列表 -->
<div v-if="activeTab === 'spy'" class="space-y-2">
<Card v-if="gameStore.player.spyReports.length === 0">
<CardContent class="py-8 text-center text-muted-foreground">{{ t('messagesView.noSpyReports') }}</CardContent>
</Card>
<Card v-for="report in sortedSpyReports" :key="report.id">
<CardHeader>
<div class="flex justify-between items-start gap-2">
<div>
<Card
v-for="report in sortedSpyReports"
:key="report.id"
@click="openSpyReport(report)"
class="cursor-pointer hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Eye class="h-4 w-4 flex-shrink-0" />
<CardTitle class="text-base sm:text-lg">{{ t('messagesView.spyReport') }}</CardTitle>
<CardDescription class="text-xs sm:text-sm">
{{ formatDate(report.timestamp) }}
</CardDescription>
<Badge v-if="!report.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
<Badge variant="outline" class="text-xs">{{ report.targetPlanetId }}</Badge>
</div>
<Badge variant="outline">
{{ report.targetPlanetId }}
</Badge>
<Button @click.stop="deleteSpyReport(report.id)" variant="ghost" size="icon" class="h-8 w-8 flex-shrink-0">
<X class="h-4 w-4" />
</Button>
</div>
<CardDescription class="text-xs sm:text-sm">
{{ formatDate(report.timestamp) }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- 资源 -->
<div>
<p class="text-sm font-medium mb-2">{{ t('messagesView.resources') }}:</p>
<div class="flex flex-wrap gap-3 text-xs sm:text-sm">
<span class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(report.resources.metal) }}
</span>
<span class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(report.resources.crystal) }}
</span>
<span class="flex items-center gap-1">
<ResourceIcon type="deuterium" size="sm" />
{{ formatNumber(report.resources.deuterium) }}
</span>
</div>
</div>
<!-- 舰队 -->
<div v-if="report.fleet">
<p class="text-sm font-medium mb-2">{{ t('messagesView.fleet') }}:</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<div v-for="(count, shipType) in report.fleet" :key="shipType" class="text-xs sm:text-sm">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-1 font-medium">{{ count }}</span>
</div>
</div>
</div>
<!-- 防御 -->
<div v-if="report.defense && hasDefense(report.defense)">
<p class="text-sm font-medium mb-2">{{ t('messagesView.defense') }}:</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<div v-for="(count, defenseType) in report.defense" :key="defenseType" class="text-xs sm:text-sm">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}:</span>
<span class="ml-1 font-medium">{{ count }}</span>
</div>
</div>
</div>
<!-- 建筑 -->
<div v-if="report.buildings">
<p class="text-sm font-medium mb-2">{{ t('messagesView.buildings') }}:</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<template v-for="(level, buildingType) in report.buildings" :key="buildingType">
<div v-if="level && level > 0" class="text-xs sm:text-sm">
<span class="text-muted-foreground">{{ BUILDINGS[buildingType].name }}:</span>
<span class="ml-1 font-medium">Lv {{ level }}</span>
</div>
</template>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- 战斗报告对话框 -->
<BattleReportDialog v-model:open="showBattleDialog" :report="selectedBattleReport" />
<!-- 间谍报告对话框 -->
<SpyReportDialog v-model:open="showSpyDialog" :report="selectedSpyReport" />
</div>
</template>
<script setup lang="ts">
import { useGameStore } from '@/stores/gameStore'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { computed, ref } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import ResourceIcon from '@/components/ResourceIcon.vue'
import { formatNumber, formatDate } from '@/utils/format'
import BattleReportDialog from '@/components/BattleReportDialog.vue'
import SpyReportDialog from '@/components/SpyReportDialog.vue'
import { formatDate } from '@/utils/format'
import { X, Sword, Eye } from 'lucide-vue-next'
import type { BattleResult, SpyReport } from '@/types/game'
const gameStore = useGameStore()
const { t } = useI18n()
const { SHIPS, DEFENSES, BUILDINGS } = useGameConfig()
const activeTab = ref<'battles' | 'spy'>('battles')
// 对话框状态
const showBattleDialog = ref(false)
const showSpyDialog = ref(false)
const selectedBattleReport = ref<BattleResult | null>(null)
const selectedSpyReport = ref<SpyReport | null>(null)
// 排序后的战斗报告(最新的在前)
const sortedBattleReports = computed(() => {
return [...gameStore.player.battleReports].sort((a, b) => b.timestamp - a.timestamp)
@@ -244,9 +124,49 @@
return [...gameStore.player.spyReports].sort((a, b) => b.timestamp - a.timestamp)
})
// 检查是否有防御设施
const hasDefense = (defense: any): boolean => {
if (!defense) return false
return Object.values(defense).some((count: any) => count > 0)
// 未读战斗报告数量
const unreadBattles = computed(() => {
return gameStore.player.battleReports.filter(r => !r.read).length
})
// 未读间谍报告数量
const unreadSpyReports = computed(() => {
return gameStore.player.spyReports.filter(r => !r.read).length
})
// 打开战斗报告
const openBattleReport = (report: BattleResult) => {
selectedBattleReport.value = report
showBattleDialog.value = true
// 标记为已读
if (!report.read) {
report.read = true
}
}
// 打开间谍报告
const openSpyReport = (report: SpyReport) => {
selectedSpyReport.value = report
showSpyDialog.value = true
// 标记为已读
if (!report.read) {
report.read = true
}
}
// 删除战斗报告
const deleteBattleReport = (reportId: string) => {
const index = gameStore.player.battleReports.findIndex(r => r.id === reportId)
if (index > -1) {
gameStore.player.battleReports.splice(index, 1)
}
}
// 删除间谍报告
const deleteSpyReport = (reportId: string) => {
const index = gameStore.player.spyReports.findIndex(r => r.id === reportId)
if (index > -1) {
gameStore.player.spyReports.splice(index, 1)
}
}
</script>

View File

@@ -34,6 +34,7 @@
<TableHead class="text-right">{{ t('resources.current') }}</TableHead>
<TableHead class="text-right">{{ t('resources.max') }}</TableHead>
<TableHead class="text-right">{{ t('resources.production') }}{{ t('resources.perHour') }}</TableHead>
<TableHead class="text-right">{{ t('resources.consumption') }}{{ t('resources.perHour') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -44,40 +45,139 @@
{{ t(`resources.${resourceType.key}`) }}
</div>
</TableCell>
<!-- 电量特殊显示 -->
<template v-if="resourceType.key === 'energy'">
<TableCell
class="text-right"
: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]) }}
</TableCell>
<TableCell class="text-right text-muted-foreground">-</TableCell>
<TableCell class="text-right text-muted-foreground">
{{ formatNumber(energyProduction) }} / {{ formatNumber(energyConsumption) }}
</TableCell>
</template>
<!-- 其他资源正常显示 -->
<template v-else>
<TableCell
class="text-right"
:class="getResourceColor(planet.resources[resourceType.key], capacity?.[resourceType.key] || Infinity)"
>
{{ formatNumber(planet.resources[resourceType.key]) }}
</TableCell>
<TableCell class="text-right text-muted-foreground">
{{ formatNumber(capacity?.[resourceType.key] || 0) }}
</TableCell>
<TableCell class="text-right text-muted-foreground">
{{ formatNumber(production?.[resourceType.key] || 0) }}
</TableCell>
</template>
<!-- 所有资源统一显示 -->
<TableCell
class="text-right"
:class="getResourceColor(planet.resources[resourceType.key], capacity?.[resourceType.key] || Infinity)"
>
{{ formatNumber(planet.resources[resourceType.key]) }}
</TableCell>
<TableCell class="text-right text-muted-foreground">
{{ formatNumber(capacity?.[resourceType.key] || 0) }}
</TableCell>
<TableCell class="text-right text-green-600 dark:text-green-400">
+{{ formatNumber(production?.[resourceType.key] || 0) }}
</TableCell>
<TableCell class="text-right text-red-600 dark:text-red-400">
<template v-if="resourceType.key === 'energy'">
-{{ formatNumber(energyConsumption) }}
</template>
<template v-else>
-
</template>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
<!-- 资源获取来源 -->
<Card>
<CardHeader>
<CardTitle>{{ t('overview.productionSources') }}</CardTitle>
<CardDescription>{{ t('overview.productionSourcesDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div v-for="resourceType in resourceTypes" :key="resourceType.key" class="border-b last:border-b-0 pb-4 last:pb-0">
<div class="flex items-center gap-2 mb-2">
<ResourceIcon :type="resourceType.key" size="sm" />
<span class="font-semibold">{{ t(`resources.${resourceType.key}`) }}</span>
</div>
<div v-if="productionBreakdown" class="ml-6 space-y-1 text-sm">
<!-- 建筑基础产量 -->
<div class="flex justify-between">
<span class="text-muted-foreground">
{{ t(productionBreakdown[resourceType.key].buildingName) }}
({{ t('common.level') }} {{ productionBreakdown[resourceType.key].buildingLevel }})
</span>
<span class="text-green-600 dark:text-green-400">
+{{ formatNumber(Math.floor(productionBreakdown[resourceType.key].baseProduction)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 加成列表 -->
<div v-for="(bonus, idx) in productionBreakdown[resourceType.key].bonuses" :key="idx" class="flex justify-between">
<span class="text-muted-foreground ml-4">
{{ t(bonus.name) }}
</span>
<span :class="bonus.value > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
{{ bonus.value > 0 ? '+' : '' }}{{ bonus.value }}%
</span>
</div>
<!-- 最终产量 -->
<div class="flex justify-between font-semibold pt-1 border-t mt-1">
<span>{{ t('overview.totalProduction') }}</span>
<span class="text-green-600 dark:text-green-400">
+{{ formatNumber(Math.floor(productionBreakdown[resourceType.key].finalProduction)) }}/{{ t('resources.hour') }}
</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 资源消耗来源 -->
<Card>
<CardHeader>
<CardTitle>{{ t('overview.consumptionSources') }}</CardTitle>
<CardDescription>{{ t('overview.consumptionSourcesDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-2">
<!-- 金属矿消耗 -->
<div v-if="consumptionBreakdown && consumptionBreakdown.metalMine.buildingLevel > 0" class="flex justify-between text-sm">
<span class="text-muted-foreground">
{{ t(consumptionBreakdown.metalMine.buildingName) }}
({{ t('common.level') }} {{ consumptionBreakdown.metalMine.buildingLevel }})
</span>
<span class="text-red-600 dark:text-red-400">
-{{ formatNumber(Math.floor(consumptionBreakdown.metalMine.consumption)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 晶体矿消耗 -->
<div v-if="consumptionBreakdown && consumptionBreakdown.crystalMine.buildingLevel > 0" class="flex justify-between text-sm">
<span class="text-muted-foreground">
{{ t(consumptionBreakdown.crystalMine.buildingName) }}
({{ t('common.level') }} {{ consumptionBreakdown.crystalMine.buildingLevel }})
</span>
<span class="text-red-600 dark:text-red-400">
-{{ formatNumber(Math.floor(consumptionBreakdown.crystalMine.consumption)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 重氢合成器消耗 -->
<div v-if="consumptionBreakdown && consumptionBreakdown.deuteriumSynthesizer.buildingLevel > 0" class="flex justify-between text-sm">
<span class="text-muted-foreground">
{{ t(consumptionBreakdown.deuteriumSynthesizer.buildingName) }}
({{ t('common.level') }} {{ consumptionBreakdown.deuteriumSynthesizer.buildingLevel }})
</span>
<span class="text-red-600 dark:text-red-400">
-{{ formatNumber(Math.floor(consumptionBreakdown.deuteriumSynthesizer.consumption)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 总消耗 -->
<div v-if="consumptionBreakdown" class="flex justify-between font-semibold pt-2 border-t">
<span>{{ t('overview.totalConsumption') }}</span>
<span class="text-red-600 dark:text-red-400">
-{{ formatNumber(Math.floor(consumptionBreakdown.total)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 无消耗提示 -->
<div v-if="consumptionBreakdown && consumptionBreakdown.total === 0" class="text-sm text-muted-foreground text-center py-2">
{{ t('overview.noConsumption') }}
</div>
</div>
</CardContent>
</Card>
<!-- 舰队信息 -->
<Card>
<CardHeader>
@@ -109,8 +209,8 @@
import { formatNumber, getResourceColor } from '@/utils/format'
import type { Planet } from '@/types/game'
import * as publicLogic from '@/logic/publicLogic'
import * as officerLogic from '@/logic/officerLogic'
import * as resourceLogic from '@/logic/resourceLogic'
import * as officerLogic from '@/logic/officerLogic'
const gameStore = useGameStore()
const { t } = useI18n()
@@ -119,18 +219,25 @@
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 energyConsumption = computed(() => {
if (!planet.value) return 0
return resourceLogic.calculateEnergyConsumption(planet.value)
})
// 资源产量详细breakdown
const productionBreakdown = computed(() => {
if (!planet.value) return null
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, Date.now())
return resourceLogic.calculateProductionBreakdown(planet.value, bonuses)
})
// 资源消耗详细breakdown
const consumptionBreakdown = computed(() => {
if (!planet.value) return null
return resourceLogic.calculateConsumptionBreakdown(planet.value)
})
// 资源类型配置
const resourceTypes = [
{ key: 'metal' as const },

View File

@@ -7,7 +7,7 @@
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<Card v-for="techType in Object.values(TechnologyType)" :key="techType" class="relative">
<CardUnlockOverlay :requirements="TECHNOLOGIES[techType].requirements" />
<CardUnlockOverlay :requirements="TECHNOLOGIES[techType].requirements" :currentLevel="getTechLevel(techType)" />
<CardHeader>
<div class="flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
@@ -63,7 +63,7 @@
</div>
<Button @click="handleResearch(techType)" :disabled="!canResearch(techType)" class="w-full">
{{ t('researchView.research') }}
{{ getResearchButtonText(techType) }}
</Button>
</div>
</CardContent>
@@ -98,7 +98,7 @@
const gameStore = useGameStore()
const detailDialog = useDetailDialogStore()
const { t } = useI18n()
const { TECHNOLOGIES } = useGameConfig()
const { TECHNOLOGIES, BUILDINGS } = useGameConfig()
const planet = computed(() => gameStore.currentPlanet)
const player = computed(() => gameStore.player)
const alertDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
@@ -123,8 +123,86 @@
return true
}
// 检查升级前置条件是否满足
const checkUpgradeRequirements = (techType: TechnologyType): boolean => {
if (!planet.value) return false
const config = TECHNOLOGIES.value[techType]
const currentLevel = getTechLevel(techType)
const targetLevel = currentLevel + 1
// 获取目标等级的所有前置条件(包括等级门槛)
const requirements = publicLogic.getLevelRequirements(config, targetLevel)
if (!requirements || Object.keys(requirements).length === 0) return true
return publicLogic.checkRequirements(planet.value, gameStore.player.technologies, requirements)
}
// 获取研究按钮文本
const getResearchButtonText = (techType: TechnologyType): string => {
if (!planet.value) return t('researchView.research')
const config = TECHNOLOGIES.value[techType]
const currentLevel = getTechLevel(techType)
// 检查是否达到等级上限
if (config.maxLevel !== undefined && currentLevel >= config.maxLevel) {
return t('researchView.maxLevelReached') // "等级已满"
}
if (player.value.researchQueue.length > 0) return t('researchView.research')
// 检查前置条件
if (!checkUpgradeRequirements(techType)) {
return t('buildingsView.requirementsNotMet') // "条件不足"
}
return t('researchView.research') // "研究"
}
// 获取前置条件列表文本
const getRequirementsList = (techType: TechnologyType): string => {
const config = TECHNOLOGIES.value[techType]
const currentLevel = getTechLevel(techType)
const targetLevel = currentLevel + 1
// 获取目标等级的所有前置条件(包括等级门槛)
const requirements = publicLogic.getLevelRequirements(config, targetLevel)
if (!requirements || !planet.value) return ''
const lines: string[] = []
for (const [key, requiredLevel] of Object.entries(requirements)) {
// 检查是否为建筑类型
if (Object.values(BuildingType).includes(key as BuildingType)) {
const bt = key as BuildingType
const currentLevel = planet.value.buildings[bt] || 0
const name = BUILDINGS.value[bt]?.name || bt
const status = currentLevel >= requiredLevel ? '✓' : '✗'
lines.push(`${status} ${name}: Lv ${requiredLevel} (${t('common.current')}: Lv ${currentLevel})`)
}
// 检查是否为科技类型
else if (Object.values(TechnologyType).includes(key as TechnologyType)) {
const tt = key as TechnologyType
const currentLevel = gameStore.player.technologies[tt] || 0
const name = TECHNOLOGIES.value[tt]?.name || tt
const status = currentLevel >= requiredLevel ? '✓' : '✗'
lines.push(`${status} ${name}: Lv ${requiredLevel} (${t('common.current')}: Lv ${currentLevel})`)
}
}
return lines.join('\n')
}
// 研究科技
const handleResearch = (techType: TechnologyType) => {
// 检查前置条件
if (!checkUpgradeRequirements(techType)) {
alertDialog.value?.show({
title: t('common.requirementsNotMet'),
message: getRequirementsList(techType)
})
return
}
const success = researchTechnology(techType)
if (!success) {
alertDialog.value?.show({
@@ -141,10 +219,18 @@
// 检查是否可以研究
const canResearch = (techType: TechnologyType): boolean => {
if (!planet.value || player.value.researchQueue.length > 0) return false
if (!planet.value) return false
const config = TECHNOLOGIES.value[techType]
const currentLevel = getTechLevel(techType)
// 检查是否达到等级上限
if (config.maxLevel !== undefined && currentLevel >= config.maxLevel) {
return false
}
if (player.value.researchQueue.length > 0) return false
const cost = getTechnologyCost(techType, currentLevel + 1)
return (

View File

@@ -59,19 +59,16 @@
<CardDescription>{{ t('settings.gameSettingsDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- 玩家名称 -->
<div class="flex items-center justify-between">
<Label for="player-name">{{ t('settings.playerName') }}</Label>
<Input id="player-name" v-model="playerName" @blur="updatePlayerName" class="max-w-xs" />
</div>
<!-- 游戏速度 -->
<div class="flex items-center justify-between">
<!-- 游戏暂停 -->
<div class="flex items-center justify-between p-4 border rounded-lg">
<div class="space-y-1">
<Label>{{ t('settings.gameSpeed') }}</Label>
<p class="text-sm text-muted-foreground">{{ t('settings.gameSpeedDesc') }}</p>
<h3 class="font-medium">{{ t('settings.gamePause') }}</h3>
<p class="text-sm text-muted-foreground">{{ t('settings.gamePauseDesc') }}</p>
</div>
<div class="text-2xl font-bold">1x</div>
<Button @click="togglePause" :variant="gameStore.isPaused ? 'default' : 'outline'">
<component :is="gameStore.isPaused ? Play : Pause" class="mr-2 h-4 w-4" />
{{ gameStore.isPaused ? t('settings.resume') : t('settings.pause') }}
</Button>
</div>
</CardContent>
</Card>
@@ -89,7 +86,7 @@
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('settings.buildDate') }}:</span>
<span class="font-medium">{{ new Date().toLocaleDateString() }}</span>
<span class="font-medium">{{ pkg.buildDate }}</span>
</div>
</div>
@@ -139,8 +136,6 @@
import { useI18n } from '@/composables/useI18n'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
AlertDialog,
AlertDialogAction,
@@ -151,17 +146,17 @@
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Download, Upload, Trash2, ExternalLink, MessagesSquare } from 'lucide-vue-next'
import { Download, Upload, Trash2, ExternalLink, MessagesSquare, Play, Pause } from 'lucide-vue-next'
import { saveAs } from 'file-saver'
import { toast } from 'vue-sonner'
import pkg from '../../package.json'
import 'vue-sonner/style.css'
const { t } = useI18n()
const gameStore = useGameStore()
const fileInputRef = ref<HTMLInputElement>()
const isExporting = ref(false)
const playerName = ref(gameStore.player.name)
const showConfirmDialog = ref(false)
const confirmTitle = ref('')
@@ -176,17 +171,30 @@
window.open(`https://qm.qq.com/q/${pkg.id}`, '_blank')
}
// 导出数据
// 导出数据(包含游戏数据和地图数据)
const handleExport = async () => {
try {
isExporting.value = true
const data = localStorage.getItem(pkg.name)
if (!data) {
// 获取游戏数据
const gameData = localStorage.getItem(pkg.name)
// 获取地图数据
const universeData = localStorage.getItem(`${pkg.name}-universe`)
if (!gameData) {
toast.error(t('settings.exportFailed'))
return
}
// 合并数据
const exportData = {
game: gameData,
universe: universeData || null
}
const fileName = `${pkg.name}-${new Date().toISOString().slice(0, 10)}-${Date.now()}.json`
saveAs(new Blob([data], { type: 'application/json' }), fileName)
const jsonString = JSON.stringify(exportData, null, 2)
saveAs(new Blob([jsonString], { type: 'application/json' }), fileName)
toast.success(t('settings.exportSuccess'))
} catch (error) {
console.error('Export failed:', error)
@@ -205,14 +213,14 @@
const handleFileSelect = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
confirmTitle.value = t('settings.importConfirmTitle')
confirmMessage.value = t('settings.importConfirmMessage')
showConfirmDialog.value = true
gameStore.isPaused = true
confirmCallback = () => importData(file)
}
// 导入数据
// 导入数据(包含游戏数据和地图数据)
const importData = async (file: File) => {
try {
const reader = new FileReader()
@@ -220,9 +228,28 @@
try {
const result = e.target?.result
if (typeof result === 'string') {
localStorage.setItem(pkg.name, result)
const importData = JSON.parse(result)
// 兼容旧版本:如果是旧格式(直接是字符串),只导入游戏数据
if (typeof importData === 'string' || !importData.game) {
localStorage.setItem(pkg.name, result)
toast.success(t('settings.importSuccess'))
setTimeout(() => window.location.reload(), 1000)
return
}
// 新格式:分别导入游戏数据和地图数据
if (importData.game) {
localStorage.setItem(pkg.name, importData.game)
}
if (importData.universe) {
localStorage.setItem(`${pkg.name}-universe`, importData.universe)
}
toast.success(t('settings.importSuccess'))
setTimeout(() => location.reload(), 500)
// 延迟刷新页面以让toast显示
setTimeout(() => window.location.reload(), 1000)
} else {
toast.error(t('settings.importFailed'))
}
@@ -253,10 +280,13 @@
window.location.reload()
}
// 更新玩家名称
const updatePlayerName = () => {
if (playerName.value.trim()) {
gameStore.player.name = playerName.value.trim()
// 切换游戏暂停状态
const togglePause = () => {
gameStore.isPaused = !gameStore.isPaused
if (gameStore.isPaused) {
toast.info(t('settings.gamePaused'))
} else {
toast.success(t('settings.gameResumed'))
}
}
@@ -271,6 +301,7 @@
// 取消操作
const cancelAction = () => {
gameStore.isPaused = false
confirmCallback = null
showConfirmDialog.value = false
// 重置文件输入

View File

@@ -5,6 +5,29 @@
<h1 class="text-2xl sm:text-3xl font-bold mb-4 sm:mb-6">{{ t('shipyardView.title') }}</h1>
<!-- 舰队仓储显示 -->
<div class="mb-4 sm:mb-6 p-3 sm:p-4 bg-muted/50 rounded-lg border">
<div class="flex items-center justify-between">
<div class="text-sm sm:text-base font-medium">{{ t('shipyardView.fleetStorage') }}:</div>
<div class="text-sm sm:text-base font-bold">
<span :class="fleetStorageUsage > maxFleetStorage ? 'text-destructive' : 'text-primary'">
{{ formatNumber(fleetStorageUsage) }}
</span>
<span class="text-muted-foreground mx-1">/</span>
<span>{{ formatNumber(maxFleetStorage) }}</span>
</div>
</div>
<div class="mt-2">
<div class="w-full bg-background rounded-full h-2.5 sm:h-3 overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="fleetStorageUsage > maxFleetStorage ? 'bg-destructive' : 'bg-primary'"
:style="{ width: `${Math.min((fleetStorageUsage / maxFleetStorage) * 100, 100)}%` }"
></div>
</div>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4">
<Card v-for="shipType in Object.values(ShipType)" :key="shipType" class="relative">
<CardUnlockOverlay :requirements="SHIPS[shipType].requirements" />
@@ -151,6 +174,7 @@
import { formatNumber, getResourceCostColor } from '@/utils/format'
import * as shipValidation from '@/logic/shipValidation'
import * as publicLogic from '@/logic/publicLogic'
import * as fleetStorageLogic from '@/logic/fleetStorageLogic'
const gameStore = useGameStore()
const detailDialog = useDetailDialogStore()
@@ -159,6 +183,18 @@
const planet = computed(() => gameStore.currentPlanet)
const alertDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
// 舰队仓储使用量
const fleetStorageUsage = computed(() => {
if (!planet.value) return 0
return fleetStorageLogic.calculateFleetStorageUsage(planet.value.fleet)
})
// 舰队仓储上限
const maxFleetStorage = computed(() => {
if (!planet.value) return 0
return fleetStorageLogic.calculateMaxFleetStorage(planet.value, gameStore.player.technologies)
})
// 每种舰船的建造数量
const quantities = ref<Record<ShipType, number>>({
[ShipType.LightFighter]: 0,
@@ -170,7 +206,8 @@
[ShipType.ColonyShip]: 0,
[ShipType.Recycler]: 0,
[ShipType.EspionageProbe]: 0,
[ShipType.DarkMatterHarvester]: 0
[ShipType.DarkMatterHarvester]: 0,
[ShipType.Deathstar]: 0
})
const buildShip = (shipType: ShipType, quantity: number): boolean => {