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:
parent
ec7671d0cb
commit
2b37be7494
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
* minus placement.tetherMargin);
|
* minus placement.tetherMargin);
|
||||||
* - GROUP SHAPE: 4–8 rocks, sizes in [64, 128], at least one full-size
|
* - GROUP SHAPE: 4–8 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 targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters}${countOk ? '' : ' — ' + countWhy}`, countOk);
|
check(`count: ${sample.length} systems obey targetObjects−planets (±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);
|
||||||
|
|
|
||||||
|
|
@ -280,7 +280,9 @@ function layoutSystem(seed, systemId, planets, freeSpace) {
|
||||||
*
|
*
|
||||||
* A cluster is a GROUP of 4–8 rocks (data → cluster.groupSize), each 64–128
|
* A cluster is a GROUP of 4–8 rocks (data → cluster.groupSize), each 64–128
|
||||||
* 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;
|
const a = rng.range(0, TAU);
|
||||||
let oy = 0;
|
const r = spread * Math.sqrt(rng.next()); // uniform in area
|
||||||
for (let attempt = 0; attempt < 24; attempt++) {
|
members.push({ x: Math.cos(a) * r, y: Math.sin(a) * r, size: sizes[k] });
|
||||||
const a = rng.range(0, TAU);
|
|
||||||
const r = spread * Math.sqrt(rng.next()); // uniform in area
|
|
||||||
ox = Math.cos(a) * r;
|
|
||||||
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)));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue