From 3eeba28daaaf5ca34a0c907f66dbfd680cfcea12 Mon Sep 17 00:00:00 2001 From: zwt13703 Date: Sat, 11 Apr 2026 08:34:44 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E7=BD=91=E6=A0=BC=E5=8C=96=E4=BF=AF?= =?UTF-8?q?=E8=A7=86=E6=88=98=E5=9C=BA=EF=BC=88=E5=9C=B0=E9=9D=A2=E6=A3=8B?= =?UTF-8?q?=E7=9B=98=20+=20Grid=20=E8=A1=A8=E7=8E=B0=EF=BC=89=E3=80=82=20?= =?UTF-8?q?=20=202.=20=E6=8C=87=E4=BB=A4=E7=9B=AE=E6=A0=87=E9=80=89?= =?UTF-8?q?=E6=8B=A9=EF=BC=9A=E6=94=BB=E5=87=BB/=E6=8A=80=E8=83=BD=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=AB=8B=E5=8D=B3=E7=BB=93=E7=AE=97=EF=BC=8C?= =?UTF-8?q?=E5=85=88=E8=BF=9B=E5=85=A5=E2=80=9C=E9=80=89=E6=8B=A9=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E2=80=9D=E7=8A=B6=E6=80=81=E3=80=82=20=20=203.=20?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E6=95=8C=E6=96=B9=E5=8D=95=E4=BD=8D=E6=89=8D?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=AF=B9=E5=BA=94=E6=8C=87=E4=BB=A4=EF=BC=88?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E5=88=B6=E7=9B=AE=E6=A0=87=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=EF=BC=89=E3=80=82=20=20=204.=20=E8=8C=83=E5=9B=B4=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E7=8E=AF=EF=BC=9A=E6=94=BB=E5=87=BB=E5=92=8C=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E5=88=86=E5=88=AB=E6=98=BE=E7=A4=BA=E4=B8=8D=E5=90=8C?= =?UTF-8?q?=E8=8C=83=E5=9B=B4/=E9=A2=9C=E8=89=B2=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E3=80=82=20=20=205.=20HUD=20=E6=8F=90=E7=A4=BA=E6=96=87?= =?UTF-8?q?=E6=A1=88=EF=BC=9A=E4=BC=9A=E6=98=BE=E7=A4=BA=E2=80=9C=E8=AF=B7?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E7=9B=AE=E6=A0=87=EF=BC=88=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E6=95=8C=E6=96=B9=E5=8D=95=E4=BD=8D=EF=BC=89=E2=80=9D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tasks/task_detail_2026_04_11.md | 11 ++ src/data/gameConfig.ts | 2 +- src/ui/BattlePanel.vue | 148 +++++++++++++++++++++------ 3 files changed, 129 insertions(+), 32 deletions(-) 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.enemyName }}

{{ gameState.battle.message }}

+

{{ hudHint }}(点击敌方单位)

@@ -290,6 +372,10 @@ onBeforeUnmount(disposeScene) font-size: 13px; } +.battle-head .hint { + color: #ffd988; +} + .bars { margin-top: 10px; display: grid;