18 changed files with 1301 additions and 3 deletions
@ -0,0 +1 @@ |
|||
.gitnexus |
|||
@ -0,0 +1,9 @@ |
|||
node_modules/ |
|||
dist/ |
|||
build/ |
|||
tmp/ |
|||
temp/ |
|||
.cache/ |
|||
.env |
|||
bin/ |
|||
obj/ |
|||
@ -0,0 +1,40 @@ |
|||
using System.Linq; |
|||
using Build_God_Api.DB; |
|||
using Build_God_Api.Dto; |
|||
using Build_God_Api.Services; |
|||
using Microsoft.AspNetCore.Authorization; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
|
|||
namespace Build_God_Api.Controllers |
|||
{ |
|||
[ApiController] |
|||
[Route("api/god/[controller]")]
|
|||
[Authorize] |
|||
public class BattleController( |
|||
IBattleService battleService, |
|||
ICharacterService characterService, |
|||
ICurrentUserService currentUserService |
|||
) : ControllerBase |
|||
{ |
|||
private readonly IBattleService _battleService = battleService; |
|||
private readonly ICharacterService _characterService = characterService; |
|||
private readonly ICurrentUserService _currentUserService = currentUserService; |
|||
|
|||
[HttpPost("challenge")] |
|||
public async Task<ActionResult<ChallengeMonsterResponse>> ChallengeMonster([FromBody] ChallengeMonsterRequest request) |
|||
{ |
|||
var characters = await _characterService.GetAllCharacters(); |
|||
var currentCharacter = characters |
|||
.Where(c => c.AccountId == _currentUserService.UserId) |
|||
.OrderByDescending(c => c.LastLogin) |
|||
.FirstOrDefault(); |
|||
|
|||
if (currentCharacter == null) |
|||
{ |
|||
return BadRequest(new { message = "请先创建角色" }); |
|||
} |
|||
|
|||
return await _battleService.ChallengeMonster(currentCharacter.Id, request.MonsterId); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
namespace Build_God_Api.Dto |
|||
{ |
|||
public class ChallengeMonsterRequest |
|||
{ |
|||
public int MonsterId { get; set; } |
|||
} |
|||
|
|||
public class ChallengeMonsterResponse |
|||
{ |
|||
public bool Success { get; set; } |
|||
public int MonsterId { get; set; } |
|||
public string MonsterName { get; set; } = ""; |
|||
public int MonsterHealth { get; set; } |
|||
public int MonsterMaxHealth { get; set; } |
|||
public int PlayerHealth { get; set; } |
|||
public int PlayerMaxHealth { get; set; } |
|||
public List<BattleRewardDto> Rewards { get; set; } = new(); |
|||
public string Message { get; set; } = ""; |
|||
} |
|||
|
|||
public class BattleRewardDto |
|||
{ |
|||
public string Type { get; set; } = ""; |
|||
public string ItemName { get; set; } = ""; |
|||
public int Count { get; set; } |
|||
} |
|||
|
|||
public class BattleLogDto |
|||
{ |
|||
public int Round { get; set; } |
|||
public bool IsPlayerAttack { get; set; } |
|||
public int Damage { get; set; } |
|||
public bool IsCritical { get; set; } |
|||
public string Description { get; set; } = ""; |
|||
} |
|||
} |
|||
@ -0,0 +1,247 @@ |
|||
using Build_God_Api.DB; |
|||
using Build_God_Api.Dto; |
|||
using Build_God_Api.Services.Game; |
|||
using SqlSugar; |
|||
using System.Linq; |
|||
|
|||
namespace Build_God_Api.Services |
|||
{ |
|||
public interface IBattleService |
|||
{ |
|||
Task<ChallengeMonsterResponse> ChallengeMonster(int characterId, int monsterId); |
|||
} |
|||
|
|||
public class BattleService( |
|||
ISqlSugarClient db, |
|||
ICurrentUserService currentUserService, |
|||
IBagService bagService, |
|||
ICharacterAttributeCalculateService calculateService |
|||
) : IBattleService |
|||
{ |
|||
private readonly ISqlSugarClient _db = db; |
|||
private readonly ICurrentUserService _currentUserService = currentUserService; |
|||
private readonly IBagService _bagService = bagService; |
|||
private readonly ICharacterAttributeCalculateService _calculateService = calculateService; |
|||
|
|||
public async Task<ChallengeMonsterResponse> ChallengeMonster(int characterId, int monsterId) |
|||
{ |
|||
var response = new ChallengeMonsterResponse(); |
|||
|
|||
try |
|||
{ |
|||
var character = await _db.Queryable<Character>().Where(c => c.Id == characterId).FirstAsync(); |
|||
if (character == null) |
|||
{ |
|||
response.Success = false; |
|||
response.Message = "角色不存在"; |
|||
return response; |
|||
} |
|||
|
|||
var attributes = await _calculateService.CalculateAttributesAsync(character); |
|||
|
|||
var monster = await _db.Queryable<Monster>().Where(m => m.Id == monsterId).FirstAsync(); |
|||
if (monster == null) |
|||
{ |
|||
response.Success = false; |
|||
response.Message = "怪物不存在"; |
|||
return response; |
|||
} |
|||
|
|||
var rewards = await _db.Queryable<MonsterReward>().Where(r => r.MonsterId == monsterId).ToListAsync(); |
|||
|
|||
var playerHealth = (int)character.CurrentHP; |
|||
var playerMaxHealth = (int)attributes.MaxHP; |
|||
var monsterHealth = monster.Health; |
|||
var monsterMaxHealth = monster.Health; |
|||
|
|||
var playerAttack = (int)attributes.Attack; |
|||
var playerDefend = (int)attributes.Defend; |
|||
var playerCriticalRate = attributes.CriticalRate; |
|||
|
|||
var monsterAttack = monster.Attack; |
|||
var monsterDefend = monster.Defense; |
|||
|
|||
var round = 0; |
|||
var maxRounds = 100; |
|||
|
|||
while (playerHealth > 0 && monsterHealth > 0 && round < maxRounds) |
|||
{ |
|||
round++; |
|||
|
|||
var playerDamage = CalculateDamage(playerAttack, monsterDefend, playerCriticalRate); |
|||
monsterHealth -= playerDamage; |
|||
|
|||
if (monsterHealth <= 0) break; |
|||
|
|||
var monsterDamage = CalculateDamage(monsterAttack, playerDefend, monster.CriticalRate); |
|||
playerHealth -= monsterDamage; |
|||
} |
|||
|
|||
response.MonsterId = monsterId; |
|||
response.MonsterName = monster.Name; |
|||
response.MonsterHealth = Math.Max(0, monsterHealth); |
|||
response.MonsterMaxHealth = monsterMaxHealth; |
|||
response.PlayerHealth = Math.Max(0, playerHealth); |
|||
response.PlayerMaxHealth = playerMaxHealth; |
|||
|
|||
if (monsterHealth <= 0) |
|||
{ |
|||
response.Success = true; |
|||
response.Message = "战斗胜利!"; |
|||
|
|||
foreach (var reward in rewards) |
|||
{ |
|||
var battleReward = await GrantReward(characterId, reward); |
|||
if (battleReward != null) |
|||
{ |
|||
response.Rewards.Add(battleReward); |
|||
} |
|||
} |
|||
|
|||
await UpdateMissionProgress(characterId, monsterId); |
|||
} |
|||
else |
|||
{ |
|||
response.Success = false; |
|||
response.Message = "战斗失败"; |
|||
} |
|||
|
|||
character.CurrentHP = Math.Max(0, playerHealth); |
|||
await _db.Updateable(character).ExecuteCommandAsync(); |
|||
|
|||
return response; |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
Console.WriteLine($"ChallengeMonster error: {ex.Message}"); |
|||
response.Success = false; |
|||
response.Message = "战斗发生错误"; |
|||
return response; |
|||
} |
|||
} |
|||
|
|||
private int CalculateDamage(int attack, int defend, decimal criticalRate) |
|||
{ |
|||
var baseDamage = Math.Max(1, attack - defend); |
|||
|
|||
var random = Random.Shared.NextDouble(); |
|||
var isCritical = random < (double)criticalRate / 100; |
|||
|
|||
if (isCritical) |
|||
{ |
|||
baseDamage = (int)(baseDamage * 1.5); |
|||
} |
|||
|
|||
var variance = Random.Shared.Next(90, 111) / 100m; |
|||
return (int)(baseDamage * variance); |
|||
} |
|||
|
|||
private async Task<BattleRewardDto?> GrantReward(int characterId, MonsterReward reward) |
|||
{ |
|||
var battleReward = new BattleRewardDto(); |
|||
|
|||
switch (reward.Type) |
|||
{ |
|||
case RewardType.Pill: |
|||
case RewardType.Equipment: |
|||
var characterBag = await _db.Queryable<CharacterBag>() |
|||
.Where(b => b.CharacterId == characterId) |
|||
.FirstAsync(); |
|||
if (characterBag != null) |
|||
{ |
|||
await _bagService.AddItemToBag(characterBag.Id, (int)reward.Type, reward.ItemId, reward.Count); |
|||
} |
|||
battleReward.Type = reward.Type == RewardType.Pill ? "丹药" : "装备"; |
|||
battleReward.ItemName = reward.ItemName; |
|||
battleReward.Count = reward.Count; |
|||
break; |
|||
|
|||
case RewardType.Money: |
|||
await AddMoney(characterId, reward.Count); |
|||
battleReward.Type = "灵石"; |
|||
battleReward.ItemName = "灵石"; |
|||
battleReward.Count = reward.Count; |
|||
break; |
|||
} |
|||
|
|||
// 处理经验奖励(作为特殊处理:如果ItemName包含"经验")
|
|||
if (reward.Type == RewardType.Money && reward.ItemName == "经验") |
|||
{ |
|||
await AddExperience(characterId, reward.Count); |
|||
battleReward.Type = "经验"; |
|||
battleReward.ItemName = "经验"; |
|||
battleReward.Count = reward.Count; |
|||
} |
|||
|
|||
return battleReward; |
|||
} |
|||
|
|||
private async Task AddExperience(int characterId, int amount) |
|||
{ |
|||
var character = await _db.Queryable<Character>().Where(c => c.Id == characterId).FirstAsync(); |
|||
if (character != null) |
|||
{ |
|||
character.CurrentExp += amount; |
|||
await _db.Updateable(character).ExecuteCommandAsync(); |
|||
} |
|||
} |
|||
|
|||
private async Task AddMoney(int characterId, int amount) |
|||
{ |
|||
var character = await _db.Queryable<Character>().Where(c => c.Id == characterId).FirstAsync(); |
|||
if (character != null) |
|||
{ |
|||
character.Money += amount; |
|||
await _db.Updateable(character).ExecuteCommandAsync(); |
|||
} |
|||
} |
|||
|
|||
private async Task UpdateMissionProgress(int characterId, int monsterId) |
|||
{ |
|||
var dailyMissions = await _db.Queryable<CharacterDailyMission>() |
|||
.Where(m => m.CharacterId == characterId && m.Status == DailyMissionStatus.InProgress) |
|||
.ToListAsync(); |
|||
|
|||
foreach (var dailyMission in dailyMissions) |
|||
{ |
|||
var missionProgresses = await _db.Queryable<MissionProgress>() |
|||
.Where(p => p.MissionId == dailyMission.MissionId && p.TargetType == ProgressTargetType.KillMonster) |
|||
.ToListAsync(); |
|||
|
|||
foreach (var progress in missionProgresses) |
|||
{ |
|||
if (progress.TargetItemId == monsterId) |
|||
{ |
|||
var characterProgress = await _db.Queryable<CharacterMissionProgress>() |
|||
.Where(cp => cp.CharacterId == characterId |
|||
&& cp.MissionId == dailyMission.MissionId |
|||
&& cp.MissionProgressId == progress.Id) |
|||
.FirstAsync(); |
|||
|
|||
if (characterProgress == null) |
|||
{ |
|||
characterProgress = new CharacterMissionProgress |
|||
{ |
|||
CharacterId = characterId, |
|||
MissionId = dailyMission.MissionId, |
|||
MissionProgressId = progress.Id, |
|||
CurrentCount = 1, |
|||
IsCompleted = 1 >= progress.TargetCount, |
|||
CreatedOn = DateTime.Now, |
|||
UpdatedOn = DateTime.Now |
|||
}; |
|||
await _db.Insertable(characterProgress).ExecuteCommandAsync(); |
|||
} |
|||
else |
|||
{ |
|||
characterProgress.CurrentCount++; |
|||
characterProgress.IsCompleted = characterProgress.CurrentCount >= progress.TargetCount; |
|||
characterProgress.UpdatedOn = DateTime.Now; |
|||
await _db.Updateable(characterProgress).ExecuteCommandAsync(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
import http from './index' |
|||
|
|||
export interface MonsterDto { |
|||
id: number |
|||
name: string |
|||
description: string |
|||
health: number |
|||
attack: number |
|||
defense: number |
|||
criticalRate: number |
|||
level: number |
|||
type: number |
|||
icon?: string |
|||
rewards?: MonsterRewardDto[] |
|||
} |
|||
|
|||
export interface MonsterRewardDto { |
|||
id: number |
|||
monsterId: number |
|||
rewardType: number |
|||
itemId: number |
|||
itemName: string |
|||
count: number |
|||
} |
|||
|
|||
export interface ChallengeMonsterResponse { |
|||
success: boolean |
|||
monsterId: number |
|||
monsterName: string |
|||
monsterHealth: number |
|||
monsterMaxHealth: number |
|||
playerHealth: number |
|||
playerMaxHealth: number |
|||
rewards: BattleRewardDto[] |
|||
message: string |
|||
} |
|||
|
|||
export interface BattleRewardDto { |
|||
type: string |
|||
itemName: string |
|||
count: number |
|||
} |
|||
|
|||
export const monsterApi = { |
|||
getMonsterList: (): Promise<MonsterDto[]> => { |
|||
return http.get('/monster/all', {}) |
|||
}, |
|||
|
|||
getMonsterById: (id: number): Promise<MonsterDto> => { |
|||
return http.get(`/monster/${id}`) |
|||
}, |
|||
|
|||
challengeMonster: (monsterId: number): Promise<ChallengeMonsterResponse> => { |
|||
return http.post('/battle/challenge', { monsterId }) |
|||
} |
|||
} |
|||
|
After Width: | Height: | Size: 760 B |
@ -0,0 +1,66 @@ |
|||
import { defineStore } from 'pinia' |
|||
import { ref } from 'vue' |
|||
import { monsterApi, type MonsterDto, type ChallengeMonsterResponse } from '@/api/monster' |
|||
|
|||
export const useMonsterStore = defineStore('monster', () => { |
|||
const monsters = ref<MonsterDto[]>([]) |
|||
const currentMonster = ref<MonsterDto | null>(null) |
|||
const lastBattleResult = ref<ChallengeMonsterResponse | null>(null) |
|||
const loading = ref(false) |
|||
|
|||
const fetchMonsters = async () => { |
|||
loading.value = true |
|||
try { |
|||
const data = await monsterApi.getMonsterList() |
|||
monsters.value = data |
|||
} catch (error) { |
|||
console.error('Failed to fetch monsters:', error) |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
const fetchMonster = async (id: number) => { |
|||
loading.value = true |
|||
try { |
|||
const data = await monsterApi.getMonsterById(id) |
|||
currentMonster.value = data |
|||
return data |
|||
} catch (error) { |
|||
console.error('Failed to fetch monster:', error) |
|||
return null |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
const challengeMonster = async (monsterId: number): Promise<ChallengeMonsterResponse | null> => { |
|||
loading.value = true |
|||
try { |
|||
const result = await monsterApi.challengeMonster(monsterId) |
|||
lastBattleResult.value = result |
|||
return result |
|||
} catch (error) { |
|||
console.error('Failed to challenge monster:', error) |
|||
return null |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
const clearBattleResult = () => { |
|||
lastBattleResult.value = null |
|||
currentMonster.value = null |
|||
} |
|||
|
|||
return { |
|||
monsters, |
|||
currentMonster, |
|||
lastBattleResult, |
|||
loading, |
|||
fetchMonsters, |
|||
fetchMonster, |
|||
challengeMonster, |
|||
clearBattleResult |
|||
} |
|||
}) |
|||
@ -0,0 +1,451 @@ |
|||
<script setup lang="ts"> |
|||
import { ref, computed, onMounted, onUnmounted } from 'vue' |
|||
import { useRoute, useRouter } from 'vue-router' |
|||
import { useMonsterStore } from '@/stores/monster' |
|||
import { useCharacterStore } from '@/stores/character' |
|||
import { ElMessage } from 'element-plus' |
|||
import { ArrowLeft } from '@element-plus/icons-vue' |
|||
|
|||
const route = useRoute() |
|||
const router = useRouter() |
|||
const monsterStore = useMonsterStore() |
|||
const characterStore = useCharacterStore() |
|||
|
|||
const monsterId = computed(() => parseInt(route.query.id as string)) |
|||
|
|||
const playerHealth = ref(0) |
|||
const playerMaxHealth = ref(0) |
|||
const monsterHealth = ref(0) |
|||
const monsterMaxHealth = ref(0) |
|||
const battleLog = ref<{ round: number; text: string; isPlayer: boolean }[]>([]) |
|||
const isFighting = ref(false) |
|||
const battleResult = ref<'win' | 'lose' | null>(null) |
|||
const rewards = ref<{ type: string; itemName: string; count: number }[]>([]) |
|||
|
|||
const playerHealthPercent = computed(() => { |
|||
if (playerMaxHealth.value === 0) return 0 |
|||
return (playerHealth.value / playerMaxHealth.value) * 100 |
|||
}) |
|||
|
|||
const monsterHealthPercent = computed(() => { |
|||
if (monsterMaxHealth.value === 0) return 0 |
|||
return (monsterHealth.value / monsterMaxHealth.value) * 100 |
|||
}) |
|||
|
|||
const currentCharacter = computed(() => characterStore.currentCharacter) |
|||
const currentMonster = computed(() => monsterStore.currentMonster) |
|||
|
|||
const getMonsterIcon = (iconName?: string) => { |
|||
if (!iconName) return '/src/assets/images/item-default.svg' |
|||
return `/src/assets/images/monster/${iconName}` |
|||
} |
|||
|
|||
const calculateDamage = (attack: number, defend: number, criticalRate: number): { damage: number; isCritical: boolean } => { |
|||
const baseDamage = Math.max(1, attack - defend) |
|||
const random = Math.random() |
|||
const isCritical = random < criticalRate / 100 |
|||
const damage = isCritical ? Math.floor(baseDamage * 1.5) : baseDamage |
|||
const variance = (90 + Math.floor(Math.random() * 21)) / 100 |
|||
return { damage: Math.floor(damage * variance), isCritical } |
|||
} |
|||
|
|||
const startBattle = async () => { |
|||
if (!currentMonster.value || !currentCharacter.value) { |
|||
ElMessage.error('数据加载中...') |
|||
return |
|||
} |
|||
|
|||
isFighting.value = true |
|||
battleResult.value = null |
|||
battleLog.value = [] |
|||
rewards.value = [] |
|||
|
|||
playerMaxHealth.value = currentCharacter.value.maxHP |
|||
playerHealth.value = currentCharacter.value.currentHP |
|||
monsterMaxHealth.value = currentMonster.value.health |
|||
monsterHealth.value = currentMonster.value.health |
|||
|
|||
const playerAttack = currentCharacter.value.attack |
|||
const playerDefend = currentCharacter.value.defend |
|||
const playerCriticalRate = currentCharacter.value.criticalRate |
|||
const monsterAttack = currentMonster.value.attack |
|||
const monsterDefend = currentMonster.value.defense |
|||
|
|||
let round = 0 |
|||
const maxRounds = 100 |
|||
|
|||
const fight = () => { |
|||
if (battleResult.value !== null) return |
|||
|
|||
round++ |
|||
|
|||
// 玩家攻击 |
|||
const playerResult = calculateDamage(playerAttack, monsterDefend, playerCriticalRate) |
|||
monsterHealth.value = Math.max(0, monsterHealth.value - playerResult.damage) |
|||
battleLog.value.push({ |
|||
round, |
|||
text: `你${playerResult.isCritical ? '暴击' : '攻击'}造成 ${playerResult.damage} 伤害`, |
|||
isPlayer: true |
|||
}) |
|||
|
|||
if (monsterHealth.value <= 0) { |
|||
battleResult.value = 'win' |
|||
finishBattle() |
|||
return |
|||
} |
|||
|
|||
// 怪物攻击 |
|||
const monsterResult = calculateDamage(monsterAttack, playerDefend, currentMonster.value.criticalRate) |
|||
playerHealth.value = Math.max(0, playerHealth.value - monsterResult.damage) |
|||
battleLog.value.push({ |
|||
round, |
|||
text: `怪物攻击造成 ${monsterResult.damage} 伤害`, |
|||
isPlayer: false |
|||
}) |
|||
|
|||
if (playerHealth.value <= 0) { |
|||
battleResult.value = 'lose' |
|||
finishBattle() |
|||
return |
|||
} |
|||
|
|||
if (round < maxRounds) { |
|||
setTimeout(fight, 800) |
|||
} |
|||
} |
|||
|
|||
setTimeout(fight, 500) |
|||
} |
|||
|
|||
const finishBattle = async () => { |
|||
if (!currentMonster.value) return |
|||
|
|||
const result = await monsterStore.challengeMonster(currentMonster.value.id) |
|||
|
|||
if (result) { |
|||
rewards.value = result.rewards || [] |
|||
|
|||
if (result.success) { |
|||
ElMessage.success('战斗胜利!') |
|||
} else { |
|||
ElMessage.warning('战斗失败') |
|||
} |
|||
|
|||
// 刷新角色数据 |
|||
await characterStore.fetchCharacters() |
|||
} |
|||
|
|||
isFighting.value = false |
|||
} |
|||
|
|||
const goBack = () => { |
|||
router.push('/monster-list') |
|||
} |
|||
|
|||
const retry = () => { |
|||
battleResult.value = null |
|||
battleLog.value = [] |
|||
rewards.value = [] |
|||
startBattle() |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
if (monsterId.value) { |
|||
await monsterStore.fetchMonster(monsterId.value) |
|||
|
|||
if (currentCharacter.value) { |
|||
playerMaxHealth.value = currentCharacter.value.maxHP |
|||
playerHealth.value = currentCharacter.value.currentHP |
|||
} |
|||
|
|||
if (currentMonster.value) { |
|||
monsterMaxHealth.value = currentMonster.value.health |
|||
monsterHealth.value = currentMonster.value.health |
|||
} |
|||
} |
|||
}) |
|||
|
|||
onUnmounted(() => { |
|||
monsterStore.clearBattleResult() |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="battle-container"> |
|||
<div class="battle-header"> |
|||
<el-button :icon="ArrowLeft" circle @click="goBack" /> |
|||
<h2>战斗</h2> |
|||
</div> |
|||
|
|||
<div v-if="!currentMonster || !currentCharacter" class="loading"> |
|||
加载中... |
|||
</div> |
|||
|
|||
<template v-else> |
|||
<!-- 战斗区域 --> |
|||
<div class="battle-area"> |
|||
<!-- 怪物 --> |
|||
<div class="combatant monster"> |
|||
<div class="combatant-icon"> |
|||
<img :src="getMonsterIcon(currentMonster.icon)" :alt="currentMonster.name" /> |
|||
</div> |
|||
<div class="combatant-name">{{ currentMonster.name }}</div> |
|||
<el-progress |
|||
:percentage="monsterHealthPercent" |
|||
:stroke-width="12" |
|||
:color="monsterHealthPercent > 30 ? '#ef4444' : '#dc2626'" |
|||
/> |
|||
<div class="health-text"> |
|||
{{ monsterHealth }} / {{ monsterMaxHealth }} |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- VS --> |
|||
<div class="vs">VS</div> |
|||
|
|||
<!-- 玩家 --> |
|||
<div class="combatant player"> |
|||
<div class="combatant-icon"> |
|||
<img src="/src/assets/images/character.svg" :alt="currentCharacter.name" /> |
|||
</div> |
|||
<div class="combatant-name">{{ currentCharacter.name }}</div> |
|||
<el-progress |
|||
:percentage="playerHealthPercent" |
|||
:stroke-width="12" |
|||
:color="playerHealthPercent > 30 ? '#22c55e' : '#ef4444'" |
|||
/> |
|||
<div class="health-text"> |
|||
{{ playerHealth }} / {{ playerMaxHealth }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 战斗日志 --> |
|||
<div class="battle-log"> |
|||
<div |
|||
v-for="(log, index) in battleLog" |
|||
:key="index" |
|||
class="log-item" |
|||
:class="{ player: log.isPlayer }" |
|||
> |
|||
<span class="round">第{{ log.round }}回合</span> |
|||
<span class="text">{{ log.text }}</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 结果区域 --> |
|||
<div v-if="battleResult" class="battle-result"> |
|||
<div class="result-title" :class="{ win: battleResult === 'win', lose: battleResult === 'lose' }"> |
|||
{{ battleResult === 'win' ? '战斗胜利!' : '战斗失败' }} |
|||
</div> |
|||
|
|||
<div v-if="battleResult === 'win' && rewards.length > 0" class="rewards"> |
|||
<h3>获得奖励</h3> |
|||
<div class="reward-list"> |
|||
<div v-for="reward in rewards" :key="reward.itemName" class="reward-item"> |
|||
<span class="reward-type">{{ reward.type }}</span> |
|||
<span class="reward-name">{{ reward.itemName }}</span> |
|||
<span class="reward-count">×{{ reward.count }}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="result-actions"> |
|||
<el-button @click="goBack">返回</el-button> |
|||
<el-button type="primary" @click="retry">再战</el-button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 开始战斗按钮 --> |
|||
<div v-if="!battleResult && !isFighting" class="start-battle"> |
|||
<el-button type="danger" size="large" @click="startBattle"> |
|||
开始战斗 |
|||
</el-button> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped lang="css"> |
|||
.battle-container { |
|||
padding: 20px; |
|||
min-height: 100vh; |
|||
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%); |
|||
} |
|||
|
|||
.battle-header { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 16px; |
|||
margin-bottom: 24px; |
|||
} |
|||
|
|||
.battle-header h2 { |
|||
color: #f8fafc; |
|||
margin: 0; |
|||
} |
|||
|
|||
.loading { |
|||
color: #64748b; |
|||
text-align: center; |
|||
padding: 60px 20px; |
|||
} |
|||
|
|||
.battle-area { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 40px 20px; |
|||
background: rgba(0, 0, 0, 0.2); |
|||
border-radius: 16px; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.combatant { |
|||
text-align: center; |
|||
flex: 1; |
|||
} |
|||
|
|||
.combatant-icon { |
|||
width: 100px; |
|||
height: 100px; |
|||
margin: 0 auto 16px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
background: rgba(255, 255, 255, 0.08); |
|||
border-radius: 16px; |
|||
} |
|||
|
|||
.combatant-icon img { |
|||
width: 70px; |
|||
height: 70px; |
|||
object-fit: contain; |
|||
} |
|||
|
|||
.combatant-name { |
|||
color: #f8fafc; |
|||
font-size: 18px; |
|||
font-weight: 600; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.health-text { |
|||
color: #94a3b8; |
|||
font-size: 14px; |
|||
margin-top: 8px; |
|||
} |
|||
|
|||
.vs { |
|||
color: #f59e0b; |
|||
font-size: 32px; |
|||
font-weight: 800; |
|||
padding: 0 24px; |
|||
} |
|||
|
|||
.battle-log { |
|||
background: rgba(0, 0, 0, 0.3); |
|||
border-radius: 12px; |
|||
padding: 16px; |
|||
max-height: 200px; |
|||
overflow-y: auto; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.log-item { |
|||
display: flex; |
|||
gap: 12px; |
|||
padding: 8px 0; |
|||
border-bottom: 1px solid rgba(255, 255, 255, 0.04); |
|||
} |
|||
|
|||
.log-item:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.log-item .round { |
|||
color: #64748b; |
|||
font-size: 12px; |
|||
min-width: 70px; |
|||
} |
|||
|
|||
.log-item .text { |
|||
color: #f8fafc; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.log-item.player .text { |
|||
color: #22c55e; |
|||
} |
|||
|
|||
.battle-result { |
|||
text-align: center; |
|||
padding: 24px; |
|||
background: rgba(0, 0, 0, 0.3); |
|||
border-radius: 12px; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.result-title { |
|||
font-size: 28px; |
|||
font-weight: 700; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.result-title.win { |
|||
color: #22c55e; |
|||
} |
|||
|
|||
.result-title.lose { |
|||
color: #ef4444; |
|||
} |
|||
|
|||
.rewards { |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.rewards h3 { |
|||
color: #f8fafc; |
|||
margin: 0 0 12px; |
|||
} |
|||
|
|||
.reward-list { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.reward-item { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 8px; |
|||
padding: 12px; |
|||
background: rgba(34, 197, 94, 0.1); |
|||
border-radius: 8px; |
|||
} |
|||
|
|||
.reward-type { |
|||
color: #22c55e; |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.reward-name { |
|||
color: #f8fafc; |
|||
} |
|||
|
|||
.reward-count { |
|||
color: #22c55e; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.result-actions { |
|||
display: flex; |
|||
gap: 12px; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.start-battle { |
|||
text-align: center; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,302 @@ |
|||
<script setup lang="ts"> |
|||
import { ref, computed, onMounted } from 'vue' |
|||
import { useRouter } from 'vue-router' |
|||
import { useMonsterStore } from '@/stores/monster' |
|||
import { useCharacterStore } from '@/stores/character' |
|||
import { ElMessage } from 'element-plus' |
|||
import defaultIcon from '@/assets/images/item-default.svg' |
|||
|
|||
const router = useRouter() |
|||
const monsterStore = useMonsterStore() |
|||
const characterStore = useCharacterStore() |
|||
|
|||
const getMonsterIcon = (iconName?: string) => { |
|||
if (!iconName) return defaultIcon |
|||
return `/src/assets/images/monster/${iconName}` |
|||
} |
|||
|
|||
const getMonsterTypeLabel = (type: number) => { |
|||
const labels: Record<number, string> = { |
|||
1: '普通', |
|||
2: '精英', |
|||
3: '首领' |
|||
} |
|||
return labels[type] || '普通' |
|||
} |
|||
|
|||
const getMonsterTypeClass = (type: number) => { |
|||
const classes: Record<number, string> = { |
|||
1: 'type-normal', |
|||
2: 'type-elite', |
|||
3: 'type-boss' |
|||
} |
|||
return classes[type] || 'type-normal' |
|||
} |
|||
|
|||
const playerLevel = computed(() => characterStore.currentCharacter?.levelId || 0) |
|||
|
|||
const canChallenge = (monster: { level: number }) => { |
|||
return playerLevel.value >= monster.level |
|||
} |
|||
|
|||
const handleChallenge = async (monster: { id: number; level: number }) => { |
|||
if (!canChallenge(monster)) { |
|||
ElMessage.warning('等级不足,无法挑战') |
|||
return |
|||
} |
|||
router.push(`/battle?id=${monster.id}`) |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
await monsterStore.fetchMonsters() |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="monster-list-container"> |
|||
<div class="header"> |
|||
<h2>怪物挑战</h2> |
|||
<span class="player-level">等级: {{ playerLevel }}</span> |
|||
</div> |
|||
|
|||
<div v-if="monsterStore.loading" class="loading"> |
|||
加载中... |
|||
</div> |
|||
|
|||
<div v-else-if="monsterStore.monsters.length === 0" class="empty"> |
|||
暂无可挑战的怪物 |
|||
</div> |
|||
|
|||
<div v-else class="monster-grid"> |
|||
<div |
|||
v-for="monster in monsterStore.monsters" |
|||
:key="monster.id" |
|||
class="monster-card" |
|||
:class="{ disabled: !canChallenge(monster) }" |
|||
@click="handleChallenge(monster)" |
|||
> |
|||
<div class="monster-icon"> |
|||
<img :src="getMonsterIcon(monster.icon)" :alt="monster.name" /> |
|||
</div> |
|||
<div class="monster-info"> |
|||
<h3>{{ monster.name }}</h3> |
|||
<span class="monster-level">等级: {{ monster.level }}</span> |
|||
<span class="monster-type" :class="getMonsterTypeClass(monster.type)"> |
|||
{{ getMonsterTypeLabel(monster.type) }} |
|||
</span> |
|||
</div> |
|||
<div class="monster-stats"> |
|||
<div class="stat"> |
|||
<span class="label">生命</span> |
|||
<span class="value">{{ monster.health }}</span> |
|||
</div> |
|||
<div class="stat"> |
|||
<span class="label">攻击</span> |
|||
<span class="value">{{ monster.attack }}</span> |
|||
</div> |
|||
<div class="stat"> |
|||
<span class="label">防御</span> |
|||
<span class="value">{{ monster.defense }}</span> |
|||
</div> |
|||
</div> |
|||
<div class="monster-rewards" v-if="monster.rewards && monster.rewards.length > 0"> |
|||
<span class="reward-label">击杀奖励:</span> |
|||
<span |
|||
v-for="reward in monster.rewards" |
|||
:key="reward.id" |
|||
class="reward-item" |
|||
> |
|||
{{ reward.itemName }} ×{{ reward.count }} |
|||
</span> |
|||
</div> |
|||
<div class="challenge-hint"> |
|||
{{ canChallenge(monster) ? '点击挑战' : '等级不足' }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped lang="css"> |
|||
.monster-list-container { |
|||
padding: 20px; |
|||
min-height: 100vh; |
|||
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%); |
|||
} |
|||
|
|||
.header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 24px; |
|||
} |
|||
|
|||
.header h2 { |
|||
color: #f8fafc; |
|||
margin: 0; |
|||
font-size: 24px; |
|||
} |
|||
|
|||
.player-level { |
|||
color: #94a3b8; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.loading, |
|||
.empty { |
|||
color: #64748b; |
|||
text-align: center; |
|||
padding: 60px 20px; |
|||
} |
|||
|
|||
.monster-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); |
|||
gap: 16px; |
|||
} |
|||
|
|||
.monster-card { |
|||
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); |
|||
border: 1px solid rgba(255, 255, 255, 0.08); |
|||
border-radius: 12px; |
|||
padding: 20px; |
|||
cursor: pointer; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.monster-card:hover { |
|||
transform: translateY(-2px); |
|||
border-color: rgba(139, 92, 246, 0.5); |
|||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); |
|||
} |
|||
|
|||
.monster-card.disabled { |
|||
opacity: 0.5; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.monster-card.disabled:hover { |
|||
transform: none; |
|||
border-color: rgba(255, 255, 255, 0.08); |
|||
box-shadow: none; |
|||
} |
|||
|
|||
.monster-icon { |
|||
width: 80px; |
|||
height: 80px; |
|||
margin: 0 auto 16px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
background: rgba(255, 255, 255, 0.04); |
|||
border-radius: 12px; |
|||
} |
|||
|
|||
.monster-icon img { |
|||
width: 60px; |
|||
height: 60px; |
|||
object-fit: contain; |
|||
} |
|||
|
|||
.monster-info { |
|||
text-align: center; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.monster-info h3 { |
|||
color: #f8fafc; |
|||
margin: 0 0 8px; |
|||
font-size: 18px; |
|||
} |
|||
|
|||
.monster-level { |
|||
color: #94a3b8; |
|||
font-size: 14px; |
|||
margin-right: 8px; |
|||
} |
|||
|
|||
.monster-type { |
|||
display: inline-block; |
|||
padding: 2px 8px; |
|||
border-radius: 4px; |
|||
font-size: 12px; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.type-normal { |
|||
background-color: #475569; |
|||
color: #e2e8f0; |
|||
} |
|||
|
|||
.type-elite { |
|||
background-color: #7c3aed; |
|||
color: #fff; |
|||
} |
|||
|
|||
.type-boss { |
|||
background-color: #dc2626; |
|||
color: #fff; |
|||
} |
|||
|
|||
.monster-stats { |
|||
display: flex; |
|||
justify-content: center; |
|||
gap: 16px; |
|||
margin: 12px 0; |
|||
padding: 12px 0; |
|||
border-top: 1px solid rgba(255, 255, 255, 0.08); |
|||
border-bottom: 1px solid rgba(255, 255, 255, 0.08); |
|||
} |
|||
|
|||
.stat { |
|||
text-align: center; |
|||
} |
|||
|
|||
.stat .label { |
|||
display: block; |
|||
color: #64748b; |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.stat .value { |
|||
display: block; |
|||
color: #f8fafc; |
|||
font-size: 16px; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.monster-rewards { |
|||
margin-top: 12px; |
|||
padding-top: 12px; |
|||
border-top: 1px solid rgba(255, 255, 255, 0.08); |
|||
} |
|||
|
|||
.reward-label { |
|||
display: block; |
|||
color: #64748b; |
|||
font-size: 12px; |
|||
margin-bottom: 4px; |
|||
} |
|||
|
|||
.reward-item { |
|||
display: inline-block; |
|||
color: #22c55e; |
|||
font-size: 12px; |
|||
margin-right: 8px; |
|||
background: rgba(34, 197, 94, 0.1); |
|||
padding: 2px 6px; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.challenge-hint { |
|||
margin-top: 12px; |
|||
text-align: center; |
|||
color: #8b5cf6; |
|||
font-size: 14px; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.monster-card.disabled .challenge-hint { |
|||
color: #ef4444; |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue