1. 真正战斗触发:探索时遇敌会进入战斗,不再是占位提示。
2. 回合制战斗 MVP:攻击/技能/防御/道具 四个指令可用,含敌方回合与伤害结算。 3. 胜负结算与返回:战斗结束后可“返回探索”继续移动。 4. 探索锁定:战斗中会锁定场景移动,避免状态冲突。 5. 状态联动:状态面板增加药草数量与“探索中/战斗中”。
This commit is contained in:
parent
823e4c5090
commit
08d4c1349e
|
|
@ -53,3 +53,14 @@
|
||||||
3. 更新状态面板为实时数据显示,并执行构建验证确保可编译运行。
|
3. 更新状态面板为实时数据显示,并执行构建验证确保可编译运行。
|
||||||
- **执行结果**: 已完成探索层 MVP,玩家可在 3D 场景中移动并获得基础 RPG 反馈,项目继续可在此基础上对接战斗系统。
|
- **执行结果**: 已完成探索层 MVP,玩家可在 3D 场景中移动并获得基础 RPG 反馈,项目继续可在此基础上对接战斗系统。
|
||||||
|
|
||||||
|
# 任务执行摘要
|
||||||
|
|
||||||
|
## 会话 ID: 1775840072
|
||||||
|
- [2026-04-11 00:54:32]
|
||||||
|
- **执行原因**: 用户继续要求推进开发,目标是从探索占位过渡到可执行的战斗流程。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 扩展全局游戏状态,加入战斗状态机、行动指令与敌方回合逻辑。
|
||||||
|
2. 新增战斗面板组件,提供攻击/技能/防御/道具操作与战斗日志展示。
|
||||||
|
3. 将场景遇敌逻辑接入战斗入口,并在战斗期间锁定探索移动;完成构建验证。
|
||||||
|
- **执行结果**: 已形成“探索移动 -> 遇敌触发 -> 回合战斗 -> 结算返回探索”的可玩闭环 MVP。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ThreeScene from './game/ThreeScene.vue'
|
import ThreeScene from './game/ThreeScene.vue'
|
||||||
|
import BattlePanel from './ui/BattlePanel.vue'
|
||||||
import StatusPanel from './ui/StatusPanel.vue'
|
import StatusPanel from './ui/StatusPanel.vue'
|
||||||
import { gameConfig } from './data/gameConfig'
|
import { gameConfig } from './data/gameConfig'
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -15,5 +16,6 @@ import { gameConfig } from './data/gameConfig'
|
||||||
<StatusPanel />
|
<StatusPanel />
|
||||||
<ThreeScene />
|
<ThreeScene />
|
||||||
</main>
|
</main>
|
||||||
|
<BattlePanel />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export const gameConfig = {
|
export const gameConfig = {
|
||||||
title: 'Breath of Fire-like RPG Demo',
|
title: 'Breath of Fire-like RPG Demo',
|
||||||
tagline: '里程碑 1: 可移动探索 + 碰撞 + 遇敌占位',
|
tagline: '里程碑 2: 探索遭遇 + 回合制战斗 MVP',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import * as THREE from 'three'
|
||||||
import type { Mesh, Object3D } from 'three'
|
import type { Mesh, Object3D } from 'three'
|
||||||
import { GameLoop } from '../core/loop'
|
import { GameLoop } from '../core/loop'
|
||||||
import { KeyboardInput } from '../core/input'
|
import { KeyboardInput } from '../core/input'
|
||||||
import { useGameState } from './state'
|
import { startBattle, useGameState } from './state'
|
||||||
|
|
||||||
const root = ref<HTMLDivElement | null>(null)
|
const root = ref<HTMLDivElement | null>(null)
|
||||||
const gameState = useGameState()
|
const gameState = useGameState()
|
||||||
|
|
@ -131,6 +131,13 @@ const initScene = () => {
|
||||||
const cameraOffset = new THREE.Vector3(4.5, 5.2, 6.2)
|
const cameraOffset = new THREE.Vector3(4.5, 5.2, 6.2)
|
||||||
|
|
||||||
loop = new GameLoop((dt) => {
|
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 axis = keyboard?.getMoveAxis() ?? { x: 0, z: 0 }
|
||||||
const hasMoveInput = axis.x !== 0 || axis.z !== 0
|
const hasMoveInput = axis.x !== 0 || axis.z !== 0
|
||||||
|
|
||||||
|
|
@ -152,7 +159,7 @@ const initScene = () => {
|
||||||
if (gameState.encounterMeter >= encounterTarget) {
|
if (gameState.encounterMeter >= encounterTarget) {
|
||||||
gameState.encounterMeter = 0
|
gameState.encounterMeter = 0
|
||||||
encounterTarget = 35 + Math.random() * 30
|
encounterTarget = 35 + Math.random() * 30
|
||||||
gameState.notice = '触发遇敌(占位):可切入战斗场景'
|
startBattle('你在平原中前进')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
gameState.notice = '前方有障碍,无法通过'
|
gameState.notice = '前方有障碍,无法通过'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,43 @@
|
||||||
import { reactive } from 'vue'
|
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',
|
hp: '120 / 120',
|
||||||
mp: '38 / 38',
|
mp: '38 / 38',
|
||||||
level: 1,
|
level: 1,
|
||||||
|
|
@ -9,6 +46,173 @@ const state = reactive({
|
||||||
moveSpeed: 3.2,
|
moveSpeed: 3.2,
|
||||||
encounterMeter: 0,
|
encounterMeter: 0,
|
||||||
notice: '按 WASD 或方向键移动',
|
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
|
export const useGameState = () => state
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -11,10 +11,12 @@ const stats = useGameState()
|
||||||
<li>Lv. {{ stats.level }}</li>
|
<li>Lv. {{ stats.level }}</li>
|
||||||
<li>HP: {{ stats.hp }}</li>
|
<li>HP: {{ stats.hp }}</li>
|
||||||
<li>MP: {{ stats.mp }}</li>
|
<li>MP: {{ stats.mp }}</li>
|
||||||
|
<li>药草: {{ stats.player.potions }}</li>
|
||||||
<li>区域: {{ stats.area }}</li>
|
<li>区域: {{ stats.area }}</li>
|
||||||
<li>坐标: {{ stats.positionText }}</li>
|
<li>坐标: {{ stats.positionText }}</li>
|
||||||
<li>移速: {{ stats.moveSpeed.toFixed(1) }}</li>
|
<li>移速: {{ stats.moveSpeed.toFixed(1) }}</li>
|
||||||
<li>遇敌计量: {{ stats.encounterMeter.toFixed(0) }}</li>
|
<li>遇敌计量: {{ stats.encounterMeter.toFixed(0) }}</li>
|
||||||
|
<li>战斗状态: {{ stats.battle.active ? '战斗中' : '探索中' }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="notice">{{ stats.notice }}</p>
|
<p class="notice">{{ stats.notice }}</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue