Files
text-adventure-game/components/panels/MapPanel.vue
Claude cb412544e9 Initial commit: Text Adventure Game
Features:
- Combat system with AP/EP hit calculation and three-layer defense
- Auto-combat/farming mode
- Item system with stacking support
- Skill system with levels, milestones, and parent skill sync
- Shop system with dynamic pricing
- Inventory management with bulk selling
- Event system
- Game loop with offline earnings
- Save/Load system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:13:51 +08:00

607 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="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 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 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 } from '@/config/enemies.js'
import { getShopConfig } from '@/config/shop.js'
import { initCombat, getEnvironmentType } from '@/utils/combatSystem.js'
import TextButton from '@/components/common/TextButton.vue'
import ProgressBar from '@/components/common/ProgressBar.vue'
import FilterTabs from '@/components/common/FilterTabs.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') {
// 检查击杀条件(需要追踪击杀数)
const killCount = player.flags[`kill_${loc.unlockCondition.target}`] || 0
locked = killCount < loc.unlockCondition.count
lockReason = `需要击杀${loc.unlockCondition.count}${getEnemyName(loc.unlockCondition.target)}`
} 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' }
}
// 战斗中只显示战斗相关
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')
// 探索逻辑
break
case 'combat':
startCombat()
break
case 'flee':
game.addLog('尝试逃跑...', 'combat')
// 逃跑逻辑
break
case 'read':
game.addLog('找个安静的地方阅读...', 'info')
break
}
}
function startCombat() {
const current = LOCATION_CONFIG[player.currentLocation]
if (!current || !current.enemies || current.enemies.length === 0) {
game.addLog('这里没有敌人', 'info')
return
}
const enemyId = current.enemies[0]
const enemyConfig = ENEMY_CONFIG[enemyId]
if (!enemyConfig) {
game.addLog('敌人配置错误', 'error')
return
}
game.addLog(`遇到了 ${enemyConfig.name}!`, 'combat')
// 获取环境类型
const environment = getEnvironmentType(player.currentLocation)
// 使用 initCombat 初始化战斗(包含 expReward 和 drops
game.combatState = initCombat(enemyId, 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 current = LOCATION_CONFIG[player.currentLocation]
if (current && current.enemies && current.enemies.length > 0) {
startCombat()
}
}
} else {
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;
}
&__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;
}
}
.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>