orbit/js/ui/DiscoveryCompass.js

378 lines
15 KiB
JavaScript

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { CyberShape } from './CyberShape.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const ARROW_KEY = '__compass_arrow';
const TAU = Math.PI * 2;
/**
* The off-screen compass: for every DISCOVERED object that is currently
* off-screen, a themed arrow sits on the screen edge pointing at it, with
* a cut-corner chip beside it carrying the object's TYPE and NAME (if it
* has one). This is how the player finds their way back to worlds they've
* already found while exploring the rest of the system.
*
* Visual language = the shared cyberpunk set (data/theme.json +
* CyberShape): neon chevron arrows with a soft glow pass over a dark
* fill, speed ticks streaming behind, a slow beacon pulse — and a dim
* cut-corner readout chip (neon type label, ink name).
*
* 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
*
* 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);
* 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, 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
this.setDepth(40); // above the HUD dossier (30)
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
/** @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);
}
/**
* Reconcile + move the arrows. Call once per frame with the CURRENT
* off-screen discovered set (the scene computes it — see GameScene).
*/
refresh(targets, view, w, h, time, delta) {
// 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));
}
for (const [id, e] of [...this.entries]) {
if (!seen.has(id)) {
e.arrow.destroy();
e.chipRoot.destroy();
this.entries.delete(id);
}
}
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; // 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);
for (const t of targets) {
const e = this.entries.get(t.id);
const sx = t.x - view.left; // screen coords (the game never zooms)
const sy = t.y - view.top;
const desired = Math.atan2(sy - cy, sx - cx);
e.angle = e.angle === null ? desired : lerpAngle(e.angle, desired, k);
}
// Keep arrows from stacking where the objects cluster in one direction.
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, 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
// points outward; its body + speed ticks extend inward. (Local tip
// offset TIP, tail offset TAIL — see ensureArrowTexture.)
const TIP = 25;
const TAIL = 28;
e.arrow.setX(a.x - dx * TIP).setY(a.y - dy * TIP).setRotation(e.angle);
// 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).
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, sh - e.h / 2 - 6);
e.chipRoot.setPosition(px, py);
// Slow beacon pulse, staggered per object.
e.arrow.setAlpha(0.8 + 0.2 * Math.sin(time * 0.004 + e.phase));
}
}
/** Build the arrow + readout chip for one target. */
makeEntry(t) {
const scene = this.scene;
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() : '';
// Text is measured first (canvas fonts), then the chip is cut to fit.
const typeText = scene.add
.text(0, 0, typeLabel, { fontFamily: fam, fontSize: '10px', color: toCss(neon), letterSpacing: 2 })
.setOrigin(0, 0.5);
let nameText = null;
if (nameLabel) {
nameText = scene.add
.text(0, 0, nameLabel, { fontFamily: fam, fontSize: '13px', color: toCss(ink), letterSpacing: 1 })
.setOrigin(0, 0.5);
}
const padX = 13;
const gap = 2;
const typeW = typeText.width;
const typeH = typeText.height;
const nameW = nameText ? nameText.width : 0;
const nameH = nameText ? nameText.height : 0;
const w = Math.max(typeW, nameW) + padX * 2;
const h = typeH + nameH + gap + 13;
const total = typeH + nameH + gap;
const x0 = -w / 2 + padX; // left-aligned readout, vertically centered
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,
});
// MenuButton pattern: build the Container by hand, then add the pieces.
const chipRoot = new Phaser.GameObjects.Container(scene, 0, 0);
scene.add.existing(chipRoot);
chipRoot.setScrollFactor(0); // UI — pinned to the screen
chipRoot.setDepth(40); // with the compass (above the HUD dossier)
chipRoot.add(chip);
chipRoot.add(typeText);
if (nameText) chipRoot.add(nameText);
// Texture FIRST: in v4 an image bound to a not-yet-existing key keeps
// the __MISSING texture forever, even after the key is generated.
ensureArrowTexture(scene);
const arrow = scene.add.image(0, 0, ARROW_KEY);
arrow.setScrollFactor(0); // UI — pinned to the screen
arrow.setDepth(40);
// 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 };
// 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);
}
}
// ----------------------------------------------------------------------
// Pure geometry (no Phaser) — exported for dev/discovery.test.mjs
// ----------------------------------------------------------------------
/** v4-safe local clamp (no Phaser.Math dependency in the pure path). */
function clampNum(v, lo, hi) {
return Math.min(hi, Math.max(lo, v));
}
function wrapPI(a) {
const t = ((((a + Math.PI) % TAU) + TAU) % TAU);
return t - Math.PI;
}
/**
* Ease angle `a` toward `b` by fraction `k`, always the shortest way.
*/
export function lerpAngle(a, b, k) {
return a + wrapPI(b - a) * clampNum(k, 0, 1);
}
/**
* Where the ray from the screen center at `angle` (radians, screen y-down)
* meets the screen-edge rect inset by `inset` px. That's where an off-screen
* object's arrow goes, pointing outward along the ray.
*/
export function edgeAnchor(w, h, inset, angle) {
const hx = Math.max(1, w / 2 - inset);
const hy = Math.max(1, h / 2 - inset);
const dx = Math.cos(angle);
const dy = Math.sin(angle);
let t = Infinity;
if (Math.abs(dx) > 1e-9) t = Math.min(t, (dx > 0 ? hx : -hx) / dx);
if (Math.abs(dy) > 1e-9) t = Math.min(t, (dy > 0 ? hy : -hy) / dy);
if (!Number.isFinite(t)) return { x: w / 2, y: h / 2 };
return { x: w / 2 + dx * t, y: h / 2 + dy * t };
}
/**
* Is the circle (x, y, r) on screen — fully or partly? `view` is the
* camera's world-space rect { left, top, w, h }.
*/
export function circleInView(x, y, r, view) {
const cx = clampNum(x, view.left, view.left + view.w);
const cy = clampNum(y, view.top, view.top + view.h);
const dx = x - cx;
const dy = y - cy;
return dx * dx + dy * dy <= r * r;
}
/** Midpoint of the shorter arc between two angles. */
function midAngle(a, b) {
return a + wrapPI(b - a) / 2;
}
/**
* Keep arrows at least `minSep` px apart along the edge: repeatedly nudge
* the angular gap of any pair that is too close. Mutates entries' `angle`.
*/
function separateAngles(entries, w, h, inset, minSep, cx, cy) {
if (entries.length < 2 || minSep <= 0) return;
for (let iter = 0; iter < 4; iter++) {
let touched = false;
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const ai = edgeAnchor(w, h, inset, entries[i].angle);
const aj = edgeAnchor(w, h, inset, entries[j].angle);
const d = Math.hypot(ai.x - aj.x, ai.y - aj.y);
if (d >= minSep) continue;
const midA = midAngle(entries[i].angle, entries[j].angle);
const midP = edgeAnchor(w, h, inset, midA);
const dist = Math.max(60, Math.hypot(midP.x - cx, midP.y - cy));
const push = ((minSep - d) * 0.5) / dist; // radians closing half the gap
const s1 = Math.sign(wrapPI(entries[i].angle - midA)) || 1;
const s2 = Math.sign(wrapPI(entries[j].angle - midA)) || -1;
entries[i].angle += (s1 * push) / 2;
entries[j].angle += (s2 * push) / 2;
touched = true;
}
}
if (!touched) break;
}
}
// ----------------------------------------------------------------------
/**
* The arrow glyph, generated once per game (procedural, no assets —
* same pattern as Ship.ensureTexture). A neon 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.
*/
function ensureArrowTexture(scene) {
if (scene.textures.exists(ARROW_KEY)) return;
const neon = toColor(config.get('theme.colors.neon', '#00e5ff'));
const fill = toColor(config.get('theme.colors.panel', '#0a1120'));
const W = 56;
const H = 36;
const head = [
{ x: 53, y: 18 }, // tip
{ x: 12, y: 3 },
{ x: 24, y: 18 }, // notch
{ x: 12, y: 33 },
];
const g = scene.make.graphics({ add: false });
// Soft outer pass (the "neon glow"), then dark fill, then the sharp edge.
g.lineStyle(7, neon, 0.22);
g.strokePoints(head, true);
g.fillStyle(fill, 0.9);
g.fillPoints(head, true);
g.lineStyle(2, neon, 1);
g.strokePoints(head, true);
// Speed ticks behind the head (stronger toward the tip).
g.lineStyle(2, neon, 0.8);
g.lineBetween(3, 13, 13, 16);
g.lineBetween(0, 18, 15, 18);
g.lineStyle(2, neon, 0.5);
g.lineBetween(3, 23, 13, 20);
g.generateTexture(ARROW_KEY, W, H);
g.destroy();
}