feat(combat): add 'overwhelm' win condition with visual effects and result display

- Introduces `winType` field to track victory conditions (commander/depletion/overwhelm)
- Implements `_overwhelmCheck()` logic to detect when one side exhausts all resources while the other retains combat capability
- Adds `_animateOverwhelm()` sequence: banner, rally, strikes, HP drain, and commander defeat with shake/flash/particle effects
- Updates `BattleResultScene` to show "VICTORY BY OVERWHELM!" / "OVERWHELMED" titles and annotate report accordingly
This commit is contained in:
Brian Fertig 2026-04-04 11:28:46 -06:00
parent 05ea5a1515
commit d1a5deb7ed
3 changed files with 211 additions and 6 deletions

View File

@ -27,6 +27,7 @@ export class CombatEngine {
this.turn = 0;
this.log = [];
this.winner = null; // 'player' | 'opponent' | null
this.winType = null; // 'commander' | 'depletion' | 'overwhelm'
this.events = []; // events for animation: [{type, data}]
this.playerGoesFirst = true; // alternates each turn
}
@ -457,11 +458,13 @@ export class CombatEngine {
_winCheck() {
if (this.playerCommander.currentHP <= 0) {
this.winner = 'opponent';
this.winType = 'commander';
this._log('Player commander destroyed — Opponent wins!');
return true;
}
if (this.opponentCommander.currentHP <= 0) {
this.winner = 'player';
this.winType = 'commander';
this._log('Opponent commander destroyed — Player wins!');
return true;
}
@ -469,12 +472,43 @@ export class CombatEngine {
if (this.playerDeckIdx >= this.playerDeck.length &&
this.playerHand.length === 0 && this.playerLanes.length === 0) {
this.winner = 'opponent';
this.winType = 'depletion';
this._log('Player ran out of cards — Opponent wins!');
return true;
}
return false;
}
_overwhelmCheck() {
if (this.winner) return false;
const playerDepleted =
this.playerLanes.length === 0 &&
this.playerHand.length === 0 &&
this.playerDeckIdx >= this.playerDeck.length &&
this.playerCommander.currentHP > 0;
const opponentDepleted =
this.opponentLanes.length === 0 &&
this.ai.hand.length === 0 &&
this.ai.deckIndex >= this.ai.deckCards.length &&
this.opponentCommander.currentHP > 0;
if (opponentDepleted && !playerDepleted) {
this.winner = 'player';
this.winType = 'overwhelm';
this._log('Opponent overwhelmed — Player wins!');
return true;
}
if (playerDepleted && !opponentDepleted) {
this.winner = 'opponent';
this.winType = 'overwhelm';
this._log('Player overwhelmed — Opponent wins!');
return true;
}
return false;
}
// Phase 1: draw cards, AI deploys (only when opponent goes first), return player's hand
beginTurn() {
if (this.winner) return { hand: [], canDeploy: false, playerGoesFirst: this.playerGoesFirst };
@ -1144,6 +1178,7 @@ export class CombatEngine {
this._deathCheck();
this._winCheck();
this._postBattlePhase();
this._overwhelmCheck();
return [...this.events];
}

View File

@ -48,6 +48,7 @@ export class BattleResultScene extends Phaser.Scene {
init(data) {
this.won = data.won;
this.winType = data.winType || 'commander';
this.turnCount = data.turnCount;
this.battleEvents = data.battleEvents || [];
this.battleLog = data.battleLog || [];
@ -263,7 +264,11 @@ export class BattleResultScene extends Phaser.Scene {
_showTitle() {
const cx = LEFT_W + (1920 - LEFT_W) / 2;
const title = this.add.text(cx, 50, this.won ? 'VICTORY!' : 'DEFEAT', {
const isOverwhelm = this.winType === 'overwhelm';
const titleText = isOverwhelm
? (this.won ? 'VICTORY BY OVERWHELM!' : 'OVERWHELMED')
: (this.won ? 'VICTORY!' : 'DEFEAT');
const title = this.add.text(cx, 50, titleText, {
fontSize: '72px',
color: this.won ? '#44ff44' : '#ff4444',
stroke: '#000000', strokeThickness: 6,
@ -661,7 +666,7 @@ export class BattleResultScene extends Phaser.Scene {
'',
`> \u2500\u2500\u2500\u2500 AFTER-ACTION REPORT \u2500\u2500\u2500\u2500`,
`> OPERATION: ${missionName.toUpperCase()}`,
`> RESULT: ${this.won ? 'VICTORY' : 'DEFEAT'}`,
`> RESULT: ${this.won ? 'VICTORY' : 'DEFEAT'}${this.winType === 'overwhelm' ? ' (OVERWHELM)' : ''}`,
`> DURATION: ${this.turnCount} TURNS`,
'',
`> \u2500\u2500\u2500\u2500 COMBAT METRICS \u2500\u2500\u2500\u2500`,

View File

@ -914,11 +914,16 @@ export class BattleScene extends Phaser.Scene {
this._updateLog();
this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle' || e.type === 'burrowTick'), () => {
this._animateInitiativeHandoff();
this.isAnimating = false;
this.statusText.setText('Press SPACE or click Next Turn to advance');
if (this.engine.winner) {
if (this.engine.winner && this.engine.winType === 'overwhelm') {
this.isAnimating = false;
this._animateOverwhelm(() => this._showResult());
} else if (this.engine.winner) {
this.isAnimating = false;
this.time.delayedCall(600, () => this._showResult());
} else {
this._animateInitiativeHandoff();
this.isAnimating = false;
this.statusText.setText('Press SPACE or click Next Turn to advance');
}
});
return;
@ -4755,6 +4760,165 @@ export class BattleScene extends Phaser.Scene {
}
}
_animateOverwhelm(onComplete) {
const { width, height } = this.scale;
const winnerSide = this.engine.winner; // 'player' | 'opponent'
const state = this.engine.getState();
// Gather winning lane card objects
const winnerLanes = winnerSide === 'player' ? state.player.lanes : state.opponent.lanes;
const winnerCards = winnerLanes
.map(c => this.cardObjects.get(c.instanceId))
.filter(obj => obj?.scene);
// Losing commander
const loserCmd = winnerSide === 'player' ? state.opponent.commander : state.player.commander;
const loserCmdObj = this.commanderObjects.get(loserCmd.instanceId);
const loserCmdPos = winnerSide === 'player'
? this.battlefield.getOpponentCommanderPos()
: this.battlefield.getPlayerCommanderPos();
// ── Phase 1: "OVERWHELM!" Banner ──────────────────────────────────────
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0)
.setDepth(28);
this.tweens.add({ targets: overlay, alpha: 0.5, duration: 300 });
const bannerText = this.add.text(width / 2, height / 2, 'OVERWHELM!', {
fontSize: '64px', color: '#ffd700', fontStyle: 'bold',
stroke: '#000000', strokeThickness: 6, fontFamily: 'RaiderCrusader'
}).setOrigin(0.5).setDepth(30).setScale(0);
this.sound.play('sfx_legendary_play', { volume: 0.9 });
this.tweens.add({
targets: bannerText, scaleX: 1.2, scaleY: 1.2,
duration: 400, ease: 'Back.Out',
onComplete: () => {
this.time.delayedCall(300, () => {
this.tweens.add({ targets: bannerText, alpha: 0, duration: 300 });
});
}
});
// ── Phase 2: Cards Rally (after 1000ms) ───────────────────────────────
this.time.delayedCall(1000, () => {
if (!this.scene.isActive()) return;
const targetX = loserCmdPos.x;
const targetY = loserCmdPos.y;
winnerCards.forEach((cardObj, i) => {
if (!cardObj.scene) return;
const rallyX = targetX + 80 + i * 50;
const rallyY = cardObj.y + (targetY - cardObj.y) * 0.6;
cardObj.setDepth(25);
this.tweens.add({
targets: cardObj,
x: rallyX, y: rallyY, scaleX: 1.3, scaleY: 1.3,
duration: 500, ease: 'Power2', delay: i * 80
});
});
// ── Phase 3: Sequential Strikes (after rally completes) ─────────
const rallyDuration = 500 + (winnerCards.length - 1) * 80 + 200;
this.time.delayedCall(rallyDuration, () => {
if (!this.scene.isActive()) return;
const strikeNext = (idx) => {
if (idx >= winnerCards.length) {
// ── Phase 4: Commander HP Drain ────────────────────────
this.time.delayedCall(200, () => {
if (!this.scene.isActive()) return;
this.sound.play('sfx_destroy', { volume: 0.85 });
const startHP = loserCmd.currentHP;
const counter = { hp: startHP };
this.tweens.add({
targets: counter, hp: 0, duration: 600,
onUpdate: () => {
if (loserCmdObj?.scene && loserCmdObj.hpText) {
loserCmdObj.hpText.setText(`${Math.max(0, Math.round(counter.hp))}`);
}
},
onComplete: () => {
// ── Phase 5: Commander Defeat ─────────────────
if (!this.scene.isActive()) return;
if (loserCmdObj?.scene) {
this.tweens.add({
targets: loserCmdObj, alpha: 0, scaleX: 1.5, scaleY: 1.5,
duration: 400, ease: 'Cubic.easeIn'
});
}
this.cameras.main.shake(300, 0.008);
this.cameras.main.flash(200, 255, 255, 255, false, null, 0.3);
// ── Phase 6: Transition ───────────────────────
this.time.delayedCall(600, () => {
if (!this.scene.isActive()) return;
this.tweens.add({
targets: overlay, alpha: 0, duration: 300,
onComplete: () => onComplete()
});
});
}
});
});
return;
}
const cardObj = winnerCards[idx];
if (!cardObj?.scene) { strikeNext(idx + 1); return; }
// Lunge toward commander
const lungeX = cardObj.x + (loserCmdPos.x - cardObj.x > 0 ? 30 : -30);
this.tweens.add({
targets: cardObj, x: lungeX, duration: 80,
onComplete: () => {
// Impact effects on commander
this.sound.play('sfx_damage', { volume: 0.6 });
if (loserCmdObj?.scene) {
// Shake
const origX = loserCmdObj.x;
this.tweens.add({
targets: loserCmdObj, x: origX + 18,
duration: 60, yoyo: true, repeat: 2,
onComplete: () => { if (loserCmdObj.scene) loserCmdObj.x = origX; }
});
// Red flash
if (loserCmdObj.bg) {
loserCmdObj.bg.setAlpha(0.6);
this.tweens.add({ targets: loserCmdObj.bg, alpha: 0, duration: 200 });
}
}
// Particle burst at commander
const emitter = this.add.particles(loserCmdPos.x, loserCmdPos.y, 'particle_dot', {
speed: { min: 60, max: 200 },
scale: { start: 1.2, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 500,
blendMode: 'ADD',
tint: [0xff4400, 0xff8800, 0xffcc00],
quantity: 30,
emitting: false
}).setDepth(29);
emitter.explode(30);
this.time.delayedCall(600, () => { if (emitter.scene) emitter.destroy(); });
// Lunge back
this.tweens.add({
targets: cardObj, x: cardObj.x - (lungeX - cardObj.x), duration: 80,
onComplete: () => strikeNext(idx + 1)
});
}
});
};
strikeNext(0);
});
});
}
_showResult() {
if (this._resultShown) return;
this._resultShown = true;
@ -4829,6 +4993,7 @@ export class BattleScene extends Phaser.Scene {
this._goingToResultScene = true;
this.scene.start('BattleResultScene', {
won,
winType: this.engine.winType || 'commander',
turnCount: this.engine.turn,
battleEvents: this._allBattleEvents || [],
battleLog: this.engine.log,