diff --git a/assets/videos/rocky-surface-02.mp4 b/assets/videos/rocky-surface-02.mp4 new file mode 100644 index 0000000..42d36ca Binary files /dev/null and b/assets/videos/rocky-surface-02.mp4 differ diff --git a/data/game.json b/data/game.json index 805b29a..9825ec2 100644 --- a/data/game.json +++ b/data/game.json @@ -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 } } } diff --git a/data/planets.json b/data/planets.json index d6645d0..cc171a9 100644 --- a/data/planets.json +++ b/data/planets.json @@ -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", diff --git a/data/stations.json b/data/stations.json index f0aaf20..43bc9b7 100644 --- a/data/stations.json +++ b/data/stations.json @@ -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..size = the station's keepout radius (px — the wings/ring extent); kinds..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..size = the station's keepout radius (px — the wings/ring extent); kinds..ringSpeed = the deep-space station's ring rotation (rad/s).", "enabled": true, "shipClearance": 50, + "compassColor": "#ff4d5e", "kinds": { "deepSpaceStation": { "size": 108, diff --git a/dev/compass-far.html b/dev/compass-far.html new file mode 100644 index 0000000..05f8376 --- /dev/null +++ b/dev/compass-far.html @@ -0,0 +1,31 @@ + + + + + Orbit — dev compass far-display test + + + + + + + + +
+ + + diff --git a/dev/compass-far.mjs b/dev/compass-far.mjs new file mode 100644 index 0000000..9e46e59 --- /dev/null +++ b/dev/compass-far.mjs @@ -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) }; + } +}); diff --git a/dev/discovery.test.mjs b/dev/discovery.test.mjs index 7216c1b..fffae1c 100644 --- a/dev/discovery.test.mjs +++ b/dev/discovery.test.mjs @@ -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']) { diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 2f526d2..846ba2b 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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, }); } diff --git a/js/ui/DiscoveryCompass.js b/js/ui/DiscoveryCompass.js index 2dba933..e41093c 100644 --- a/js/ui/DiscoveryCompass.js +++ b/js/ui/DiscoveryCompass.js @@ -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} 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;