orbit/js/scenes/SurfaceScene.js

382 lines
14 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 { 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`).
*
* TAKE OFF stops this scene — the paused GameScene resumes exactly
* where it was (ship, tethers, discovery, standing, everything).
*
* 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.phase = 'landing'; // 'landing' → 'surface'
this.gameScene = null; // the paused GameScene beneath us (save state lives there)
this.backdrop = null;
this.landVideo = null;
this.surfaceVideo = null;
this.actionBar = null;
this.menuSubBar = null;
this.savePanel = null;
this.saveManager = null;
this.landKey = null;
this.surfaceKey = null;
}
/** 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.landUrl = this.resolveUrl(entry.land ?? null);
this.surfaceUrl = this.resolveUrl(entry.surface ?? null);
if (this.landUrl) this.load.video(this.landKey, this.landUrl);
if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl);
}
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);
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.buildDeck();
}
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: stop this scene (the shutdown hook
* cleans the deck + clips) and wake the sleeping GameScene — the run
* resumes exactly where the ship left it.
*/
takeOff() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
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);
}
shutdown() {
this.destroyVideo(this.landVideo);
this.destroyVideo(this.surfaceVideo);
this.landVideo = null;
this.surfaceVideo = null;
this.actionBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();
this.actionBar = null;
this.menuSubBar = null;
this.savePanel = null;
this.backdrop?.destroy();
this.backdrop = null;
}
}