diff --git a/assets/videos/toxic-takeoff-01.mp4 b/assets/videos/toxic-takeoff-01.mp4 new file mode 100644 index 0000000..5afe936 Binary files /dev/null and b/assets/videos/toxic-takeoff-01.mp4 differ diff --git a/data/combat.json b/data/combat.json index d154d37..0557831 100644 --- a/data/combat.json +++ b/data/combat.json @@ -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, diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 8a1a117..c564344 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 diff --git a/js/ui/GameOverOverlay.js b/js/ui/GameOverOverlay.js new file mode 100644 index 0000000..a1ff86e --- /dev/null +++ b/js/ui/GameOverOverlay.js @@ -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(); + } +}