orbit/js/entities/AsteroidCluster.js

331 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 48 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).
*/
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();
}