Add real Game Over flow to end the run on hull death
- Raise a new GameOverOverlay (scrim, semi-transparent "GAME OVER" headline, Return to Main Menu / Continue) when the player's hull breaks in combat - Add _playerWentDown beat that freezes the world and owns the screen, gating input and the update loop while active - Route the death decision through a skill seam (_playerDeathRespawns / _syncDeathFlowRespawn) so a future respawnOnDeath skill can override; default ends the run - Wire Continue to resume the newest save (SaveManager.latest -> prepareLoad) then scene.restart, mirroring the menu's Continue - Update combat.json playerShip comment to reflect the real death flow replacing the placeholder respawn in the main game - Add toxic-takeoff-01.mp4 asset
This commit is contained in:
parent
fce8a19202
commit
34613b254e
Binary file not shown.
|
|
@ -481,7 +481,7 @@
|
|||
},
|
||||
|
||||
"playerShip": {
|
||||
"_comment": "Your armor (both contexts — the real ship's stats.hullIntegrity/shields replace this when combat ships for real). shields absorb before the hull; after shieldRegenDelay s without a hit, shields regrow at shieldRegenRate/s up to their max. respawn = when your hull breaks you come back (respawnDelay s) instead of the run ending — a stand-in until the real death flow lands.",
|
||||
"_comment": "Your armor (both contexts — the real ship's stats.hullIntegrity/shields replace this when combat ships for real). shields absorb before the hull; after shieldRegenDelay s without a hit, shields regrow at shieldRegenRate/s up to their max. respawn = when your hull breaks you come back (respawnDelay s) instead of the run ending. The MAIN GAME now runs the real death flow (js/scenes/GameScene.js → _playerWentDown: the Game Over beat ends the run — it forces playerRespawn off, overridable only by a skill), so here it only keeps the ?combat range's sandbox convenience.",
|
||||
"hull": 100,
|
||||
"shields": 40,
|
||||
"shieldRegenDelay": 4,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
|
|||
import { scoutGood } from '../economy/TradeScout.js';
|
||||
import * as Stock from '../economy/Stock.js';
|
||||
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
||||
import { GameOverOverlay } from '../ui/GameOverOverlay.js';
|
||||
import { SavePanel } from '../ui/SavePanel.js';
|
||||
import { SaveManager } from '../save/SaveManager.js';
|
||||
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js';
|
||||
|
|
@ -1149,6 +1150,11 @@ export class GameScene extends Phaser.Scene {
|
|||
if (a.shieldHit > 0) this.combatHud.flash('shield');
|
||||
if (a.hullHit > 0) this.combatHud.flash('hull');
|
||||
},
|
||||
// THE DEATH FLOW (the real one): the hull broke → the scene raises
|
||||
// the Game Over beat (js/ui/GameOverOverlay.js) instead of the
|
||||
// placeholder respawn (that flag is set on the skill layer's seam,
|
||||
// below — _playerDeathRespawns).
|
||||
onPlayerDown: () => this._playerWentDown(),
|
||||
});
|
||||
// THE LEVEL-UP MARQUEE (js/ui/LevelUpMarquee.js) — the big beat on a
|
||||
// combat level landing: the world freezes and dims, the old level
|
||||
|
|
@ -1667,6 +1673,9 @@ export class GameScene extends Phaser.Scene {
|
|||
if (this.combat) {
|
||||
this.combat.skillState = this.skillState;
|
||||
this.combat.refreshSkillPassives();
|
||||
// THE DEATH FLOW's respawn gate (the seam reads the LIVE ledger, so a
|
||||
// skill that overrides death stays in sync as ranks change):
|
||||
this.combat.playerRespawn = this._playerDeathRespawns();
|
||||
}
|
||||
// The assignment hand: {id, from} while a skill is being placed on a seat
|
||||
// (js/ui/CombatBar.js waits on it), null the rest of the time.
|
||||
|
|
@ -1959,6 +1968,10 @@ export class GameScene extends Phaser.Scene {
|
|||
// throttle-locked, the ship runs straight into the line and stops
|
||||
// there instead of resting on it).
|
||||
this.input.on('pointerdown', (pointer) => {
|
||||
// The Game Over beat is up — it owns the whole screen (its two buttons
|
||||
// fire on their own pointerdown; a world click never lands behind it,
|
||||
// so a button press never doubles as a fly-here for the downed ship).
|
||||
if (this._gameOver === true) return;
|
||||
// A jump clip is in flight — it owns the whole screen. A DOUBLE-CLICK
|
||||
// (two quick presses) skips the rest of the clip (the same window as
|
||||
// SurfaceScene's landing/takeoff skip); a single press is swallowed.
|
||||
|
|
@ -2788,7 +2801,8 @@ export class GameScene extends Phaser.Scene {
|
|||
this.levelMarquee?.active === true ||
|
||||
!!this._jumping ||
|
||||
this.classWindow?.isOpen === true ||
|
||||
this.skillWindow?.isOpen === true;
|
||||
this.skillWindow?.isOpen === true ||
|
||||
this._gameOver === true; // the Game Over beat owns the screen (the run is over)
|
||||
// The freeze's PARKING (the ack pause's onPause is the same contract
|
||||
// for its own freeze): while ANY freeze holds, the action is parked
|
||||
// dead EVERY frame — the ship (target + throttle cleared, velocity
|
||||
|
|
@ -8202,6 +8216,7 @@ export class GameScene extends Phaser.Scene {
|
|||
// armor is re-rolled from base, so the pool grows (and the bar's orbs show
|
||||
// it) the moment the point lands.
|
||||
this.combat?.refreshSkillPassives?.();
|
||||
this._syncDeathFlowRespawn(); // a ranked skill may override the death flow
|
||||
this.playSfx?.(spec.sfx.spend);
|
||||
const def = this.skillState.defOf(res.id);
|
||||
this.consoleToast(
|
||||
|
|
@ -8240,6 +8255,7 @@ export class GameScene extends Phaser.Scene {
|
|||
this.registry.set('playerSkills', this.skillState);
|
||||
// THE PASSIVE SEAM, run backwards: unspent ranks stop counting now.
|
||||
this.combat?.refreshSkillPassives?.();
|
||||
this._syncDeathFlowRespawn(); // a refunded skill may end the override
|
||||
this.playSfx?.(spec.sfx.refund ?? spec.sfx.denied);
|
||||
this.consoleToast(
|
||||
fillSkillTemplate(spec.resetToast, {
|
||||
|
|
@ -10030,6 +10046,87 @@ export class GameScene extends Phaser.Scene {
|
|||
this.scene.start('MenuScene');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// THE DEATH FLOW — the player's hull broke in combat
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* THE RESPAWN GATE (the skill seam): does the run bring the player back
|
||||
* in their seat, or does the run END? The default stands — death ends the
|
||||
* run and the Game Over beat owns the screen (js/ui/GameOverOverlay.js).
|
||||
* A SKILL may override that: any LIVE skill (rank ≥ 1) whose effect
|
||||
* declares `kind: 'respawnOnDeath'` would bring the player back instead.
|
||||
* None is developed yet, so this is inert and always answers false.
|
||||
* @returns {boolean} true = the old respawn placeholder runs
|
||||
*/
|
||||
_playerDeathRespawns() {
|
||||
const ss = this.skillState;
|
||||
if (ss?.ranks) {
|
||||
for (const [id, rank] of ss.ranks) {
|
||||
if (rank <= 0) continue;
|
||||
const def = ss.defOf(id);
|
||||
if (def?.effect?.kind === 'respawnOnDeath') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Re-resolve the gate after a rank lands or is refunded (the seam reads
|
||||
* the LIVE ledger, so a skill that overrides death stays in sync). */
|
||||
_syncDeathFlowRespawn() {
|
||||
if (this.combat) this.combat.playerRespawn = this._playerDeathRespawns();
|
||||
}
|
||||
|
||||
/**
|
||||
* THE BEAT (CombatSystem → onPlayerDown): the hull broke and the run is
|
||||
* over — freeze the world (the update() loop's freeze picks up
|
||||
* `this._gameOver` next frame: the ship stays down, the hostiles hold)
|
||||
* and raise the Game Over overlay: the large semi-transparent headline
|
||||
* centred, RETURN TO MAIN MENU / CONTINUE below it.
|
||||
*/
|
||||
_playerWentDown() {
|
||||
if (this._gameOver) return; // one beat per death
|
||||
// A live skill overrode the death flow — the player is coming back in
|
||||
// their seat (the respawn placeholder runs), so there is no beat.
|
||||
if (this.combat?.playerRespawn === true) return;
|
||||
this._gameOver = true;
|
||||
this.savePanel?.close(); // the deck's save doors fold away behind the beat
|
||||
this.menuSubBar?.dismiss();
|
||||
if (!this._gameOverOverlay) {
|
||||
this._gameOverOverlay = new GameOverOverlay(this);
|
||||
}
|
||||
this._gameOverOverlay.show({ canContinue: this.saveManager.hasAny() });
|
||||
}
|
||||
|
||||
/**
|
||||
* THE CONTINUE DOOR (the Game Over beat's button): resume the NEWEST save
|
||||
* in the bank — exactly the main menu's Continue (SaveManager.latest by
|
||||
* savedAt → prepareLoad stages the restore in the shared registry) — then
|
||||
* rebuild this scene in place so the fresh create() consumes it (the same
|
||||
* seam as the in-game Load's reloadRun).
|
||||
*/
|
||||
continueFromGameOver() {
|
||||
if (this._gameOverCut) return;
|
||||
const latest = this.saveManager.latest();
|
||||
if (!latest) {
|
||||
this.consoleToast?.('NO SAVE ON FILE — CONTINUE IS DARK', { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._gameOverCut = true;
|
||||
prepareLoad(this.registry, latest.record);
|
||||
console.info(
|
||||
`orbit — game over: resuming ${latest.record.galaxyName ?? 'the galaxy'} (slot ${latest.slot ?? 'auto'}, seed ${latest.record.seed})`,
|
||||
);
|
||||
this.scene.restart();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._gameOverCut = false;
|
||||
this.playSfx?.('deconstruct'); // the refused-spend voice (the step-backwards)
|
||||
this.consoleToast?.('SIGNAL LOST — COULD NOT RESUME THE SAVE', { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) });
|
||||
}
|
||||
}
|
||||
|
||||
/** The sub-bar's anchor: the MENU button's center x + width, and the
|
||||
* deck's TOP EDGE as the resting line — the sub-bar rests on the bottom
|
||||
* bar (the deck), not on the button (the buttons are inset below the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import Phaser from '../vendor/phaser.js';
|
||||
import { toColor, toCss } from '../utils/Color.js';
|
||||
import { fontStack, themeColor } from '../utils/Theme.js';
|
||||
import { MenuButton } from './MenuButton.js';
|
||||
|
||||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||
|
||||
/**
|
||||
* THE GAME OVER BEAT (js/scenes/GameScene.js → _playerWentDown): the player's
|
||||
* hull broke in combat and the run is over — a dim over the whole frame, a
|
||||
* large semi-transparent "GAME OVER" dead centre, and two buttons below it:
|
||||
*
|
||||
* RETURN TO MAIN MENU — back to MenuScene (the same door as the sub-bar's
|
||||
* last button, GameScene.returnToMenu);
|
||||
* CONTINUE — resume the NEWEST save in the bank, exactly the
|
||||
* menu's Continue (SaveManager.latest → prepareLoad);
|
||||
* dimmed when the bank is empty.
|
||||
*
|
||||
* The world stays FROZEN behind it (GameScene adds its flag to the update()
|
||||
* freeze, so the ship stays down and the hostiles hold their breath).
|
||||
*/
|
||||
export class GameOverOverlay {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene the GameScene (screen-fixed UI, scrolling camera)
|
||||
*/
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
const w = scene.scale.width;
|
||||
const h = scene.scale.height;
|
||||
|
||||
// The dim over the whole frame — the world behind goes quiet. Clicking
|
||||
// it does nothing (the two buttons are the only doors).
|
||||
this.scrim = scene.add
|
||||
.rectangle(w / 2, h / 2, w, h, 0x02040a, 0)
|
||||
.setScrollFactor(0)
|
||||
.setInteractive({
|
||||
useHandCursor: false,
|
||||
hitArea: new Phaser.Geom.Rectangle(0, 0, w, h),
|
||||
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
|
||||
});
|
||||
|
||||
// THE HEADLINE — large, semi-transparent, dead centre (slightly above
|
||||
// true centre so the button row reads as its feet).
|
||||
const fam = fontStack('header', FONT_FALLBACK);
|
||||
const size = Math.max(54, Math.round(h * 0.085));
|
||||
const titleCss = toCss(themeColor('neon2', 0xff2d6f));
|
||||
this.title = scene.add
|
||||
.text(w / 2, h / 2 - h * 0.07, 'GAME OVER', {
|
||||
fontFamily: fam,
|
||||
fontSize: `${size}px`,
|
||||
color: titleCss,
|
||||
letterSpacing: Math.max(4, Math.round(size * 0.12)),
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setScrollFactor(0)
|
||||
.setAlpha(0)
|
||||
.setInteractive({
|
||||
useHandCursor: false,
|
||||
hitArea: new Phaser.Geom.Rectangle(0, 0, 10, 10),
|
||||
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
|
||||
});
|
||||
|
||||
// THE DOORS — RETURN TO MAIN MENU (the safe way out) and CONTINUE
|
||||
// (resume the newest save). Built screen-fixed (the game camera moves).
|
||||
this.menuBtn = new MenuButton(
|
||||
scene,
|
||||
0,
|
||||
0,
|
||||
'Return to Main Menu',
|
||||
() => this.scene.returnToMenu(),
|
||||
{ screenFixed: true, fontSize: 14, upper: true, paddingX: 26, paddingY: 12 },
|
||||
);
|
||||
this.continueBtn = new MenuButton(
|
||||
scene,
|
||||
0,
|
||||
0,
|
||||
'Continue',
|
||||
() => this.scene.continueFromGameOver(),
|
||||
{ screenFixed: true, fontSize: 14, upper: true, paddingX: 26, paddingY: 12 },
|
||||
);
|
||||
this.menuBtn.setAlpha(0);
|
||||
this.continueBtn.setAlpha(0);
|
||||
|
||||
this._offResize = () => this._layout();
|
||||
scene.scale.on('resize', this._offResize);
|
||||
this._layout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the title + the two buttons for the current frame size: the
|
||||
* headline above centre, the button row centred below it.
|
||||
*/
|
||||
_layout() {
|
||||
const w = this.scene.scale.width;
|
||||
const h = this.scene.scale.height;
|
||||
this.scrim.setSize(w, h);
|
||||
this.scrim.hitArea.setBounds(0, 0, w, h);
|
||||
this.title.setPosition(w / 2, h / 2 - h * 0.07);
|
||||
const gap = 30;
|
||||
const mw = this.menuBtn.style.width;
|
||||
const cw = this.continueBtn.style.width;
|
||||
const bh = this.menuBtn.style.height;
|
||||
const rowW = mw + gap + cw;
|
||||
const y = h / 2 + h * 0.07 + bh * 0.5;
|
||||
const menuX = w / 2 - rowW / 2 + mw / 2;
|
||||
const contX = w / 2 + rowW / 2 - cw / 2;
|
||||
this.menuBtn.setPosition(menuX, y);
|
||||
this.continueBtn.setPosition(contX, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise the beat: dim up, headline fade, buttons flicker in.
|
||||
* @param {object} [o] { canContinue?: boolean — the bank has a save }
|
||||
*/
|
||||
show(o = {}) {
|
||||
this.continueBtn.setDisabled(o.canContinue === false);
|
||||
this.scene.tweens.add({ targets: this.scrim, alpha: 0.62, duration: 320, ease: 'Sine.easeOut' });
|
||||
this.scene.tweens.add({
|
||||
targets: this.title,
|
||||
alpha: 0.82, // the semi-transparent headline (the user's ask — a ghost, not a slam)
|
||||
duration: 460,
|
||||
ease: 'Sine.easeOut',
|
||||
delay: 120,
|
||||
});
|
||||
this.scene.tweens.add({
|
||||
targets: [this.menuBtn, this.continueBtn],
|
||||
alpha: 1,
|
||||
duration: 180,
|
||||
ease: 'Sine.easeOut',
|
||||
delay: 320,
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.scene.scale.off('resize', this._offResize);
|
||||
this.scrim.destroy();
|
||||
this.title.destroy();
|
||||
this.menuBtn.destroy();
|
||||
this.continueBtn.destroy();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue