diff --git a/docs/tasks/task_detail_2026_04_11.md b/docs/tasks/task_detail_2026_04_11.md
index 08a4738..fdd64bd 100644
--- a/docs/tasks/task_detail_2026_04_11.md
+++ b/docs/tasks/task_detail_2026_04_11.md
@@ -97,3 +97,14 @@
3. 增加战斗场景资源生命周期管理(进入初始化、退出释放),并完成构建验证。
- **执行结果**: 战斗已从纯 UI 面板升级为俯视角战斗场景,形成“3D 战场 + 回合指令 HUD”的可玩表现。
+# 任务执行摘要
+
+## 会话 ID: 1775866460
+- [2026-04-11 08:14:20]
+- **执行原因**: 用户继续要求优化战斗体验,目标增加俯视战斗的战术交互感。
+- **执行过程**:
+ 1. 在 BattlePanel 中加入网格化战场表现与俯视镜头,增强回合战斗场景感。
+ 2. 增加目标选择流程:攻击/技能进入待选目标状态,点击敌方单位后执行指令。
+ 3. 新增技能/攻击范围环提示、HUD 引导文案和资源释放逻辑,完成构建验证。
+- **执行结果**: 战斗场景已具备基础战术交互(目标选择+范围提示),体验更接近俯视角 RPG 回合战斗。
+
diff --git a/src/data/gameConfig.ts b/src/data/gameConfig.ts
index a3a1f5f..c3fe512 100644
--- a/src/data/gameConfig.ts
+++ b/src/data/gameConfig.ts
@@ -1,4 +1,4 @@
export const gameConfig = {
title: 'Breath of Fire-like RPG Demo',
- tagline: '里程碑 5: 俯视角回合战斗场景',
+ tagline: '里程碑 6: 俯视战术战斗(目标选择/范围提示)',
}
diff --git a/src/ui/BattlePanel.vue b/src/ui/BattlePanel.vue
index 814c1ef..375ae7e 100644
--- a/src/ui/BattlePanel.vue
+++ b/src/ui/BattlePanel.vue
@@ -15,16 +15,53 @@ 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 rangeRing: THREE.Mesh | null = null
let prevEnemyHp = 0
let prevPlayerHp = 0
let enemyHitTimer = 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') => {
+ if (gameState.battle.turn !== 'player') return
+
+ if (action === 'attack' || action === 'skill') {
+ pendingAction.value = action
+ hudHint.value = action === 'attack' ? '请选择攻击目标' : '请选择技能目标'
+ return
+ }
+
+ resetPendingAction()
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 () => {
await nextTick()
if (!viewport.value || renderer) return
@@ -33,11 +70,13 @@ const initScene = async () => {
scene.background = new THREE.Color('#0f1720')
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)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+ renderer.domElement.style.touchAction = 'none'
+ renderer.domElement.addEventListener('pointerdown', onPointerDown)
viewport.value.appendChild(renderer.domElement)
const hemi = new THREE.HemisphereLight('#b3e0ff', '#1b2631', 0.7)
@@ -47,48 +86,68 @@ const initScene = async () => {
keyLight.position.set(4, 8, 2)
scene.add(keyLight)
- const arena = new THREE.Mesh(
- new THREE.CylinderGeometry(5.6, 6.2, 0.35, 48),
+ const arenaBase = new THREE.Mesh(
+ new THREE.CylinderGeometry(5.8, 6.4, 0.4, 48),
new THREE.MeshStandardMaterial({ color: '#293847' }),
)
- arena.position.y = -0.2
- scene.add(arena)
+ arenaBase.position.y = -0.22
+ scene.add(arenaBase)
- 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 arenaGrid = new THREE.GridHelper(10, 10, '#58758a', '#2b3d4d')
+ arenaGrid.position.y = 0.01
+ scene.add(arenaGrid)
+
+ const tileMaterialA = new THREE.MeshStandardMaterial({ color: '#1a2a37', metalness: 0.1, roughness: 0.9 })
+ 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 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)
+ playerUnit.position.set(-2, 0.55, 2)
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.position.set(2, 0.7, -2)
enemyUnit.rotation.x = Math.PI
scene.add(enemyUnit)
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 }),
)
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)
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 }),
)
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)
+ 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 = () => {
if (!viewport.value || !renderer || !camera) return
const width = viewport.value.clientWidth
@@ -108,20 +167,13 @@ const initScene = async () => {
const tick = () => {
frameId = requestAnimationFrame(tick)
- if (!scene || !camera || !renderer || !playerUnit || !enemyUnit || !playerAura || !enemyAura) return
-
- if (!gameState.battle.active) {
- return
- }
+ if (!scene || !camera || !renderer || !playerUnit || !enemyUnit || !playerAura || !enemyAura || !rangeRing) 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
- }
+ if (gameState.battle.enemyHp < prevEnemyHp) enemyHitTimer = 0.32
+ if (gameState.player.hp < prevPlayerHp) playerHitTimer = 0.32
prevEnemyHp = gameState.battle.enemyHp
prevPlayerHp = gameState.player.hp
@@ -131,9 +183,7 @@ const initScene = async () => {
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
+ 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'
@@ -145,6 +195,22 @@ const initScene = async () => {
;(playerUnit.material as THREE.MeshStandardMaterial).emissive.setHex(playerHitTimer > 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)
renderer.render(scene, camera)
}
@@ -156,6 +222,10 @@ const disposeScene = () => {
cancelAnimationFrame(frameId)
resizeObserver?.disconnect()
+ if (renderer) {
+ renderer.domElement.removeEventListener('pointerdown', onPointerDown)
+ }
+
if (viewport.value && renderer && viewport.value.contains(renderer.domElement)) {
viewport.value.removeChild(renderer.domElement)
}
@@ -182,6 +252,8 @@ const disposeScene = () => {
enemyUnit = null
playerAura = null
enemyAura = null
+ rangeRing = null
+ resetPendingAction()
}
watch(
@@ -195,6 +267,15 @@ watch(
},
)
+watch(
+ () => gameState.battle.turn,
+ (turn) => {
+ if (turn !== 'player') {
+ resetPendingAction()
+ }
+ },
+)
+
onMounted(async () => {
if (gameState.battle.active) {
await initScene()
@@ -213,6 +294,7 @@ onBeforeUnmount(disposeScene)
{{ gameState.battle.message }} {{ hudHint }}(点击敌方单位)俯视战斗:{{ gameState.battle.enemyName }}