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": {
|
"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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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