feat: 实现游戏核心系统和UI组件

核心系统:
- combatSystem: 战斗逻辑、伤害计算、战斗状态管理
- skillSystem: 技能系统、技能解锁、经验值、里程碑
- taskSystem: 任务系统、任务类型、任务执行和完成
- eventSystem: 事件系统、随机事件处理
- environmentSystem: 环境系统、时间流逝、区域效果
- levelingSystem: 升级系统、属性成长
- soundSystem: 音效系统

配置文件:
- enemies: 敌人配置、掉落表
- events: 事件配置、事件效果
- items: 物品配置、装备属性
- locations: 地点配置、探索事件
- skills: 技能配置、技能树

UI组件:
- CraftingDrawer: 制造界面
- InventoryDrawer: 背包界面
- 其他UI优化和动画

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-01-23 16:20:10 +08:00
parent 021f6a54f5
commit 16223c89a5
25 changed files with 2731 additions and 318 deletions

View File

@@ -13,6 +13,7 @@
:class="`log-item--${log.type}`"
:id="'log-' + log.id"
>
<text class="log-item__icon">{{ getLogIcon(log.type) }}</text>
<text class="log-item__time">[{{ log.time }}]</text>
<text class="log-item__message">{{ log.message }}</text>
</view>
@@ -45,52 +46,88 @@ watch(lastLogId, (newId) => {
})
}
}, { immediate: true })
// 获取日志类型图标
function getLogIcon(type) {
const icons = {
combat: '⚔️',
system: '🔔',
reward: '🎁',
info: '',
warning: '⚠️',
error: '❌',
success: '✅',
story: '📖'
}
return icons[type] || '•'
}
</script>
<style lang="scss" scoped>
.log-panel {
height: 100%;
padding: 16rpx;
padding: 12rpx 16rpx;
background-color: $bg-primary;
}
.log-item {
display: flex;
flex-wrap: wrap;
padding: 8rpx 0;
border-bottom: 1rpx solid $bg-tertiary;
align-items: flex-start;
padding: 8rpx 12rpx;
margin-bottom: 4rpx;
border-radius: 6rpx;
background-color: $bg-secondary;
border-left: 3rpx solid transparent;
animation: slideIn 0.2s ease;
&__icon {
font-size: 20rpx;
margin-right: 8rpx;
flex-shrink: 0;
}
&__time {
color: $text-muted;
font-size: 22rpx;
font-size: 20rpx;
margin-right: 8rpx;
flex-shrink: 0;
}
&__message {
color: $text-primary;
font-size: 26rpx;
font-size: 24rpx;
line-height: 1.5;
flex: 1;
word-break: break-word;
}
// 不同类型的日志样式
&--combat {
background-color: rgba($danger, 0.1);
border-left-color: $danger;
.log-item__message {
color: $danger;
}
}
&--system {
background-color: rgba($accent, 0.1);
border-left-color: $accent;
.log-item__message {
color: $accent;
font-weight: bold;
font-weight: 500;
}
}
&--reward {
background-color: rgba($warning, 0.1);
border-left-color: $warning;
.log-item__message {
color: $warning;
font-weight: 500;
}
}
@@ -101,25 +138,55 @@ watch(lastLogId, (newId) => {
}
&--warning {
background-color: rgba($warning, 0.1);
border-left-color: $warning;
.log-item__message {
color: $warning;
}
}
&--error {
background-color: rgba($danger, 0.15);
border-left-color: $danger;
.log-item__message {
color: $danger;
}
}
&--success {
background-color: rgba($success, 0.1);
border-left-color: $success;
.log-item__message {
color: $success;
}
}
&--story {
background-color: rgba($info, 0.1);
border-left-color: $info;
.log-item__message {
color: $info;
font-style: italic;
}
}
}
.log-anchor {
height: 1rpx;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>

View File

@@ -105,9 +105,10 @@ 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 { ENEMY_CONFIG, getRandomEnemyForLocation } from '@/config/enemies.js'
import { getShopConfig } from '@/config/shop.js'
import { initCombat, getEnvironmentType } from '@/utils/combatSystem.js'
import { triggerExploreEvent, tryFlee } from '@/utils/eventSystem.js'
import TextButton from '@/components/common/TextButton.vue'
import ProgressBar from '@/components/common/ProgressBar.vue'
import FilterTabs from '@/components/common/FilterTabs.vue'
@@ -151,10 +152,10 @@ const availableLocations = computed(() => {
// 检查解锁条件
if (loc.unlockCondition) {
if (loc.unlockCondition.type === 'kill') {
// 检查击杀条件(需要追踪击杀数
const killCount = player.flags[`kill_${loc.unlockCondition.target}`] || 0
// 检查击杀条件(使用统一的killCount存储
const killCount = (player.killCount && player.killCount[loc.unlockCondition.target]) || 0
locked = killCount < loc.unlockCondition.count
lockReason = `需要击杀${loc.unlockCondition.count}${getEnemyName(loc.unlockCondition.target)}`
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)
@@ -191,7 +192,8 @@ const currentActions = computed(() => {
trade: { id: 'trade', label: '交易', type: 'default' },
explore: { id: 'explore', label: '探索', type: 'warning' },
combat: { id: 'combat', label: '战斗', type: 'danger' },
read: { id: 'read', label: '阅读', type: 'default' }
read: { id: 'read', label: '阅读', type: 'default' },
crafting: { id: 'crafting', label: '制造', type: 'primary' }
}
// 战斗中只显示战斗相关
@@ -302,33 +304,37 @@ function doAction(actionId) {
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 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]
// 使用新的敌人获取函数
const enemyConfig = getRandomEnemyForLocation(player.currentLocation)
if (!enemyConfig) {
game.addLog('敌人配置错误', 'error')
game.addLog('这里没有敌人', 'info')
return
}
@@ -337,8 +343,8 @@ function startCombat() {
// 获取环境类型
const environment = getEnvironmentType(player.currentLocation)
// 使用 initCombat 初始化战斗(包含 expReward 和 drops
game.combatState = initCombat(enemyId, enemyConfig, environment)
// 使用 initCombat 初始化战斗
game.combatState = initCombat(enemyConfig.id, enemyConfig, environment)
game.inCombat = true
}
@@ -368,8 +374,8 @@ function toggleAutoCombat() {
game.addLog('自动战斗已开启 - 战斗结束后自动寻找新敌人', 'info')
// 如果当前不在战斗中且在危险区域,立即开始战斗
if (!game.inCombat) {
const current = LOCATION_CONFIG[player.currentLocation]
if (current && current.enemies && current.enemies.length > 0) {
const enemyConfig = getRandomEnemyForLocation(player.currentLocation)
if (enemyConfig) {
startCombat()
}
}

View File

@@ -145,21 +145,137 @@
<text class="skill-empty__text">暂无技能</text>
</view>
</scroll-view>
<!-- 书籍区域 -->
<Collapse title="📚 书籍" :expanded="showBooks" @toggle="showBooks = $event">
<!-- 活动中的阅读任务 -->
<view v-if="readingTask" class="reading-task">
<text class="reading-task__title">正在阅读{{ readingTask.bookName }}</text>
<ProgressBar
:value="readingTask.progress"
:max="readingTask.totalTime"
color="#4ecdc4"
:showText="true"
height="16rpx"
/>
<text class="reading-task__time">{{ readingTimeText }}</text>
</view>
<!-- 可阅读的书籍列表 -->
<view v-if="!readingTask && ownedBooks.length > 0" class="books-list">
<view
v-for="book in ownedBooks"
:key="book.id"
class="book-item"
@click="startReading(book)"
>
<text class="book-item__icon">{{ book.icon || '📖' }}</text>
<view class="book-item__info">
<text class="book-item__name">{{ book.name }}</text>
<text class="book-item__desc">阅读需 {{ book.readingTime }}</text>
</view>
<TextButton text="阅读" type="primary" size="small" />
</view>
</view>
<view v-if="!readingTask && ownedBooks.length === 0" class="books-empty">
<text class="books-empty__text">暂无书籍去商店看看吧</text>
</view>
</Collapse>
<!-- 训练区域 -->
<Collapse title="⚔️ 训练" :expanded="showTraining" @toggle="showTraining = $event">
<!-- 活动中的训练任务 -->
<view v-if="trainingTask" class="training-task">
<text class="training-task__title">正在训练{{ trainingTask.skillName }}</text>
<ProgressBar
:value="trainingTask.progress"
:max="trainingTask.totalTime || 300"
color="#ff6b6b"
:showText="true"
height="16rpx"
/>
<TextButton text="停止训练" type="warning" size="small" @click="stopTraining" />
</view>
<!-- 可训练的技能列表 -->
<view v-if="!trainingTask && trainableSkills.length > 0" class="trainable-list">
<view
v-for="skill in trainableSkills"
:key="skill.id"
class="trainable-item"
>
<text class="trainable-item__icon">{{ skill.icon || '⚔️' }}</text>
<view class="trainable-item__info">
<text class="trainable-item__name">{{ skill.name }}</text>
<text class="trainable-item__level">Lv.{{ skill.level }}</text>
</view>
<TextButton
text="训练"
type="primary"
size="small"
:disabled="player.currentStats.stamina < 10"
@click="startTraining(skill)"
/>
</view>
</view>
<view v-if="!trainingTask && trainableSkills.length === 0" class="training-empty">
<text class="training-empty__text">暂无可训练技能</text>
</view>
</Collapse>
<!-- 活动任务面板 -->
<Collapse title="📋 活动任务" :expanded="showTasks" @toggle="showTasks = $event">
<view v-if="activeTasksList.length > 0" class="active-tasks-list">
<view v-for="task in activeTasksList" :key="task.id" class="active-task-item">
<view class="active-task-item__header">
<text class="active-task-item__name">{{ task.name }}</text>
<TextButton
text="取消"
type="warning"
size="small"
@click="cancelActiveTask(task)"
/>
</view>
<ProgressBar
:value="task.progress"
:max="task.total || 100"
color="#4ecdc4"
:showText="true"
height="12rpx"
/>
<text class="active-task-item__time">{{ task.timeText }}</text>
</view>
</view>
<view v-else class="tasks-empty">
<text class="tasks-empty__text">暂无活动任务</text>
</view>
</Collapse>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { usePlayerStore } from '@/store/player'
import { useGameStore } from '@/store/game'
import { SKILL_CONFIG } from '@/config/skills'
import { ITEM_CONFIG } from '@/config/items'
import { startTask } from '@/utils/taskSystem'
import { getSkillDisplayInfo } from '@/utils/skillSystem'
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'
import TextButton from '@/components/common/TextButton.vue'
const player = usePlayerStore()
const game = useGameStore()
const showMoreStats = ref(false)
const currentSkillFilter = ref('all')
const showBooks = ref(false)
const showTraining = ref(false)
const showTasks = ref(true)
const skillFilterTabs = [
{ id: 'all', label: '全部' },
@@ -183,15 +299,8 @@ const currencyText = computed(() => {
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
}))
.map(([id, _]) => getSkillDisplayInfo(player, id))
.filter(Boolean)
.sort((a, b) => b.level - a.level || b.exp - a.exp)
if (currentSkillFilter.value === 'all') {
@@ -201,33 +310,191 @@ const filteredSkills = computed(() => {
})
const finalStats = computed(() => {
// 计算最终属性(基础值 + 装备加成)
let attack = 0
// 计算最终属性(基础值 + 装备加成 + 全局加成
let attack = player.baseStats?.strength || 0
let defense = 0
let critRate = 5
let critRate = (player.baseStats?.dexterity || 0) + (player.globalBonus?.critRate || 0)
// 武器攻击力
if (player.equipment.weapon) {
attack += player.equipment.weapon.baseDamage || 0
// 装备属性加成
if (player.equipmentStats) {
attack += player.equipmentStats.attack || 0
defense += player.equipmentStats.defense || 0
critRate += player.equipmentStats.critRate || 0
}
// 防具防御力
if (player.equipment.armor) {
defense += player.equipment.armor.baseDefense || 0
// 如果没有equipmentStats直接从装备计算
if (!player.equipmentStats) {
// 武器攻击力使用finalDamage如果存在
if (player.equipment.weapon) {
const weapon = player.equipment.weapon
attack += weapon.finalDamage || weapon.baseDamage || 0
}
// 防具防御力使用finalDefense如果存在
if (player.equipment.armor) {
const armor = player.equipment.armor
defense += armor.finalDefense || armor.baseDefense || 0
}
// 盾牌格挡
if (player.equipment.shield) {
const shield = player.equipment.shield
defense += shield.finalDefense || shield.baseDefense || 0
}
}
// 计算AP和EP
const ap = player.baseStats.dexterity + player.baseStats.intuition * 0.2
const ep = player.baseStats.agility + player.baseStats.intuition * 0.2
// 计算AP和EP(包含装备加成)
const ap = (player.baseStats?.dexterity || 0) + (player.baseStats?.intuition || 0) * 0.2
const ep = (player.baseStats?.agility || 0) + (player.baseStats?.intuition || 0) * 0.2
return {
attack: Math.floor(attack),
defense: Math.floor(defense),
critRate: critRate,
critRate: Math.min(80, Math.floor(critRate)),
ap: Math.floor(ap),
ep: Math.floor(ep)
}
})
// 书籍相关
const ownedBooks = computed(() => {
return (player.inventory || []).filter(item => item.type === 'book')
})
const readingTask = computed(() => {
const task = (game.activeTasks || []).find(t => t.type === 'reading')
if (!task) return null
const bookConfig = ITEM_CONFIG[task.data.itemId]
return {
bookName: bookConfig?.name || '未知书籍',
progress: task.progress,
totalTime: bookConfig?.readingTime || 60
}
})
const readingTimeText = computed(() => {
if (!readingTask.value) return ''
const remaining = Math.max(0, readingTask.value.totalTime - readingTask.value.progress)
const minutes = Math.floor(remaining / 60)
const seconds = Math.floor(remaining % 60)
return `剩余 ${minutes}:${String(seconds).padStart(2, '0')}`
})
function startReading(book) {
const result = startTask(game, player, 'reading', {
itemId: book.id,
duration: book.readingTime || 60
})
if (result.success) {
game.addLog(`开始阅读《${book.name}`, 'info')
} else {
game.addLog(result.message, 'error')
}
}
// 训练相关
const trainableSkills = computed(() => {
return Object.entries(player.skills || {})
.filter(([_, skill]) => skill.unlocked && SKILL_CONFIG[_]?.type === 'combat')
.map(([id, _]) => {
const info = getSkillDisplayInfo(player, id)
return {
id,
name: info?.name || id,
icon: info?.icon || '⚔️',
level: info?.level || 0
}
})
.filter(Boolean)
})
const trainingTask = computed(() => {
const task = (game.activeTasks || []).find(t => t.type === 'training')
if (!task) return null
const info = getSkillDisplayInfo(player, task.data.skillId)
return {
skillName: info?.name || '未知技能',
progress: task.progress,
totalTime: task.data.duration || 300,
taskId: task.id
}
})
function startTraining(skill) {
const result = startTask(game, player, 'training', {
skillId: skill.id,
duration: 300 // 5分钟
})
if (result.success) {
game.addLog(`开始训练 ${skill.name}`, 'info')
} else {
game.addLog(result.message, 'error')
}
}
function stopTraining() {
if (trainingTask.value) {
const { endTask } = require('@/utils/taskSystem')
endTask(game, player, trainingTask.value.taskId, false)
game.addLog('停止了训练', 'info')
}
}
// 活动任务相关
const activeTasksList = computed(() => {
const tasks = (game.activeTasks || []).map(task => {
let name = '未知任务'
let total = 100
if (task.type === 'reading') {
const book = ITEM_CONFIG[task.data.itemId]
name = `阅读《${book?.name || '未知书籍'}`
total = book?.readingTime || 60
} else if (task.type === 'training') {
const skill = SKILL_CONFIG[task.data.skillId]
name = `训练 ${skill?.name || '技能'}`
total = task.data.duration || 300
} else if (task.type === 'resting') {
name = '休息'
total = task.data.duration || 60
}
const progress = Math.min(task.progress, total)
const percentage = Math.floor((progress / total) * 100)
// 计算剩余时间
const remaining = Math.max(0, total - progress)
const minutes = Math.floor(remaining / 60)
const seconds = Math.floor(remaining % 60)
let timeText = ''
if (minutes > 0) {
timeText = `剩余 ${minutes}${seconds}`
} else {
timeText = `剩余 ${seconds}`
}
return {
id: task.id,
name,
progress,
total,
percentage,
timeText
}
})
return tasks.sort((a, b) => b.progress - a.progress)
})
function cancelActiveTask(task) {
const { endTask } = require('@/utils/taskSystem')
endTask(game, player, task.id, false)
game.addLog(`取消了 ${task.name}`, 'info')
}
</script>
<style lang="scss" scoped>
@@ -420,4 +687,180 @@ const finalStats = computed(() => {
font-size: 22rpx;
}
}
// 书籍相关样式
.reading-task {
padding: 16rpx;
background-color: $bg-tertiary;
border-radius: 8rpx;
margin-bottom: 12rpx;
&__title {
display: block;
color: $text-primary;
font-size: 24rpx;
margin-bottom: 8rpx;
}
&__time {
display: block;
color: $text-secondary;
font-size: 20rpx;
margin-top: 8rpx;
text-align: right;
}
}
.books-list {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.book-item {
display: flex;
align-items: center;
gap: 12rpx;
padding: 12rpx;
background-color: $bg-primary;
border-radius: 8rpx;
&:active {
background-color: $bg-tertiary;
}
&__icon {
font-size: 32rpx;
}
&__info {
flex: 1;
}
&__name {
display: block;
color: $text-primary;
font-size: 26rpx;
}
&__desc {
display: block;
color: $text-secondary;
font-size: 22rpx;
margin-top: 4rpx;
}
}
.books-empty {
padding: 32rpx;
text-align: center;
&__text {
color: $text-muted;
font-size: 24rpx;
}
}
// 训练相关样式
.training-task {
padding: 16rpx;
background-color: $bg-tertiary;
border-radius: 8rpx;
margin-bottom: 12rpx;
&__title {
display: block;
color: $text-primary;
font-size: 24rpx;
margin-bottom: 8rpx;
}
}
.trainable-list {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.trainable-item {
display: flex;
align-items: center;
gap: 12rpx;
padding: 12rpx;
background-color: $bg-primary;
border-radius: 8rpx;
&__icon {
font-size: 32rpx;
}
&__info {
flex: 1;
}
&__name {
display: block;
color: $text-primary;
font-size: 26rpx;
}
&__level {
display: block;
color: $accent;
font-size: 22rpx;
margin-top: 4rpx;
}
}
.training-empty {
padding: 32rpx;
text-align: center;
&__text {
color: $text-muted;
font-size: 24rpx;
}
}
// 活动任务样式
.active-tasks-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.active-task-item {
padding: 12rpx;
background-color: $bg-primary;
border-radius: 8rpx;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
}
&__name {
color: $text-primary;
font-size: 24rpx;
}
&__time {
display: block;
color: $text-secondary;
font-size: 20rpx;
margin-top: 4rpx;
}
}
.tasks-empty {
padding: 32rpx;
text-align: center;
&__text {
color: $text-muted;
font-size: 24rpx;
}
}
</style>