import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { toColor } from '../utils/Color.js'; import { Planet } from './Planet.js'; const HALO_KEY = '__asteroid_halo'; const MOTE_KEY = '__asteroid_mote'; /** * An asteroid cluster: a loose group of 4–8 slowly tumbling rocks drifting * in the void (record from SystemGenerator.generateAsteroidClusters). * * The render is pure presentation of the generated record — every rock's * frame, size, offset, spin, the group's drift, the dust halo, the tint. * The motion (the "key part" of the game's feel): * - each rock tumbles on its OWN slow spin — its own speed and direction * (record.asteroids[].spin / .phase); * - the whole group is LOOSE: it drifts very slowly around its center * (record.groupSpin / .groupPhase) — a random patch of rocks, not a * rigid rock; * - a fine halo of dust motes orbits the rocks just outside them, * counter-drifting a little (record.debris); * - a soft starlight halo sits behind the group, and each cluster carries * a subtle warm/cool tint (record.tint) so no two fields read alike. * * A cluster is SOLID, like a world: the ship may close in to * `asteroids.shipClearance` px (edge-to-edge) of any rock, but can never * fly through one (constrainShip — the same circle rule as Planet, applied * to each rock; planets never overlap, and cluster rocks only slightly, so * a couple of projection passes are stable). * * update(time) is driven by the scene (GameScene.update) — it advances the * spins and CACHES each rock's current world position (m.wx/m.wy), which * the ship constraint (GameScene.onPostUpdate, after the physics step) * reads, so collision tracks the drifting rocks. */ export class AsteroidCluster extends Phaser.GameObjects.Container { static TEXTURE_KEY = 'asteroids'; /** * @param {Phaser.Scene} scene * @param {object} record — the generated cluster (SystemGenerator) * @param {object} [o={}] * @param {number} [o.depth=5] — render depth (planets sit at 5) */ constructor(scene, record, o = {}) { super(scene, record.x, record.y); scene.add.existing(this); // v4 quirk: new'd objects are not on the display list this.record = record; this.discoveryId = record.id; // discovery + compass identity this.discoveryName = record.name; this.bound = record.bound ?? 200; // max extent from center (discovery radius) this.clearance = config.get('asteroids.shipClearance', 50); this.setDepth(o.depth ?? 5); // Rocks: local (un-rotated) data + render image. World positions are // cached in wx/wy every update() — collision reads those. this.members = (record.asteroids ?? []).map((a) => ({ frame: a.frame, lx: a.x, ly: a.y, radius: a.size / 2, spin: a.spin, phase: a.phase, img: null, wx: this.x, wy: this.y, })); this.groupPhase = record.groupPhase ?? 0; this.groupSpin = record.groupSpin ?? 0; // rad/s, signed this.debrisPhase = record.debrisPhase ?? 0; this.debrisSpin = record.debrisSpin ?? -this.groupSpin * 0.5; // rad/s, signed // Reused by runtime rock surgery (splits add rocks of a new size — // the mining sequence, js/mining/Mining.js). this.frameSize = Math.max(1, config.get('asteroids.frameWidth', 128)); this.tint = record.tint; // --- Starlight halo (soft, behind the group) ------------------------- const haloCfg = config.get('asteroids.cluster.halo', {}); if (haloCfg.enabled !== false) { ensureHaloTexture(scene); const halo = scene.add.image(0, 0, HALO_KEY); halo.setScale((this.bound * (haloCfg.scale ?? 2.4)) / 32); halo.setAlpha(haloCfg.alpha ?? 0.14); halo.setTint(toColor(haloCfg.color, '#7fa8d8')); this.add(halo); } // --- The loose group: a container that drifts, holding each rock ---- this.groupBody = new Phaser.GameObjects.Container(scene, 0, 0); for (const m of this.members) { this.groupBody.add(this._rockImage(m)); } this.add(this.groupBody); // --- Dust: fine motes orbiting just outside the rocks ---------------- if (Array.isArray(record.debris) && record.debris.length > 0) { ensureMoteTexture(scene); this.debrisBody = new Phaser.GameObjects.Container(scene, 0, 0); for (const d of record.debris) { const dot = scene.add.image(d.x, d.y, MOTE_KEY); dot.setScale(Math.max(0.6, d.size) / 6); dot.setAlpha(d.alpha); this.debrisBody.add(dot); } this.add(this.debrisBody); } } /** * Advance the motion (driven by GameScene.update BEFORE ship constraint, * so the rocks the ship is held back by are exactly where they render). * @param {number} time — scene time, ms */ update(time) { const t = time / 1000; const rot = this.groupRotation(time); this.groupBody.rotation = rot; const cos = Math.cos(rot); const sin = Math.sin(rot); for (const m of this.members) { if (m.img) m.img.rotation = m.phase + m.spin * t; // World position of this rock (group drift applied): m.wx = this.x + m.lx * cos - m.ly * sin; m.wy = this.y + m.lx * sin + m.ly * cos; } if (this.debrisBody) { // The dust glides on its own slow orbit — loose, not locked to the // rocks (record.debrisSpin; legacy records fall back to a gentle // counter-drift). this.debrisBody.rotation = this.debrisPhase + this.debrisSpin * t; } } /** The group's drift rotation at `time` (rad) — update() uses this. */ groupRotation(time) { return this.groupPhase + this.groupSpin * (time / 1000); } /** A WORLD-space delta as LOCAL group coords (the current drift undone). */ worldToLocalDelta(dx, dy, time) { const rot = this.groupRotation(time); const c = Math.cos(-rot); const s = Math.sin(-rot); return { x: dx * c - dy * s, y: dx * s + dy * c }; } // ------------------------------------------------------------------ // Runtime rock surgery (the mining sequence splits and consumes rocks) // ------------------------------------------------------------------ /** * Rescale a rock in place: `sizePx` = its width = height (diameter px). * The radius drives collision + the beam's impact point (both read the * live member), the image scale drives the render — so everything stays * locked to the rock as it shrinks. */ setSize(m, sizePx) { m.radius = Math.max(1, sizePx) / 2; if (m.img) m.img.setScale((m.radius * 2) / this.frameSize); } /** * Add a rock to the group (a split piece): same sheet frame, LOCAL px * (group drift applies to it like every other member), its own slow * spin. Returns the member record (the beam/scene read it live). */ addRock(frame, lx, ly, sizePx, spin = 0, phase = 0) { const m = { frame, lx, ly, radius: Math.max(1, sizePx) / 2, spin, phase, img: null, wx: this.x, wy: this.y, }; this.groupBody.add(this._rockImage(m)); this.members.push(m); return m; } /** Remove a rock (mined out): splice it from the members, kill its image. */ removeMember(m) { const i = this.members.indexOf(m); if (i !== -1) this.members.splice(i, 1); m.img?.destroy(); m.img = null; } /** The render image for a member record (constructor + addRock share it). */ _rockImage(m) { const img = this.scene.add.image(m.lx, m.ly, AsteroidCluster.TEXTURE_KEY, m.frame); img.setScale((m.radius * 2) / this.frameSize); // 128 px rock at 1.0, 64 px at 0.5 if (this.tint) img.setTint(this.tint); m.img = img; return img; } /** * Keep a ship out of every rock: the same keep-out circle rule as a * planet (ship may reach `clearance` edge-to-edge, never closer), * projected per rock. Cluster rocks may slightly overlap, so the * projection is iterated a few passes until stable — cheap (≤ 8 circles). * * @returns {boolean} CONTACT: true when any rock actually touched the * ship this frame (see Planet.constrainShip). */ constrainShip(ship, shipRadius = 0) { const body = ship.body; let x = ship.x; let y = ship.y; let vx = body.velocity.x; let vy = body.velocity.y; let ax = body.acceleration ? body.acceleration.x : 0; let ay = body.acceleration ? body.acceleration.y : 0; for (let pass = 0; pass < 4; pass++) { let moved = false; for (const m of this.members) { const r = Planet.resolve( m.wx, m.wy, m.radius + this.clearance + shipRadius, x, y, vx, vy, ax, ay, ); if (r.x !== x || r.y !== y || r.vx !== vx || r.vy !== vy) moved = true; x = r.x; y = r.y; vx = r.vx; vy = r.vy; ax = r.ax; ay = r.ay; } if (!moved) break; } const touched = x !== ship.x || y !== ship.y || vx !== body.velocity.x || vy !== body.velocity.y || (body.acceleration && (ax !== body.acceleration.x || ay !== body.acceleration.y)); ship.x = x; ship.y = y; body.velocity.x = vx; body.velocity.y = vy; if (body.acceleration) { body.acceleration.x = ax; body.acceleration.y = ay; } return touched; } /** * A world point the ship may be sent to (click-to-fly / autopilot): a * point inside the cluster's keep-out is pushed out along the ray from * the cluster center, then out of every rock's keep-out circle. */ aimPoint(wx, wy, shipRadius = 0) { let x = wx; let y = wy; const dx = x - this.x; const dy = y - this.y; const d = Math.hypot(dx, dy); const boundMin = this.bound + this.clearance + shipRadius; if (d < boundMin) { if (d > 0) { x = this.x + (dx / d) * boundMin; y = this.y + (dy / d) * boundMin; } else { x = this.x + boundMin; // dead center: +x y = this.y; } } return this._pushOutOfRocks(x, y, shipRadius); } /** * An approach point on the cluster's rim at `angle` (rad), `gap` px * (edge-to-edge) off the nearest rock — the side the ship is coming from. */ edgePoint(angle, gap, shipRadius = 0) { const x = this.x + Math.cos(angle) * (this.bound + gap + shipRadius); const y = this.y + Math.sin(angle) * (this.bound + gap + shipRadius); return this._pushOutOfRocks(x, y, shipRadius); } /** Push a point outside every rock's keep-out circle (a few passes). */ _pushOutOfRocks(x, y, shipRadius = 0) { for (let pass = 0; pass < 4; pass++) { let moved = false; for (const m of this.members) { const r = Planet.resolve( m.wx, m.wy, m.radius + this.clearance + shipRadius, x, y, 0, 0, ); if (r.x !== x || r.y !== y) moved = true; x = r.x; y = r.y; } if (!moved) break; } return { x, y }; } } // ---------------------------------------------------------------------- /** * The starlight halo: a soft radial falloff, generated once (white — the * per-cluster color/alpha come from data/asteroids.json → cluster.halo at * use time). Same pattern as Ship.ensureTexture / the compass arrow. */ function ensureHaloTexture(scene) { if (scene.textures.exists(HALO_KEY)) return; const S = 64; const C = S / 2; const g = scene.make.graphics({ add: false }); const steps = 18; for (let i = steps; i >= 1; i--) { const t = i / steps; g.fillStyle(0xffffff, Math.pow(1 - t, 1.7) * 0.5); g.fillCircle(C, C, C * t); } g.generateTexture(HALO_KEY, S, S); g.destroy(); } /** A single dust mote: a soft 8 px dot (white-blue). */ function ensureMoteTexture(scene) { if (scene.textures.exists(MOTE_KEY)) return; const g = scene.make.graphics({ add: false }); g.fillStyle(0xdfe8ff, 0.35); g.fillCircle(4, 4, 4); g.fillStyle(0xffffff, 0.8); g.fillCircle(4, 4, 2); g.generateTexture(MOTE_KEY, 8, 8); g.destroy(); }