refactor(combat): granular attack processing with live state updates

- Split `commitPlayerDeploy` into `beginCommit`, `processNextAttack`, and `finalizeCommit` to enable per-attack animation control.
- Introduce `_pendingAttacks` queue in CombatEngine to sequence attacks one by one, allowing the UI to react to intermediate damage (e.g., counter-kills).
- Refactor BattleScene commander rendering into persistent Containers (`commanderObjects`) that support live `refresh()` for HP/stats updates during combat.
- Update `_animateAttack` to handle commander targets and skip scale-back animations for dead cards, deferring cleanup to `_refreshCardAfterAttack`.
- Implement recursive attack loop in `_processNextAttackStep` that drives the battle forward, updating visuals immediately after each hit before proceeding.
This commit is contained in:
Brian Fertig 2026-03-12 17:45:18 -06:00
parent b9ee2b5aea
commit e1656838fc
2 changed files with 229 additions and 93 deletions

View File

@ -297,6 +297,84 @@ export class CombatEngine {
return [...this.events];
}
// ── Step-by-step combat API (used by animated battle scene) ──────────────
// Deploy the chosen card and run all pre-attack phases.
// Does NOT resolve attacks yet — call processNextAttack() for each one.
beginCommit(chosenCard) {
this.events = [];
if (chosenCard && this.playerLanes.length < MAX_LANES) {
const idx = this.playerHand.findIndex(c => c.instanceId === chosenCard.instanceId);
if (idx !== -1) {
this.playerHand.splice(idx, 1);
this.playerLanes.push(chosenCard);
this._log(`Player deploys ${chosenCard.name}`);
this.events.push({ type: 'deploy', side: 'player', card: chosenCard });
}
}
this._activationPhase();
this._commanderSkillPhase();
this._rupturePhase();
this._jamPhase();
this._buildPendingAttacks();
return [...this.events];
}
// Build the ordered list of attacks for this turn (called by beginCommit).
_buildPendingAttacks() {
this._pendingAttacks = [];
for (let i = 0; i < this.playerLanes.length; i++) {
const attacker = this.playerLanes[i];
if (attacker.currentHP <= 0 || attacker.currentDelay > 0 || attacker.jamTurns > 0) continue;
this._pendingAttacks.push({
attacker,
target: this.opponentLanes[i] || null,
alliedLanes: this.playerLanes,
enemyLanes: this.opponentLanes,
enemyCommander: this.opponentCommander,
side: 'player'
});
}
for (let i = 0; i < this.opponentLanes.length; i++) {
const attacker = this.opponentLanes[i];
if (attacker.currentHP <= 0 || attacker.currentDelay > 0 || attacker.jamTurns > 0) continue;
this._pendingAttacks.push({
attacker,
target: this.playerLanes[i] || null,
alliedLanes: this.opponentLanes,
enemyLanes: this.playerLanes,
enemyCommander: this.playerCommander,
side: 'opponent'
});
}
}
// Execute exactly one pending attack and return its events.
// Returns [] if the attacker died from a previous counter-attack this turn.
processNextAttack() {
if (!this._pendingAttacks || this._pendingAttacks.length === 0) return [];
const pending = this._pendingAttacks.shift();
if (pending.attacker.currentHP <= 0) return this.processNextAttack();
this.events = [];
this._performAttack(
pending.attacker, pending.target, pending.enemyCommander,
pending.alliedLanes, pending.enemyLanes, pending.side
);
return [...this.events];
}
hasPendingAttacks() {
return !!(this._pendingAttacks && this._pendingAttacks.length > 0);
}
// Run death-check and win-check after all attacks have been animated.
finalizeCommit() {
this.events = [];
this._deathCheck();
this._winCheck();
return [...this.events];
}
// Run one full turn, return events (used by runToCompletion)
stepTurn() {
if (this.winner) return this.events;

View File

@ -146,39 +146,58 @@ export class BattleScene extends Phaser.Scene {
_buildCommanderDisplay() {
const state = this.engine.getState();
// Commander card: 160w × 180h, centred at x=110
// Opponent row y=240, Player row y=490
this.commanderObjects = new Map(); // instanceId → Container (persists across _renderState)
// ── Opponent commander ────────────────────────────────────────────────────
const oCmd = state.opponent.commander;
this.oCmdRect = this.add.rectangle(110, 240, 160, 180, 0xaa2222)
.setStrokeStyle(3, 0xff4444);
this.add.text(110, 152, 'ENEMY CMD', { fontSize: '11px', color: '#888888' }).setOrigin(0.5);
this.oCmdName = this.add.text(110, 166, oCmd.name, {
fontSize: '13px', color: '#ff8888', wordWrap: { width: 150 }, align: 'center'
}).setOrigin(0.5);
this.oCmdStats = this.add.text(110, 228, `ATK:${oCmd.currentAttack} ARM:${oCmd.currentArmor}`, {
fontSize: '12px', color: '#ffaaaa'
}).setOrigin(0.5);
this.oCmdHP = new HealthBar(this, 30, 240 + 90 + 6, 160, 10, oCmd.health);
this.oCmdHP.update(oCmd.currentHP, oCmd.health);
const specs = [
{ data: state.opponent.commander, cx: 110, cy: 240, label: 'ENEMY CMD',
bgColor: 0xaa2222, borderColor: 0xff4444, nameColor: '#ff8888', statsColor: '#ffaaaa' },
{ data: state.player.commander, cx: 110, cy: 490, label: 'COMMANDER',
bgColor: 0x2244aa, borderColor: 0xffd700, nameColor: '#ffd700', statsColor: '#aaaaff' }
];
// ── Player commander ──────────────────────────────────────────────────────
const pCmd = state.player.commander;
this.pCmdRect = this.add.rectangle(110, 490, 160, 180, 0x2244aa)
.setStrokeStyle(3, 0xffd700);
this.add.text(110, 402, 'COMMANDER', { fontSize: '11px', color: '#888888' }).setOrigin(0.5);
this.pCmdName = this.add.text(110, 416, pCmd.name, {
fontSize: '13px', color: '#ffd700', wordWrap: { width: 150 }, align: 'center'
}).setOrigin(0.5);
this.pCmdStats = this.add.text(110, 478, `ATK:${pCmd.currentAttack} ARM:${pCmd.currentArmor}`, {
fontSize: '12px', color: '#aaaaff'
}).setOrigin(0.5);
this.pCmdHP = new HealthBar(this, 30, 490 + 90 + 6, 160, 10, pCmd.health);
this.pCmdHP.update(pCmd.currentHP, pCmd.health);
for (const s of specs) {
const w = 160, h = 180;
const container = this.add.container(s.cx, s.cy);
// Deck/hand counts
this.oDeckText = this.add.text(14, 92, '', { fontSize: '12px', color: '#aaaaaa' });
// Background rectangle — stored as .bg so _animateAttack can tween fillColor
const bg = this.add.rectangle(0, 0, w, h, s.bgColor).setStrokeStyle(3, s.borderColor);
container.add(bg);
container.bg = bg;
// Label above the card (not inside container so it doesn't scale/shake with it)
this.add.text(s.cx, s.cy - h / 2 - 14, s.label,
{ fontSize: '11px', color: '#888888' }).setOrigin(0.5);
// Name
const nameText = this.add.text(0, -h / 2 + 18, s.data.name, {
fontSize: '13px', color: s.nameColor, wordWrap: { width: w - 10 }, align: 'center'
}).setOrigin(0.5);
container.add(nameText);
// Stats
const statsText = this.add.text(0, h / 2 - 32, `ATK:${s.data.currentAttack} ARM:${s.data.currentArmor}`, {
fontSize: '12px', color: s.statsColor
}).setOrigin(0.5);
container.add(statsText);
// HP bar (positioned relative to the scene, not the container, so we can update it easily)
const hpBar = new HealthBar(this, s.cx - w / 2, s.cy + h / 2 + 2, w, 10, s.data.health);
hpBar.update(s.data.currentHP, s.data.health);
// Attach helpers
container._hpBar = hpBar;
container._stats = statsText;
container._data = s.data;
container.isCommander = true;
container.refresh = function () {
this._hpBar.update(this._data.currentHP, this._data.health);
this._stats.setText(`ATK:${this._data.currentAttack} ARM:${this._data.currentArmor}`);
};
this.commanderObjects.set(s.data.instanceId, container);
}
this.oDeckText = this.add.text(14, 92, '', { fontSize: '12px', color: '#aaaaaa' });
this.pDeckText = this.add.text(14, 385, '', { fontSize: '12px', color: '#aaaaaa' });
}
@ -225,50 +244,99 @@ export class BattleScene extends Phaser.Scene {
_finishTurn(chosenCard) {
this.waitingForPick = false;
this.isAnimating = true;
this.statusText.setText('Deploying...');
// Snapshot player lane before combat resolves
// Snapshot player lanes before deploy so we can detect the new card
const oldPlayerIds = new Set(
this.engine.getState().player.lanes.map(c => c.instanceId)
);
const events = this.engine.commitPlayerDeploy(chosenCard);
const state = this.engine.getState();
// Deploy + pre-attack phases (NO damage applied yet)
this.engine.beginCommit(chosenCard);
// Update commander stat displays to post-combat values
this.pCmdHP.update(state.player.commander.currentHP, state.player.commander.health);
this.oCmdHP.update(state.opponent.commander.currentHP, state.opponent.commander.health);
this.pCmdStats.setText(`ATK:${state.player.commander.currentAttack} ARM:${state.player.commander.currentArmor}`);
this.oCmdStats.setText(`ATK:${state.opponent.commander.currentAttack} ARM:${state.opponent.commander.currentArmor}`);
this.pDeckText.setText(`Deck:${state.player.deckRemaining} Hand:${state.player.hand.length}`);
this.oDeckText.setText(`Deck:${state.opponent.deckRemaining} Hand:${state.opponent.hand.length}`);
// Render the post-combat field state
// Render the field with the newly deployed card(s) but pre-combat HP
this._renderState();
const runAttacks = () => {
this._animateEvents(events, () => {
this.isAnimating = false;
this._updateLog();
this.statusText.setText('Press SPACE or click Next Turn to advance');
if (this.engine.winner) {
this.time.delayedCall(600, () => this._showResult());
}
});
};
// If the player actually deployed a card, animate it appearing first
const newPlayerCard = chosenCard
? state.player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
: null;
const startAttacks = () => this.time.delayedCall(200, () => this._processNextAttackStep());
if (newPlayerCard) {
const cardObj = this.cardObjects.get(newPlayerCard.instanceId);
this.statusText.setText(`You deploy: ${newPlayerCard.name}`);
this._animateDeploy(cardObj, () => {
this.time.delayedCall(200, runAttacks);
});
this._animateDeploy(cardObj, startAttacks);
} else {
runAttacks();
startAttacks();
}
}
// Drive one attack at a time. Called recursively until all attacks are done.
_processNextAttackStep() {
if (!this.engine.hasPendingAttacks()) {
// All attacks resolved — run death-check, win-check, then tidy up display
this.engine.finalizeCommit();
this._renderState(); // removes dead cards, shows clean final state
const state = this.engine.getState();
this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
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');
if (this.engine.winner) {
this.time.delayedCall(600, () => this._showResult());
}
return;
}
const events = this.engine.processNextAttack();
const attackEvent = events.find(e => e.type === 'attack');
if (!attackEvent) {
// Attacker was already dead (counter-killed earlier this turn) — skip
this.time.delayedCall(30, () => this._processNextAttackStep());
return;
}
// Update commander HP bars immediately (siege / strike may have just hit)
const state = this.engine.getState();
this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
this._animateAttack(attackEvent, () => {
// Refresh attacker HP (counter skills may have damaged it)
this._refreshCardAfterAttack(attackEvent.attacker);
// Refresh / kill defender
this._refreshCardAfterAttack(attackEvent.defender);
this.time.delayedCall(220, () => this._processNextAttackStep());
});
}
// Update a card's HP bar and stats at the moment of impact, then handle death.
_refreshCardAfterAttack(cardData) {
if (!cardData) return;
const obj = this.cardObjects.get(cardData.instanceId)
?? this.commanderObjects?.get(cardData.instanceId);
if (!obj) return;
obj.refresh(); // updates HP bar + stats text to current values
// Commanders never get destroyed — the win-check handles game-over
if (cardData.currentHP <= 0 && !obj.isCommander) {
this.cardObjects.delete(cardData.instanceId);
this.tweens.killTweensOf(obj);
obj.setScale(1).setDepth(0);
this.tweens.add({
targets: obj,
scaleX: 1.25, scaleY: 1.25,
alpha: 0,
duration: 480,
ease: 'Power2',
onComplete: () => { if (obj.scene) obj.destroy(); }
});
}
}
@ -294,23 +362,6 @@ export class BattleScene extends Phaser.Scene {
});
}
// Play attack events one at a time, waiting for each to finish before starting the next
_animateEvents(events, onComplete) {
const attacks = events.filter(e => e.type === 'attack');
if (attacks.length === 0) { onComplete(); return; }
const doNext = (i) => {
if (i >= attacks.length) {
this.time.delayedCall(150, onComplete);
return;
}
this._animateAttack(attacks[i], () => {
this.time.delayedCall(220, () => doNext(i + 1));
});
};
doNext(0);
}
// Animate a single attack.
// Both the attacker and defender scale up together, hold enlarged while
// the particle burst + shake + damage number play, then scale back down.
@ -326,15 +377,16 @@ export class BattleScene extends Phaser.Scene {
bloodthirsty: 0x882244, xeno: 0x22aa44, righteous: 0xaaaa22
};
const attackerObj = this.cardObjects.get(event.attacker.instanceId);
const defenderObj = this.cardObjects.get(event.defender?.instanceId);
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const attackerObj = _lookup(event.attacker.instanceId);
const defenderObj = _lookup(event.defender?.instanceId);
const attName = event.attacker.name;
const defName = event.defender?.name ?? 'Commander';
this.statusText.setText(`${attName} ⚔ ${defName} — ${event.damage} damage!`);
// ── Scale a card up, hold, then back down ─────────────────────────────────
const enlargeCard = (obj) => {
// ── Scale a card up, hold, then back down (skip scale-back if card is dead) ──
const enlargeCard = (obj, cardRef) => {
if (!obj) return;
obj.setDepth(20);
this.tweens.add({
@ -345,20 +397,23 @@ export class BattleScene extends Phaser.Scene {
onComplete: () => {
this.time.delayedCall(HOLD_MS, () => {
if (!obj.scene) return;
this.tweens.add({
targets: obj,
scaleX: 1, scaleY: 1,
duration: SCALE_DN_MS,
ease: 'Power2',
onComplete: () => { if (obj.scene) obj.setDepth(0); }
});
// Don't scale back dead cards — _refreshCardAfterAttack handles them
if (cardRef.currentHP > 0) {
this.tweens.add({
targets: obj,
scaleX: 1, scaleY: 1,
duration: SCALE_DN_MS,
ease: 'Power2',
onComplete: () => { if (obj.scene) obj.setDepth(0); }
});
}
});
}
});
};
enlargeCard(attackerObj);
enlargeCard(defenderObj);
enlargeCard(attackerObj, event.attacker);
enlargeCard(defenderObj, event.defender ?? { currentHP: 1 });
// ── Attacker: particle burst from card edges ──────────────────────────────
if (attackerObj) {
@ -380,12 +435,15 @@ export class BattleScene extends Phaser.Scene {
this.time.delayedCall(800, () => { if (emitter?.scene) emitter.destroy(); });
}
// ── Defender: shake + red flash + floating damage number ─────────────────
// Starts once both cards are fully enlarged
// ── Defender: shake + red flash + HP update + floating damage number ──────
// Fires once both cards are fully enlarged
this.time.delayedCall(SCALE_UP_MS + 80, () => {
if (defenderObj?.scene) {
const baseColor = FACTION_COLORS[event.defender.faction] || 0x444444;
// Update HP bar at the exact moment of impact
defenderObj.refresh();
// Flash red → fade back to faction colour
this.tweens.add({
targets: defenderObj.bg,
@ -403,11 +461,11 @@ export class BattleScene extends Phaser.Scene {
yoyo: true,
repeat: 6,
ease: 'Linear',
onComplete: () => { defenderObj.x = origX; }
onComplete: () => { if (defenderObj.scene) defenderObj.x = origX; }
});
}
// Floating damage number (shown whether defender survived or not)
// Floating damage number
const dmgAnchorX = defenderObj?.x ?? (attackerObj?.x ?? 640);
const dmgAnchorY = defenderObj?.y ?? (attackerObj?.y ?? 360);
const dmgText = this.add.text(