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>
This commit is contained in:
64
components/common/Collapse.vue
Normal file
64
components/common/Collapse.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<view class="collapse">
|
||||
<view class="collapse__header" @click="toggle">
|
||||
<text class="collapse__title">{{ title }}</text>
|
||||
<text class="collapse__arrow">{{ isExpanded ? '▼' : '▶' }}</text>
|
||||
</view>
|
||||
<view v-show="isExpanded" class="collapse__content">
|
||||
<slot></slot>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
expanded: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle'])
|
||||
|
||||
const isExpanded = ref(props.expanded)
|
||||
|
||||
function toggle() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
emit('toggle', isExpanded.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.collapse {
|
||||
border-top: 1rpx solid $border-color;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx;
|
||||
background-color: $bg-secondary;
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
background-color: $bg-tertiary;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: $text-primary;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
color: $text-secondary;
|
||||
font-size: 20rpx;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 16rpx;
|
||||
background-color: $bg-primary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
components/common/FilterTabs.vue
Normal file
49
components/common/FilterTabs.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<view class="filter-tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="filter-tabs__item"
|
||||
:class="{ 'filter-tabs__item--active': modelValue === tab.id }"
|
||||
@click="$emit('update:modelValue', tab.id)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
tabs: { type: Array, default: () => [] },
|
||||
modelValue: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
padding: 8rpx;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__item {
|
||||
padding: 12rpx 20rpx;
|
||||
border-radius: 8rpx;
|
||||
background-color: $bg-tertiary;
|
||||
color: $text-secondary;
|
||||
font-size: 24rpx;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: $accent;
|
||||
color: $bg-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
components/common/ProgressBar.vue
Normal file
53
components/common/ProgressBar.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<view class="progress-bar" :style="{ height }">
|
||||
<view class="progress-bar__fill" :style="fillStyle"></view>
|
||||
<view v-if="showText" class="progress-bar__text">
|
||||
{{ value }}/{{ max }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: Number, default: 0 },
|
||||
max: { type: Number, default: 100 },
|
||||
color: { type: String, default: '#4ecdc4' },
|
||||
showText: { type: Boolean, default: true },
|
||||
height: { type: String, default: '16rpx' }
|
||||
})
|
||||
|
||||
const percentage = computed(() => {
|
||||
return Math.min(100, Math.max(0, (props.value / props.max) * 100))
|
||||
})
|
||||
|
||||
const fillStyle = computed(() => ({
|
||||
width: `${percentage.value}%`,
|
||||
backgroundColor: props.color
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
background-color: $bg-tertiary;
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
&__text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 20rpx;
|
||||
color: $text-primary;
|
||||
text-shadow: 0 0 4rpx rgba(0,0,0,0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
29
components/common/StatItem.vue
Normal file
29
components/common/StatItem.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<view class="stat-item">
|
||||
<text v-if="icon" class="stat-item__icon">{{ icon }}</text>
|
||||
<text class="stat-item__label">{{ label }}</text>
|
||||
<text class="stat-item__value" :style="{ color }">{{ value }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: String,
|
||||
value: [Number, String],
|
||||
icon: String,
|
||||
color: { type: String, default: '#e0e0e0' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.stat-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
|
||||
&__icon { font-size: 28rpx; }
|
||||
&__label { color: $text-secondary; }
|
||||
&__value { font-weight: bold; }
|
||||
}
|
||||
</style>
|
||||
68
components/common/TextButton.vue
Normal file
68
components/common/TextButton.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<view
|
||||
class="text-button"
|
||||
:class="[`text-button--${type}`, { 'text-button--disabled': disabled }]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<text>{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
text: { type: String, required: true },
|
||||
type: { type: String, default: 'default' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
function handleClick() {
|
||||
if (!props.disabled) {
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.text-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16rpx 32rpx;
|
||||
border-radius: 8rpx;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--default {
|
||||
background-color: $bg-tertiary;
|
||||
color: $text-primary;
|
||||
|
||||
&:active {
|
||||
background-color: $bg-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background-color: rgba($danger, 0.2);
|
||||
color: $danger;
|
||||
|
||||
&:active {
|
||||
background-color: rgba($danger, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background-color: rgba($warning, 0.2);
|
||||
color: $warning;
|
||||
|
||||
&:active {
|
||||
background-color: rgba($warning, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
147
components/drawers/EventDrawer.vue
Normal file
147
components/drawers/EventDrawer.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<view class="event-drawer">
|
||||
<view class="event-header">
|
||||
<text class="event-title">{{ eventData.title }}</text>
|
||||
<text class="event-close" @click="close">×</text>
|
||||
</view>
|
||||
|
||||
<view class="event-content">
|
||||
<!-- NPC信息 -->
|
||||
<view v-if="eventData.npc" class="npc-info">
|
||||
<text class="npc-name">{{ eventData.npc.name }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 对话文本 -->
|
||||
<scroll-view class="dialogue-text" scroll-y>
|
||||
<text class="dialogue-content">{{ eventData.text }}</text>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 选项按钮 -->
|
||||
<view class="dialogue-choices">
|
||||
<TextButton
|
||||
v-for="choice in eventData.choices"
|
||||
:key="choice.text"
|
||||
:text="choice.text"
|
||||
@click="selectChoice(choice)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useGameStore } from '@/store/game'
|
||||
import { usePlayerStore } from '@/store/player'
|
||||
import { handleDialogueChoice } from '@/utils/eventSystem'
|
||||
import TextButton from '@/components/common/TextButton.vue'
|
||||
|
||||
const game = useGameStore()
|
||||
const player = usePlayerStore()
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const eventData = computed(() => {
|
||||
if (!game.currentEvent) {
|
||||
return {
|
||||
title: '事件',
|
||||
npc: null,
|
||||
text: '暂无事件',
|
||||
choices: []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: game.currentEvent.npc?.name || '事件',
|
||||
npc: game.currentEvent.npc,
|
||||
text: game.currentEvent.text || '',
|
||||
choices: game.currentEvent.choices || []
|
||||
}
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function selectChoice(choice) {
|
||||
const result = handleDialogueChoice(game, player, choice)
|
||||
|
||||
if (result.closeEvent) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.event-drawer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $bg-secondary;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
color: $text-primary;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.event-close {
|
||||
color: $text-secondary;
|
||||
font-size: 48rpx;
|
||||
padding: 0 16rpx;
|
||||
|
||||
&:active {
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.event-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.npc-info {
|
||||
padding: 16rpx;
|
||||
background-color: $bg-primary;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.npc-name {
|
||||
color: $accent;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dialogue-text {
|
||||
flex: 1;
|
||||
max-height: 400rpx;
|
||||
margin-bottom: 24rpx;
|
||||
padding: 16rpx;
|
||||
background-color: $bg-primary;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.dialogue-content {
|
||||
color: $text-primary;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.dialogue-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
</style>
|
||||
398
components/drawers/InventoryDrawer.vue
Normal file
398
components/drawers/InventoryDrawer.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<view class="inventory-drawer">
|
||||
<view class="drawer-header">
|
||||
<text class="drawer-title">📦 背包</text>
|
||||
<text class="drawer-close" @click="close">×</text>
|
||||
</view>
|
||||
|
||||
<FilterTabs
|
||||
:tabs="filterTabs"
|
||||
:modelValue="currentFilter"
|
||||
@update:modelValue="currentFilter = $event"
|
||||
/>
|
||||
|
||||
<scroll-view class="inventory-list" scroll-y>
|
||||
<view
|
||||
v-for="item in filteredItems"
|
||||
:key="item.uniqueId || item.id"
|
||||
class="inventory-item"
|
||||
:class="{ 'inventory-item--equipped': isEquipped(item) }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<text class="inventory-item__icon">{{ item.icon || '📦' }}</text>
|
||||
<view class="inventory-item__info">
|
||||
<text class="inventory-item__name" :style="{ color: qualityColor(item.quality) }">
|
||||
{{ item.name }}
|
||||
</text>
|
||||
<text v-if="item.count > 1" class="inventory-item__count">x{{ item.count }}</text>
|
||||
</view>
|
||||
<text v-if="isEquipped(item)" class="inventory-item__equipped-badge">E</text>
|
||||
</view>
|
||||
<view v-if="filteredItems.length === 0" class="inventory-empty">
|
||||
<text class="inventory-empty__text">背包空空如也</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 物品详情 -->
|
||||
<view v-if="selectedItem" class="item-detail">
|
||||
<view class="item-detail__header">
|
||||
<text class="item-detail__name">{{ selectedItem.name }}</text>
|
||||
<text class="item-detail__close" @click="selectedItem = null">×</text>
|
||||
</view>
|
||||
<text class="item-detail__desc">{{ selectedItem.description }}</text>
|
||||
<text v-if="isEquipped(selectedItem)" class="item-detail__equipped">⚔️ 已装备</text>
|
||||
<view class="item-detail__actions">
|
||||
<TextButton
|
||||
v-if="canEquip && !isEquipped(selectedItem)"
|
||||
text="装备"
|
||||
type="primary"
|
||||
@click="equipItem"
|
||||
/>
|
||||
<TextButton
|
||||
v-if="canEquip && isEquipped(selectedItem)"
|
||||
text="卸下"
|
||||
type="warning"
|
||||
@click="unequipItem"
|
||||
/>
|
||||
<TextButton
|
||||
v-if="canUse"
|
||||
text="使用"
|
||||
@click="useItem"
|
||||
/>
|
||||
<TextButton
|
||||
text="出售"
|
||||
type="default"
|
||||
@click="sellItem"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { usePlayerStore } from '@/store/player'
|
||||
import { useGameStore } from '@/store/game'
|
||||
import FilterTabs from '@/components/common/FilterTabs.vue'
|
||||
import TextButton from '@/components/common/TextButton.vue'
|
||||
|
||||
const player = usePlayerStore()
|
||||
const game = useGameStore()
|
||||
|
||||
const currentFilter = ref('all')
|
||||
const selectedItem = ref(null)
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const filterTabs = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'weapon', label: '武器' },
|
||||
{ id: 'armor', label: '防具' },
|
||||
{ id: 'consumable', label: '消耗品' },
|
||||
{ id: 'material', label: '素材' }
|
||||
]
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const items = player.inventory || []
|
||||
if (currentFilter.value === 'all') {
|
||||
return items
|
||||
}
|
||||
return items.filter(item => item.type === currentFilter.value)
|
||||
})
|
||||
|
||||
const canEquip = computed(() => {
|
||||
if (!selectedItem.value) return false
|
||||
return ['weapon', 'armor', 'shield', 'accessory'].includes(selectedItem.value.type)
|
||||
})
|
||||
|
||||
const canUse = computed(() => {
|
||||
if (!selectedItem.value) return false
|
||||
return selectedItem.value.type === 'consumable'
|
||||
})
|
||||
|
||||
// 装备槽位映射
|
||||
const slotMap = {
|
||||
weapon: 'weapon',
|
||||
armor: 'armor',
|
||||
shield: 'shield',
|
||||
accessory: 'accessory'
|
||||
}
|
||||
|
||||
// 检查物品是否已装备
|
||||
function isEquipped(item) {
|
||||
if (!item || !item.type) return false
|
||||
const slot = slotMap[item.type]
|
||||
if (!slot) return false
|
||||
const equipped = player.equipment[slot]
|
||||
// 通过 uniqueId 或 id 比较
|
||||
return equipped && (equipped.uniqueId === item.uniqueId || equipped.id === item.id)
|
||||
}
|
||||
|
||||
function qualityColor(quality) {
|
||||
if (!quality) return '#ffffff'
|
||||
if (quality >= 200) return '#f97316' // 传说
|
||||
if (quality >= 160) return '#a855f7' // 史诗
|
||||
if (quality >= 130) return '#60a5fa' // 稀有
|
||||
if (quality >= 100) return '#4ade80' // 优秀
|
||||
if (quality >= 50) return '#ffffff' // 普通
|
||||
return '#808080' // 垃圾
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function selectItem(item) {
|
||||
selectedItem.value = item
|
||||
}
|
||||
|
||||
function equipItem() {
|
||||
if (!selectedItem.value) return
|
||||
const item = selectedItem.value
|
||||
const slot = slotMap[item.type]
|
||||
|
||||
if (!slot) return
|
||||
|
||||
// 如果该槽位已有装备,先卸下旧装备放回背包
|
||||
const oldEquip = player.equipment[slot]
|
||||
if (oldEquip) {
|
||||
player.inventory.push(oldEquip)
|
||||
}
|
||||
|
||||
// 从背包中移除该物品
|
||||
const index = player.inventory.findIndex(i =>
|
||||
(i.uniqueId === item.uniqueId) || (i.id === item.id && i.uniqueId === undefined)
|
||||
)
|
||||
if (index > -1) {
|
||||
player.inventory.splice(index, 1)
|
||||
}
|
||||
|
||||
// 装备该物品
|
||||
player.equipment[slot] = item
|
||||
|
||||
game.addLog(`装备了 ${item.name}`, 'info')
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
function unequipItem() {
|
||||
if (!selectedItem.value) return
|
||||
const item = selectedItem.value
|
||||
const slot = slotMap[item.type]
|
||||
|
||||
if (!slot) return
|
||||
|
||||
// 从装备槽移除
|
||||
player.equipment[slot] = null
|
||||
|
||||
// 放回背包
|
||||
player.inventory.push(item)
|
||||
|
||||
game.addLog(`卸下了 ${item.name}`, 'info')
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
function useItem() {
|
||||
if (!selectedItem.value) return
|
||||
const item = selectedItem.value
|
||||
|
||||
// 检查是否是消耗品
|
||||
if (item.type !== 'consumable') return
|
||||
|
||||
// 应用效果
|
||||
if (item.effect) {
|
||||
if (item.effect.health) {
|
||||
player.currentStats.health = Math.min(
|
||||
player.currentStats.maxHealth,
|
||||
player.currentStats.health + item.effect.health
|
||||
)
|
||||
}
|
||||
if (item.effect.stamina) {
|
||||
player.currentStats.stamina = Math.min(
|
||||
player.currentStats.maxStamina,
|
||||
player.currentStats.stamina + item.effect.stamina
|
||||
)
|
||||
}
|
||||
if (item.effect.sanity) {
|
||||
player.currentStats.sanity = Math.min(
|
||||
player.currentStats.maxSanity,
|
||||
player.currentStats.sanity + item.effect.sanity
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 从背包中移除(如果是堆叠物品,减少数量)
|
||||
if (item.count > 1) {
|
||||
item.count--
|
||||
} else {
|
||||
const index = player.inventory.findIndex(i => i.id === item.id)
|
||||
if (index > -1) {
|
||||
player.inventory.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
game.addLog(`使用了 ${item.name}`, 'info')
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
function sellItem() {
|
||||
if (!selectedItem.value) return
|
||||
// 已装备的物品不能出售
|
||||
if (isEquipped(selectedItem.value)) {
|
||||
game.addLog('已装备的物品需要先卸下才能出售', 'error')
|
||||
return
|
||||
}
|
||||
game.addLog(`出售了 ${selectedItem.value.name}`, 'info')
|
||||
// 出售逻辑待完善
|
||||
selectedItem.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.inventory-drawer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $bg-secondary;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
color: $text-primary;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
color: $text-secondary;
|
||||
font-size: 48rpx;
|
||||
padding: 0 16rpx;
|
||||
|
||||
&:active {
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.inventory-list {
|
||||
flex: 1;
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.inventory-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx;
|
||||
background-color: $bg-primary;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 8rpx;
|
||||
|
||||
&:active {
|
||||
background-color: $bg-tertiary;
|
||||
}
|
||||
|
||||
&--equipped {
|
||||
background-color: rgba($accent, 0.15);
|
||||
border: 1rpx solid $accent;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 40rpx;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__name {
|
||||
display: block;
|
||||
color: $text-primary;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
&__count {
|
||||
display: block;
|
||||
color: $text-secondary;
|
||||
font-size: 22rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
&__equipped-badge {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: $accent;
|
||||
color: $bg-primary;
|
||||
font-size: 18rpx;
|
||||
font-weight: bold;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.inventory-empty {
|
||||
padding: 80rpx;
|
||||
text-align: center;
|
||||
|
||||
&__text {
|
||||
color: $text-muted;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
padding: 24rpx;
|
||||
background-color: $bg-primary;
|
||||
border-top: 1rpx solid $border-color;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__name {
|
||||
color: $text-primary;
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__close {
|
||||
color: $text-secondary;
|
||||
font-size: 40rpx;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
display: block;
|
||||
color: $text-secondary;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__equipped {
|
||||
display: inline-block;
|
||||
padding: 4rpx 12rpx;
|
||||
background-color: rgba($accent, 0.2);
|
||||
color: $accent;
|
||||
font-size: 22rpx;
|
||||
border-radius: 4rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
633
components/drawers/ShopDrawer.vue
Normal file
633
components/drawers/ShopDrawer.vue
Normal file
@@ -0,0 +1,633 @@
|
||||
<template>
|
||||
<view class="shop-drawer">
|
||||
<view class="drawer-header">
|
||||
<view class="header-left">
|
||||
<text class="drawer-title">{{ shopConfig.icon }} {{ shopConfig.name }}</text>
|
||||
<text class="drawer-owner">{{ shopConfig.owner }}</text>
|
||||
</view>
|
||||
<text class="drawer-close" @click="close">×</text>
|
||||
</view>
|
||||
|
||||
<!-- 玩家货币显示 -->
|
||||
<view class="currency-display">
|
||||
<text class="currency-label">你的金币:</text>
|
||||
<text class="currency-value">{{ formatCurrency(player.currency.copper) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 买/卖切换 -->
|
||||
<FilterTabs
|
||||
:tabs="modeTabs"
|
||||
:modelValue="currentMode"
|
||||
@update:modelValue="currentMode = $event"
|
||||
/>
|
||||
|
||||
<!-- 批量操作栏(仅出售模式) -->
|
||||
<view v-if="currentMode === 'sell'" class="bulk-actions">
|
||||
<view class="bulk-actions__left">
|
||||
<text class="bulk-actions__count">已选: {{ selectedItems.length }}</text>
|
||||
<text class="bulk-actions__total">总价: {{ totalSellPrice }}铜</text>
|
||||
</view>
|
||||
<view class="bulk-actions__right">
|
||||
<text
|
||||
v-if="!bulkMode"
|
||||
class="bulk-actions__link"
|
||||
@click="bulkMode = true"
|
||||
>
|
||||
批量出售
|
||||
</text>
|
||||
<template v-else>
|
||||
<text class="bulk-actions__link" @click="selectAll">全选</text>
|
||||
<text class="bulk-actions__link" @click="cancelBulk">取消</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 购买列表 -->
|
||||
<scroll-view v-if="currentMode === 'buy'" class="shop-list" scroll-y>
|
||||
<view
|
||||
v-for="item in shopItems"
|
||||
:key="item.id"
|
||||
class="shop-item"
|
||||
:class="{ 'shop-item--disabled': !canBuy(item) }"
|
||||
@click="tryBuy(item)"
|
||||
>
|
||||
<view class="shop-item__main">
|
||||
<text class="shop-item__icon">{{ item.icon || '📦' }}</text>
|
||||
<view class="shop-item__info">
|
||||
<text class="shop-item__name">{{ item.name }}</text>
|
||||
<text class="shop-item__desc">{{ item.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="shop-item__right">
|
||||
<text class="shop-item__price">{{ getBuyPrice(item.id) }}铜</text>
|
||||
<text v-if="item.stock > 0" class="shop-item__stock">库存:{{ item.stock }}</text>
|
||||
<text v-else class="shop-item__stock">无限</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="shopItems.length === 0" class="shop-empty">
|
||||
<text class="shop-empty__text">商店空空如也</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 出售列表 -->
|
||||
<scroll-view v-else class="shop-list" scroll-y>
|
||||
<view
|
||||
v-for="item in sellableItems"
|
||||
:key="item.uniqueId || item.id"
|
||||
class="shop-item"
|
||||
:class="{
|
||||
'shop-item--selected': isItemSelected(item),
|
||||
'shop-item--bulk': bulkMode
|
||||
}"
|
||||
@click="handleSellItemClick(item)"
|
||||
>
|
||||
<!-- 多选复选框 -->
|
||||
<view v-if="bulkMode" class="shop-item__checkbox">
|
||||
<text v-if="isItemSelected(item)" class="checkbox-icon">✓</text>
|
||||
</view>
|
||||
<view class="shop-item__main">
|
||||
<text class="shop-item__icon">{{ item.icon || '📦' }}</text>
|
||||
<view class="shop-item__info">
|
||||
<text class="shop-item__name">{{ item.name }}</text>
|
||||
<text v-if="item.count > 1" class="shop-item__count">x{{ item.count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="shop-item__price">{{ getSellPrice(item) }}铜</text>
|
||||
</view>
|
||||
<view v-if="sellableItems.length === 0" class="shop-empty">
|
||||
<text class="shop-empty__text">没有可出售的物品</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 批量出售按钮 -->
|
||||
<view v-if="bulkMode && selectedItems.length > 0" class="bulk-confirm-bar">
|
||||
<text class="bulk-confirm-text">出售 {{ selectedItems.length }} 件物品,获得 {{ totalSellPrice }} 铜币</text>
|
||||
<TextButton text="确认出售" type="primary" @click="confirmBulkSell" />
|
||||
</view>
|
||||
|
||||
<!-- 物品详情弹窗 -->
|
||||
<view v-if="selectedItem" class="item-detail-popup" @click="selectedItem = null">
|
||||
<view class="item-detail" @click.stop>
|
||||
<text class="item-detail__name">{{ selectedItem.name }}</text>
|
||||
<text class="item-detail__desc">{{ selectedItem.description }}</text>
|
||||
<view class="item-detail__actions">
|
||||
<TextButton
|
||||
v-if="currentMode === 'buy' && selectedItem.buyItem"
|
||||
text="购买"
|
||||
type="primary"
|
||||
@click="confirmBuy"
|
||||
/>
|
||||
<TextButton
|
||||
v-if="currentMode === 'sell' && selectedItem.sellItem"
|
||||
text="出售"
|
||||
type="primary"
|
||||
@click="confirmSell"
|
||||
/>
|
||||
<TextButton
|
||||
text="取消"
|
||||
@click="selectedItem = null"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { usePlayerStore } from '@/store/player'
|
||||
import { useGameStore } from '@/store/game'
|
||||
import { getShopConfig, getBuyPrice as calcBuyPrice, getSellPrice as calcSellPrice } from '@/config/shop.js'
|
||||
import { addItemToInventory, removeItemFromInventory } from '@/utils/itemSystem.js'
|
||||
import FilterTabs from '@/components/common/FilterTabs.vue'
|
||||
import TextButton from '@/components/common/TextButton.vue'
|
||||
|
||||
const player = usePlayerStore()
|
||||
const game = useGameStore()
|
||||
|
||||
const currentMode = ref('buy')
|
||||
const selectedItem = ref(null)
|
||||
const bulkMode = ref(false)
|
||||
const selectedItems = ref([])
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const modeTabs = [
|
||||
{ id: 'buy', label: '购买' },
|
||||
{ id: 'sell', label: '出售' }
|
||||
]
|
||||
|
||||
// 获取当前商店配置
|
||||
const shopConfig = computed(() => {
|
||||
const config = getShopConfig(player.currentLocation)
|
||||
return config || { name: '未知商店', owner: '未知', icon: '🏪', items: [] }
|
||||
})
|
||||
|
||||
// 商店可售物品
|
||||
const shopItems = computed(() => {
|
||||
const config = shopConfig.value
|
||||
if (!config.items) return []
|
||||
|
||||
return config.items
|
||||
.filter(item => item.stock === -1 || item.stock > 0)
|
||||
.map(shopItem => {
|
||||
const itemConfig = { ...shopItem }
|
||||
itemConfig.id = shopItem.itemId
|
||||
return itemConfig
|
||||
})
|
||||
})
|
||||
|
||||
// 玩家可出售物品
|
||||
const sellableItems = computed(() => {
|
||||
return (player.inventory || []).filter(item => {
|
||||
// 关键道具不能出售
|
||||
if (item.type === 'key') return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 计算选中物品总价
|
||||
const totalSellPrice = computed(() => {
|
||||
return selectedItems.value.reduce((total, item) => {
|
||||
return total + getSellPrice(item) * item.count
|
||||
}, 0)
|
||||
})
|
||||
|
||||
function formatCurrency(copper) {
|
||||
if (copper >= 10000) {
|
||||
const gold = Math.floor(copper / 10000)
|
||||
const silver = Math.floor((copper % 10000) / 100)
|
||||
const remaining = copper % 100
|
||||
return `${gold}金 ${silver}银 ${remaining}铜`
|
||||
} else if (copper >= 100) {
|
||||
const silver = Math.floor(copper / 100)
|
||||
const remaining = copper % 100
|
||||
return `${silver}银 ${remaining}铜`
|
||||
}
|
||||
return `${copper}铜`
|
||||
}
|
||||
|
||||
function getBuyPrice(itemId) {
|
||||
return calcBuyPrice(game, itemId)
|
||||
}
|
||||
|
||||
function getSellPrice(item) {
|
||||
return calcSellPrice(game, item.id, item.quality || 100)
|
||||
}
|
||||
|
||||
function canBuy(item) {
|
||||
const price = getBuyPrice(item.id)
|
||||
return player.currency.copper >= price
|
||||
}
|
||||
|
||||
function tryBuy(item) {
|
||||
if (!canBuy(item)) {
|
||||
game.addLog('金币不足!', 'error')
|
||||
return
|
||||
}
|
||||
selectedItem.value = {
|
||||
...item,
|
||||
buyItem: true
|
||||
}
|
||||
}
|
||||
|
||||
function trySell(item) {
|
||||
selectedItem.value = {
|
||||
...item,
|
||||
sellItem: true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查物品是否被选中
|
||||
function isItemSelected(item) {
|
||||
return selectedItems.value.some(i =>
|
||||
(i.uniqueId && i.uniqueId === item.uniqueId) || i.id === item.id
|
||||
)
|
||||
}
|
||||
|
||||
// 处理出售列表点击
|
||||
function handleSellItemClick(item) {
|
||||
if (bulkMode.value) {
|
||||
// 批量模式:切换选中状态
|
||||
const index = selectedItems.value.findIndex(i =>
|
||||
(i.uniqueId && i.uniqueId === item.uniqueId) || i.id === item.id
|
||||
)
|
||||
if (index > -1) {
|
||||
selectedItems.value.splice(index, 1)
|
||||
} else {
|
||||
selectedItems.value.push(item)
|
||||
}
|
||||
} else {
|
||||
// 普通模式:显示详情
|
||||
trySell(item)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选
|
||||
function selectAll() {
|
||||
selectedItems.value = [...sellableItems.value]
|
||||
}
|
||||
|
||||
// 取消批量模式
|
||||
function cancelBulk() {
|
||||
bulkMode.value = false
|
||||
selectedItems.value = []
|
||||
}
|
||||
|
||||
// 批量出售
|
||||
function confirmBulkSell() {
|
||||
if (selectedItems.value.length === 0) return
|
||||
|
||||
let totalEarned = 0
|
||||
const itemsToRemove = []
|
||||
|
||||
for (const item of selectedItems.value) {
|
||||
const price = getSellPrice(item) * item.count
|
||||
totalEarned += price
|
||||
itemsToRemove.push(item)
|
||||
}
|
||||
|
||||
// 移除所有选中的物品
|
||||
for (const item of itemsToRemove) {
|
||||
removeItemFromInventory(player, item.uniqueId || item.id)
|
||||
}
|
||||
|
||||
// 增加金币
|
||||
player.currency.copper += totalEarned
|
||||
game.addLog(`批量出售了 ${itemsToRemove.length} 件物品,获得 ${totalEarned} 铜币`, 'reward')
|
||||
|
||||
// 重置选择
|
||||
bulkMode.value = false
|
||||
selectedItems.value = []
|
||||
}
|
||||
|
||||
function confirmBuy() {
|
||||
if (!selectedItem.value) return
|
||||
|
||||
const item = selectedItem.value
|
||||
const price = getBuyPrice(item.id)
|
||||
|
||||
if (player.currency.copper < price) {
|
||||
game.addLog('金币不足!', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 扣除金币
|
||||
player.currency.copper -= price
|
||||
|
||||
// 添加物品
|
||||
const result = addItemToInventory(player, item.id, 1)
|
||||
|
||||
if (result.success) {
|
||||
game.addLog(`购买了 ${result.item.name}`, 'reward')
|
||||
|
||||
// 减少商店库存
|
||||
const shop = getShopConfig(player.currentLocation)
|
||||
if (shop) {
|
||||
const shopItem = shop.items.find(i => i.itemId === item.id)
|
||||
if (shopItem && shopItem.stock > 0) {
|
||||
shopItem.stock--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
function confirmSell() {
|
||||
if (!selectedItem.value) return
|
||||
|
||||
const item = selectedItem.value
|
||||
const price = getSellPrice(item)
|
||||
|
||||
// 移除物品
|
||||
const result = removeItemFromInventory(player, item.uniqueId || item.id)
|
||||
|
||||
if (result.success) {
|
||||
// 增加金币
|
||||
player.currency.copper += price
|
||||
game.addLog(`出售了 ${item.name},获得 ${price} 铜币`, 'reward')
|
||||
}
|
||||
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.shop-drawer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $bg-secondary;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
display: block;
|
||||
color: $text-primary;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.drawer-owner {
|
||||
display: block;
|
||||
color: $text-secondary;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
color: $text-secondary;
|
||||
font-size: 48rpx;
|
||||
padding: 0 16rpx;
|
||||
|
||||
&:active {
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.currency-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx;
|
||||
background-color: rgba($accent, 0.1);
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.currency-label {
|
||||
color: $text-secondary;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.currency-value {
|
||||
color: $accent;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12rpx 24rpx;
|
||||
background-color: rgba($bg-primary, 0.5);
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
&__count {
|
||||
color: $text-primary;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
&__total {
|
||||
color: $accent;
|
||||
font-size: 24rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: $accent;
|
||||
font-size: 24rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-confirm-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx;
|
||||
background-color: rgba($accent, 0.1);
|
||||
border-top: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.bulk-confirm-text {
|
||||
color: $text-primary;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.shop-list {
|
||||
flex: 1;
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.shop-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx;
|
||||
background-color: $bg-primary;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 8rpx;
|
||||
|
||||
&:active {
|
||||
background-color: $bg-tertiary;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: rgba($accent, 0.15);
|
||||
border: 1rpx solid $accent;
|
||||
}
|
||||
|
||||
&--bulk {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__checkbox {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid $border-color;
|
||||
border-radius: 4rpx;
|
||||
margin-right: 12rpx;
|
||||
background-color: $bg-secondary;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 40rpx;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
display: block;
|
||||
color: $text-primary;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
display: block;
|
||||
color: $text-secondary;
|
||||
font-size: 22rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
&__count {
|
||||
display: block;
|
||||
color: $text-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
&__price {
|
||||
color: $accent;
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__stock {
|
||||
color: $text-muted;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-icon {
|
||||
color: $accent;
|
||||
font-size: 24rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.shop-empty {
|
||||
padding: 80rpx;
|
||||
text-align: center;
|
||||
|
||||
&__text {
|
||||
color: $text-muted;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.item-detail-popup {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
width: 80%;
|
||||
max-width: 500rpx;
|
||||
padding: 32rpx;
|
||||
background-color: $bg-secondary;
|
||||
border-radius: 16rpx;
|
||||
|
||||
&__name {
|
||||
display: block;
|
||||
color: $text-primary;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
display: block;
|
||||
color: $text-secondary;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
components/layout/Drawer.vue
Normal file
49
components/layout/Drawer.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<view v-if="visible" class="drawer" @click="handleMaskClick">
|
||||
<view class="drawer__content" :style="{ width }" @click.stop>
|
||||
<slot></slot>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
width: { type: String, default: '600rpx' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
function handleMaskClick() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0,0,0,0.6);
|
||||
z-index: 999;
|
||||
|
||||
&__content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: $bg-secondary;
|
||||
border-left: 1rpx solid $border-color;
|
||||
animation: slideIn 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
</style>
|
||||
59
components/layout/TabBar.vue
Normal file
59
components/layout/TabBar.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab-bar__item"
|
||||
:class="{ 'tab-bar__item--active': modelValue === tab.id }"
|
||||
@click="$emit('update:modelValue', tab.id)"
|
||||
>
|
||||
<text v-if="tab.icon" class="tab-bar__icon">{{ tab.icon }}</text>
|
||||
<text class="tab-bar__label">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
tabs: { type: Array, default: () => [] },
|
||||
modelValue: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
height: 100rpx;
|
||||
background-color: $bg-secondary;
|
||||
border-top: 1rpx solid $border-color;
|
||||
|
||||
&__item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
color: $text-secondary;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:active {
|
||||
background-color: $bg-tertiary;
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 20rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
125
components/panels/LogPanel.vue
Normal file
125
components/panels/LogPanel.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<scroll-view
|
||||
class="log-panel"
|
||||
scroll-y
|
||||
:scroll-into-view="scrollToId"
|
||||
:scroll-with-animation="true"
|
||||
:scroll-top="scrollTop"
|
||||
>
|
||||
<view
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="log-item"
|
||||
:class="`log-item--${log.type}`"
|
||||
:id="'log-' + log.id"
|
||||
>
|
||||
<text class="log-item__time">[{{ log.time }}]</text>
|
||||
<text class="log-item__message">{{ log.message }}</text>
|
||||
</view>
|
||||
<view :id="'log-anchor-' + lastLogId" class="log-anchor"></view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch, nextTick, ref } from 'vue'
|
||||
import { useGameStore } from '@/store/game'
|
||||
|
||||
const game = useGameStore()
|
||||
const scrollToId = ref('')
|
||||
const scrollTop = ref(0)
|
||||
|
||||
const logs = computed(() => game.logs)
|
||||
|
||||
const lastLogId = computed(() => {
|
||||
return logs.value.length > 0 ? logs.value[logs.value.length - 1].id : 0
|
||||
})
|
||||
|
||||
watch(lastLogId, (newId) => {
|
||||
if (newId) {
|
||||
nextTick(() => {
|
||||
scrollToId.value = 'log-anchor-' + newId
|
||||
// 重置scrollToId以允许下次滚动
|
||||
setTimeout(() => {
|
||||
scrollToId.value = ''
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.log-panel {
|
||||
height: 100%;
|
||||
padding: 16rpx;
|
||||
background-color: $bg-primary;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 8rpx 0;
|
||||
border-bottom: 1rpx solid $bg-tertiary;
|
||||
|
||||
&__time {
|
||||
color: $text-muted;
|
||||
font-size: 22rpx;
|
||||
margin-right: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__message {
|
||||
color: $text-primary;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// 不同类型的日志样式
|
||||
&--combat {
|
||||
.log-item__message {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
&--system {
|
||||
.log-item__message {
|
||||
color: $accent;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&--reward {
|
||||
.log-item__message {
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
.log-item__message {
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&--warning {
|
||||
.log-item__message {
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
|
||||
&--error {
|
||||
.log-item__message {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
&--success {
|
||||
.log-item__message {
|
||||
color: $success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.log-anchor {
|
||||
height: 1rpx;
|
||||
}
|
||||
</style>
|
||||
606
components/panels/MapPanel.vue
Normal file
606
components/panels/MapPanel.vue
Normal file
@@ -0,0 +1,606 @@
|
||||
<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>
|
||||
423
components/panels/StatusPanel.vue
Normal file
423
components/panels/StatusPanel.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<view class="status-panel">
|
||||
<!-- 资源进度条 -->
|
||||
<view class="status-panel__resources">
|
||||
<view class="resource-row">
|
||||
<text class="resource-label">HP</text>
|
||||
<view class="resource-bar">
|
||||
<ProgressBar
|
||||
:value="player.currentStats.health"
|
||||
:max="player.currentStats.maxHealth"
|
||||
color="#ff6b6b"
|
||||
:showText="true"
|
||||
height="24rpx"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="resource-row">
|
||||
<text class="resource-label">耐力</text>
|
||||
<view class="resource-bar">
|
||||
<ProgressBar
|
||||
:value="player.currentStats.stamina"
|
||||
:max="player.currentStats.maxStamina"
|
||||
color="#ffe66d"
|
||||
:showText="true"
|
||||
height="24rpx"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="resource-row">
|
||||
<text class="resource-label">精神</text>
|
||||
<view class="resource-bar">
|
||||
<ProgressBar
|
||||
:value="player.currentStats.sanity"
|
||||
:max="player.currentStats.maxSanity"
|
||||
color="#4ecdc4"
|
||||
:showText="true"
|
||||
height="24rpx"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主属性摘要 -->
|
||||
<view class="status-panel__stats">
|
||||
<StatItem label="力量" :value="player.baseStats.strength" icon="💪" />
|
||||
<StatItem label="敏捷" :value="player.baseStats.agility" icon="🏃" />
|
||||
<StatItem label="灵巧" :value="player.baseStats.dexterity" icon="🎯" />
|
||||
<StatItem label="智力" :value="player.baseStats.intuition" icon="🧠" />
|
||||
<StatItem label="体质" :value="player.baseStats.vitality" icon="❤️" />
|
||||
</view>
|
||||
|
||||
<!-- 等级信息 -->
|
||||
<view class="status-panel__level">
|
||||
<text class="level-text">Lv.{{ player.level.current }}</text>
|
||||
<ProgressBar
|
||||
:value="player.level.exp"
|
||||
:max="player.level.maxExp"
|
||||
color="#a855f7"
|
||||
:showText="true"
|
||||
height="16rpx"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 货币 -->
|
||||
<view class="status-panel__currency">
|
||||
<text class="currency-icon">💰</text>
|
||||
<text class="currency-text">{{ currencyText }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 装备栏 -->
|
||||
<view class="status-panel__equipment">
|
||||
<text class="section-title">🎽 装备</text>
|
||||
<view class="equipment-slots">
|
||||
<view class="equipment-slot" :class="{ 'equipment-slot--empty': !player.equipment.weapon }">
|
||||
<text class="equipment-slot__label">武器</text>
|
||||
<text v-if="player.equipment.weapon" class="equipment-slot__item">
|
||||
{{ player.equipment.weapon.icon }} {{ player.equipment.weapon.name }}
|
||||
</text>
|
||||
<text v-else class="equipment-slot__empty">空</text>
|
||||
</view>
|
||||
<view class="equipment-slot" :class="{ 'equipment-slot--empty': !player.equipment.armor }">
|
||||
<text class="equipment-slot__label">防具</text>
|
||||
<text v-if="player.equipment.armor" class="equipment-slot__item">
|
||||
{{ player.equipment.armor.icon }} {{ player.equipment.armor.name }}
|
||||
</text>
|
||||
<text v-else class="equipment-slot__empty">空</text>
|
||||
</view>
|
||||
<view class="equipment-slot" :class="{ 'equipment-slot--empty': !player.equipment.shield }">
|
||||
<text class="equipment-slot__label">盾牌</text>
|
||||
<text v-if="player.equipment.shield" class="equipment-slot__item">
|
||||
{{ player.equipment.shield.icon }} {{ player.equipment.shield.name }}
|
||||
</text>
|
||||
<text v-else class="equipment-slot__empty">空</text>
|
||||
</view>
|
||||
<view class="equipment-slot" :class="{ 'equipment-slot--empty': !player.equipment.accessory }">
|
||||
<text class="equipment-slot__label">饰品</text>
|
||||
<text v-if="player.equipment.accessory" class="equipment-slot__item">
|
||||
{{ player.equipment.accessory.icon }} {{ player.equipment.accessory.name }}
|
||||
</text>
|
||||
<text v-else class="equipment-slot__empty">空</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 更多属性折叠 -->
|
||||
<Collapse
|
||||
title="更多属性"
|
||||
:expanded="showMoreStats"
|
||||
@toggle="showMoreStats = $event"
|
||||
>
|
||||
<view class="status-panel__more-stats">
|
||||
<StatItem label="攻击力" :value="finalStats.attack" />
|
||||
<StatItem label="防御力" :value="finalStats.defense" />
|
||||
<StatItem label="暴击率" :value="finalStats.critRate + '%'" />
|
||||
<StatItem label="AP" :value="finalStats.ap" />
|
||||
<StatItem label="EP" :value="finalStats.ep" />
|
||||
</view>
|
||||
</Collapse>
|
||||
|
||||
<!-- 技能筛选 -->
|
||||
<FilterTabs
|
||||
:tabs="skillFilterTabs"
|
||||
:modelValue="currentSkillFilter"
|
||||
@update:modelValue="currentSkillFilter = $event"
|
||||
/>
|
||||
|
||||
<!-- 技能列表 -->
|
||||
<scroll-view class="status-panel__skills" scroll-y>
|
||||
<view v-for="skill in filteredSkills" :key="skill.id" class="skill-item">
|
||||
<text class="skill-item__icon">{{ skill.icon || '⚡' }}</text>
|
||||
<view class="skill-item__info">
|
||||
<text class="skill-item__name">{{ skill.name }}</text>
|
||||
<text class="skill-item__level">Lv.{{ skill.level }}</text>
|
||||
</view>
|
||||
<view class="skill-item__bar">
|
||||
<ProgressBar
|
||||
:value="skill.exp"
|
||||
:max="skill.maxExp"
|
||||
height="8rpx"
|
||||
:showText="false"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="filteredSkills.length === 0" class="skill-empty">
|
||||
<text class="skill-empty__text">暂无技能</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePlayerStore } from '@/store/player'
|
||||
import { SKILL_CONFIG } from '@/config/skills'
|
||||
import ProgressBar from '@/components/common/ProgressBar.vue'
|
||||
import StatItem from '@/components/common/StatItem.vue'
|
||||
import Collapse from '@/components/common/Collapse.vue'
|
||||
import FilterTabs from '@/components/common/FilterTabs.vue'
|
||||
|
||||
const player = usePlayerStore()
|
||||
const showMoreStats = ref(false)
|
||||
const currentSkillFilter = ref('all')
|
||||
|
||||
const skillFilterTabs = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'combat', label: '战斗' },
|
||||
{ id: 'life', label: '生活' },
|
||||
{ id: 'passive', label: '被动' }
|
||||
]
|
||||
|
||||
const currencyText = computed(() => {
|
||||
const c = player.currency.copper
|
||||
const gold = Math.floor(c / 10000)
|
||||
const silver = Math.floor((c % 10000) / 100)
|
||||
const copper = c % 100
|
||||
const parts = []
|
||||
if (gold > 0) parts.push(`${gold}金`)
|
||||
if (silver > 0 || gold > 0) parts.push(`${silver}银`)
|
||||
parts.push(`${copper}铜`)
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
const filteredSkills = computed(() => {
|
||||
const skills = Object.entries(player.skills || {})
|
||||
.filter(([_, skill]) => skill.unlocked)
|
||||
.map(([id, skill]) => ({
|
||||
id,
|
||||
name: SKILL_CONFIG[id]?.name || id,
|
||||
type: SKILL_CONFIG[id]?.type || 'passive',
|
||||
icon: SKILL_CONFIG[id]?.icon || '',
|
||||
level: skill.level,
|
||||
exp: skill.exp,
|
||||
maxExp: SKILL_CONFIG[id]?.expPerLevel(skill.level + 1) || 100
|
||||
}))
|
||||
.sort((a, b) => b.level - a.level || b.exp - a.exp)
|
||||
|
||||
if (currentSkillFilter.value === 'all') {
|
||||
return skills
|
||||
}
|
||||
return skills.filter(s => s.type === currentSkillFilter.value)
|
||||
})
|
||||
|
||||
const finalStats = computed(() => {
|
||||
// 计算最终属性(基础值 + 装备加成)
|
||||
let attack = 0
|
||||
let defense = 0
|
||||
let critRate = 5
|
||||
|
||||
// 武器攻击力
|
||||
if (player.equipment.weapon) {
|
||||
attack += player.equipment.weapon.baseDamage || 0
|
||||
}
|
||||
|
||||
// 防具防御力
|
||||
if (player.equipment.armor) {
|
||||
defense += player.equipment.armor.baseDefense || 0
|
||||
}
|
||||
|
||||
// 计算AP和EP
|
||||
const ap = player.baseStats.dexterity + player.baseStats.intuition * 0.2
|
||||
const ep = player.baseStats.agility + player.baseStats.intuition * 0.2
|
||||
|
||||
return {
|
||||
attack: Math.floor(attack),
|
||||
defense: Math.floor(defense),
|
||||
critRate: critRate,
|
||||
ap: Math.floor(ap),
|
||||
ep: Math.floor(ep)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.status-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16rpx;
|
||||
background-color: $bg-primary;
|
||||
|
||||
&__resources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx;
|
||||
background-color: $bg-secondary;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
padding: 16rpx;
|
||||
background-color: $bg-secondary;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__level {
|
||||
padding: 16rpx;
|
||||
background-color: $bg-secondary;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__currency {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx;
|
||||
background-color: $bg-secondary;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__equipment {
|
||||
padding: 16rpx;
|
||||
background-color: $bg-secondary;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__more-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
&__skills {
|
||||
flex: 1;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.resource-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.resource-bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-label {
|
||||
min-width: 80rpx;
|
||||
color: $text-secondary;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.level-text {
|
||||
display: block;
|
||||
color: $accent;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.currency-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.currency-text {
|
||||
color: $warning;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.skill-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 12rpx;
|
||||
background-color: $bg-secondary;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 8rpx;
|
||||
|
||||
&__icon {
|
||||
font-size: 32rpx;
|
||||
width: 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__name {
|
||||
color: $text-primary;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
&__level {
|
||||
color: $accent;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skill-empty {
|
||||
padding: 48rpx;
|
||||
text-align: center;
|
||||
|
||||
&__text {
|
||||
color: $text-muted;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
color: $text-primary;
|
||||
font-size: 24rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.equipment-slots {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.equipment-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12rpx;
|
||||
background-color: $bg-primary;
|
||||
border-radius: 8rpx;
|
||||
border: 1rpx solid $border-color;
|
||||
|
||||
&--empty {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: $text-secondary;
|
||||
font-size: 20rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
&__item {
|
||||
color: $text-primary;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
color: $text-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user