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:
parent
94cc87a177
commit
aa8cf8e091
Binary file not shown.
Binary file not shown.
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 world→plate
|
||||
* 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 world→plate 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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue