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); * 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 */ export class DiscoveryCompass extends Phaser.GameObjects.Container { constructor(scene) { 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} target id → entry (arrow, chip, angle…) */ this.entries = new Map(); } /** * 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; const cx = w / 2; const cy = h / 2; // 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, h, this.inset, this.minSeparation, cx, cy); for (const t of targets) { const e = this.entries.get(t.id); const a = edgeAnchor(w, h, 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, h - 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 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: toColor(config.get('theme.colors.panel', '#0a1120')), 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; return { arrow, chipRoot, w, h, angle: null, phase: phase * 0.063 }; } } // ---------------------------------------------------------------------- // 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(); }