Add autopilot confirm dialog and wheel zoom / drag pan to the map consol

- Clicking a charted object now opens an ENGAGE AUTOPILOT confirm before
  committing: CONFIRM plots the course and closes the console; CANCEL,
  scrim click, or ESC leave the map untouched (ESC is routed to the dialog
  first in GameScene).
- Wheel zoom (1x-8x) re-renders the chart crisp about the cursor, drag pans
  the zoomed view with frame-edge clamping, and double-tap on empty plate
  resets to 1x; each system remembers its last view.
- Fix ConfirmOverlay steady-state so it holds open values instead of
  collapsing to a 2px line once the open animation lands.
- Fix Phaser v4 hit-area space (unscaled frame coords) and hover/course-line
  destination offset by the plate origin so the full plate is clickable and
  decor draws at the correct world position.
- Add dev/map-click.mjs coverage for dialog, zoom, pan, and real-input paths;
  add rocky takeoff/landing video assets.
This commit is contained in:
Brian Fertig 2026-09-07 10:27:29 -06:00
parent 94cc87a177
commit aa8cf8e091
7 changed files with 697 additions and 53 deletions

Binary file not shown.

Binary file not shown.

View File

@ -138,9 +138,23 @@
"enabled": true,
"radiusSlop": 9
},
"zoom": {
"_comment": "Wheel zoom + drag pan over the chart plate (Phaser 4's input plugin delivers `wheel` and pointer events to the plate's image, whose hit area IS the plate — so they only fire over the plate; Phaser also stops the page scroll on the canvas by default). Zooming re-renders the chart at the new scale about the cursor (the painter's grid/labels adapt, so it stays crisp) — the visible box is the full frame ÷ z, clamped inside it. A press that moves past 5px pans the zoomed view (drag); release on a still press is the click (select / double-tap reset). Each system remembers its last view; double-tap on empty plate resets to 1×. sensitivity = exponential zoom per px of wheel delta (0.0022 ≈ 2.2× per 500 px of scroll).",
"enabled": true,
"zMin": 1,
"zMax": 8,
"sensitivity": 0.0022
},
"clickToast": {
"_comment": "Plate interaction copy.",
"coursePlotted": "COURSE PLOTTED — {name}",
"noCourse": "NO NAV LOCK — OBJECT NOT CHARTED"
},
"confirm": {
"_comment": "The ENGAGE AUTOPILOT dialog (js/ui/ConfirmOverlay.js, the save pop-up's confirm) shown over the plate before a click engages autopilot — autopilot is a commitment, so it's confirmed. CONFIRM (ENGAGE) plots the course AND closes the console; CANCEL, a scrim click, or ESC leaves the map exactly as it was (the plate + chrome are inert while it's up). width/height/labels are the console language; the destination + distance body is computed live from the pick.",
"width": 440,
"height": 168,
"title": "ENGAGE AUTOPILOT",
"label": "ENGAGE"
}
}

View File

@ -5,7 +5,11 @@
* 1. open the map console (deck MAP button)
* 2. hover a discovered object on the chart the tooltip
* (name + "TAP TO PLOT COURSE") must be part of the window
* 3. click it autopilot (ship.target set)
* 3. click it the ENGAGE AUTOPILOT confirm opens (autopilot is a
* commitment, so it's confirmed before it engages):
* - CANCEL / scrim / ESC map stays open, no course
* - ENGAGE course plotted (ship.target) AND the map closes
* While the confirm is up the plate is inert (no zoom/hover/click).
* 4. close the map NOTHING of the tooltip may linger on screen
*
* The known bug: ttPlate/ttName/ttSub/ttHint are created with
@ -94,6 +98,7 @@ try {
if (!win._hits || win._hits.length === 0) throw new Error('no hits on the chart');
const hit = win._hits[0];
const hitLabel = String(hit.label ?? hit.id).toUpperCase();
const sx = win.geo.mapX + hit.x;
const sy = win.geo.mapY + hit.y;
@ -114,6 +119,21 @@ try {
};
};
// Real-input helpers (shared by the dialog + zoom + pan sections)
const frames = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const sm = win.scene.scale;
const cb = sm.canvasBounds;
const cbL = cb.left ?? cb.x, cbT = cb.top ?? cb.y;
const toClient = (gx, gy) => [cbL + gx / sm.displayScale.x, cbT + gy / sm.displayScale.y];
const canvas = win.scene.game.canvas;
const realPress = async (gx, gy) => {
const [cx, cy] = toClient(gx, gy);
for (const type of ['mousedown', 'mouseup']) {
canvas.dispatchEvent(new MouseEvent(type, { clientX: cx, clientY: cy, bubbles: true, cancelable: true, button: 0 }));
await frames();
}
};
// ---- 1: hover the object ------------------------------------------------
win._setHover({ x: sx, y: sy });
await sleep(120);
@ -130,30 +150,282 @@ try {
mapImg: { inDL: iMapImg >= 0, interactive: !!(win.mapImg.input && win.mapImg.input.enabled) },
};
// ---- 2: click the object (through the real handler) --------------------
// ---- 1b: hover decor is drawn in WORLD coordinates ---------------------
// hits are PLATE-LOCAL (plate top-left = 0,0), hoverG draws in world
// space, and the plate sits at geo.mapX/mapY — so the ring, the course
// line's destination end and the dot must all be offset to world. (This
// used to be forgotten for the ring/line/dot: the ship end was correct,
// the destination end landed at plate-local coords, off by mapX/mapY.)
const hoverDraw = (() => {
const g = win.hoverG;
const circles = [];
const lines = [];
const origCircle = g.strokeCircle.bind(g);
const origLine = g.strokeLineShape.bind(g);
g.strokeCircle = (x, y, r) => { circles.push([x, y, r]); return origCircle(x, y, r); };
g.strokeLineShape = (shape) => { lines.push(shape); return origLine(shape); };
g.clear();
win._paintHover();
g.strokeCircle = origCircle;
g.strokeLineShape = origLine;
const ex = win.geo.mapX + hit.x;
const ey = win.geo.mapY + hit.y;
const ring = circles.find((c) => Math.abs(c[2] - (hit.r + 5)) < 1e-6);
const ln = lines[0];
return {
ex,
ey,
ringAtDest: !!ring && Math.abs(ring[0] - ex) < 0.5 && Math.abs(ring[1] - ey) < 0.5,
lineEndsAtDest: !!ln && Math.abs(ln.x2 - ex) < 0.5 && Math.abs(ln.y2 - ey) < 0.5,
lineShipEnd: ln ? [ln.x1, ln.y1] : null,
ring: ring ?? null,
};
})();
// ---- 2: click the object → the ENGAGE AUTOPILOT confirm opens ---------
// (autopilot is a commitment — the dialog asks BEFORE onSelect fires)
const dlg = win.dialog;
const targetBefore = s.ship.target;
win.mapImg.emit('pointerdown', { x: sx, y: sy });
await sleep(120);
const click = {
targetWasSet: s.ship.target !== null && s.ship.target !== targetBefore,
target: s.ship.target ? { x: Math.round(s.ship.target.x), y: Math.round(s.ship.target.y) } : null,
flash: !!win._flash,
win.mapImg.emit('pointerup', {});
await sleep(700); // open anim (~170ms) + title decode (420ms)
const zDlg = win._view.z;
const [iw, iwY] = toClient(win.geo.mapX + win.geo.mapW / 2, win.geo.mapY + win.geo.mapH / 2);
const evDlg = new WheelEvent('wheel', { clientX: iw, clientY: iwY, deltaY: -300, bubbles: true, cancelable: true });
canvas.dispatchEvent(evDlg); // plate-inert probe: this must NOT zoom
await frames();
const dialog = {
isOpen: dlg.isOpen,
title: dlg.title.text,
body: dlg.bodyTexts.map((t) => t.text),
confirmLabel: dlg.confirmBtn.labelText.text,
targetUnchanged: s.ship.target === targetBefore,
plateInertWheel: win._view.z === zDlg,
};
// a click on EMPTY plate must not retarget
// ---- 2b: CANCEL keeps the map open, plots no course ---------------------
dlg.cancel();
await sleep(300); // close anim (~130ms)
const dialogCancelled = {
hidden: dlg.state === 'hidden',
mapStillOpen: win.isOpen,
targetUnchanged: s.ship.target === targetBefore,
};
// ---- 2c: ESC cancels the confirm (map stays open) -----------------------
win._setHover({ x: sx, y: sy });
win.mapImg.emit('pointerdown', { x: sx, y: sy });
win.mapImg.emit('pointerup', {});
await sleep(250); // enough for state === 'opening' (isOpen true)
const escSeen = [];
if (s.input.keyboard) {
for (const name of ['keydown-ESC', 'keydown-Escape', 'keydown-ESCAPE']) {
s.input.keyboard.once(name, () => escSeen.push(name));
}
}
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true }));
await sleep(300);
const dialogEsc = {
hidden: dlg.state === 'hidden',
mapStillOpen: win.isOpen,
targetUnchanged: s.ship.target === targetBefore,
escEventSeen: escSeen,
};
// a click on EMPTY plate must not retarget (and opens no dialog)
s.ship.target = null;
win.mapImg.emit('pointerdown', { x: win.geo.mapX + 4, y: win.geo.mapY + 4 });
win.mapImg.emit('pointerup', {});
await sleep(120);
const missClick = { targetStayedNull: s.ship.target === null };
const missClick = { targetStayedNull: s.ship.target === null, noDialog: !dlg.isOpen };
// ---- 2d: REAL pipeline: canvas click over the object opens the confirm;
// a real click on ENGAGE confirms (course + map closes) ---------
// The v3→v4 port bug lived here: mapImg's hit rect was in world-plate
// coords, but v4 hit tests in the image's UNSCALED frame space — so
// only the plate's top-left quarter was actually clickable.
const hitPl = s.sys.input.hitTestPointer({ x: sx, y: sy });
const realHit = hitPl.includes(win.mapImg);
s.ship.target = null;
await realPress(sx, sy);
await sleep(250);
const realDialog = dlg.isOpen;
// ENGAGE button (dialog-local w/2-84, h/2-24 — real click on it)
const bx = dlg.x + dlg.confirmBtn.x;
const by = dlg.y + dlg.confirmBtn.y;
await realPress(bx, by);
await sleep(600); // confirm close (~130ms) + map close (~150ms)
const realConfirm = {
targetWasSet: s.ship.target !== null && s.ship.target !== targetBefore,
target: s.ship.target ? { x: Math.round(s.ship.target.x), y: Math.round(s.ship.target.y) } : null,
mapClosed: win.openState === 'closed',
dialogHidden: dlg.state === 'hidden',
};
// re-open the console for the zoom / pan sections
win.open();
await sleep(1800);
// ---- 2e: wheel zoom ----------------------------------------------------
const geo = win.geo;
const center = { x: geo.mapW / 2, y: geo.mapH / 2 };
const baseScale = win._tf.scale;
const worldAt = (pt) => ({ x: (pt.x - win._tf.ox) / win._tf.scale, y: (pt.y - win._tf.oy) / win._tf.scale });
// (a) 2× about the plate centre — crisp repaint, cursor point anchored
const wAnchor = worldAt(center);
win._zoomAt(center.x, center.y, 2);
await frames();
const zoomIn = { z: win._view.z, scale: win._tf.scale, anchor: worldAt(center) };
// (b)/(c) clamping: below min → 1×, above max → 8×
win._zoomAt(center.x, center.y, 0.001);
await frames();
const clampedMin = win._view.z;
win._zoomAt(center.x, center.y, 999);
await frames();
const clampedMax = win._view.z;
// (d) hit accuracy at 4×: the object nearest the anchor, hover + click it
win._view = { z: 4, cx: wAnchor.x, cy: wAnchor.y };
win._saveView();
win._queueRedraw();
await frames();
let zHit = null, zBest = Infinity;
for (const h of win._hits) {
const w = worldAt({ x: h.x, y: h.y });
const d = Math.hypot(w.x - wAnchor.x, w.y - wAnchor.y);
if (d < zBest) { zBest = d; zHit = h; }
}
const zoomHit = {
id: zHit?.id ?? null,
objectAt: win._objectAt(win._platePoint({ x: geo.mapX + zHit?.x, y: geo.mapY + zHit?.y }))?.id === zHit?.id,
};
s.ship.target = null;
win.mapImg.emit('pointerdown', { x: geo.mapX + zHit.x, y: geo.mapY + zHit.y });
win.mapImg.emit('pointerup', {});
await sleep(200);
zoomHit.clickOpensDialog = dlg.isOpen;
dlg.cancel(); // cancel to restore the plate (the confirm is topmost while up)
await sleep(300);
// (e) double-tap EMPTY plate → reset to 1×
let dblTap = { reset: false, tried: false };
for (const c of [{ x: 6, y: 6 }, { x: geo.mapW - 6, y: 6 }, { x: 6, y: geo.mapH - 6 }, { x: geo.mapW - 6, y: geo.mapH - 6 }]) {
if (win._objectAt(c)) continue; // find a truly empty corner
dblTap.tried = true;
s.ship.target = null;
win.mapImg.emit('pointerdown', { x: geo.mapX + c.x, y: geo.mapY + c.y });
win.mapImg.emit('pointerup', {});
await sleep(30);
win.mapImg.emit('pointerdown', { x: geo.mapX + c.x, y: geo.mapY + c.y });
win.mapImg.emit('pointerup', {});
await frames();
dblTap.reset = win._view.z === 1;
break;
}
// (f) the REAL wheel path (DOM WheelEvent → Phaser input plugin →
// the plate's `wheel` event): over the plate zooms in; over the
// chrome neither zooms nor targets the plate.
const zBeforeWheel = win._view.z;
const [wx1, wy1] = toClient(geo.mapX + geo.mapW / 2, geo.mapY + geo.mapH / 2);
const evIn = new WheelEvent('wheel', { clientX: wx1, clientY: wy1, deltaY: -300, bubbles: true, cancelable: true });
canvas.dispatchEvent(evIn);
await frames();
const wheelIn = { prevented: evIn.defaultPrevented, z: win._view.z, moved: win._view.z !== zBeforeWheel };
const [wx2, wy2] = toClient(30, 20); // title-bar area, off the plate
const evOut = new WheelEvent('wheel', { clientX: wx2, clientY: wy2, deltaY: -300, bubbles: true, cancelable: true });
canvas.dispatchEvent(evOut);
await frames();
const wheelOut = { prevented: evOut.defaultPrevented, z: win._view.z };
// (f2) DRAG PAN (the click now fires on release, so a press that moves
// past the slop pans instead of clicking).
const vSave = { ...win._view };
const restoreView = async () => {
win._view = { ...vSave };
win._saveView();
win._queueRedraw();
await frames();
};
const pan = {};
{
// set the view EXACTLY (a direct 2× about the current centre), not via
// the relative factor, so the assertions are exact
const wC = worldAt(center);
win._view = { z: 2, cx: wC.x, cy: wC.y };
win._saveView();
win._queueRedraw();
await frames();
const c0 = { ...win._view };
const sc = win._tf.scale;
const A = { x: center.x - 60, y: center.y - 40 };
win.mapImg.emit('pointerdown', { x: A.x, y: A.y });
win.mapImg.emit('pointermove', { x: A.x + 30, y: A.y + 20 });
win.mapImg.emit('pointerup', {});
await frames();
pan.z = win._view.z;
pan.dx = Math.abs(win._view.cx - (c0.cx - 30 / sc)) * sc;
pan.dy = Math.abs(win._view.cy - (c0.cy - 20 / sc)) * sc;
pan.noTarget = s.ship.target === null;
pan.hoverCleared = !win._hover;
// hard drag left (2000px — past the frame edge, whatever the size) →
// the centre clamps to the frame's right edge
const B = { x: center.x, y: center.y };
win.mapImg.emit('pointerdown', { x: B.x, y: B.y });
win.mapImg.emit('pointermove', { x: B.x - 2000, y: B.y });
win.mapImg.emit('pointerup', {});
await frames();
const full = win._full;
const vw = full.w / win._view.z;
const edge = full.cx + (full.w - vw) / 2;
pan.clampedPx = Math.abs(win._view.cx - edge) * sc;
pan.clamped = pan.clampedPx < 1e-3;
pan.edgeSlip = Math.abs(win._view.cx - edge); // world units beyond the edge (want 0)
// a drag that STARTS on an object must not select it (or confirm it)
s.ship.target = null;
const h0 = win._hits[0];
win.mapImg.emit('pointerdown', { x: geo.mapX + h0.x, y: geo.mapY + h0.y });
win.mapImg.emit('pointermove', { x: geo.mapX + h0.x + 24, y: geo.mapY + h0.y });
win.mapImg.emit('pointerup', {});
await frames();
pan.dragNoSelect = s.ship.target === null && !dlg.isOpen;
// the REAL input pipeline: mousedown → mousemove → mouseup
const R = { x: center.x + 40, y: center.y };
const [r0x, r0y] = toClient(R.x, R.y);
const kx = 20 / sm.displayScale.x, ky = 12 / sm.displayScale.y;
const cB = { ...win._view };
const scB = win._tf.scale;
canvas.dispatchEvent(new MouseEvent('mousedown', { clientX: r0x, clientY: r0y, bubbles: true, cancelable: true, button: 0 }));
await frames();
canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: r0x + kx, clientY: r0y + ky, bubbles: true, cancelable: true }));
await frames();
canvas.dispatchEvent(new MouseEvent('mouseup', { clientX: r0x + kx, clientY: r0y + ky, bubbles: true, cancelable: true }));
await frames();
pan.realDx = Math.abs(win._view.cx - (cB.cx - 20 / scB)) * scB;
pan.realDy = Math.abs(win._view.cy - (cB.cy - 12 / scB)) * scB;
await restoreView();
}
// (g) per-system memory
const viewMemory = win._views.has(win._snap?.systemId);
// ?keepOpen=1 — stop here (map still open, tooltip up) so a screenshot
// harness can capture the chart + tooltip on screen.
const keepOpen = new URLSearchParams(location.search).has('keepOpen');
let after = null, reopen = null, after2 = null;
if (keepOpen) {
const pass = click.targetWasSet && missClick.targetStayedNull;
win._resetZoom(); // the screenshot wants the full-frame chart
await frames();
const hk = win._hits[0];
win._setHover({ x: geo.mapX + hk.x, y: geo.mapY + hk.y });
await sleep(150);
const pass = dialog.isOpen && dialogCancelled.mapStillOpen && dialogEsc.mapStillOpen
&& missClick.targetStayedNull && realDialog && realConfirm.targetWasSet;
window.__MAPCLICK = {
ready: true, pass, keepOpen: true, before, click, missClick, errors,
ready: true, pass, keepOpen: true, before, dialog, dialogCancelled, dialogEsc, missClick, realDialog, realConfirm, realHit, errors,
};
console.log('MAPCLICK (keepOpen) ready');
} else {
@ -173,9 +445,15 @@ try {
// ---- 4: re-open, hover, close again (no leftovers, no crash) -----------
win.open();
await sleep(1800);
win._setHover({ x: sx, y: sy });
const h2 = win._hits[0]; // live hit list for the (possibly zoomed) view
win._setHover({ x: geo.mapX + h2.x, y: geo.mapY + h2.y });
await sleep(120);
reopen = { open: win.isOpen, hoverVisible: effVisible(win.ttName) };
reopen = {
open: win.isOpen,
hoverVisible: effVisible(win.ttName),
hoverId: win._hover?.id ?? null,
zoomKept: win._view.z === wheelIn.z,
};
win.close();
await sleep(700);
after2 = {
@ -198,9 +476,51 @@ try {
before.ttPlate.effVisible && before.ttName.effVisible);
check('while open: tooltip paints ABOVE the map surface (inside the window)',
before.ttPlate.paintsAboveMap && before.ttName.paintsAboveMap);
check('clicking a discovered object sets the ship target (autopilot)',
click.targetWasSet);
check('clicking empty plate does NOT retarget', missClick.targetStayedNull);
check('hover ring is drawn at the destination\u2019s WORLD position (offset by the plate origin)',
hoverDraw.ringAtDest);
check('course line ends at the destination\u2019s WORLD position (ship→destination)',
hoverDraw.lineEndsAtDest);
check('clicking a discovered object opens the ENGAGE AUTOPILOT confirm',
dialog.isOpen);
check('the confirm is titled ENGAGE AUTOPILOT',
dialog.title.trim() === 'ENGAGE AUTOPILOT');
check('the confirm names the destination',
dialog.body.join(' ').toUpperCase().includes(hitLabel));
check('the confirm button reads ENGAGE', dialog.confirmLabel === 'ENGAGE');
check('confirming is deferred (no target until ENGAGE)',
dialog.targetUnchanged);
check('while the confirm is up the plate is inert (wheel does not zoom)',
dialog.plateInertWheel);
check('CANCEL closes the dialog, keeps the map open, plots no course',
dialogCancelled.hidden && dialogCancelled.mapStillOpen && dialogCancelled.targetUnchanged);
check('ESC cancels the confirm (map stays open, no course)',
dialogEsc.hidden && dialogEsc.mapStillOpen && dialogEsc.targetUnchanged);
check('clicking empty plate does NOT retarget (and opens no dialog)',
missClick.targetStayedNull && missClick.noDialog);
check('real hit test: a point over the object resolves the chart plate (mapImg)', realHit);
check('REAL canvas click over the object opens the confirm (full input pipeline)', realDialog);
check('REAL click on ENGAGE: course plotted AND the map closes',
realConfirm.targetWasSet && realConfirm.mapClosed && realConfirm.dialogHidden);
check('zoom in 2×: chart re-rendered at double scale (crisp, not stretched)',
zoomIn.z === 2 && Math.abs(zoomIn.scale - baseScale * 2) / baseScale < 0.01);
check('zoom in 2×: the point under the cursor stays under the cursor',
Math.abs(zoomIn.anchor.x - wAnchor.x) * baseScale < 0.5 && Math.abs(zoomIn.anchor.y - wAnchor.y) * baseScale < 0.5);
check('zoom out clamps at 1×', clampedMin === 1);
check('zoom in clamps at 8×', clampedMax === 8);
check('at 4× zoom: the object under the pointer still resolves (hover target)', zoomHit.objectAt);
check('at 4× zoom: clicking it opens the confirm', zoomHit.clickOpensDialog);
check('double-tap empty plate resets zoom to 1×', dblTap.tried && dblTap.reset);
check('wheel over the plate zooms in', wheelIn.moved && wheelIn.z > 1);
check('wheel over the chrome does NOT zoom the plate', wheelOut.z === wheelIn.z);
check('drag over the plate pans the zoomed view (exact dx/scale, dy/scale)',
pan.dx < 0.5 && pan.dy < 0.5);
check('drag keeps the zoom level', pan.z === 2);
check('drag pan clamps at the frame edge (and never overshoots it)', pan.clamped && pan.edgeSlip < 1e-6);
check('a drag does not select / does not confirm', pan.noTarget && pan.dragNoSelect);
check('drag clears the hover', pan.hoverCleared);
check('REAL mousedown→mousemove→mouseup pans through the input pipeline',
pan.realDx < 0.5 && pan.realDy < 0.5);
check('the system\'s zoom view is remembered (per-system memory)', viewMemory);
if (!keepOpen) {
check('after close: tooltip plate hidden (no lingering button)',
!after.ttPlate.effVisible);
@ -209,15 +529,17 @@ try {
check('after close: hover ring cleared',
!after.hoverG.effVisible || win.hoverG.commandBuffer.length === 0);
check('re-open: hover tooltip visible on the chart again', reopen.open && reopen.hoverVisible);
check('re-open: the system\'s zoom view persisted (per-system memory)',
reopen.zoomKept === true);
check('second close: tooltip fully hidden (no leftovers)',
after2.ttContHidden && after2.ttPlateHidden && after2.ttNameHidden && after2.ttHintHidden);
}
check('no console errors', errors.length === 0);
const pass = results.every((r) => r.pass);
window.__MAPCLICK = { ready: true, pass, results, before, click, missClick, after, reopen, after2, errors };
window.__MAPCLICK = { ready: true, pass, keepOpen, results, before, hoverDraw, dialog, dialogCancelled, dialogEsc, missClick, realDialog, realConfirm, realHit, zoom: { zoomIn, clampedMin, clampedMax, zoomHit, dblTap, wheelIn, wheelOut, pan, viewMemory }, after, reopen, after2, errors };
console.log(pass ? 'MAPCLICK PASS' : 'MAPCLICK FAIL');
console.log(JSON.stringify({ results, before, click, after }, null, 1));
console.log(JSON.stringify({ results, dialog, dialogCancelled, dialogEsc, realConfirm }, null, 1));
} catch (err) {
const results = [{ label: `THREW: ${err.message}`, pass: false }];
window.__MAPCLICK = { ready: true, pass: false, results, errors };

View File

@ -2852,8 +2852,13 @@ export class GameScene extends Phaser.Scene {
this.researchWindow.close();
return;
}
// The map console (depth 80) — the same contract.
// The map console (depth 80) — the same contract. Its autopilot
// confirm sits on top: ESC cancels the dialog, not the map.
if (this.mapWindow && this.mapWindow.isOpen) {
if (this.mapWindow.dialog && this.mapWindow.dialog.isOpen) {
this.mapWindow.dialog.cancel();
return;
}
this.mapWindow.close();
return;
}

View File

@ -234,16 +234,22 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this.lastTime = time;
if (this.state === 'hidden') return;
const p = Phaser.Math.Clamp((time - this.t0) / (this.state === 'opening' ? this.openDur : this.closeDur), 0, 1);
// 'shown' is the STEADY state: hold the open values. (Using the close
// math with the open's t0 — the old `state === 'opening' ? … : …` —
// collapsed the panel to a 2px line and dropped the scrim to zero the
// moment the open animation landed, leaving just the floating text and
// buttons over an un-dimmed window.)
const closing = this.state === 'closing';
const p = Phaser.Math.Clamp((time - this.t0) / (closing ? this.closeDur : this.openDur), 0, 1);
const e = p * p * (3 - 2 * p); // smoothstep
const h = this.state === 'opening' ? Math.max(2, e * this.h) : Math.max(2, (1 - e) * this.h);
const h = closing ? Math.max(2, (1 - e) * this.h) : Math.max(2, e * this.h);
this.drawPanel(h);
// The world behind goes quiet — the scrim rides the same curve.
this.scrim.setAlpha(0.72 * (this.state === 'opening' ? e : 1 - e));
this.scrim.setAlpha(0.72 * (closing ? 1 - e : e));
// RGB channels: converge on open (±start → 0, α .5 → 0), diverge on close.
const start = 10;
if (this.state === 'opening') {
if (!closing) {
const off = start * (1 - e);
const a = 0.5 * (1 - e);
this.ghostC.setAlpha(a).setPosition(off * 0.85, -off * 0.3);

View File

@ -29,11 +29,22 @@
* follows the ship every frame via `getShip()`), a scan sweep band, the
* hover highlight + tooltip, a click flash, the glitch bursts.
*
* Clicking a discovered object fires `onSelect(objectId)` the scene
* plots the course (autopilot). The window is a pure view: all system
* data arrives through the `getChart()` snapshot callback (GameScene);
* it is re-polled while open, and the canvas repaints on any change
* (discovery, tether radius, ).
* Clicking a discovered object opens the ENGAGE AUTOPILOT confirm (the
* save pop-up's ConfirmOverlay, over the plate) CONFIRM fires
* `onSelect(objectId)` (the scene plots the course) AND closes the
* console; CANCEL / scrim / ESC keep the map as it was. The window is a
* pure view: all system data arrives through the `getChart()` snapshot
* callback (GameScene); it is re-polled while open, and the canvas
* repaints on any change (discovery, tether radius, ).
*
* WHEEL ZOOM + DRAG PAN over the plate (data/map.json zoom): the
* chart is RE-PAINTED at the new scale (the painter's grid / labels
* adapt, so it stays crisp no blurry texture stretch), each system
* remembers its last view, and a double-tap on empty plate resets to
* 1×. While zoomed, a drag pans the view about the cursor. (Phaser 4
* delivers wheel + pointer events to interactive objects the plate's
* image carries the listeners, so they only fire over the plate; Phaser
* itself stops the page scroll on the canvas by default.)
*/
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
@ -45,6 +56,7 @@ import { Rng } from '../utils/Rng.js';
import { Tether } from '../tether/Tether.js';
import { chartBounds, fitToRect } from '../galaxy/SystemChart.js';
import { CyberShape } from './CyberShape.js';
import { ConfirmOverlay } from './ConfirmOverlay.js';
const TAU = Math.PI * 2;
const HEADER = fontStack('header');
@ -614,16 +626,35 @@ function drawVignette(ctx, w, h) {
* @param {number} w,h plate size in CSS px
* @param {number} dpr device pixel ratio (for the fog layer's sharpness)
* @param {object} snap the GameScene chart snapshot (see mapChartSnapshot)
* @returns {{tf:object, bounds:object, hits:Array<object>}} the worldplate
* transform + the hit list for pointer queries
* @param {{z:number,cx:number,cy:number}} [view] optional zoom view
* (z == 1 paints the whole system; z > 1 paints a z÷1 box of the
* full bounds centred on (cx, cy), clamped inside them)
* @returns {{tf:object, bounds:object, hits:Array<object>, fullBounds:object}}
* the worldplate transform + the hit list for pointer queries
* (+ the full un-zoomed frame, for clamping pans)
*/
function paintChart(ctx, w, h, dpr, snap) {
function paintChart(ctx, w, h, dpr, snap, view) {
const pad = config.get('map.bounds.padding', 1024);
// the frame: EVERY object (found or not) + the central body + the
// player's tether reach (a zone is part of the system's extent), padded
const boundsObjs = [...(snap.objects ?? []), ...(snap.tethers ?? [])];
if (snap.central) boundsObjs.push({ x: 0, y: 0, radius: snap.central.radius ?? 200 });
const bounds = chartBounds(boundsObjs, pad);
const fullBounds = chartBounds(boundsObjs, pad);
let bounds = fullBounds;
if (view && view.z > 1) {
// ZOOMED view: shrink the frame box by 1/z about (cx, cy) and clamp
// the centre so the visible box stays inside the full frame. The
// grid/labels below are scale-adaptive, so the repaint stays crisp.
const vw = fullBounds.w / view.z;
const vh = fullBounds.h / view.z;
bounds = {
...fullBounds,
cx: Phaser.Math.Clamp(view.cx, fullBounds.cx - (fullBounds.w - vw) / 2, fullBounds.cx + (fullBounds.w - vw) / 2),
cy: Phaser.Math.Clamp(view.cy, fullBounds.cy - (fullBounds.h - vh) / 2, fullBounds.cy + (fullBounds.h - vh) / 2),
w: vw,
h: vh,
};
}
const tf = fitToRect(bounds, w, h);
const pmin = config.get('map.chart.planetMin', 5);
const pmax = config.get('map.chart.planetMax', 13);
@ -705,7 +736,7 @@ function paintChart(ctx, w, h, dpr, snap) {
drawChrome(ctx, w, h, bounds, tf);
drawVignette(ctx, w, h);
return { tf, bounds, hits };
return { tf, bounds, hits, fullBounds };
}
export class MapWindow extends Phaser.GameObjects.Container {
@ -720,7 +751,8 @@ export class MapWindow extends Phaser.GameObjects.Container {
* @param {() => {x:number, y:number, heading:number}|null} [opts.getShip]
* the ship's live world position + heading (every frame)
* @param {(objectId: string) => void} [opts.onSelect] a discovered
* object was clicked on the chart (the scene plots the course)
* object was CONFIRMED on the chart (dialog ENGAGE) the scene
* plots the course (autopilot) and the console closes
* @param {() => void} [opts.onLocked] the (standby) GALAXY tab was hit
*/
constructor(scene, opts = {}) {
@ -747,6 +779,15 @@ export class MapWindow extends Phaser.GameObjects.Container {
this._hits = [];
this._hover = null;
this._flash = null;
// wheel zoom — current view = {z, cx, cy}: zoom factor + the world
// point at the plate centre (ignored when z == 1). _views keeps
// each system's last view so it is still there when we return.
this._view = { z: 1, cx: 0, cy: 0 };
this._views = new Map();
this._rafPending = false; // repaint queued by a wheel zoom
this._lastTap = 0; // double-tap reset clock (v4 has no dblclick)
this._drag = null; // press/drag state (click on release, pan on move)
this._full = null; // full un-zoomed frame bounds (pan clamping)
this._texN = 0;
this._texKey = null;
this._fontsRepaintDone = false; // the webfont repaint runs at most ONCE
@ -899,6 +940,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
this._paintClose();
});
this.closeG.on('pointerdown', () => {
if (this.dialog && this.dialog.isOpen) return; // the scrim's cancel owns the click
this.sfx('ui_click');
this.close();
});
@ -922,6 +964,21 @@ export class MapWindow extends Phaser.GameObjects.Container {
// first paint (GameScene already has its system — data is live now)
const snap = this._snapOf();
if (snap) this._applySnap(snap);
// autopilot CONFIRM (the save pop-up's dialog, js/ui/ConfirmOverlay.js)
// — shown over the plate before a click engages autopilot. A child of
// this window: it paints above the chrome (depth 30 > the glitch
// layers' 10), fades with the close tween, and dies with destroy().
this.dialog = new ConfirmOverlay(
this.scene,
this.geo.mapX + this.geo.mapW / 2, // over the plate
this.geo.mapY + this.geo.mapH / 2,
config.get('map.confirm.width', 440),
config.get('map.confirm.height', 168),
{ areaW: this.scene.scale.width, areaH: this.scene.scale.height }, // scrim = the whole window
);
this.dialog.setDepth(30);
this.add(this.dialog);
}
_ghost() {
@ -1197,6 +1254,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
_tabHit(entry) {
if (this.dialog && this.dialog.isOpen) return; // the dialog is the topmost thing
if (entry.standby) {
// GALAXY — built as standby for now: shake it + the scene toasts
this.sfx('ui_click');
@ -1246,6 +1304,15 @@ export class MapWindow extends Phaser.GameObjects.Container {
return key;
}
/** mapImg's hit area, in v4 frame space (frame top-left at (0,0)).
* The frame is the chart canvas (plate size × dpr), so this covers
* exactly the displayed plate. */
_refreshPlateHitArea() {
if (!this.mapImg?.input) return;
const f = this.mapImg.frame;
this.mapImg.input.hitArea = new Phaser.Geom.Rectangle(0, 0, f.width, f.height);
}
_buildPlate() {
const { mapX, mapY, mapW, mapH } = this.geo;
const s = this.scene.add;
@ -1256,23 +1323,36 @@ export class MapWindow extends Phaser.GameObjects.Container {
// Redrawn (new texture, old one dropped) whenever the snapshot changes.
this.mapImg = s.image(cx, cy, this._blankKey()).setScrollFactor(0).setDepth(1);
this.mapImg.setDisplaySize(mapW, mapH);
const hitRect = new Phaser.Geom.Rectangle(mapX, mapY, mapW, mapH);
// v4 input gotcha: the hit test maps the pointer into the image's
// UNSCALED frame space (frame top-left at (0,0)) before calling the
// hitAreaCallback — world-plate coordinates (as in v3) land a full
// display-origin off, so only the top-left quarter of the plate is
// clickable. Size the rect from the CURRENT frame and re-assert it
// on every texture swap (redraw does this).
const hitRect = new Phaser.Geom.Rectangle(0, 0, 2, 2); // matches the 2×2 blank key
this.mapImg.setInteractive({
useHandCursor: false,
hitArea: hitRect,
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
this.mapImg.on('pointermove', (p) => this._setHover(p));
this.mapImg.on('pointerout', () => this._setHover(null));
this.mapImg.on('pointerdown', (p) => {
const pt = this._platePoint(p);
const o = this._objectAt(pt);
if (o) {
this.sfx('ui_click');
this._flash = { x: o.x, y: o.y, r: o.r, t0: this.scene.time.now };
this.onSelect?.(o.id);
}
this._refreshPlateHitArea();
this.mapImg.on('pointerdown', (p) => this._startDrag(p));
this.mapImg.on('pointermove', (p) => {
if (this._drag) this._onDragMove(p);
else this._setHover(p);
});
// the CLICK (select / double-tap reset) fires on release, unless the
// press turned into a drag (pan) — so drag and tap coexist
this.mapImg.on('pointerup', () => this._finishDrag(true));
this.mapImg.on('pointerout', () => {
this._setHover(null);
if (this._drag) this._finishDrag(false); // left the plate → cancel click
});
// wheel zoom — Phaser 4's input plugin emits a `wheel` event on
// interactive objects under the pointer; mapImg's hit area IS the
// plate, so this only fires over the plate (Phaser also stops the
// page scroll on the canvas by default)
this.mapImg.on('wheel', (p, _dx, dy) => this._onWheelZoom(p, dy));
this.add(this.mapImg);
// frame chrome over the plate edge (brackets + border)
@ -1296,6 +1376,21 @@ export class MapWindow extends Phaser.GameObjects.Container {
this.feedDot = s.circle(mapX + 74, mapY + 12, 2.5, C.amber, 0.9).setScrollFactor(0).setDepth(3);
this.add(this.feedDot);
// wheel-zoom + pan hint (top-right of the plate, opposite the feed tag)
if (config.get('map.zoom.enabled', true) !== false) {
this.zoomTag = s
.text(mapX + mapW - 10, mapY + 8, 'SCROLL TO ZOOM · DRAG TO PAN · DOUBLE-TAP 1×', {
fontFamily: BODY,
fontSize: '9px',
color: toCss(C.faint),
letterSpacing: 2,
})
.setOrigin(1, 0)
.setScrollFactor(0)
.setDepth(3);
this.add(this.zoomTag);
}
// scan sweep band crawling across the plate (left → right, loop)
const swKey = 'map_plate_sweep';
if (!this.scene.textures.exists(swKey)) {
@ -1641,7 +1736,10 @@ export class MapWindow extends Phaser.GameObjects.Container {
const res = snap.stats?.res ?? { total: 0, found: 0, pct: 0 };
const total = nav.total + res.total;
const found = nav.found + res.found;
this.statusTxt.setText(total > 0 ? `OBJECTS ${total} · CHARTED ${found}` : 'NO OBJECTS LOGGED');
let txt = total > 0 ? `OBJECTS ${total} · CHARTED ${found}` : 'NO OBJECTS LOGGED';
const z = this._view?.z ?? 1;
if (z > 1.01) txt += ` · MAG ${z.toFixed(1)}×`;
this.statusTxt.setText(txt);
this.statusTxt.setColor(total > 0 ? toCss(C.ink) : toCss(C.faint));
const g = this.statusBar;
g.clear();
@ -1674,8 +1772,11 @@ export class MapWindow extends Phaser.GameObjects.Container {
this._snap = snap;
this._tf = null;
this._bounds = null;
this._full = null;
this._hits = [];
this._hover = null;
// restore this system's remembered view (fresh = the whole system)
this._view = (snap.systemId != null && this._views.get(snap.systemId)) || { z: 1, cx: 0, cy: 0 };
this.redraw();
this._paintStats();
this._paintStatusStrip();
@ -1694,9 +1795,10 @@ export class MapWindow extends Phaser.GameObjects.Container {
canvas.height = Math.max(8, Math.round(mapH * dpr));
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const out = paintChart(ctx, mapW, mapH, dpr, snap);
const out = paintChart(ctx, mapW, mapH, dpr, snap, this._view);
this._tf = out.tf;
this._bounds = out.bounds;
this._full = out.fullBounds;
this._hits = out.hits;
const prev = this._texKey;
const key = `map_chart_${this._texN++}`;
@ -1707,6 +1809,9 @@ export class MapWindow extends Phaser.GameObjects.Container {
// the image was born on the 2×2 blank key, so the display size must
// be re-asserted on every texture swap or it renders ~415× oversized.
this.mapImg.setDisplaySize(mapW, mapH);
// …and the hit area is sized from the frame, so it must follow too
// (a stale 2×2 rect makes the plate almost entirely unclickable).
this._refreshPlateHitArea();
if (prev && prev !== key) this.scene.textures.remove(prev);
this._texKey = key;
// The canvas paints with the fallback face until the webfonts land —
@ -1736,7 +1841,9 @@ export class MapWindow extends Phaser.GameObjects.Container {
close() {
if (this.openState === 'closed' || this.openState === 'closing') return;
if (this.dialog && this.dialog.isOpen) this.dialog.cancel(); // the dialog goes first
this.openState = 'closing';
this._drag = null; // a press that outlives the window is no one's click
this.video?.pause?.();
this.sfx('ui_close');
this._setHover(null);
@ -1784,6 +1891,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
push(this.plateFrame, 500, 300, 'fade');
push(this.feedTag, 560, 260, 'fade');
push(this.feedDot, 560, 260, 'fade');
if (this.zoomTag) push(this.zoomTag, 560, 260, 'fade');
push(this.statsG, 620, 300, 'fade');
this.sysNameTxt.y = this.geo.statsY + 10;
push(this.sysNameTxt, 620, 300, 'fade');
@ -1837,6 +1945,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
// ------------------------------------------------------------ per-frame
update(time) {
if (this.dialog) this.dialog.update(time); // the autopilot confirm, if up
if (!this.isOpen) return;
// reveal timeline
@ -1983,6 +2092,182 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
}
// ------------------------------------------------------------ wheel zoom
/** Wheel zoom (object-level `wheel` event from Phaser 4's input
* plugin mapImg's hit area is the plate, so this only fires over
* the plate). `p` is the pointer (game coords), `dy` the raw deltaY. */
_onWheelZoom(p, dy) {
if (this._destroyed || !this.isOpen || !this._tf || (this.dialog && this.dialog.isOpen)) return;
const z = config.get('map.zoom', {});
if (z.enabled === false) return;
const dyPx = dy * ((p.event?.deltaMode ?? 0) === 1 ? 32 : 1); // line-mode → px
if (!dyPx) return;
const { mapX, mapY } = this.geo;
this._zoomAt(p.x - mapX, p.y - mapY, Math.exp(-dyPx * (z.sensitivity ?? 0.0022)), z.zMin ?? 1, z.zMax ?? 8);
}
/** Zoom about a plate point (px,py in plate-local px) by `factor`. */
_zoomAt(px, py, factor, zMin = 1, zMax = 8) {
const tf = this._tf;
if (!tf || !this._snap) return;
const z0 = this._view.z;
const z1 = Phaser.Math.Clamp(z0 * factor, zMin, zMax);
if (z1 === z0) return;
// the world point under the cursor, in the CURRENT view
const wx = (px - tf.ox) / tf.scale;
const wy = (py - tf.oy) / tf.scale;
// solve the new centre so that point stays under the cursor when the
// plate re-fits at scale (tf.scale / z0) * z1
const s1 = (tf.scale / z0) * z1;
this._view = {
z: z1,
cx: wx - (px - this.geo.mapW / 2) / s1,
cy: wy - (py - this.geo.mapH / 2) / s1,
};
this._saveView();
this._queueRedraw();
}
_resetZoom() {
if (this._view.z <= 1) return;
this._view = { z: 1, cx: 0, cy: 0 };
this._saveView();
this._hover = null;
this._queueRedraw();
}
/** Remember this system's view so it is still there when we return. */
_saveView() {
const id = this._snap?.systemId;
if (id != null) this._views.set(id, { ...this._view });
}
/** One repaint per frame, no matter how fast the wheel spins. */
_queueRedraw() {
if (this._rafPending) return;
this._rafPending = true;
requestAnimationFrame(() => {
this._rafPending = false;
if (this._destroyed) return;
this._hover = null; // every hit position moved under the new view
this.redraw();
this._paintHover();
this._paintStatusStrip();
});
}
// ------------------------------------------------------------ drag pan
/** A press on the plate. Nothing happens yet — the click (select /
* double-tap reset) fires on release, unless the pointer moves past
* the slop, in which case the drag pans the zoomed view instead. */
_startDrag(p) {
if (this._destroyed || (this.dialog && this.dialog.isOpen)) return;
this._drag = {
x: p.x,
y: p.y,
moved: false,
over: this._objectAt(this._platePoint(p)),
};
}
_onDragMove(p) {
const d = this._drag;
if (!d) return;
const dx = p.x - d.x;
const dy = p.y - d.y;
if (!d.moved) {
if (Math.hypot(dx, dy) < 5) return; // still a click, not a drag
d.moved = true;
this._hover = null; // the drag owns the plate from here on
this._paintHover();
if (this._view.z <= 1) return; // nothing to pan at 1×
}
d.x = p.x;
d.y = p.y;
this._panBy(dx, dy);
}
/** Release after a press. If it dragged, the pan was applied as it
* went and the click is cancelled. Otherwise it's a genuine click:
* select the object under the press, or (empty plate) a double-tap
* zoom reset. Releasing off the plate cancels the click. */
_finishDrag(releaseInside) {
const d = this._drag;
this._drag = null;
if (!d || this._destroyed) return;
if (d.moved) {
this._lastTap = 0; // a drag is not a reset tap
this._saveView();
return;
}
if (!releaseInside) return;
const now = this.scene.time.now;
const o = d.over;
if (o) {
this.sfx('ui_click');
this._lastTap = 0; // a selection is not a zoom-reset tap
this._askAutopilot(o); // confirm first — CONFIRM plots the course + closes
return;
}
// double-tap on EMPTY plate → reset zoom (v4 has no dblclick event)
if (this._view.z > 1 && now - this._lastTap < 320) {
this._lastTap = 0;
this.sfx('ui_window');
this._resetZoom();
return;
}
this._lastTap = now;
}
/** Pan the zoomed view by plate pixels: the point under the cursor
* stays under the cursor, the centre stays inside the full frame. */
_panBy(dpx, dpy) {
const tf = this._tf;
const full = this._full;
if (!tf || !full || this._view.z <= 1) return;
const z = this._view.z;
const vw = full.w / z;
const vh = full.h / z;
this._view = {
z,
cx: Phaser.Math.Clamp(this._view.cx - dpx / tf.scale, full.cx - (full.w - vw) / 2, full.cx + (full.w - vw) / 2),
cy: Phaser.Math.Clamp(this._view.cy - dpy / tf.scale, full.cy - (full.h - vh) / 2, full.cy + (full.h - vh) / 2),
};
this._saveView();
this._queueRedraw();
}
/**
* Autopilot is a commitment ask before engaging (the save pop-up's
* ConfirmOverlay, over the plate). CONFIRM fires onSelect (the scene
* plots the course) and closes the console; CANCEL, the scrim, or ESC
* leave the map exactly as it was. While the dialog is up the plate is
* inert (the guards in _startDrag / _onWheelZoom / _setHover the
* scrim covers the whole window, so it also takes the clicks meant for
* the chrome, and its own pointerdown IS the cancel).
*/
_askAutopilot(o) {
this._hover = null; // the dialog speaks for the pick
this._paintHover();
const label = String(o.label ?? o.id).toUpperCase();
const body = [`DESTINATION — ${label}`];
const sh = this.getShip?.();
if (sh) body[1] = `DISTANCE ${Math.round(Math.hypot(o.x - sh.x, o.y - sh.y)).toLocaleString('en-US')}`;
this.dialog.show({
title: config.get('map.confirm.title', 'ENGAGE AUTOPILOT'),
body,
accent: C.amber,
confirmLabel: config.get('map.confirm.label', 'ENGAGE'),
onConfirm: () => {
// the pick ripples once while the window fades out
this._flash = { x: o.x + this.geo.mapX, y: o.y + this.geo.mapY, r: o.r, t0: this.scene.time.now };
this.onSelect?.(o.id);
this.close();
},
time: this.scene.time.now,
});
}
// ------------------------------------------------------------ pointer
_platePoint(p) {
return { x: p.x - this.geo.mapX, y: p.y - this.geo.mapY };
@ -2003,6 +2288,11 @@ export class MapWindow extends Phaser.GameObjects.Container {
}
_setHover(p) {
if (this.dialog && this.dialog.isOpen) {
// the confirm owns the plate — no hover churn under the scrim
if (this._hover) { this._hover = null; this._paintHover(); }
return;
}
if (!this.isOpen || p === null) {
this._hover = null;
this._paintHover();
@ -2029,26 +2319,33 @@ export class MapWindow extends Phaser.GameObjects.Container {
return;
}
const o = this._hover;
// hits are PLATE-LOCAL (plate top-left = 0,0); hoverG draws in world
// coords, and the plate sits at geo.mapX/mapY — so the destination
// end of everything below is offset to world first. (The ship end
// already was: a line from a world point to a plate-local point is
// why the course line used to miss its destination.)
const ox = o.x + this.geo.mapX;
const oy = o.y + this.geo.mapY;
// highlight ring + soft glow
g.lineStyle(1.5, C.neon, 0.9);
g.strokeCircle(o.x, o.y, o.r + 5);
g.strokeCircle(ox, oy, o.r + 5);
g.lineStyle(4, C.neon, 0.18);
g.strokeCircle(o.x, o.y, o.r + 9);
g.strokeCircle(ox, oy, o.r + 9);
// course line from the ship (if we have a live position)
const sh = this.getShip?.();
if (sh && this._tf) {
const sx = this.geo.mapX + this._tf.toX(sh.x);
const sy = this.geo.mapY + this._tf.toY(sh.y);
g.lineStyle(1, C.amber, 0.5);
g.strokeLineShape(new Phaser.Geom.Line(sx, sy, o.x, o.y));
g.strokeLineShape(new Phaser.Geom.Line(sx, sy, ox, oy));
g.fillStyle(C.amber, 0.7);
g.fillCircle(o.x, o.y, 2);
g.fillCircle(ox, oy, 2);
}
// tooltip above the object (clamped inside the plate)
this.ttName.setText(String(o.label ?? o.id).toUpperCase());
const ttW = Math.max(this.ttName.width, this.ttHint.width) + 24;
let tx = o.x + this.geo.mapX;
let ty = o.y + this.geo.mapY - o.r - 16;
let tx = ox;
let ty = oy - o.r - 16;
tx = Phaser.Math.Clamp(tx, this.geo.mapX + ttW / 2 + 6, this.geo.mapX + this.geo.mapW - ttW / 2 - 6);
ty = Math.max(ty, this.geo.mapY + 46);
this.ttPlate.clear();