orbit/js/entities/AsteroidCluster.js

265 lines
9.4 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
// --- 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();
}