1. 数据驱动战斗配置:敌人和技能参数从逻辑代码拆到配置文件。

2. 场景状态机:新增 sceneMode(explore/battleTransition/battle)。
  3. 战斗切入过渡:遇敌先进入短过场,再切到战斗界面。
  4. 探索锁定规则升级:只在 explore 状态允许移动。
This commit is contained in:
zwt13703 2026-04-11 07:46:52 +08:00
parent f2da55321f
commit c2a3283c54
7 changed files with 166 additions and 29 deletions

View File

@ -2,6 +2,7 @@
import ThreeScene from './game/ThreeScene.vue' import ThreeScene from './game/ThreeScene.vue'
import BattlePanel from './ui/BattlePanel.vue' import BattlePanel from './ui/BattlePanel.vue'
import StatusPanel from './ui/StatusPanel.vue' import StatusPanel from './ui/StatusPanel.vue'
import TransitionOverlay from './ui/TransitionOverlay.vue'
import { gameConfig } from './data/gameConfig' import { gameConfig } from './data/gameConfig'
</script> </script>
@ -16,6 +17,7 @@ import { gameConfig } from './data/gameConfig'
<StatusPanel /> <StatusPanel />
<ThreeScene /> <ThreeScene />
</main> </main>
<TransitionOverlay />
<BattlePanel /> <BattlePanel />
</div> </div>
</template> </template>

53
src/data/battleData.ts Normal file
View File

@ -0,0 +1,53 @@
export interface EnemyDef {
id: string
name: string
hp: number
minAtk: number
maxAtk: number
rewardExp: [number, number]
}
export interface SkillDef {
id: string
name: string
mpCost: number
minDamage: number
maxDamage: number
}
export const enemies: EnemyDef[] = [
{
id: 'plain-wolf',
name: '平原狼',
hp: 65,
minAtk: 10,
maxAtk: 18,
rewardExp: [12, 20],
},
{
id: 'cave-slime',
name: '洞穴史莱姆',
hp: 72,
minAtk: 9,
maxAtk: 16,
rewardExp: [14, 22],
},
{
id: 'mountain-lizard',
name: '山地蜥蜴',
hp: 78,
minAtk: 11,
maxAtk: 19,
rewardExp: [16, 24],
},
]
export const skills: SkillDef[] = [
{
id: 'flame',
name: '火焰术',
mpCost: 6,
minDamage: 24,
maxDamage: 34,
},
]

View File

@ -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: '里程碑 2: 探索遭遇 + 回合制战斗 MVP', tagline: '里程碑 3: 数据驱动战斗 + 切场过渡',
} }

View File

@ -131,7 +131,7 @@ 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 (gameState.sceneMode !== 'explore') {
if (renderer && scene && camera) { if (renderer && scene && camera) {
renderer.render(scene, camera) renderer.render(scene, camera)
} }

View File

@ -1,8 +1,10 @@
import { reactive } from 'vue' import { reactive } from 'vue'
import { enemies, skills, type EnemyDef } from '../data/battleData'
export type BattleAction = 'attack' | 'skill' | 'defend' | 'item' export type BattleAction = 'attack' | 'skill' | 'defend' | 'item'
type BattleTurn = 'player' | 'enemy' | 'resolved' type BattleTurn = 'player' | 'enemy' | 'resolved'
type SceneMode = 'explore' | 'battleTransition' | 'battle'
interface BattleState { interface BattleState {
active: boolean active: boolean
@ -14,6 +16,7 @@ interface BattleState {
victory: boolean | null victory: boolean | null
message: string message: string
log: string[] log: string[]
enemyData: EnemyDef | null
} }
interface PlayerState { interface PlayerState {
@ -33,6 +36,7 @@ interface GameState {
moveSpeed: number moveSpeed: number
encounterMeter: number encounterMeter: number
notice: string notice: string
sceneMode: SceneMode
player: PlayerState player: PlayerState
battle: BattleState battle: BattleState
} }
@ -46,6 +50,7 @@ const state = reactive<GameState>({
moveSpeed: 3.2, moveSpeed: 3.2,
encounterMeter: 0, encounterMeter: 0,
notice: '按 WASD 或方向键移动', notice: '按 WASD 或方向键移动',
sceneMode: 'explore',
player: { player: {
hp: 120, hp: 120,
maxHp: 120, maxHp: 120,
@ -63,10 +68,12 @@ const state = reactive<GameState>({
victory: null, victory: null,
message: '', message: '',
log: [], log: [],
enemyData: null,
}, },
}) })
let enemyActTimer = 0 let enemyActTimer = 0
let battleEnterTimer = 0
const addBattleLog = (line: string) => { const addBattleLog = (line: string) => {
state.battle.log.push(line) state.battle.log.push(line)
@ -79,6 +86,8 @@ const roll = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min return Math.floor(Math.random() * (max - min + 1)) + min
} }
const pickRandom = <T>(arr: T[]) => arr[roll(0, arr.length - 1)]
const updateDerivedBars = () => { const updateDerivedBars = () => {
state.hp = `${Math.max(0, state.player.hp)} / ${state.player.maxHp}` state.hp = `${Math.max(0, state.player.hp)} / ${state.player.maxHp}`
state.mp = `${Math.max(0, state.player.mp)} / ${state.player.maxMp}` state.mp = `${Math.max(0, state.player.mp)} / ${state.player.maxMp}`
@ -89,7 +98,8 @@ const resolveBattle = (victory: boolean) => {
state.battle.victory = victory state.battle.victory = victory
if (victory) { if (victory) {
const exp = roll(12, 20) const rewardRange = state.battle.enemyData?.rewardExp ?? [12, 20]
const exp = roll(rewardRange[0], rewardRange[1])
state.notice = `战斗胜利,获得 ${exp} 经验(占位)` state.notice = `战斗胜利,获得 ${exp} 经验(占位)`
state.battle.message = '胜利。点击“返回探索”继续。' state.battle.message = '胜利。点击“返回探索”继续。'
addBattleLog(`你击败了 ${state.battle.enemyName}`) addBattleLog(`你击败了 ${state.battle.enemyName}`)
@ -108,7 +118,9 @@ const resolveBattle = (victory: boolean) => {
const enemyTurn = () => { const enemyTurn = () => {
if (!state.battle.active || state.battle.turn !== 'enemy') return if (!state.battle.active || state.battle.turn !== 'enemy') return
const enemyDamage = roll(10, 18) const minAtk = state.battle.enemyData?.minAtk ?? 10
const maxAtk = state.battle.enemyData?.maxAtk ?? 18
const enemyDamage = roll(minAtk, maxAtk)
const damage = state.battle.guarding ? Math.floor(enemyDamage * 0.55) : enemyDamage const damage = state.battle.guarding ? Math.floor(enemyDamage * 0.55) : enemyDamage
state.player.hp -= damage state.player.hp -= damage
state.battle.guarding = false state.battle.guarding = false
@ -126,17 +138,16 @@ const enemyTurn = () => {
} }
export const startBattle = (reason = '遭遇野怪') => { export const startBattle = (reason = '遭遇野怪') => {
if (state.battle.active) return if (state.battle.active || state.sceneMode === 'battleTransition') return
const enemies = [ const enemy = pickRandom(enemies)
{ name: '平原狼', hp: 65 }, state.sceneMode = 'battleTransition'
{ name: '洞穴史莱姆', hp: 72 }, state.notice = `遭遇 ${enemy.name},准备进入战斗...`
{ name: '山地蜥蜴', hp: 78 },
]
const enemy = enemies[roll(0, enemies.length - 1)]
clearTimeout(battleEnterTimer)
battleEnterTimer = window.setTimeout(() => {
state.battle.active = true state.battle.active = true
state.sceneMode = 'battle'
state.battle.turn = 'player' state.battle.turn = 'player'
state.battle.enemyName = enemy.name state.battle.enemyName = enemy.name
state.battle.enemyHp = enemy.hp state.battle.enemyHp = enemy.hp
@ -145,8 +156,10 @@ export const startBattle = (reason = '遭遇野怪') => {
state.battle.victory = null state.battle.victory = null
state.battle.message = '你的回合:选择一个指令。' state.battle.message = '你的回合:选择一个指令。'
state.battle.log = [`${reason},出现了 ${enemy.name}`] state.battle.log = [`${reason},出现了 ${enemy.name}`]
state.battle.enemyData = enemy
state.encounterMeter = 0 state.encounterMeter = 0
state.notice = `进入战斗:${enemy.name}` state.notice = `进入战斗:${enemy.name}`
}, 450)
} }
export const actInBattle = (action: BattleAction) => { export const actInBattle = (action: BattleAction) => {
@ -161,17 +174,19 @@ export const actInBattle = (action: BattleAction) => {
} }
if (action === 'skill') { if (action === 'skill') {
const cost = 6 const skill = skills[0]
if (state.player.mp < cost) { if (!skill) return
if (state.player.mp < skill.mpCost) {
addBattleLog('MP 不足,技能释放失败。') addBattleLog('MP 不足,技能释放失败。')
state.battle.message = 'MP 不足,请重新选择。' state.battle.message = 'MP 不足,请重新选择。'
return return
} }
state.player.mp -= cost state.player.mp -= skill.mpCost
damage = roll(24, 34) damage = roll(skill.minDamage, skill.maxDamage)
state.battle.enemyHp -= damage state.battle.enemyHp -= damage
addBattleLog(`你使用火焰术,造成 ${damage} 点伤害。`) addBattleLog(`你使用${skill.name},造成 ${damage} 点伤害。`)
updateDerivedBars() updateDerivedBars()
} }
@ -212,6 +227,8 @@ export const closeBattle = () => {
state.battle.message = '' state.battle.message = ''
state.battle.log = [] state.battle.log = []
state.battle.enemyName = '' state.battle.enemyName = ''
state.battle.enemyData = null
state.sceneMode = 'explore'
state.notice = '战斗结束,继续探索。' state.notice = '战斗结束,继续探索。'
} }

View File

@ -16,7 +16,8 @@ const stats = useGameState()
<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> <li>场景状态: {{ stats.sceneMode }}</li>
<li>战斗状态: {{ stats.battle.active ? '战斗中' : '未战斗' }}</li>
</ul> </ul>
<p class="notice">{{ stats.notice }}</p> <p class="notice">{{ stats.notice }}</p>
</aside> </aside>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { useGameState } from '../game/state'
const gameState = useGameState()
</script>
<template>
<div v-if="gameState.sceneMode === 'battleTransition'" class="overlay">
<div class="flash" />
<p>Encounter...</p>
</div>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
z-index: 45;
display: grid;
place-items: center;
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.15), rgba(8, 12, 16, 0.95));
animation: fadeIn 0.2s ease-out;
}
.flash {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-30deg,
rgba(255, 255, 255, 0.06) 0px,
rgba(255, 255, 255, 0.06) 6px,
transparent 6px,
transparent 18px
);
animation: stripeMove 0.7s linear infinite;
}
p {
position: relative;
margin: 0;
font-size: 28px;
letter-spacing: 1px;
color: #e6eef4;
text-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
@keyframes stripeMove {
from {
transform: translateX(0);
}
to {
transform: translateX(24px);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>