战斗表现优化:动画、伤害飘字、暴击/MP/治疗样式
- 添加敌我攻击/受击动画与命中闪烁 - 敌人支持独立攻击时长、命中延迟配置 - 伤害飘字增加随机滚动、跟随、淡出效果 - 实现暴击、MP伤害、治疗的飘字颜色区分
This commit is contained in:
parent
3eeba28daa
commit
bab629a29e
|
|
@ -108,3 +108,14 @@
|
|||
3. 新增技能/攻击范围环提示、HUD 引导文案和资源释放逻辑,完成构建验证。
|
||||
- **执行结果**: 战斗场景已具备基础战术交互(目标选择+范围提示),体验更接近俯视角 RPG 回合战斗。
|
||||
|
||||
# 任务执行摘要
|
||||
|
||||
## 会话 ID: 1775868509
|
||||
- [2026-04-11 08:48:29]
|
||||
- **执行原因**: 用户要求补齐战斗演出:攻击/受击动画、敌方不同攻击节奏、伤害飘字滚动与颜色区分。
|
||||
- **执行过程**:
|
||||
1. 扩展敌人配置,新增攻击总时长、命中延迟、MP 打击概率等参数,支持不同敌人差异化反馈时序。
|
||||
2. 重构战斗状态机为事件驱动(attack/hit/popup),将命中与结算从瞬时执行改为时序执行,并加入暴击、MP 伤害与恢复事件。
|
||||
3. 在俯视战场中接入攻击位移动画、受击闪烁动画和飘字系统(随机滚动到最终值、暴击/MP/治疗颜色区分)。
|
||||
- **执行结果**: 已实现完整战斗演出链路,反馈时机随敌人攻击动画参数变化,并通过构建验证。
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ export interface EnemyDef {
|
|||
hp: number
|
||||
minAtk: number
|
||||
maxAtk: number
|
||||
attackAnimMs: number
|
||||
hitDelayMs: number
|
||||
mpStrikeChance: number
|
||||
rewardExp: [number, number]
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +25,9 @@ export const enemies: EnemyDef[] = [
|
|||
hp: 65,
|
||||
minAtk: 10,
|
||||
maxAtk: 18,
|
||||
attackAnimMs: 540,
|
||||
hitDelayMs: 280,
|
||||
mpStrikeChance: 0.05,
|
||||
rewardExp: [12, 20],
|
||||
},
|
||||
{
|
||||
|
|
@ -30,6 +36,9 @@ export const enemies: EnemyDef[] = [
|
|||
hp: 72,
|
||||
minAtk: 9,
|
||||
maxAtk: 16,
|
||||
attackAnimMs: 860,
|
||||
hitDelayMs: 520,
|
||||
mpStrikeChance: 0.35,
|
||||
rewardExp: [14, 22],
|
||||
},
|
||||
{
|
||||
|
|
@ -38,6 +47,9 @@ export const enemies: EnemyDef[] = [
|
|||
hp: 78,
|
||||
minAtk: 11,
|
||||
maxAtk: 19,
|
||||
attackAnimMs: 680,
|
||||
hitDelayMs: 360,
|
||||
mpStrikeChance: 0.15,
|
||||
rewardExp: [16, 24],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const gameConfig = {
|
||||
title: 'Breath of Fire-like RPG Demo',
|
||||
tagline: '里程碑 6: 俯视战术战斗(目标选择/范围提示)',
|
||||
tagline: '里程碑 7: 攻击/受击动画 + 时序反馈 + 飘字系统',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,24 @@ import { reactive } from 'vue'
|
|||
import { enemies, skills, type EnemyDef } from '../data/battleData'
|
||||
|
||||
export type BattleAction = 'attack' | 'skill' | 'defend' | 'item'
|
||||
|
||||
type BattleTurn = 'player' | 'enemy' | 'resolved'
|
||||
export type BattleTurn = 'player' | 'enemy' | 'resolved'
|
||||
export type SceneMode = 'explore' | 'battleTransition' | 'battle'
|
||||
|
||||
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 {
|
||||
id: string
|
||||
|
|
@ -28,6 +41,8 @@ interface BattleState {
|
|||
message: string
|
||||
log: string[]
|
||||
enemyData: EnemyDef | null
|
||||
fxEvents: BattleFxEvent[]
|
||||
nextFxId: number
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
|
|
@ -81,6 +96,8 @@ const state = reactive<GameState>({
|
|||
message: '',
|
||||
log: [],
|
||||
enemyData: null,
|
||||
fxEvents: [],
|
||||
nextFxId: 1,
|
||||
},
|
||||
quest: {
|
||||
id: 'wolf-subjugation',
|
||||
|
|
@ -92,8 +109,24 @@ const state = reactive<GameState>({
|
|||
},
|
||||
})
|
||||
|
||||
let enemyActTimer = 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) => {
|
||||
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) => {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
|
@ -147,29 +191,142 @@ const resolveBattle = (victory: boolean) => {
|
|||
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 = () => {
|
||||
if (!state.battle.active || state.battle.turn !== 'enemy') return
|
||||
|
||||
const minAtk = state.battle.enemyData?.minAtk ?? 10
|
||||
const maxAtk = state.battle.enemyData?.maxAtk ?? 18
|
||||
const enemyDamage = roll(minAtk, maxAtk)
|
||||
const damage = state.battle.guarding ? Math.floor(enemyDamage * 0.55) : enemyDamage
|
||||
state.player.hp -= damage
|
||||
state.battle.guarding = false
|
||||
updateDerivedBars()
|
||||
const enemy = state.battle.enemyData
|
||||
const totalAnimMs = enemy?.attackAnimMs ?? 640
|
||||
const hitDelayMs = Math.min(enemy?.hitDelayMs ?? 360, totalAnimMs - 80)
|
||||
|
||||
addBattleLog(`${state.battle.enemyName} 攻击造成 ${damage} 点伤害。`)
|
||||
state.battle.message = `${state.battle.enemyName} 准备攻击...`
|
||||
emitFx({
|
||||
kind: 'attack',
|
||||
actor: 'enemy',
|
||||
durationMs: totalAnimMs,
|
||||
hitDelayMs,
|
||||
})
|
||||
|
||||
if (state.player.hp <= 0) {
|
||||
resolveBattle(false)
|
||||
scheduleBattle(hitDelayMs, () => {
|
||||
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
|
||||
}
|
||||
|
||||
state.battle.turn = 'player'
|
||||
state.battle.message = '你的回合:选择一个指令。'
|
||||
const totalAnimMs = isSkill ? 820 : 560
|
||||
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 = () => {
|
||||
|
|
@ -221,6 +378,8 @@ export const startBattle = (reason = '遭遇野怪') => {
|
|||
state.battle.message = '你的回合:选择一个指令。'
|
||||
state.battle.log = [`${reason},出现了 ${enemy.name}!`]
|
||||
state.battle.enemyData = enemy
|
||||
state.battle.fxEvents = []
|
||||
state.battle.nextFxId = 1
|
||||
state.encounterMeter = 0
|
||||
state.notice = `进入战斗:${enemy.name}`
|
||||
}, 450)
|
||||
|
|
@ -229,34 +388,26 @@ export const startBattle = (reason = '遭遇野怪') => {
|
|||
export const actInBattle = (action: BattleAction) => {
|
||||
if (!state.battle.active || state.battle.turn !== 'player') return
|
||||
|
||||
let damage = 0
|
||||
|
||||
if (action === 'attack') {
|
||||
damage = roll(14, 22)
|
||||
state.battle.enemyHp -= damage
|
||||
addBattleLog(`你使用普通攻击,造成 ${damage} 点伤害。`)
|
||||
launchPlayerAttack(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'skill') {
|
||||
const skill = skills[0]
|
||||
if (!skill) 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()
|
||||
launchPlayerAttack(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'defend') {
|
||||
state.battle.guarding = true
|
||||
addBattleLog('你进入防御姿态,下一次受伤降低。')
|
||||
state.battle.turn = 'enemy'
|
||||
state.battle.message = '你进入防御,敌方即将行动...'
|
||||
scheduleBattle(260, () => {
|
||||
if (!state.battle.active || state.battle.turn === 'resolved') return
|
||||
enemyTurn()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'item') {
|
||||
|
|
@ -269,29 +420,30 @@ export const actInBattle = (action: BattleAction) => {
|
|||
state.player.potions -= 1
|
||||
const heal = 30
|
||||
state.player.hp = Math.min(state.player.maxHp, state.player.hp + heal)
|
||||
addBattleLog(`你使用药草,恢复 ${heal} HP。`)
|
||||
updateDerivedBars()
|
||||
}
|
||||
emitFx({ kind: 'popup', target: 'player', value: heal, valueType: 'heal', crit: false })
|
||||
addBattleLog(`你使用药草,恢复 ${heal} HP。`)
|
||||
|
||||
if (state.battle.enemyHp <= 0) {
|
||||
state.battle.enemyHp = 0
|
||||
resolveBattle(true)
|
||||
return
|
||||
state.battle.turn = 'enemy'
|
||||
state.battle.message = '你使用道具后,敌方即将行动...'
|
||||
scheduleBattle(300, () => {
|
||||
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 = () => {
|
||||
if (!state.battle.active || state.battle.turn !== 'resolved') return
|
||||
|
||||
clearBattleTimers()
|
||||
|
||||
state.battle.active = false
|
||||
state.battle.message = ''
|
||||
state.battle.log = []
|
||||
state.battle.enemyName = ''
|
||||
state.battle.enemyData = null
|
||||
state.battle.fxEvents = []
|
||||
state.sceneMode = 'explore'
|
||||
state.notice = '战斗结束,继续探索。'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
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 viewport = ref<HTMLDivElement | null>(null)
|
||||
const popups = ref<DamagePopup[]>([])
|
||||
|
||||
let renderer: THREE.WebGLRenderer | 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 rangeRing: THREE.Mesh | null = null
|
||||
|
||||
let prevEnemyHp = 0
|
||||
let prevPlayerHp = 0
|
||||
let enemyHitTimer = 0
|
||||
let playerHitTimer = 0
|
||||
let playerAttackFx: { startMs: number; endMs: number } | null = null
|
||||
let enemyAttackFx: { startMs: number; endMs: number } | null = null
|
||||
let playerHitUntilMs = 0
|
||||
let enemyHitUntilMs = 0
|
||||
let lastFxId = 0
|
||||
|
||||
const raycaster = new THREE.Raycaster()
|
||||
const pointer = new THREE.Vector2()
|
||||
|
|
@ -28,6 +44,9 @@ const pointer = new THREE.Vector2()
|
|||
const pendingAction = ref<'attack' | 'skill' | null>(null)
|
||||
const hudHint = ref('')
|
||||
|
||||
const playerBase = new THREE.Vector3(-2, 0.55, 2)
|
||||
const enemyBase = new THREE.Vector3(2, 0.7, -2)
|
||||
|
||||
const resetPendingAction = () => {
|
||||
pendingAction.value = null
|
||||
hudHint.value = ''
|
||||
|
|
@ -62,6 +81,112 @@ const onPointerDown = (event: PointerEvent) => {
|
|||
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 () => {
|
||||
await nextTick()
|
||||
if (!viewport.value || renderer) return
|
||||
|
|
@ -111,15 +236,18 @@ const initScene = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
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, 0.55, 2)
|
||||
playerUnit = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(0.42, 1.0, 4, 10),
|
||||
new THREE.MeshStandardMaterial({ color: '#68d8c3' }),
|
||||
)
|
||||
playerUnit.position.copy(playerBase)
|
||||
scene.add(playerUnit)
|
||||
|
||||
enemyUnit = new THREE.Mesh(new THREE.ConeGeometry(0.55, 1.35, 14), enemyMaterial)
|
||||
enemyUnit.position.set(2, 0.7, -2)
|
||||
enemyUnit = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(0.55, 1.35, 14),
|
||||
new THREE.MeshStandardMaterial({ color: '#ef8f6b' }),
|
||||
)
|
||||
enemyUnit.position.copy(enemyBase)
|
||||
enemyUnit.rotation.x = Math.PI
|
||||
scene.add(enemyUnit)
|
||||
|
||||
|
|
@ -128,7 +256,7 @@ const initScene = async () => {
|
|||
new THREE.MeshBasicMaterial({ color: '#86ffdf', transparent: true, opacity: 0.7 }),
|
||||
)
|
||||
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)
|
||||
|
||||
enemyAura = new THREE.Mesh(
|
||||
|
|
@ -136,7 +264,7 @@ const initScene = async () => {
|
|||
new THREE.MeshBasicMaterial({ color: '#ffb193', transparent: true, opacity: 0.7 }),
|
||||
)
|
||||
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)
|
||||
|
||||
rangeRing = new THREE.Mesh(
|
||||
|
|
@ -144,7 +272,7 @@ const initScene = async () => {
|
|||
new THREE.MeshBasicMaterial({ color: '#e2f078', transparent: true, opacity: 0.8 }),
|
||||
)
|
||||
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
|
||||
scene.add(rangeRing)
|
||||
|
||||
|
|
@ -161,8 +289,7 @@ const initScene = async () => {
|
|||
resizeObserver.observe(viewport.value)
|
||||
resize()
|
||||
|
||||
prevEnemyHp = gameState.battle.enemyHp
|
||||
prevPlayerHp = gameState.player.hp
|
||||
lastFxId = 0
|
||||
|
||||
const tick = () => {
|
||||
frameId = requestAnimationFrame(tick)
|
||||
|
|
@ -170,18 +297,43 @@ const initScene = async () => {
|
|||
if (!scene || !camera || !renderer || !playerUnit || !enemyUnit || !playerAura || !enemyAura || !rangeRing) 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
|
||||
if (gameState.player.hp < prevPlayerHp) playerHitTimer = 0.32
|
||||
prevEnemyHp = gameState.battle.enemyHp
|
||||
prevPlayerHp = gameState.player.hp
|
||||
const nowMs = performance.now()
|
||||
const now = nowMs * 0.001
|
||||
|
||||
enemyHitTimer = Math.max(0, enemyHitTimer - 0.016)
|
||||
playerHitTimer = Math.max(0, playerHitTimer - 0.016)
|
||||
let playerAtkOffset = 0
|
||||
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
|
||||
enemyUnit.position.y = 0.7 + Math.sin(now * 2.5 + 0.8) * 0.07
|
||||
let enemyAtkOffset = 0
|
||||
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
|
||||
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
|
||||
;(enemyAura.material as THREE.MeshBasicMaterial).opacity = enemyTurn ? 0.95 : 0.38
|
||||
|
||||
;(playerUnit.material as THREE.MeshStandardMaterial).emissive.setHex(playerHitTimer > 0 ? 0x6a2323 : 0x0)
|
||||
;(enemyUnit.material as THREE.MeshStandardMaterial).emissive.setHex(enemyHitTimer > 0 ? 0x6a2323 : 0x0)
|
||||
const playerIsHit = nowMs < playerHitUntilMs
|
||||
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') {
|
||||
rangeRing.visible = true
|
||||
|
|
@ -211,6 +366,8 @@ const initScene = async () => {
|
|||
|
||||
rangeRing.position.y = 0.03 + Math.sin(now * 5) * 0.01
|
||||
|
||||
updatePopupPositions(nowMs)
|
||||
|
||||
camera.lookAt(0, 0, 0)
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
|
@ -253,6 +410,11 @@ const disposeScene = () => {
|
|||
playerAura = null
|
||||
enemyAura = null
|
||||
rangeRing = null
|
||||
playerAttackFx = null
|
||||
enemyAttackFx = null
|
||||
popups.value = []
|
||||
playerHitUntilMs = 0
|
||||
enemyHitUntilMs = 0
|
||||
resetPendingAction()
|
||||
}
|
||||
|
||||
|
|
@ -288,7 +450,21 @@ onBeforeUnmount(disposeScene)
|
|||
<template>
|
||||
<div v-if="gameState.battle.active" class="battle-overlay">
|
||||
<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">
|
||||
<header class="battle-head">
|
||||
|
|
@ -346,12 +522,54 @@ onBeforeUnmount(disposeScene)
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.battle-viewport-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.battle-viewport {
|
||||
border: 1px solid #3b556a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue