import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { toCss } from '../utils/Color.js'; import { fontStack, themeColor } from '../utils/Theme.js'; import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; import { playSfxOn } from '../utils/Sfx.js'; import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js'; import { ActionBar } from '../ui/ActionBar.js'; import { MenuSubBar } from '../ui/MenuSubBar.js'; import { SavePanel } from '../ui/SavePanel.js'; import { SaveManager } from '../save/SaveManager.js'; import { BuildWindow } from '../ui/BuildWindow.js'; import { buildDefs } from '../research/ResearchModel.js'; /** * The double-click window (ms): two presses this close together during a * one-shot flight clip (the landing or the takeoff) skip the rest of the * clip (onPointerDown → skipFlightClip). On the deck itself there is no * clip in flight, so the skip is a no-op there. */ const DOUBLE_CLICK_MS = 350; /** * SURFACE — the planet's surface, started ON TOP of the sleeping * GameScene (GameScene.startLanding → launch('SurfaceScene') + sleep): * * 1. LANDING — the full-screen one-shot clip (data/landing.json → * videos[planets.png frame] → `land`), cover-scaled over an opaque * plate. No UI during the flight down. A double-click (two quick * presses) skips the rest of the clip — the deck comes up now. * 2. SURFACE — the clip is swapped for the looping world clip (`surface`), * and the command deck appears: the GameScene deck with Research * replaced by SHOP (swaps the loop for the world's `shop` clip — * e.g. terran-shop-01.mp4 — press again to swap back) and TAKE OFF * left of MENU (data/actionbar.json → surface.buttons). Menu keeps the GameScene's * save sub-bar — Save/Load operate on the paused GameScene's state * (SavePanel `stateScene`). While the clip loops, the planet's name * decodes in at the upper-left (Ethnocentric, the menu title's shade, * black-stroked) with its type + tether level beneath (Centauri) — * see buildHud(); the HUD dies with the loop clip. * * 3. TAKE OFF — the world's one-shot take-off clip (`takeoff`) plays, * then this scene stops — the paused GameScene resumes exactly where * it was (ship, tethers, discovery, standing, everything). A world * with no `takeoff` clip leaves immediately. A double-click skips * the rest of the clip — the flight world wakes now. * * Video selection is by the planet's planets.png SHEET FRAME (the same * index its sprite uses — frames 0..2 the terran worlds, 3..5 the gas * giants, per data/planets.json → frames), so a planet always lands with * the clip matching the art it is drawn with. Missing clips fall back to * the existing stubs in data/landing.json; a broken/missing file skips * straight to the surface instead of stranding the player. SHOP swaps the * surface loop for the frame's `shop` clip (null on worlds without one — * a console note instead). */ export class SurfaceScene extends Phaser.Scene { constructor() { super('SurfaceScene'); } init(data = {}) { this.planetFrame = Number(data.frame ?? 0); // planets.png frame (video key) this.planetName = String(data.name ?? ''); this.planetType = String(data.type ?? ''); // e.g. "Rocky World" (planets.typeLabels) this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0))); this.phase = 'landing'; // 'landing' → 'surface' this.lastClickT = 0; // last pointerdown (performance.now(), ms) — the double-click skip this.musicSpec = 'music.frames.' + this.planetFrame; // the surface loop (data/music.json) this.gameScene = null; // the paused GameScene beneath us (save state lives there) this.backdrop = null; this.landVideo = null; this.surfaceVideo = null; this.hudName = null; // upper-left: the planet's name (decodes in) this.hudLine = null; // upper-left: type + tether level (decodes in) this.hudLines = null; // the HUD's decode timeline (updateHud) this.hudT0 = null; this.actionBar = null; this.menuSubBar = null; this.savePanel = null; this.saveManager = null; this.landKey = null; this.surfaceKey = null; this.takeoffKey = null; this.takeoffVideo = null; this.takeoffDone = false; // finishTakeoff idempotency ('complete' + a guard can both fire) this.shopKey = null; this.shopVideo = null; // the SHOP clip (created on first press, then paused/resumed) this.shopMode = false; // true while the shop clip is on screen (SHOP toggles it) this.noteG = null; // console-note glyphs (consoleNote) this.buildWindow = null; // the BUILD console (deck slot) — a view over gameScene.buildState } /** Pick this world's clips (data/landing.json) and queue them. */ preload() { const entry = (config.get('landing.videos') ?? [])[this.planetFrame] ?? {}; this.landKey = `__surf_land_${this.planetFrame}`; this.surfaceKey = `__surf_loop_${this.planetFrame}`; this.takeoffKey = `__surf_takeoff_${this.planetFrame}`; this.shopKey = `__surf_shop_${this.planetFrame}`; this.landUrl = this.resolveUrl(entry.land ?? null); this.surfaceUrl = this.resolveUrl(entry.surface ?? null); this.takeoffUrl = this.resolveUrl(entry.takeoff ?? null); this.shopUrl = this.resolveUrl(entry.shop ?? null); if (this.landUrl) this.load.video(this.landKey, this.landUrl); if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl); if (this.takeoffUrl) this.load.video(this.takeoffKey, this.takeoffUrl); if (this.shopUrl) this.load.video(this.shopKey, this.shopUrl); // The surface's music loop (data/music.json → frames[planets.png frame]) // — the same key as the videos, so a world hums the track matching the // art it is drawn with. A frame with no entry is silent. if (config.get('music.enabled', true)) { const track = config.get(this.musicSpec); if (typeof track === 'string') { this.load.audio(musicKey(this.musicSpec), track); } } } create() { // The run's state (ship, tethers, discovery, …) stays parked in the // paused GameScene — the save panel reads/writes it through there. this.gameScene = this.scene.get('GameScene') ?? null; const W = this.scale.width; const H = this.scale.height; // Launched over the flight world — the first thing painted must be an // opaque full-screen plate, or GameScene bleeds through the seams. this.backdrop = this.add.rectangle(W / 2, H / 2, W, H, 0x04060d).setScrollFactor(0).setDepth(0); // The surface hum — on from the moment the landing sequence starts // (landing clip, or straight to the deck when there is no clip) until // the takeoff clip ends (finishTakeoff) or the scene leaves otherwise. this.setSurfaceMusic(true); // v4 (Giedi) quirk: scene transitions stop us via sys.shutdown and emit // the 'shutdown' EVENT — they never call our shutdown() method — so the // hum's stop is also hooked on the event (Return to Menu path). this.events.once('shutdown', () => this.setSurfaceMusic(false)); // Pointer input — bound once per visit from the LANDING stage on // (the scene's input plugin drops its listeners on shutdown, v4, so // this is fresh every launch; buildDeck no longer re-binds it): // a double-click skips the in-flight clip (skipFlightClip), and on // the deck the handler owns the sub-bar / outside-click behavior. this.input.on('pointerdown', (pointer) => this.onPointerDown(pointer)); if (this.hasVideo(this.landKey)) { this.playLanding(); } else { this.startSurface(); // no landing clip for this frame — straight to the deck } } // ------------------------------------------------------------------ // Stage 1 — the one-shot landing clip // ------------------------------------------------------------------ playLanding() { // v4: add.video(x, y, key) — the key is the LAST argument (it loads the // cached clip and attaches the