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.turn = 0;
|
||||||
this.log = [];
|
this.log = [];
|
||||||
this.winner = null; // 'player' | 'opponent' | null
|
this.winner = null; // 'player' | 'opponent' | null
|
||||||
|
this.winType = null; // 'commander' | 'depletion' | 'overwhelm'
|
||||||
this.events = []; // events for animation: [{type, data}]
|
this.events = []; // events for animation: [{type, data}]
|
||||||
this.playerGoesFirst = true; // alternates each turn
|
this.playerGoesFirst = true; // alternates each turn
|
||||||
}
|
}
|
||||||
|
|
@ -457,11 +458,13 @@ export class CombatEngine {
|
||||||
_winCheck() {
|
_winCheck() {
|
||||||
if (this.playerCommander.currentHP <= 0) {
|
if (this.playerCommander.currentHP <= 0) {
|
||||||
this.winner = 'opponent';
|
this.winner = 'opponent';
|
||||||
|
this.winType = 'commander';
|
||||||
this._log('Player commander destroyed — Opponent wins!');
|
this._log('Player commander destroyed — Opponent wins!');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (this.opponentCommander.currentHP <= 0) {
|
if (this.opponentCommander.currentHP <= 0) {
|
||||||
this.winner = 'player';
|
this.winner = 'player';
|
||||||
|
this.winType = 'commander';
|
||||||
this._log('Opponent commander destroyed — Player wins!');
|
this._log('Opponent commander destroyed — Player wins!');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -469,12 +472,43 @@ export class CombatEngine {
|
||||||
if (this.playerDeckIdx >= this.playerDeck.length &&
|
if (this.playerDeckIdx >= this.playerDeck.length &&
|
||||||
this.playerHand.length === 0 && this.playerLanes.length === 0) {
|
this.playerHand.length === 0 && this.playerLanes.length === 0) {
|
||||||
this.winner = 'opponent';
|
this.winner = 'opponent';
|
||||||
|
this.winType = 'depletion';
|
||||||
this._log('Player ran out of cards — Opponent wins!');
|
this._log('Player ran out of cards — Opponent wins!');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
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
|
// Phase 1: draw cards, AI deploys (only when opponent goes first), return player's hand
|
||||||
beginTurn() {
|
beginTurn() {
|
||||||
if (this.winner) return { hand: [], canDeploy: false, playerGoesFirst: this.playerGoesFirst };
|
if (this.winner) return { hand: [], canDeploy: false, playerGoesFirst: this.playerGoesFirst };
|
||||||
|
|
@ -1144,6 +1178,7 @@ export class CombatEngine {
|
||||||
this._deathCheck();
|
this._deathCheck();
|
||||||
this._winCheck();
|
this._winCheck();
|
||||||
this._postBattlePhase();
|
this._postBattlePhase();
|
||||||
|
this._overwhelmCheck();
|
||||||
return [...this.events];
|
return [...this.events];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export class BattleResultScene extends Phaser.Scene {
|
||||||
|
|
||||||
init(data) {
|
init(data) {
|
||||||
this.won = data.won;
|
this.won = data.won;
|
||||||
|
this.winType = data.winType || 'commander';
|
||||||
this.turnCount = data.turnCount;
|
this.turnCount = data.turnCount;
|
||||||
this.battleEvents = data.battleEvents || [];
|
this.battleEvents = data.battleEvents || [];
|
||||||
this.battleLog = data.battleLog || [];
|
this.battleLog = data.battleLog || [];
|
||||||
|
|
@ -263,7 +264,11 @@ export class BattleResultScene extends Phaser.Scene {
|
||||||
_showTitle() {
|
_showTitle() {
|
||||||
const cx = LEFT_W + (1920 - LEFT_W) / 2;
|
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',
|
fontSize: '72px',
|
||||||
color: this.won ? '#44ff44' : '#ff4444',
|
color: this.won ? '#44ff44' : '#ff4444',
|
||||||
stroke: '#000000', strokeThickness: 6,
|
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`,
|
`> \u2500\u2500\u2500\u2500 AFTER-ACTION REPORT \u2500\u2500\u2500\u2500`,
|
||||||
`> OPERATION: ${missionName.toUpperCase()}`,
|
`> OPERATION: ${missionName.toUpperCase()}`,
|
||||||
`> RESULT: ${this.won ? 'VICTORY' : 'DEFEAT'}`,
|
`> RESULT: ${this.won ? 'VICTORY' : 'DEFEAT'}${this.winType === 'overwhelm' ? ' (OVERWHELM)' : ''}`,
|
||||||
`> DURATION: ${this.turnCount} TURNS`,
|
`> DURATION: ${this.turnCount} TURNS`,
|
||||||
'',
|
'',
|
||||||
`> \u2500\u2500\u2500\u2500 COMBAT METRICS \u2500\u2500\u2500\u2500`,
|
`> \u2500\u2500\u2500\u2500 COMBAT METRICS \u2500\u2500\u2500\u2500`,
|
||||||
|
|
|
||||||
|
|
@ -914,11 +914,16 @@ export class BattleScene extends Phaser.Scene {
|
||||||
this._updateLog();
|
this._updateLog();
|
||||||
|
|
||||||
this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle' || e.type === 'burrowTick'), () => {
|
this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle' || e.type === 'burrowTick'), () => {
|
||||||
this._animateInitiativeHandoff();
|
if (this.engine.winner && this.engine.winType === 'overwhelm') {
|
||||||
this.isAnimating = false;
|
this.isAnimating = false;
|
||||||
this.statusText.setText('Press SPACE or click Next Turn to advance');
|
this._animateOverwhelm(() => this._showResult());
|
||||||
if (this.engine.winner) {
|
} else if (this.engine.winner) {
|
||||||
|
this.isAnimating = false;
|
||||||
this.time.delayedCall(600, () => this._showResult());
|
this.time.delayedCall(600, () => this._showResult());
|
||||||
|
} else {
|
||||||
|
this._animateInitiativeHandoff();
|
||||||
|
this.isAnimating = false;
|
||||||
|
this.statusText.setText('Press SPACE or click Next Turn to advance');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
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() {
|
_showResult() {
|
||||||
if (this._resultShown) return;
|
if (this._resultShown) return;
|
||||||
this._resultShown = true;
|
this._resultShown = true;
|
||||||
|
|
@ -4829,6 +4993,7 @@ export class BattleScene extends Phaser.Scene {
|
||||||
this._goingToResultScene = true;
|
this._goingToResultScene = true;
|
||||||
this.scene.start('BattleResultScene', {
|
this.scene.start('BattleResultScene', {
|
||||||
won,
|
won,
|
||||||
|
winType: this.engine.winType || 'commander',
|
||||||
turnCount: this.engine.turn,
|
turnCount: this.engine.turn,
|
||||||
battleEvents: this._allBattleEvents || [],
|
battleEvents: this._allBattleEvents || [],
|
||||||
battleLog: this.engine.log,
|
battleLog: this.engine.log,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue