Add compact far-display mode to the discovery compass

- Targets beyond `farDistance` from the ship fold to a small text-less chip with a shrunken arrow, reducing visual clutter for distant objects
- Hovering a far chip expands it to the full readout after an intent delay; hovering off folds it back only after a grace period, so quick flicks don't pop or yank it
- Assign per-type compass accent colors: green for planets (data/planets.json), red for stations (data/stations.json), gray for asteroid clusters (already present)
- Export `isFarTarget` and `syncEntryMode` as pure helpers for Node testing; add far-display coverage to dev/discovery.test.mjs
- Add a headless integration driver (dev/compass-far.html + .mjs) that boots GameScene, parks the ship between near and far targets, and exercises the hover-intent lifecycle end-to-end
- Pass the ship position into `compass.refresh()` from GameScene so the compass can compute per-target far/near state each frame
This commit is contained in:
Brian Fertig 2026-09-07 11:07:20 -06:00
parent 642c2cc016
commit 146dc207df
9 changed files with 603 additions and 47 deletions

Binary file not shown.

View File

@ -21,11 +21,17 @@
"followRate": 3.0
},
"discovery": {
"_comment": "Discovery rule: when the ship gets within distance px of an object's edge (edge-to-edge), the object is discovered — and stays discovered for the rest of the galaxy. compass = the off-screen arrows: edgeInset px in from the screen edge, and minSeparation px kept between arrows so they never stack.",
"_comment": "Discovery rule: when the ship gets within distance px of an object's edge (edge-to-edge), the object is discovered — and stays discovered for the rest of the galaxy. compass = the off-screen arrows: edgeInset px in from the screen edge, and minSeparation px kept between arrows so they never stack; farDistance px from the SHIP is where the FAR display begins — the arrow shrinks to farArrowScale and the chip folds to a smallBox px text-less box (hovering the small box expands it back to the full readout); smallHitSlack = hover/click slack (px) around the small box so it stays an easy target. expandDelay/collapseDelay (ms) = the hover INTENT on the small box: the expand waits expandDelay after the pointer enters (a flick over doesn't pop it) and the fold-back waits collapseDelay after the pointer leaves (hover back within the grace and it stays expanded).",
"distance": 540,
"compass": {
"edgeInset": 26,
"minSeparation": 130
"minSeparation": 130,
"farDistance": 5120,
"farArrowScale": 0.6,
"smallBox": 26,
"smallHitSlack": 16,
"expandDelay": 300,
"collapseDelay": 400
}
}
}

View File

@ -1,5 +1,5 @@
{
"_comment": "Planet visuals + home-planet rules + star rules (the central body of every non-home system) + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — present ONLY at the origin of the system they start in (the starting system's central body); every other system's central body is its star (`star` below). scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). solarSystem = the top-down LAYOUT BAND (js/galaxy/SystemGenerator.js → layoutSystem): every PAIR of layout objects — planets, free-space stations, and the central body (the star, or the home world in the starting system — at the local origin) — sits at least minSpacing and at most the maximum apart, center to center. Normal systems: [minSpacing, maxSpacing] = 6400..15360 px. The home system: [minSpacing, homeMaxSpacing] = 6400..10240 px, and it is capped at 3 objects — 5 points (home world + 4) cannot sit 6400..10240 px apart: the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618 > 1.6. Normal systems lay out as a regular N-gon ring; the home system as a regular (N+1)-polygon with the home world as one vertex. The rotation biases toward the jump-gate directions (data/gates.json). minSpacing/maxSpacing/homeMaxSpacing are center-to-center px (the same unit as the 1024 px world disc).",
"_comment": "Planet visuals + home-planet rules + star rules (the central body of every non-home system) + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — present ONLY at the origin of the system they start in (the starting system's central body); every other system's central body is its star (`star` below). scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). compassColor = the compass-arrow accent for planets (the home world + the system's worlds — data/stations.json's stations and data/asteroids.json's clusters carry their own; the scan signal compass reads it too). solarSystem = the top-down LAYOUT BAND (js/galaxy/SystemGenerator.js → layoutSystem): every PAIR of layout objects — planets, free-space stations, and the central body (the star, or the home world in the starting system — at the local origin) — sits at least minSpacing and at most the maximum apart, center to center. Normal systems: [minSpacing, maxSpacing] = 6400..15360 px. The home system: [minSpacing, homeMaxSpacing] = 6400..10240 px, and it is capped at 3 objects — 5 points (home world + 4) cannot sit 6400..10240 px apart: the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618 > 1.6. Normal systems lay out as a regular N-gon ring; the home system as a regular (N+1)-polygon with the home world as one vertex. The rotation biases toward the jump-gate directions (data/gates.json). minSpacing/maxSpacing/homeMaxSpacing are center-to-center px (the same unit as the 1024 px world disc).",
"texture": "assets/images/planets.png",
"frameWidth": 1024,
"frameHeight": 1024,
@ -23,6 +23,7 @@
"ice": "#cfeaff",
"lava": "#ff8a5c"
},
"compassColor": "#3dff88",
"typeLabels": {
"terran": "Home World",
"rocky": "Rocky World",

View File

@ -1,7 +1,8 @@
{
"_comment": "SPACE STATIONS — free-space settlements (deepSpaceStation, waypoint) rendered as world objects (js/entities/Station.js). enabled = draw them in-world (solid + discoverable + comms targets); shipClearance = the edge gap the ship holds at their surface (px — never crosses, like planets); kinds.<kind>.size = the station's keepout radius (px — the wings/ring extent); kinds.<kind>.ringSpeed = the deep-space station's ring rotation (rad/s).",
"_comment": "SPACE STATIONS — free-space settlements (deepSpaceStation, waypoint) rendered as world objects (js/entities/Station.js). enabled = draw them in-world (solid + discoverable + comms targets); shipClearance = the edge gap the ship holds at their surface (px — never crosses, like planets); compassColor = the compass-arrow accent for stations (red — planets get green from data/planets.json, clusters their own gray from data/asteroids.json); kinds.<kind>.size = the station's keepout radius (px — the wings/ring extent); kinds.<kind>.ringSpeed = the deep-space station's ring rotation (rad/s).",
"enabled": true,
"shipClearance": 50,
"compassColor": "#ff4d5e",
"kinds": {
"deepSpaceStation": {
"size": 108,

31
dev/compass-far.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev compass far-display test</title>
<!-- This page lives in /dev, but the game's relative asset paths are
rooted at the project root — resolve them against it. -->
<base href="../" />
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<!-- The CDP runner (dev/cdp-firefox.mjs) reads window.__CAPTURED_ERRORS__
to surface any page-level failure the driver itself can't see. -->
<script>
window.__CAPTURED_ERRORS__ = [];
window.__CAPTURED_LOGS__ = [];
const __oe = console.error.bind(console);
const __ol = console.log.bind(console);
console.error = (...a) => { window.__CAPTURED_ERRORS__.push(a.map(String).join(' ').slice(0, 600)); __oe(...a); };
console.log = (...a) => { window.__CAPTURED_LOGS__.push(a.map(String).join(' ').slice(0, 300)); __ol(...a); };
window.addEventListener('error', (e) => window.__CAPTURED_ERRORS__.push('window: ' + e.message + ' @ ' + (e.filename||'') + ':' + (e.lineno||'')));
window.addEventListener('unhandledrejection', (e) => window.__CAPTURED_ERRORS__.push('rejection: ' + String(e.reason && e.reason.stack || e.reason)));
</script>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/compass-far.mjs"></script>
</body>
</html>

198
dev/compass-far.mjs Normal file
View File

@ -0,0 +1,198 @@
/**
* Compass FAR-display integration driver (headless browser NOT a Node
* test). Boots the real GameScene and checks the near/far split of the
* off-screen compass (js/ui/DiscoveryCompass.js):
*
* - the ship is parked 3000 px from the home world (home is OFF-SCREEN
* but NEAR full display), opposite a system planet (which is then
* > 5120 px away FAR compact display);
* - every discovered object gets a compass entry; FAR entries are the
* small text-less box + shrunken arrow, NEAR entries the full readout;
* - hovering a FAR chip expands it back to the full readout (type +
* name, arrow back to full) AFTER the pointer has rested
* (expandDelay a flick over doesn't pop it), and hovering off
* folds it back in only after the grace (collapseDelay) runs out;
* - the scene's click guard (compass.contains) covers the small chip's
* generous hit slack but not a click well off it.
*
* Served by dev/compass-far.html; the results land in
* `window.__COMPASS_FAR__` for the CDP runner (dev/cdp-firefox.mjs):
*
* python3 -m http.server 8080
* node dev/cdp-firefox.mjs http://localhost:8080/dev/compass-far.html \
* 'return window.__COMPASS_FAR__;'
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
import { toColor } from '../js/utils/Color.js';
import { themeColor } from '../js/utils/Theme.js';
const data = await ConfigLoader.load();
config.init(data);
// A deterministic galaxy (same system every run) + a quiet run (no audio
// files to fetch in headless).
globalThis.__ORBIT_DEV_SEED = 'COMPASSFAR';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene]; // boots straight into the flight scene
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
const game = new Phaser.Game(gameConfig);
window.game = game;
const results = [];
const check = (label, cond) => {
const pass = !!cond;
results.push({ label, pass });
console.log(`${pass ? '✔' : '✘ FAIL'} ${label}`);
};
// WAIT — throttling-proof (this headless box starves setTimeout). Poll the
// game's own clock (shared engine loop time) on rAF until it advances.
const gameClock = () => {
try {
const s = window.game.scene.getScenes(true)[0];
if (s && typeof s.time.now === 'number') return s.time.now;
} catch {}
return null;
};
const wait = (ms) => new Promise((resolve) => {
const base = gameClock();
if (base === null) {
const start = performance.now();
setTimeout(() => resolve(), ms);
return;
}
const poll = () => {
const now = gameClock();
if (now !== null && now - base >= ms) return resolve();
requestAnimationFrame(poll);
};
requestAnimationFrame(poll);
});
const run = async () => {
const scene = game.scene.getScene('GameScene');
const bootT0 = Date.now();
while (!game.scene.isActive('GameScene') || !scene.ship || !scene.compass) {
if (Date.now() - bootT0 > 90000) throw new Error('GameScene never came up');
await new Promise((r) => setTimeout(r, 50));
}
await wait(300); // let the first frames settle
const cfg = config.get('game.discovery.compass', {});
check('far display configured (5120 / 0.6 / 26)',
cfg.farDistance === 5120 && cfg.farArrowScale === 0.6 && cfg.smallBox === 26);
// --- Park the ship: NEAR the home world, FAR from a system planet ----
// Home world at the origin: 3000 px away is off-screen but within
// farDistance (full display). The system planets sit ≥ 6400 px from the
// origin; the ship parks on the ray OPPOSITE one of them, so that
// planet is ≥ 6400 + 3000 px away (far beyond farDistance — compact).
const planet = scene.systemPlanets[0];
check('the test system has a planet to be far away', !!planet);
if (!planet) return finish();
const ang = Math.atan2(planet.y, planet.x);
const shipX = -Math.cos(ang) * 3000;
const shipY = -Math.sin(ang) * 3000;
scene.ship.setPosition(shipX, shipY);
scene.ship.setTarget(shipX, shipY); // no drift
const dHome = Math.hypot(shipX, shipY);
const dPlanet = Math.hypot(planet.x - shipX, planet.y - shipY);
check('ship split: home near (< 5120), planet far (> 5120)',
dHome < cfg.farDistance && dPlanet > cfg.farDistance);
// --- Discover everything (the compass shows DISCOVERED objects) ------
const sysId = scene.systemRecord.id;
const objs = scene.discoverableObjects();
const colorOf = {};
for (const o of objs) colorOf[o.id] = o.color;
const idOf = (e) => [...scene.compass.entries.entries()].find(([, en]) => en === e)?.[0];
let known = scene.discovery.bySystem.get(sysId) ?? new Set();
for (const o of objs) known.add(o.id);
scene.discovery.bySystem.set(sysId, known);
// Give the compass a couple of frames to reconcile its entries.
await wait(250);
check('compass built one entry per discovered object',
scene.compass.entries.size === objs.length);
const farE = [...scene.compass.entries.values()].filter((e) => e.far);
const nearE = [...scene.compass.entries.values()].filter((e) => !e.far);
check('at least one FAR entry and one NEAR entry', farE.length > 0 && nearE.length > 0);
check('FAR entries are the compact display (small box, no text, small arrow)',
farE.length > 0 && farE.every((e) => e.mode === 'small'
&& e.w === cfg.smallBox && e.h === cfg.smallBox
&& e.typeText.alpha < 0.1
&& (!e.nameText || e.nameText.alpha < 0.1)
&& e.arrow.scale < 0.9));
check('NEAR entries keep the full readout (text, full arrow)',
nearE.length > 0 && nearE.every((e) => e.mode === 'full'
&& e.typeText.alpha > 0.9
&& e.arrow.scale > 0.99));
// --- Hover a FAR chip: expand → read → (press) → fold back ----------
const e = farE[0];
scene.compass.onChipOver(e); // the chip's pointerover handler
check('hover IN: the small box brightens, but the expand WAITS (intent)',
e.mode === 'small' && e.expandAt != null);
// expandDelay + the 130 ms text fade + headroom, on the game clock.
await wait(900);
check('hover IN (after the rest): the far chip expands to the full readout',
e.mode === 'full' && e.w === e.baseW && e.h === e.baseH
&& e.typeText.alpha > 0.9
&& e.arrow.scale > 0.99);
check('hover IN: the chip keeps the target\u2019s accent color',
e.neon === (colorOf[idOf(e)] ? toColor(colorOf[idOf(e)]) : themeColor('neon', 0x00e5ff)),
);
// The press path still works from the expanded state (autopilot seam):
// onSelect is the scene's autopilotTo — the chip's own pointerdown fires
// pressChip, which calls onSelect; check the seam is wired, not the flight.
check('press seam intact (onSelect bound to the scene\u2019s autopilot)',
typeof scene.compass.onSelect === 'function');
scene.compass.onChipOut(e); // the chip's pointerout handler
check('hover OUT: a stray exit doesn\u2019t yank it down (grace armed)',
e.mode === 'full' && e.collapseAt != null);
// Grace (400 ms) + the fold fade + headroom.
await wait(900);
check('hover OUT (grace runs out): folds back to the compact display (still far)',
e.mode === 'small' && e.w === cfg.smallBox && e.typeText.alpha < 0.1);
// --- Hover back within the grace: the chip STAYS up ------------------
scene.compass.onChipOver(e);
await wait(900);
check('hover again: it expands once more (the seam is repeatable)',
e.mode === 'full' && e.typeText.alpha > 0.9);
scene.compass.onChipOut(e);
check('... and hovering back in during the grace cancels the fold',
(() => { scene.compass.onChipOver(e); return e.collapseAt === null && e.mode === 'full'; })());
// Leave it for good: folded back to the small box before the guard tests.
scene.compass.onChipOut(e);
await wait(900);
check('final leave: back to the compact display', e.mode === 'small' && e.w === cfg.smallBox);
// --- The scene's click guard around the small box --------------------
const px = e.chipRoot.x, py = e.chipRoot.y;
check('contains: the small chip\u2019s generous slack is a chip click',
scene.compass.contains(px, py) === true);
check('contains: a click well off the small chip is a fly-here',
scene.compass.contains(px + 40, py) === false);
finish();
};
function finish() {
const pass = results.every((r) => r.pass) && results.length > 0;
window.__COMPASS_FAR__ = { pass, results };
}
game.events.once('ready', async () => {
try {
await run();
} catch (err) {
window.__COMPASS_FAR__ = { pass: false, error: String(err && err.stack || err) };
}
});

View File

@ -23,6 +23,11 @@
* lerpAngle always takes the short arc;
* - the compass's chip hit test (contains) — the scene's guard that keeps
* click-to-fly away from autopilot clicks on name tags;
* - the FAR display (compact chip + shrunken arrow beyond
* game.discovery.compass.farDistance, hover-expanding back to the full
* readout with the hover INTENT: the expand waits for the pointer to
* rest, the fold-back waits for it to leave) isFarTarget,
* syncEntryMode, and the chip's mode switch;
* - the new planet class pools resolve to real sheet frames.
*/
@ -50,11 +55,25 @@ globalThis.window = {
Phaser: {
GameObjects: {
Container: class {
constructor(scene) { this.scene = scene; }
setScrollFactor() { return this; }
setDepth() { return this; }
},
Sprite: class {},
},
Geom: {
Rectangle: class {
constructor(x = 0, y = 0, width = 0, height = 0) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
static Contains(r, px, py) {
return px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height;
}
},
},
},
};
@ -62,7 +81,7 @@ const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { Discovery } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Discovery.js')).href);
const { Planet } = await import(pathToFileURL(join(__dirname, '../js/entities/Planet.js')).href);
const { DiscoveryCompass, edgeAnchor, circleInView, lerpAngle } = await import(
const { DiscoveryCompass, edgeAnchor, circleInView, lerpAngle, isFarTarget, syncEntryMode } = await import(
pathToFileURL(join(__dirname, '../js/ui/DiscoveryCompass.js')).href
);
@ -256,6 +275,109 @@ check('lerpAngle eases the short way (3→3, no long-way sweep)', Math.abs(le
check('compass options: negative reserve clamps to 0', new DiscoveryCompass(fakeScene, { reserveBottom: -50 }).reserveBottom === 0);
}
// --- The FAR display (compact chip + shrunken arrow, hover expands) -----
{
const fc = config.get('game.discovery.compass');
check(
'far display is configured (farDistance 5120 / arrow 0.6 / smallBox 26 / intent delays)',
fc.farDistance === 5120 && fc.farArrowScale === 0.6 && fc.smallBox === 26 && fc.smallHitSlack === 16
&& fc.expandDelay === 300 && fc.collapseDelay === 400,
);
check('isFarTarget: beyond farDistance ⇒ compact', isFarTarget({ x: 6000, y: 0 }, { x: 0, y: 0 }, 5120) === true);
check('isFarTarget: exactly at farDistance ⇒ full', isFarTarget({ x: 5120, y: 0 }, { x: 0, y: 0 }, 5120) === false);
check('isFarTarget: one px past ⇒ compact', isFarTarget({ x: 5121, y: 0 }, { x: 0, y: 0 }, 5120) === true);
check('isFarTarget: no ship ⇒ full (standalone path)', isFarTarget({ x: 99999, y: 0 }, null, 5120) === false);
check('isFarTarget: farDistance off (0) ⇒ full', isFarTarget({ x: 99999, y: 0 }, { x: 0, y: 0 }, 0) === false);
const fakeScene = {
add: { existing() {} },
tweens: { add() {} },
time: { now: 0 },
};
const compass = new DiscoveryCompass(fakeScene);
check('compass far defaults match the config (incl. intent delays)',
compass.farDistance === 5120 && compass.smallBox === 26 && compass.farArrowScale === 0.6
&& compass.expandDelay === 300 && compass.collapseDelay === 400);
const noop = () => {};
const entry = {
mode: 'full',
baseW: 120, baseH: 44, w: 120, h: 44,
far: true, hovered: false,
expandAt: null, collapseAt: null,
arrowScale: 1,
arrow: { setScale(a) { this.scale = a; } },
chip: { clear: noop, fillStyle: noop, fillPoints: noop, lineStyle: noop, strokePoints: noop, setInteractive: noop },
chipRoot: { setScale: noop },
typeText: { setAlpha(a) { this.alpha = a; } },
nameText: { setAlpha(a) { this.alpha = a; } },
neon: 0xffffff, fill: 0x0a1120,
};
compass.entries.set('f', entry);
compass.setMode(entry, 'small', false);
check('setMode(small): chip folds to smallBox, text hidden, arrow shrunk',
entry.w === 26 && entry.h === 26
&& entry.typeText.alpha === 0 && entry.nameText.alpha === 0
&& entry.arrowScale === 0.6 && entry.arrow.scale === 0.6,
);
check('setMode(small): hit slack keeps the small box an easy target', entry.hitHalfX === 13 + 16 + 6 && entry.hitHalfY === 35);
compass.setMode(entry, 'full', false);
check('setMode(full): the full readout restores (hover-expand)',
entry.w === 120 && entry.h === 44
&& entry.typeText.alpha === 1 && entry.nameText.alpha === 1
&& entry.arrowScale === 1 && entry.arrow.scale === 1 && entry.hitHalfX === 66,
);
// The scene's click guard covers the small chip's generous slack but not
// a click well off it (that is a fly-here again).
const smallEntry = { chipRoot: { x: 100, y: 100 }, w: 26, h: 26, hitHalfX: 35, hitHalfY: 35 };
compass.entries.set('s', smallEntry);
check('contains: inside the small chip\'s slack ⇒ true', compass.contains(100 + 30, 100) === true);
check('contains: well off the small chip ⇒ false', compass.contains(100 + 40, 100) === false);
// --- Hover INTENT (the expand waits, the fold-back has a grace) ------
compass.setMode(entry, 'small', false);
fakeScene.time.now = 1000;
compass.onChipOver(entry);
check('hover IN: brightens now; the expand waits the intent delay',
entry.hovered === true && entry.mode === 'small' && entry.expandAt === 1000 + compass.expandDelay);
check('within the expand window: the chip holds small',
syncEntryMode(entry, entry.expandAt - 1, true) === null && entry.mode === 'small');
compass.onChipOut(entry);
check('a flick over (out before the deadline) never pops it',
entry.expandAt === null && entry.mode === 'small');
fakeScene.time.now = 2000;
compass.onChipOver(entry);
check('after the rest: the far chip expands',
syncEntryMode(entry, 2000 + compass.expandDelay + 1, true) === 'expand');
compass.setMode(entry, 'full', false);
check('expanded: the full readout is back', entry.mode === 'full' && entry.typeText.alpha === 1);
fakeScene.time.now = 3000;
compass.onChipOut(entry);
check('hover OUT: the fold-back waits the grace (stays expanded)',
entry.collapseAt === 3000 + compass.collapseDelay && entry.mode === 'full' && entry.hovered === false);
check('within the grace: no collapse',
syncEntryMode(entry, entry.collapseAt - 1, true) === null && entry.mode === 'full');
compass.onChipOver(entry); // pointer back within the grace
check('hover back within the grace: fold cancelled, chip stays up',
entry.collapseAt === null && entry.hovered === true && entry.mode === 'full');
check('settles to full while hovered (far)', syncEntryMode(entry, 3500, true) === null && entry.mode === 'full');
fakeScene.time.now = 4000;
compass.onChipOut(entry);
const verdict = syncEntryMode(entry, entry.collapseAt + 1, true); // past the grace
compass.setMode(entry, verdict === 'collapse' ? 'small' : 'full', false);
check('grace runs out (pointer gone): the chip folds back to small',
verdict === 'collapse' && entry.mode === 'small');
// A NEAR chip is never far-folded: out just repaints it, no grace armed.
compass.setMode(entry, 'full', false);
entry.far = false;
compass.onChipOut(entry);
check('a NEAR chip never arms a fold-back',
entry.collapseAt === null && entry.mode === 'full');
}
// --- The new planet class pools ---------------------------------------------
for (const k of ['rocky', 'gas', 'ice', 'lava']) {

View File

@ -1428,7 +1428,10 @@ export class GameScene extends Phaser.Scene {
offscreen.push(o);
}
}
this.compass.refresh(offscreen, view, this.scale.width, this.scale.height, time, delta);
// The ship's position drives the compass's FAR display (targets
// beyond game.discovery.compass.farDistance fold to their compact
// chip + shrunken arrow until hovered — js/ui/DiscoveryCompass.js).
this.compass.refresh(offscreen, view, this.scale.width, this.scale.height, time, delta, this.ship);
}
/** The discoverable objects of the system, with compass metadata. */
@ -1445,6 +1448,10 @@ export class GameScene extends Phaser.Scene {
typeLabel: this.isHomeSystem
? config.get('planets.homeTypeLabel', 'Home World')
: config.get(`planets.starTypeLabels.${this.systemContent.star?.class}`, 'Star'),
// The compass accent: the home world is a PLANET (green —
// data/planets.json → compassColor, like the system's worlds); the
// star isn't a planet and keeps the compass's default neon cyan.
color: this.isHomeSystem ? config.get('planets.compassColor', '#3dff88') : undefined,
name: this.planet.discoveryName,
});
for (const p of this.systemPlanets) {
@ -1454,6 +1461,9 @@ export class GameScene extends Phaser.Scene {
y: p.y,
radius: p.radius,
typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name),
// The compass shows planets in green (data/planets.json →
// compassColor) — the arrow + name tag pick it up.
color: config.get('planets.compassColor', '#3dff88'),
name: p.discoveryName,
});
}
@ -1466,8 +1476,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.
// The compass shows clusters in their own light gray (their own
// accent — data/asteroids.json → compassColor) — the arrow +
// name tag pick it up.
color: config.get('asteroids.compassColor', '#c8d2e0'),
name: c.discoveryName,
});
@ -1481,6 +1492,9 @@ export class GameScene extends Phaser.Scene {
y: st.y,
radius: st.bound,
typeLabel: config.get(`settlements.kinds.${st.kind}.label`, 'Station'),
// The compass shows stations in red (data/stations.json →
// compassColor) — the arrow + name tag pick it up.
color: config.get('stations.compassColor', '#ff4d5e'),
name: st.discoveryName,
});
}

View File

@ -20,21 +20,39 @@ const TAU = Math.PI * 2;
* fill, speed ticks streaming behind, a slow beacon pulse and a dim
* cut-corner readout chip (neon type label, ink name).
*
* FAR targets (the ship more than game.discovery.compass.farDistance px
* away) get a quieter display: the arrow shrinks (farArrowScale) and the
* readout chip folds to a small text-less box (smallBox) still in the
* target's own color, so the type reads by hue. Hovering the small box
* expands it back to the full readout (and the arrow back to full) and
* keeps it there while the pointer is over it the player can still
* read and autopilot a far object without the far chrome competing with
* nearby ones; hovering off folds it back in (while it's still far).
* Both transitions ask a little INTENT first (ms, config):
* the expand waits expandDelay after the pointer enters (a quick flick
* over the box doesn't pop it), and the fold-back waits collapseDelay
* after the pointer leaves (a stray exit doesn't yank it down hover
* back within the grace and it stays up).
*
* The geometry helpers are PURE and exported for Node testing
* (dev/discovery.test.mjs):
* edgeAnchor(w, h, inset, angle) where the center-out ray meets the
* screen-edge rect (inset from border)
* circleInView(x, y, r, view) is a circle (fully or partly) on screen
* lerpAngle(a, b, k) shortest-arc angle easing
* isFarTarget(t, ship, farDist) the compact-display rule (distance > far)
* syncEntryMode(e, now, far) the per-frame FAR/hover-intent verdict
* ('expand' | 'collapse' | null)
*
* Component usage:
* const compass = new DiscoveryCompass(scene, {
* onSelect: (id) => {...}, // clicking a name tag (autopilot seam)
* reserveBottom: 104, // keep arrows/chips out of a bottom UI strip
* });
* compass.refresh(targets, view, w, h, time, delta);
* compass.refresh(targets, view, w, h, time, delta, ship);
* targets [{ id, x, y, radius, typeLabel, name? }] (world coords)
* view { left, top, w, h } the camera's world-space view rect
* ship the ship's world position; drives the FAR display
*
* Autopilot: each chip (type + name tag) is a button hover brightens
* its edge, press flashes it and pops the arrow and fires `onSelect(id)`;
@ -51,6 +69,20 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
const cfg = config.get('game.discovery.compass', {});
this.inset = cfg.edgeInset ?? 26; // arrow line, px in from the border
this.minSeparation = cfg.minSeparation ?? 130; // px kept between arrows
// FAR display (targets farther than farDistance px from the ship):
// the arrow shrinks to farArrowScale and the chip folds to a smallBox
// square with no text — hovering the small box expands it back to the
// full readout (onChipOver). smallHitSlack keeps the small box a
// comfortable hover/click target (px around it).
this.farDistance = cfg.farDistance ?? 5120;
this.farArrowScale = cfg.farArrowScale ?? 0.6;
this.smallBox = Math.max(12, cfg.smallBox ?? 26);
this.smallHitSlack = Math.max(0, cfg.smallHitSlack ?? 16);
// Hover INTENT (ms) on the small box: the expand waits expandDelay
// after pointer-enter, the fold-back waits collapseDelay after
// pointer-leave (hover back within the grace and it stays expanded).
this.expandDelay = Math.max(0, cfg.expandDelay ?? 300);
this.collapseDelay = Math.max(0, cfg.collapseDelay ?? 400);
/** @type {Map<string, object>} target id → entry (arrow, chip, angle…) */
this.entries = new Map();
// Autopilot seam: clicking a chip calls this with the target's id —
@ -65,12 +97,16 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
/**
* Reconcile + move the arrows. Call once per frame with the CURRENT
* off-screen discovered set (the scene computes it see GameScene).
* `ship` (the ship's world position) drives the FAR display targets
* farther than farDistance px from it fold to the compact chip +
* shrunken arrow (isFarTarget) until the player hovers them.
*/
refresh(targets, view, w, h, time, delta) {
refresh(targets, view, w, h, time, delta, ship = null) {
const farFor = (t) => isFarTarget(t, ship, this.farDistance);
// Reconcile: create entries for new targets, retire the rest.
const seen = new Set(targets.map((t) => t.id));
for (const t of targets) {
if (!this.entries.has(t.id)) this.entries.set(t.id, this.makeEntry(t));
if (!this.entries.has(t.id)) this.entries.set(t.id, this.makeEntry(t, farFor(t)));
}
for (const [id, e] of [...this.entries]) {
if (!seen.has(id)) {
@ -103,6 +139,13 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
for (const t of targets) {
const e = this.entries.get(t.id);
// FAR display (ship beyond farDistance): compact chip + shrunken
// arrow, with a little INTENT around the hover switch — the expand
// waits expandDelay after pointer-enter and the fold-back waits
// collapseDelay after pointer-leave (syncEntryMode drives it).
const action = syncEntryMode(e, time, farFor(t));
if (action === 'expand') this.setMode(e, 'full');
else if (action === 'collapse') this.setMode(e, 'small');
const a = edgeAnchor(w, sh, this.inset, e.angle);
const dx = Math.cos(e.angle);
const dy = Math.sin(e.angle);
@ -115,9 +158,11 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
// Chip: just inside the arrow's tail, centered on the ray, clamped
// so it never leaves the screen. The chip's leading half is its
// half-width (edge arrows) or half-height (top/bottom arrows).
// half-width (edge arrows) or half-height (top/bottom arrows). The
// tail gap scales with the arrow's mode (a shrunken far arrow sits
// closer to the edge line, and the chip follows it).
const halfLead = Math.abs(dx) >= Math.abs(dy) ? e.w / 2 : e.h / 2;
const lead = TIP + TAIL + 8 + halfLead;
const lead = (TIP + TAIL) * e.arrowScale + 8 + halfLead;
const px = clampNum(a.x - dx * lead, e.w / 2 + 6, w - e.w / 2 - 6);
const py = clampNum(a.y - dy * lead, e.h / 2 + 6, sh - e.h / 2 - 6);
e.chipRoot.setPosition(px, py);
@ -127,13 +172,16 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
}
}
/** Build the arrow + readout chip for one target. */
makeEntry(t) {
/** Build the arrow + readout chip for one target. `far` starts it in
* the compact display (no full-size flash on the first frame). */
makeEntry(t, far = false) {
const scene = this.scene;
const fam = fontStack('body', FONT_FALLBACK);
// 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.
// clusters — data/asteroids.json → compassColor, light gray; planets
// — data/planets.json, green; stations — data/stations.json, red) and
// the arrow + chip use it; anything without one keeps 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'));
@ -164,17 +212,7 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
typeText.setPosition(x0, -total / 2 + typeH / 2);
if (nameText) nameText.setPosition(x0, -total / 2 + typeH + gap + nameH / 2);
const chip = scene.add.graphics();
CyberShape.draw(chip, w, h, {
notch: Math.min(8, h * 0.3),
fill,
fillAlpha: 0.86,
stroke: neon,
strokeAlpha: 0.55,
lineWidth: 1.5,
glow: neon,
glowAlpha: 0.16,
});
const chip = scene.add.graphics(); // painted below, once `e` exists
// MenuButton pattern: build the Container by hand, then add the pieces.
const chipRoot = new Phaser.GameObjects.Container(scene, 0, 0);
@ -196,24 +234,43 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
// Stagger the pulse so a row of arrows doesn't blink in unison.
let phase = 0;
for (const ch of String(t.id)) phase = (phase * 31 + ch.charCodeAt(0)) % 997;
const e = { arrow, chipRoot, chip, w, h, neon, fill, angle: null, phase: phase * 0.063 };
// FAR from the ship? Start in the compact display (small text-less
// chip + shrunken arrow) so the first frame isn't a full-size flash.
const small = far === true;
const e = {
arrow, chipRoot, chip, typeText, nameText,
baseW: w, baseH: h, // the full readout's size (mode 'full')
w: small ? this.smallBox : w,
h: small ? this.smallBox : h,
mode: small ? 'small' : 'full',
far: small, // last computed (refresh() re-checks each frame)
hovered: false,
expandAt: null, // game-clock deadline of a pending expand (intent)
collapseAt: null, // game-clock deadline of a pending fold-back (grace)
arrowScale: small ? this.farArrowScale : 1,
neon, fill, angle: null, phase: phase * 0.063,
};
this.drawChip(e, false);
if (small) {
typeText.setAlpha(0);
if (nameText) nameText.setAlpha(0);
arrow.setScale(e.arrowScale);
}
// Autopilot: the name tag is a button — hover brightens the chip's
// edge, press flashes it and pops the arrow — then onSelect(id) hands
// the target to the scene (GameScene sends the ship there).
// the target to the scene (GameScene sends the ship there). A SMALL
// chip first hovers into its full readout (onChipOver) — the player
// sees the type + name, then presses.
if (this.onSelect) {
// v4 quirk (same rule as ActionBar.buildSlots): hit-testing uses the
// object's OWN scrollFactor — the chip must be screen-fixed in input
// space too, or clicks miss it once the camera has scrolled.
chip.setScrollFactor(0);
const hit = new Phaser.Geom.Rectangle(-w / 2, -h / 2, w, h);
chip.setInteractive({
useHandCursor: true,
hitArea: hit,
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
});
chip.on('pointerover', () => this.paintChip(e, 'hover'));
chip.on('pointerout', () => this.paintChip(e, 'base'));
this.setChipHit(e);
chip.on('pointerover', () => this.onChipOver(e));
chip.on('pointerout', () => this.onChipOut(e));
chip.on('pointerdown', () => this.pressChip(e, t.id));
}
return e;
@ -224,20 +281,25 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
* chip click is an autopilot, not a fly-here). */
contains(px, py) {
for (const e of this.entries.values()) {
// e.hitHalfX/Y cover the chip's CURRENT mode (a small chip keeps a
// generous hit slack so a near-miss click doesn't fly the ship);
// hand-built test entries fall back to the old +6 hover slack.
const hx = e.hitHalfX ?? (e.w / 2 + 6);
const hy = e.hitHalfY ?? (e.h / 2 + 6);
const dx = Math.abs(px - e.chipRoot.x);
const dy = Math.abs(py - e.chipRoot.y);
if (dx <= e.w / 2 + 6 && dy <= e.h / 2 + 6) return true; // +6: hover scale
if (dx <= hx && dy <= hy) return true;
}
return false;
}
/** Repaint the chip's edge for 'base' | 'hover'. */
paintChip(e, state) {
const hover = state === 'hover';
if (hover) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
/** Paint the chip for its CURRENT mode (full readout, or the small
* text-less box) + hover state no animation (setMode animates). */
drawChip(e, hover) {
const small = e.mode === 'small';
e.chip.clear();
CyberShape.draw(e.chip, e.w, e.h, {
notch: Math.min(8, e.h * 0.3),
notch: small ? 5 : Math.min(8, e.h * 0.3),
fill: e.fill,
fillAlpha: 0.86,
stroke: e.neon,
@ -246,15 +308,95 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
glow: e.neon,
glowAlpha: hover ? 0.5 : 0.16,
});
}
/** Paint + the little hover pop (the hover tick). */
paintChip(e, hover) {
if (hover) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
this.drawChip(e, hover);
this.scene.tweens.add({ targets: e.chipRoot, scale: hover ? 1.05 : 1, duration: 130, ease: 'Sine.easeOut' });
}
/** Switch a chip between the full readout and the small box: the text
* fades, the arrow rescales to its mode, and the chip lands with a
* small pop (skipped when animate=false creation and the Node test).
* The pointer state (hovered) is kept across the switch. */
setMode(e, mode, animate = true) {
e.mode = mode;
const small = mode === 'small';
e.w = small ? this.smallBox : e.baseW;
e.h = small ? this.smallBox : e.baseH;
e.arrowScale = small ? this.farArrowScale : 1;
this.setChipHit(e);
this.drawChip(e, e.hovered);
const textA = small ? 0 : 1;
if (!animate) {
for (const tt of [e.typeText, e.nameText]) if (tt) tt.setAlpha(textA);
e.arrow.setScale(e.arrowScale);
return;
}
for (const tt of [e.typeText, e.nameText]) {
if (tt) this.scene.tweens.add({ targets: tt, alpha: textA, duration: 130, ease: 'Sine.easeOut' });
}
this.scene.tweens.add({ targets: e.arrow, scale: e.arrowScale, duration: 160, ease: 'Sine.easeOut' });
e.chipRoot.setScale(1.16); // land pop
this.scene.tweens.add({ targets: e.chipRoot, scale: 1, duration: 160, ease: 'Sine.easeOut' });
}
/** The chip's hit rectangle for its CURRENT mode the small box keeps
* a comfortable slack around it so the hover-expand target isn't a
* 26 px pixel hunt. (v4: re-calling setInteractive on an object that
* already has input only flips `enabled` the hitArea must be
* swapped in place.) */
setChipHit(e) {
const slack = e.mode === 'small' ? this.smallHitSlack : 0;
const hw = e.w / 2 + slack;
const hh = e.h / 2 + slack;
const rect = new Phaser.Geom.Rectangle(-hw, -hh, hw * 2, hh * 2);
const cb = (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py);
if (e.chip.input) {
e.chip.input.hitArea = rect;
e.chip.input.hitAreaCallback = cb;
} else {
e.chip.setInteractive({ useHandCursor: true, hitArea: rect, hitAreaCallback: cb });
}
// contains() slack: the hover pop (1.05) around the hit rect.
e.hitHalfX = hw + 6;
e.hitHalfY = hh + 6;
}
/** Hover IN: a small (far) chip EARNs its expansion the box brightens
* now, and the full readout lands once the pointer has rested for
* expandDelay ms (syncEntryMode fires it); a quick flick over doesn't
* pop it. Any pending fold-back is cancelled (the intent changed).
* A full chip just brightens. */
onChipOver(e) {
e.hovered = true;
e.collapseAt = null; // the pointer came back — stay up
if (e.mode === 'small') e.expandAt = (this.scene.time?.now ?? 0) + this.expandDelay;
this.paintChip(e, true);
}
/** Hover OUT: a quick exit doesn't yank an expanded chip down the fold
* waits collapseDelay ms (the grace); hover back within it and the
* chip stays expanded (the player means to read/click it). A small
* chip that never expanded just repaints itself un-hovered. */
onChipOut(e) {
e.hovered = false;
e.expandAt = null; // the pointer left before the expand earned itself
if (e.mode === 'full' && e.far) {
e.collapseAt = (this.scene.time?.now ?? 0) + this.collapseDelay;
return;
}
this.paintChip(e, false);
}
/** Press feedback, then the autopilot callback. */
pressChip(e, id) {
this.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice)
e.chipRoot.setAlpha(0.55);
this.scene.tweens.add({ targets: e.chipRoot, alpha: 1, duration: 260, ease: 'Sine.easeOut' });
this.scene.tweens.add({ targets: e.arrow, scale: 1.3, duration: 110, yoyo: true, ease: 'Sine.easeOut' });
this.scene.tweens.add({ targets: e.arrow, scale: e.arrowScale * 1.3, duration: 110, yoyo: true, ease: 'Sine.easeOut' });
if (this.onSelect) this.onSelect(id);
}
}
@ -273,6 +415,46 @@ function wrapPI(a) {
return t - Math.PI;
}
/**
* FAR display rule: is the target more than `farDistance` px from the
* ship? (the compass folds far targets to the compact chip + shrunken
* arrow until hovered). No ship, or farDistance off ( 0) never far.
*/
export function isFarTarget(t, ship, farDistance) {
if (!t || !ship || farDistance <= 0) return false;
return Math.hypot(t.x - ship.x, t.y - ship.y) > farDistance;
}
/**
* Per-frame FAR/hover-intent verdict for one compass entry (called from
* refresh()): updates `e.far`, settles the entry to its desired display
* once any pending intent window has elapsed, and RETURNS the action to
* take ('expand' | 'collapse' | null) the caller applies it (setMode),
* so this stays pure of scene/tween machinery (Node-testable).
*
* The two intent windows: `e.expandAt` (expand after the pointer has
* RESTED over the small box) and `e.collapseAt` (fold back only after the
* pointer has LEFT for a moment hover back within the grace and the
* collapse is dropped). While a window is still running, no action the
* chip holds its current display.
*/
export function syncEntryMode(e, now, far) {
e.far = far;
if (e.expandAt != null) {
if (now < e.expandAt) return null; // the rest is still running
e.expandAt = null;
return e.hovered && e.mode === 'small' ? 'expand' : null;
}
if (e.collapseAt != null) {
if (now < e.collapseAt) return null; // the grace is still running
e.collapseAt = null;
return !e.hovered && e.far && e.mode === 'full' ? 'collapse' : null;
}
const want = e.far && !e.hovered ? 'small' : 'full';
if (e.mode === want) return null;
return want === 'full' ? 'expand' : 'collapse';
}
/**
* Ease angle `a` toward `b` by fraction `k`, always the shortest way.
*/
@ -350,8 +532,9 @@ function separateAngles(entries, w, h, inset, minSep, cx, cy) {
* 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).
* each arrow image is tinted per target (per-type accents from the data
* files: gray clusters, green planets, red stations; anything without one
* = the theme neon).
*/
function ensureArrowTexture(scene) {
if (scene.textures.exists(ARROW_KEY)) return;