diff --git a/README.md b/README.md index effce0a..9359cf1 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/assets/images/originals/Terran Surface 01.png b/assets/images/originals/Terran Surface 01.png new file mode 100644 index 0000000..f277c72 Binary files /dev/null and b/assets/images/originals/Terran Surface 01.png differ diff --git a/dev/discovery.test.mjs b/dev/discovery.test.mjs index 60daa35..836f037 100644 --- a/dev/discovery.test.mjs +++ b/dev/discovery.test.mjs @@ -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']) { diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 714f20d..24663dd 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 9c3e2d9..8ffb6f4 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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); diff --git a/js/ui/DiscoveryCompass.js b/js/ui/DiscoveryCompass.js index 2b8aa0a..ab16e53 100644 --- a/js/ui/DiscoveryCompass.js +++ b/js/ui/DiscoveryCompass.js @@ -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} 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); } }