265 lines
9.4 KiB
JavaScript
265 lines
9.4 KiB
JavaScript
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
|
||
|
||
// --- 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 ----
|
||
const frameSize = Math.max(1, config.get('asteroids.frameWidth', 128));
|
||
const tint = record.tint;
|
||
this.groupBody = new Phaser.GameObjects.Container(scene, 0, 0);
|
||
for (const m of this.members) {
|
||
const img = scene.add.image(m.lx, m.ly, AsteroidCluster.TEXTURE_KEY, m.frame);
|
||
img.setScale((m.radius * 2) / frameSize); // 128 px rock at 1.0, 64 px at 0.5
|
||
if (tint) img.setTint(tint);
|
||
m.img = img;
|
||
this.groupBody.add(img);
|
||
}
|
||
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.groupPhase + this.groupSpin * t;
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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).
|
||
*/
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
}
|