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 }; } }