Files
Claude ccfd6a5e75 fix: 修复武器经验获取并完善义体系统
- 修复战斗胜利后未获得武器技能经验的问题 (initCombat未传递skillExpReward)
- 每级武器技能提供5%武器伤害加成(已实现,无需修改)
- 实现义体安装/卸载功能,支持NPC对话交互
- StatusPanel添加义体装备槽显示
- MapPanel修复NPC对话import问题
- 新增成就系统框架
- 添加项目文档CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:52:32 +08:00

685 lines
16 KiB
Vue
Raw Permalink 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 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, startNPCDialogue } 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')
// 使用 eventSystem 的 startNPCDialogue 来处理对话
startNPCDialogue(game, npc.id, 'first', player)
}
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.preferredStance)
game.inCombat = true
}
function changeStance(stance) {
if (game.combatState) {
game.combatState.stance = stance
game.preferredStance = 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>