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:
parent
9299859003
commit
2cbd8e8962
|
|
@ -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 0–2) 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 |
|
|
@ -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']) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue