diff --git a/assets/videos/rocky-land-02.mp4 b/assets/videos/rocky-land-02.mp4 new file mode 100644 index 0000000..46239c2 Binary files /dev/null and b/assets/videos/rocky-land-02.mp4 differ diff --git a/assets/videos/rocky-takeoff-02.mp4 b/assets/videos/rocky-takeoff-02.mp4 new file mode 100644 index 0000000..ab954fe Binary files /dev/null and b/assets/videos/rocky-takeoff-02.mp4 differ diff --git a/data/map.json b/data/map.json index 6eb74db..2ac300e 100644 --- a/data/map.json +++ b/data/map.json @@ -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" } } \ No newline at end of file diff --git a/dev/map-click.mjs b/dev/map-click.mjs index cb2aa8e..8a6e32b 100644 --- a/dev/map-click.mjs +++ b/dev/map-click.mjs @@ -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 }; diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 56b7766..2f526d2 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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; } diff --git a/js/ui/ConfirmOverlay.js b/js/ui/ConfirmOverlay.js index 98e6cc1..7ea53e6 100644 --- a/js/ui/ConfirmOverlay.js +++ b/js/ui/ConfirmOverlay.js @@ -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); diff --git a/js/ui/MapWindow.js b/js/ui/MapWindow.js index 7125e41..54c0c5d 100644 --- a/js/ui/MapWindow.js +++ b/js/ui/MapWindow.js @@ -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}} — 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, 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();