Compare commits
No commits in common. "642c2cc0165f49d274d1b9fced6cec9e1a2af8ad" and "94cc87a1776d0225427b9c588b95fe65358b663e" have entirely different histories.
642c2cc016
...
94cc87a177
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -138,23 +138,9 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"radiusSlop": 9
|
"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": {
|
"clickToast": {
|
||||||
"_comment": "Plate interaction copy.",
|
"_comment": "Plate interaction copy.",
|
||||||
"coursePlotted": "COURSE PLOTTED — {name}",
|
"coursePlotted": "COURSE PLOTTED — {name}",
|
||||||
"noCourse": "NO NAV LOCK — OBJECT NOT CHARTED"
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<base href="../" />
|
|
||||||
<title>Orbit — Map confirm (dev probe)</title>
|
|
||||||
<style>
|
|
||||||
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
|
||||||
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
|
||||||
</style>
|
|
||||||
<script src="lib/phaser.min.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="game"></div>
|
|
||||||
<script type="module" src="dev/dialog-shot.mjs"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
/**
|
|
||||||
* Dev-only: boot GameScene, open the MAP console, hover a discovered
|
|
||||||
* object and open the ENGAGE AUTOPILOT confirm — leave it up for a
|
|
||||||
* screenshot (the dialog over the plate, scrim darkening the window).
|
|
||||||
*
|
|
||||||
* node dev/server.mjs 8091
|
|
||||||
* node dev/shot-firefox.mjs \
|
|
||||||
* "http://127.0.0.1:8091/dev/dialog-shot.html" \
|
|
||||||
* "window.__DIALOG_SHOT ? window.__DIALOG_SHOT.ready : null" \
|
|
||||||
* /tmp/dialog-shot.png 30000
|
|
||||||
*/
|
|
||||||
import Phaser from '../js/vendor/phaser.js';
|
|
||||||
import { ConfigLoader } from '../js/config/ConfigLoader.js';
|
|
||||||
import { config } from '../js/config/Config.js';
|
|
||||||
import { createGameConfig } from '../js/config/GameConfig.js';
|
|
||||||
import { GameScene } from '../js/scenes/GameScene.js';
|
|
||||||
|
|
||||||
const data = await ConfigLoader.load();
|
|
||||||
config.init(data);
|
|
||||||
|
|
||||||
const errors = [];
|
|
||||||
const origErr = console.error.bind(console);
|
|
||||||
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
|
|
||||||
window.addEventListener('error', (e) => errors.push(String(e.message)));
|
|
||||||
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
|
|
||||||
|
|
||||||
const gameConfig = createGameConfig();
|
|
||||||
gameConfig.scene = [GameScene];
|
|
||||||
const game = new Phaser.Game(gameConfig);
|
|
||||||
window.game = game;
|
|
||||||
|
|
||||||
const report = document.createElement('pre');
|
|
||||||
report.id = 'report';
|
|
||||||
report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
|
|
||||||
document.body.appendChild(report);
|
|
||||||
const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
|
|
||||||
setReport('booting…');
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
async function waitScene() {
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
const s = game.scene.getScene('GameScene');
|
|
||||||
if (s && s.ship && s.mapWindow) return s;
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
throw new Error('GameScene never booted');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const s = await waitScene();
|
|
||||||
await sleep(800);
|
|
||||||
|
|
||||||
const sysId = s.systemRecord.id;
|
|
||||||
let known = s.discovery.bySystem.get(sysId);
|
|
||||||
if (!known) { known = new Set(); s.discovery.bySystem.set(sysId, known); }
|
|
||||||
const push = (obj) => { if (obj && !known.has(obj.id)) s.discovery.check(sysId, obj.x, obj.y, [obj]); };
|
|
||||||
if (s.isHomeSystem && s.planet) push({ id: 'home', x: 0, y: 0, radius: s.planet.radius });
|
|
||||||
if (s.systemPlanets[0]) push({ id: s.systemPlanets[0].discoveryId, x: s.systemPlanets[0].x, y: s.systemPlanets[0].y, radius: s.systemPlanets[0].radius });
|
|
||||||
if (s.systemStations[0]) push({ id: s.systemStations[0].discoveryId, x: s.systemStations[0].x, y: s.systemStations[0].y, radius: s.systemStations[0].bound ?? 60 });
|
|
||||||
if (s.asteroidClusters?.[0]) push({ id: s.asteroidClusters[0].discoveryId, x: s.asteroidClusters[0].x, y: s.asteroidClusters[0].y, radius: s.asteroidClusters[0].bound ?? 100 });
|
|
||||||
|
|
||||||
s.deckAction('map');
|
|
||||||
await sleep(1600);
|
|
||||||
const win = s.mapWindow;
|
|
||||||
|
|
||||||
const snap = win._snapOf();
|
|
||||||
if (snap && win._fp !== win._fpOf(snap)) win._applySnap(snap);
|
|
||||||
if (!win._hits?.length) throw new Error('no hits on the chart');
|
|
||||||
const hit = win._hits[0];
|
|
||||||
win._setHover({ x: win.geo.mapX + hit.x, y: win.geo.mapY + hit.y });
|
|
||||||
await sleep(150);
|
|
||||||
// open the confirm (what a click does) and leave it up
|
|
||||||
win.mapImg.emit('pointerdown', { x: win.geo.mapX + hit.x, y: win.geo.mapY + hit.y });
|
|
||||||
win.mapImg.emit('pointerup', {});
|
|
||||||
await sleep(700); // open anim + title decode
|
|
||||||
|
|
||||||
const dlg = win.dialog;
|
|
||||||
const frameAt = (dt) => new Promise((r) => setTimeout(r, dt));
|
|
||||||
const probe = (label) => ({
|
|
||||||
[label]: {
|
|
||||||
state: dlg.state,
|
|
||||||
scrimAlpha: +dlg.scrim.alpha.toFixed(3),
|
|
||||||
panelCmds: dlg.panelG.commandBuffer.length,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const probeShown = probe('at700ms');
|
|
||||||
await frameAt(300);
|
|
||||||
const probeLater = probe('at1000ms');
|
|
||||||
const lines = [
|
|
||||||
errors.length === 0 ? 'DIALOG OK — no console errors' : `ERRORS:\n${errors.slice(0, 4).join('\n')}`,
|
|
||||||
`window: ${win.openState} · dialog: ${dlg.state}`,
|
|
||||||
`title: ${dlg.title.text || '—'}`,
|
|
||||||
`body: ${dlg.bodyTexts.map((t) => t.text).join(' | ') || '—'}`,
|
|
||||||
`confirm: ${dlg.confirmBtn.labelText.text} · cancel: ${dlg.cancelBtn.labelText.text}`,
|
|
||||||
`target: ${s.ship.target ? 'set (BUG — must be null until ENGAGE)' : 'null (correct — deferred)'}`,
|
|
||||||
`probe ${JSON.stringify(probeShown)} ${JSON.stringify(probeLater)}`,
|
|
||||||
];
|
|
||||||
setReport(lines);
|
|
||||||
window.__DIALOG_SHOT = { ready: true, lines, errors, probe: { ...probeShown, ...probeLater }, pass: dlg.isOpen && s.ship.target === null && errors.length === 0 && probeLater.at1000ms.scrimAlpha > 0.5 };
|
|
||||||
} catch (err) {
|
|
||||||
errors.push(`FATAL: ${err.message}`);
|
|
||||||
setReport(['FATAL: ' + err.message, ...errors.slice(0, 4)]);
|
|
||||||
window.__DIALOG_SHOT = { ready: true, fatal: String(err.message), errors };
|
|
||||||
}
|
|
||||||
|
|
@ -5,11 +5,7 @@
|
||||||
* 1. open the map console (deck MAP button)
|
* 1. open the map console (deck MAP button)
|
||||||
* 2. hover a discovered object on the chart → the tooltip
|
* 2. hover a discovered object on the chart → the tooltip
|
||||||
* (name + "TAP TO PLOT COURSE") must be part of the window
|
* (name + "TAP TO PLOT COURSE") must be part of the window
|
||||||
* 3. click it → the ENGAGE AUTOPILOT confirm opens (autopilot is a
|
* 3. click it → autopilot (ship.target set)
|
||||||
* 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
|
* 4. close the map → NOTHING of the tooltip may linger on screen
|
||||||
*
|
*
|
||||||
* The known bug: ttPlate/ttName/ttSub/ttHint are created with
|
* The known bug: ttPlate/ttName/ttSub/ttHint are created with
|
||||||
|
|
@ -98,7 +94,6 @@ try {
|
||||||
if (!win._hits || win._hits.length === 0) throw new Error('no hits on the chart');
|
if (!win._hits || win._hits.length === 0) throw new Error('no hits on the chart');
|
||||||
|
|
||||||
const hit = win._hits[0];
|
const hit = win._hits[0];
|
||||||
const hitLabel = String(hit.label ?? hit.id).toUpperCase();
|
|
||||||
const sx = win.geo.mapX + hit.x;
|
const sx = win.geo.mapX + hit.x;
|
||||||
const sy = win.geo.mapY + hit.y;
|
const sy = win.geo.mapY + hit.y;
|
||||||
|
|
||||||
|
|
@ -119,21 +114,6 @@ try {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Real-input helpers (shared by the dialog + zoom + pan sections)
|
|
||||||
const frames = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
||||||
const sm = win.scene.scale;
|
|
||||||
const cb = sm.canvasBounds;
|
|
||||||
const cbL = cb.left ?? cb.x, cbT = cb.top ?? cb.y;
|
|
||||||
const toClient = (gx, gy) => [cbL + gx / sm.displayScale.x, cbT + gy / sm.displayScale.y];
|
|
||||||
const canvas = win.scene.game.canvas;
|
|
||||||
const realPress = async (gx, gy) => {
|
|
||||||
const [cx, cy] = toClient(gx, gy);
|
|
||||||
for (const type of ['mousedown', 'mouseup']) {
|
|
||||||
canvas.dispatchEvent(new MouseEvent(type, { clientX: cx, clientY: cy, bubbles: true, cancelable: true, button: 0 }));
|
|
||||||
await frames();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- 1: hover the object ------------------------------------------------
|
// ---- 1: hover the object ------------------------------------------------
|
||||||
win._setHover({ x: sx, y: sy });
|
win._setHover({ x: sx, y: sy });
|
||||||
await sleep(120);
|
await sleep(120);
|
||||||
|
|
@ -150,282 +130,30 @@ try {
|
||||||
mapImg: { inDL: iMapImg >= 0, interactive: !!(win.mapImg.input && win.mapImg.input.enabled) },
|
mapImg: { inDL: iMapImg >= 0, interactive: !!(win.mapImg.input && win.mapImg.input.enabled) },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- 1b: hover decor is drawn in WORLD coordinates ---------------------
|
// ---- 2: click the object (through the real handler) --------------------
|
||||||
// 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;
|
const targetBefore = s.ship.target;
|
||||||
win.mapImg.emit('pointerdown', { x: sx, y: sy });
|
win.mapImg.emit('pointerdown', { x: sx, y: sy });
|
||||||
win.mapImg.emit('pointerup', {});
|
|
||||||
await sleep(700); // open anim (~170ms) + title decode (420ms)
|
|
||||||
const zDlg = win._view.z;
|
|
||||||
const [iw, iwY] = toClient(win.geo.mapX + win.geo.mapW / 2, win.geo.mapY + win.geo.mapH / 2);
|
|
||||||
const evDlg = new WheelEvent('wheel', { clientX: iw, clientY: iwY, deltaY: -300, bubbles: true, cancelable: true });
|
|
||||||
canvas.dispatchEvent(evDlg); // plate-inert probe: this must NOT zoom
|
|
||||||
await frames();
|
|
||||||
const dialog = {
|
|
||||||
isOpen: dlg.isOpen,
|
|
||||||
title: dlg.title.text,
|
|
||||||
body: dlg.bodyTexts.map((t) => t.text),
|
|
||||||
confirmLabel: dlg.confirmBtn.labelText.text,
|
|
||||||
targetUnchanged: s.ship.target === targetBefore,
|
|
||||||
plateInertWheel: win._view.z === zDlg,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- 2b: CANCEL keeps the map open, plots no course ---------------------
|
|
||||||
dlg.cancel();
|
|
||||||
await sleep(300); // close anim (~130ms)
|
|
||||||
const dialogCancelled = {
|
|
||||||
hidden: dlg.state === 'hidden',
|
|
||||||
mapStillOpen: win.isOpen,
|
|
||||||
targetUnchanged: s.ship.target === targetBefore,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- 2c: ESC cancels the confirm (map stays open) -----------------------
|
|
||||||
win._setHover({ x: sx, y: sy });
|
|
||||||
win.mapImg.emit('pointerdown', { x: sx, y: sy });
|
|
||||||
win.mapImg.emit('pointerup', {});
|
|
||||||
await sleep(250); // enough for state === 'opening' (isOpen true)
|
|
||||||
const escSeen = [];
|
|
||||||
if (s.input.keyboard) {
|
|
||||||
for (const name of ['keydown-ESC', 'keydown-Escape', 'keydown-ESCAPE']) {
|
|
||||||
s.input.keyboard.once(name, () => escSeen.push(name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true }));
|
|
||||||
await sleep(300);
|
|
||||||
const dialogEsc = {
|
|
||||||
hidden: dlg.state === 'hidden',
|
|
||||||
mapStillOpen: win.isOpen,
|
|
||||||
targetUnchanged: s.ship.target === targetBefore,
|
|
||||||
escEventSeen: escSeen,
|
|
||||||
};
|
|
||||||
|
|
||||||
// a click on EMPTY plate must not retarget (and opens no dialog)
|
|
||||||
s.ship.target = null;
|
|
||||||
win.mapImg.emit('pointerdown', { x: win.geo.mapX + 4, y: win.geo.mapY + 4 });
|
|
||||||
win.mapImg.emit('pointerup', {});
|
|
||||||
await sleep(120);
|
await sleep(120);
|
||||||
const missClick = { targetStayedNull: s.ship.target === null, noDialog: !dlg.isOpen };
|
const click = {
|
||||||
|
|
||||||
// ---- 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,
|
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,
|
target: s.ship.target ? { x: Math.round(s.ship.target.x), y: Math.round(s.ship.target.y) } : null,
|
||||||
mapClosed: win.openState === 'closed',
|
flash: !!win._flash,
|
||||||
dialogHidden: dlg.state === 'hidden',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// re-open the console for the zoom / pan sections
|
// a click on EMPTY plate must not retarget
|
||||||
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;
|
s.ship.target = null;
|
||||||
win.mapImg.emit('pointerdown', { x: geo.mapX + zHit.x, y: geo.mapY + zHit.y });
|
win.mapImg.emit('pointerdown', { x: win.geo.mapX + 4, y: win.geo.mapY + 4 });
|
||||||
win.mapImg.emit('pointerup', {});
|
await sleep(120);
|
||||||
await sleep(200);
|
const missClick = { targetStayedNull: s.ship.target === null };
|
||||||
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
|
// ?keepOpen=1 — stop here (map still open, tooltip up) so a screenshot
|
||||||
// harness can capture the chart + tooltip on screen.
|
// harness can capture the chart + tooltip on screen.
|
||||||
const keepOpen = new URLSearchParams(location.search).has('keepOpen');
|
const keepOpen = new URLSearchParams(location.search).has('keepOpen');
|
||||||
let after = null, reopen = null, after2 = null;
|
let after = null, reopen = null, after2 = null;
|
||||||
if (keepOpen) {
|
if (keepOpen) {
|
||||||
win._resetZoom(); // the screenshot wants the full-frame chart
|
const pass = click.targetWasSet && missClick.targetStayedNull;
|
||||||
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 = {
|
window.__MAPCLICK = {
|
||||||
ready: true, pass, keepOpen: true, before, dialog, dialogCancelled, dialogEsc, missClick, realDialog, realConfirm, realHit, errors,
|
ready: true, pass, keepOpen: true, before, click, missClick, errors,
|
||||||
};
|
};
|
||||||
console.log('MAPCLICK (keepOpen) ready');
|
console.log('MAPCLICK (keepOpen) ready');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -445,15 +173,9 @@ try {
|
||||||
// ---- 4: re-open, hover, close again (no leftovers, no crash) -----------
|
// ---- 4: re-open, hover, close again (no leftovers, no crash) -----------
|
||||||
win.open();
|
win.open();
|
||||||
await sleep(1800);
|
await sleep(1800);
|
||||||
const h2 = win._hits[0]; // live hit list for the (possibly zoomed) view
|
win._setHover({ x: sx, y: sy });
|
||||||
win._setHover({ x: geo.mapX + h2.x, y: geo.mapY + h2.y });
|
|
||||||
await sleep(120);
|
await sleep(120);
|
||||||
reopen = {
|
reopen = { open: win.isOpen, hoverVisible: effVisible(win.ttName) };
|
||||||
open: win.isOpen,
|
|
||||||
hoverVisible: effVisible(win.ttName),
|
|
||||||
hoverId: win._hover?.id ?? null,
|
|
||||||
zoomKept: win._view.z === wheelIn.z,
|
|
||||||
};
|
|
||||||
win.close();
|
win.close();
|
||||||
await sleep(700);
|
await sleep(700);
|
||||||
after2 = {
|
after2 = {
|
||||||
|
|
@ -476,51 +198,9 @@ try {
|
||||||
before.ttPlate.effVisible && before.ttName.effVisible);
|
before.ttPlate.effVisible && before.ttName.effVisible);
|
||||||
check('while open: tooltip paints ABOVE the map surface (inside the window)',
|
check('while open: tooltip paints ABOVE the map surface (inside the window)',
|
||||||
before.ttPlate.paintsAboveMap && before.ttName.paintsAboveMap);
|
before.ttPlate.paintsAboveMap && before.ttName.paintsAboveMap);
|
||||||
check('hover ring is drawn at the destination\u2019s WORLD position (offset by the plate origin)',
|
check('clicking a discovered object sets the ship target (autopilot)',
|
||||||
hoverDraw.ringAtDest);
|
click.targetWasSet);
|
||||||
check('course line ends at the destination\u2019s WORLD position (ship→destination)',
|
check('clicking empty plate does NOT retarget', missClick.targetStayedNull);
|
||||||
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) {
|
if (!keepOpen) {
|
||||||
check('after close: tooltip plate hidden (no lingering button)',
|
check('after close: tooltip plate hidden (no lingering button)',
|
||||||
!after.ttPlate.effVisible);
|
!after.ttPlate.effVisible);
|
||||||
|
|
@ -529,17 +209,15 @@ try {
|
||||||
check('after close: hover ring cleared',
|
check('after close: hover ring cleared',
|
||||||
!after.hoverG.effVisible || win.hoverG.commandBuffer.length === 0);
|
!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: 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)',
|
check('second close: tooltip fully hidden (no leftovers)',
|
||||||
after2.ttContHidden && after2.ttPlateHidden && after2.ttNameHidden && after2.ttHintHidden);
|
after2.ttContHidden && after2.ttPlateHidden && after2.ttNameHidden && after2.ttHintHidden);
|
||||||
}
|
}
|
||||||
check('no console errors', errors.length === 0);
|
check('no console errors', errors.length === 0);
|
||||||
|
|
||||||
const pass = results.every((r) => r.pass);
|
const pass = results.every((r) => r.pass);
|
||||||
window.__MAPCLICK = { ready: true, pass, keepOpen, results, before, hoverDraw, dialog, dialogCancelled, dialogEsc, missClick, realDialog, realConfirm, realHit, zoom: { zoomIn, clampedMin, clampedMax, zoomHit, dblTap, wheelIn, wheelOut, pan, viewMemory }, after, reopen, after2, errors };
|
window.__MAPCLICK = { ready: true, pass, results, before, click, missClick, after, reopen, after2, errors };
|
||||||
console.log(pass ? 'MAPCLICK PASS' : 'MAPCLICK FAIL');
|
console.log(pass ? 'MAPCLICK PASS' : 'MAPCLICK FAIL');
|
||||||
console.log(JSON.stringify({ results, dialog, dialogCancelled, dialogEsc, realConfirm }, null, 1));
|
console.log(JSON.stringify({ results, before, click, after }, null, 1));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const results = [{ label: `THREW: ${err.message}`, pass: false }];
|
const results = [{ label: `THREW: ${err.message}`, pass: false }];
|
||||||
window.__MAPCLICK = { ready: true, pass: false, results, errors };
|
window.__MAPCLICK = { ready: true, pass: false, results, errors };
|
||||||
|
|
|
||||||
|
|
@ -2852,13 +2852,8 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.researchWindow.close();
|
this.researchWindow.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The map console (depth 80) — the same contract. Its autopilot
|
// The map console (depth 80) — the same contract.
|
||||||
// confirm sits on top: ESC cancels the dialog, not the map.
|
|
||||||
if (this.mapWindow && this.mapWindow.isOpen) {
|
if (this.mapWindow && this.mapWindow.isOpen) {
|
||||||
if (this.mapWindow.dialog && this.mapWindow.dialog.isOpen) {
|
|
||||||
this.mapWindow.dialog.cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.mapWindow.close();
|
this.mapWindow.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -234,22 +234,16 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
|
||||||
this.lastTime = time;
|
this.lastTime = time;
|
||||||
if (this.state === 'hidden') return;
|
if (this.state === 'hidden') return;
|
||||||
|
|
||||||
// 'shown' is the STEADY state: hold the open values. (Using the close
|
const p = Phaser.Math.Clamp((time - this.t0) / (this.state === 'opening' ? this.openDur : this.closeDur), 0, 1);
|
||||||
// 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 e = p * p * (3 - 2 * p); // smoothstep
|
||||||
const h = closing ? Math.max(2, (1 - e) * this.h) : Math.max(2, e * this.h);
|
const h = this.state === 'opening' ? Math.max(2, e * this.h) : Math.max(2, (1 - e) * this.h);
|
||||||
this.drawPanel(h);
|
this.drawPanel(h);
|
||||||
// The world behind goes quiet — the scrim rides the same curve.
|
// The world behind goes quiet — the scrim rides the same curve.
|
||||||
this.scrim.setAlpha(0.72 * (closing ? 1 - e : e));
|
this.scrim.setAlpha(0.72 * (this.state === 'opening' ? e : 1 - e));
|
||||||
|
|
||||||
// RGB channels: converge on open (±start → 0, α .5 → 0), diverge on close.
|
// RGB channels: converge on open (±start → 0, α .5 → 0), diverge on close.
|
||||||
const start = 10;
|
const start = 10;
|
||||||
if (!closing) {
|
if (this.state === 'opening') {
|
||||||
const off = start * (1 - e);
|
const off = start * (1 - e);
|
||||||
const a = 0.5 * (1 - e);
|
const a = 0.5 * (1 - e);
|
||||||
this.ghostC.setAlpha(a).setPosition(off * 0.85, -off * 0.3);
|
this.ghostC.setAlpha(a).setPosition(off * 0.85, -off * 0.3);
|
||||||
|
|
|
||||||
|
|
@ -29,22 +29,11 @@
|
||||||
* follows the ship every frame via `getShip()`), a scan sweep band, the
|
* follows the ship every frame via `getShip()`), a scan sweep band, the
|
||||||
* hover highlight + tooltip, a click flash, the glitch bursts.
|
* hover highlight + tooltip, a click flash, the glitch bursts.
|
||||||
*
|
*
|
||||||
* Clicking a discovered object opens the ENGAGE AUTOPILOT confirm (the
|
* Clicking a discovered object fires `onSelect(objectId)` — the scene
|
||||||
* save pop-up's ConfirmOverlay, over the plate) — CONFIRM fires
|
* plots the course (autopilot). The window is a pure view: all system
|
||||||
* `onSelect(objectId)` (the scene plots the course) AND closes the
|
* data arrives through the `getChart()` snapshot callback (GameScene);
|
||||||
* console; CANCEL / scrim / ESC keep the map as it was. The window is a
|
* it is re-polled while open, and the canvas repaints on any change
|
||||||
* pure view: all system data arrives through the `getChart()` snapshot
|
* (discovery, tether radius, …).
|
||||||
* 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 Phaser from '../vendor/phaser.js';
|
||||||
import { config } from '../config/Config.js';
|
import { config } from '../config/Config.js';
|
||||||
|
|
@ -56,7 +45,6 @@ import { Rng } from '../utils/Rng.js';
|
||||||
import { Tether } from '../tether/Tether.js';
|
import { Tether } from '../tether/Tether.js';
|
||||||
import { chartBounds, fitToRect } from '../galaxy/SystemChart.js';
|
import { chartBounds, fitToRect } from '../galaxy/SystemChart.js';
|
||||||
import { CyberShape } from './CyberShape.js';
|
import { CyberShape } from './CyberShape.js';
|
||||||
import { ConfirmOverlay } from './ConfirmOverlay.js';
|
|
||||||
|
|
||||||
const TAU = Math.PI * 2;
|
const TAU = Math.PI * 2;
|
||||||
const HEADER = fontStack('header');
|
const HEADER = fontStack('header');
|
||||||
|
|
@ -626,35 +614,16 @@ function drawVignette(ctx, w, h) {
|
||||||
* @param {number} w,h — plate size in CSS px
|
* @param {number} w,h — plate size in CSS px
|
||||||
* @param {number} dpr — device pixel ratio (for the fog layer's sharpness)
|
* @param {number} dpr — device pixel ratio (for the fog layer's sharpness)
|
||||||
* @param {object} snap — the GameScene chart snapshot (see mapChartSnapshot)
|
* @param {object} snap — the GameScene chart snapshot (see mapChartSnapshot)
|
||||||
* @param {{z:number,cx:number,cy:number}} [view] — optional zoom view
|
* @returns {{tf:object, bounds:object, hits:Array<object>}} — the world→plate
|
||||||
* (z == 1 paints the whole system; z > 1 paints a z÷1 box of the
|
* transform + the hit list for pointer queries
|
||||||
* 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, view) {
|
function paintChart(ctx, w, h, dpr, snap) {
|
||||||
const pad = config.get('map.bounds.padding', 1024);
|
const pad = config.get('map.bounds.padding', 1024);
|
||||||
// the frame: EVERY object (found or not) + the central body + the
|
// 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
|
// player's tether reach (a zone is part of the system's extent), padded
|
||||||
const boundsObjs = [...(snap.objects ?? []), ...(snap.tethers ?? [])];
|
const boundsObjs = [...(snap.objects ?? []), ...(snap.tethers ?? [])];
|
||||||
if (snap.central) boundsObjs.push({ x: 0, y: 0, radius: snap.central.radius ?? 200 });
|
if (snap.central) boundsObjs.push({ x: 0, y: 0, radius: snap.central.radius ?? 200 });
|
||||||
const fullBounds = chartBounds(boundsObjs, pad);
|
const bounds = 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 tf = fitToRect(bounds, w, h);
|
||||||
const pmin = config.get('map.chart.planetMin', 5);
|
const pmin = config.get('map.chart.planetMin', 5);
|
||||||
const pmax = config.get('map.chart.planetMax', 13);
|
const pmax = config.get('map.chart.planetMax', 13);
|
||||||
|
|
@ -736,7 +705,7 @@ function paintChart(ctx, w, h, dpr, snap, view) {
|
||||||
drawChrome(ctx, w, h, bounds, tf);
|
drawChrome(ctx, w, h, bounds, tf);
|
||||||
drawVignette(ctx, w, h);
|
drawVignette(ctx, w, h);
|
||||||
|
|
||||||
return { tf, bounds, hits, fullBounds };
|
return { tf, bounds, hits };
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MapWindow extends Phaser.GameObjects.Container {
|
export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
|
|
@ -751,8 +720,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
* @param {() => {x:number, y:number, heading:number}|null} [opts.getShip]
|
* @param {() => {x:number, y:number, heading:number}|null} [opts.getShip]
|
||||||
* — the ship's live world position + heading (every frame)
|
* — the ship's live world position + heading (every frame)
|
||||||
* @param {(objectId: string) => void} [opts.onSelect] — a discovered
|
* @param {(objectId: string) => void} [opts.onSelect] — a discovered
|
||||||
* object was CONFIRMED on the chart (dialog ENGAGE) — the scene
|
* object was clicked on the chart (the scene plots the course)
|
||||||
* plots the course (autopilot) and the console closes
|
|
||||||
* @param {() => void} [opts.onLocked] — the (standby) GALAXY tab was hit
|
* @param {() => void} [opts.onLocked] — the (standby) GALAXY tab was hit
|
||||||
*/
|
*/
|
||||||
constructor(scene, opts = {}) {
|
constructor(scene, opts = {}) {
|
||||||
|
|
@ -779,15 +747,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
this._hits = [];
|
this._hits = [];
|
||||||
this._hover = null;
|
this._hover = null;
|
||||||
this._flash = 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._texN = 0;
|
||||||
this._texKey = null;
|
this._texKey = null;
|
||||||
this._fontsRepaintDone = false; // the webfont repaint runs at most ONCE
|
this._fontsRepaintDone = false; // the webfont repaint runs at most ONCE
|
||||||
|
|
@ -940,7 +899,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
this._paintClose();
|
this._paintClose();
|
||||||
});
|
});
|
||||||
this.closeG.on('pointerdown', () => {
|
this.closeG.on('pointerdown', () => {
|
||||||
if (this.dialog && this.dialog.isOpen) return; // the scrim's cancel owns the click
|
|
||||||
this.sfx('ui_click');
|
this.sfx('ui_click');
|
||||||
this.close();
|
this.close();
|
||||||
});
|
});
|
||||||
|
|
@ -964,21 +922,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
// first paint (GameScene already has its system — data is live now)
|
// first paint (GameScene already has its system — data is live now)
|
||||||
const snap = this._snapOf();
|
const snap = this._snapOf();
|
||||||
if (snap) this._applySnap(snap);
|
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() {
|
_ghost() {
|
||||||
|
|
@ -1254,7 +1197,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
}
|
}
|
||||||
|
|
||||||
_tabHit(entry) {
|
_tabHit(entry) {
|
||||||
if (this.dialog && this.dialog.isOpen) return; // the dialog is the topmost thing
|
|
||||||
if (entry.standby) {
|
if (entry.standby) {
|
||||||
// GALAXY — built as standby for now: shake it + the scene toasts
|
// GALAXY — built as standby for now: shake it + the scene toasts
|
||||||
this.sfx('ui_click');
|
this.sfx('ui_click');
|
||||||
|
|
@ -1304,15 +1246,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
return key;
|
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() {
|
_buildPlate() {
|
||||||
const { mapX, mapY, mapW, mapH } = this.geo;
|
const { mapX, mapY, mapW, mapH } = this.geo;
|
||||||
const s = this.scene.add;
|
const s = this.scene.add;
|
||||||
|
|
@ -1323,36 +1256,23 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
// Redrawn (new texture, old one dropped) whenever the snapshot changes.
|
// Redrawn (new texture, old one dropped) whenever the snapshot changes.
|
||||||
this.mapImg = s.image(cx, cy, this._blankKey()).setScrollFactor(0).setDepth(1);
|
this.mapImg = s.image(cx, cy, this._blankKey()).setScrollFactor(0).setDepth(1);
|
||||||
this.mapImg.setDisplaySize(mapW, mapH);
|
this.mapImg.setDisplaySize(mapW, mapH);
|
||||||
// v4 input gotcha: the hit test maps the pointer into the image's
|
const hitRect = new Phaser.Geom.Rectangle(mapX, mapY, mapW, mapH);
|
||||||
// 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({
|
this.mapImg.setInteractive({
|
||||||
useHandCursor: false,
|
useHandCursor: false,
|
||||||
hitArea: hitRect,
|
hitArea: hitRect,
|
||||||
hitAreaCallback: (area, px, py) => area.contains(px, py),
|
hitAreaCallback: (area, px, py) => area.contains(px, py),
|
||||||
});
|
});
|
||||||
this._refreshPlateHitArea();
|
this.mapImg.on('pointermove', (p) => this._setHover(p));
|
||||||
this.mapImg.on('pointerdown', (p) => this._startDrag(p));
|
this.mapImg.on('pointerout', () => this._setHover(null));
|
||||||
this.mapImg.on('pointermove', (p) => {
|
this.mapImg.on('pointerdown', (p) => {
|
||||||
if (this._drag) this._onDragMove(p);
|
const pt = this._platePoint(p);
|
||||||
else this._setHover(p);
|
const o = this._objectAt(pt);
|
||||||
|
if (o) {
|
||||||
|
this.sfx('ui_click');
|
||||||
|
this._flash = { x: o.x, y: o.y, r: o.r, t0: this.scene.time.now };
|
||||||
|
this.onSelect?.(o.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// the CLICK (select / double-tap reset) fires on release, unless the
|
|
||||||
// press turned into a drag (pan) — so drag and tap coexist
|
|
||||||
this.mapImg.on('pointerup', () => this._finishDrag(true));
|
|
||||||
this.mapImg.on('pointerout', () => {
|
|
||||||
this._setHover(null);
|
|
||||||
if (this._drag) this._finishDrag(false); // left the plate → cancel click
|
|
||||||
});
|
|
||||||
// wheel zoom — Phaser 4's input plugin emits a `wheel` event on
|
|
||||||
// interactive objects under the pointer; mapImg's hit area IS the
|
|
||||||
// plate, so this only fires over the plate (Phaser also stops the
|
|
||||||
// page scroll on the canvas by default)
|
|
||||||
this.mapImg.on('wheel', (p, _dx, dy) => this._onWheelZoom(p, dy));
|
|
||||||
this.add(this.mapImg);
|
this.add(this.mapImg);
|
||||||
|
|
||||||
// frame chrome over the plate edge (brackets + border)
|
// frame chrome over the plate edge (brackets + border)
|
||||||
|
|
@ -1376,21 +1296,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
this.feedDot = s.circle(mapX + 74, mapY + 12, 2.5, C.amber, 0.9).setScrollFactor(0).setDepth(3);
|
this.feedDot = s.circle(mapX + 74, mapY + 12, 2.5, C.amber, 0.9).setScrollFactor(0).setDepth(3);
|
||||||
this.add(this.feedDot);
|
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)
|
// scan sweep band crawling across the plate (left → right, loop)
|
||||||
const swKey = 'map_plate_sweep';
|
const swKey = 'map_plate_sweep';
|
||||||
if (!this.scene.textures.exists(swKey)) {
|
if (!this.scene.textures.exists(swKey)) {
|
||||||
|
|
@ -1736,10 +1641,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
const res = snap.stats?.res ?? { total: 0, found: 0, pct: 0 };
|
const res = snap.stats?.res ?? { total: 0, found: 0, pct: 0 };
|
||||||
const total = nav.total + res.total;
|
const total = nav.total + res.total;
|
||||||
const found = nav.found + res.found;
|
const found = nav.found + res.found;
|
||||||
let txt = total > 0 ? `OBJECTS ${total} · CHARTED ${found}` : 'NO OBJECTS LOGGED';
|
this.statusTxt.setText(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));
|
this.statusTxt.setColor(total > 0 ? toCss(C.ink) : toCss(C.faint));
|
||||||
const g = this.statusBar;
|
const g = this.statusBar;
|
||||||
g.clear();
|
g.clear();
|
||||||
|
|
@ -1772,11 +1674,8 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
this._snap = snap;
|
this._snap = snap;
|
||||||
this._tf = null;
|
this._tf = null;
|
||||||
this._bounds = null;
|
this._bounds = null;
|
||||||
this._full = null;
|
|
||||||
this._hits = [];
|
this._hits = [];
|
||||||
this._hover = null;
|
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.redraw();
|
||||||
this._paintStats();
|
this._paintStats();
|
||||||
this._paintStatusStrip();
|
this._paintStatusStrip();
|
||||||
|
|
@ -1795,10 +1694,9 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
canvas.height = Math.max(8, Math.round(mapH * dpr));
|
canvas.height = Math.max(8, Math.round(mapH * dpr));
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
const out = paintChart(ctx, mapW, mapH, dpr, snap, this._view);
|
const out = paintChart(ctx, mapW, mapH, dpr, snap);
|
||||||
this._tf = out.tf;
|
this._tf = out.tf;
|
||||||
this._bounds = out.bounds;
|
this._bounds = out.bounds;
|
||||||
this._full = out.fullBounds;
|
|
||||||
this._hits = out.hits;
|
this._hits = out.hits;
|
||||||
const prev = this._texKey;
|
const prev = this._texKey;
|
||||||
const key = `map_chart_${this._texN++}`;
|
const key = `map_chart_${this._texN++}`;
|
||||||
|
|
@ -1809,9 +1707,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
// the image was born on the 2×2 blank key, so the display size must
|
// 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.
|
// be re-asserted on every texture swap or it renders ~415× oversized.
|
||||||
this.mapImg.setDisplaySize(mapW, mapH);
|
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);
|
if (prev && prev !== key) this.scene.textures.remove(prev);
|
||||||
this._texKey = key;
|
this._texKey = key;
|
||||||
// The canvas paints with the fallback face until the webfonts land —
|
// The canvas paints with the fallback face until the webfonts land —
|
||||||
|
|
@ -1841,9 +1736,7 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
if (this.openState === 'closed' || this.openState === 'closing') return;
|
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.openState = 'closing';
|
||||||
this._drag = null; // a press that outlives the window is no one's click
|
|
||||||
this.video?.pause?.();
|
this.video?.pause?.();
|
||||||
this.sfx('ui_close');
|
this.sfx('ui_close');
|
||||||
this._setHover(null);
|
this._setHover(null);
|
||||||
|
|
@ -1891,7 +1784,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
push(this.plateFrame, 500, 300, 'fade');
|
push(this.plateFrame, 500, 300, 'fade');
|
||||||
push(this.feedTag, 560, 260, 'fade');
|
push(this.feedTag, 560, 260, 'fade');
|
||||||
push(this.feedDot, 560, 260, 'fade');
|
push(this.feedDot, 560, 260, 'fade');
|
||||||
if (this.zoomTag) push(this.zoomTag, 560, 260, 'fade');
|
|
||||||
push(this.statsG, 620, 300, 'fade');
|
push(this.statsG, 620, 300, 'fade');
|
||||||
this.sysNameTxt.y = this.geo.statsY + 10;
|
this.sysNameTxt.y = this.geo.statsY + 10;
|
||||||
push(this.sysNameTxt, 620, 300, 'fade');
|
push(this.sysNameTxt, 620, 300, 'fade');
|
||||||
|
|
@ -1945,7 +1837,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
|
|
||||||
// ------------------------------------------------------------ per-frame
|
// ------------------------------------------------------------ per-frame
|
||||||
update(time) {
|
update(time) {
|
||||||
if (this.dialog) this.dialog.update(time); // the autopilot confirm, if up
|
|
||||||
if (!this.isOpen) return;
|
if (!this.isOpen) return;
|
||||||
|
|
||||||
// reveal timeline
|
// reveal timeline
|
||||||
|
|
@ -2092,182 +1983,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ wheel zoom
|
|
||||||
/** Wheel zoom (object-level `wheel` event from Phaser 4's input
|
|
||||||
* plugin — mapImg's hit area is the plate, so this only fires over
|
|
||||||
* the plate). `p` is the pointer (game coords), `dy` the raw deltaY. */
|
|
||||||
_onWheelZoom(p, dy) {
|
|
||||||
if (this._destroyed || !this.isOpen || !this._tf || (this.dialog && this.dialog.isOpen)) return;
|
|
||||||
const z = config.get('map.zoom', {});
|
|
||||||
if (z.enabled === false) return;
|
|
||||||
const dyPx = dy * ((p.event?.deltaMode ?? 0) === 1 ? 32 : 1); // line-mode → px
|
|
||||||
if (!dyPx) return;
|
|
||||||
const { mapX, mapY } = this.geo;
|
|
||||||
this._zoomAt(p.x - mapX, p.y - mapY, Math.exp(-dyPx * (z.sensitivity ?? 0.0022)), z.zMin ?? 1, z.zMax ?? 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Zoom about a plate point (px,py in plate-local px) by `factor`. */
|
|
||||||
_zoomAt(px, py, factor, zMin = 1, zMax = 8) {
|
|
||||||
const tf = this._tf;
|
|
||||||
if (!tf || !this._snap) return;
|
|
||||||
const z0 = this._view.z;
|
|
||||||
const z1 = Phaser.Math.Clamp(z0 * factor, zMin, zMax);
|
|
||||||
if (z1 === z0) return;
|
|
||||||
// the world point under the cursor, in the CURRENT view
|
|
||||||
const wx = (px - tf.ox) / tf.scale;
|
|
||||||
const wy = (py - tf.oy) / tf.scale;
|
|
||||||
// solve the new centre so that point stays under the cursor when the
|
|
||||||
// plate re-fits at scale (tf.scale / z0) * z1
|
|
||||||
const s1 = (tf.scale / z0) * z1;
|
|
||||||
this._view = {
|
|
||||||
z: z1,
|
|
||||||
cx: wx - (px - this.geo.mapW / 2) / s1,
|
|
||||||
cy: wy - (py - this.geo.mapH / 2) / s1,
|
|
||||||
};
|
|
||||||
this._saveView();
|
|
||||||
this._queueRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
_resetZoom() {
|
|
||||||
if (this._view.z <= 1) return;
|
|
||||||
this._view = { z: 1, cx: 0, cy: 0 };
|
|
||||||
this._saveView();
|
|
||||||
this._hover = null;
|
|
||||||
this._queueRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remember this system's view so it is still there when we return. */
|
|
||||||
_saveView() {
|
|
||||||
const id = this._snap?.systemId;
|
|
||||||
if (id != null) this._views.set(id, { ...this._view });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One repaint per frame, no matter how fast the wheel spins. */
|
|
||||||
_queueRedraw() {
|
|
||||||
if (this._rafPending) return;
|
|
||||||
this._rafPending = true;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
this._rafPending = false;
|
|
||||||
if (this._destroyed) return;
|
|
||||||
this._hover = null; // every hit position moved under the new view
|
|
||||||
this.redraw();
|
|
||||||
this._paintHover();
|
|
||||||
this._paintStatusStrip();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ drag pan
|
|
||||||
/** A press on the plate. Nothing happens yet — the click (select /
|
|
||||||
* double-tap reset) fires on release, unless the pointer moves past
|
|
||||||
* the slop, in which case the drag pans the zoomed view instead. */
|
|
||||||
_startDrag(p) {
|
|
||||||
if (this._destroyed || (this.dialog && this.dialog.isOpen)) return;
|
|
||||||
this._drag = {
|
|
||||||
x: p.x,
|
|
||||||
y: p.y,
|
|
||||||
moved: false,
|
|
||||||
over: this._objectAt(this._platePoint(p)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
_onDragMove(p) {
|
|
||||||
const d = this._drag;
|
|
||||||
if (!d) return;
|
|
||||||
const dx = p.x - d.x;
|
|
||||||
const dy = p.y - d.y;
|
|
||||||
if (!d.moved) {
|
|
||||||
if (Math.hypot(dx, dy) < 5) return; // still a click, not a drag
|
|
||||||
d.moved = true;
|
|
||||||
this._hover = null; // the drag owns the plate from here on
|
|
||||||
this._paintHover();
|
|
||||||
if (this._view.z <= 1) return; // nothing to pan at 1×
|
|
||||||
}
|
|
||||||
d.x = p.x;
|
|
||||||
d.y = p.y;
|
|
||||||
this._panBy(dx, dy);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Release after a press. If it dragged, the pan was applied as it
|
|
||||||
* went and the click is cancelled. Otherwise it's a genuine click:
|
|
||||||
* select the object under the press, or (empty plate) a double-tap
|
|
||||||
* zoom reset. Releasing off the plate cancels the click. */
|
|
||||||
_finishDrag(releaseInside) {
|
|
||||||
const d = this._drag;
|
|
||||||
this._drag = null;
|
|
||||||
if (!d || this._destroyed) return;
|
|
||||||
if (d.moved) {
|
|
||||||
this._lastTap = 0; // a drag is not a reset tap
|
|
||||||
this._saveView();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!releaseInside) return;
|
|
||||||
const now = this.scene.time.now;
|
|
||||||
const o = d.over;
|
|
||||||
if (o) {
|
|
||||||
this.sfx('ui_click');
|
|
||||||
this._lastTap = 0; // a selection is not a zoom-reset tap
|
|
||||||
this._askAutopilot(o); // confirm first — CONFIRM plots the course + closes
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// double-tap on EMPTY plate → reset zoom (v4 has no dblclick event)
|
|
||||||
if (this._view.z > 1 && now - this._lastTap < 320) {
|
|
||||||
this._lastTap = 0;
|
|
||||||
this.sfx('ui_window');
|
|
||||||
this._resetZoom();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._lastTap = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pan the zoomed view by plate pixels: the point under the cursor
|
|
||||||
* stays under the cursor, the centre stays inside the full frame. */
|
|
||||||
_panBy(dpx, dpy) {
|
|
||||||
const tf = this._tf;
|
|
||||||
const full = this._full;
|
|
||||||
if (!tf || !full || this._view.z <= 1) return;
|
|
||||||
const z = this._view.z;
|
|
||||||
const vw = full.w / z;
|
|
||||||
const vh = full.h / z;
|
|
||||||
this._view = {
|
|
||||||
z,
|
|
||||||
cx: Phaser.Math.Clamp(this._view.cx - dpx / tf.scale, full.cx - (full.w - vw) / 2, full.cx + (full.w - vw) / 2),
|
|
||||||
cy: Phaser.Math.Clamp(this._view.cy - dpy / tf.scale, full.cy - (full.h - vh) / 2, full.cy + (full.h - vh) / 2),
|
|
||||||
};
|
|
||||||
this._saveView();
|
|
||||||
this._queueRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Autopilot is a commitment — ask before engaging (the save pop-up's
|
|
||||||
* ConfirmOverlay, over the plate). CONFIRM fires onSelect (the scene
|
|
||||||
* plots the course) and closes the console; CANCEL, the scrim, or ESC
|
|
||||||
* leave the map exactly as it was. While the dialog is up the plate is
|
|
||||||
* inert (the guards in _startDrag / _onWheelZoom / _setHover — the
|
|
||||||
* scrim covers the whole window, so it also takes the clicks meant for
|
|
||||||
* the chrome, and its own pointerdown IS the cancel).
|
|
||||||
*/
|
|
||||||
_askAutopilot(o) {
|
|
||||||
this._hover = null; // the dialog speaks for the pick
|
|
||||||
this._paintHover();
|
|
||||||
const label = String(o.label ?? o.id).toUpperCase();
|
|
||||||
const body = [`DESTINATION — ${label}`];
|
|
||||||
const sh = this.getShip?.();
|
|
||||||
if (sh) body[1] = `DISTANCE ${Math.round(Math.hypot(o.x - sh.x, o.y - sh.y)).toLocaleString('en-US')}`;
|
|
||||||
this.dialog.show({
|
|
||||||
title: config.get('map.confirm.title', 'ENGAGE AUTOPILOT'),
|
|
||||||
body,
|
|
||||||
accent: C.amber,
|
|
||||||
confirmLabel: config.get('map.confirm.label', 'ENGAGE'),
|
|
||||||
onConfirm: () => {
|
|
||||||
// the pick ripples once while the window fades out
|
|
||||||
this._flash = { x: o.x + this.geo.mapX, y: o.y + this.geo.mapY, r: o.r, t0: this.scene.time.now };
|
|
||||||
this.onSelect?.(o.id);
|
|
||||||
this.close();
|
|
||||||
},
|
|
||||||
time: this.scene.time.now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ pointer
|
// ------------------------------------------------------------ pointer
|
||||||
_platePoint(p) {
|
_platePoint(p) {
|
||||||
return { x: p.x - this.geo.mapX, y: p.y - this.geo.mapY };
|
return { x: p.x - this.geo.mapX, y: p.y - this.geo.mapY };
|
||||||
|
|
@ -2288,11 +2003,6 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
}
|
}
|
||||||
|
|
||||||
_setHover(p) {
|
_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) {
|
if (!this.isOpen || p === null) {
|
||||||
this._hover = null;
|
this._hover = null;
|
||||||
this._paintHover();
|
this._paintHover();
|
||||||
|
|
@ -2319,33 +2029,26 @@ export class MapWindow extends Phaser.GameObjects.Container {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const o = this._hover;
|
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
|
// highlight ring + soft glow
|
||||||
g.lineStyle(1.5, C.neon, 0.9);
|
g.lineStyle(1.5, C.neon, 0.9);
|
||||||
g.strokeCircle(ox, oy, o.r + 5);
|
g.strokeCircle(o.x, o.y, o.r + 5);
|
||||||
g.lineStyle(4, C.neon, 0.18);
|
g.lineStyle(4, C.neon, 0.18);
|
||||||
g.strokeCircle(ox, oy, o.r + 9);
|
g.strokeCircle(o.x, o.y, o.r + 9);
|
||||||
// course line from the ship (if we have a live position)
|
// course line from the ship (if we have a live position)
|
||||||
const sh = this.getShip?.();
|
const sh = this.getShip?.();
|
||||||
if (sh && this._tf) {
|
if (sh && this._tf) {
|
||||||
const sx = this.geo.mapX + this._tf.toX(sh.x);
|
const sx = this.geo.mapX + this._tf.toX(sh.x);
|
||||||
const sy = this.geo.mapY + this._tf.toY(sh.y);
|
const sy = this.geo.mapY + this._tf.toY(sh.y);
|
||||||
g.lineStyle(1, C.amber, 0.5);
|
g.lineStyle(1, C.amber, 0.5);
|
||||||
g.strokeLineShape(new Phaser.Geom.Line(sx, sy, ox, oy));
|
g.strokeLineShape(new Phaser.Geom.Line(sx, sy, o.x, o.y));
|
||||||
g.fillStyle(C.amber, 0.7);
|
g.fillStyle(C.amber, 0.7);
|
||||||
g.fillCircle(ox, oy, 2);
|
g.fillCircle(o.x, o.y, 2);
|
||||||
}
|
}
|
||||||
// tooltip above the object (clamped inside the plate)
|
// tooltip above the object (clamped inside the plate)
|
||||||
this.ttName.setText(String(o.label ?? o.id).toUpperCase());
|
this.ttName.setText(String(o.label ?? o.id).toUpperCase());
|
||||||
const ttW = Math.max(this.ttName.width, this.ttHint.width) + 24;
|
const ttW = Math.max(this.ttName.width, this.ttHint.width) + 24;
|
||||||
let tx = ox;
|
let tx = o.x + this.geo.mapX;
|
||||||
let ty = oy - o.r - 16;
|
let ty = o.y + this.geo.mapY - o.r - 16;
|
||||||
tx = Phaser.Math.Clamp(tx, this.geo.mapX + ttW / 2 + 6, this.geo.mapX + this.geo.mapW - ttW / 2 - 6);
|
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);
|
ty = Math.max(ty, this.geo.mapY + 46);
|
||||||
this.ttPlate.clear();
|
this.ttPlate.clear();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue