diff --git a/data/asteroids.json b/data/asteroids.json index 1718050..8329ded 100644 --- a/data/asteroids.json +++ b/data/asteroids.json @@ -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, "texture": "assets/images/asteroids.png", "frameWidth": 128, "frameHeight": 128, "frameCount": 10, "typeLabel": "Asteroid Cluster", + "compassColor": "#c8d2e0", "shipClearance": 50, "cluster": { "groupSize": { "min": 4, "max": 8 }, "sizes": { "min": 64, "max": 128 }, "minFullSize": 1, "spread": 140, + "gapFactor": 1.12, "spin": { "minDegPerSec": 0.3, "maxDegPerSec": 1.6 }, "groupSpin": { "minDegPerSec": 0.12, "maxDegPerSec": 0.4 }, "tint": { diff --git a/dev/asteroids.test.mjs b/dev/asteroids.test.mjs index 69a32af..b4a9be7 100644 --- a/dev/asteroids.test.mjs +++ b/dev/asteroids.test.mjs @@ -14,6 +14,7 @@ * minus placement.tetherMargin); * - 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, + * every rock pair keeps a gapFactor × (r1+r2) gap (no interpenetration), * bound = max(offset + size/2); * - SPACING: no cluster within 1024 px (center-to-center) of ANY 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) { 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 ---------------------------------------------------------- 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(`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(`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); diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index e17d7c2..4b42fa9 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -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 * 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 * group drifts slowly around its center (cluster.groupSpin) — the render * (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); 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 - // allowed — collision clusters look organic; solids never fully - // interpenetrate, see the 0.75 factor). + // Offsets: a dense random blob around the center (uniform in area), + // then a relaxation pass enforces a small gap between EVERY pair of + // 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 = []; 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 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] }); + const a = rng.range(0, TAU); + const r = spread * Math.sqrt(rng.next()); // uniform in area + members.push({ x: Math.cos(a) * r, y: Math.sin(a) * r, size: sizes[k] }); } + relaxRockGaps(members, gapFactor); 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 @@ -512,6 +511,39 @@ function placeInAnnulus(rng, rMin, rMax, placed, minSep, maxAttempts) { 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). */ function homeTetherRadius() { const level = Math.max(1, Math.floor(config.get('tether.homeLevel', 1))); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index a73c86d..5b17139 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -544,6 +544,9 @@ export class GameScene extends Phaser.Scene { y: c.y, radius: c.bound, 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, }); } diff --git a/js/ui/DiscoveryCompass.js b/js/ui/DiscoveryCompass.js index ab16e53..653afb1 100644 --- a/js/ui/DiscoveryCompass.js +++ b/js/ui/DiscoveryCompass.js @@ -131,7 +131,10 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container { makeEntry(t) { const scene = this.scene; 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 fill = toColor(config.get('theme.colors.panel', '#0a1120')); 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. ensureArrowTexture(scene); 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.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 — - * same pattern as Ship.ensureTexture). A neon chevron head with a soft - * glow pass over a dark fill, plus speed ticks streaming behind it. - * Points +x (angle 0); the tip sits 25 px right of the texture center. + * same pattern as Ship.ensureTexture). A chevron head with a soft glow + * pass over a dark fill, plus speed ticks streaming behind it. Points +x + * (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) { 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 W = 56; const H = 36;