Compare commits

..

No commits in common. "642c2cc0165f49d274d1b9fced6cec9e1a2af8ad" and "94cc87a1776d0225427b9c588b95fe65358b663e" have entirely different histories.

10 changed files with 52 additions and 818 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -138,23 +138,9 @@
"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

@ -1,17 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — Map confirm (dev probe)</title>
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/dialog-shot.mjs"></script>
</body>
</html>

View File

@ -1,105 +0,0 @@
/**
* Dev-only: boot GameScene, open the MAP console, hover a discovered
* object and open the ENGAGE AUTOPILOT confirm leave it up for a
* screenshot (the dialog over the plate, scrim darkening the window).
*
* node dev/server.mjs 8091
* node dev/shot-firefox.mjs \
* "http://127.0.0.1:8091/dev/dialog-shot.html" \
* "window.__DIALOG_SHOT ? window.__DIALOG_SHOT.ready : null" \
* /tmp/dialog-shot.png 30000
*/
import Phaser from '../js/vendor/phaser.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { config } from '../js/config/Config.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
const data = await ConfigLoader.load();
config.init(data);
const errors = [];
const origErr = console.error.bind(console);
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
window.addEventListener('error', (e) => errors.push(String(e.message)));
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
const report = document.createElement('pre');
report.id = 'report';
report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
document.body.appendChild(report);
const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
setReport('booting…');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitScene() {
for (let i = 0; i < 100; i++) {
const s = game.scene.getScene('GameScene');
if (s && s.ship && s.mapWindow) return s;
await sleep(100);
}
throw new Error('GameScene never booted');
}
try {
const s = await waitScene();
await sleep(800);
const sysId = s.systemRecord.id;
let known = s.discovery.bySystem.get(sysId);
if (!known) { known = new Set(); s.discovery.bySystem.set(sysId, known); }
const push = (obj) => { if (obj && !known.has(obj.id)) s.discovery.check(sysId, obj.x, obj.y, [obj]); };
if (s.isHomeSystem && s.planet) push({ id: 'home', x: 0, y: 0, radius: s.planet.radius });
if (s.systemPlanets[0]) push({ id: s.systemPlanets[0].discoveryId, x: s.systemPlanets[0].x, y: s.systemPlanets[0].y, radius: s.systemPlanets[0].radius });
if (s.systemStations[0]) push({ id: s.systemStations[0].discoveryId, x: s.systemStations[0].x, y: s.systemStations[0].y, radius: s.systemStations[0].bound ?? 60 });
if (s.asteroidClusters?.[0]) push({ id: s.asteroidClusters[0].discoveryId, x: s.asteroidClusters[0].x, y: s.asteroidClusters[0].y, radius: s.asteroidClusters[0].bound ?? 100 });
s.deckAction('map');
await sleep(1600);
const win = s.mapWindow;
const snap = win._snapOf();
if (snap && win._fp !== win._fpOf(snap)) win._applySnap(snap);
if (!win._hits?.length) throw new Error('no hits on the chart');
const hit = win._hits[0];
win._setHover({ x: win.geo.mapX + hit.x, y: win.geo.mapY + hit.y });
await sleep(150);
// open the confirm (what a click does) and leave it up
win.mapImg.emit('pointerdown', { x: win.geo.mapX + hit.x, y: win.geo.mapY + hit.y });
win.mapImg.emit('pointerup', {});
await sleep(700); // open anim + title decode
const dlg = win.dialog;
const frameAt = (dt) => new Promise((r) => setTimeout(r, dt));
const probe = (label) => ({
[label]: {
state: dlg.state,
scrimAlpha: +dlg.scrim.alpha.toFixed(3),
panelCmds: dlg.panelG.commandBuffer.length,
},
});
const probeShown = probe('at700ms');
await frameAt(300);
const probeLater = probe('at1000ms');
const lines = [
errors.length === 0 ? 'DIALOG OK — no console errors' : `ERRORS:\n${errors.slice(0, 4).join('\n')}`,
`window: ${win.openState} · dialog: ${dlg.state}`,
`title: ${dlg.title.text || '—'}`,
`body: ${dlg.bodyTexts.map((t) => t.text).join(' | ') || '—'}`,
`confirm: ${dlg.confirmBtn.labelText.text} · cancel: ${dlg.cancelBtn.labelText.text}`,
`target: ${s.ship.target ? 'set (BUG — must be null until ENGAGE)' : 'null (correct — deferred)'}`,
`probe ${JSON.stringify(probeShown)} ${JSON.stringify(probeLater)}`,
];
setReport(lines);
window.__DIALOG_SHOT = { ready: true, lines, errors, probe: { ...probeShown, ...probeLater }, pass: dlg.isOpen && s.ship.target === null && errors.length === 0 && probeLater.at1000ms.scrimAlpha > 0.5 };
} catch (err) {
errors.push(`FATAL: ${err.message}`);
setReport(['FATAL: ' + err.message, ...errors.slice(0, 4)]);
window.__DIALOG_SHOT = { ready: true, fatal: String(err.message), errors };
}

View File

@ -5,11 +5,7 @@
* 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 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).
* 3. click it autopilot (ship.target set)
* 4. close the map NOTHING of the tooltip may linger on screen
*
* The known bug: ttPlate/ttName/ttSub/ttHint are created with
@ -98,7 +94,6 @@ 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;
@ -119,21 +114,6 @@ 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);
@ -150,282 +130,30 @@ try {
mapImg: { inDL: iMapImg >= 0, interactive: !!(win.mapImg.input && win.mapImg.input.enabled) },
};
// ---- 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;
// ---- 2: click the object (through the real handler) --------------------
const targetBefore = s.ship.target;
win.mapImg.emit('pointerdown', { x: sx, y: sy });
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,
};
// ---- 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, 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 = {
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,
mapClosed: win.openState === 'closed',
dialogHidden: dlg.state === 'hidden',
flash: !!win._flash,
};
// 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,
};
// a click on EMPTY plate must not retarget
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);
win.mapImg.emit('pointerdown', { x: win.geo.mapX + 4, y: win.geo.mapY + 4 });
await sleep(120);
const missClick = { targetStayedNull: s.ship.target === null };
// ?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) {
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;
const pass = click.targetWasSet && missClick.targetStayedNull;
window.__MAPCLICK = {
ready: true, pass, keepOpen: true, before, dialog, dialogCancelled, dialogEsc, missClick, realDialog, realConfirm, realHit, errors,
ready: true, pass, keepOpen: true, before, click, missClick, errors,
};
console.log('MAPCLICK (keepOpen) ready');
} else {
@ -445,15 +173,9 @@ try {
// ---- 4: re-open, hover, close again (no leftovers, no crash) -----------
win.open();
await sleep(1800);
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 });
win._setHover({ x: sx, y: sy });
await sleep(120);
reopen = {
open: win.isOpen,
hoverVisible: effVisible(win.ttName),
hoverId: win._hover?.id ?? null,
zoomKept: win._view.z === wheelIn.z,
};
reopen = { open: win.isOpen, hoverVisible: effVisible(win.ttName) };
win.close();
await sleep(700);
after2 = {
@ -476,51 +198,9 @@ 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('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);
check('clicking a discovered object sets the ship target (autopilot)',
click.targetWasSet);
check('clicking empty plate does NOT retarget', missClick.targetStayedNull);
if (!keepOpen) {
check('after close: tooltip plate hidden (no lingering button)',
!after.ttPlate.effVisible);
@ -529,17 +209,15 @@ 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, 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 };
window.__MAPCLICK = { ready: true, pass, results, before, click, missClick, after, reopen, after2, errors };
console.log(pass ? 'MAPCLICK PASS' : 'MAPCLICK FAIL');
console.log(JSON.stringify({ results, dialog, dialogCancelled, dialogEsc, realConfirm }, null, 1));
console.log(JSON.stringify({ results, before, click, after }, null, 1));
} catch (err) {
const results = [{ label: `THREW: ${err.message}`, pass: false }];
window.__MAPCLICK = { ready: true, pass: false, results, errors };

View File

@ -2852,13 +2852,8 @@ export class GameScene extends Phaser.Scene {
this.researchWindow.close();
return;
}
// The map console (depth 80) — the same contract. Its autopilot
// confirm sits on top: ESC cancels the dialog, not the map.
// The map console (depth 80) — the same contract.
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,22 +234,16 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this.lastTime = time;
if (this.state === 'hidden') return;
// '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 p = Phaser.Math.Clamp((time - this.t0) / (this.state === 'opening' ? this.openDur : this.closeDur), 0, 1);
const e = p * p * (3 - 2 * p); // smoothstep
const h = closing ? Math.max(2, (1 - e) * this.h) : Math.max(2, e * this.h);
const h = this.state === 'opening' ? Math.max(2, e * this.h) : Math.max(2, (1 - e) * this.h);
this.drawPanel(h);
// The world behind goes quiet — the scrim rides the same curve.
this.scrim.setAlpha(0.72 * (closing ? 1 - e : e));
this.scrim.setAlpha(0.72 * (this.state === 'opening' ? e : 1 - e));
// RGB channels: converge on open (±start → 0, α .5 → 0), diverge on close.
const start = 10;
if (!closing) {
if (this.state === 'opening') {
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,22 +29,11 @@
* 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 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.)
* 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, ).
*/
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
@ -56,7 +45,6 @@ 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');
@ -626,35 +614,16 @@ 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)
* @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)
* @returns {{tf:object, bounds:object, hits:Array<object>}} the worldplate
* transform + the hit list for pointer queries
*/
function paintChart(ctx, w, h, dpr, snap, view) {
function paintChart(ctx, w, h, dpr, snap) {
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 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 bounds = chartBounds(boundsObjs, pad);
const tf = fitToRect(bounds, w, h);
const pmin = config.get('map.chart.planetMin', 5);
const pmax = config.get('map.chart.planetMax', 13);
@ -736,7 +705,7 @@ function paintChart(ctx, w, h, dpr, snap, view) {
drawChrome(ctx, w, h, bounds, tf);
drawVignette(ctx, w, h);
return { tf, bounds, hits, fullBounds };
return { tf, bounds, hits };
}
export class MapWindow extends Phaser.GameObjects.Container {
@ -751,8 +720,7 @@ 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 CONFIRMED on the chart (dialog ENGAGE) the scene
* plots the course (autopilot) and the console closes
* object was clicked on the chart (the scene plots the course)
* @param {() => void} [opts.onLocked] the (standby) GALAXY tab was hit
*/
constructor(scene, opts = {}) {
@ -779,15 +747,6 @@ 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
@ -940,7 +899,6 @@ 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();
});
@ -964,21 +922,6 @@ 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() {
@ -1254,7 +1197,6 @@ 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');
@ -1304,15 +1246,6 @@ 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;
@ -1323,36 +1256,23 @@ 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);
// 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
const hitRect = new Phaser.Geom.Rectangle(mapX, mapY, mapW, mapH);
this.mapImg.setInteractive({
useHandCursor: false,
hitArea: hitRect,
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
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);
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);
}
});
// 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)
@ -1376,21 +1296,6 @@ 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)) {
@ -1736,10 +1641,7 @@ 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;
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.setText(total > 0 ? `OBJECTS ${total} · CHARTED ${found}` : 'NO OBJECTS LOGGED');
this.statusTxt.setColor(total > 0 ? toCss(C.ink) : toCss(C.faint));
const g = this.statusBar;
g.clear();
@ -1772,11 +1674,8 @@ 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();
@ -1795,10 +1694,9 @@ 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, this._view);
const out = paintChart(ctx, mapW, mapH, dpr, snap);
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++}`;
@ -1809,9 +1707,6 @@ 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 —
@ -1841,9 +1736,7 @@ 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);
@ -1891,7 +1784,6 @@ 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');
@ -1945,7 +1837,6 @@ 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
@ -2092,182 +1983,6 @@ 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 };
@ -2288,11 +2003,6 @@ 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();
@ -2319,33 +2029,26 @@ 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(ox, oy, o.r + 5);
g.strokeCircle(o.x, o.y, o.r + 5);
g.lineStyle(4, C.neon, 0.18);
g.strokeCircle(ox, oy, o.r + 9);
g.strokeCircle(o.x, o.y, 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, ox, oy));
g.strokeLineShape(new Phaser.Geom.Line(sx, sy, o.x, o.y));
g.fillStyle(C.amber, 0.7);
g.fillCircle(ox, oy, 2);
g.fillCircle(o.x, o.y, 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 = ox;
let ty = oy - o.r - 16;
let tx = o.x + this.geo.mapX;
let ty = o.y + this.geo.mapY - 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();