1. 网格化俯视战场(地面棋盘 + Grid 表现)。

2. 指令目标选择:攻击/技能 不再立即结算,先进入“选择目标”状态。
  3. 点击敌方单位才执行对应指令(回合制目标确认)。
  4. 范围提示环:攻击和技能分别显示不同范围/颜色提示。
  5. HUD 提示文案:会显示“请选择目标(点击敌方单位)”。
This commit is contained in:
zwt13703 2026-04-11 08:34:44 +08:00
parent f1d5b7e047
commit 3eeba28daa
3 changed files with 129 additions and 32 deletions

View File

@ -97,3 +97,14 @@
3. 增加战斗场景资源生命周期管理(进入初始化、退出释放),并完成构建验证。 3. 增加战斗场景资源生命周期管理(进入初始化、退出释放),并完成构建验证。
- **执行结果**: 战斗已从纯 UI 面板升级为俯视角战斗场景形成“3D 战场 + 回合指令 HUD”的可玩表现。 - **执行结果**: 战斗已从纯 UI 面板升级为俯视角战斗场景形成“3D 战场 + 回合指令 HUD”的可玩表现。
# 任务执行摘要
## 会话 ID: 1775866460
- [2026-04-11 08:14:20]
- **执行原因**: 用户继续要求优化战斗体验,目标增加俯视战斗的战术交互感。
- **执行过程**:
1. 在 BattlePanel 中加入网格化战场表现与俯视镜头,增强回合战斗场景感。
2. 增加目标选择流程:攻击/技能进入待选目标状态,点击敌方单位后执行指令。
3. 新增技能/攻击范围环提示、HUD 引导文案和资源释放逻辑,完成构建验证。
- **执行结果**: 战斗场景已具备基础战术交互(目标选择+范围提示),体验更接近俯视角 RPG 回合战斗。

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: '里程碑 5: 俯视角回合战斗场景', tagline: '里程碑 6: 俯视战术战斗(目标选择/范围提示)',
} }

View File

@ -15,16 +15,53 @@ let playerUnit: THREE.Mesh | null = null
let enemyUnit: THREE.Mesh | null = null let enemyUnit: THREE.Mesh | null = null
let playerAura: THREE.Mesh | null = null let playerAura: THREE.Mesh | null = null
let enemyAura: THREE.Mesh | null = null let enemyAura: THREE.Mesh | null = null
let rangeRing: THREE.Mesh | null = null
let prevEnemyHp = 0 let prevEnemyHp = 0
let prevPlayerHp = 0 let prevPlayerHp = 0
let enemyHitTimer = 0 let enemyHitTimer = 0
let playerHitTimer = 0 let playerHitTimer = 0
const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
const pendingAction = ref<'attack' | 'skill' | null>(null)
const hudHint = ref('')
const resetPendingAction = () => {
pendingAction.value = null
hudHint.value = ''
}
const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => { const runAction = (action: 'attack' | 'skill' | 'defend' | 'item') => {
if (gameState.battle.turn !== 'player') return
if (action === 'attack' || action === 'skill') {
pendingAction.value = action
hudHint.value = action === 'attack' ? '请选择攻击目标' : '请选择技能目标'
return
}
resetPendingAction()
actInBattle(action) actInBattle(action)
} }
const onPointerDown = (event: PointerEvent) => {
if (!renderer || !camera || !enemyUnit || !pendingAction.value) return
if (gameState.battle.turn !== 'player') return
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(pointer, camera)
const hits = raycaster.intersectObject(enemyUnit, false)
if (hits.length === 0) return
actInBattle(pendingAction.value)
resetPendingAction()
}
const initScene = async () => { const initScene = async () => {
await nextTick() await nextTick()
if (!viewport.value || renderer) return if (!viewport.value || renderer) return
@ -33,11 +70,13 @@ const initScene = async () => {
scene.background = new THREE.Color('#0f1720') scene.background = new THREE.Color('#0f1720')
camera = new THREE.PerspectiveCamera(52, 1, 0.1, 80) camera = new THREE.PerspectiveCamera(52, 1, 0.1, 80)
camera.position.set(0, 11.2, 0.1) camera.position.set(0, 10.5, 5.8)
camera.lookAt(0, 0, 0) camera.lookAt(0, 0, 0)
renderer = new THREE.WebGLRenderer({ antialias: true }) renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.domElement.style.touchAction = 'none'
renderer.domElement.addEventListener('pointerdown', onPointerDown)
viewport.value.appendChild(renderer.domElement) viewport.value.appendChild(renderer.domElement)
const hemi = new THREE.HemisphereLight('#b3e0ff', '#1b2631', 0.7) const hemi = new THREE.HemisphereLight('#b3e0ff', '#1b2631', 0.7)
@ -47,48 +86,68 @@ const initScene = async () => {
keyLight.position.set(4, 8, 2) keyLight.position.set(4, 8, 2)
scene.add(keyLight) scene.add(keyLight)
const arena = new THREE.Mesh( const arenaBase = new THREE.Mesh(
new THREE.CylinderGeometry(5.6, 6.2, 0.35, 48), new THREE.CylinderGeometry(5.8, 6.4, 0.4, 48),
new THREE.MeshStandardMaterial({ color: '#293847' }), new THREE.MeshStandardMaterial({ color: '#293847' }),
) )
arena.position.y = -0.2 arenaBase.position.y = -0.22
scene.add(arena) scene.add(arenaBase)
const ring = new THREE.Mesh( const arenaGrid = new THREE.GridHelper(10, 10, '#58758a', '#2b3d4d')
new THREE.TorusGeometry(5.8, 0.12, 16, 80), arenaGrid.position.y = 0.01
new THREE.MeshStandardMaterial({ color: '#4b6a80', emissive: '#1c2e3b', emissiveIntensity: 0.45 }), scene.add(arenaGrid)
)
ring.rotation.x = Math.PI / 2 const tileMaterialA = new THREE.MeshStandardMaterial({ color: '#1a2a37', metalness: 0.1, roughness: 0.9 })
scene.add(ring) const tileMaterialB = new THREE.MeshStandardMaterial({ color: '#223543', metalness: 0.1, roughness: 0.9 })
for (let x = -4; x <= 4; x += 2) {
for (let z = -4; z <= 4; z += 2) {
const tile = new THREE.Mesh(
new THREE.PlaneGeometry(2, 2),
(Math.abs(x + z) / 2) % 2 === 0 ? tileMaterialA : tileMaterialB,
)
tile.rotation.x = -Math.PI / 2
tile.position.set(x, 0, z)
scene.add(tile)
}
}
const playerMaterial = new THREE.MeshStandardMaterial({ color: '#68d8c3' }) const playerMaterial = new THREE.MeshStandardMaterial({ color: '#68d8c3' })
const enemyMaterial = new THREE.MeshStandardMaterial({ color: '#ef8f6b' }) const enemyMaterial = new THREE.MeshStandardMaterial({ color: '#ef8f6b' })
playerUnit = new THREE.Mesh(new THREE.CapsuleGeometry(0.42, 1.0, 4, 10), playerMaterial) playerUnit = new THREE.Mesh(new THREE.CapsuleGeometry(0.42, 1.0, 4, 10), playerMaterial)
playerUnit.position.set(-2.2, 0.55, 1.8) playerUnit.position.set(-2, 0.55, 2)
scene.add(playerUnit) scene.add(playerUnit)
enemyUnit = new THREE.Mesh(new THREE.ConeGeometry(0.55, 1.35, 14), enemyMaterial) enemyUnit = new THREE.Mesh(new THREE.ConeGeometry(0.55, 1.35, 14), enemyMaterial)
enemyUnit.position.set(2.1, 0.7, -1.4) enemyUnit.position.set(2, 0.7, -2)
enemyUnit.rotation.x = Math.PI enemyUnit.rotation.x = Math.PI
scene.add(enemyUnit) scene.add(enemyUnit)
playerAura = new THREE.Mesh( playerAura = new THREE.Mesh(
new THREE.RingGeometry(0.55, 0.74, 24), new THREE.RingGeometry(0.55, 0.76, 24),
new THREE.MeshBasicMaterial({ color: '#86ffdf', transparent: true, opacity: 0.7 }), new THREE.MeshBasicMaterial({ color: '#86ffdf', transparent: true, opacity: 0.7 }),
) )
playerAura.rotation.x = -Math.PI / 2 playerAura.rotation.x = -Math.PI / 2
playerAura.position.set(-2.2, 0.02, 1.8) playerAura.position.set(-2, 0.02, 2)
scene.add(playerAura) scene.add(playerAura)
enemyAura = new THREE.Mesh( enemyAura = new THREE.Mesh(
new THREE.RingGeometry(0.55, 0.74, 24), new THREE.RingGeometry(0.55, 0.76, 24),
new THREE.MeshBasicMaterial({ color: '#ffb193', transparent: true, opacity: 0.7 }), new THREE.MeshBasicMaterial({ color: '#ffb193', transparent: true, opacity: 0.7 }),
) )
enemyAura.rotation.x = -Math.PI / 2 enemyAura.rotation.x = -Math.PI / 2
enemyAura.position.set(2.1, 0.02, -1.4) enemyAura.position.set(2, 0.02, -2)
scene.add(enemyAura) scene.add(enemyAura)
rangeRing = new THREE.Mesh(
new THREE.RingGeometry(1.1, 1.28, 40),
new THREE.MeshBasicMaterial({ color: '#e2f078', transparent: true, opacity: 0.8 }),
)
rangeRing.rotation.x = -Math.PI / 2
rangeRing.position.set(2, 0.03, -2)
rangeRing.visible = false
scene.add(rangeRing)
const resize = () => { const resize = () => {
if (!viewport.value || !renderer || !camera) return if (!viewport.value || !renderer || !camera) return
const width = viewport.value.clientWidth const width = viewport.value.clientWidth
@ -108,20 +167,13 @@ const initScene = async () => {
const tick = () => { const tick = () => {
frameId = requestAnimationFrame(tick) frameId = requestAnimationFrame(tick)
if (!scene || !camera || !renderer || !playerUnit || !enemyUnit || !playerAura || !enemyAura) return if (!scene || !camera || !renderer || !playerUnit || !enemyUnit || !playerAura || !enemyAura || !rangeRing) return
if (!gameState.battle.active) return
if (!gameState.battle.active) {
return
}
const now = performance.now() * 0.001 const now = performance.now() * 0.001
if (gameState.battle.enemyHp < prevEnemyHp) { if (gameState.battle.enemyHp < prevEnemyHp) enemyHitTimer = 0.32
enemyHitTimer = 0.32 if (gameState.player.hp < prevPlayerHp) playerHitTimer = 0.32
}
if (gameState.player.hp < prevPlayerHp) {
playerHitTimer = 0.32
}
prevEnemyHp = gameState.battle.enemyHp prevEnemyHp = gameState.battle.enemyHp
prevPlayerHp = gameState.player.hp prevPlayerHp = gameState.player.hp
@ -131,9 +183,7 @@ const initScene = async () => {
playerUnit.position.y = 0.55 + Math.sin(now * 3.1) * 0.06 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 enemyUnit.position.y = 0.7 + Math.sin(now * 2.5 + 0.8) * 0.07
const enemyHpRatio = gameState.battle.enemyMaxHp const enemyHpRatio = gameState.battle.enemyMaxHp ? gameState.battle.enemyHp / gameState.battle.enemyMaxHp : 1
? gameState.battle.enemyHp / gameState.battle.enemyMaxHp
: 1
enemyUnit.scale.setScalar(0.86 + enemyHpRatio * 0.2) enemyUnit.scale.setScalar(0.86 + enemyHpRatio * 0.2)
const playerTurn = gameState.battle.turn === 'player' const playerTurn = gameState.battle.turn === 'player'
@ -145,6 +195,22 @@ const initScene = async () => {
;(playerUnit.material as THREE.MeshStandardMaterial).emissive.setHex(playerHitTimer > 0 ? 0x6a2323 : 0x0) ;(playerUnit.material as THREE.MeshStandardMaterial).emissive.setHex(playerHitTimer > 0 ? 0x6a2323 : 0x0)
;(enemyUnit.material as THREE.MeshStandardMaterial).emissive.setHex(enemyHitTimer > 0 ? 0x6a2323 : 0x0) ;(enemyUnit.material as THREE.MeshStandardMaterial).emissive.setHex(enemyHitTimer > 0 ? 0x6a2323 : 0x0)
if (pendingAction.value === 'attack') {
rangeRing.visible = true
rangeRing.scale.setScalar(1)
;(rangeRing.material as THREE.MeshBasicMaterial).color.set('#e2f078')
;(rangeRing.material as THREE.MeshBasicMaterial).opacity = 0.82
} else if (pendingAction.value === 'skill') {
rangeRing.visible = true
rangeRing.scale.setScalar(1.35)
;(rangeRing.material as THREE.MeshBasicMaterial).color.set('#f5897c')
;(rangeRing.material as THREE.MeshBasicMaterial).opacity = 0.9
} else {
rangeRing.visible = false
}
rangeRing.position.y = 0.03 + Math.sin(now * 5) * 0.01
camera.lookAt(0, 0, 0) camera.lookAt(0, 0, 0)
renderer.render(scene, camera) renderer.render(scene, camera)
} }
@ -156,6 +222,10 @@ const disposeScene = () => {
cancelAnimationFrame(frameId) cancelAnimationFrame(frameId)
resizeObserver?.disconnect() resizeObserver?.disconnect()
if (renderer) {
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
}
if (viewport.value && renderer && viewport.value.contains(renderer.domElement)) { if (viewport.value && renderer && viewport.value.contains(renderer.domElement)) {
viewport.value.removeChild(renderer.domElement) viewport.value.removeChild(renderer.domElement)
} }
@ -182,6 +252,8 @@ const disposeScene = () => {
enemyUnit = null enemyUnit = null
playerAura = null playerAura = null
enemyAura = null enemyAura = null
rangeRing = null
resetPendingAction()
} }
watch( watch(
@ -195,6 +267,15 @@ watch(
}, },
) )
watch(
() => gameState.battle.turn,
(turn) => {
if (turn !== 'player') {
resetPendingAction()
}
},
)
onMounted(async () => { onMounted(async () => {
if (gameState.battle.active) { if (gameState.battle.active) {
await initScene() await initScene()
@ -213,6 +294,7 @@ onBeforeUnmount(disposeScene)
<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>
<p v-if="hudHint" class="hint">{{ hudHint }}点击敌方单位</p>
</header> </header>
<div class="bars"> <div class="bars">
@ -290,6 +372,10 @@ onBeforeUnmount(disposeScene)
font-size: 13px; font-size: 13px;
} }
.battle-head .hint {
color: #ffd988;
}
.bars { .bars {
margin-top: 10px; margin-top: 10px;
display: grid; display: grid;