209 lines
7.6 KiB
JavaScript
209 lines
7.6 KiB
JavaScript
import Phaser from '../vendor/phaser.js';
|
||
import { config } from '../config/Config.js';
|
||
import { toColor } from '../utils/Color.js';
|
||
import { Rng } from '../utils/Rng.js';
|
||
import { Galaxy } from '../galaxy/Galaxy.js';
|
||
import { formatSystemReport } from '../galaxy/SystemReport.js';
|
||
import { Ship } from '../entities/Ship.js';
|
||
import { Planet } from '../entities/Planet.js';
|
||
import { Starfield } from '../visuals/Starfield.js';
|
||
|
||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||
|
||
/**
|
||
* The game world (v0.3: the home planet — the player's Terran world —
|
||
* in the current system's open space).
|
||
* Click anywhere to fly there.
|
||
*/
|
||
export class GameScene extends Phaser.Scene {
|
||
constructor() {
|
||
super({ key: 'GameScene' });
|
||
}
|
||
|
||
preload() {
|
||
// The planet spritesheet: frameWidth×frameHeight frames, frame 0 = the
|
||
// Terran home world (more worlds slot into later 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. Show the player which one. (This
|
||
// also establishes the galaxy — and its seed — for what follows.)
|
||
this.createSystemHud();
|
||
|
||
// The home planet — the player's Terran world, always present in the
|
||
// system they start in. It sits at the world origin.
|
||
const homeName = config.get('planets.homePlanet', 'terran');
|
||
const homeFrame = config.get(`planets.frames.${homeName}`, 0);
|
||
this.planet = new Planet(this, 0, 0, homeFrame);
|
||
this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10)
|
||
|
||
// 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();
|
||
|
||
// Hint
|
||
this.hint = this.add
|
||
.text(this.scale.width / 2, this.scale.height - 26, config.get('game.hintText', ''), {
|
||
fontFamily: FONT_FALLBACK,
|
||
fontSize: '14px',
|
||
color: '#54608a',
|
||
})
|
||
.setOrigin(0.5)
|
||
.setScrollFactor(0); // UI: pinned to the screen, not the world
|
||
|
||
// Input: click = fly there. A click inside the planet clamps to the
|
||
// keep-out rim — the ship can stop at the clearance, never inside.
|
||
this.input.on('pointerdown', (pointer) => {
|
||
const aim = this.planet.aimPoint(pointer.worldX, pointer.worldY, this.ship.radius);
|
||
this.showTargetMarker(aim.x, aim.y);
|
||
this.ship.setTarget(aim.x, aim.y);
|
||
this.hideHint();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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 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.
|
||
*
|
||
* The system's CONTENTS are generated here, on arrival — 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() {
|
||
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}")`);
|
||
}
|
||
|
||
const current = this.galaxy.currentSystem();
|
||
const content = this.galaxy.ensureContent(current.id);
|
||
const report = formatSystemReport(content);
|
||
const fam = FONT_FALLBACK;
|
||
|
||
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: fam,
|
||
fontSize: '17px',
|
||
fontStyle: 'bold',
|
||
color: toColor(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'),
|
||
});
|
||
line(report.subtitle, { fontFamily: fam, fontSize: '12px', color: '#8fa0c9' });
|
||
y += 2;
|
||
for (const s of report.settlements) {
|
||
line(s.text, { fontFamily: fam, fontSize: '13px', color: toColor(s.color, 0x8fa0c9) });
|
||
}
|
||
line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
|
||
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
|
||
}
|
||
|
||
update(_time, delta) {
|
||
this.ship.update(_time, delta);
|
||
// The home world is solid: the ship may come within the clearance in
|
||
// data/planets.json of its rim, but never closer (or through it).
|
||
this.planet.constrainShip(this.ship, this.ship.radius);
|
||
this.updateCamera(delta);
|
||
this.starfield.update(); // after the camera, so it sees this frame's motion
|
||
}
|
||
|
||
/**
|
||
* 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),
|
||
);
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|