feat(combat): implement alternating initiative with enhanced attack animations

- Added `playerGoesFirst` state to `CombatEngine` to alternate turn order each round.
- Updated `beginTurn` and `beginCommit` to handle AI deployment based on the current initiative (opponent deploys first if they have initiative, otherwise after the player).
- Modified `_buildPendingAttacks` to respect the alternating attack order.
- Added `animateHPLoss` to `CardObject.js` for a visual HP reduction animation similar to the Berserk gain effect.
- Enhanced `_animateAttack` in `BattleScene.js`:
  - Integrated sprite animations for attacks (gun turret) and explosions using new spritesheets.
  - Synchronized HP loss and Berserk gain animations with the attack sprite completion.
  - Added an "Attacks First" indicator icon next to the commander of the side with initiative.
- Updated `BootScene.js` to load the new `attacksFirst` UI image and `attacks` spritesheet.
This commit is contained in:
Brian Fertig 2026-03-13 10:31:09 -06:00
parent 73ce826100
commit c7f4dc9bbb
5 changed files with 228 additions and 78 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

View File

@ -28,6 +28,7 @@ export class CombatEngine {
this.log = [];
this.winner = null; // 'player' | 'opponent' | null
this.events = []; // events for animation: [{type, data}]
this.playerGoesFirst = true; // alternates each turn
}
_log(msg) {
@ -260,22 +261,24 @@ export class CombatEngine {
return false;
}
// Phase 1: draw cards, AI deploys, return player's hand for selection
// Phase 1: draw cards, AI deploys (only when opponent goes first), return player's hand
beginTurn() {
if (this.winner) return { hand: [], canDeploy: false };
if (this.winner) return { hand: [], canDeploy: false, playerGoesFirst: this.playerGoesFirst };
this.events = [];
this.turn++;
this._log(`--- Turn ${this.turn} ---`);
this._drawPhase();
// AI deploys its card immediately
const aiCard = this.ai.getNextCard();
if (aiCard && this.opponentLanes.length < MAX_LANES) {
this.opponentLanes.push(aiCard);
this._log(`Opponent deploys ${aiCard.name}`);
this.events.push({ type: 'deploy', side: 'opponent', card: aiCard });
// AI deploys immediately only on opponent-first turns
if (!this.playerGoesFirst) {
const aiCard = this.ai.getNextCard();
if (aiCard && this.opponentLanes.length < MAX_LANES) {
this.opponentLanes.push(aiCard);
this._log(`Opponent deploys ${aiCard.name}`);
this.events.push({ type: 'deploy', side: 'opponent', card: aiCard });
}
}
const canDeploy = this.playerHand.length > 0 && this.playerLanes.length < MAX_LANES;
return { hand: [...this.playerHand], canDeploy };
return { hand: [...this.playerHand], canDeploy, playerGoesFirst: this.playerGoesFirst };
}
// Phase 2: deploy chosen card (or null to pass), then resolve the turn
@ -314,40 +317,48 @@ export class CombatEngine {
this.events.push({ type: 'deploy', side: 'player', card: chosenCard });
}
}
// On player-first turns, AI deploys after the player
if (this.playerGoesFirst) {
const aiCard = this.ai.getNextCard();
if (aiCard && this.opponentLanes.length < MAX_LANES) {
this.opponentLanes.push(aiCard);
this._log(`Opponent deploys ${aiCard.name}`);
this.events.push({ type: 'deploy', side: 'opponent', card: aiCard });
}
}
this._activationPhase();
this._commanderSkillPhase();
this._rupturePhase();
this._jamPhase();
this._buildPendingAttacks();
this.playerGoesFirst = !this.playerGoesFirst; // flip initiative for next turn
return [...this.events];
}
// Build the ordered list of attacks for this turn (called by beginCommit).
// Attack order respects the current initiative (playerGoesFirst, before it is flipped).
_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'
});
const addAttacks = (attackerLanes, enemyLanes, enemyCommander, alliedLanes, side) => {
for (let i = 0; i < attackerLanes.length; i++) {
const attacker = attackerLanes[i];
if (attacker.currentHP <= 0 || attacker.currentDelay > 0 || attacker.jamTurns > 0) continue;
this._pendingAttacks.push({
attacker,
target: enemyLanes[i] || null,
alliedLanes,
enemyLanes,
enemyCommander,
side
});
}
};
if (this.playerGoesFirst) {
addAttacks(this.playerLanes, this.opponentLanes, this.opponentCommander, this.playerLanes, 'player');
addAttacks(this.opponentLanes, this.playerLanes, this.playerCommander, this.opponentLanes, 'opponent');
} else {
addAttacks(this.opponentLanes, this.playerLanes, this.playerCommander, this.opponentLanes, 'opponent');
addAttacks(this.playerLanes, this.opponentLanes, this.opponentCommander, this.playerLanes, 'player');
}
}

View File

@ -247,6 +247,65 @@ export class CardObject extends Phaser.GameObjects.Container {
});
}
animateHPLoss(damage, onComplete) {
if (!this.hpText || !this.scene) { if (onComplete) onComplete(); return; }
const w = this.options.width || 80;
const s = w / 80;
const h = this.options.height || 110;
const bannerH = Math.round(h * 0.12);
const bottomBannerCY = h / 2 - bannerH / 2;
// Rewind to pre-damage value so animation transitions old → new
this.hpText.setText(`${Math.max(0, this.cardData.currentHP + damage)}`);
this.scene.tweens.add({
targets: this.hpText,
scaleX: 2, scaleY: 2,
duration: 200,
ease: 'Back.Out',
onComplete: () => {
if (!this.scene) { if (onComplete) onComplete(); return; }
const lossText = this.scene.add.text(
this.hpText.x + this.hpText.width * 2 + Math.round(4 * s),
bottomBannerCY,
`-${damage}`,
{
fontSize: `${Math.round(11 * s)}px`,
color: '#ff4444',
fontStyle: 'bold',
stroke: '#000000',
strokeThickness: Math.max(1, Math.round(2 * s))
}
).setOrigin(0, 0.5).setDepth(50);
this.add(lossText);
this.scene.time.delayedCall(500, () => {
if (!this.scene) { if (onComplete) onComplete(); return; }
this.hpText.setText(`${Math.max(0, this.cardData.currentHP)}`);
this.scene.tweens.add({
targets: this.hpText,
scaleX: 1, scaleY: 1,
duration: 200,
ease: 'Power2'
});
this.scene.tweens.add({
targets: lossText,
alpha: 0,
y: bottomBannerCY - Math.round(14 * s),
duration: 300,
ease: 'Power2',
onComplete: () => {
if (lossText.scene) lossText.destroy();
if (onComplete) onComplete();
}
});
});
}
});
}
flash(color = 0xff4444) {
const factionColor = FACTION_COLORS[this.cardData.faction] || 0x444444;
this.scene.tweens.add({

View File

@ -31,6 +31,24 @@ export class BattleScene extends Phaser.Scene {
g.destroy();
}
// Sprite animations (global — skip if already registered from a previous battle)
if (!this.anims.exists('attack_anim')) {
this.anims.create({
key: 'attack_anim',
frames: this.anims.generateFrameNumbers('attacks', { start: 0, end: 4 }),
frameRate: 10,
repeat: 0
});
}
if (!this.anims.exists('explosion_anim')) {
this.anims.create({
key: 'explosion_anim',
frames: this.anims.generateFrameNumbers('attacks', { start: 5, end: 8 }),
frameRate: 10,
repeat: 0
});
}
// Background
this.add.rectangle(width / 2, height / 2, width, height, 0x1a1a2e);
@ -48,7 +66,7 @@ export class BattleScene extends Phaser.Scene {
// Side labels
this.add.text(14, 95, 'OPPONENT', { fontSize: '17px', color: '#ff8888' });
this.add.text(14, 520, 'PLAYER', { fontSize: '17px', color: '#88aaff' });
this.add.text(14, 520, 'PLAYER', { fontSize: '17px', color: '#88aaff' });
this.battlefield = new BattleField(this, {
playerY: 715, opponentY: 305,
@ -118,6 +136,8 @@ export class BattleScene extends Phaser.Scene {
this.autoTimer = null;
this.waitingForPick = false;
this.isAnimating = false;
this.playerGoesFirst = true;
this.initiativeIndicator = null;
const autoBtn = this.add.rectangle(1680, 35, 180, 44, 0x224422)
.setInteractive({ useHandCursor: true })
@ -147,8 +167,8 @@ export class BattleScene extends Phaser.Scene {
this.commanderObjects = new Map(); // instanceId → CardObject (persists across _renderState)
const specs = [
{ data: state.opponent.commander, cx: 150, cy: 305, label: 'ENEMY CMD', labelColor: '#ff8888' },
{ data: state.player.commander, cx: 150, cy: 715, label: 'COMMANDER', labelColor: '#ffd700' }
{ data: state.opponent.commander, cx: 150, cy: 305, label: 'ENEMY CMD', labelColor: '#ff8888' },
{ data: state.player.commander, cx: 150, cy: 715, label: 'COMMANDER', labelColor: '#ffd700' }
];
for (const s of specs) {
@ -173,18 +193,16 @@ export class BattleScene extends Phaser.Scene {
if (this.waitingForPick || this.isAnimating) return;
if (this.engine.winner) { this._showResult(); return; }
// Snapshot which opponent cards are already on the field before this turn
// Snapshot opponent lanes before this turn (only matters on opponent-first turns)
const oldOpponentIds = new Set(
this.engine.getState().opponent.lanes.map(c => c.instanceId)
);
const { hand, canDeploy } = this.engine.beginTurn();
const { hand, canDeploy, playerGoesFirst } = this.engine.beginTurn();
this.playerGoesFirst = playerGoesFirst;
this.turnText.setText(`Turn ${this.engine.turn}`);
this._renderState();
// Find the newly deployed opponent card (if any)
const newOpponentCard = this.engine.getState().opponent.lanes
.find(c => !oldOpponentIds.has(c.instanceId));
this._updateInitiativeIndicator(playerGoesFirst);
const proceedAfterDeploy = () => {
if (canDeploy && !this.autoPlay) {
@ -196,6 +214,12 @@ export class BattleScene extends Phaser.Scene {
}
};
// On opponent-first turns, show enemy deploy animation before showing picker.
// On player-first turns, opponent hasn't deployed yet — go straight to picker.
const newOpponentCard = !playerGoesFirst
? this.engine.getState().opponent.lanes.find(c => !oldOpponentIds.has(c.instanceId))
: null;
if (newOpponentCard) {
this.isAnimating = true;
const cardObj = this.cardObjects.get(newOpponentCard.instanceId);
@ -214,29 +238,48 @@ export class BattleScene extends Phaser.Scene {
this.isAnimating = true;
this.statusText.setText('Deploying...');
// Snapshot player lanes before deploy so we can detect the new card
// Snapshot both sides before deploy so we can detect new cards
const oldPlayerIds = new Set(
this.engine.getState().player.lanes.map(c => c.instanceId)
);
const oldOpponentIds = new Set(
this.engine.getState().opponent.lanes.map(c => c.instanceId)
);
// Deploy + pre-attack phases (NO damage applied yet)
// On player-first turns, beginCommit also deploys the AI card
this.engine.beginCommit(chosenCard);
// Render the field with the newly deployed card(s) but pre-combat HP
// Render the field with all newly deployed cards but pre-combat HP
this._renderState();
const newPlayerCard = chosenCard
? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
: null;
// On player-first turns the AI deployed inside beginCommit — animate it after player
const newOpponentCard = this.playerGoesFirst
? this.engine.getState().opponent.lanes.find(c => !oldOpponentIds.has(c.instanceId))
: null;
const startAttacks = () => this.time.delayedCall(200, () => this._processNextAttackStep());
const animateOpponentThenAttack = () => {
if (newOpponentCard) {
const cardObj = this.cardObjects.get(newOpponentCard.instanceId);
this.statusText.setText(`Enemy deploys: ${newOpponentCard.name}`);
this._animateDeploy(cardObj, startAttacks);
} else {
startAttacks();
}
};
if (newPlayerCard) {
const cardObj = this.cardObjects.get(newPlayerCard.instanceId);
this.statusText.setText(`You deploy: ${newPlayerCard.name}`);
this._animateDeploy(cardObj, startAttacks);
this._animateDeploy(cardObj, animateOpponentThenAttack);
} else {
startAttacks();
animateOpponentThenAttack();
}
}
@ -279,38 +322,28 @@ export class BattleScene extends Phaser.Scene {
const berserkEvent = events.find(e => e.type === 'berserk');
this._animateAttack(attackEvent, () => {
// Refresh / kill defender
this._refreshCardAfterAttack(attackEvent.defender);
const proceed = () => {
this._refreshCardAfterAttack(attackEvent.attacker);
this.time.delayedCall(220, () => this._processNextAttackStep());
};
if (berserkEvent) {
const cardObj = this.cardObjects.get(berserkEvent.card.instanceId)
?? this.commanderObjects?.get(berserkEvent.card.instanceId);
if (cardObj) {
// Rewind ATK text to pre-gain value so animation transitions old → new
cardObj.atkText.setText(`${berserkEvent.card.currentAttack - berserkEvent.gain}`);
cardObj.animateBerserkGain(berserkEvent.gain, proceed);
return;
}
}
proceed();
});
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.
_refreshCardAfterAttack(cardData) {
if (!cardData) return;
const obj = this.cardObjects.get(cardData.instanceId)
?? this.commanderObjects?.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) {
// Explosion sprite centered on the card
const explosionSprite = this.add.sprite(obj.x, obj.y, 'attacks')
.setDisplaySize(630, 630)
.setDepth(23);
explosionSprite.play('explosion_anim');
explosionSprite.once('animationcomplete', () => { if (explosionSprite.scene) explosionSprite.destroy(); });
this.cardObjects.delete(cardData.instanceId);
this.tweens.killTweensOf(obj);
obj.setScale(1).setDepth(0);
@ -318,7 +351,7 @@ export class BattleScene extends Phaser.Scene {
targets: obj,
scaleX: 1.25, scaleY: 1.25,
alpha: 0,
duration: 480,
duration: 400,
ease: 'Power2',
onComplete: () => { if (obj.scene) obj.destroy(); }
});
@ -350,20 +383,20 @@ export class BattleScene extends Phaser.Scene {
// 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.
_animateAttack(event, onComplete) {
const SCALE = 1.78;
_animateAttack(event, onComplete, berserkEvent = null) {
const SCALE = 1.78;
const SCALE_UP_MS = 280;
const HOLD_MS = 3100;
const HOLD_MS = 3100;
const SCALE_DN_MS = 280;
const ATTACK_MS = SCALE_UP_MS + HOLD_MS + SCALE_DN_MS; // 1660 ms
const ATTACK_MS = SCALE_UP_MS + HOLD_MS + SCALE_DN_MS; // 1660 ms
const { width, height } = this.scale;
const cardW = 260;
const enlargedW = cardW * SCALE; // ~463px at 1.78×
const gap = enlargedW * 0.75; // ~347px between enlarged cards
const cardW = 260;
const enlargedW = cardW * SCALE; // ~463px at 1.78×
const gap = enlargedW * 0.75; // ~347px between enlarged cards
const attackerDestX = width / 2 - gap / 2 - enlargedW / 2; // ~554
const defenderDestX = width / 2 + gap / 2 + enlargedW / 2; // ~1366
const centerY = height / 2; // 540
const centerY = height / 2; // 540
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const attackerObj = _lookup(event.attacker.instanceId);
@ -422,7 +455,7 @@ export class BattleScene extends Phaser.Scene {
});
};
enlargeCard(attackerObj, event.attacker, attackerDestX, attackerOrigX, attackerOrigY);
enlargeCard(attackerObj, event.attacker, attackerDestX, attackerOrigX, attackerOrigY);
enlargeCard(defenderObj, event.defender ?? { currentHP: 1 }, defenderDestX, defenderOrigX, defenderOrigY);
// ── VS image — appears between cards once they arrive at center ───────────
@ -443,11 +476,24 @@ export class BattleScene extends Phaser.Scene {
});
});
// ── Attacker: particle burst — fires once card has arrived at center ──────
// ── Attacker: attack sprite + particle burst — fires once card arrives at center ──
if (attackerObj) {
const enlargedH = 364 * SCALE;
this.time.delayedCall(SCALE_UP_MS, () => {
if (!attackerObj.scene) return;
// Gun turret animation centered on the attacking card
const attackSprite = this.add.sprite(
attackerDestX,
centerY,
'attacks'
).setDisplaySize(480, 480).setDepth(22);
attackSprite.play('attack_anim');
attackSprite.once('animationcomplete', () => {
if (attackSprite.scene) attackSprite.destroy();
this._onAttackReconcile(event, berserkEvent);
});
const emitter = this.add.particles(attackerDestX, centerY, 'particle_dot', {
speed: { min: 90, max: 300 },
scale: { start: 1.8, end: 0 },
@ -516,6 +562,24 @@ export class BattleScene extends Phaser.Scene {
this.time.delayedCall(ATTACK_MS, 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));
const defenderObj = _lookup(event.defender?.instanceId);
const attackerObj = _lookup(event.attacker?.instanceId);
// HP loss animation on defender
if (defenderObj?.scene && event.damage > 0) {
defenderObj.animateHPLoss(event.damage);
}
// Berserk ATK gain animation on attacker
if (berserkEvent && attackerObj?.scene) {
attackerObj.atkText.setText(`${berserkEvent.card.currentAttack - berserkEvent.gain}`);
attackerObj.animateBerserkGain(berserkEvent.gain);
}
}
_showCardPicker(hand) {
const { width, height } = this.scale;
@ -547,7 +611,7 @@ export class BattleScene extends Phaser.Scene {
this.pickerObjects.push(title);
const startX = width / 2 - totalW / 2 + cardW / 2;
const cardY = panelY + 8;
const cardY = panelY + 8;
hand.forEach((card, i) => {
const x = startX + i * (cardW + gap);
@ -704,6 +768,20 @@ export class BattleScene extends Phaser.Scene {
backBtn.on('pointerdown', () => this.scene.start(this.missionData ? 'CampaignScene' : 'MainMenuScene'));
}
_updateInitiativeIndicator(playerGoesFirst) {
if (this.initiativeIndicator) {
this.initiativeIndicator.destroy();
this.initiativeIndicator = null;
}
if (!this.textures.exists('attacksFirst')) return;
// Position the indicator to the left of the commander card (commander at x=150, width=240)
const indicatorX = 22;
const indicatorY = playerGoesFirst ? 715 : 305;
this.initiativeIndicator = this.add.image(indicatorX, indicatorY, 'attacksFirst')
.setDisplaySize(44, 44)
.setDepth(5);
}
_makeBackButton() {
const bg = this.add.rectangle(80, 35, 180, 44, 0x333333)
.setInteractive({ useHandCursor: true })

View File

@ -9,6 +9,8 @@ export class BootScene extends Phaser.Scene {
preload() {
// Load all JSON data files
this.load.image('vs', 'assets/images/ui/vs.png');
this.load.image('attacksFirst', 'assets/images/ui/attacksFirst.png');
this.load.spritesheet('attacks', 'assets/images/spritesheets/attacks.png', { frameWidth: 460, frameHeight: 460 });
this.load.json('cards', 'data/cards.json');
this.load.json('packs', 'data/packs.json');
this.load.json('missions', 'data/missions.json');