Add compass autopilot: clicking a name tag sends the ship to that world

- DiscoveryCompass name tags are now interactive buttons with hover/press feedback; firing onSelect hands the target id to the scene, which retargets the ship via GameScene.autopilotTo (aim at the keep-out rim on the approach side).
- Compass layout reserves the command-deck strip (reserveBottom) so chips/arrows are never buried under the deck.
- Click-to-fly now ignores clicks that land on a compass chip (DiscoveryCompass.contains), keeping autopilot and fly-here from colliding.
- Tests cover the chip hit test, onSelect seam, and reserveBottom clamping; docs updated to describe the new autopilot behavior.
This commit is contained in:
Brian Fertig 2026-09-03 20:59:43 -06:00
parent 9299859003
commit 2cbd8e8962
6 changed files with 183 additions and 33 deletions

View File

@ -41,7 +41,10 @@ python3 -m http.server 8080
The current system's dossier (name, identity, what's there) shows
top-left in the game scene.
- Game screen with a basic top-down ship: **click anywhere to fly there**
in the current system's open space (system boundaries/jumps come next)
in the current system's open space (system boundaries/jumps come next).
Discovered worlds get a screen-edge arrow + name tag; **clicking the
name tag autopilots the ship there** (it arrives on the keep-out rim,
facing the world)
- **The home planet** — the player's Terran world — in the system you
start in: a 1024 px disc rendered 1:1 from the `assets/images/planets.png`
spritesheet. Which Terran face it shows (frames 02) is a
@ -129,6 +132,7 @@ runtime data — loaders and tests ignore them.
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node
node dev/starfield.test.mjs # runs the real Starfield.create() in Node
node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager
node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test
node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@ -14,7 +14,9 @@
* different seed different);
* - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on
* the screen-edge rect (edges AND corners), circleInView is exact,
* lerpAngle always takes the short arc.
* 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 new planet class pools resolve to real sheet frames.
*/
@ -39,14 +41,22 @@ config.init(configData);
// Minimal Phaser stub — enough to import the UI/entity modules below.
globalThis.window = {
Phaser: { GameObjects: { Container: class {}, Sprite: class {} } },
Phaser: {
GameObjects: {
Container: class {
setScrollFactor() { return this; }
setDepth() { return this; }
},
Sprite: class {},
},
},
};
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
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 { edgeAnchor, circleInView, lerpAngle } = await import(
const { DiscoveryCompass, edgeAnchor, circleInView, lerpAngle } = await import(
pathToFileURL(join(__dirname, '../js/ui/DiscoveryCompass.js')).href
);
@ -179,6 +189,22 @@ check(
);
check('lerpAngle eases the short way (3→3, no long-way sweep)', Math.abs(lerpAngle(3.0, -3.0, 1) - (-3.0 + 2 * Math.PI)) < 1e-9);
// --- Autopilot seam: the chip hit test (the scene's click-to-fly guard) ----
{
const fakeScene = { add: { existing() {} } };
const compass = new DiscoveryCompass(fakeScene);
compass.entries.set('a', { chipRoot: { x: 100, y: 100 }, w: 50, h: 30 });
check('compass.contains: chip center ⇒ true', compass.contains(100, 100) === true);
check('compass.contains: inside the chip rect ⇒ true', compass.contains(124, 114) === true);
check('compass.contains: just outside, within hover slack ⇒ true', compass.contains(130, 100) === true);
check('compass.contains: outside ⇒ false', compass.contains(133, 100) === false);
check('compass.contains: no entries ⇒ false', new DiscoveryCompass(fakeScene).contains(1, 1) === false);
// Options: onSelect seam + deck reserve (layout input, clamped ≥ 0).
const c2 = new DiscoveryCompass(fakeScene, { onSelect: () => {}, reserveBottom: 104 });
check('compass options: onSelect kept, reserveBottom applied', typeof c2.onSelect === 'function' && c2.reserveBottom === 104);
check('compass options: negative reserve clamps to 0', new DiscoveryCompass(fakeScene, { reserveBottom: -50 }).reserveBottom === 0);
}
// --- The new planet class pools ---------------------------------------------
for (const k of ['rocky', 'gas', 'ice', 'lava']) {

View File

@ -234,6 +234,11 @@ world** — solid, rendered, flyable-to. Rules and seams:
of an edge, once, per system) with rim ping + toast; off-screen
compass — themed screen-edge arrows with type/name chips pointing
at discovered worlds; pure, save-ready discovery state
- [x] Compass autopilot: clicking a name tag sends the ship to that
world — it targets the keep-out rim on the side the ship is
approaching from (arrival facing the world; docking seam next).
The scene's click-to-fly guards against deck AND chip clicks
(`ActionBar.contains` / `DiscoveryCompass.contains`)
(`js/galaxy/Discovery.js`)
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
and the player's place in a populated galaxy

View File

@ -24,8 +24,9 @@ const BODY_FONT = () => fontStack('body', FONT_FALLBACK);
* Discovery: come within discovery distance (data/game.json) of a world's
* edge and it is DISCOVERED (state in this.discovery). Discovered worlds
* that are off-screen get a themed compass arrow on the screen edge
* (this.compass) pointing the way back so the player always has a
* reference to found worlds while exploring the rest of the system.
* (this.compass) pointing the way back and clicking its name tag
* autopilots the ship there (GameScene.autopilotTo: it targets the
* keep-out rim on the side the ship is approaching from).
*/
export class GameScene extends Phaser.Scene {
constructor() {
@ -121,7 +122,19 @@ export class GameScene extends Phaser.Scene {
this.discovery = new Discovery(config.get('game.discovery.distance', 540));
this.registry.set('discovery', this.discovery);
}
this.compass = new DiscoveryCompass(this);
// The command deck's bottom strip (config: data/actionbar.json) — the
// compass lays out its arrows/name tags ABOVE it so a tag is never
// buried under the deck, and the hint sits above it too.
const deckEnabled = config.get('actionbar.enabled', true) === true;
const deckReserve = deckEnabled
? config.get('actionbar.height', 92) + config.get('actionbar.margin.bottom', 12)
: 0;
this.compass = new DiscoveryCompass(this, {
// Autopilot: clicking a compass name tag sends the ship to that
// discovered object (GameScene.autopilotTo below).
onSelect: (id) => this.autopilotTo(id),
reserveBottom: deckReserve,
});
// The command deck — the cyberpunk action bar across the bottom of the
// screen (config: data/actionbar.json). Six evenly spaced slots:
@ -129,23 +142,19 @@ export class GameScene extends Phaser.Scene {
// for the player's loop — research (time-based, one at a time, see
// data/research.json) and building (credits + minerals, see
// data/builds.json) — whose behavior and panels come next.
this.actionBar =
config.get('actionbar.enabled', true) === true
? new ActionBar(this, {
onAction: (id) => {
// TODO(command deck): 'research' → research panel, 'build' → build
// panel, 'ship' → ship screen, 'menu' → main menu.
console.info(`[orbit] command deck: ${id}`);
},
})
: null;
this.actionBar = deckEnabled
? new ActionBar(this, {
onAction: (id) => {
// TODO(command deck): 'research' → research panel, 'build' → build
// panel, 'ship' → ship screen, 'menu' → main menu.
console.info(`[orbit] command deck: ${id}`);
},
})
: null;
// Hint (pinned just ABOVE the command deck, not under it)
const deckRoom = this.actionBar
? config.get('actionbar.height', 92) + config.get('actionbar.margin.bottom', 12)
: 0;
this.hint = this.add
.text(this.scale.width / 2, this.scale.height - deckRoom - (deckRoom ? 20 : 26), config.get('game.hintText', ''), {
.text(this.scale.width / 2, this.scale.height - deckReserve - (deckReserve ? 20 : 26), config.get('game.hintText', ''), {
fontFamily: BODY_FONT(),
fontSize: '14px',
color: '#54608a',
@ -154,12 +163,15 @@ export class GameScene extends Phaser.Scene {
.setOrigin(0.5)
.setScrollFactor(0); // UI: pinned to the screen, not the world
// Input: click = fly there (a click ON the command deck is deck
// business, not flight). A click inside a planet clamps to that
// planet's keep-out rim — the ship can stop at the clearance, never
// inside. (Worlds don't overlap, so sequential clamping is exact.)
// Input: click = fly there. A click ON the command deck is deck
// business, and a click on a compass name tag is an autopilot (it
// already retargeted the ship) — neither is a fly-here.
// A click inside a planet clamps to that planet's keep-out rim — the
// ship can stop at the clearance, never inside. (Worlds don't
// overlap, so sequential clamping is exact.)
this.input.on('pointerdown', (pointer) => {
if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
if (this.compass.contains(pointer.x, pointer.y)) return;
let aim = { x: pointer.worldX, y: pointer.worldY };
for (const p of this.solidPlanets) {
aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
@ -321,6 +333,33 @@ export class GameScene extends Phaser.Scene {
return out;
}
/**
* Autopilot the player clicked a compass name tag (any UI could wire
* to this): send the ship to a discovered object. It flies to the
* keep-out rim the clearance point on the side the ship is coming
* from and arrives to a stop, exactly like a click-to-fly onto the
* rim. Clicking elsewhere retargets the same way (last click wins).
*/
autopilotTo(id) {
const o = this.discoverableObjects().find((v) => v.id === id);
if (!o) return;
// The solid planet behind this discovery entry.
const planet =
id === 'home' ? this.planet : (this.systemPlanets.find((p) => p.discoveryId === id) ?? this.planet);
// Approach point: on the rim (clearance + hull), on the side the ship
// is approaching from (center → ship direction) — it ends up facing
// the world.
const dx = this.ship.x - o.x;
const dy = this.ship.y - o.y;
const d = Math.hypot(dx, dy) || 1;
let aim = planet.edgePoint(Math.atan2(dy, dx), planet.clearance, this.ship.radius);
// Belt and braces: no other world may own this point either.
for (const p of this.solidPlanets) aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
this.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
}
/** The "new object" moment: a rim ping at the world + a HUD toast. */
celebrateDiscovery(o) {
const neon = themeColor('neon', 0x00e5ff);

View File

@ -28,13 +28,21 @@ const TAU = Math.PI * 2;
* lerpAngle(a, b, k) shortest-arc angle easing
*
* Component usage:
* const compass = new DiscoveryCompass(scene);
* 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);
* targets [{ id, x, y, radius, typeLabel, name? }] (world coords)
* view { left, top, w, h } the camera's world-space view rect
*
* Autopilot: each chip (type + name tag) is a button hover brightens
* its edge, press flashes it and pops the arrow and fires `onSelect(id)`;
* the scene decides what "go there" means (GameScene.autopilotTo sends the
* ship to the object's keep-out rim).
*/
export class DiscoveryCompass extends Phaser.GameObjects.Container {
constructor(scene) {
constructor(scene, o = {}) {
super(scene, 0, 0);
scene.add.existing(this); // v4 quirk: new'd objects are not on the display list
this.setScrollFactor(0); // UI — pinned to the screen
@ -45,6 +53,13 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
this.minSeparation = cfg.minSeparation ?? 130; // px kept between arrows
/** @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 —
// the scene decides what "go there" means (see GameScene.autopilotTo).
this.onSelect = typeof o.onSelect === 'function' ? o.onSelect : null;
// Screen strip reserved for other UI (the command deck at the bottom):
// arrows + chips are laid out in the remaining rect, so a name tag is
// never buried under the deck.
this.reserveBottom = Math.max(0, o.reserveBottom ?? 0);
}
/**
@ -67,8 +82,11 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
if (this.entries.size === 0) return;
const dt = Math.min(delta, 64) / 1000;
// Layout strip: the reserved bottom UI (the command deck) is removed,
// so arrows + chips never end up buried under it.
const sh = Math.max(80, h - this.reserveBottom);
const cx = w / 2;
const cy = h / 2;
const cy = h / 2; // direction still points from the TRUE screen center
// Ease each arrow toward its object (shortest arc, no long-way sweep).
const k = 1 - Math.exp(-9 * dt);
@ -81,11 +99,11 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
}
// Keep arrows from stacking where the objects cluster in one direction.
separateAngles(targets.map((t) => this.entries.get(t.id)), w, h, this.inset, this.minSeparation, cx, cy);
separateAngles(targets.map((t) => this.entries.get(t.id)), w, sh, this.inset, this.minSeparation, cx, sh / 2);
for (const t of targets) {
const e = this.entries.get(t.id);
const a = edgeAnchor(w, h, this.inset, e.angle);
const a = edgeAnchor(w, sh, this.inset, e.angle);
const dx = Math.cos(e.angle);
const dy = Math.sin(e.angle);
// The arrow's tip sits on the edge line (inset from the border) and
@ -101,7 +119,7 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
const halfLead = Math.abs(dx) >= Math.abs(dy) ? e.w / 2 : e.h / 2;
const lead = TIP + TAIL + 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, h - e.h / 2 - 6);
const py = clampNum(a.y - dy * lead, e.h / 2 + 6, sh - e.h / 2 - 6);
e.chipRoot.setPosition(px, py);
// Slow beacon pulse, staggered per object.
@ -115,6 +133,7 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
const fam = fontStack('body', FONT_FALLBACK);
const neon = themeColor('neon', 0x00e5ff);
const ink = themeColor('ink', 0xeaf6ff);
const fill = toColor(config.get('theme.colors.panel', '#0a1120'));
const typeLabel = (t.typeLabel ?? 'OBJECT').toUpperCase();
const nameLabel = t.name ? String(t.name).toUpperCase() : '';
@ -145,7 +164,7 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
const chip = scene.add.graphics();
CyberShape.draw(chip, w, h, {
notch: Math.min(8, h * 0.3),
fill: toColor(config.get('theme.colors.panel', '#0a1120')),
fill,
fillAlpha: 0.86,
stroke: neon,
strokeAlpha: 0.55,
@ -173,7 +192,64 @@ 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;
return { arrow, chipRoot, w, h, angle: null, phase: phase * 0.063 };
const e = { arrow, chipRoot, chip, w, h, neon, fill, angle: null, phase: phase * 0.063 };
// 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).
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'));
chip.on('pointerdown', () => this.pressChip(e, t.id));
}
return e;
}
/** Does (px, py) screen coords fall on one of the name-tag chips?
* The scene uses this to keep click-to-fly away from chip clicks (a
* chip click is an autopilot, not a fly-here). */
contains(px, py) {
for (const e of this.entries.values()) {
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
}
return false;
}
/** Repaint the chip's edge for 'base' | 'hover'. */
paintChip(e, state) {
const hover = state === 'hover';
e.chip.clear();
CyberShape.draw(e.chip, e.w, e.h, {
notch: Math.min(8, e.h * 0.3),
fill: e.fill,
fillAlpha: 0.86,
stroke: e.neon,
strokeAlpha: hover ? 1 : 0.55,
lineWidth: 1.5,
glow: e.neon,
glowAlpha: hover ? 0.5 : 0.16,
});
this.scene.tweens.add({ targets: e.chipRoot, scale: hover ? 1.05 : 1, duration: 130, ease: 'Sine.easeOut' });
}
/** Press feedback, then the autopilot callback. */
pressChip(e, id) {
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' });
if (this.onSelect) this.onSelect(id);
}
}