Fix asteroid rock overlap and add distinct compass color for clusters

- Replace the old per-rock placement retry loop with a deterministic pairwise relaxation pass (relaxRockGaps) that enforces a gapFactor x (r1+r2) separation between every pair, so rocks keep a subtle visible gap instead of interpenetrating
- Add cluster.gapFactor (default 1.12) and asteroids.compassColor (#c8d2e0) config keys, with updated comments in data/asteroids.json and SystemGenerator.js
- Tint the DiscoveryCompass arrow per target: bake the arrow texture white and tint it to the target's color (worlds keep theme neon cyan, clusters use their light gray)
- Extend dev/asteroids.test.mjs to assert every rock pair respects the gapFactor separation
This commit is contained in:
Brian Fertig 2026-09-04 08:45:28 -06:00
parent ec7671d0cb
commit 2b37be7494
5 changed files with 77 additions and 21 deletions

View File

@ -1,17 +1,19 @@
{ {
"_comment": "ASTEROID CLUSTERS — loose groups of slowly tumbling rocks drifting in the system's void. texture = spritesheet of frameWidth×frameHeight asteroid frames (frameCount frames). cluster = how a group looks & moves: groupSize = rocks per cluster (minFullSize guarantees at least one full-size rock), sizes = per-rock diameter px, spread = how far a rock may sit from the cluster center, spin = each rock's OWN very slow tumble (deg/s, direction per-rock random), groupSpin = the whole loose group drifts around its center (deg/s), tint = subtle warm/cool starlight per cluster (anchors blended toward white by strength), halo = soft glow behind the group, debris = a halo of fine dust motes orbiting just outside the rocks. distribution = how many clusters a system gets: targetObjects planetCount, ±jitter, clamped to [minClusters, maxClusters] (more planets ⇒ fewer clusters); the starting system always gets at least startingSystemMinClusters, and those first ones are placed INSIDE the player's initial tether so they're reachable from the spawn. placement = where clusters may sit: minObjectSpacing = no cluster may be closer (center-to-center) than this to ANY other object (home world, planets, free-space stations, other clusters); minRadius/maxRadius = the annulus around the origin random clusters live in; tetherMargin = how far inside the tether rim a starting-system cluster must sit (whole group stays reachable). shipClearance = how close (edge-to-edge) the ship may get to a rock — clusters are solid, like worlds.", "_comment": "ASTEROID CLUSTERS — loose groups of slowly tumbling rocks drifting in the system's void. texture = spritesheet of frameWidth×frameHeight asteroid frames (frameCount frames). cluster = how a group looks & moves: groupSize = rocks per cluster (minFullSize guarantees at least one full-size rock), sizes = per-rock diameter px, spread = how far a rock may sit from the cluster center, gapFactor = the small edge-gap between ANY two rocks, as a multiple of their combined radii (1.0 = touching, 1.12 = a subtle gap — the generator pushes apart just the overlapping pairs, each by half, so groups stay compact), spin = each rock's OWN very slow tumble (deg/s, direction per-rock random), groupSpin = the whole loose group drifts around its center (deg/s), tint = subtle warm/cool starlight per cluster (anchors blended toward white by strength), halo = soft glow behind the group, debris = a halo of fine dust motes orbiting just outside the rocks. distribution = how many clusters a system gets: targetObjects planetCount, ±jitter, clamped to [minClusters, maxClusters] (more planets ⇒ fewer clusters); the starting system always gets at least startingSystemMinClusters, and those first ones are placed INSIDE the player's initial tether so they're reachable from the spawn. placement = where clusters may sit: minObjectSpacing = no cluster may be closer (center-to-center) than this to ANY other object (home world, planets, free-space stations, other clusters); minRadius/maxRadius = the annulus around the origin random clusters live in; tetherMargin = how far inside the tether rim a starting-system cluster must sit (whole group stays reachable). shipClearance = how close (edge-to-edge) the ship may get to a rock — clusters are solid, like worlds.",
"enabled": true, "enabled": true,
"texture": "assets/images/asteroids.png", "texture": "assets/images/asteroids.png",
"frameWidth": 128, "frameWidth": 128,
"frameHeight": 128, "frameHeight": 128,
"frameCount": 10, "frameCount": 10,
"typeLabel": "Asteroid Cluster", "typeLabel": "Asteroid Cluster",
"compassColor": "#c8d2e0",
"shipClearance": 50, "shipClearance": 50,
"cluster": { "cluster": {
"groupSize": { "min": 4, "max": 8 }, "groupSize": { "min": 4, "max": 8 },
"sizes": { "min": 64, "max": 128 }, "sizes": { "min": 64, "max": 128 },
"minFullSize": 1, "minFullSize": 1,
"spread": 140, "spread": 140,
"gapFactor": 1.12,
"spin": { "minDegPerSec": 0.3, "maxDegPerSec": 1.6 }, "spin": { "minDegPerSec": 0.3, "maxDegPerSec": 1.6 },
"groupSpin": { "minDegPerSec": 0.12, "maxDegPerSec": 0.4 }, "groupSpin": { "minDegPerSec": 0.12, "maxDegPerSec": 0.4 },
"tint": { "tint": {

View File

@ -14,6 +14,7 @@
* minus placement.tetherMargin); * minus placement.tetherMargin);
* - GROUP SHAPE: 48 rocks, sizes in [64, 128], at least one full-size * - GROUP SHAPE: 48 rocks, sizes in [64, 128], at least one full-size
* rock, frames in [0, frameCount) with NO frame twice in a cluster, * rock, frames in [0, frameCount) with NO frame twice in a cluster,
* every rock pair keeps a gapFactor × (r1+r2) gap (no interpenetration),
* bound = max(offset + size/2); * bound = max(offset + size/2);
* - SPACING: no cluster within 1024 px (center-to-center) of ANY other * - SPACING: no cluster within 1024 px (center-to-center) of ANY other
* object home world (origin), planets, free-space stations, other * object home world (origin), planets, free-space stations, other
@ -163,6 +164,18 @@ for (const rec of sample) {
if (Math.abs(bound - cl.bound) > 1e-9) { if (Math.abs(bound - cl.bound) > 1e-9) {
shapeOk = false; shapeWhy = `${rec.id}#${i}: bound ${cl.bound} ≠ max(offset + size/2) ${bound}`; break; shapeOk = false; shapeWhy = `${rec.id}#${i}: bound ${cl.bound} ≠ max(offset + size/2) ${bound}`; break;
} }
// Rocks keep a small gap from each other: no pair closer than
// gapFactor × (r1 + r2) centers (the generator's relaxation contract).
const gap = Math.max(1, CL.gapFactor ?? 1.12);
outer: for (let ai = 0; ai < rocks.length; ai++) {
for (let bi = ai + 1; bi < rocks.length; bi++) {
const d = Math.hypot(rocks[ai].x - rocks[bi].x, rocks[ai].y - rocks[bi].y);
const need = gap * (rocks[ai].size / 2 + rocks[bi].size / 2);
if (d < need - 1e-6) {
shapeOk = false; shapeWhy = `${rec.id}#${i}: rocks ${ai}/${bi} ${d.toFixed(1)} px apart (< ${need.toFixed(1)} gap)`; break outer;
}
}
}
// --- Motion ---------------------------------------------------------- // --- Motion ----------------------------------------------------------
if (!rocks.every((r) => Math.abs(r.spin) >= spinMin - 1e-12 && Math.abs(r.spin) <= spinMax + 1e-12)) { if (!rocks.every((r) => Math.abs(r.spin) >= spinMin - 1e-12 && Math.abs(r.spin) <= spinMax + 1e-12)) {
@ -212,7 +225,7 @@ for (const rec of sample) {
} }
check(`count: ${sample.length} systems obey targetObjectsplanets (±jitter), clamped ${D.minClusters}${D.maxClusters}${countOk ? '' : ' — ' + countWhy}`, countOk); check(`count: ${sample.length} systems obey targetObjectsplanets (±jitter), clamped ${D.minClusters}${D.maxClusters}${countOk ? '' : ' — ' + countWhy}`, countOk);
check(`shape: groups of ${CL.groupSize.min}${CL.groupSize.max}, sizes ${CL.sizes.min}${CL.sizes.max}, ≥1 full-size, frames unique per cluster, bound correct${shapeOk ? '' : ' — ' + shapeWhy}`, shapeOk); check(`shape: groups of ${CL.groupSize.min}${CL.groupSize.max}, sizes ${CL.sizes.min}${CL.sizes.max}, ≥1 full-size, frames unique per cluster, rocks keep a ${CL.gapFactor ?? 1.12}× gap, bound correct${shapeOk ? '' : ' — ' + shapeWhy}`, shapeOk);
check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY object (home, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk); check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY object (home, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk);
check(`scatter: every cluster inside the ${P.minRadius}${P.maxRadius} annulus around the origin${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk); check(`scatter: every cluster inside the ${P.minRadius}${P.maxRadius} annulus around the origin${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk);
check(`motion: per-rock spins & group drifts within the slow-spin ranges${spinOk ? '' : ' — ' + spinWhy}`, spinOk); check(`motion: per-rock spins & group drifts within the slow-spin ranges${spinOk ? '' : ' — ' + spinWhy}`, spinOk);

View File

@ -280,7 +280,9 @@ function layoutSystem(seed, systemId, planets, freeSpace) {
* *
* A cluster is a GROUP of 48 rocks (data cluster.groupSize), each 64128 * A cluster is a GROUP of 48 rocks (data cluster.groupSize), each 64128
* px (cluster.sizes) with at least one full-size rock, sitting within * px (cluster.sizes) with at least one full-size rock, sitting within
* `cluster.spread` of the cluster center. Every rock tumbles on its own * `cluster.spread` of the cluster center, and kept a small gap apart from
* each other (cluster.gapFactor the generator relaxes any overlap so no
* two rocks interpenetrate). Every rock tumbles on its own
* slow spin (its own speed and direction cluster.spin), and the whole * slow spin (its own speed and direction cluster.spin), and the whole
* group drifts slowly around its center (cluster.groupSpin) the render * group drifts slowly around its center (cluster.groupSpin) the render
* (js/entities/AsteroidCluster.js) reads these straight off the record. * (js/entities/AsteroidCluster.js) reads these straight off the record.
@ -397,22 +399,19 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace) {
const fullRocks = Math.min(Math.max(0, Math.floor(clusterCfg.minFullSize ?? 1)), memberCount); const fullRocks = Math.min(Math.max(0, Math.floor(clusterCfg.minFullSize ?? 1)), memberCount);
for (let k = 0; k < fullRocks; k++) sizes[rng.int(0, memberCount - 1)] = sizeMax; for (let k = 0; k < fullRocks; k++) sizes[rng.int(0, memberCount - 1)] = sizeMax;
// Offsets: a dense random blob around the center (slight overlap is // Offsets: a dense random blob around the center (uniform in area),
// allowed — collision clusters look organic; solids never fully // then a relaxation pass enforces a small gap between EVERY pair of
// interpenetrate, see the 0.75 factor). // rocks (cluster.gapFactor — 1.12 ≈ a subtle gap): only pairs that
// would overlap move, each by half the shortfall, so the group keeps
// its random look and stays compact while no rocks interpenetrate.
const gapFactor = Math.max(1, clusterCfg.gapFactor ?? 1.12);
const members = []; const members = [];
for (let k = 0; k < memberCount; k++) { for (let k = 0; k < memberCount; k++) {
let ox = 0;
let oy = 0;
for (let attempt = 0; attempt < 24; attempt++) {
const a = rng.range(0, TAU); const a = rng.range(0, TAU);
const r = spread * Math.sqrt(rng.next()); // uniform in area const r = spread * Math.sqrt(rng.next()); // uniform in area
ox = Math.cos(a) * r; members.push({ x: Math.cos(a) * r, y: Math.sin(a) * r, size: sizes[k] });
oy = Math.sin(a) * r;
if (members.every((m) => Math.hypot(ox - m.x, oy - m.y) >= 0.75 * (sizes[k] / 2 + m.size / 2))) break;
}
members.push({ x: ox, y: oy, size: sizes[k] });
} }
relaxRockGaps(members, gapFactor);
const bound = Math.max(...members.map((m) => Math.hypot(m.x, m.y) + m.size / 2)); const bound = Math.max(...members.map((m) => Math.hypot(m.x, m.y) + m.size / 2));
// Spins: each rock tumbles on its own — its own slow speed, its own // Spins: each rock tumbles on its own — its own slow speed, its own
@ -512,6 +511,39 @@ function placeInAnnulus(rng, rMin, rMax, placed, minSep, maxAttempts) {
return { x: best.x, y: best.y }; return { x: best.x, y: best.y };
} }
/**
* Enforce the generator's gap rule: after the pass, every pair of rocks is
* gapFactor × (r1 + r2) apart (centers) a small visible edge-gap
* (1.0 = touching). Iterative pairwise separation: each violator moves half
* the shortfall, only violating pairs move (minimal displacement), and a
* few dozen sweeps settle any rock count. Fully deterministic (fixed pair
* order, fixed iteration cap).
*/
function relaxRockGaps(members, gapFactor, maxIter = 96) {
for (let it = 0; it < maxIter; it++) {
let worst = 0;
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
const a = members[i];
const b = members[j];
let dx = b.x - a.x;
let dy = b.y - a.y;
let d = Math.hypot(dx, dy);
const need = gapFactor * (a.size / 2 + b.size / 2);
if (d >= need) continue;
if (d < 1e-9) { dx = 1; dy = 0; d = 1; } // coincident: deterministic axis
const push = (need - d) / 2;
a.x -= (dx / d) * push;
a.y -= (dy / d) * push;
b.x += (dx / d) * push;
b.y += (dy / d) * push;
worst = Math.max(worst, need - d);
}
}
if (worst <= 1e-6) break;
}
}
/** The starting system's initial tether radius (home world's level). */ /** The starting system's initial tether radius (home world's level). */
function homeTetherRadius() { function homeTetherRadius() {
const level = Math.max(1, Math.floor(config.get('tether.homeLevel', 1))); const level = Math.max(1, Math.floor(config.get('tether.homeLevel', 1)));

View File

@ -544,6 +544,9 @@ export class GameScene extends Phaser.Scene {
y: c.y, y: c.y,
radius: c.bound, radius: c.bound,
typeLabel: config.get('asteroids.typeLabel', 'Asteroid Cluster'), typeLabel: config.get('asteroids.typeLabel', 'Asteroid Cluster'),
// The compass shows clusters in their own light gray (not the
// worlds' neon cyan) — the arrow + name tag pick it up.
color: config.get('asteroids.compassColor', '#c8d2e0'),
name: c.discoveryName, name: c.discoveryName,
}); });
} }

View File

@ -131,7 +131,10 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
makeEntry(t) { makeEntry(t) {
const scene = this.scene; const scene = this.scene;
const fam = fontStack('body', FONT_FALLBACK); const fam = fontStack('body', FONT_FALLBACK);
const neon = themeColor('neon', 0x00e5ff); // Per-target accent: a target may carry its own color (asteroid
// clusters — data/asteroids.json → compassColor, light gray) and the
// arrow + chip use it; worlds keep the theme's neon cyan.
const neon = t.color ? toColor(t.color) : themeColor('neon', 0x00e5ff);
const ink = themeColor('ink', 0xeaf6ff); const ink = themeColor('ink', 0xeaf6ff);
const fill = toColor(config.get('theme.colors.panel', '#0a1120')); const fill = toColor(config.get('theme.colors.panel', '#0a1120'));
const typeLabel = (t.typeLabel ?? 'OBJECT').toUpperCase(); const typeLabel = (t.typeLabel ?? 'OBJECT').toUpperCase();
@ -186,6 +189,7 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
// the __MISSING texture forever, even after the key is generated. // the __MISSING texture forever, even after the key is generated.
ensureArrowTexture(scene); ensureArrowTexture(scene);
const arrow = scene.add.image(0, 0, ARROW_KEY); const arrow = scene.add.image(0, 0, ARROW_KEY);
arrow.setTint(neon); // the texture is baked white; tint per target
arrow.setScrollFactor(0); // UI — pinned to the screen arrow.setScrollFactor(0); // UI — pinned to the screen
arrow.setDepth(40); arrow.setDepth(40);
@ -341,13 +345,15 @@ function separateAngles(entries, w, h, inset, minSep, cx, cy) {
/** /**
* The arrow glyph, generated once per game (procedural, no assets * The arrow glyph, generated once per game (procedural, no assets
* same pattern as Ship.ensureTexture). A neon chevron head with a soft * same pattern as Ship.ensureTexture). A chevron head with a soft glow
* glow pass over a dark fill, plus speed ticks streaming behind it. * pass over a dark fill, plus speed ticks streaming behind it. Points +x
* Points +x (angle 0); the tip sits 25 px right of the texture center. * (angle 0); the tip sits 25 px right of the texture center. Baked WHITE
* each arrow image is tinted per target (worlds = theme neon, asteroid
* clusters = their light gray).
*/ */
function ensureArrowTexture(scene) { function ensureArrowTexture(scene) {
if (scene.textures.exists(ARROW_KEY)) return; if (scene.textures.exists(ARROW_KEY)) return;
const neon = toColor(config.get('theme.colors.neon', '#00e5ff')); const neon = 0xffffff; // white source; tinted per arrow (see makeEntry)
const fill = toColor(config.get('theme.colors.panel', '#0a1120')); const fill = toColor(config.get('theme.colors.panel', '#0a1120'));
const W = 56; const W = 56;
const H = 36; const H = 36;