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:
Brian Fertig 2026-09-27 18:44:19 -06:00
parent fce8a19202
commit 34613b254e
4 changed files with 240 additions and 2 deletions

Binary file not shown.

View File

@ -481,7 +481,7 @@
}, },
"playerShip": { "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, "hull": 100,
"shields": 40, "shields": 40,
"shieldRegenDelay": 4, "shieldRegenDelay": 4,

View File

@ -46,6 +46,7 @@ import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
import { scoutGood } from '../economy/TradeScout.js'; import { scoutGood } from '../economy/TradeScout.js';
import * as Stock from '../economy/Stock.js'; import * as Stock from '../economy/Stock.js';
import { MenuSubBar } from '../ui/MenuSubBar.js'; import { MenuSubBar } from '../ui/MenuSubBar.js';
import { GameOverOverlay } from '../ui/GameOverOverlay.js';
import { SavePanel } from '../ui/SavePanel.js'; import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js'; import { SaveManager } from '../save/SaveManager.js';
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.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.shieldHit > 0) this.combatHud.flash('shield');
if (a.hullHit > 0) this.combatHud.flash('hull'); 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 // THE LEVEL-UP MARQUEE (js/ui/LevelUpMarquee.js) — the big beat on a
// combat level landing: the world freezes and dims, the old level // combat level landing: the world freezes and dims, the old level
@ -1667,6 +1673,9 @@ export class GameScene extends Phaser.Scene {
if (this.combat) { if (this.combat) {
this.combat.skillState = this.skillState; this.combat.skillState = this.skillState;
this.combat.refreshSkillPassives(); 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 // 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. // (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 // throttle-locked, the ship runs straight into the line and stops
// there instead of resting on it). // there instead of resting on it).
this.input.on('pointerdown', (pointer) => { 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 // 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 // (two quick presses) skips the rest of the clip (the same window as
// SurfaceScene's landing/takeoff skip); a single press is swallowed. // 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.levelMarquee?.active === true ||
!!this._jumping || !!this._jumping ||
this.classWindow?.isOpen === true || 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 // 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 // for its own freeze): while ANY freeze holds, the action is parked
// dead EVERY frame — the ship (target + throttle cleared, velocity // 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 // armor is re-rolled from base, so the pool grows (and the bar's orbs show
// it) the moment the point lands. // it) the moment the point lands.
this.combat?.refreshSkillPassives?.(); this.combat?.refreshSkillPassives?.();
this._syncDeathFlowRespawn(); // a ranked skill may override the death flow
this.playSfx?.(spec.sfx.spend); this.playSfx?.(spec.sfx.spend);
const def = this.skillState.defOf(res.id); const def = this.skillState.defOf(res.id);
this.consoleToast( this.consoleToast(
@ -8240,6 +8255,7 @@ export class GameScene extends Phaser.Scene {
this.registry.set('playerSkills', this.skillState); this.registry.set('playerSkills', this.skillState);
// THE PASSIVE SEAM, run backwards: unspent ranks stop counting now. // THE PASSIVE SEAM, run backwards: unspent ranks stop counting now.
this.combat?.refreshSkillPassives?.(); this.combat?.refreshSkillPassives?.();
this._syncDeathFlowRespawn(); // a refunded skill may end the override
this.playSfx?.(spec.sfx.refund ?? spec.sfx.denied); this.playSfx?.(spec.sfx.refund ?? spec.sfx.denied);
this.consoleToast( this.consoleToast(
fillSkillTemplate(spec.resetToast, { fillSkillTemplate(spec.resetToast, {
@ -10030,6 +10046,87 @@ export class GameScene extends Phaser.Scene {
this.scene.start('MenuScene'); 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 /** 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 * 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 * bar (the deck), not on the button (the buttons are inset below the

141
js/ui/GameOverOverlay.js Normal file
View File

@ -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();
}
}