- 创建地图布局工具(mapLayout.js) - 预定义区域位置配置 - BFS计算可达距离和最短路径 - 地图数据结构构建 - 添加迷你地图组件(MiniMap.vue) - 可视化显示所有区域节点和连接线 - 当前位置脉冲动画高亮 - 相邻区域虚线标记 - 锁定区域显示🔒图标 - 显示到各区域的距离步数 - 点击区域显示前往路径 - 图例说明不同区域类型 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
692 lines
16 KiB
Vue
692 lines
16 KiB
Vue
<template>
|
||
<view class="map-panel">
|
||
<!-- 顶部状态 -->
|
||
<view class="map-panel__header">
|
||
<view class="location-info">
|
||
<text class="location-icon">📍</text>
|
||
<text class="location-name">{{ currentLocation.name }}</text>
|
||
</view>
|
||
<TextButton text="📦 背包" type="default" @click="openInventory" />
|
||
</view>
|
||
|
||
<!-- 当前位置描述 -->
|
||
<view class="map-panel__description">
|
||
<text class="description-text">{{ currentLocation.description }}</text>
|
||
</view>
|
||
|
||
<!-- 迷你地图 -->
|
||
<view class="map-panel__minimap">
|
||
<MiniMap />
|
||
</view>
|
||
|
||
<!-- 战斗状态 -->
|
||
<view v-if="game.inCombat" class="combat-status">
|
||
<text class="combat-status__title">⚔️ 战斗中</text>
|
||
<text class="combat-status__enemy">{{ combatState.enemyName }}</text>
|
||
<ProgressBar
|
||
:value="combatState.enemyHp"
|
||
:max="combatState.enemyMaxHp"
|
||
color="#ff6b6b"
|
||
:showText="true"
|
||
height="20rpx"
|
||
/>
|
||
<FilterTabs
|
||
:tabs="stanceTabs"
|
||
:modelValue="combatState.stance"
|
||
@update:modelValue="changeStance"
|
||
/>
|
||
</view>
|
||
|
||
<!-- 寻找敌人中状态 -->
|
||
<view v-if="game.isSearching && !game.inCombat" class="searching-status">
|
||
<text class="searching-status__title">🔍 寻找中...</text>
|
||
<text class="searching-status__desc">正在寻找新的敌人</text>
|
||
<view class="searching-status__dots">
|
||
<text class="dot">●</text>
|
||
<text class="dot">●</text>
|
||
<text class="dot">●</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 区域列表 -->
|
||
<view class="map-panel__locations-header">
|
||
<text class="locations-title">可前往的区域</text>
|
||
</view>
|
||
<scroll-view class="map-panel__locations" scroll-y>
|
||
<view
|
||
v-for="loc in availableLocations"
|
||
:key="loc.id"
|
||
class="location-item"
|
||
:class="{ 'location-item--locked': loc.locked, 'location-item--current': loc.id === player.currentLocation }"
|
||
@click="tryTravel(loc)"
|
||
>
|
||
<view class="location-item__main">
|
||
<text class="location-item__name">{{ loc.name }}</text>
|
||
<text class="location-item__type">[{{ loc.typeText }}]</text>
|
||
</view>
|
||
<text v-if="loc.locked" class="location-item__lock">🔒</text>
|
||
<text v-else-if="loc.id === player.currentLocation" class="location-item__current">当前</text>
|
||
<text v-else class="location-item__arrow">→</text>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 活动按钮 -->
|
||
<view class="map-panel__actions">
|
||
<text class="actions-title">可用活动</text>
|
||
<view class="actions-list">
|
||
<TextButton
|
||
v-for="action in currentActions"
|
||
:key="action.id"
|
||
:text="action.label"
|
||
:type="action.type || 'default'"
|
||
:disabled="action.disabled"
|
||
@click="doAction(action.id)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 自动战斗开关 -->
|
||
<view class="map-panel__auto-combat">
|
||
<text class="auto-combat-label">自动战斗</text>
|
||
<view
|
||
class="auto-combat-toggle"
|
||
:class="{ 'auto-combat-toggle--active': game.autoCombat }"
|
||
@click="toggleAutoCombat"
|
||
>
|
||
<text class="auto-combat-toggle__slider">
|
||
{{ game.autoCombat ? 'ON' : 'OFF' }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- NPC 列表 -->
|
||
<view v-if="availableNPCs.length > 0" class="map-panel__npcs">
|
||
<text class="npcs-title">可交互的角色</text>
|
||
<view class="npcs-list">
|
||
<TextButton
|
||
v-for="npc in availableNPCs"
|
||
:key="npc.id"
|
||
:text="`${npc.icon} ${npc.name}`"
|
||
type="primary"
|
||
@click="talkToNPC(npc)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
import { useGameStore } from '@/store/game'
|
||
import { usePlayerStore } from '@/store/player'
|
||
import { LOCATION_CONFIG } from '@/config/locations'
|
||
import { NPC_CONFIG } from '@/config/npcs.js'
|
||
import { ENEMY_CONFIG, getRandomEnemyForLocation } from '@/config/enemies.js'
|
||
import { getShopConfig } from '@/config/shop.js'
|
||
import { initCombat, getEnvironmentType } from '@/utils/combatSystem.js'
|
||
import { triggerExploreEvent, tryFlee } from '@/utils/eventSystem.js'
|
||
import TextButton from '@/components/common/TextButton.vue'
|
||
import ProgressBar from '@/components/common/ProgressBar.vue'
|
||
import FilterTabs from '@/components/common/FilterTabs.vue'
|
||
import MiniMap from '@/components/common/MiniMap.vue'
|
||
|
||
const game = useGameStore()
|
||
const player = usePlayerStore()
|
||
|
||
const stanceTabs = [
|
||
{ id: 'attack', label: '攻击' },
|
||
{ id: 'defense', label: '防御' },
|
||
{ id: 'balance', label: '平衡' }
|
||
]
|
||
|
||
const currentLocation = computed(() => {
|
||
return LOCATION_CONFIG[player.currentLocation] || { name: '未知', description: '', type: 'unknown' }
|
||
})
|
||
|
||
const combatState = computed(() => {
|
||
if (!game.combatState) {
|
||
return { enemyName: '', enemyHp: 0, enemyMaxHp: 0, stance: 'balance' }
|
||
}
|
||
return {
|
||
enemyName: game.combatState.enemy?.name || '',
|
||
enemyHp: game.combatState.enemy?.hp || 0,
|
||
enemyMaxHp: game.combatState.enemy?.maxHp || 0,
|
||
stance: game.combatState.stance || 'balance'
|
||
}
|
||
})
|
||
|
||
const availableLocations = computed(() => {
|
||
const current = LOCATION_CONFIG[player.currentLocation]
|
||
if (!current || !current.connections) return []
|
||
|
||
return current.connections.map(locId => {
|
||
const loc = LOCATION_CONFIG[locId]
|
||
if (!loc) return null
|
||
|
||
let locked = false
|
||
let lockReason = ''
|
||
|
||
// 检查解锁条件
|
||
if (loc.unlockCondition) {
|
||
if (loc.unlockCondition.type === 'kill') {
|
||
// 检查击杀条件(使用统一的killCount存储)
|
||
const killCount = (player.killCount && player.killCount[loc.unlockCondition.target]) || 0
|
||
locked = killCount < loc.unlockCondition.count
|
||
lockReason = `需要击杀${loc.unlockCondition.count}只${getEnemyName(loc.unlockCondition.target)} (当前: ${killCount})`
|
||
} else if (loc.unlockCondition.type === 'item') {
|
||
// 检查物品条件
|
||
const hasItem = player.inventory.some(i => i.id === loc.unlockCondition.item)
|
||
locked = !hasItem
|
||
lockReason = '需要特定钥匙'
|
||
}
|
||
}
|
||
|
||
const typeMap = {
|
||
safe: '安全',
|
||
danger: '危险',
|
||
dungeon: '副本'
|
||
}
|
||
|
||
return {
|
||
id: loc.id,
|
||
name: loc.name,
|
||
type: loc.type,
|
||
typeText: typeMap[loc.type] || '未知',
|
||
locked,
|
||
lockReason,
|
||
description: loc.description
|
||
}
|
||
}).filter(Boolean)
|
||
})
|
||
|
||
const currentActions = computed(() => {
|
||
const current = LOCATION_CONFIG[player.currentLocation]
|
||
if (!current || !current.activities) return []
|
||
|
||
const actionMap = {
|
||
rest: { id: 'rest', label: '休息', type: 'default' },
|
||
talk: { id: 'talk', label: '对话', type: 'default' },
|
||
trade: { id: 'trade', label: '交易', type: 'default' },
|
||
explore: { id: 'explore', label: '探索', type: 'warning' },
|
||
combat: { id: 'combat', label: '战斗', type: 'danger' },
|
||
read: { id: 'read', label: '阅读', type: 'default' },
|
||
crafting: { id: 'crafting', label: '制造', type: 'primary' }
|
||
}
|
||
|
||
// 战斗中只显示战斗相关
|
||
if (game.inCombat) {
|
||
return [
|
||
{ id: 'flee', label: '逃跑', type: 'warning' }
|
||
]
|
||
}
|
||
|
||
return (current.activities || []).map(act => actionMap[act]).filter(Boolean)
|
||
})
|
||
|
||
// 获取当前可用的 NPC
|
||
const availableNPCs = computed(() => {
|
||
const currentLocationId = player.currentLocation
|
||
|
||
return Object.entries(NPC_CONFIG)
|
||
.filter(([_, npc]) => {
|
||
// NPC 必须在当前位置
|
||
if (npc.location !== currentLocationId) return false
|
||
|
||
// 检查解锁条件
|
||
if (npc.unlockCondition) {
|
||
if (npc.unlockCondition.type === 'quest' && !player.flags[npc.unlockCondition.quest]) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
})
|
||
.map(([id, npc]) => ({
|
||
id,
|
||
name: npc.name,
|
||
icon: npc.icon || '👤',
|
||
dialogue: npc.dialogue
|
||
}))
|
||
})
|
||
|
||
function talkToNPC(npc) {
|
||
game.addLog(`与 ${npc.name} 开始对话...`, 'info')
|
||
|
||
// 打开 NPC 对话
|
||
if (npc.dialogue && npc.dialogue.first) {
|
||
game.drawerState.event = true
|
||
game.currentEvent = {
|
||
type: 'npc_dialogue',
|
||
npcId: npc.id,
|
||
dialogue: npc.dialogue.first,
|
||
choices: npc.dialogue.first.choices || []
|
||
}
|
||
}
|
||
}
|
||
|
||
function openInventory() {
|
||
game.drawerState.inventory = true
|
||
}
|
||
|
||
function tryTravel(location) {
|
||
if (location.locked) {
|
||
game.addLog(location.lockReason, 'info')
|
||
return
|
||
}
|
||
if (location.id === player.currentLocation) {
|
||
game.addLog('你已经在当前区域了', 'info')
|
||
return
|
||
}
|
||
if (game.inCombat) {
|
||
game.addLog('战斗中无法移动!', 'combat')
|
||
return
|
||
}
|
||
|
||
// 移动到新区域
|
||
player.currentLocation = location.id
|
||
game.addLog(`前往了 ${location.name}`, 'system')
|
||
|
||
// 检查区域事件
|
||
checkLocationEvent(location.id)
|
||
}
|
||
|
||
function doAction(actionId) {
|
||
switch (actionId) {
|
||
case 'rest':
|
||
game.addLog('开始休息...', 'info')
|
||
// 休息逻辑
|
||
player.currentStats.stamina = Math.min(
|
||
player.currentStats.maxStamina,
|
||
player.currentStats.stamina + 20
|
||
)
|
||
player.currentStats.health = Math.min(
|
||
player.currentStats.maxHealth,
|
||
player.currentStats.health + 10
|
||
)
|
||
game.addLog('恢复了耐力和生命值', 'reward')
|
||
break
|
||
case 'talk':
|
||
game.addLog('寻找可以对话的人...', 'info')
|
||
// 对话逻辑
|
||
break
|
||
case 'trade':
|
||
// 检查当前位置是否有商店
|
||
const shop = getShopConfig(player.currentLocation)
|
||
if (shop) {
|
||
game.drawerState.shop = true
|
||
game.addLog(`打开了 ${shop.name}`, 'info')
|
||
} else {
|
||
game.addLog('这里没有商店', 'info')
|
||
}
|
||
break
|
||
case 'explore':
|
||
game.addLog('开始探索...', 'info')
|
||
const exploreResult = triggerExploreEvent(game, player)
|
||
if (!exploreResult.success) {
|
||
game.addLog(exploreResult.message, 'info')
|
||
}
|
||
break
|
||
case 'combat':
|
||
startCombat()
|
||
break
|
||
case 'flee':
|
||
game.addLog('尝试逃跑...', 'combat')
|
||
const fleeResult = tryFlee(game, player)
|
||
if (!fleeResult.success) {
|
||
// 逃跑失败,玩家会多受到一次攻击(在战斗循环中处理)
|
||
}
|
||
break
|
||
case 'read':
|
||
game.addLog('找个安静的地方阅读...', 'info')
|
||
break
|
||
case 'crafting':
|
||
game.drawerState.crafting = true
|
||
game.addLog('打开制造界面...', 'info')
|
||
break
|
||
}
|
||
}
|
||
|
||
function startCombat() {
|
||
// 使用新的敌人获取函数
|
||
const enemyConfig = getRandomEnemyForLocation(player.currentLocation)
|
||
|
||
if (!enemyConfig) {
|
||
game.addLog('这里没有敌人', 'info')
|
||
return
|
||
}
|
||
|
||
game.addLog(`遇到了 ${enemyConfig.name}!`, 'combat')
|
||
|
||
// 获取环境类型
|
||
const environment = getEnvironmentType(player.currentLocation)
|
||
|
||
// 使用 initCombat 初始化战斗
|
||
game.combatState = initCombat(enemyConfig.id, enemyConfig, environment)
|
||
game.inCombat = true
|
||
}
|
||
|
||
function changeStance(stance) {
|
||
if (game.combatState) {
|
||
game.combatState.stance = stance
|
||
game.addLog(`切换到${stanceTabs.find(t => t.id === stance)?.label}姿态`, 'combat')
|
||
}
|
||
}
|
||
|
||
function checkLocationEvent(locationId) {
|
||
// 检查位置相关事件
|
||
// 这里可以触发事件抽屉等
|
||
}
|
||
|
||
function getEnemyName(enemyId) {
|
||
const enemyNames = {
|
||
wild_dog: '野狗',
|
||
test_boss: '测试Boss'
|
||
}
|
||
return enemyNames[enemyId] || enemyId
|
||
}
|
||
|
||
function toggleAutoCombat() {
|
||
game.autoCombat = !game.autoCombat
|
||
if (game.autoCombat) {
|
||
game.addLog('自动战斗已开启 - 战斗结束后自动寻找新敌人', 'info')
|
||
// 如果当前不在战斗中且在危险区域,立即开始战斗
|
||
if (!game.inCombat) {
|
||
const enemyConfig = getRandomEnemyForLocation(player.currentLocation)
|
||
if (enemyConfig) {
|
||
startCombat()
|
||
}
|
||
}
|
||
} else {
|
||
// 关闭自动战斗时,清除寻找中状态
|
||
game.isSearching = false
|
||
game.addLog('自动战斗已关闭', 'info')
|
||
}
|
||
}
|
||
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.map-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
padding: 16rpx;
|
||
background-color: $bg-primary;
|
||
|
||
&__header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16rpx;
|
||
background-color: $bg-secondary;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
&__description {
|
||
padding: 16rpx;
|
||
background-color: $bg-secondary;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
&__minimap {
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
&__locations-header {
|
||
padding: 12rpx 16rpx 8rpx;
|
||
}
|
||
|
||
&__locations {
|
||
flex: 1;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
&__actions {
|
||
background-color: $bg-secondary;
|
||
border-radius: 12rpx;
|
||
padding: 16rpx;
|
||
}
|
||
}
|
||
|
||
.location-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.location-icon {
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.location-name {
|
||
color: $text-primary;
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.description-text {
|
||
color: $text-secondary;
|
||
font-size: 24rpx;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.combat-status {
|
||
padding: 16rpx;
|
||
background-color: rgba($danger, 0.1);
|
||
border: 1rpx solid $danger;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
|
||
&__title {
|
||
display: block;
|
||
color: $danger;
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
&__enemy {
|
||
display: block;
|
||
color: $text-primary;
|
||
font-size: 26rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
}
|
||
|
||
// 寻找中状态样式
|
||
.searching-status {
|
||
padding: 16rpx;
|
||
background-color: rgba($accent, 0.1);
|
||
border: 1rpx solid $accent;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
text-align: center;
|
||
|
||
&__title {
|
||
display: block;
|
||
color: $accent;
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
&__desc {
|
||
display: block;
|
||
color: $text-secondary;
|
||
font-size: 24rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
&__dots {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 12rpx;
|
||
|
||
.dot {
|
||
color: $accent;
|
||
font-size: 24rpx;
|
||
animation: pulse 1.5s ease-in-out infinite;
|
||
|
||
&:nth-child(2) {
|
||
animation-delay: 0.3s;
|
||
}
|
||
|
||
&:nth-child(3) {
|
||
animation-delay: 0.6s;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% {
|
||
opacity: 0.3;
|
||
transform: scale(0.8);
|
||
}
|
||
50% {
|
||
opacity: 1;
|
||
transform: scale(1.2);
|
||
}
|
||
}
|
||
|
||
.locations-title {
|
||
color: $text-secondary;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.location-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16rpx;
|
||
background-color: $bg-secondary;
|
||
border-radius: 8rpx;
|
||
margin-bottom: 8rpx;
|
||
transition: background-color 0.2s;
|
||
|
||
&:active {
|
||
background-color: $bg-tertiary;
|
||
}
|
||
|
||
&--locked {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
&--current {
|
||
opacity: 0.8;
|
||
border: 1rpx solid $accent;
|
||
}
|
||
|
||
&__main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
&__name {
|
||
color: $text-primary;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
&__type {
|
||
color: $text-secondary;
|
||
font-size: 22rpx;
|
||
}
|
||
|
||
&__lock {
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
&__current {
|
||
color: $accent;
|
||
font-size: 22rpx;
|
||
}
|
||
|
||
&__arrow {
|
||
color: $text-muted;
|
||
font-size: 24rpx;
|
||
}
|
||
}
|
||
|
||
.actions-title {
|
||
display: block;
|
||
color: $text-secondary;
|
||
font-size: 24rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.actions-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.npcs-title {
|
||
display: block;
|
||
color: $text-secondary;
|
||
font-size: 24rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.npcs-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
// 自动战斗开关样式
|
||
.map-panel__auto-combat {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16rpx;
|
||
background-color: $bg-secondary;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.auto-combat-label {
|
||
color: $text-primary;
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.auto-combat-toggle {
|
||
position: relative;
|
||
width: 100rpx;
|
||
height: 48rpx;
|
||
background-color: $bg-tertiary;
|
||
border-radius: 24rpx;
|
||
cursor: pointer;
|
||
transition: background-color 0.3s;
|
||
|
||
&--active {
|
||
background-color: $accent;
|
||
}
|
||
|
||
&__slider {
|
||
position: absolute;
|
||
top: 4rpx;
|
||
left: 4rpx;
|
||
width: 88rpx;
|
||
height: 40rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: $bg-primary;
|
||
border-radius: 20rpx;
|
||
font-size: 20rpx;
|
||
font-weight: bold;
|
||
transition: transform 0.3s;
|
||
color: $text-secondary;
|
||
}
|
||
|
||
&--active &__slider {
|
||
background-color: rgba(255, 255, 255, 0.9);
|
||
color: $accent;
|
||
}
|
||
}
|
||
</style>
|