refactor(combat): move Bloodrage and Legion to preBattle phase with animations

- Updated `data/cards.json` to change triggers for Bloodrage from `passive` to `preBattle`, and renamed Strike skills to Legion with `preBattle` trigger.
- Refactored `CombatEngine.js` and `SkillProcessor.js`:
  - Removed ad-hoc event pushing for Bloodrage during the attack phase.
  - Moved Legion and Bloodrage processing into the `_preBattlePhase` to generate buff events that are animated before combat resolves.
  - Added logic to track temporary buffs in `_tempBuffs` for Legion.
- Enhanced `BattleScene.js`:
  - Implemented new animations for Legion (cycling flexing arm sprite) and Bloodrage (pulsing red sprite).
  - Added support for restoring and re-applying Drain effects during the pre-battle display phase to ensure correct visual state before animation.
  - Updated sound loading in `BootScene.js` for new `legion` and `bloodrage` effects.
This commit is contained in:
Brian Fertig 2026-03-15 21:55:36 -06:00
parent 217bf1148e
commit fc5babf5a3
9 changed files with 253 additions and 134 deletions

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 4.9 MiB

View File

@ -1969,7 +1969,7 @@
{
"name": "bloodrage",
"value": 2,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -1982,7 +1982,7 @@
{
"name": "bloodrage",
"value": 3,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -1995,7 +1995,7 @@
{
"name": "bloodrage",
"value": 5,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -2008,7 +2008,7 @@
{
"name": "bloodrage",
"value": 7,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -2021,7 +2021,7 @@
{
"name": "bloodrage",
"value": 10,
"trigger": "passive"
"trigger": "preBattle"
}
]
}
@ -2048,9 +2048,9 @@
"trigger": "preAttack"
},
{
"name": "strike",
"name": "legion",
"value": 1,
"trigger": "preAttack"
"trigger": "preBattle"
}
]
},
@ -2063,12 +2063,12 @@
{
"name": "siphon",
"value": 5,
"trigger": "preAttack"
"trigger": "preBattle"
},
{
"name": "strike",
"name": "legion",
"value": 2,
"trigger": "preAttack"
"trigger": "preBattle"
}
]
},
@ -2084,9 +2084,9 @@
"trigger": "preAttack"
},
{
"name": "strike",
"name": "legion",
"value": 2,
"trigger": "preAttack"
"trigger": "preBattle"
}
]
},
@ -2102,9 +2102,9 @@
"trigger": "preAttack"
},
{
"name": "strike",
"name": "legion",
"value": 3,
"trigger": "preAttack"
"trigger": "preBattle"
}
]
},
@ -2120,9 +2120,9 @@
"trigger": "preAttack"
},
{
"name": "strike",
"value": 5,
"trigger": "preAttack"
"name": "legion",
"value": 4,
"trigger": "preBattle"
}
]
}
@ -2252,7 +2252,7 @@
{
"name": "bloodrage",
"value": 2,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -2270,7 +2270,7 @@
{
"name": "bloodrage",
"value": 3,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -2288,7 +2288,7 @@
{
"name": "bloodrage",
"value": 5,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -2306,7 +2306,7 @@
{
"name": "bloodrage",
"value": 7,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -2324,7 +2324,7 @@
{
"name": "bloodrage",
"value": 10,
"trigger": "passive"
"trigger": "preBattle"
}
]
}
@ -2555,7 +2555,7 @@
{
"name": "bloodrage",
"value": 3,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -2573,7 +2573,7 @@
{
"name": "bloodrage",
"value": 5,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -2591,7 +2591,7 @@
{
"name": "bloodrage",
"value": 7,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -2609,7 +2609,7 @@
{
"name": "bloodrage",
"value": 10,
"trigger": "passive"
"trigger": "preBattle"
}
]
},
@ -2627,7 +2627,7 @@
{
"name": "bloodrage",
"value": 15,
"trigger": "passive"
"trigger": "preBattle"
}
]
}
@ -2837,4 +2837,4 @@
"flavorText": "The tide rises. The tide claims all.",
"artKey": "bt_archon_1"
}
]
]

View File

@ -153,17 +153,14 @@ export class CombatEngine {
attackingCard: attacker
};
// Pre-attack passive/on_attack skills (flurry, valor, legion — pierce now fires in preAttack phase)
// Pre-attack passive/on_attack skills (flurry, valor — pierce now fires in preAttack phase)
for (const s of attacker.skills) {
if (['flurry', 'valor', 'legion', 'bloodrage'].includes(s.name) &&
if (['flurry', 'valor'].includes(s.name) &&
['on_attack', 'passive'].includes(s.trigger)) {
const passiveCtx = { ...context };
this.skillProcessor.process(s, attacker, target,
alliedLanes.filter(c => c.currentHP > 0),
enemyLanes.filter(c => c.currentHP > 0), passiveCtx);
if (s.name === 'bloodrage' && passiveCtx.bloodrageGain) {
this.events.push({ type: 'bloodrage', card: attacker, gain: passiveCtx.bloodrageGain });
}
if (passiveCtx.extraAttacks) context.extraAttacks = passiveCtx.extraAttacks;
}
}
@ -354,7 +351,10 @@ export class CombatEngine {
this._rupturePhase();
this._jamPhase();
this._preBattlePhase();
this._deathCheck();
// NOTE: No _deathCheck() here — dead cards stay in lanes so the scene can
// animate their death during preBattle (e.g. drain kills). _buildPendingAttacks
// already skips dead cards via currentHP <= 0 checks. finalizeCommit() calls
// _deathCheck() after all animations are done.
this._buildPendingAttacks();
this.playerGoesFirst = !this.playerGoesFirst; // flip initiative for next turn
return [...this.events];
@ -388,6 +388,14 @@ export class CombatEngine {
} else if (s.name === 'rally' && ctx.rallyTarget) {
buffs.push({ skill: 'rally', source: card, target: ctx.rallyTarget, amount: s.value });
}
if (s.name === 'legion' && ctx.legionCard) {
buffs.push({ skill: 'legion', source: card, target: card, amount: ctx.legionGain });
this._log(`${card.name} legion: +${ctx.legionGain} ATK`);
}
if (s.name === 'bloodrage' && ctx.bloodrageGain) {
buffs.push({ skill: 'bloodrage', source: card, target: card, amount: ctx.bloodrageGain });
this._log(`${card.name} bloodrage: +${ctx.bloodrageGain} ATK`);
}
if (s.name === 'siege' && ctx.siegeTarget) {
const hpBefore = ctx.siegeTarget.currentHP + ctx.siegeDamage;
siegeFires.push({ skill: 'siege', source: card, target: ctx.siegeTarget, damage: ctx.siegeDamage, hpBefore });

View File

@ -351,9 +351,14 @@ export class SkillProcessor {
}
}
legion(skill, attacker, defender, allies) {
legion(skill, attacker, defender, allies, enemies, context) {
const sameFaction = allies.filter(a => a.faction === attacker.faction && a !== attacker && a.currentHP > 0);
attacker.currentAttack += skill.value * sameFaction.length;
const gain = skill.value * sameFaction.length;
if (gain <= 0) return;
attacker.currentAttack += gain;
attacker._tempBuffs = attacker._tempBuffs || [];
attacker._tempBuffs.push({ stat: 'currentAttack', amount: gain, source: 'legion' });
if (context) { context.legionCard = attacker; context.legionGain = gain; }
}
siphon(skill, attacker, defender, allies, enemies, context) {
@ -414,9 +419,9 @@ export class SkillProcessor {
}
bloodrage(skill, attacker, defender, allies, enemies, context) {
if (attacker.currentHP <= Math.floor(attacker.health / 2)) {
if (attacker.currentHP < attacker.health * 0.5) {
attacker.currentAttack += skill.value;
if (context) context.bloodrageGain = skill.value;
if (context) { context.bloodrageCard = attacker; context.bloodrageGain = skill.value; }
}
}
}

View File

@ -315,15 +315,17 @@ export class BattleScene extends Phaser.Scene {
const commitEvents = this.engine.beginCommit(chosenCard);
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle');
// Temporarily restore any jam/enfeeble changes so _renderState() builds
// Temporarily restore any jam/enfeeble/drain changes so _renderState() builds
// CardObjects with the original (pre-combat) values displayed. They are re-applied
// immediately after so combat math is unaffected.
this._restoreJamSkillsForDisplay(preBattleEvents);
this._restoreEnfeebleForDisplay(preBattleEvents);
this._restoreDrainForDisplay(preBattleEvents);
// Render the field with all newly deployed cards but pre-combat HP
this._renderState();
this._reapplyJamSkillReductions(preBattleEvents);
this._reapplyEnfeebleReductions(preBattleEvents);
this._reapplyDrainDamage(preBattleEvents);
const newPlayerCard = chosenCard
? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
@ -385,7 +387,6 @@ export class BattleScene extends Phaser.Scene {
const events = this.engine.processNextAttack();
const preAttackEvent = events.find(e => e.type === 'preAttack');
const postAttackEvent = events.find(e => e.type === 'postAttack');
const bloodrageEvent = events.find(e => e.type === 'bloodrage');
// Group events into attack rounds — flurry produces multiple attack events.
// Each round carries its own attack, berserk, counter, and on-attack-all events.
@ -439,13 +440,7 @@ export class BattleScene extends Phaser.Scene {
});
};
if (roundIdx === 0) {
this._onPreAttackStep(preAttackEvent, () => {
if (bloodrageEvent) {
this._animateBloodrageGain(bloodrageEvent, runAttack);
} else {
runAttack();
}
});
this._onPreAttackStep(preAttackEvent, runAttack);
} else {
runAttack();
}
@ -1894,119 +1889,130 @@ export class BattleScene extends Phaser.Scene {
_animateSiphonFire(attacker, siphonFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
const targetObj = siphonFire.target ? _lookup(siphonFire.target.instanceId) : null;
if (!sourceObj?.scene) { onComplete(); return; }
// If no target across, skip animation but still refresh HP (heal already applied in engine)
if (!targetObj?.scene) {
if (siphonFire.heal > 0) {
sourceObj.flash(0x00ff00);
this.time.delayedCall(200, () => {
if (sourceObj.scene) sourceObj.refresh();
onComplete();
});
} else {
onComplete();
}
return;
}
if (siphonFire.heal <= 0) { onComplete(); return; }
this.sound.play('sfx_siphon', { volume: 0.8 });
this.statusText.setText(`${attacker.name} siphons +${siphonFire.heal} HP!`);
const BASE_SCALE = 160 / 460;
const PULSE_SCALE = BASE_SCALE * 1.55;
const PULSE_DUR = 220;
const siphonSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 21)
.setScale(BASE_SCALE * 0.6)
.setDepth(30);
const sprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 21)
.setScale(BASE_SCALE)
.setDepth(35);
// Blood-red glow
const glow = siphonSprite.postFX.addGlow(0xcc0000, 6, 0);
const glow = sprite.postFX.addGlow(0xcc0000, 6, 0);
this.tweens.add({
targets: glow,
outerStrength: 14,
duration: 250,
outerStrength: 18,
duration: 300,
yoyo: true,
repeat: -1,
ease: 'Sine.inOut'
ease: 'Sine.InOut'
});
// Bright red particle emitter tracking the sprite
const emitter = this.add.particles(siphonSprite.x, siphonSprite.y, 'particle_dot', {
speed: { min: 20, max: 70 },
scale: { start: 0.6, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 350,
tint: [0xff2222, 0xff4444, 0xff0000, 0xff6666],
blendMode: 'ADD',
frequency: 30
}).setDepth(29);
// Fly from attacker to the card directly across
this.tweens.add({
targets: siphonSprite,
x: targetObj.x,
y: targetObj.y,
scaleX: BASE_SCALE,
scaleY: BASE_SCALE,
duration: 320,
ease: 'Cubic.easeIn',
onUpdate: () => {
if (emitter.scene) emitter.setPosition(siphonSprite.x, siphonSprite.y);
},
// Pulse up and back 3 times, then heal and fade
this.tweens.chain({
targets: sprite,
tweens: [
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
],
onComplete: () => {
if (!siphonSprite.scene) {
if (emitter.scene) emitter.destroy();
onComplete();
return;
}
// Hold on target for ~500ms with pulsing glow
emitter.stop();
this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); });
this.time.delayedCall(500, () => {
if (!siphonSprite.scene) { onComplete(); return; }
// Fade out the sprite
this.tweens.add({
targets: siphonSprite,
alpha: 0,
scaleX: BASE_SCALE * 1.3,
scaleY: BASE_SCALE * 1.3,
duration: 300,
ease: 'Power2',
onComplete: () => {
if (siphonSprite.scene) siphonSprite.destroy();
// Refresh HP on the attacker (heal already applied)
if (sourceObj.scene) {
sourceObj.flash(0x00ff00);
this.time.delayedCall(200, () => {
if (sourceObj.scene) sourceObj.refresh();
onComplete();
});
} else {
onComplete();
}
}
// Green flash + HP refresh on the attacker
if (sourceObj.scene) {
sourceObj.flash(0x00ff00);
this.time.delayedCall(150, () => {
if (sourceObj.scene) sourceObj.refresh();
});
}
// Fade sprite out
this.tweens.add({
targets: sprite,
alpha: 0,
duration: 250,
onComplete: () => {
if (sprite.scene) sprite.destroy();
onComplete();
}
});
}
});
}
_animateBloodrageGain(bloodrageEvent, onComplete) {
// ── Bloodrage — frame 23 sprite pulses over card 3×, red glow, ATK gain text ──
_animateBloodrageBuff(buff, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const cardObj = _lookup(bloodrageEvent.card.instanceId);
const cardObj = _lookup(buff.target.instanceId);
if (!cardObj?.scene) { onComplete(); return; }
this.statusText.setText(`${bloodrageEvent.card.name} enters a blood rage! +${bloodrageEvent.gain} ATK`);
cardObj.flash(0xff0000);
// Show ATK gain using berserk animation
cardObj.atkText.setText(`${bloodrageEvent.card.currentAttack - bloodrageEvent.gain}`);
cardObj.animateBerserkGain(bloodrageEvent.gain, () => {
onComplete();
this.statusText.setText(`${buff.source.name} enters a blood rage! +${buff.amount} ATK`);
this.sound.play('sfx_bloodrage', { volume: 0.8 });
const BASE_SCALE = 160 / 460;
const PULSE_SCALE = BASE_SCALE * 1.6;
const PULSE_DUR = 200;
const sprite = this.add.sprite(cardObj.x, cardObj.y, 'attacks', 23)
.setScale(BASE_SCALE)
.setDepth(35)
.setAlpha(0);
// Red pulsing glow
const glow = sprite.postFX.addGlow(0xff2200, 8, 0);
this.tweens.add({
targets: glow,
outerStrength: 22,
duration: 250,
yoyo: true,
repeat: -1,
ease: 'Sine.InOut'
});
// Fade in, then pulse 3×, then show ATK gain and fade out
this.tweens.add({
targets: sprite,
alpha: 1,
duration: 120,
onComplete: () => {
// Show ATK floating text immediately
cardObj.animateBerserkGain(buff.amount, () => {});
this.tweens.chain({
targets: sprite,
tweens: [
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
{ scaleX: PULSE_SCALE, scaleY: PULSE_SCALE, duration: PULSE_DUR, ease: 'Quad.Out' },
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: PULSE_DUR, ease: 'Quad.In' },
],
onComplete: () => {
if (cardObj.scene) cardObj.flash(0xff2200);
this.tweens.add({
targets: sprite,
alpha: 0,
scaleX: BASE_SCALE * 1.4,
scaleY: BASE_SCALE * 1.4,
duration: 300,
ease: 'Power2',
onComplete: () => {
if (sprite.scene) sprite.destroy();
onComplete();
}
});
}
});
}
});
}
@ -2032,6 +2038,10 @@ export class BattleScene extends Phaser.Scene {
this._animateRallyAll(group.items, () => next(idx + 1));
} else if (group.skill === 'rally') {
this._animateRallyBuff(group, () => next(idx + 1));
} else if (group.skill === 'legion') {
this._animateLegionBuff(group, () => next(idx + 1));
} else if (group.skill === 'bloodrage') {
this._animateBloodrageBuff(group, () => next(idx + 1));
} else {
next(idx + 1);
}
@ -2128,6 +2138,73 @@ export class BattleScene extends Phaser.Scene {
});
}
// ── Legion — flexing arm sprite cycles 3× over card, blue glow, ATK gain text ──
_animateLegionBuff(buff, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const cardObj = _lookup(buff.target.instanceId);
if (!cardObj?.scene) { onComplete(); return; }
this.statusText.setText(`${buff.source.name} legion: +${buff.amount} ATK from allies!`);
this.sound.play('sfx_legion', { volume: 0.8 });
// Show ATK gain floating text immediately
cardObj.animateBerserkGain(buff.amount, () => {});
// Sprite animation: frames 25 (arm down) and 26 (arm up) cycling 3× over ~3s
// 3 cycles × 2 frames = 6 swaps; each frame shows for ~500ms → total ~3s
const FRAME_DUR = 480;
const BASE_SCALE = 160 / 460;
const sprite = this.add.sprite(cardObj.x, cardObj.y, 'attacks', 25)
.setScale(BASE_SCALE * 1.1)
.setDepth(35)
.setAlpha(0);
// Blue glow
const glow = sprite.postFX.addGlow(0x4488ff, 8, 0);
this.tweens.add({
targets: glow,
outerStrength: 20,
duration: 400,
yoyo: true,
repeat: -1,
ease: 'Sine.InOut'
});
// Fade in, then cycle frames, then fade out
this.tweens.add({
targets: sprite,
alpha: 1,
duration: 150,
onComplete: () => {
let frame = 25;
let cycles = 0;
const totalFlips = 6; // 3 full cycles × 2 frames each
const flip = () => {
if (!sprite.scene) { onComplete(); return; }
frame = frame === 25 ? 26 : 25;
sprite.setFrame(frame);
cycles++;
if (cycles < totalFlips) {
this.time.delayedCall(FRAME_DUR, flip);
} else {
// All cycles done — fade out sprite then call complete
this.tweens.add({
targets: sprite,
alpha: 0,
duration: 250,
delay: FRAME_DUR,
onComplete: () => {
if (sprite.scene) sprite.destroy();
onComplete();
}
});
}
};
this.time.delayedCall(FRAME_DUR, flip);
}
});
}
// ── "all" rally — parallel arc flights to all allies, sound plays once ──
_animateRallyAll(buffs, onComplete) {
if (!buffs.length) { onComplete(); return; }
@ -2699,6 +2776,33 @@ export class BattleScene extends Phaser.Scene {
}
}
// Temporarily restore drain victims' HP and undo source heal so _renderState()
// shows pre-drain values. The drain animation will handle the visual HP changes.
_restoreDrainForDisplay(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.drainFires || []) {
fire.target.currentHP += fire.damage;
fire.source.currentHP -= fire.heal;
for (const sec of fire.secondaries || []) {
sec.target.currentHP += sec.damage;
}
}
}
}
// Re-apply drain damage and source heal after _renderState() so combat math stays correct.
_reapplyDrainDamage(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.drainFires || []) {
fire.target.currentHP -= fire.damage;
fire.source.currentHP += fire.heal;
for (const sec of fire.secondaries || []) {
sec.target.currentHP -= sec.damage;
}
}
}
}
// Show floating "-N skill" reduction text near a card's skill area. Calls onComplete when done.
_showJamReductionFloat(cardObj, reductions, duration, onComplete) {
if (!cardObj?.scene || !reductions?.length) { if (onComplete) onComplete(); return; }

View File

@ -35,7 +35,9 @@ export class BootScene extends Phaser.Scene {
this.load.audio('sfx_enfeeble', 'assets/audio/fx/enfeeble.mp3');
this.load.audio('sfx_jam', 'assets/audio/fx/jam.mp3');
this.load.audio('sfx_siphon', 'assets/audio/fx/siphon.mp3');
this.load.audio('sfx_legion', 'assets/audio/fx/legion.mp3');
this.load.audio('sfx_drain', 'assets/audio/fx/drain.mp3');
this.load.audio('sfx_bloodrage', 'assets/audio/fx/bloodrage.mp3');
this.load.audio('sfx_swipe_01', 'assets/audio/fx/swipe_01.mp3');
this.load.audio('sfx_swipe_02', 'assets/audio/fx/swipe_02.mp3');
this.load.audio('sfx_menu_select', 'assets/audio/fx/menu_select.mp3');