feat(combat): implement pre-battle buffs and pre-attack skill animations

- Updated `cards.json` and `skills.json` to define `preBattle` (Rally) and `preAttack` (Strike, Mortar) triggers with new descriptions.
- Added `_preBattlePhase` in `CombatEngine.js` to process Rally skills before combat, tracking temporary buffs on cards.
- Added `_postBattlePhase` to remove temporary buffs after combat resolves, ensuring stat changes are cleared for the next turn.
- Modified `SkillProcessor.js` to handle `Strike`, `Mortar`, and `Rally` logic with context data for animation targets and damage values.
- Implemented `_onPreAttackStep` in `BattleScene.js` to sequence animations before the main attack:
  - `_animateMortarFire`: Flies a mortar shell in an arc, explodes on impact, and handles HP loss/death.
  - `_animateStrikeFire`: Launches a missile at the opposing lane/commander with similar impact effects.
  - `_animateRallyBuff`: Animates a flag flying between cards to visualize ATK gains.
- Updated `CardObject.js` `animateHPLoss` to accept explicit `fromHP`/`toHP` arguments, ensuring correct display values during multi-step damage sequences.
- Added `_playSkillExplosion` for consistent impact visual effects during skill resolution.
- Adjusted attack flow to pause for pre-attack animations before the standard card clash sequence.
This commit is contained in:
Brian Fertig 2026-03-13 15:45:57 -06:00
parent 74d5abd2a1
commit a17adba1e9
8 changed files with 625 additions and 51 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -13,7 +13,7 @@
{
"name": "rally",
"value": 2,
"trigger": "on_turn_start"
"trigger": "preBattle"
}
],
"flavorText": "Iron discipline forged an empire from ash.",
@ -33,7 +33,7 @@
{
"name": "strike",
"value": 1,
"trigger": "on_attack"
"trigger": "preAttack"
}
],
"flavorText": "The backbone of the Imperial war machine.",
@ -93,7 +93,7 @@
{
"name": "strike",
"value": 3,
"trigger": "on_attack"
"trigger": "preAttack"
}
],
"flavorText": "One shot. One less problem.",
@ -118,7 +118,7 @@
{
"name": "strike",
"value": 2,
"trigger": "on_attack"
"trigger": "preAttack"
}
],
"flavorText": "Built for war. Perfected through slaughter.",
@ -131,14 +131,14 @@
"type": "assault",
"rarity": "rare",
"attack": 2,
"health": 14,
"armor": 1,
"delay": 2,
"health": 6,
"armor": 0,
"delay": 0,
"skills": [
{
"name": "mortar",
"value": 3,
"trigger": "on_attack"
"value": 6,
"trigger": "preAttack"
}
],
"flavorText": "Indirect fire is still very much on fire.",
@ -198,7 +198,7 @@
{
"name": "strike",
"value": 4,
"trigger": "on_attack"
"trigger": "preAttack"
},
{
"name": "armored",
@ -288,7 +288,7 @@
{
"name": "strike",
"value": 2,
"trigger": "on_attack"
"trigger": "preAttack"
}
],
"flavorText": "Pain is just another fuel source.",
@ -328,7 +328,7 @@
{
"name": "rally",
"value": 3,
"trigger": "on_attack"
"trigger": "preBattle"
}
],
"flavorText": "Where he goes, the pack follows.",
@ -418,7 +418,7 @@
{
"name": "strike",
"value": 3,
"trigger": "on_attack"
"trigger": "preAttack"
}
],
"flavorText": "The Overlord doesn't negotiate. He dictates terms from the rubble.",

View File

@ -1,8 +1,8 @@
[
{
"name": "strike",
"description": "Deal extra damage equal to value, ignoring armor.",
"trigger": "on_attack",
"description": "Before attacking: fire a missile at the card directly across (or commander) for value damage (armor applies).",
"trigger": "preAttack",
"category": "offense"
},
{
@ -37,8 +37,8 @@
},
{
"name": "rally",
"description": "Grant a random allied assault card +value attack.",
"trigger": "on_attack",
"description": "Grant a random allied assault card +value attack for this turn.",
"trigger": "preBattle",
"category": "support"
},
{
@ -85,8 +85,8 @@
},
{
"name": "mortar",
"description": "Deal value damage to a random enemy card, ignoring lane positions.",
"trigger": "on_attack",
"description": "Before attacking: fire a mortar at a random enemy card for value damage (armor applies).",
"trigger": "preAttack",
"category": "offense"
},
{

View File

@ -165,7 +165,8 @@ export class CombatEngine {
const extraAttacks = (context.extraAttacks || 0) + 1;
for (let a = 0; a < extraAttacks; a++) {
const currentTarget = target || null;
// Skip a target that was already killed (e.g. by mortar during preAttack)
const currentTarget = (target && target.currentHP > 0) ? target : null;
if (currentTarget) {
// Attack the lane card
const pierce = context.pierceBonus || 0;
@ -330,11 +331,64 @@ export class CombatEngine {
this._commanderSkillPhase();
this._rupturePhase();
this._jamPhase();
this._preBattlePhase();
this._buildPendingAttacks();
this.playerGoesFirst = !this.playerGoesFirst; // flip initiative for next turn
return [...this.events];
}
// Process preBattle skills for a set of cards and return buff descriptors.
_collectPreBattleBuffs(cards, allies, enemies) {
const buffs = [];
const liveAllies = allies.filter(c => c.currentHP > 0);
const liveEnemies = enemies.filter(c => c.currentHP > 0);
for (const card of cards) {
if (card.currentHP <= 0) continue;
for (const s of card.skills) {
if (s.trigger === 'preBattle') {
const ctx = { rng: this.rng };
this.skillProcessor.process(s, card, null, liveAllies, liveEnemies, ctx);
if (ctx.rallyTarget) {
buffs.push({ skill: 'rally', source: card, target: ctx.rallyTarget, amount: s.value });
}
}
}
}
return buffs;
}
// Emit the 8-step pre-battle sequence and process preBattle skills.
_preBattlePhase() {
const firstSide = this.playerGoesFirst ? 'player' : 'opponent';
const otherSide = this.playerGoesFirst ? 'opponent' : 'player';
const firstCmd = this.playerGoesFirst ? this.playerCommander : this.opponentCommander;
const otherCmd = this.playerGoesFirst ? this.opponentCommander : this.playerCommander;
const firstLanes = this.playerGoesFirst ? [...this.playerLanes] : [...this.opponentLanes];
const otherLanes = this.playerGoesFirst ? [...this.opponentLanes] : [...this.playerLanes];
const firstAllies = [firstCmd, ...firstLanes];
const otherAllies = [otherCmd, ...otherLanes];
// Steps 1–2: commander defensive buffs (placeholder — no defensive skills yet)
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, buffs: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [] });
// Steps 3–4: commander offensive buffs
const firstCmdBuffs = this._collectPreBattleBuffs([firstCmd], firstAllies, otherAllies);
const otherCmdBuffs = this._collectPreBattleBuffs([otherCmd], otherAllies, firstAllies);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdBuffs });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdBuffs });
// Steps 5–6: lane defensive buffs (placeholder)
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [] });
// Steps 7–8: lane offensive buffs
const firstLaneBuffs = this._collectPreBattleBuffs(firstLanes, firstAllies, otherAllies);
const otherLaneBuffs = this._collectPreBattleBuffs(otherLanes, otherAllies, firstAllies);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneBuffs });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneBuffs });
}
// Build the ordered list of attacks for this turn (called by beginCommit).
// Attack order respects the current initiative (playerGoesFirst, before it is flipped).
_buildPendingAttacks() {
@ -369,6 +423,49 @@ export class CombatEngine {
const pending = this._pendingAttacks.shift();
if (pending.attacker.currentHP <= 0) return this.processNextAttack();
this.events = [];
// --- preAttack skill phase ---
// Process skills with trigger === 'preAttack' (e.g. mortar) before damage resolves.
const preAttackFires = [];
const liveEnemies = pending.enemyLanes.filter(c => c.currentHP > 0);
const liveAllies = pending.alliedLanes.filter(c => c.currentHP > 0);
for (const s of pending.attacker.skills) {
if (s.trigger !== 'preAttack') continue;
const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander };
this.skillProcessor.process(s, pending.attacker, pending.target, liveAllies, liveEnemies, ctx);
if (s.name === 'mortar' && ctx.mortarTarget) {
// hpBefore: recover the HP value before the skill applied its damage
const hpBefore = ctx.mortarTarget.currentHP + ctx.mortarDamage;
preAttackFires.push({ skill: 'mortar', target: ctx.mortarTarget, damage: ctx.mortarDamage, hpBefore });
if (ctx.mortarTarget.currentHP <= 0) {
this._log(`${ctx.mortarTarget.name} is destroyed by mortar`);
this.events.push({ type: 'death', card: ctx.mortarTarget, side: pending.side === 'player' ? 'opponent' : 'player' });
}
}
if (s.name === 'strike' && ctx.strikeTarget) {
const hpBefore = ctx.strikeTarget.currentHP + ctx.strikeDamage;
const targetIsCommander = ctx.strikeTarget === pending.enemyCommander;
preAttackFires.push({ skill: 'strike', target: ctx.strikeTarget, damage: ctx.strikeDamage, hpBefore, targetIsCommander });
if (!targetIsCommander && ctx.strikeTarget.currentHP <= 0) {
this._log(`${ctx.strikeTarget.name} is destroyed by strike`);
this.events.push({ type: 'death', card: ctx.strikeTarget, side: pending.side === 'player' ? 'opponent' : 'player' });
}
}
}
// Emit preAttack event (inserted before any death events from above)
const offensiveSkills = pending.attacker.skills.filter(s =>
s.trigger === 'on_attack' || s.trigger === 'passive'
);
this.events.unshift({
type: 'preAttack',
attacker: pending.attacker,
target: pending.target,
side: pending.side,
skills: offensiveSkills,
preAttackFires
});
this._performAttack(
pending.attacker, pending.target, pending.enemyCommander,
pending.alliedLanes, pending.enemyLanes, pending.side
@ -385,9 +482,56 @@ export class CombatEngine {
this.events = [];
this._deathCheck();
this._winCheck();
this._postBattlePhase();
return [...this.events];
}
// Remove all temp buffs from a set of cards and return debuff descriptors.
_removeAndCollectDebuffs(cards) {
const debuffs = [];
for (const card of cards) {
if (!card._tempBuffs?.length) continue;
for (const buff of card._tempBuffs) {
if (buff.stat === 'currentAttack') card.currentAttack = Math.max(0, card.currentAttack - buff.amount);
debuffs.push({ skill: buff.source, target: card, amount: buff.amount });
}
card._tempBuffs = [];
}
return debuffs;
}
// Emit the 8-step post-battle sequence and remove all temp buffs.
// playerGoesFirst was already flipped in beginCommit, so !playerGoesFirst = who went first this turn.
_postBattlePhase() {
const firstWasPlayer = !this.playerGoesFirst;
const firstSide = firstWasPlayer ? 'player' : 'opponent';
const otherSide = firstWasPlayer ? 'opponent' : 'player';
const firstCmd = firstWasPlayer ? this.playerCommander : this.opponentCommander;
const otherCmd = firstWasPlayer ? this.opponentCommander : this.playerCommander;
const firstLanes = firstWasPlayer ? [...this.playerLanes] : [...this.opponentLanes];
const otherLanes = firstWasPlayer ? [...this.opponentLanes] : [...this.playerLanes];
// Steps 1–2: commander defensive debuffs (placeholder)
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, debuffs: [] });
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, debuffs: [] });
// Steps 3–4: commander offensive debuffs
const firstCmdDebuffs = this._removeAndCollectDebuffs([firstCmd]);
const otherCmdDebuffs = this._removeAndCollectDebuffs([otherCmd]);
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, debuffs: firstCmdDebuffs });
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, debuffs: otherCmdDebuffs });
// Steps 5–6: lane defensive debuffs (placeholder)
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, debuffs: [] });
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, debuffs: [] });
// Steps 7–8: lane offensive debuffs
const firstLaneDebuffs = this._removeAndCollectDebuffs(firstLanes);
const otherLaneDebuffs = this._removeAndCollectDebuffs(otherLanes);
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, debuffs: firstLaneDebuffs });
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, debuffs: otherLaneDebuffs });
}
// Run one full turn, return events (used by runToCompletion)
stepTurn() {
if (this.winner) return this.events;

View File

@ -6,11 +6,16 @@ export class SkillProcessor {
if (handler) handler.call(this, skill, attacker, defender, allies, enemies, context);
}
strike(skill, attacker, defender) {
if (!defender) return;
const dmg = Math.max(0, skill.value - Math.max(0, defender.currentArmor));
defender.currentHP -= dmg;
strike(skill, attacker, defender, allies, enemies, context) {
const target = defender || context?.enemyCommander;
if (!target) return;
const dmg = Math.max(0, skill.value - Math.max(0, target.currentArmor));
target.currentHP -= dmg;
attacker._lastSkillDmg = (attacker._lastSkillDmg || 0) + dmg;
if (context) {
context.strikeTarget = target;
context.strikeDamage = dmg;
}
}
swipe(skill, attacker, defender, allies, enemies) {
@ -44,11 +49,20 @@ export class SkillProcessor {
}
}
rally(skill, attacker, defender, allies) {
rally(skill, attacker, defender, allies, enemies, context) {
const assaultAllies = allies.filter(a => a.type === 'assault' && a !== attacker && a.currentHP > 0);
if (assaultAllies.length === 0) return;
const target = assaultAllies[Math.floor(Math.random() * assaultAllies.length)];
const rng = context?.rng;
const idx = rng
? rng.nextInt(0, assaultAllies.length - 1)
: Math.floor(Math.random() * assaultAllies.length);
const target = assaultAllies[idx];
target.currentAttack += skill.value;
// Track temp buff so postBattle can reverse it
target._tempBuffs = target._tempBuffs || [];
target._tempBuffs.push({ stat: 'currentAttack', amount: skill.value, source: 'rally' });
// Expose to caller for event / animation data
context.rallyTarget = target;
}
heal(skill, attacker, defender, allies) {
@ -105,11 +119,17 @@ export class SkillProcessor {
}
}
mortar(skill, attacker, defender, allies, enemies) {
mortar(skill, attacker, defender, allies, enemies, context) {
if (enemies.length === 0) return;
const target = enemies[Math.floor(Math.random() * enemies.length)];
const rng = context?.rng;
const idx = rng ? rng.nextInt(0, enemies.length - 1) : Math.floor(Math.random() * enemies.length);
const target = enemies[idx];
const dmg = Math.max(0, skill.value - Math.max(0, target.currentArmor));
target.currentHP -= dmg;
if (context) {
context.mortarTarget = target;
context.mortarDamage = dmg;
}
}
flurry(skill, attacker, defender, allies, enemies, context) {

View File

@ -247,7 +247,9 @@ export class CardObject extends Phaser.GameObjects.Container {
});
}
animateHPLoss(damage, onComplete) {
// fromHP / toHP let callers supply explicit display values when cardData.currentHP
// has already been updated beyond this single damage event (e.g. preAttack skills).
animateHPLoss(damage, onComplete, fromHP = null, toHP = null) {
if (!this.hpText || !this.scene) { if (onComplete) onComplete(); return; }
const w = this.options.width || 80;
@ -256,8 +258,11 @@ export class CardObject extends Phaser.GameObjects.Container {
const bannerH = Math.round(h * 0.12);
const bottomBannerCY = h / 2 - bannerH / 2;
const displayFrom = fromHP !== null ? Math.max(0, fromHP) : Math.max(0, this.cardData.currentHP + damage);
const displayTo = toHP !== null ? Math.max(0, toHP) : Math.max(0, this.cardData.currentHP);
// Rewind to pre-damage value so animation transitions old → new
this.hpText.setText(`${Math.max(0, this.cardData.currentHP + damage)}`);
this.hpText.setText(`${displayFrom}`);
this.scene.tweens.add({
targets: this.hpText,
@ -283,7 +288,7 @@ export class CardObject extends Phaser.GameObjects.Container {
this.scene.time.delayedCall(500, () => {
if (!this.scene) { if (onComplete) onComplete(); return; }
this.hpText.setText(`${Math.max(0, this.cardData.currentHP)}`);
this.hpText.setText(`${displayTo}`);
this.scene.tweens.add({
targets: this.hpText,
scaleX: 1, scaleY: 1,

View File

@ -248,7 +248,8 @@ export class BattleScene extends Phaser.Scene {
// Deploy + pre-attack phases (NO damage applied yet)
// On player-first turns, beginCommit also deploys the AI card
this.engine.beginCommit(chosenCard);
const commitEvents = this.engine.beginCommit(chosenCard);
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle');
// Render the field with all newly deployed cards but pre-combat HP
this._renderState();
@ -262,7 +263,9 @@ export class BattleScene extends Phaser.Scene {
? this.engine.getState().opponent.lanes.find(c => !oldOpponentIds.has(c.instanceId))
: null;
const startAttacks = () => this.time.delayedCall(200, () => this._processNextAttackStep());
const startAttacks = () => this._processPreBattle(preBattleEvents, () => {
this.time.delayedCall(200, () => this._processNextAttackStep());
});
const animateOpponentThenAttack = () => {
if (newOpponentCard) {
@ -287,7 +290,7 @@ export class BattleScene extends Phaser.Scene {
_processNextAttackStep() {
if (!this.engine.hasPendingAttacks()) {
// All attacks resolved — run death-check, win-check, then tidy up display
this.engine.finalizeCommit();
const postBattleEvents = this.engine.finalizeCommit();
this._renderState(); // removes dead cards, shows clean final state
const state = this.engine.getState();
@ -295,19 +298,22 @@ export class BattleScene extends Phaser.Scene {
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
this.pDeckText.setText(`Deck:${state.player.deckRemaining} Hand:${state.player.hand.length}`);
this.oDeckText.setText(`Deck:${state.opponent.deckRemaining} Hand:${state.opponent.hand.length}`);
this.isAnimating = false;
this._updateLog();
this.statusText.setText('Press SPACE or click Next Turn to advance');
this._animateInitiativeHandoff();
if (this.engine.winner) {
this.time.delayedCall(600, () => this._showResult());
}
this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle'), () => {
this._animateInitiativeHandoff();
this.isAnimating = false;
this.statusText.setText('Press SPACE or click Next Turn to advance');
if (this.engine.winner) {
this.time.delayedCall(600, () => this._showResult());
}
});
return;
}
const events = this.engine.processNextAttack();
const attackEvent = events.find(e => e.type === 'attack');
const preAttackEvent = events.find(e => e.type === 'preAttack');
const attackEvent = events.find(e => e.type === 'attack');
if (!attackEvent) {
// Attacker was already dead (counter-killed earlier this turn) — skip
@ -315,18 +321,23 @@ export class BattleScene extends Phaser.Scene {
return;
}
// Update commander HP bars immediately (siege / strike may have just hit)
// Update commander HP bars for siege damage (fires during _performAttack).
// preAttack skills (strike) correct the targeted commander's HP themselves at the
// start of their animation, overriding this refresh for that specific card.
const state = this.engine.getState();
this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
const berserkEvent = events.find(e => e.type === 'berserk');
this._animateAttack(attackEvent, () => {
this._refreshCardAfterAttack(attackEvent.defender);
this._refreshCardAfterAttack(attackEvent.attacker);
this.time.delayedCall(220, () => this._processNextAttackStep());
}, berserkEvent);
// Fire pre-attack hook, then proceed to attack animation
this._onPreAttackStep(preAttackEvent, () => {
this._animateAttack(attackEvent, () => {
this._refreshCardAfterAttack(attackEvent.defender);
this._refreshCardAfterAttack(attackEvent.attacker);
this.time.delayedCall(220, () => this._processNextAttackStep());
}, berserkEvent);
});
}
// Update a card's HP bar and stats at the moment of impact, then handle death.
@ -345,7 +356,7 @@ export class BattleScene extends Phaser.Scene {
}
// Explosion sprite + fade-out for a defeated lane card.
_playCardDeath(obj, cardData) {
_playCardDeath(obj, cardData, onComplete = null) {
this.cardObjects.delete(cardData.instanceId);
this.tweens.killTweensOf(obj);
@ -357,14 +368,17 @@ export class BattleScene extends Phaser.Scene {
explosionSprite.play('explosion_anim');
explosionSprite.once('animationcomplete', () => {
if (explosionSprite.scene) explosionSprite.destroy();
if (!obj.scene) return;
if (!obj.scene) { if (onComplete) onComplete(); return; }
this.tweens.add({
targets: obj,
scaleX: 1.25, scaleY: 1.25,
alpha: 0,
duration: 400,
ease: 'Power2',
onComplete: () => { if (obj.scene) obj.destroy(); }
onComplete: () => {
if (obj.scene) obj.destroy();
if (onComplete) onComplete();
}
});
});
}
@ -391,6 +405,249 @@ export class BattleScene extends Phaser.Scene {
});
}
// Called immediately before each card's attack animation.
// Handles preAttack skill fires (mortar, strike, etc.) before the regular attack plays.
_onPreAttackStep(event, onComplete) {
if (!event || !event.preAttackFires?.length) { onComplete(); return; }
// Animate each preAttack fire sequentially in the order they were emitted
const fires = event.preAttackFires;
const next = idx => {
if (idx >= fires.length) { onComplete(); return; }
const fire = fires[idx];
const cb = () => next(idx + 1);
if (fire.skill === 'mortar') {
this._animateMortarFire(event.attacker, fire, cb);
} else if (fire.skill === 'strike') {
this._animateStrikeFire(event.attacker, fire, cb);
} else {
cb();
}
};
next(0);
}
// Animate a mortar shell flying from attacker to target, apply damage, handle death.
_animateMortarFire(attacker, mortarFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
const targetObj = _lookup(mortarFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
// Immediately correct the target's HP display to the pre-mortar value.
// The early commander refresh (and any prior render) may have already set it to
// the final post-all-damage value; we override it here so it's accurate throughout.
if (targetObj.hpText && targetObj.scene) {
targetObj.hpText.setText(`${Math.max(0, mortarFire.hpBefore)}`);
}
this.statusText.setText(`${attacker.name} fires mortar at ${mortarFire.target.name}!`);
const BASE_SCALE = 160 / 460;
// Place mortar sprite on the attacking card; default sprite points up
const mortarSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 11)
.setScale(BASE_SCALE)
.setDepth(30);
// Rotate to face the target (sprite points up = -PI/2 from Phaser's "right" baseline)
const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y);
mortarSprite.rotation = angle + Math.PI / 2;
// Brief pause so the rotated mortar is visible on the card before launching
this.time.delayedCall(450, () => {
const startX = sourceObj.x, startY = sourceObj.y;
const endX = targetObj.x, endY = targetObj.y;
const ARC_HEIGHT = -180;
const progress = { t: 0 };
const emitter = this.add.particles(startX, startY, 'particle_dot', {
speed: { min: 15, max: 60 },
scale: { start: 0.5, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 280,
tint: [0xff8800, 0xff4400, 0x888888],
blendMode: 'ADD',
frequency: 30
}).setDepth(29);
this.tweens.add({
targets: progress,
t: 1,
duration: 600,
ease: 'Quad.easeIn',
onUpdate: () => {
const sin = Math.sin(progress.t * Math.PI);
mortarSprite.x = startX + (endX - startX) * progress.t;
mortarSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin;
if (emitter.scene) emitter.setPosition(mortarSprite.x, mortarSprite.y);
},
onComplete: () => {
emitter.stop();
this.time.delayedCall(250, () => { if (emitter.scene) emitter.destroy(); });
if (mortarSprite.scene) mortarSprite.destroy();
if (!targetObj.scene) { onComplete(); return; }
// Explosion bursts on impact, then resolve HP loss and death
this._playSkillExplosion(targetObj.x, targetObj.y, () => {
if (!targetObj.scene) { onComplete(); return; }
// Show only this skill's damage; use explicit HP values so the display
// reflects the post-mortar HP correctly even though the engine has
// already applied the regular attack damage to cardData.currentHP.
const hpAfter = Math.max(0, mortarFire.hpBefore - mortarFire.damage);
const afterImpact = () => {
// Lock display to the post-mortar HP so the regular attack
// animateHPLoss starts from the right value
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
if (mortarFire.target.currentHP <= 0 && !targetObj.isCommander) {
this._playCardDeath(targetObj, mortarFire.target, onComplete);
} else {
onComplete();
}
};
if (mortarFire.damage > 0) {
targetObj.animateHPLoss(mortarFire.damage, afterImpact, mortarFire.hpBefore, hpAfter);
} else {
afterImpact();
}
});
}
});
});
}
// Plays the skill-explosion sprite (frame 12) at (x, y): grows from tiny while
// rotating, then fades out. Calls onComplete when the fade finishes.
// Animate a missile flying from attacker to the card directly across (or commander),
// play skill explosion on impact, apply damage, handle death.
_animateStrikeFire(attacker, strikeFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(attacker.instanceId);
const targetObj = _lookup(strikeFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
// Immediately correct the target's HP display to the pre-strike value.
// Commanders are refreshed to the final HP before _onPreAttackStep runs;
// overriding here ensures the correct value is visible for the full animation.
if (targetObj.hpText && targetObj.scene) {
targetObj.hpText.setText(`${Math.max(0, strikeFire.hpBefore)}`);
}
this.statusText.setText(`${attacker.name} fires missile at ${strikeFire.target.name}!`);
const BASE_SCALE = 160 / 460;
// Place missile sprite (frame 9) on the attacking card; default points up
const missileSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 9)
.setScale(BASE_SCALE)
.setDepth(30);
// Rotate to face the target (sprite points up = -PI/2 from Phaser's right baseline)
const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y);
missileSprite.rotation = angle + Math.PI / 2;
// Brief pause so the aimed missile is visible before launch
this.time.delayedCall(300, () => {
const startX = sourceObj.x, startY = sourceObj.y;
const endX = targetObj.x, endY = targetObj.y;
const ARC_HEIGHT = -70; // missiles fly flatter than mortar shells
const progress = { t: 0 };
const emitter = this.add.particles(startX, startY, 'particle_dot', {
speed: { min: 20, max: 80 },
scale: { start: 0.4, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 200,
tint: [0xff6600, 0xffcc00, 0xffffff],
blendMode: 'ADD',
frequency: 20
}).setDepth(29);
this.tweens.add({
targets: progress,
t: 1,
duration: 450,
ease: 'Quad.easeIn',
onUpdate: () => {
const sin = Math.sin(progress.t * Math.PI);
missileSprite.x = startX + (endX - startX) * progress.t;
missileSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin;
if (emitter.scene) emitter.setPosition(missileSprite.x, missileSprite.y);
},
onComplete: () => {
emitter.stop();
this.time.delayedCall(200, () => { if (emitter.scene) emitter.destroy(); });
if (missileSprite.scene) missileSprite.destroy();
if (!targetObj.scene) { onComplete(); return; }
// Explosion on impact, then resolve damage
this._playSkillExplosion(targetObj.x, targetObj.y, () => {
if (!targetObj.scene) { onComplete(); return; }
const hpAfter = Math.max(0, strikeFire.hpBefore - strikeFire.damage);
const afterImpact = () => {
// Lock display to post-strike HP so the regular attack
// animateHPLoss starts from the right value
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
if (strikeFire.target.currentHP <= 0 && !strikeFire.targetIsCommander) {
this._playCardDeath(targetObj, strikeFire.target, onComplete);
} else {
onComplete();
}
};
if (strikeFire.damage > 0) {
targetObj.animateHPLoss(strikeFire.damage, afterImpact, strikeFire.hpBefore, hpAfter);
} else {
afterImpact();
}
});
}
});
});
}
_playSkillExplosion(x, y, onComplete) {
const BASE_SCALE = 160 / 460;
const sprite = this.add.sprite(x, y, 'attacks', 12)
.setScale(0.05)
.setAngle(0)
.setDepth(35);
// Phase 1 — grow and spin into existence
this.tweens.add({
targets: sprite,
scaleX: BASE_SCALE * 1.5,
scaleY: BASE_SCALE * 1.5,
angle: 270,
duration: 420,
ease: 'Cubic.Out',
onComplete: () => {
if (!sprite.scene) { onComplete(); return; }
// Phase 2 — expand slightly and fade out
this.tweens.add({
targets: sprite,
scaleX: BASE_SCALE * 2.0,
scaleY: BASE_SCALE * 2.0,
angle: 360,
alpha: 0,
duration: 320,
ease: 'Power2',
onComplete: () => {
if (sprite.scene) sprite.destroy();
onComplete();
}
});
}
});
}
// Animate a single attack.
// Cards slide to center (attacker left, defender right), scale up, hold while
// particle burst + shake + damage number play, then scale down and slide back.
@ -527,7 +784,15 @@ export class BattleScene extends Phaser.Scene {
// Fires once both cards are fully enlarged at center
this.time.delayedCall(SCALE_UP_MS + 80, () => {
if (defenderObj?.scene) {
// Refresh stats (ATK, ARM, DLY) but preserve the HP text that was already
// set by the preAttack animation or by animateHPLoss (which fired 80ms ago
// in _onAttackReconcile). Overwriting HP here would corrupt the in-progress
// HP loss animation and prematurely show the final HP on commanders.
const savedHP = defenderObj.hpText?.text;
defenderObj.refresh();
if (savedHP !== undefined && defenderObj.hpText?.scene) {
defenderObj.hpText.setText(savedHP);
}
// Flash red overlay → fade out
defenderObj.bg.setAlpha(0.70);
@ -573,6 +838,146 @@ export class BattleScene extends Phaser.Scene {
this.time.delayedCall(ATTACK_MS, onComplete);
}
// Drives the 8-step preBattle sequence before attacks begin.
_processPreBattle(events, onComplete) {
const processStep = (idx) => {
if (idx >= events.length) { onComplete(); return; }
this._onPreBattleStep(events[idx], () => processStep(idx + 1));
};
processStep(0);
}
// Called once per preBattle step. Add buff animations here as skills are defined.
_onPreBattleStep(event, onComplete) {
if (event.buffs?.length > 0) {
this._processBuffAnimations(event.buffs, onComplete);
} else {
onComplete();
}
}
_processBuffAnimations(buffs, onComplete) {
const next = (idx) => {
if (idx >= buffs.length) { onComplete(); return; }
const buff = buffs[idx];
if (buff.skill === 'rally') {
this._animateRallyBuff(buff, () => next(idx + 1));
} else {
next(idx + 1);
}
};
next(0);
}
_animateRallyBuff(buff, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(buff.source.instanceId);
const targetObj = _lookup(buff.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
this.statusText.setText(`${buff.source.name} rallies ${buff.target.name}! +${buff.amount} ATK`);
// Base scale for 160px display from 460px source frame
const BASE_SCALE = 160 / 460;
// Flag sprite placed on the source card
const flagSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 10)
.setScale(BASE_SCALE)
.setDepth(30);
// Pulsing glow on the flag
const glow = flagSprite.postFX.addGlow(0xffdd00, 8, 0);
this.tweens.add({
targets: glow,
outerStrength: 18,
duration: 300,
yoyo: true,
repeat: -1,
ease: 'Sine.inOut'
});
// Particle emitter — position synced manually via onUpdate so it tracks the moving flag
const emitter = this.add.particles(sourceObj.x, sourceObj.y, 'particle_dot', {
speed: { min: 20, max: 70 },
scale: { start: 0.7, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 350,
tint: [0xffdd00, 0xffaa00, 0xffffff],
blendMode: 'ADD',
frequency: 25
}).setDepth(29);
// Arc flight — tween a progress value and compute position + scale each frame
const startX = sourceObj.x;
const startY = sourceObj.y;
const endX = targetObj.x;
const endY = targetObj.y;
const ARC_HEIGHT = -200; // px upward at the midpoint
const progress = { t: 0 };
this.tweens.add({
targets: progress,
t: 1,
duration: 700,
ease: 'Sine.inOut',
onUpdate: () => {
const sin = Math.sin(progress.t * Math.PI);
flagSprite.x = startX + (endX - startX) * progress.t;
flagSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin;
flagSprite.setScale(BASE_SCALE * (1 + 0.55 * sin)); // swells in the middle
if (emitter.scene) emitter.setPosition(flagSprite.x, flagSprite.y);
},
onComplete: () => {
flagSprite.setScale(BASE_SCALE);
emitter.stop();
this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); });
if (!targetObj.scene) {
if (flagSprite.scene) flagSprite.destroy();
onComplete();
return;
}
// Reuse berserk animation to show the ATK boost
targetObj.atkText.setText(`${buff.target.currentAttack - buff.amount}`);
targetObj.animateBerserkGain(buff.amount, () => {
this.tweens.add({
targets: flagSprite,
alpha: 0,
scaleX: BASE_SCALE * 1.5, scaleY: BASE_SCALE * 1.5,
duration: 300,
ease: 'Power2',
onComplete: () => {
if (flagSprite.scene) flagSprite.destroy();
onComplete();
}
});
});
}
});
}
// Drives the 8-step postBattle sequence after all attacks resolve.
_processPostBattle(events, onComplete) {
const processStep = (idx) => {
if (idx >= events.length) { onComplete(); return; }
this._onPostBattleStep(events[idx], () => processStep(idx + 1));
};
processStep(0);
}
// Called once per postBattle step. Refreshes cards whose temp buffs were removed.
_onPostBattleStep(event, onComplete) {
if (event.debuffs?.length > 0) {
for (const debuff of event.debuffs) {
const obj = this.cardObjects.get(debuff.target.instanceId)
?? this.commanderObjects?.get(debuff.target.instanceId);
obj?.refresh();
}
}
onComplete();
}
// Fires when the attack animation ends — attach all post-attack consequences here.
_onAttackReconcile(event, berserkEvent) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));