diff --git a/docs/tasks/task_detail_2026_04_11.md b/docs/tasks/task_detail_2026_04_11.md index fdd64bd..0af31fa 100644 --- a/docs/tasks/task_detail_2026_04_11.md +++ b/docs/tasks/task_detail_2026_04_11.md @@ -108,3 +108,14 @@ 3. 新增技能/攻击范围环提示、HUD 引导文案和资源释放逻辑,完成构建验证。 - **执行结果**: 战斗场景已具备基础战术交互(目标选择+范围提示),体验更接近俯视角 RPG 回合战斗。 +# 任务执行摘要 + +## 会话 ID: 1775868509 +- [2026-04-11 08:48:29] +- **执行原因**: 用户要求补齐战斗演出:攻击/受击动画、敌方不同攻击节奏、伤害飘字滚动与颜色区分。 +- **执行过程**: + 1. 扩展敌人配置,新增攻击总时长、命中延迟、MP 打击概率等参数,支持不同敌人差异化反馈时序。 + 2. 重构战斗状态机为事件驱动(attack/hit/popup),将命中与结算从瞬时执行改为时序执行,并加入暴击、MP 伤害与恢复事件。 + 3. 在俯视战场中接入攻击位移动画、受击闪烁动画和飘字系统(随机滚动到最终值、暴击/MP/治疗颜色区分)。 +- **执行结果**: 已实现完整战斗演出链路,反馈时机随敌人攻击动画参数变化,并通过构建验证。 + diff --git a/src/data/battleData.ts b/src/data/battleData.ts index a0c10eb..d03d4db 100644 --- a/src/data/battleData.ts +++ b/src/data/battleData.ts @@ -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], }, ] diff --git a/src/data/gameConfig.ts b/src/data/gameConfig.ts index c3fe512..52ce029 100644 --- a/src/data/gameConfig.ts +++ b/src/data/gameConfig.ts @@ -1,4 +1,4 @@ export const gameConfig = { title: 'Breath of Fire-like RPG Demo', - tagline: '里程碑 6: 俯视战术战斗(目标选择/范围提示)', + tagline: '里程碑 7: 攻击/受击动画 + 时序反馈 + 飘字系统', } diff --git a/src/game/state.ts b/src/game/state.ts index c092690..bade265 100644 --- a/src/game/state.ts +++ b/src/game/state.ts @@ -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({ message: '', log: [], enemyData: null, + fxEvents: [], + nextFxId: 1, }, quest: { id: 'wolf-subjugation', @@ -92,8 +109,24 @@ const state = reactive({ }, }) -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) => { + 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 = '战斗结束,继续探索。' } diff --git a/src/ui/BattlePanel.vue b/src/ui/BattlePanel.vue index 375ae7e..e1fa1c8 100644 --- a/src/ui/BattlePanel.vue +++ b/src/ui/BattlePanel.vue @@ -1,10 +1,25 @@