1. 网格化俯视战场(地面棋盘 + Grid 表现)。
2. 指令目标选择:攻击/技能 不再立即结算,先进入“选择目标”状态。 3. 点击敌方单位才执行对应指令(回合制目标确认)。 4. 范围提示环:攻击和技能分别显示不同范围/颜色提示。 5. HUD 提示文案:会显示“请选择目标(点击敌方单位)”。
This commit is contained in:
parent
f1d5b7e047
commit
3eeba28daa
|
|
@ -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 回合战斗。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: 俯视战术战斗(目标选择/范围提示)',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue