1. 真正战斗触发:探索时遇敌会进入战斗,不再是占位提示。

2. 回合制战斗 MVP:攻击/技能/防御/道具 四个指令可用,含敌方回合与伤害结算。
  3. 胜负结算与返回:战斗结束后可“返回探索”继续移动。
  4. 探索锁定:战斗中会锁定场景移动,避免状态冲突。
  5. 状态联动:状态面板增加药草数量与“探索中/战斗中”。
This commit is contained in:
zwt13703 2026-04-11 07:39:05 +08:00
parent 823e4c5090
commit 08d4c1349e
7 changed files with 382 additions and 4 deletions

View File

@ -53,3 +53,14 @@
3. 更新状态面板为实时数据显示,并执行构建验证确保可编译运行。
- **执行结果**: 已完成探索层 MVP玩家可在 3D 场景中移动并获得基础 RPG 反馈,项目继续可在此基础上对接战斗系统。
# 任务执行摘要
## 会话 ID: 1775840072
- [2026-04-11 00:54:32]
- **执行原因**: 用户继续要求推进开发,目标是从探索占位过渡到可执行的战斗流程。
- **执行过程**:
1. 扩展全局游戏状态,加入战斗状态机、行动指令与敌方回合逻辑。
2. 新增战斗面板组件,提供攻击/技能/防御/道具操作与战斗日志展示。
3. 将场景遇敌逻辑接入战斗入口,并在战斗期间锁定探索移动;完成构建验证。
- **执行结果**: 已形成“探索移动 -> 遇敌触发 -> 回合战斗 -> 结算返回探索”的可玩闭环 MVP。

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import ThreeScene from './game/ThreeScene.vue'
import BattlePanel from './ui/BattlePanel.vue'
import StatusPanel from './ui/StatusPanel.vue'
import { gameConfig } from './data/gameConfig'
</script>
@ -15,5 +16,6 @@ import { gameConfig } from './data/gameConfig'
<StatusPanel />
<ThreeScene />
</main>
<BattlePanel />
</div>
</template>

View File

@ -1,4 +1,4 @@
export const gameConfig = {
title: 'Breath of Fire-like RPG Demo',
tagline: '里程碑 1: 可移动探索 + 碰撞 + 遇敌占位',
tagline: '里程碑 2: 探索遭遇 + 回合制战斗 MVP',
}

View File

@ -4,7 +4,7 @@ import * as THREE from 'three'
import type { Mesh, Object3D } from 'three'
import { GameLoop } from '../core/loop'
import { KeyboardInput } from '../core/input'
import { useGameState } from './state'
import { startBattle, useGameState } from './state'
const root = ref<HTMLDivElement | null>(null)
const gameState = useGameState()
@ -131,6 +131,13 @@ const initScene = () => {
const cameraOffset = new THREE.Vector3(4.5, 5.2, 6.2)
loop = new GameLoop((dt) => {
if (gameState.battle.active) {
if (renderer && scene && camera) {
renderer.render(scene, camera)
}
return
}
const axis = keyboard?.getMoveAxis() ?? { x: 0, z: 0 }
const hasMoveInput = axis.x !== 0 || axis.z !== 0
@ -152,7 +159,7 @@ const initScene = () => {
if (gameState.encounterMeter >= encounterTarget) {
gameState.encounterMeter = 0
encounterTarget = 35 + Math.random() * 30
gameState.notice = '触发遇敌(占位):可切入战斗场景'
startBattle('你在平原中前进')
}
} else {
gameState.notice = '前方有障碍,无法通过'

View File

@ -1,6 +1,43 @@
import { reactive } from 'vue'
const state = reactive({
export type BattleAction = 'attack' | 'skill' | 'defend' | 'item'
type BattleTurn = 'player' | 'enemy' | 'resolved'
interface BattleState {
active: boolean
turn: BattleTurn
enemyName: string
enemyHp: number
enemyMaxHp: number
guarding: boolean
victory: boolean | null
message: string
log: string[]
}
interface PlayerState {
hp: number
maxHp: number
mp: number
maxMp: number
potions: number
}
interface GameState {
hp: string
mp: string
level: number
area: string
positionText: string
moveSpeed: number
encounterMeter: number
notice: string
player: PlayerState
battle: BattleState
}
const state = reactive<GameState>({
hp: '120 / 120',
mp: '38 / 38',
level: 1,
@ -9,6 +46,173 @@ const state = reactive({
moveSpeed: 3.2,
encounterMeter: 0,
notice: '按 WASD 或方向键移动',
player: {
hp: 120,
maxHp: 120,
mp: 38,
maxMp: 38,
potions: 3,
},
battle: {
active: false,
turn: 'player',
enemyName: '',
enemyHp: 0,
enemyMaxHp: 0,
guarding: false,
victory: null,
message: '',
log: [],
},
})
let enemyActTimer = 0
const addBattleLog = (line: string) => {
state.battle.log.push(line)
if (state.battle.log.length > 8) {
state.battle.log.shift()
}
}
const roll = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min
}
const updateDerivedBars = () => {
state.hp = `${Math.max(0, state.player.hp)} / ${state.player.maxHp}`
state.mp = `${Math.max(0, state.player.mp)} / ${state.player.maxMp}`
}
const resolveBattle = (victory: boolean) => {
state.battle.turn = 'resolved'
state.battle.victory = victory
if (victory) {
const exp = roll(12, 20)
state.notice = `战斗胜利,获得 ${exp} 经验(占位)`
state.battle.message = '胜利。点击“返回探索”继续。'
addBattleLog(`你击败了 ${state.battle.enemyName}`)
} else {
state.notice = '战斗失败(占位):已自动复活到 60 HP'
state.battle.message = '失败。点击“返回探索”继续。'
addBattleLog('你被击倒了。')
state.player.hp = 60
state.player.mp = Math.max(state.player.mp, 10)
updateDerivedBars()
}
clearTimeout(enemyActTimer)
}
const enemyTurn = () => {
if (!state.battle.active || state.battle.turn !== 'enemy') return
const enemyDamage = roll(10, 18)
const damage = state.battle.guarding ? Math.floor(enemyDamage * 0.55) : enemyDamage
state.player.hp -= damage
state.battle.guarding = false
updateDerivedBars()
addBattleLog(`${state.battle.enemyName} 攻击造成 ${damage} 点伤害。`)
if (state.player.hp <= 0) {
resolveBattle(false)
return
}
state.battle.turn = 'player'
state.battle.message = '你的回合:选择一个指令。'
}
export const startBattle = (reason = '遭遇野怪') => {
if (state.battle.active) return
const enemies = [
{ name: '平原狼', hp: 65 },
{ name: '洞穴史莱姆', hp: 72 },
{ name: '山地蜥蜴', hp: 78 },
]
const enemy = enemies[roll(0, enemies.length - 1)]
state.battle.active = true
state.battle.turn = 'player'
state.battle.enemyName = enemy.name
state.battle.enemyHp = enemy.hp
state.battle.enemyMaxHp = enemy.hp
state.battle.guarding = false
state.battle.victory = null
state.battle.message = '你的回合:选择一个指令。'
state.battle.log = [`${reason},出现了 ${enemy.name}`]
state.encounterMeter = 0
state.notice = `进入战斗:${enemy.name}`
}
export const actInBattle = (action: BattleAction) => {
if (!state.battle.active || state.battle.turn !== 'player') return
let damage = 0
if (action === 'attack') {
damage = roll(14, 22)
state.battle.enemyHp -= damage
addBattleLog(`你使用普通攻击,造成 ${damage} 点伤害。`)
}
if (action === 'skill') {
const cost = 6
if (state.player.mp < cost) {
addBattleLog('MP 不足,技能释放失败。')
state.battle.message = 'MP 不足,请重新选择。'
return
}
state.player.mp -= cost
damage = roll(24, 34)
state.battle.enemyHp -= damage
addBattleLog(`你使用火焰术,造成 ${damage} 点伤害。`)
updateDerivedBars()
}
if (action === 'defend') {
state.battle.guarding = true
addBattleLog('你进入防御姿态,下一次受伤降低。')
}
if (action === 'item') {
if (state.player.potions <= 0) {
addBattleLog('没有可用药草。')
state.battle.message = '道具不足,请重新选择。'
return
}
state.player.potions -= 1
const heal = 30
state.player.hp = Math.min(state.player.maxHp, state.player.hp + heal)
addBattleLog(`你使用药草,恢复 ${heal} HP。`)
updateDerivedBars()
}
if (state.battle.enemyHp <= 0) {
state.battle.enemyHp = 0
resolveBattle(true)
return
}
state.battle.turn = 'enemy'
state.battle.message = '敌方行动中...'
enemyActTimer = window.setTimeout(enemyTurn, 500)
}
export const closeBattle = () => {
if (!state.battle.active || state.battle.turn !== 'resolved') return
state.battle.active = false
state.battle.message = ''
state.battle.log = []
state.battle.enemyName = ''
state.notice = '战斗结束,继续探索。'
}
export const useGameState = () => state

152
src/ui/BattlePanel.vue Normal file
View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { actInBattle, closeBattle, useGameState } from '../game/state'
const gameState = useGameState()
const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
actInBattle(action)
}
</script>
<template>
<div v-if="gameState.battle.active" class="battle-overlay">
<section class="battle-card">
<header class="battle-head">
<h2>战斗中{{ gameState.battle.enemyName }}</h2>
<p>{{ gameState.battle.message }}</p>
</header>
<div class="bars">
<div>
<strong>我方</strong>
<p>HP {{ gameState.hp }} | MP {{ gameState.mp }} | 药草 {{ gameState.player.potions }}</p>
</div>
<div>
<strong>敌方</strong>
<p>HP {{ gameState.battle.enemyHp }} / {{ gameState.battle.enemyMaxHp }}</p>
</div>
</div>
<div class="actions" v-if="gameState.battle.turn !== 'resolved'">
<button :disabled="gameState.battle.turn !== 'player'" @click="runAction('attack')">攻击</button>
<button :disabled="gameState.battle.turn !== 'player'" @click="runAction('skill')">技能</button>
<button :disabled="gameState.battle.turn !== 'player'" @click="runAction('defend')">防御</button>
<button :disabled="gameState.battle.turn !== 'player'" @click="runAction('item')">道具</button>
</div>
<div class="result" v-else>
<p>{{ gameState.battle.victory ? '战斗胜利' : '战斗失败' }}</p>
<button @click="closeBattle">返回探索</button>
</div>
<ul class="log">
<li v-for="(line, index) in gameState.battle.log" :key="`${index}-${line}`">{{ line }}</li>
</ul>
</section>
</div>
</template>
<style scoped>
.battle-overlay {
position: fixed;
inset: 0;
background: rgba(6, 10, 14, 0.78);
display: grid;
place-items: center;
z-index: 50;
padding: 14px;
}
.battle-card {
width: min(760px, 100%);
border-radius: 12px;
border: 1px solid #365064;
background: linear-gradient(180deg, #12202a, #0f1921);
padding: 14px;
}
.battle-head h2 {
margin: 0;
font-size: 20px;
}
.battle-head p {
margin: 6px 0 0;
color: #9cc0d6;
}
.bars {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.bars > div {
border: 1px solid #2f4555;
border-radius: 8px;
padding: 10px;
background: #172632;
}
.bars p {
margin: 6px 0 0;
font-size: 13px;
}
.actions {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
button {
border: 1px solid #42627a;
border-radius: 8px;
background: #203646;
color: #d7e0e6;
padding: 10px;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.result p {
margin: 0;
}
.log {
margin: 12px 0 0;
border: 1px solid #2f4555;
border-radius: 8px;
background: #111d27;
padding: 10px 12px;
list-style: none;
display: grid;
gap: 6px;
max-height: 150px;
overflow: auto;
font-size: 13px;
}
@media (max-width: 720px) {
.bars {
grid-template-columns: 1fr;
}
.actions {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@ -11,10 +11,12 @@ const stats = useGameState()
<li>Lv. {{ stats.level }}</li>
<li>HP: {{ stats.hp }}</li>
<li>MP: {{ stats.mp }}</li>
<li>药草: {{ stats.player.potions }}</li>
<li>区域: {{ stats.area }}</li>
<li>坐标: {{ stats.positionText }}</li>
<li>移速: {{ stats.moveSpeed.toFixed(1) }}</li>
<li>遇敌计量: {{ stats.encounterMeter.toFixed(0) }}</li>
<li>战斗状态: {{ stats.battle.active ? '战斗中' : '探索中' }}</li>
</ul>
<p class="notice">{{ stats.notice }}</p>
</aside>