345 lines
12 KiB
JavaScript
345 lines
12 KiB
JavaScript
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.fitCover(v);
|
|
v.setVolume(this.clipVolume());
|
|
|
|
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
|
|
// Safety valve: a stalled element (neither complete nor error) still
|
|
// gets the player to the deck.
|
|
this.time.delayedCall(8000, () => {
|
|
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.fitCover(s);
|
|
s.setVolume(this.clipVolume());
|
|
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-scale: the clip fills the screen, cropping whatever overflows. */
|
|
fitCover(v) {
|
|
const vw = (v.video && v.video.width) || v.width || 864;
|
|
const vh = (v.video && v.video.height) || v.height || 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.setDisplaySize(vw * s, vh * 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;
|
|
}
|
|
}
|