orbit/js/scenes/SurfaceScene.js

586 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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';
/**
* 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.
* 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 (a seam for now) 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.
*
* 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.
*/
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.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)
}
/** 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.landUrl = this.resolveUrl(entry.land ?? null);
this.surfaceUrl = this.resolveUrl(entry.surface ?? null);
this.takeoffUrl = this.resolveUrl(entry.takeoff ?? 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);
// 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));
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 <video> element).
const v = this.add.video(0, 0, this.landKey).setOrigin(0.5).setDepth(10);
this.attachClip(v);
this.landVideo = v;
// Fire it straight away. If the browser's autoplay policy locks it
// (no gesture yet), v4's Video retries internally every ~500ms until
// it's allowed — the click that sent the ship down counts, so this
// normally starts on the first attempt. 'complete'/'error' both land
// the player on the deck either way.
v.play();
v.on('complete', () => this.startSurface()); // played once → surface
v.on('error', () => this.startSurface()); // broken clip → don't strand the player
const el = v.video;
// The clip must play to its END before the surface stage (loop clip +
// deck), so no fixed "8s" valve — the current clips are ~15s and a
// constant would cut the landing in half. Two targeted guards instead:
// 1. STALLED — a clip that never starts moving (autoplay locked and
// never unlocked, decode failure, …) must not hold the deck: after
// a grace period with no playback progress, advance anyway.
// 2. CAP — 'ended' is expected by duration + margin; if the element
// goes silent before it, advance anyway.
const playing = () => !!el && (el.currentTime > 0.1 || !el.paused);
this.time.delayedCall(5000, () => {
if (this.landVideo === v && this.phase === 'landing' && !playing()) {
this.startSurface();
}
});
const durS = Number(el && el.duration);
if (Number.isFinite(durS) && durS > 0) {
this.time.delayedCall(durS * 1000 + 5000, () => {
if (this.landVideo === v && this.phase === 'landing') this.startSurface();
});
}
}
// ------------------------------------------------------------------
// Stage 2 — the looping surface clip + the deck
// ------------------------------------------------------------------
startSurface() {
if (this.phase === 'surface') return;
this.phase = 'surface';
if (this.landVideo) {
this.destroyVideo(this.landVideo);
this.landVideo = null;
}
if (this.hasVideo(this.surfaceKey)) {
const s = this.add.video(0, 0, this.surfaceKey).setOrigin(0.5).setDepth(10);
this.attachClip(s);
s.setLoop(true);
s.play();
this.surfaceVideo = s;
// A decode failure on the loop clip keeps the plate — the deck
// still works, the world just stays dark.
this.buildHud(); // the name + type/tether — only while the clip loops
}
this.buildDeck();
}
// ------------------------------------------------------------------
// HUD (upper-left) — the planet's name + type + tether level
// ------------------------------------------------------------------
/**
* The upper-left HUD, built with the looping surface clip and alive
* exactly as long as that clip runs (destroyHud on take-off/shutdown):
*
* ESHKAELURA ← the planet's name — Ethnocentric
* Home World, Tether Level 1 ← the body face (Centauri)
*
* The name takes the SAME shade the menu's game title uses
* (data/menu.json → colors.title, falling back to the theme's ink) with
* a black stroke behind it, so it holds over any clip regardless of
* what the video shows beneath it. Both lines decode in with the
* shared scramble (js/utils/Decode.js) — the name first, the line
* beneath it after (the dossier's pacing, GameScene.createSystemHud).
*/
buildHud() {
const hud = config.section('landing.hud', {});
const X = hud.marginX ?? 24;
const Y = hud.marginY ?? 20;
const name = String(this.planetName ?? '').toUpperCase();
this.hudName = this.add
.text(X, Y, '', {
fontFamily: fontStack('header'), // Ethnocentric — the theme's header face
fontSize: `${hud.nameFontSize ?? 32}px`,
color: toCss(config.get('menu.colors.title') ?? themeColor('ink', 0xeaf6ff)),
stroke: toCss(hud.nameStroke ?? '#000000'),
strokeThickness: hud.nameStrokeWidth ?? 4,
letterSpacing: hud.nameLetterSpacing ?? 2,
})
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen
.setDepth(60); // above the deck (50) / sub-bar — it never overlaps them
const parts = [];
if (this.planetType) parts.push(this.planetType);
parts.push(`Tether Level ${this.tetherLevel}`);
const line = parts.join(', ');
this.hudLine = this.add
.text(X, Y + this.hudName.height + (hud.lineGap ?? 10), '', {
fontFamily: fontStack('body'), // Centauri — the theme's body face
fontSize: `${hud.lineFontSize ?? 16}px`,
color: toCss(hud.lineColor ?? themeColor('dim', 0x7d92c4)),
stroke: toCss(hud.lineStroke ?? '#000000'),
strokeThickness: hud.lineStrokeWidth ?? 2,
letterSpacing: hud.lineLetterSpacing ?? 1,
})
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen
.setDepth(60);
// The decode timeline — updateHud() drives it from the scene's
// update() (the engine loop time, same base as the dossier's).
this.hudLines = [
{ text: this.hudName, value: name, delay: 140, dur: decodeDur(name.length), settled: false },
{ text: this.hudLine, value: line, delay: 420, dur: decodeDur(line.length), settled: false },
];
this.hudT0 = null; // anchored on the first update frame (the clock is stale in create())
}
/** Per-frame: the HUD's decode (driven from update() with the loop time). */
updateHud(time) {
if (!this.hudLines) return;
if (this.hudT0 === null) {
this.hudT0 = time;
for (const ln of this.hudLines) ln.dec = new ScrambleDecode(ln.value, time + ln.delay, ln.dur);
}
let done = true;
for (const ln of this.hudLines) {
if (ln.settled) continue;
if (!ln.dec.started(time)) {
done = false;
continue;
}
ln.text.setText(ln.dec.display(time));
if (ln.dec.finished(time)) ln.settled = true;
else done = false;
}
if (done) this.hudLines = null; // the text holds — stop the per-frame churn
}
/** The HUD belongs to the surface stage — the take-off clip owns the screen. */
destroyHud() {
this.hudName?.destroy();
this.hudLine?.destroy();
this.hudName = null;
this.hudLine = null;
this.hudLines = null;
this.hudT0 = null;
}
buildDeck() {
// The surface deck: same bar, different slots — Shop for Research,
// Take Off left of Menu (data/actionbar.json → surface.buttons).
const deckButtons = config.get('actionbar.surface.buttons');
this.actionBar = new ActionBar(this, {
onAction: (id) => this.deckAction(id),
buttons: Array.isArray(deckButtons) ? deckButtons : undefined,
});
// The MENU button keeps the GameScene's save system. The run's state
// is in the paused GameScene beneath us — SavePanel captures from it
// (stateScene) and a confirmed load stages the restore there.
this.saveManager = new SaveManager(); // the localStorage bank
this.menuSubBar = new MenuSubBar(this, {
anchor: this.menuAnchor(),
onAction: (id) => this.subBarAction(id),
});
this.savePanel = new SavePanel(this, {
// Load confirmed: the restore is staged — hand back to the menu
// (its New Game is the "start" the staged restore rides on).
onLoadComplete: () => this.returnToMenu(),
stateScene: this.gameScene ?? this,
});
// ESC — topmost open thing first (mirror of GameScene.escAction).
this.input.keyboard?.on('keydown-ESC', () => this.escAction());
// Clicks: outside the sub-bar, close it. (No click-to-fly on the
// surface — the world is a backdrop.)
this.input.on('pointerdown', (pointer) => this.onPointerDown(pointer));
}
// ------------------------------------------------------------------
// Deck behavior
// ------------------------------------------------------------------
/**
* The deck's button presses (ActionBar → onAction). SHOP / BUILD /
* SHIP are seams for the surface economy (data/builds.json plugs in
* later); TAKE OFF goes back to the ship; MENU is the save door.
*/
deckAction(id) {
if (id === 'menu') {
this.menuAction();
return;
}
if (id === 'takeoff') {
this.takeOff();
return;
}
console.info(`[orbit] surface deck: ${id}${this.planetName}`);
}
/** The MENU button: fold the sub-bar up / fold it back down. */
menuAction() {
if (config.get('save.subBar.enabled', true) !== true) return;
if (this.savePanel && this.savePanel.isOpen) {
this.savePanel.close();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) {
this.menuSubBar.close();
return;
}
// Load Game is live only when the bank holds at least one save.
this.menuSubBar.setDisabled('load', !this.saveManager.hasAny());
this.menuSubBar.open();
}
/** The sub-bar's button presses (MenuSubBar → onAction). */
subBarAction(id) {
if (id === 'save') {
this.savePanel.show('save');
return;
}
if (id === 'load') {
if (this.saveManager.hasAny()) this.savePanel.show('load');
return;
}
if (id === 'mainMenu') {
this.returnToMenu();
return;
}
}
/** ESC: confirm dialog → save pop-up → sub-bar. */
escAction() {
if (this.savePanel) {
if (this.savePanel.confirm.isOpen) {
this.savePanel.confirm.cancel();
return;
}
if (this.savePanel.isOpen) {
this.savePanel.close();
return;
}
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
}
/**
* TAKE OFF — leave the surface. The world's one-shot take-off clip
* (`takeoff`) plays first, then the scene stops and the sleeping
* GameScene wakes — the run resumes exactly where the ship left it.
* No clip for this world → leave immediately.
*/
takeOff() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
if (this.takeoffVideo) return; // the take-off clip is already in flight
if (this.hasVideo(this.takeoffKey)) {
this.playTakeoff();
return;
}
this.finishTakeoff();
}
/**
* Stage 3 — the one-shot take-off clip, then back to the flight world.
* Same shape as the landing stage (full-screen clip, no deck over it),
* and the same two guards: a clip that never starts, or goes silent
* past duration + margin, must not hold the run.
*/
playTakeoff() {
// The deck + loop clip + HUD belong to the surface stage — the clip
// owns the screen (same "no UI mid-flight" rule as the landing stage).
this.actionBar?.setVisible(false);
this.destroyHud();
if (this.surfaceVideo) {
this.destroyVideo(this.surfaceVideo); // the opaque clip hides it — free the decoder
this.surfaceVideo = null;
}
const v = this.add.video(0, 0, this.takeoffKey).setOrigin(0.5).setDepth(20);
this.attachClip(v);
this.takeoffVideo = v;
v.play();
v.on('complete', () => this.finishTakeoff()); // played once → back to the ship
v.on('error', () => this.finishTakeoff()); // broken clip → don't strand the player
const el = v.video;
const playing = () => !!el && (el.currentTime > 0.1 || !el.paused);
this.time.delayedCall(5000, () => {
if (this.takeoffVideo === v && !playing()) this.finishTakeoff();
});
const durS = Number(el && el.duration);
if (Number.isFinite(durS) && durS > 0) {
this.time.delayedCall(durS * 1000 + 5000, () => {
if (this.takeoffVideo === v) this.finishTakeoff();
});
}
}
/**
* The surface's music (data/music.json → frames[this.planetFrame]): a
* loop that runs from the start of the landing sequence to the end of
* the takeoff clip. One loop per world (the shared voice's isPlaying
* guard); no-ops when music is disabled or the frame has no track.
*/
setSurfaceMusic(on) {
if (on) playMusicOn(this, this.musicSpec);
else stopMusicOn(this, this.musicSpec);
}
/** Take-off clip done (or bailed out) — back to the flight world. */
finishTakeoff() {
if (this.takeoffDone) return;
this.takeoffDone = true;
this.setSurfaceMusic(false); // the takeoff clip ends — the hum ends with it
this.destroyVideo(this.takeoffVideo);
this.takeoffVideo = null;
this.scene.stop('SurfaceScene');
this.scene.wake('GameScene');
}
/** Return to the main menu (the sub-bar's last button). */
returnToMenu() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
this.scene.start('MenuScene');
}
onPointerDown(pointer) {
if (this.savePanel?.isOpen) return; // the modal scrim owns the click
if (this.menuSubBar?.isOpen) {
if (this.menuSubBar.contains(pointer.x, pointer.y)) return;
this.menuSubBar.close();
return;
}
if (this.actionBar?.contains(pointer.x, pointer.y)) return;
// A click on the surface itself — the seam for surface interactions.
}
// ------------------------------------------------------------------
// Plumbing
// ------------------------------------------------------------------
/** The sub-bar's anchor: the MENU button's center + top edge. */
menuAnchor() {
const menuSlot = this.actionBar?.slots?.find((s) => s.id === 'menu');
if (menuSlot) {
return {
x: menuSlot.slot.x,
y: menuSlot.slot.y - this.actionBar.style.bh / 2,
w: this.actionBar.style.bw,
};
}
// Deck missing (dev): a virtual button at the bottom right.
return { x: this.scale.width - 96, y: this.scale.height - 58, w: 120 };
}
hasVideo(key) {
const c = this.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
/**
* Cover-fit a clip to the screen and keep it that way.
*
* v4 quirk: the video object's bookkeeping size (width/height, and even
* frame.realWidth) is a placeholder until the first presented frame
* lands — sizing against it (setDisplaySize divides by that width) ends
* up ~3.4× too big, because the frame swap preserves the SCALE, not the
* size. That's how the clips rendered heavily zoomed in. So:
* - fit now with the best known dimensions (a placeholder-free guess),
* - re-fit on 'created', which fires on the first presented frame and
* carries the clip's true (w, h) — after that, frame × scale is
* authoritative and stable for the clip's whole life.
*/
attachClip(v) {
v.setVolume(this.clipVolume());
this.fitCover(v);
v.on('created', (vv, w, h) => {
if (vv === v) this.fitCover(vv, w, h);
});
}
/** Cover-scale: the clip fills the screen, cropping whatever overflows. */
fitCover(v, iw = 0, ih = 0) {
const el = v.video;
const vw = iw || (el && (el.videoWidth || el.width)) || (v.frame && v.frame.realWidth) || 864;
const vh = ih || (el && (el.videoHeight || el.height)) || (v.frame && v.frame.realHeight) || 480;
const W = this.scale.width;
const H = this.scale.height;
const s = Math.max(W / vw, H / vh);
v.setPosition(W / 2, H / 2);
v.setScale(s);
}
clipVolume() {
return Math.max(0, Math.min(1, Number(config.get('landing.volume', 1))));
}
resolveUrl(file) {
if (!file) return null;
if (/^(https?:)?\/\//.test(file)) return file; // absolute — use as-is
const base = String(config.get('landing.videoBase', 'assets/videos/'));
return (base.endsWith('/') ? base : `${base}/`) + file;
}
destroyVideo(v) {
if (!v) return;
try {
v.off();
v.stop(false);
v.destroy();
} catch {
/* already gone */
}
}
update(time, delta) {
this.time.update(time, delta);
this.tweens.update();
this.actionBar?.update(time, delta);
this.menuSubBar?.update(time, delta);
this.savePanel?.update(time);
this.updateHud(time); // the upper-left name/type decode (surface stage only)
}
shutdown() {
this.setSurfaceMusic(false); // the hum can't outlive the scene
this.destroyVideo(this.landVideo);
this.destroyVideo(this.surfaceVideo);
this.destroyVideo(this.takeoffVideo);
this.landVideo = null;
this.surfaceVideo = null;
this.takeoffVideo = null;
this.destroyHud();
this.actionBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();
this.actionBar = null;
this.menuSubBar = null;
this.savePanel = null;
this.backdrop?.destroy();
this.backdrop = null;
}
}