战斗表现优化:动画、伤害飘字、暴击/MP/治疗样式

- 添加敌我攻击/受击动画与命中闪烁
- 敌人支持独立攻击时长、命中延迟配置
- 伤害飘字增加随机滚动、跟随、淡出效果
- 实现暴击、MP伤害、治疗的飘字颜色区分
This commit is contained in:
zwt13703 2026-04-11 10:02:38 +08:00
parent 3eeba28daa
commit bab629a29e
5 changed files with 468 additions and 75 deletions

View File

@ -108,3 +108,14 @@
3. 新增技能/攻击范围环提示、HUD 引导文案和资源释放逻辑,完成构建验证。 3. 新增技能/攻击范围环提示、HUD 引导文案和资源释放逻辑,完成构建验证。
- **执行结果**: 战斗场景已具备基础战术交互(目标选择+范围提示),体验更接近俯视角 RPG 回合战斗。 - **执行结果**: 战斗场景已具备基础战术交互(目标选择+范围提示),体验更接近俯视角 RPG 回合战斗。
# 任务执行摘要
## 会话 ID: 1775868509
- [2026-04-11 08:48:29]
- **执行原因**: 用户要求补齐战斗演出:攻击/受击动画、敌方不同攻击节奏、伤害飘字滚动与颜色区分。
- **执行过程**:
1. 扩展敌人配置新增攻击总时长、命中延迟、MP 打击概率等参数,支持不同敌人差异化反馈时序。
2. 重构战斗状态机为事件驱动attack/hit/popup将命中与结算从瞬时执行改为时序执行并加入暴击、MP 伤害与恢复事件。
3. 在俯视战场中接入攻击位移动画、受击闪烁动画和飘字系统(随机滚动到最终值、暴击/MP/治疗颜色区分)。
- **执行结果**: 已实现完整战斗演出链路,反馈时机随敌人攻击动画参数变化,并通过构建验证。

View File

@ -4,6 +4,9 @@ export interface EnemyDef {
hp: number hp: number
minAtk: number minAtk: number
maxAtk: number maxAtk: number
attackAnimMs: number
hitDelayMs: number
mpStrikeChance: number
rewardExp: [number, number] rewardExp: [number, number]
} }
@ -22,6 +25,9 @@ export const enemies: EnemyDef[] = [
hp: 65, hp: 65,
minAtk: 10, minAtk: 10,
maxAtk: 18, maxAtk: 18,
attackAnimMs: 540,
hitDelayMs: 280,
mpStrikeChance: 0.05,
rewardExp: [12, 20], rewardExp: [12, 20],
}, },
{ {
@ -30,6 +36,9 @@ export const enemies: EnemyDef[] = [
hp: 72, hp: 72,
minAtk: 9, minAtk: 9,
maxAtk: 16, maxAtk: 16,
attackAnimMs: 860,
hitDelayMs: 520,
mpStrikeChance: 0.35,
rewardExp: [14, 22], rewardExp: [14, 22],
}, },
{ {
@ -38,6 +47,9 @@ export const enemies: EnemyDef[] = [
hp: 78, hp: 78,
minAtk: 11, minAtk: 11,
maxAtk: 19, maxAtk: 19,
attackAnimMs: 680,
hitDelayMs: 360,
mpStrikeChance: 0.15,
rewardExp: [16, 24], rewardExp: [16, 24],
}, },
] ]

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: '里程碑 6: 俯视战术战斗(目标选择/范围提示)', tagline: '里程碑 7: 攻击/受击动画 + 时序反馈 + 飘字系统',
} }

View File

@ -2,11 +2,24 @@ import { reactive } from 'vue'
import { enemies, skills, type EnemyDef } from '../data/battleData' import { enemies, skills, type EnemyDef } from '../data/battleData'
export type BattleAction = 'attack' | 'skill' | 'defend' | 'item' export type BattleAction = 'attack' | 'skill' | 'defend' | 'item'
export type BattleTurn = 'player' | 'enemy' | 'resolved'
type BattleTurn = 'player' | 'enemy' | 'resolved'
export type SceneMode = 'explore' | 'battleTransition' | 'battle' export type SceneMode = 'explore' | 'battleTransition' | 'battle'
type QuestStatus = 'not_started' | 'in_progress' | 'completed' | 'turned_in' type QuestStatus = 'not_started' | 'in_progress' | 'completed' | 'turned_in'
type UnitSide = 'player' | 'enemy'
type PopupValueType = 'hp' | 'mp' | 'heal'
export interface BattleFxEvent {
id: number
kind: 'attack' | 'hit' | 'popup'
actor?: UnitSide
target?: UnitSide
value?: number
valueType?: PopupValueType
crit?: boolean
durationMs?: number
hitDelayMs?: number
}
interface QuestState { interface QuestState {
id: string id: string
@ -28,6 +41,8 @@ interface BattleState {
message: string message: string
log: string[] log: string[]
enemyData: EnemyDef | null enemyData: EnemyDef | null
fxEvents: BattleFxEvent[]
nextFxId: number
} }
interface PlayerState { interface PlayerState {
@ -81,6 +96,8 @@ const state = reactive<GameState>({
message: '', message: '',
log: [], log: [],
enemyData: null, enemyData: null,
fxEvents: [],
nextFxId: 1,
}, },
quest: { quest: {
id: 'wolf-subjugation', id: 'wolf-subjugation',
@ -92,8 +109,24 @@ const state = reactive<GameState>({
}, },
}) })
let enemyActTimer = 0
let battleEnterTimer = 0 let battleEnterTimer = 0
const battleTimers: number[] = []
const scheduleBattle = (delayMs: number, task: () => void) => {
const id = window.setTimeout(() => {
const idx = battleTimers.indexOf(id)
if (idx >= 0) battleTimers.splice(idx, 1)
task()
}, delayMs)
battleTimers.push(id)
}
const clearBattleTimers = () => {
for (const id of battleTimers) {
clearTimeout(id)
}
battleTimers.length = 0
}
const addBattleLog = (line: string) => { const addBattleLog = (line: string) => {
state.battle.log.push(line) state.battle.log.push(line)
@ -102,6 +135,17 @@ const addBattleLog = (line: string) => {
} }
} }
const emitFx = (event: Omit<BattleFxEvent, 'id'>) => {
state.battle.fxEvents.push({
id: state.battle.nextFxId++,
...event,
})
if (state.battle.fxEvents.length > 120) {
state.battle.fxEvents.splice(0, state.battle.fxEvents.length - 120)
}
}
const roll = (min: number, max: number) => { const roll = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min return Math.floor(Math.random() * (max - min + 1)) + min
} }
@ -147,29 +191,142 @@ const resolveBattle = (victory: boolean) => {
updateDerivedBars() updateDerivedBars()
} }
clearTimeout(enemyActTimer) clearBattleTimers()
}
const applyEnemyImpact = () => {
const enemy = state.battle.enemyData
if (!enemy || !state.battle.active || state.battle.turn !== 'enemy') return
const useMpStrike = Math.random() < enemy.mpStrikeChance && state.player.mp > 0
if (useMpStrike) {
const mpDamage = roll(6, 13)
const realDamage = Math.min(state.player.mp, mpDamage)
state.player.mp -= realDamage
updateDerivedBars()
emitFx({ kind: 'hit', target: 'player' })
emitFx({ kind: 'popup', target: 'player', value: realDamage, valueType: 'mp', crit: false })
addBattleLog(`${state.battle.enemyName} 施放抽魔,造成 ${realDamage} MP 损失。`)
return
}
const crit = Math.random() < 0.14
const enemyDamage = roll(enemy.minAtk, enemy.maxAtk)
const baseDamage = crit ? Math.floor(enemyDamage * 1.65) : enemyDamage
const finalDamage = state.battle.guarding ? Math.floor(baseDamage * 0.55) : baseDamage
state.player.hp -= finalDamage
state.battle.guarding = false
updateDerivedBars()
emitFx({ kind: 'hit', target: 'player' })
emitFx({ kind: 'popup', target: 'player', value: finalDamage, valueType: 'hp', crit })
addBattleLog(`${state.battle.enemyName} 攻击造成 ${finalDamage} 点伤害${crit ? '(暴击)' : ''}`)
} }
const enemyTurn = () => { const enemyTurn = () => {
if (!state.battle.active || state.battle.turn !== 'enemy') return if (!state.battle.active || state.battle.turn !== 'enemy') return
const minAtk = state.battle.enemyData?.minAtk ?? 10 const enemy = state.battle.enemyData
const maxAtk = state.battle.enemyData?.maxAtk ?? 18 const totalAnimMs = enemy?.attackAnimMs ?? 640
const enemyDamage = roll(minAtk, maxAtk) const hitDelayMs = Math.min(enemy?.hitDelayMs ?? 360, totalAnimMs - 80)
const damage = state.battle.guarding ? Math.floor(enemyDamage * 0.55) : enemyDamage
state.player.hp -= damage
state.battle.guarding = false
updateDerivedBars()
addBattleLog(`${state.battle.enemyName} 攻击造成 ${damage} 点伤害。`) state.battle.message = `${state.battle.enemyName} 准备攻击...`
emitFx({
kind: 'attack',
actor: 'enemy',
durationMs: totalAnimMs,
hitDelayMs,
})
if (state.player.hp <= 0) { scheduleBattle(hitDelayMs, () => {
resolveBattle(false) if (!state.battle.active || state.battle.turn !== 'enemy') return
applyEnemyImpact()
if (state.player.hp <= 0) {
resolveBattle(false)
return
}
const recovery = Math.max(120, totalAnimMs - hitDelayMs)
scheduleBattle(recovery, () => {
if (!state.battle.active || state.battle.turn === 'resolved') return
state.battle.turn = 'player'
state.battle.message = '你的回合:选择一个指令。'
})
})
}
const launchPlayerAttack = (isSkill: boolean) => {
const skill = skills[0]
if (isSkill && !skill) return
if (isSkill && state.player.mp < (skill?.mpCost ?? 0)) {
addBattleLog('MP 不足,技能释放失败。')
state.battle.message = 'MP 不足,请重新选择。'
return return
} }
state.battle.turn = 'player' const totalAnimMs = isSkill ? 820 : 560
state.battle.message = '你的回合:选择一个指令。' const hitDelayMs = isSkill ? 460 : 260
state.battle.turn = 'enemy'
state.battle.message = isSkill ? '你正在施放技能...' : '你正在攻击...'
emitFx({
kind: 'attack',
actor: 'player',
durationMs: totalAnimMs,
hitDelayMs,
})
scheduleBattle(hitDelayMs, () => {
if (!state.battle.active || state.battle.turn !== 'enemy') return
let damage = 0
let crit = false
if (isSkill) {
const castSkill = skills[0]
if (!castSkill) return
state.player.mp -= castSkill.mpCost
damage = roll(castSkill.minDamage, castSkill.maxDamage)
crit = Math.random() < 0.2
if (crit) {
damage = Math.floor(damage * 1.7)
}
addBattleLog(`你使用${castSkill.name},造成 ${damage} 点伤害${crit ? '(暴击)' : ''}`)
} else {
damage = roll(14, 22)
crit = Math.random() < 0.12
if (crit) {
damage = Math.floor(damage * 1.6)
}
addBattleLog(`你使用普通攻击,造成 ${damage} 点伤害${crit ? '(暴击)' : ''}`)
}
state.battle.enemyHp -= damage
if (state.battle.enemyHp < 0) state.battle.enemyHp = 0
updateDerivedBars()
emitFx({ kind: 'hit', target: 'enemy' })
emitFx({ kind: 'popup', target: 'enemy', value: damage, valueType: 'hp', crit })
if (state.battle.enemyHp <= 0) {
resolveBattle(true)
return
}
const recovery = Math.max(120, totalAnimMs - hitDelayMs)
scheduleBattle(recovery + 180, () => {
if (!state.battle.active || state.battle.turn === 'resolved') return
enemyTurn()
})
})
} }
export const interactQuestNpc = () => { export const interactQuestNpc = () => {
@ -221,6 +378,8 @@ export const startBattle = (reason = '遭遇野怪') => {
state.battle.message = '你的回合:选择一个指令。' state.battle.message = '你的回合:选择一个指令。'
state.battle.log = [`${reason},出现了 ${enemy.name}`] state.battle.log = [`${reason},出现了 ${enemy.name}`]
state.battle.enemyData = enemy state.battle.enemyData = enemy
state.battle.fxEvents = []
state.battle.nextFxId = 1
state.encounterMeter = 0 state.encounterMeter = 0
state.notice = `进入战斗:${enemy.name}` state.notice = `进入战斗:${enemy.name}`
}, 450) }, 450)
@ -229,34 +388,26 @@ export const startBattle = (reason = '遭遇野怪') => {
export const actInBattle = (action: BattleAction) => { export const actInBattle = (action: BattleAction) => {
if (!state.battle.active || state.battle.turn !== 'player') return if (!state.battle.active || state.battle.turn !== 'player') return
let damage = 0
if (action === 'attack') { if (action === 'attack') {
damage = roll(14, 22) launchPlayerAttack(false)
state.battle.enemyHp -= damage return
addBattleLog(`你使用普通攻击,造成 ${damage} 点伤害。`)
} }
if (action === 'skill') { if (action === 'skill') {
const skill = skills[0] launchPlayerAttack(true)
if (!skill) return return
if (state.player.mp < skill.mpCost) {
addBattleLog('MP 不足,技能释放失败。')
state.battle.message = 'MP 不足,请重新选择。'
return
}
state.player.mp -= skill.mpCost
damage = roll(skill.minDamage, skill.maxDamage)
state.battle.enemyHp -= damage
addBattleLog(`你使用${skill.name},造成 ${damage} 点伤害。`)
updateDerivedBars()
} }
if (action === 'defend') { if (action === 'defend') {
state.battle.guarding = true state.battle.guarding = true
addBattleLog('你进入防御姿态,下一次受伤降低。') addBattleLog('你进入防御姿态,下一次受伤降低。')
state.battle.turn = 'enemy'
state.battle.message = '你进入防御,敌方即将行动...'
scheduleBattle(260, () => {
if (!state.battle.active || state.battle.turn === 'resolved') return
enemyTurn()
})
return
} }
if (action === 'item') { if (action === 'item') {
@ -269,29 +420,30 @@ export const actInBattle = (action: BattleAction) => {
state.player.potions -= 1 state.player.potions -= 1
const heal = 30 const heal = 30
state.player.hp = Math.min(state.player.maxHp, state.player.hp + heal) state.player.hp = Math.min(state.player.maxHp, state.player.hp + heal)
addBattleLog(`你使用药草,恢复 ${heal} HP。`)
updateDerivedBars() updateDerivedBars()
} emitFx({ kind: 'popup', target: 'player', value: heal, valueType: 'heal', crit: false })
addBattleLog(`你使用药草,恢复 ${heal} HP。`)
if (state.battle.enemyHp <= 0) { state.battle.turn = 'enemy'
state.battle.enemyHp = 0 state.battle.message = '你使用道具后,敌方即将行动...'
resolveBattle(true) scheduleBattle(300, () => {
return if (!state.battle.active || state.battle.turn === 'resolved') return
enemyTurn()
})
} }
state.battle.turn = 'enemy'
state.battle.message = '敌方行动中...'
enemyActTimer = window.setTimeout(enemyTurn, 500)
} }
export const closeBattle = () => { export const closeBattle = () => {
if (!state.battle.active || state.battle.turn !== 'resolved') return if (!state.battle.active || state.battle.turn !== 'resolved') return
clearBattleTimers()
state.battle.active = false state.battle.active = false
state.battle.message = '' state.battle.message = ''
state.battle.log = [] state.battle.log = []
state.battle.enemyName = '' state.battle.enemyName = ''
state.battle.enemyData = null state.battle.enemyData = null
state.battle.fxEvents = []
state.sceneMode = 'explore' state.sceneMode = 'explore'
state.notice = '战斗结束,继续探索。' state.notice = '战斗结束,继续探索。'
} }

View File

@ -1,10 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as THREE from 'three' import * as THREE from 'three'
import { actInBattle, closeBattle, useGameState } from '../game/state' import { actInBattle, closeBattle, useGameState, type BattleFxEvent } from '../game/state'
interface DamagePopup {
id: number
target: 'player' | 'enemy'
value: number
display: number
valueType: 'hp' | 'mp' | 'heal'
crit: boolean
startMs: number
durationMs: number
x: number
y: number
offsetX: number
}
const gameState = useGameState() const gameState = useGameState()
const viewport = ref<HTMLDivElement | null>(null) const viewport = ref<HTMLDivElement | null>(null)
const popups = ref<DamagePopup[]>([])
let renderer: THREE.WebGLRenderer | null = null let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null let scene: THREE.Scene | null = null
@ -17,10 +32,11 @@ 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 rangeRing: THREE.Mesh | null = null
let prevEnemyHp = 0 let playerAttackFx: { startMs: number; endMs: number } | null = null
let prevPlayerHp = 0 let enemyAttackFx: { startMs: number; endMs: number } | null = null
let enemyHitTimer = 0 let playerHitUntilMs = 0
let playerHitTimer = 0 let enemyHitUntilMs = 0
let lastFxId = 0
const raycaster = new THREE.Raycaster() const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2() const pointer = new THREE.Vector2()
@ -28,6 +44,9 @@ const pointer = new THREE.Vector2()
const pendingAction = ref<'attack' | 'skill' | null>(null) const pendingAction = ref<'attack' | 'skill' | null>(null)
const hudHint = ref('') const hudHint = ref('')
const playerBase = new THREE.Vector3(-2, 0.55, 2)
const enemyBase = new THREE.Vector3(2, 0.7, -2)
const resetPendingAction = () => { const resetPendingAction = () => {
pendingAction.value = null pendingAction.value = null
hudHint.value = '' hudHint.value = ''
@ -62,6 +81,112 @@ const onPointerDown = (event: PointerEvent) => {
resetPendingAction() resetPendingAction()
} }
const processFxEvent = (fx: BattleFxEvent) => {
const now = performance.now()
if (fx.kind === 'attack') {
const total = fx.durationMs ?? 600
if (fx.actor === 'player') {
playerAttackFx = { startMs: now, endMs: now + total }
}
if (fx.actor === 'enemy') {
enemyAttackFx = { startMs: now, endMs: now + total }
}
return
}
if (fx.kind === 'hit') {
if (fx.target === 'player') playerHitUntilMs = now + 240
if (fx.target === 'enemy') enemyHitUntilMs = now + 240
return
}
if (fx.kind === 'popup' && fx.target && fx.value && fx.valueType) {
popups.value.push({
id: fx.id,
target: fx.target,
value: fx.value,
display: fx.value,
valueType: fx.valueType,
crit: Boolean(fx.crit),
startMs: now,
durationMs: 940,
x: 0,
y: 0,
offsetX: (Math.random() - 0.5) * 30,
})
}
}
const updatePopupPositions = (nowMs: number) => {
if (!camera || !renderer || !playerUnit || !enemyUnit) return
const cam = camera
const pUnit = playerUnit
const eUnit = enemyUnit
const width = renderer.domElement.clientWidth
const height = renderer.domElement.clientHeight
const project = (worldPos: THREE.Vector3) => {
const p = worldPos.clone().project(cam)
return {
x: ((p.x + 1) / 2) * width,
y: ((-p.y + 1) / 2) * height,
}
}
popups.value = popups.value
.map((popup) => {
const elapsed = nowMs - popup.startMs
const progress = elapsed / popup.durationMs
const anchor = popup.target === 'player' ? pUnit.position : eUnit.position
const topPos = project(new THREE.Vector3(anchor.x, anchor.y + 1.35, anchor.z))
let display = popup.value
const rollPhase = 420
if (elapsed < rollPhase) {
const min = Math.max(1, Math.floor(popup.value * 0.45))
const max = Math.max(min + 1, Math.floor(popup.value * 1.45))
display = Math.floor(Math.random() * (max - min + 1)) + min
}
return {
...popup,
display,
x: topPos.x + popup.offsetX,
y: topPos.y - progress * 42,
}
})
.filter((popup) => nowMs - popup.startMs < popup.durationMs)
}
const popupText = (popup: DamagePopup) => {
if (popup.valueType === 'heal') return `+${popup.display}`
if (popup.valueType === 'mp') return `-${popup.display} MP`
return `-${popup.display}`
}
const popupClass = (popup: DamagePopup) => {
return {
crit: popup.crit,
hp: popup.valueType === 'hp',
mp: popup.valueType === 'mp',
heal: popup.valueType === 'heal',
}
}
const popupStyle = (popup: DamagePopup) => {
const elapsed = performance.now() - popup.startMs
const progress = Math.min(1, elapsed / popup.durationMs)
const alpha = Math.max(0, 1 - progress)
return {
transform: `translate(${popup.x.toFixed(1)}px, ${popup.y.toFixed(1)}px) scale(${(1 + (popup.crit ? 0.1 : 0)).toFixed(2)})`,
opacity: alpha.toFixed(2),
}
}
const initScene = async () => { const initScene = async () => {
await nextTick() await nextTick()
if (!viewport.value || renderer) return if (!viewport.value || renderer) return
@ -111,15 +236,18 @@ const initScene = async () => {
} }
} }
const playerMaterial = new THREE.MeshStandardMaterial({ color: '#68d8c3' }) playerUnit = new THREE.Mesh(
const enemyMaterial = new THREE.MeshStandardMaterial({ color: '#ef8f6b' }) new THREE.CapsuleGeometry(0.42, 1.0, 4, 10),
new THREE.MeshStandardMaterial({ color: '#68d8c3' }),
playerUnit = new THREE.Mesh(new THREE.CapsuleGeometry(0.42, 1.0, 4, 10), playerMaterial) )
playerUnit.position.set(-2, 0.55, 2) playerUnit.position.copy(playerBase)
scene.add(playerUnit) scene.add(playerUnit)
enemyUnit = new THREE.Mesh(new THREE.ConeGeometry(0.55, 1.35, 14), enemyMaterial) enemyUnit = new THREE.Mesh(
enemyUnit.position.set(2, 0.7, -2) new THREE.ConeGeometry(0.55, 1.35, 14),
new THREE.MeshStandardMaterial({ color: '#ef8f6b' }),
)
enemyUnit.position.copy(enemyBase)
enemyUnit.rotation.x = Math.PI enemyUnit.rotation.x = Math.PI
scene.add(enemyUnit) scene.add(enemyUnit)
@ -128,7 +256,7 @@ const initScene = async () => {
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, 0.02, 2) playerAura.position.set(playerBase.x, 0.02, playerBase.z)
scene.add(playerAura) scene.add(playerAura)
enemyAura = new THREE.Mesh( enemyAura = new THREE.Mesh(
@ -136,7 +264,7 @@ const initScene = async () => {
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, 0.02, -2) enemyAura.position.set(enemyBase.x, 0.02, enemyBase.z)
scene.add(enemyAura) scene.add(enemyAura)
rangeRing = new THREE.Mesh( rangeRing = new THREE.Mesh(
@ -144,7 +272,7 @@ const initScene = async () => {
new THREE.MeshBasicMaterial({ color: '#e2f078', transparent: true, opacity: 0.8 }), new THREE.MeshBasicMaterial({ color: '#e2f078', transparent: true, opacity: 0.8 }),
) )
rangeRing.rotation.x = -Math.PI / 2 rangeRing.rotation.x = -Math.PI / 2
rangeRing.position.set(2, 0.03, -2) rangeRing.position.set(enemyBase.x, 0.03, enemyBase.z)
rangeRing.visible = false rangeRing.visible = false
scene.add(rangeRing) scene.add(rangeRing)
@ -161,8 +289,7 @@ const initScene = async () => {
resizeObserver.observe(viewport.value) resizeObserver.observe(viewport.value)
resize() resize()
prevEnemyHp = gameState.battle.enemyHp lastFxId = 0
prevPlayerHp = gameState.player.hp
const tick = () => { const tick = () => {
frameId = requestAnimationFrame(tick) frameId = requestAnimationFrame(tick)
@ -170,18 +297,43 @@ const initScene = async () => {
if (!scene || !camera || !renderer || !playerUnit || !enemyUnit || !playerAura || !enemyAura || !rangeRing) 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 for (const fx of gameState.battle.fxEvents) {
if (fx.id > lastFxId) {
processFxEvent(fx)
lastFxId = fx.id
}
}
if (gameState.battle.enemyHp < prevEnemyHp) enemyHitTimer = 0.32 const nowMs = performance.now()
if (gameState.player.hp < prevPlayerHp) playerHitTimer = 0.32 const now = nowMs * 0.001
prevEnemyHp = gameState.battle.enemyHp
prevPlayerHp = gameState.player.hp
enemyHitTimer = Math.max(0, enemyHitTimer - 0.016) let playerAtkOffset = 0
playerHitTimer = Math.max(0, playerHitTimer - 0.016) if (playerAttackFx && nowMs < playerAttackFx.endMs) {
const p = (nowMs - playerAttackFx.startMs) / (playerAttackFx.endMs - playerAttackFx.startMs)
playerAtkOffset = Math.sin(Math.PI * Math.max(0, Math.min(1, p))) * 1.1
} else {
playerAttackFx = null
}
playerUnit.position.y = 0.55 + Math.sin(now * 3.1) * 0.06 let enemyAtkOffset = 0
enemyUnit.position.y = 0.7 + Math.sin(now * 2.5 + 0.8) * 0.07 if (enemyAttackFx && nowMs < enemyAttackFx.endMs) {
const p = (nowMs - enemyAttackFx.startMs) / (enemyAttackFx.endMs - enemyAttackFx.startMs)
enemyAtkOffset = Math.sin(Math.PI * Math.max(0, Math.min(1, p))) * 1.05
} else {
enemyAttackFx = null
}
playerUnit.position.set(
playerBase.x + playerAtkOffset,
playerBase.y + Math.sin(now * 3.1) * 0.06,
playerBase.z - playerAtkOffset * 0.2,
)
enemyUnit.position.set(
enemyBase.x - enemyAtkOffset,
enemyBase.y + Math.sin(now * 2.5 + 0.8) * 0.07,
enemyBase.z + enemyAtkOffset * 0.2,
)
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) enemyUnit.scale.setScalar(0.86 + enemyHpRatio * 0.2)
@ -192,8 +344,11 @@ const initScene = async () => {
;(playerAura.material as THREE.MeshBasicMaterial).opacity = playerTurn ? 0.95 : 0.38 ;(playerAura.material as THREE.MeshBasicMaterial).opacity = playerTurn ? 0.95 : 0.38
;(enemyAura.material as THREE.MeshBasicMaterial).opacity = enemyTurn ? 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) const playerIsHit = nowMs < playerHitUntilMs
;(enemyUnit.material as THREE.MeshStandardMaterial).emissive.setHex(enemyHitTimer > 0 ? 0x6a2323 : 0x0) const enemyIsHit = nowMs < enemyHitUntilMs
;(playerUnit.material as THREE.MeshStandardMaterial).emissive.setHex(playerIsHit ? 0x6a2323 : 0x0)
;(enemyUnit.material as THREE.MeshStandardMaterial).emissive.setHex(enemyIsHit ? 0x6a2323 : 0x0)
if (pendingAction.value === 'attack') { if (pendingAction.value === 'attack') {
rangeRing.visible = true rangeRing.visible = true
@ -211,6 +366,8 @@ const initScene = async () => {
rangeRing.position.y = 0.03 + Math.sin(now * 5) * 0.01 rangeRing.position.y = 0.03 + Math.sin(now * 5) * 0.01
updatePopupPositions(nowMs)
camera.lookAt(0, 0, 0) camera.lookAt(0, 0, 0)
renderer.render(scene, camera) renderer.render(scene, camera)
} }
@ -253,6 +410,11 @@ const disposeScene = () => {
playerAura = null playerAura = null
enemyAura = null enemyAura = null
rangeRing = null rangeRing = null
playerAttackFx = null
enemyAttackFx = null
popups.value = []
playerHitUntilMs = 0
enemyHitUntilMs = 0
resetPendingAction() resetPendingAction()
} }
@ -288,7 +450,21 @@ onBeforeUnmount(disposeScene)
<template> <template>
<div v-if="gameState.battle.active" class="battle-overlay"> <div v-if="gameState.battle.active" class="battle-overlay">
<section class="battle-stage"> <section class="battle-stage">
<div ref="viewport" class="battle-viewport" /> <div class="battle-viewport-wrap">
<div ref="viewport" class="battle-viewport" />
<div class="popup-layer">
<div
v-for="popup in popups"
:key="popup.id"
class="damage-popup"
:class="popupClass(popup)"
:style="popupStyle(popup)"
>
{{ popupText(popup) }}
</div>
</div>
</div>
<div class="battle-hud"> <div class="battle-hud">
<header class="battle-head"> <header class="battle-head">
@ -346,12 +522,54 @@ onBeforeUnmount(disposeScene)
gap: 10px; gap: 10px;
} }
.battle-viewport-wrap {
position: relative;
}
.battle-viewport { .battle-viewport {
border: 1px solid #3b556a; border: 1px solid #3b556a;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35); box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
background: #101a22; background: #101a22;
min-height: 260px;
}
.popup-layer {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
border-radius: 12px;
}
.damage-popup {
position: absolute;
left: 0;
top: 0;
transform: translate(0, 0);
font-weight: 700;
font-size: 23px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.55);
white-space: nowrap;
will-change: transform, opacity;
}
.damage-popup.hp {
color: #ffb07e;
}
.damage-popup.mp {
color: #6fcbff;
}
.damage-popup.heal {
color: #7ef7a6;
}
.damage-popup.crit {
color: #ffe26a;
font-size: 28px;
} }
.battle-hud { .battle-hud {