战斗表现优化:动画、伤害飘字、暴击/MP/治疗样式
- 添加敌我攻击/受击动画与命中闪烁 - 敌人支持独立攻击时长、命中延迟配置 - 伤害飘字增加随机滚动、跟随、淡出效果 - 实现暴击、MP伤害、治疗的飘字颜色区分
This commit is contained in:
parent
3eeba28daa
commit
bab629a29e
|
|
@ -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/治疗颜色区分)。
|
||||||
|
- **执行结果**: 已实现完整战斗演出链路,反馈时机随敌人攻击动画参数变化,并通过构建验证。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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: 攻击/受击动画 + 时序反馈 + 飘字系统',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
|
||||||
|
scheduleBattle(hitDelayMs, () => {
|
||||||
|
if (!state.battle.active || state.battle.turn !== 'enemy') return
|
||||||
|
|
||||||
|
applyEnemyImpact()
|
||||||
|
|
||||||
if (state.player.hp <= 0) {
|
if (state.player.hp <= 0) {
|
||||||
resolveBattle(false)
|
resolveBattle(false)
|
||||||
return
|
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.turn = 'player'
|
||||||
state.battle.message = '你的回合:选择一个指令。'
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
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
|
|
||||||
addBattleLog(`你使用普通攻击,造成 ${damage} 点伤害。`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'skill') {
|
|
||||||
const skill = skills[0]
|
|
||||||
if (!skill) return
|
|
||||||
|
|
||||||
if (state.player.mp < skill.mpCost) {
|
|
||||||
addBattleLog('MP 不足,技能释放失败。')
|
|
||||||
state.battle.message = 'MP 不足,请重新选择。'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state.player.mp -= skill.mpCost
|
if (action === 'skill') {
|
||||||
damage = roll(skill.minDamage, skill.maxDamage)
|
launchPlayerAttack(true)
|
||||||
state.battle.enemyHp -= damage
|
return
|
||||||
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.enemyHp = 0
|
|
||||||
resolveBattle(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state.battle.turn = 'enemy'
|
state.battle.turn = 'enemy'
|
||||||
state.battle.message = '敌方行动中...'
|
state.battle.message = '你使用道具后,敌方即将行动...'
|
||||||
enemyActTimer = window.setTimeout(enemyTurn, 500)
|
scheduleBattle(300, () => {
|
||||||
|
if (!state.battle.active || state.battle.turn === 'resolved') return
|
||||||
|
enemyTurn()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = '战斗结束,继续探索。'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,8 +450,22 @@ 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 class="battle-viewport-wrap">
|
||||||
<div ref="viewport" class="battle-viewport" />
|
<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">
|
||||||
<h2>俯视战斗:{{ gameState.battle.enemyName }}</h2>
|
<h2>俯视战斗:{{ gameState.battle.enemyName }}</h2>
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue