Files
text-adventure-game/components/common/MiniMap.vue
Claude 27e1c8d440 feat: 添加可视化地图系统
- 创建地图布局工具(mapLayout.js)
  - 预定义区域位置配置
  - BFS计算可达距离和最短路径
  - 地图数据结构构建
- 添加迷你地图组件(MiniMap.vue)
  - 可视化显示所有区域节点和连接线
  - 当前位置脉冲动画高亮
  - 相邻区域虚线标记
  - 锁定区域显示🔒图标
  - 显示到各区域的距离步数
  - 点击区域显示前往路径
  - 图例说明不同区域类型

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:09:44 +08:00

455 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="mini-map">
<!-- 地图容器 -->
<view class="mini-map__container">
<!-- 连接线层 -->
<view class="mini-map__connections">
<view
v-for="edge in mapData.edges"
:key="`${edge.from}-${edge.to}`"
class="connection-line"
:class="{ 'connection-line--path': isInPath(edge) }"
:style="getLineStyle(edge)"
></view>
</view>
<!-- 节点层 -->
<view
v-for="node in mapData.nodes"
:key="node.id"
class="map-node"
:class="getNodeClass(node)"
:style="getNodeStyle(node)"
@click="handleNodeClick(node)"
>
<!-- 节点圆点 -->
<view class="node__dot"></view>
<!-- 当前位置脉冲效果 -->
<view v-if="node.id === currentLocation" class="node__pulse"></view>
<!-- 节点名称 -->
<text class="node__name">{{ node.name }}</text>
<!-- 锁定图标 -->
<text v-if="isLocked(node)" class="node__lock">🔒</text>
<!-- 距离标记 -->
<text v-else-if="getDistance(node) > 0" class="node__distance">{{ getDistance(node) }}</text>
</view>
</view>
<!-- 图例 -->
<view class="mini-map__legend">
<view class="legend-item">
<view class="legend-dot legend-dot--current"></view>
<text class="legend-text">当前位置</text>
</view>
<view class="legend-item">
<view class="legend-dot legend-dot--safe"></view>
<text class="legend-text">安全</text>
</view>
<view class="legend-item">
<view class="legend-dot legend-dot--danger"></view>
<text class="legend-text">危险</text>
</view>
<view class="legend-item">
<view class="legend-dot legend-dot--dungeon"></view>
<text class="legend-text">副本</text>
</view>
</view>
<!-- 路径提示 -->
<view v-if="selectedPath.length > 1" class="mini-map__path">
<text class="path-text">前往路径: {{ selectedPath.map(id => getLocationName(id)).join(' → ') }}</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useGameStore } from '@/store/game'
import { usePlayerStore } from '@/store/player'
import { LOCATION_CONFIG } from '@/config/locations'
import { getMapData, getPath } from '@/utils/mapLayout.js'
const game = useGameStore()
const player = usePlayerStore()
const mapData = ref({ nodes: [], edges: [], width: 300, height: 400 })
const selectedPath = ref([])
const currentLocation = computed(() => player.currentLocation)
// 初始化地图
function initMap() {
const data = getMapData(player.currentLocation)
// 缩放坐标以适应容器
const containerWidth = 280
const containerHeight = 340
const scaleX = containerWidth / data.width
const scaleY = containerHeight / data.height
const scale = Math.min(scaleX, scaleY, 1)
// 居中偏移
const offsetX = (containerWidth - data.width * scale) / 2 + 10
const offsetY = (containerHeight - data.height * scale) / 2 + 10
mapData.value = {
nodes: data.nodes.map(n => ({
...n,
x: n.x * scale + offsetX,
y: n.y * scale + offsetY
})),
edges: data.edges,
width: data.width,
height: data.height,
reachable: data.reachable
}
// 更新缩放比例供样式使用
updateNodeStyles()
selectedPath.value = []
}
// 更新节点样式转换为rpx
function updateNodeStyles() {
// 在实际渲染时使用百分比或rpx
}
// 获取节点样式
function getNodeStyle(node) {
return {
left: node.x + 'px',
top: node.y + 'px'
}
}
// 获取节点样式类
function getNodeClass(node) {
const classes = []
if (node.id === currentLocation.value) {
classes.push('map-node--current')
}
if (isLocked(node)) {
classes.push('map-node--locked')
} else {
if (node.type === 'safe') classes.push('map-node--safe')
else if (node.type === 'danger') classes.push('map-node--danger')
else if (node.type === 'dungeon') classes.push('map-node--dungeon')
const dist = getDistance(node)
if (dist === 1) classes.push('map-node--adjacent')
}
return classes.join(' ')
}
// 检查区域是否锁定
function isLocked(node) {
const loc = LOCATION_CONFIG[node.id]
if (!loc?.unlockCondition) return false
if (loc.unlockCondition.type === 'kill') {
const killCount = (player.killCount && player.killCount[loc.unlockCondition.target]) || 0
return killCount < loc.unlockCondition.count
} else if (loc.unlockCondition.type === 'item') {
return !player.inventory.some(i => i.id === loc.unlockCondition.item)
}
return false
}
// 获取距离(步数)
function getDistance(node) {
return mapData.value.reachable?.[node.id] ?? 99
}
// 获取位置名称
function getLocationName(id) {
return LOCATION_CONFIG[id]?.name || id
}
// 获取连接线样式
function getLineStyle(edge) {
const fromNode = mapData.value.nodes.find(n => n.id === edge.from)
const toNode = mapData.value.nodes.find(n => n.id === edge.to)
if (!fromNode || !toNode) return {}
const dx = toNode.x - fromNode.x
const dy = toNode.y - fromNode.y
const length = Math.sqrt(dx * dx + dy * dy)
const angle = Math.atan2(dy, dx) * 180 / Math.PI
return {
left: fromNode.x + 'px',
top: fromNode.y + 'px',
width: length + 'px',
transform: `rotate(${angle}deg)`
}
}
// 检查边是否在选中路径上
function isInPath(edge) {
if (selectedPath.value.length < 2) return false
for (let i = 0; i < selectedPath.value.length - 1; i++) {
const from = selectedPath.value[i]
const to = selectedPath.value[i + 1]
if ((edge.from === from && edge.to === to) ||
(edge.from === to && edge.to === from)) {
return true
}
}
return false
}
// 处理节点点击
function handleNodeClick(node) {
if (node.id === currentLocation.value) {
selectedPath.value = []
return
}
const path = getPath(currentLocation.value, node.id)
if (path) {
selectedPath.value = path
// 如果相邻且未锁定,提示可前往
if (path.length === 2 && !isLocked(node)) {
game.addLog(`可以前往 ${node.name}`, 'info')
}
}
}
// 监听当前位置变化
watch(() => player.currentLocation, () => {
initMap()
})
onMounted(() => {
initMap()
})
</script>
<style lang="scss" scoped>
.mini-map {
display: flex;
flex-direction: column;
gap: 12rpx;
&__container {
position: relative;
width: 100%;
height: 360rpx;
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
border-radius: 12rpx;
overflow: hidden;
border: 2rpx solid #4a5568;
}
&__connections {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
&__legend {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
padding: 12rpx;
background-color: $bg-secondary;
border-radius: 8rpx;
}
&__path {
padding: 10rpx 12rpx;
background-color: rgba($accent, 0.15);
border: 1rpx solid $accent;
border-radius: 8rpx;
}
}
.connection-line {
position: absolute;
height: 2rpx;
background-color: rgba(74, 85, 104, 0.5);
transform-origin: left center;
&--path {
background-color: rgba($warning, 0.8);
height: 3rpx;
box-shadow: 0 0 6rpx rgba($warning, 0.5);
}
}
.map-node {
position: absolute;
width: 64rpx;
height: 64rpx;
transform: translate(-50%, -50%);
cursor: pointer;
transition: all 0.2s;
&:active {
transform: translate(-50%, -50%) scale(1.15);
}
&--current {
z-index: 10;
}
&--locked {
opacity: 0.5;
filter: grayscale(0.5);
}
}
.node__dot {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid;
.map-node--safe & {
background-color: rgba($success, 0.3);
border-color: $success;
}
.map-node--danger & {
background-color: rgba($danger, 0.3);
border-color: $danger;
}
.map-node--dungeon & {
background-color: rgba($accent, 0.3);
border-color: $accent;
}
.map-node--current & {
background-color: $warning;
border-color: #fff;
box-shadow: 0 0 12rpx rgba($warning, 0.8);
}
.map-node--locked & {
background-color: rgba($text-muted, 0.3);
border-color: $text-muted;
}
}
.node__pulse {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background-color: rgba($warning, 0.4);
animation: pulse 1.5s ease-out infinite;
}
@keyframes pulse {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0.8;
}
100% {
transform: translate(-50%, -50%) scale(1.8);
opacity: 0;
}
}
.map-node--adjacent .node__dot::after {
content: '';
position: absolute;
top: -8rpx;
left: -8rpx;
right: -8rpx;
bottom: -8rpx;
border-radius: 50%;
border: 1rpx dashed rgba($warning, 0.6);
}
.node__name {
position: absolute;
top: 40rpx;
left: 50%;
transform: translateX(-50%);
font-size: 20rpx;
color: #e2e8f0;
white-space: nowrap;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.8);
pointer-events: none;
}
.node__lock {
position: absolute;
top: -12rpx;
right: -12rpx;
font-size: 20rpx;
}
.node__distance {
position: absolute;
bottom: -12rpx;
right: -12rpx;
font-size: 18rpx;
color: rgba($text-secondary, 0.8);
background-color: rgba(0, 0, 0, 0.6);
padding: 2rpx 6rpx;
border-radius: 6rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 6rpx;
}
.legend-dot {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
&--current {
background-color: $warning;
}
&--safe {
background-color: $success;
}
&--danger {
background-color: $danger;
}
&--dungeon {
background-color: $accent;
}
}
.legend-text {
font-size: 20rpx;
color: $text-secondary;
}
.path-text {
font-size: 22rpx;
color: $accent;
}
</style>