1. 把战斗从纯面板改为 Three.js 顶视战场(固定俯视相机 + 战场地形 + 我方/敌方单位)。

2. 加入回合表现反馈(当前行动方光圈高亮、受击闪烁、敌方血量影响体型)。
  3. 保留原回合指令 HUD(攻击/技能/防御/道具)和日志、结算按钮。
  4. 增加战斗资源生命周期管理(进入战斗初始化,退出战斗释放 WebGL 资源)。
This commit is contained in:
zwt13703 2026-04-11 08:08:36 +08:00
parent affc6ed059
commit f1d5b7e047
3 changed files with 283 additions and 53 deletions

View File

@ -86,3 +86,14 @@
3. 在探索场景添加 NPC 模型与互动提示,接入 E 键对话,并更新状态面板展示任务进度。
- **执行结果**: 已形成第一条任务链闭环(接任务 -> 打怪累积 -> 回 NPC 交付),并通过构建验证。
# 任务执行摘要
## 会话 ID: 1775865869
- [2026-04-11 08:04:29]
- **执行原因**: 用户要求将战斗系统改为俯视角 RPG 回合战斗场景风格。
- **执行过程**:
1. 重构 BattlePanel接入 Three.js 俯视战场(相机、地形、单位、回合高亮、受击反馈)。
2. 保留并整合原有回合指令 HUD攻击/技能/防御/道具)与日志结算区。
3. 增加战斗场景资源生命周期管理(进入初始化、退出释放),并完成构建验证。
- **执行结果**: 战斗已从纯 UI 面板升级为俯视角战斗场景形成“3D 战场 + 回合指令 HUD”的可玩表现。

View File

@ -1,4 +1,4 @@
export const gameConfig = {
title: 'Breath of Fire-like RPG Demo',
tagline: '里程碑 4: NPC 任务链(接取/击败/交付)',
tagline: '里程碑 5: 俯视角回合战斗场景',
}

View File

@ -1,18 +1,217 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as THREE from 'three'
import { actInBattle, closeBattle, useGameState } from '../game/state'
const gameState = useGameState()
const viewport = ref<HTMLDivElement | null>(null)
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let resizeObserver: ResizeObserver | null = null
let frameId = 0
let playerUnit: THREE.Mesh | null = null
let enemyUnit: THREE.Mesh | null = null
let playerAura: THREE.Mesh | null = null
let enemyAura: THREE.Mesh | null = null
let prevEnemyHp = 0
let prevPlayerHp = 0
let enemyHitTimer = 0
let playerHitTimer = 0
const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
actInBattle(action)
}
const initScene = async () => {
await nextTick()
if (!viewport.value || renderer) return
scene = new THREE.Scene()
scene.background = new THREE.Color('#0f1720')
camera = new THREE.PerspectiveCamera(52, 1, 0.1, 80)
camera.position.set(0, 11.2, 0.1)
camera.lookAt(0, 0, 0)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
viewport.value.appendChild(renderer.domElement)
const hemi = new THREE.HemisphereLight('#b3e0ff', '#1b2631', 0.7)
scene.add(hemi)
const keyLight = new THREE.DirectionalLight('#fff3ce', 1.15)
keyLight.position.set(4, 8, 2)
scene.add(keyLight)
const arena = new THREE.Mesh(
new THREE.CylinderGeometry(5.6, 6.2, 0.35, 48),
new THREE.MeshStandardMaterial({ color: '#293847' }),
)
arena.position.y = -0.2
scene.add(arena)
const ring = new THREE.Mesh(
new THREE.TorusGeometry(5.8, 0.12, 16, 80),
new THREE.MeshStandardMaterial({ color: '#4b6a80', emissive: '#1c2e3b', emissiveIntensity: 0.45 }),
)
ring.rotation.x = Math.PI / 2
scene.add(ring)
const playerMaterial = new THREE.MeshStandardMaterial({ color: '#68d8c3' })
const enemyMaterial = new THREE.MeshStandardMaterial({ color: '#ef8f6b' })
playerUnit = new THREE.Mesh(new THREE.CapsuleGeometry(0.42, 1.0, 4, 10), playerMaterial)
playerUnit.position.set(-2.2, 0.55, 1.8)
scene.add(playerUnit)
enemyUnit = new THREE.Mesh(new THREE.ConeGeometry(0.55, 1.35, 14), enemyMaterial)
enemyUnit.position.set(2.1, 0.7, -1.4)
enemyUnit.rotation.x = Math.PI
scene.add(enemyUnit)
playerAura = new THREE.Mesh(
new THREE.RingGeometry(0.55, 0.74, 24),
new THREE.MeshBasicMaterial({ color: '#86ffdf', transparent: true, opacity: 0.7 }),
)
playerAura.rotation.x = -Math.PI / 2
playerAura.position.set(-2.2, 0.02, 1.8)
scene.add(playerAura)
enemyAura = new THREE.Mesh(
new THREE.RingGeometry(0.55, 0.74, 24),
new THREE.MeshBasicMaterial({ color: '#ffb193', transparent: true, opacity: 0.7 }),
)
enemyAura.rotation.x = -Math.PI / 2
enemyAura.position.set(2.1, 0.02, -1.4)
scene.add(enemyAura)
const resize = () => {
if (!viewport.value || !renderer || !camera) return
const width = viewport.value.clientWidth
const height = viewport.value.clientHeight
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
}
resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(viewport.value)
resize()
prevEnemyHp = gameState.battle.enemyHp
prevPlayerHp = gameState.player.hp
const tick = () => {
frameId = requestAnimationFrame(tick)
if (!scene || !camera || !renderer || !playerUnit || !enemyUnit || !playerAura || !enemyAura) return
if (!gameState.battle.active) {
return
}
const now = performance.now() * 0.001
if (gameState.battle.enemyHp < prevEnemyHp) {
enemyHitTimer = 0.32
}
if (gameState.player.hp < prevPlayerHp) {
playerHitTimer = 0.32
}
prevEnemyHp = gameState.battle.enemyHp
prevPlayerHp = gameState.player.hp
enemyHitTimer = Math.max(0, enemyHitTimer - 0.016)
playerHitTimer = Math.max(0, playerHitTimer - 0.016)
playerUnit.position.y = 0.55 + Math.sin(now * 3.1) * 0.06
enemyUnit.position.y = 0.7 + Math.sin(now * 2.5 + 0.8) * 0.07
const enemyHpRatio = gameState.battle.enemyMaxHp
? gameState.battle.enemyHp / gameState.battle.enemyMaxHp
: 1
enemyUnit.scale.setScalar(0.86 + enemyHpRatio * 0.2)
const playerTurn = gameState.battle.turn === 'player'
const enemyTurn = gameState.battle.turn === 'enemy'
;(playerAura.material as THREE.MeshBasicMaterial).opacity = playerTurn ? 0.95 : 0.38
;(enemyAura.material as THREE.MeshBasicMaterial).opacity = enemyTurn ? 0.95 : 0.38
;(playerUnit.material as THREE.MeshStandardMaterial).emissive.setHex(playerHitTimer > 0 ? 0x6a2323 : 0x0)
;(enemyUnit.material as THREE.MeshStandardMaterial).emissive.setHex(enemyHitTimer > 0 ? 0x6a2323 : 0x0)
camera.lookAt(0, 0, 0)
renderer.render(scene, camera)
}
tick()
}
const disposeScene = () => {
cancelAnimationFrame(frameId)
resizeObserver?.disconnect()
if (viewport.value && renderer && viewport.value.contains(renderer.domElement)) {
viewport.value.removeChild(renderer.domElement)
}
scene?.traverse((object) => {
const mesh = object as THREE.Mesh
if (mesh.geometry) mesh.geometry.dispose()
const material = mesh.material
if (Array.isArray(material)) {
material.forEach((m) => m.dispose())
} else if (material) {
material.dispose()
}
})
renderer?.dispose()
renderer = null
scene = null
camera = null
resizeObserver = null
playerUnit = null
enemyUnit = null
playerAura = null
enemyAura = null
}
watch(
() => gameState.battle.active,
async (active) => {
if (active) {
await initScene()
} else {
disposeScene()
}
},
)
onMounted(async () => {
if (gameState.battle.active) {
await initScene()
}
})
onBeforeUnmount(disposeScene)
</script>
<template>
<div v-if="gameState.battle.active" class="battle-overlay">
<section class="battle-card">
<section class="battle-stage">
<div ref="viewport" class="battle-viewport" />
<div class="battle-hud">
<header class="battle-head">
<h2>战斗中{{ gameState.battle.enemyName }}</h2>
<h2>俯视战斗{{ gameState.battle.enemyName }}</h2>
<p>{{ gameState.battle.message }}</p>
</header>
@ -42,6 +241,7 @@ const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
<ul class="log">
<li v-for="(line, index) in gameState.battle.log" :key="`${index}-${line}`">{{ line }}</li>
</ul>
</div>
</section>
</div>
</template>
@ -50,19 +250,33 @@ const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
.battle-overlay {
position: fixed;
inset: 0;
background: rgba(6, 10, 14, 0.78);
display: grid;
place-items: center;
z-index: 50;
background: radial-gradient(circle at center, rgba(14, 21, 28, 0.86), rgba(5, 9, 12, 0.95));
padding: 14px;
}
.battle-card {
width: min(760px, 100%);
.battle-stage {
width: min(1060px, 100%);
height: 100%;
margin: 0 auto;
display: grid;
grid-template-rows: minmax(260px, 58vh) auto;
gap: 10px;
}
.battle-viewport {
border: 1px solid #3b556a;
border-radius: 12px;
border: 1px solid #365064;
background: linear-gradient(180deg, #12202a, #0f1921);
padding: 14px;
overflow: hidden;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
background: #101a22;
}
.battle-hud {
border: 1px solid #304758;
border-radius: 12px;
background: linear-gradient(180deg, #13212c, #0f1a23);
padding: 12px;
}
.battle-head h2 {
@ -71,22 +285,23 @@ const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
}
.battle-head p {
margin: 6px 0 0;
color: #9cc0d6;
margin: 4px 0 0;
color: #9ec1d8;
font-size: 13px;
}
.bars {
margin-top: 12px;
margin-top: 10px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.bars > div {
border: 1px solid #2f4555;
border: 1px solid #304758;
border-radius: 8px;
padding: 10px;
background: #172632;
padding: 8px 10px;
background: #162734;
}
.bars p {
@ -95,18 +310,18 @@ const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
}
.actions {
margin-top: 12px;
margin-top: 10px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
button {
border: 1px solid #42627a;
border: 1px solid #44657e;
border-radius: 8px;
background: #203646;
background: #1e3546;
color: #d7e0e6;
padding: 10px;
padding: 9px 10px;
cursor: pointer;
}
@ -116,7 +331,7 @@ button:disabled {
}
.result {
margin-top: 12px;
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
@ -127,20 +342,24 @@ button:disabled {
}
.log {
margin: 12px 0 0;
border: 1px solid #2f4555;
margin: 10px 0 0;
border: 1px solid #304758;
border-radius: 8px;
background: #111d27;
background: #0f1b24;
padding: 10px 12px;
list-style: none;
display: grid;
gap: 6px;
max-height: 150px;
gap: 5px;
max-height: 140px;
overflow: auto;
font-size: 13px;
font-size: 12px;
}
@media (max-width: 900px) {
.battle-stage {
grid-template-rows: minmax(230px, 48vh) auto;
}
@media (max-width: 720px) {
.bars {
grid-template-columns: 1fr;
}