1. 把战斗从纯面板改为 Three.js 顶视战场(固定俯视相机 + 战场地形 + 我方/敌方单位)。
2. 加入回合表现反馈(当前行动方光圈高亮、受击闪烁、敌方血量影响体型)。 3. 保留原回合指令 HUD(攻击/技能/防御/道具)和日志、结算按钮。 4. 增加战斗资源生命周期管理(进入战斗初始化,退出战斗释放 WebGL 资源)。
This commit is contained in:
parent
affc6ed059
commit
f1d5b7e047
|
|
@ -86,3 +86,14 @@
|
||||||
3. 在探索场景添加 NPC 模型与互动提示,接入 E 键对话,并更新状态面板展示任务进度。
|
3. 在探索场景添加 NPC 模型与互动提示,接入 E 键对话,并更新状态面板展示任务进度。
|
||||||
- **执行结果**: 已形成第一条任务链闭环(接任务 -> 打怪累积 -> 回 NPC 交付),并通过构建验证。
|
- **执行结果**: 已形成第一条任务链闭环(接任务 -> 打怪累积 -> 回 NPC 交付),并通过构建验证。
|
||||||
|
|
||||||
|
# 任务执行摘要
|
||||||
|
|
||||||
|
## 会话 ID: 1775865869
|
||||||
|
- [2026-04-11 08:04:29]
|
||||||
|
- **执行原因**: 用户要求将战斗系统改为俯视角 RPG 回合战斗场景风格。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 重构 BattlePanel,接入 Three.js 俯视战场(相机、地形、单位、回合高亮、受击反馈)。
|
||||||
|
2. 保留并整合原有回合指令 HUD(攻击/技能/防御/道具)与日志结算区。
|
||||||
|
3. 增加战斗场景资源生命周期管理(进入初始化、退出释放),并完成构建验证。
|
||||||
|
- **执行结果**: 战斗已从纯 UI 面板升级为俯视角战斗场景,形成“3D 战场 + 回合指令 HUD”的可玩表现。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: '里程碑 4: NPC 任务链(接取/击败/交付)',
|
tagline: '里程碑 5: 俯视角回合战斗场景',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,217 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import * as THREE from 'three'
|
||||||
import { actInBattle, closeBattle, useGameState } from '../game/state'
|
import { actInBattle, closeBattle, useGameState } from '../game/state'
|
||||||
|
|
||||||
const gameState = useGameState()
|
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') => {
|
const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
|
||||||
actInBattle(action)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="gameState.battle.active" class="battle-overlay">
|
<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">
|
<header class="battle-head">
|
||||||
<h2>战斗中:{{ gameState.battle.enemyName }}</h2>
|
<h2>俯视战斗:{{ gameState.battle.enemyName }}</h2>
|
||||||
<p>{{ gameState.battle.message }}</p>
|
<p>{{ gameState.battle.message }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -42,6 +241,7 @@ const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
|
||||||
<ul class="log">
|
<ul class="log">
|
||||||
<li v-for="(line, index) in gameState.battle.log" :key="`${index}-${line}`">{{ line }}</li>
|
<li v-for="(line, index) in gameState.battle.log" :key="`${index}-${line}`">{{ line }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -50,19 +250,33 @@ const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
|
||||||
.battle-overlay {
|
.battle-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(6, 10, 14, 0.78);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
background: radial-gradient(circle at center, rgba(14, 21, 28, 0.86), rgba(5, 9, 12, 0.95));
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-card {
|
.battle-stage {
|
||||||
width: min(760px, 100%);
|
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-radius: 12px;
|
||||||
border: 1px solid #365064;
|
overflow: hidden;
|
||||||
background: linear-gradient(180deg, #12202a, #0f1921);
|
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
|
||||||
padding: 14px;
|
background: #101a22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-hud {
|
||||||
|
border: 1px solid #304758;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(180deg, #13212c, #0f1a23);
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-head h2 {
|
.battle-head h2 {
|
||||||
|
|
@ -71,22 +285,23 @@ const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-head p {
|
.battle-head p {
|
||||||
margin: 6px 0 0;
|
margin: 4px 0 0;
|
||||||
color: #9cc0d6;
|
color: #9ec1d8;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bars {
|
.bars {
|
||||||
margin-top: 12px;
|
margin-top: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bars > div {
|
.bars > div {
|
||||||
border: 1px solid #2f4555;
|
border: 1px solid #304758;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 8px 10px;
|
||||||
background: #172632;
|
background: #162734;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bars p {
|
.bars p {
|
||||||
|
|
@ -95,18 +310,18 @@ const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: 12px;
|
margin-top: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border: 1px solid #42627a;
|
border: 1px solid #44657e;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #203646;
|
background: #1e3546;
|
||||||
color: #d7e0e6;
|
color: #d7e0e6;
|
||||||
padding: 10px;
|
padding: 9px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +331,7 @@ button:disabled {
|
||||||
}
|
}
|
||||||
|
|
||||||
.result {
|
.result {
|
||||||
margin-top: 12px;
|
margin-top: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -127,20 +342,24 @@ button:disabled {
|
||||||
}
|
}
|
||||||
|
|
||||||
.log {
|
.log {
|
||||||
margin: 12px 0 0;
|
margin: 10px 0 0;
|
||||||
border: 1px solid #2f4555;
|
border: 1px solid #304758;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #111d27;
|
background: #0f1b24;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
max-height: 150px;
|
max-height: 140px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 900px) {
|
||||||
|
.battle-stage {
|
||||||
|
grid-template-rows: minmax(230px, 48vh) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.bars {
|
.bars {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue