168 lines
7.0 KiB
JavaScript
168 lines
7.0 KiB
JavaScript
import Phaser from '../vendor/phaser.js';
|
||
import { config } from '../config/Config.js';
|
||
|
||
/**
|
||
* A planet: a static world rendered from the shared spritesheet
|
||
* (data/planets.json → texture; `frameWidth`×`frameHeight` frames, frame 0
|
||
* is the top-left, and `frames` maps names like "terran" to the sheet
|
||
* frames that kind may be drawn as — the generator picks one per planet).
|
||
*
|
||
* A planet is a SOLID DISC. Its collision radius is half the scaled frame
|
||
* (the world fills its frame — a Terran world is 1024 px across at
|
||
* scale 1.0), and the ship is kept `shipClearance` px (edge-to-edge) off
|
||
* the rim: it can come that close, but it can never move through the
|
||
* planet or any closer.
|
||
*
|
||
* The keep-out rule is a plain circle test (constrainShip → static
|
||
* resolve) applied to the ship after it moves, so it never interferes
|
||
* with the ship's own flight model and stays testable in Node
|
||
* (dev/planet.test.mjs) without a scene.
|
||
*/
|
||
export class Planet extends Phaser.GameObjects.Sprite {
|
||
static TEXTURE_KEY = 'planets';
|
||
|
||
/**
|
||
* Pick a sheet frame for a planet of `name` from its pool in
|
||
* data/planets.json (`frames`), using the given Rng so the choice is
|
||
* seed-deterministic (same seed ⇒ same world). A pool that is just a
|
||
* single number (or a missing/empty pool) falls back to that number,
|
||
* else 0.
|
||
*/
|
||
static frameFor(name, rng) {
|
||
const pool = config.get(`planets.frames.${name}`);
|
||
if (Array.isArray(pool) && pool.length > 0) return rng.pick(pool);
|
||
return Number.isFinite(pool) ? pool : 0;
|
||
}
|
||
|
||
/**
|
||
* @param {Phaser.Scene} scene
|
||
* @param {number} x — world x of the planet's center
|
||
* @param {number} y — world y
|
||
* @param {number} [frame=0] — spritesheet frame index (usually from Planet.frameFor)
|
||
* @param {string} [name=''] — planet kind, e.g. 'terran' (data/planets.json → frames)
|
||
* @param {object} [o={}] — per-planet overrides
|
||
* @param {number} [o.scale=1] — extra size multiplier on top of planets.scale
|
||
* (gas giants run bigger; see data/planets.json → classScale)
|
||
* @param {number} [o.tint] — canvas tint (int) applied to the sheet frame,
|
||
* so non-terran kinds read differently (see data/planets.json → classTint)
|
||
*/
|
||
constructor(scene, x, y, frame = 0, name = '', o = {}) {
|
||
super(scene, x, y, Planet.TEXTURE_KEY, frame);
|
||
scene.add.existing(this);
|
||
this.name = name;
|
||
this.sheetFrame = frame; // raw planets.png frame — picks the landing/surface videos
|
||
|
||
const scale = config.get('planets.scale', 1) * (o.scale ?? 1);
|
||
this.setScale(scale);
|
||
|
||
// Collision circle: frames are square and the world fills its frame,
|
||
// so the rim is half the (scaled) frame width from the center.
|
||
this.radius = (config.get('planets.frameWidth', 1024) * scale) / 2;
|
||
// Edge-to-edge gap the ship may close in on the rim (never less).
|
||
this.clearance = config.get('planets.shipClearance', 50);
|
||
|
||
if (o.tint !== undefined && o.tint !== null) this.setTint(o.tint);
|
||
}
|
||
|
||
/** Minimum allowed center-to-center distance for a ship of `shipRadius`. */
|
||
minCenterDistance(shipRadius = 0) {
|
||
return this.radius + this.clearance + shipRadius;
|
||
}
|
||
|
||
/**
|
||
* A world point `gap` px (edge-to-edge) off this planet's rim at
|
||
* `angle` radians — e.g. where to spawn the ship near the home world.
|
||
*/
|
||
edgePoint(angle, gap, shipRadius = 0) {
|
||
const d = this.radius + gap + shipRadius;
|
||
return { x: this.x + Math.cos(angle) * d, y: this.y + Math.sin(angle) * d };
|
||
}
|
||
|
||
/**
|
||
* A world point the ship may be sent to: a point inside the keep-out
|
||
* circle (e.g. a click on the planet itself, or through it) is projected
|
||
* out onto the rim, along the ray from the center — so the ship always
|
||
* has a reachable destination and is never told to go inside. Points
|
||
* already outside pass through unchanged.
|
||
*/
|
||
aimPoint(wx, wy, shipRadius = 0) {
|
||
const minDist = this.minCenterDistance(shipRadius);
|
||
const dx = wx - this.x;
|
||
const dy = wy - this.y;
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist >= minDist) return { x: wx, y: wy };
|
||
if (dist === 0) return { x: this.x + minDist, y: this.y }; // dead center: +x
|
||
return { x: this.x + (dx / dist) * minDist, y: this.y + (dy / dist) * minDist };
|
||
}
|
||
|
||
/**
|
||
* Keep a ship (anything with x, y and body.velocity) out of the planet:
|
||
* if it is inside the keep-out circle its center is moved out to
|
||
* `minCenterDistance` and the inward part of its velocity AND
|
||
* acceleration is removed — the tangential part is kept, so a near-miss
|
||
* slides along the rim instead of sticking. Stripping the acceleration
|
||
* matters: the arcade world integrates it AFTER this runs, so without
|
||
* it the ship would be pushed back inside a fraction of a pixel on the
|
||
* very next physics step. A ship already outside is untouched; one
|
||
* riding exactly on the circle keeps its position but loses its inward
|
||
* speed, so contact is clean (no in/out jitter).
|
||
*
|
||
* @returns {boolean} CONTACT: true when the constraint actually did
|
||
* something to the ship this frame (pushed it out of the keep-out circle,
|
||
* or stripped inward velocity/acceleration). The scene uses this to stop
|
||
* a thrusting ship (the Shift+click throttle lock) the moment it runs
|
||
* into the planet.
|
||
*/
|
||
constrainShip(ship, shipRadius = 0) {
|
||
const minDist = this.minCenterDistance(shipRadius);
|
||
const body = ship.body;
|
||
const r = Planet.resolve(
|
||
this.x, this.y, minDist,
|
||
ship.x, ship.y,
|
||
body.velocity.x, body.velocity.y,
|
||
body.acceleration ? body.acceleration.x : 0,
|
||
body.acceleration ? body.acceleration.y : 0,
|
||
);
|
||
const touched =
|
||
r.x !== ship.x || r.y !== ship.y ||
|
||
r.vx !== body.velocity.x || r.vy !== body.velocity.y ||
|
||
(body.acceleration &&
|
||
(r.ax !== body.acceleration.x || r.ay !== body.acceleration.y));
|
||
ship.x = r.x;
|
||
ship.y = r.y;
|
||
body.velocity.x = r.vx;
|
||
body.velocity.y = r.vy;
|
||
if (body.acceleration) {
|
||
body.acceleration.x = r.ax;
|
||
body.acceleration.y = r.ay;
|
||
}
|
||
return touched;
|
||
}
|
||
|
||
/**
|
||
* The pure circle constraint (static so it can be tested without a
|
||
* scene): clamps a point at least `minDist` from (cx, cy) and removes
|
||
* the components of velocity and acceleration pointing into the circle.
|
||
*/
|
||
static resolve(cx, cy, minDist, x, y, vx, vy, ax = 0, ay = 0) {
|
||
const dx = x - cx;
|
||
const dy = y - cy;
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist > minDist) return { x, y, vx, vy, ax, ay };
|
||
let nx;
|
||
let ny;
|
||
if (dist === 0) { nx = 1; ny = 0; } // dead center: push out along +x
|
||
else { nx = dx / dist; ny = dy / dist; }
|
||
const ox = cx + nx * minDist;
|
||
const oy = cy + ny * minDist;
|
||
// Strip any component pointing into the circle (keep the tangential).
|
||
const strip = (v) => {
|
||
const vn = v[0] * nx + v[1] * ny;
|
||
return vn < 0 ? [v[0] - vn * nx, v[1] - vn * ny] : [v[0], v[1]];
|
||
};
|
||
const [rvx, rvy] = strip([vx, vy]);
|
||
const [rax, ray] = strip([ax, ay]);
|
||
return { x: ox, y: oy, vx: rvx, vy: rvy, ax: rax, ay: ray };
|
||
}
|
||
}
|