575 lines
25 KiB
JavaScript
575 lines
25 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).
|
|
*
|
|
* 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, 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)`;
|
|
* 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
|
|
// 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 ?? 2000);
|
|
/** @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).
|
|
* `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, 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, farFor(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);
|
|
// 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);
|
|
// 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). 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) * 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);
|
|
|
|
// 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. `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; 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'));
|
|
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(); // 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);
|
|
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.setTint(neon); // the texture is baked white; tint per target
|
|
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;
|
|
|
|
// 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). 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);
|
|
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;
|
|
}
|
|
|
|
/** 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()) {
|
|
// 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 <= hx && dy <= hy) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** 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: small ? 5 : 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,
|
|
});
|
|
}
|
|
|
|
/** 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: e.arrowScale * 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;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* `t.alwaysFull` overrides the distance rule: a target flagged this way
|
|
* (the ROUTE's orange next-gate) stays at the full readout — arrow at
|
|
* scale 1, type + name text visible — no matter how far it is, so the
|
|
* "where to fly next" marker never folds into a text-less box.
|
|
*/
|
|
export function isFarTarget(t, ship, farDistance) {
|
|
if (!t || !ship || farDistance <= 0) return false;
|
|
if (t.alwaysFull === true) return false; // always prominent (route waypoint)
|
|
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.
|
|
*/
|
|
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 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 (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;
|
|
const neon = 0xffffff; // white source; tinted per arrow (see makeEntry)
|
|
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();
|
|
}
|