orbit/js/scenes/GameScene.js

414 lines
16 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 { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Discovery } from '../galaxy/Discovery.js';
import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js';
import { Starfield } from '../visuals/Starfield.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
const BODY_FONT = () => fontStack('body', FONT_FALLBACK);
/**
* The game world (v0.3: the current system — the player's home world at the
* origin plus the system's other worlds scattered around it, in open space).
* Click anywhere to fly there.
*
* Discovery: come within discovery distance (data/game.json) of a world's
* edge and it is DISCOVERED (state in this.discovery). Discovered worlds
* that are off-screen get a themed compass arrow on the screen edge
* (this.compass) pointing the way back — so the player always has a
* reference to found worlds while exploring the rest of the system.
*/
export class GameScene extends Phaser.Scene {
constructor() {
super({ key: 'GameScene' });
}
preload() {
// The planet spritesheet: frameWidth×frameHeight frames; the first
// frames are Terran worlds (see data/planets.json → frames).
this.load.spritesheet(
Planet.TEXTURE_KEY,
config.get('planets.texture', 'assets/images/planets.png'),
{
frameWidth: config.get('planets.frameWidth', 1024),
frameHeight: config.get('planets.frameHeight', 1024),
},
);
}
create() {
// Camera: smoothly follows the ship (updateCamera below). This motion
// is what drives the parallax starfield — ship flies, view trails.
this.cameraFollowShip = config.get('game.camera.followShip', true);
this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s
// We are, after all, in a system. Establish the galaxy (shared
// registry), the current system record, and its lazy contents — the
// dossier below and the world's other planets both read them.
this.ensureGalaxy();
this.systemRecord = this.galaxy.currentSystem();
this.systemContent = this.galaxy.ensureContent(this.systemRecord.id);
this.createSystemHud();
// The home planet — the player's Terran world, always present in the
// system they start in. It sits at the world origin. Which Terran face
// it shows is a seed-deterministic pick from the terran pool, so the
// same galaxy always yields the same home world.
const homeName = config.get('planets.homePlanet', 'terran');
const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home');
this.planet = new Planet(this, 0, 0, Planet.frameFor(homeName, homeRng), homeName);
this.planet.setDepth(5); // above the starfield (depths 02), below the ship (10)
// The rest of the solar system — the generated worlds, placed by the
// generator (data/planets.json → solarSystem) in a band around the home
// world. Same solid-disc rules as home: fly close, never through.
this.systemPlanets = [];
if (config.get('planets.solarSystem.enabled', true)) {
for (const rec of this.systemContent.planets ?? []) {
if (typeof rec.x !== 'number' || typeof rec.y !== 'number') continue;
const kind = rec.class || 'rocky';
const frame = Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name));
const tint = config.get(`planets.classTint.${kind}`);
const p = new Planet(this, rec.x, rec.y, frame, kind, {
scale: rec.scale ?? 1,
tint: tint === undefined ? undefined : toColor(tint),
});
p.setDepth(5);
p.discoveryId = rec.name; // unique within the system
p.discoveryName = rec.name;
this.systemPlanets.push(p);
}
}
this.solidPlanets = [this.planet, ...this.systemPlanets];
// The ship — a short hop (~150 px, edge-to-edge) from the home world's
// rim, in a seed-derived direction: same galaxy ⇒ same start.
this.ship = new Ship(this, 0, 0);
this.ship.setDepth(10);
const spawn = this.planet.edgePoint(
Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
config.get('planets.spawnDistanceFromEdge', 150),
this.ship.radius,
);
this.ship.setPosition(spawn.x, spawn.y);
// Center the camera on the ship from the very first frame.
this.cameras.main.setScroll(
this.ship.x - this.scale.width / 2,
this.ship.y - this.scale.height / 2,
);
// Background (stars are placed around the current camera view).
this.starfield = new Starfield(this);
this.starfield.create();
// Discovery: which objects the player has found (within discovery
// distance of an edge — data/game.json), tracked per system. Kept in
// the shared registry so it survives scene restarts; serializable for
// saves later (Discovery.toJSON). The compass turns the discovered set
// into screen-edge arrows for the objects currently out of sight.
this.discovery = this.registry.get('discovery') ?? null;
if (!this.discovery) {
this.discovery = new Discovery(config.get('game.discovery.distance', 540));
this.registry.set('discovery', this.discovery);
}
this.compass = new DiscoveryCompass(this);
// The command deck — the cyberpunk action bar across the bottom of the
// screen (config: data/actionbar.json). Six evenly spaced slots:
// Research, Build, Ship, two reserved, Menu. The slots are the seams
// for the player's loop — research (time-based, one at a time, see
// data/research.json) and building (credits + minerals, see
// data/builds.json) — whose behavior and panels come next.
this.actionBar =
config.get('actionbar.enabled', true) === true
? new ActionBar(this, {
onAction: (id) => {
// TODO(command deck): 'research' → research panel, 'build' → build
// panel, 'ship' → ship screen, 'menu' → main menu.
console.info(`[orbit] command deck: ${id}`);
},
})
: null;
// Hint (pinned just ABOVE the command deck, not under it)
const deckRoom = this.actionBar
? config.get('actionbar.height', 92) + config.get('actionbar.margin.bottom', 12)
: 0;
this.hint = this.add
.text(this.scale.width / 2, this.scale.height - deckRoom - (deckRoom ? 20 : 26), config.get('game.hintText', ''), {
fontFamily: BODY_FONT(),
fontSize: '14px',
color: '#54608a',
letterSpacing: 1,
})
.setOrigin(0.5)
.setScrollFactor(0); // UI: pinned to the screen, not the world
// Input: click = fly there (a click ON the command deck is deck
// business, not flight). A click inside a planet clamps to that
// planet's keep-out rim — the ship can stop at the clearance, never
// inside. (Worlds don't overlap, so sequential clamping is exact.)
this.input.on('pointerdown', (pointer) => {
if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
let aim = { x: pointer.worldX, y: pointer.worldY };
for (const p of this.solidPlanets) {
aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
}
this.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
});
}
/**
* The galaxy comes from the shared registry (built by the menu from the
* chosen seed). Dev boots that skip the menu (dev/test-game.html) get a
* fresh dev galaxy so the scene always works standalone.
*/
ensureGalaxy() {
this.galaxy = this.registry.get('galaxy') ?? null;
if (!this.galaxy) {
const seed = Rng.randomSeedString(8);
this.galaxy = Galaxy.create(seed);
this.registry.set('galaxy', this.galaxy);
this.registry.set('seed', seed);
console.warn(`[orbit] no galaxy in the registry — generated a dev galaxy (seed "${seed}")`);
}
}
/**
* Top-left HUD: the current system's dossier — name, identity, and what
*'s ALREADY THERE: colonies, mining stations, cloud bases, stations adrift
* in open space (or "charted · unclaimed" when nobody's settled here).
* Formatted by the pure SystemReport helper; this method only renders.
*
* The system's CONTENTS were ensured in create() (ensureGalaxy +
* ensureContent) — the first touch of the lazy level-2 generation.
* (Roster/positions were fixed at the menu's New Game click; this is
* just the "build the room" part.)
*/
createSystemHud() {
const report = formatSystemReport(this.systemContent);
const fam = BODY_FONT();
const famHeader = HEADER_FONT();
const current = this.systemRecord;
let y = 14;
const line = (text, style) => {
this.add
.text(16, y, text, style)
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30);
y += 20 + (style.fontSize === '17px' ? 6 : 0);
};
line(report.title, {
fontFamily: famHeader,
fontSize: '17px',
// v4 quirk: text colors must be CSS strings (see toCss, utils/Color.js).
color: toCss(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'),
letterSpacing: 2,
});
line(report.subtitle, { fontFamily: fam, fontSize: '12px', color: '#8fa0c9', letterSpacing: 1 });
y += 2;
for (const s of report.settlements) {
line(s.text, { fontFamily: fam, fontSize: '13px', color: toCss(s.color, '#8fa0c9') });
}
line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
}
update(_time, delta) {
// Phaser v4 (Giedi): the engine does not step the scene's TimeClock or
// TweenManager — drive them here or delayedCall/addEvent/tweens never
// fire. (Same quirk as MenuScene.update; verified Sept 2026.)
this.time.update(_time, delta);
this.tweens.update();
this.ship.update(_time, delta);
// Every world in the system is solid: the ship may come within the
// clearance in data/planets.json of a rim, but never closer (or
// through it).
for (const p of this.solidPlanets) {
p.constrainShip(this.ship, this.ship.radius);
}
this.updateCamera(delta);
this.starfield.update(); // after the camera, so it sees this frame's motion
this.actionBar?.update(_time, delta); // the deck's living details
this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
}
/**
* The camera chases the ship with a frame-rate-independent ease:
* scroll += (shipCenter - scroll) * (1 - e^(-rate·dt))
* While the ship flies, the camera trails it and the starfield streams
* past in the opposite direction (real parallax). When the ship stops,
* the camera keeps easing until it is centered on the ship again — a
* slow recenter (≈1.5 s at followRate 3) with the stars parallaxing
* along for the ride.
*/
updateCamera(delta) {
if (!this.cameraFollowShip || !this.ship) return;
const rate = this.cameraFollowRate;
if (!(rate > 0)) return;
const cam = this.cameras.main;
const k = 1 - Math.exp(-rate * (Math.min(delta, 64) / 1000));
cam.setScroll(
Phaser.Math.Linear(cam.scrollX, this.ship.x - this.scale.width / 2, k),
Phaser.Math.Linear(cam.scrollY, this.ship.y - this.scale.height / 2, k),
);
}
/**
* Discovery, every frame (a handful of distance tests — cheap):
* 1. Ship within discovery distance of a world's edge → DISCOVERED
* (once — state lives in this.discovery), with a rim ping + toast.
* 2. Discovered worlds that are off-screen feed the compass, which
* draws the themed screen-edge arrows pointing back to them.
*/
updateDiscovery(time, delta) {
const objects = this.discoverableObjects();
const sysId = this.systemRecord.id;
const fresh = this.discovery.check(sysId, this.ship.x, this.ship.y, objects);
for (const o of fresh) this.celebrateDiscovery(o);
// Which discovered objects are NOT on screen right now? (The camera
// never zooms, so the world view is scroll + canvas size.)
const cam = this.cameras.main;
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
const offscreen = [];
for (const o of objects) {
if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) {
offscreen.push(o);
}
}
this.compass.refresh(offscreen, view, this.scale.width, this.scale.height, time, delta);
}
/** The discoverable objects of the system, with compass metadata. */
discoverableObjects() {
const out = [];
out.push({
id: 'home',
x: this.planet.x,
y: this.planet.y,
radius: this.planet.radius,
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
name: config.get('planets.homeName', 'Terra'),
});
for (const p of this.systemPlanets) {
out.push({
id: p.discoveryId,
x: p.x,
y: p.y,
radius: p.radius,
typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name),
name: p.discoveryName,
});
}
return out;
}
/** The "new object" moment: a rim ping at the world + a HUD toast. */
celebrateDiscovery(o) {
const neon = themeColor('neon', 0x00e5ff);
// Expanding ring at the world's rim (world space).
const ring = this.add.circle(o.x, o.y, o.radius, 0, 0).setStrokeStyle(2, neon, 0.9).setDepth(6);
this.tweens.add({
targets: ring,
scale: 1.12,
alpha: 0,
duration: 750,
ease: 'Sine.easeOut',
onComplete: () => ring.destroy(),
});
// Top-center HUD toast, in the console language.
if (Array.isArray(this.toast)) {
for (const g of this.toast) g.destroy();
}
const fam = BODY_FONT();
const type = (o.typeLabel ?? 'OBJECT').toUpperCase();
const name = o.name ? String(o.name).toUpperCase() : null;
const label = name ? `DISCOVERED — ${name} · ${type}` : `DISCOVERED — ${type}`;
const g1 = this.add
.text(0, 0, '\u25b8', { fontFamily: fam, fontSize: '12px', color: toCss(neon) })
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen, not the world
const g2 = this.add
.text(0, 0, label, {
fontFamily: fam,
fontSize: '11px',
color: toCss(themeColor('dim', 0x7d92c4)),
letterSpacing: 2,
})
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen, not the world
const total = g1.width + 10 + g2.width;
const x0 = this.scale.width / 2 - total / 2;
g1.setPosition(x0, 26).setDepth(45).setAlpha(0);
g2.setPosition(x0 + g1.width + 10, 26).setDepth(45).setAlpha(0);
this.toast = [g1, g2];
this.tweens.add({ targets: this.toast, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
this.time.delayedCall(2400, () => {
if (!Array.isArray(this.toast)) return;
const [a, b] = this.toast;
this.toast = null;
this.tweens.add({
targets: [a, b],
alpha: 0,
duration: 350,
onComplete: () => {
a.destroy();
b.destroy();
},
});
});
}
showTargetMarker(x, y) {
const color = toColor(config.get('game.markerColor', '#41c7ff'));
const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5);
this.tweens.add({
targets: marker,
scale: 2.4,
alpha: 0,
duration: 450,
ease: 'Sine.easeOut',
onComplete: () => marker.destroy(),
});
}
hideHint() {
if (!this.hint || !this.hint.active) return;
this.tweens.add({
targets: this.hint,
alpha: 0,
duration: 400,
onComplete: () => {
this.hint.destroy();
this.hint = null;
},
});
}
shutdown() {
this.starfield?.destroy();
this.compass?.destroy();
this.actionBar?.destroy();
}
}