453 lines
18 KiB
JavaScript
453 lines
18 KiB
JavaScript
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 — and clicking its name tag
|
||
* autopilots the ship there (GameScene.autopilotTo: it targets the
|
||
* keep-out rim on the side the ship is approaching from).
|
||
*/
|
||
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 0–2), 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);
|
||
}
|
||
// The command deck's bottom strip (config: data/actionbar.json) — the
|
||
// compass lays out its arrows/name tags ABOVE it so a tag is never
|
||
// buried under the deck, and the hint sits above it too.
|
||
const deckEnabled = config.get('actionbar.enabled', true) === true;
|
||
const deckReserve = deckEnabled
|
||
? config.get('actionbar.height', 92) + config.get('actionbar.margin.bottom', 12)
|
||
: 0;
|
||
this.compass = new DiscoveryCompass(this, {
|
||
// Autopilot: clicking a compass name tag sends the ship to that
|
||
// discovered object (GameScene.autopilotTo below).
|
||
onSelect: (id) => this.autopilotTo(id),
|
||
reserveBottom: deckReserve,
|
||
});
|
||
|
||
// 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 = deckEnabled
|
||
? 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)
|
||
this.hint = this.add
|
||
.text(this.scale.width / 2, this.scale.height - deckReserve - (deckReserve ? 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, and a click on a compass name tag is an autopilot (it
|
||
// already retargeted the ship) — neither is a fly-here.
|
||
// 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;
|
||
if (this.compass.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;
|
||
}
|
||
|
||
/**
|
||
* Autopilot — the player clicked a compass name tag (any UI could wire
|
||
* to this): send the ship to a discovered object. It flies to the
|
||
* keep-out rim — the clearance point on the side the ship is coming
|
||
* from — and arrives to a stop, exactly like a click-to-fly onto the
|
||
* rim. Clicking elsewhere retargets the same way (last click wins).
|
||
*/
|
||
autopilotTo(id) {
|
||
const o = this.discoverableObjects().find((v) => v.id === id);
|
||
if (!o) return;
|
||
// The solid planet behind this discovery entry.
|
||
const planet =
|
||
id === 'home' ? this.planet : (this.systemPlanets.find((p) => p.discoveryId === id) ?? this.planet);
|
||
// Approach point: on the rim (clearance + hull), on the side the ship
|
||
// is approaching from (center → ship direction) — it ends up facing
|
||
// the world.
|
||
const dx = this.ship.x - o.x;
|
||
const dy = this.ship.y - o.y;
|
||
const d = Math.hypot(dx, dy) || 1;
|
||
let aim = planet.edgePoint(Math.atan2(dy, dx), planet.clearance, this.ship.radius);
|
||
// Belt and braces: no other world may own this point either.
|
||
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 "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();
|
||
}
|
||
}
|