feat(combat): implement pre-battle healing with animations and audio

Moves the `heal` skill trigger from `on_attack` to `preBattle`, allowing cards to restore HP before combat begins. Updates `data/cards.json` to reflect new skill configurations (changing `strike`/`armored` to `swipe`/`heal` for specific cards).

Changes include:
- **Data**: Updated `skills.json` descriptions and triggers. Modified `cards.json` to use the new `heal` skill with `preBattle` trigger.
- **Engine**: Refactored `CombatEngine` to collect `healFires` during pre-battle processing and removed legacy `healAll` event logic from the attack phase.
- **Logic**: Updated `SkillProcessor` to track heal targets and amounts in the context for animation purposes.
- **Visuals**: Added `sfx_heal` audio asset and implemented new animation sequences in `BattleScene` (`_processHealFires`, `_animateHealFire`, `_doHealSpriteAndHP`) featuring flying sprites, HP text pulses, and floating damage numbers.
- **State Management**: Added `_restoreHealForDisplay` and `_reapplyHeal` to handle state snapshots correctly during the pre-battle render phase.
This commit is contained in:
Brian Fertig 2026-03-21 12:32:21 -06:00
parent bb61298038
commit ad4db7f541
9 changed files with 202 additions and 63 deletions

BIN
assets/audio/fx/heal.mp3 Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 MiB

After

Width:  |  Height:  |  Size: 6.6 MiB

View File

@ -727,14 +727,15 @@
"delay": 3,
"skills": [
{
"name": "strike",
"value": 4,
"name": "swipe",
"value": 2,
"trigger": "preAttack"
},
{
"name": "armored",
"value": 2,
"trigger": "passive"
"name": "heal",
"value": 1,
"all": true,
"trigger": "preBattle"
}
]
},
@ -745,14 +746,15 @@
"delay": 3,
"skills": [
{
"name": "strike",
"value": 6,
"name": "swipe",
"value": 4,
"trigger": "preAttack"
},
{
"name": "armored",
"value": 3,
"trigger": "passive"
"name": "heal",
"value": 5,
"all": true,
"trigger": "preBattle"
}
]
},
@ -763,14 +765,15 @@
"delay": 3,
"skills": [
{
"name": "strike",
"value": 9,
"name": "swipe",
"value": 8,
"trigger": "preAttack"
},
{
"name": "armored",
"value": 5,
"trigger": "passive"
"name": "heal",
"value": 9,
"all": true,
"trigger": "preBattle"
}
]
},
@ -781,14 +784,15 @@
"delay": 3,
"skills": [
{
"name": "strike",
"value": 14,
"name": "swipe",
"value": 11,
"trigger": "preAttack"
},
{
"name": "armored",
"value": 7,
"trigger": "passive"
"name": "heal",
"value": 14,
"all": true,
"trigger": "preBattle"
}
]
},
@ -799,14 +803,15 @@
"delay": 3,
"skills": [
{
"name": "strike",
"value": 20,
"name": "swipe",
"value": 16,
"trigger": "preAttack"
},
{
"name": "armored",
"value": 10,
"trigger": "passive"
"name": "heal",
"value": 20,
"all": true,
"trigger": "preBattle"
}
]
}

View File

@ -51,9 +51,9 @@
},
{
"name": "heal",
"description": "Restore value HP to the most wounded ally.",
"allDescription": "Restore value HP to ALL wounded allies.",
"trigger": "on_attack",
"description": "Before battle: restore value HP to the most wounded ally.",
"allDescription": "Before battle: restore value HP to ALL wounded allies.",
"trigger": "preBattle",
"category": "support",
"supportsAll": true
},

View File

@ -285,9 +285,6 @@ export class CombatEngine {
if (onAtkCtx.ruptureAllTargets) {
this.events.push({ type: 'ruptureAll', attacker, targets: onAtkCtx.ruptureAllTargets, side });
}
if (onAtkCtx.healAllTargets) {
this.events.push({ type: 'healAll', attacker, targets: onAtkCtx.healAllTargets, side });
}
if (onAtkCtx.weakenAllTargets) {
this.events.push({ type: 'weakenAll', attacker, targets: onAtkCtx.weakenAllTargets, side });
}
@ -503,6 +500,7 @@ export class CombatEngine {
const enfeebeFires = [];
const jamFires = [];
const drainFires = [];
const healFires = [];
const liveAllies = allies.filter(c => c.currentHP > 0);
const liveEnemies = enemies.filter(c => c.currentHP > 0);
for (const card of cards) {
@ -590,9 +588,16 @@ export class CombatEngine {
}
}
}
if (s.name === 'heal' && ctx.healAllTargets) {
healFires.push({ skill: 'heal', source: card, isAll: true, targets: ctx.healAllTargets });
this._log(`${card.name} heal all: restored HP to ${ctx.healAllTargets.length} allies`);
} else if (s.name === 'heal' && ctx.healTarget) {
healFires.push({ skill: 'heal', source: card, target: ctx.healTarget, healAmount: ctx.healAmount, hpBefore: ctx.healHpBefore });
this._log(`${card.name} heal: restored ${ctx.healAmount} HP to ${ctx.healTarget.name}`);
}
}
}
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, drainFires };
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, drainFires, healFires };
}
// Emit the 8-step pre-battle sequence and process preBattle skills.
@ -617,18 +622,18 @@ export class CombatEngine {
// Steps 3–4: commander offensive buffs
const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, [otherCmd]);
const otherCmdFires = this._collectPreBattleFires([otherCmd], otherAllies, firstAllies, firstCmd, [firstCmd]);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires, enfeebeFires: firstCmdFires.enfeebeFires, jamFires: firstCmdFires.jamFires, drainFires: firstCmdFires.drainFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdFires.buffs, siegeFires: otherCmdFires.siegeFires, protectFires: otherCmdFires.protectFires, enfeebeFires: otherCmdFires.enfeebeFires, jamFires: otherCmdFires.jamFires, drainFires: otherCmdFires.drainFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires, enfeebeFires: firstCmdFires.enfeebeFires, jamFires: firstCmdFires.jamFires, drainFires: firstCmdFires.drainFires, healFires: firstCmdFires.healFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdFires.buffs, siegeFires: otherCmdFires.siegeFires, protectFires: otherCmdFires.protectFires, enfeebeFires: otherCmdFires.enfeebeFires, jamFires: otherCmdFires.jamFires, drainFires: otherCmdFires.drainFires, healFires: otherCmdFires.healFires });
// Steps 5–6: lane defensive buffs (placeholder)
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [] });
// Steps 7–8: lane offensive buffs + siege fires + protect fires + enfeeble fires + jam fires
// Steps 7–8: lane offensive buffs + siege fires + protect fires + enfeeble fires + jam fires + heal fires
const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd, otherLanes);
const otherLaneFires = this._collectPreBattleFires(otherLanes, otherAllies, firstAllies, firstCmd, firstLanes);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires, enfeebeFires: firstLaneFires.enfeebeFires, jamFires: firstLaneFires.jamFires, drainFires: firstLaneFires.drainFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneFires.buffs, siegeFires: otherLaneFires.siegeFires, protectFires: otherLaneFires.protectFires, enfeebeFires: otherLaneFires.enfeebeFires, jamFires: otherLaneFires.jamFires, drainFires: otherLaneFires.drainFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires, enfeebeFires: firstLaneFires.enfeebeFires, jamFires: firstLaneFires.jamFires, drainFires: firstLaneFires.drainFires, healFires: firstLaneFires.healFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneFires.buffs, siegeFires: otherLaneFires.siegeFires, protectFires: otherLaneFires.protectFires, enfeebeFires: otherLaneFires.enfeebeFires, jamFires: otherLaneFires.jamFires, drainFires: otherLaneFires.drainFires, healFires: otherLaneFires.healFires });
}
// Build the ordered list of attacks for this turn (called by beginCommit).

View File

@ -152,7 +152,13 @@ export class SkillProcessor {
if (context) context.healAllTargets = allTargets;
return;
}
const hpBefore = damaged[0].currentHP;
damaged[0].currentHP = Math.min(damaged[0].health, damaged[0].currentHP + skill.value);
if (context) {
context.healTarget = damaged[0];
context.healAmount = damaged[0].currentHP - hpBefore;
context.healHpBefore = hpBefore;
}
}
protect(skill, attacker, defender, allies, enemies, context) {

View File

@ -330,6 +330,7 @@ export class BattleScene extends Phaser.Scene {
this._restoreEnfeebleForDisplay(preBattleEvents);
this._restoreDrainForDisplay(preBattleEvents);
this._restoreMoltForDisplay(preBattleEvents);
this._restoreHealForDisplay(preBattleEvents);
// Render the field with all newly deployed cards but pre-combat stats
this._renderState();
this._reapplyBuffs(preBattleEvents);
@ -337,6 +338,7 @@ export class BattleScene extends Phaser.Scene {
this._reapplyEnfeebleReductions(preBattleEvents);
this._reapplyDrainDamage(preBattleEvents);
this._reapplyMolt(preBattleEvents);
this._reapplyHeal(preBattleEvents);
const newPlayerCard = chosenCard
? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
@ -415,7 +417,7 @@ export class BattleScene extends Phaser.Scene {
} else if (currentRound) {
if (e.type === 'berserk') currentRound.berserkEvent = e;
else if (e.type === 'counter') currentRound.counterEvent = e;
else if (['ruptureAll', 'healAll', 'weakenAll', 'venomApply', 'venomApplyAll', 'carapace'].includes(e.type))
else if (['ruptureAll', 'weakenAll', 'venomApply', 'venomApplyAll', 'carapace'].includes(e.type))
currentRound.onAttackAllEvents.push(e);
}
}
@ -511,7 +513,6 @@ export class BattleScene extends Phaser.Scene {
const event = events[idx];
const cb = () => next(idx + 1);
if (event.type === 'ruptureAll') this._animateRuptureAll(event, cb);
else if (event.type === 'healAll') this._animateHealAll(event, cb);
else if (event.type === 'weakenAll') this._animateWeakenAll(event, cb);
else if (event.type === 'venomApply') this._animateVenomApply(event, cb);
else if (event.type === 'venomApplyAll') this._animateVenomApplyAll(event, cb);
@ -539,27 +540,121 @@ export class BattleScene extends Phaser.Scene {
}
}
_animateHealAll(event, onComplete) {
if (!event.targets?.length) { onComplete(); return; }
this.statusText.setText(`${event.attacker.name} heals all allies!`);
let remaining = event.targets.length;
const done = () => {
if (--remaining <= 0) onComplete();
// ── Heal preBattle animations ──────────────────────────────────────────────
_processHealFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
const fire = fires[idx];
if (fire.isAll) {
this._animateHealFireAll(fire, () => next(idx + 1));
} else {
this._animateHealFire(fire, () => next(idx + 1));
}
};
for (const t of event.targets) {
const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
if (!obj?.scene || t.healAmount <= 0) { done(); continue; }
// Show HP increase: set to before, animate to after
const hpBefore = t.hpBefore;
const hpAfter = t.target.currentHP;
if (obj.hpText) obj.hpText.setText(`${hpBefore}`);
// Flash green and update
obj.flash(0x00ff00);
this.time.delayedCall(300, () => {
if (obj.scene) obj.refresh();
done();
});
}
next(0);
}
_animateHealFire(fire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(fire.source.instanceId);
const targetObj = _lookup(fire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene || fire.healAmount <= 0) { onComplete(); return; }
this.sound.play('sfx_heal', { volume: 0.8 });
this.statusText.setText(`${fire.source.name} heals ${fire.target.name} for ${fire.healAmount}!`);
if (targetObj.hpText?.scene) targetObj.hpText.setText(`${fire.hpBefore}`);
this._doHealSpriteAndHP(sourceObj, targetObj, fire.healAmount, fire.target.currentHP, onComplete);
}
_animateHealFireAll(fire, onComplete) {
if (!fire.targets?.length) { onComplete(); return; }
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(fire.source.instanceId);
if (!sourceObj?.scene) { onComplete(); return; }
this.sound.play('sfx_heal', { volume: 0.8 });
this.statusText.setText(`${fire.source.name} heals all allies!`);
// Animate sprite sequentially to each target
const next = (idx) => {
if (idx >= fire.targets.length) { onComplete(); return; }
const t = fire.targets[idx];
const targetObj = _lookup(t.target.instanceId);
if (!targetObj?.scene || t.healAmount <= 0) { next(idx + 1); return; }
if (targetObj.hpText?.scene) targetObj.hpText.setText(`${t.hpBefore}`);
this._doHealSpriteAndHP(sourceObj, targetObj, t.healAmount, t.target.currentHP, () => next(idx + 1));
};
next(0);
}
// Spawns heal sprite at sourceObj, flies it to targetObj, pulses it while animating HP gain.
_doHealSpriteAndHP(sourceObj, targetObj, healAmount, hpAfter, onComplete) {
const BASE_SCALE = 100 / 460;
const healSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 34)
.setScale(BASE_SCALE)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: healSprite,
x: targetObj.x,
y: targetObj.y,
duration: 400,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) {
if (healSprite.scene) healSprite.destroy();
onComplete();
return;
}
// Pulse the sprite over the target
this.tweens.add({
targets: healSprite,
scaleX: { from: BASE_SCALE * 0.8, to: BASE_SCALE * 1.5 },
scaleY: { from: BASE_SCALE * 0.8, to: BASE_SCALE * 1.5 },
alpha: { from: 0.9, to: 0.3 },
duration: 300,
yoyo: true,
repeat: 1,
ease: 'Sine.easeInOut',
onComplete: () => { if (healSprite.scene) healSprite.destroy(); }
});
// Animate HP gain text
if (healAmount > 0 && targetObj.hpText?.scene) {
this.tweens.add({
targets: targetObj.hpText,
scaleX: 2, scaleY: 2,
duration: 200,
ease: 'Back.Out',
onComplete: () => {
if (!targetObj.scene) return;
const gainText = this.add.text(targetObj.x, targetObj.y - 20, `+${healAmount}`, {
fontSize: '14px', color: '#00ff88', fontStyle: 'bold',
stroke: '#000000', strokeThickness: 3, fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(35);
this.tweens.add({
targets: gainText,
alpha: 0, y: targetObj.y - 50,
duration: 700, ease: 'Power2',
onComplete: () => { if (gainText.scene) gainText.destroy(); }
});
this.time.delayedCall(200, () => {
if (!targetObj.scene) return;
targetObj.hpText.setText(`${hpAfter}`);
this.tweens.add({ targets: targetObj.hpText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' });
});
}
});
}
// Complete after pulse + HP animation settle
this.time.delayedCall(800, onComplete);
}
});
}
_animateWeakenAll(event, onComplete) {
@ -2019,14 +2114,17 @@ export class BattleScene extends Phaser.Scene {
const hasEnfeeble = event.enfeebeFires?.length > 0;
const hasJam = event.jamFires?.length > 0;
const hasDrain = event.drainFires?.length > 0;
const hasHeal = event.healFires?.length > 0;
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasDrain) {
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasDrain || hasHeal) {
this._processDrainFires(event.drainFires || [], () => {
this._processBuffAnimations(event.buffs || [], () => {
this._processSiegeFires(event.siegeFires || [], () => {
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], onComplete);
this._processJamFires(event.jamFires || [], () => {
this._processHealFires(event.healFires || [], onComplete);
});
});
});
});
@ -3362,6 +3460,30 @@ export class BattleScene extends Phaser.Scene {
}
// Temporarily undo molt HP/armor changes so _renderState() shows pre-molt values.
_restoreHealForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.healFires || []) {
if (fire.isAll) {
for (const t of fire.targets || []) { t.target.currentHP -= t.healAmount; }
} else if (fire.healAmount > 0) {
fire.target.currentHP -= fire.healAmount;
}
}
}
}
_reapplyHeal(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.healFires || []) {
if (fire.isAll) {
for (const t of fire.targets || []) { t.target.currentHP += t.healAmount; }
} else if (fire.healAmount > 0) {
fire.target.currentHP += fire.healAmount;
}
}
}
}
_restoreMoltForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
if (event.type !== 'molt' || event.heal <= 0) continue;

View File

@ -51,6 +51,7 @@ export class BootScene extends Phaser.Scene {
this.load.audio('sfx_burrow', 'assets/audio/fx/burrow.mp3');
this.load.audio('sfx_hive_link', 'assets/audio/fx/hive_link.mp3');
this.load.audio('sfx_swarm', 'assets/audio/fx/swarm.mp3');
this.load.audio('sfx_heal', 'assets/audio/fx/heal.mp3');
this.load.audio('music_main_menu', 'assets/audio/music/main_menu.mp3');