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:
parent
05ea5a1515
commit
d1a5deb7ed
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue