diff --git a/assets/images/originals/rocky-surface-02.png b/assets/images/originals/rocky-surface-02.png new file mode 100644 index 0000000..5596cee Binary files /dev/null and b/assets/images/originals/rocky-surface-02.png differ diff --git a/dev/map-click.html b/dev/map-click.html new file mode 100644 index 0000000..d66d35a --- /dev/null +++ b/dev/map-click.html @@ -0,0 +1,17 @@ + + + + + + Orbit — Map click (dev test) + + + + +
+ + + diff --git a/dev/map-click.mjs b/dev/map-click.mjs new file mode 100644 index 0000000..cb2aa8e --- /dev/null +++ b/dev/map-click.mjs @@ -0,0 +1,225 @@ +/** + * Dev-only: repro + regression check for the MAP console's object click. + * + * Scenario under test: + * 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) + * 4. close the map → NOTHING of the tooltip may linger on screen + * + * The known bug: ttPlate/ttName/ttSub/ttHint are created with + * scene.add() but never added to the MapWindow container (only the + * EMPTY ttCont is) — so they paint at depth 5, BEHIND the window + * (depth 80), and survive close() (nothing ever hides/clears them). + * + * node dev/server.mjs 8091 + * node dev/shot-firefox.mjs \ + * "http://127.0.0.1:8091/dev/map-click.html" \ + * "window.__MAPCLICK ? window.__MAPCLICK.ready : null" \ + * /tmp/map-click.png 30000 + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.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 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'); +} + +/** Walk up the container parents to the object's top-level display-list ancestor. */ +function topLevel(obj) { + let top = obj; + let c = obj.parentContainer; // Phaser 4: container children track their container here + while (c) { top = c; c = c.parentContainer; } + return top; +} +/** Effective (chained) visibility down to the top-level object. */ +function effVisible(obj) { + let v = true; + let o = obj; + while (o) { + v = v && o.visible !== false; + o = o.parentContainer; + } + return v; +} + +try { + const s = await waitScene(); + await sleep(800); + + // Discover the central body + first planet + first station so the + // chart (and _hits) has something to point at. + 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 }); + + // Open the console, wait for the reveal + first paint. + s.deckAction('map'); + await sleep(1800); + const win = s.mapWindow; + if (!win.isOpen) throw new Error('map window did not open'); + + // Force a fresh snapshot so _hits is populated. + const snap = win._snapOf(); + if (snap && win._fp !== win._fpOf(snap)) win._applySnap(snap); + if (!win._hits || win._hits.length === 0) throw new Error('no hits on the chart'); + + const hit = win._hits[0]; + const sx = win.geo.mapX + hit.x; + const sy = win.geo.mapY + hit.y; + + const dl = s.sys.displayList.getChildren(); + const iWin = dl.indexOf(win); + const iMapImg = dl.indexOf(win.mapImg); + + const describe = (o, label) => { + const top = topLevel(o); + const iTop = dl.indexOf(top); + return { + label, + visible: o.visible, + effVisible: effVisible(o), + inWindow: top === win, + topInDisplayList: top === win ? 'the map window' : (iTop >= 0 ? `${top.type || top.constructor.name} @ dl[${iTop}] (win=${iWin})` : 'NOT in display list'), + paintsAboveMap: top === win || (iTop > iWin), + }; + }; + + // ---- 1: hover the object ------------------------------------------------ + win._setHover({ x: sx, y: sy }); + await sleep(120); + + const before = { + ttPlate: describe(win.ttPlate, 'ttPlate'), + ttName: describe(win.ttName, 'ttName'), + ttHint: describe(win.ttHint, 'ttHint'), + ttCont: describe(win.ttCont, 'ttCont'), + hoverG: describe(win.hoverG, 'hoverG'), + hover: !!win._hover, + hoverId: win._hover?.id ?? null, + ttNameText: win.ttName.text, + mapImg: { inDL: iMapImg >= 0, interactive: !!(win.mapImg.input && win.mapImg.input.enabled) }, + }; + + // ---- 2: click the object (through the real handler) -------------------- + 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, + }; + + // a click on EMPTY plate must not retarget + s.ship.target = null; + win.mapImg.emit('pointerdown', { x: win.geo.mapX + 4, y: win.geo.mapY + 4 }); + await sleep(120); + const missClick = { targetStayedNull: s.ship.target === null }; + + // ?keepOpen=1 — stop here (map still open, tooltip up) so a screenshot + // harness can capture the chart + tooltip on screen. + const keepOpen = new URLSearchParams(location.search).has('keepOpen'); + let after = null, reopen = null, after2 = null; + if (keepOpen) { + const pass = click.targetWasSet && missClick.targetStayedNull; + window.__MAPCLICK = { + ready: true, pass, keepOpen: true, before, click, missClick, errors, + }; + console.log('MAPCLICK (keepOpen) ready'); + } else { + // ---- 3: close the map, then inspect what lingers ------------------------ + win.close(); + await sleep(700); // close tween is 150 ms + + after = { + openState: win.openState, + ttPlate: describe(win.ttPlate, 'ttPlate'), + ttName: describe(win.ttName, 'ttName'), + ttHint: describe(win.ttHint, 'ttHint'), + ttCont: describe(win.ttCont, 'ttCont'), + hoverG: describe(win.hoverG, 'hoverG'), + }; + + // ---- 4: re-open, hover, close again (no leftovers, no crash) ----------- + win.open(); + await sleep(1800); + win._setHover({ x: sx, y: sy }); + await sleep(120); + reopen = { open: win.isOpen, hoverVisible: effVisible(win.ttName) }; + win.close(); + await sleep(700); + after2 = { + openState: win.openState, + ttContHidden: !effVisible(win.ttCont), + ttPlateHidden: !effVisible(win.ttPlate), + ttNameHidden: !effVisible(win.ttName), + ttHintHidden: !effVisible(win.ttHint), + }; + } + + const results = []; + const check = (label, cond) => results.push({ label, pass: !!cond }); + + check('tooltip (ttPlate) belongs to the map window (paints with it)', + topLevel(win.ttPlate) === win); + check('tooltip name text belongs to the map window', topLevel(win.ttName) === win); + check('tooltip hint ("TAP TO PLOT COURSE") belongs to the map window', topLevel(win.ttHint) === win); + check('while open: tooltip effectively visible on the chart', + 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); + if (!keepOpen) { + check('after close: tooltip plate hidden (no lingering button)', + !after.ttPlate.effVisible); + check('after close: tooltip name hidden', !after.ttName.effVisible); + check('after close: tooltip hint hidden', !after.ttHint.effVisible); + 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('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 }; + console.log(pass ? 'MAPCLICK PASS' : 'MAPCLICK FAIL'); + console.log(JSON.stringify({ results, before, click, after }, null, 1)); +} catch (err) { + const results = [{ label: `THREW: ${err.message}`, pass: false }]; + window.__MAPCLICK = { ready: true, pass: false, results, errors }; + console.error('MAPCLICK FAIL (threw)', err); +} diff --git a/js/ui/MapWindow.js b/js/ui/MapWindow.js index 6d222b1..7125e41 100644 --- a/js/ui/MapWindow.js +++ b/js/ui/MapWindow.js @@ -1348,8 +1348,14 @@ export class MapWindow extends Phaser.GameObjects.Container { this.hoverG = s.graphics().setScrollFactor(0).setDepth(4); this.add(this.hoverG); - // tooltip (name + kind + "tap to plot course") - this.ttPlate = s.graphics().setScrollFactor(0).setDepth(5); + // tooltip (name + kind + "tap to plot course") — ALL children of + // ttCont, which is inside the window container. (They used to be + // bare scene objects at depth 5: painted BEHIND the map window + // (depth 80) and never hidden on close — a ghost "tap to plot + // course" button left on the world after the map closed.) Inside + // the container they paint with the window, fade with its close + // tween, and hide with ttCont. + this.ttPlate = s.graphics().setScrollFactor(0); this.ttName = s .text(0, 0, '', { fontFamily: HEADER, @@ -1358,8 +1364,7 @@ export class MapWindow extends Phaser.GameObjects.Container { fontStyle: 'bold', letterSpacing: 1.5, }) - .setScrollFactor(0) - .setDepth(5); + .setScrollFactor(0); this.ttSub = s .text(0, 0, '', { fontFamily: BODY, @@ -1367,8 +1372,7 @@ export class MapWindow extends Phaser.GameObjects.Container { color: toCss(C.dim), letterSpacing: 1.5, }) - .setScrollFactor(0) - .setDepth(5); + .setScrollFactor(0); this.ttHint = s .text(0, 0, 'TAP TO PLOT COURSE', { fontFamily: BODY, @@ -1376,9 +1380,9 @@ export class MapWindow extends Phaser.GameObjects.Container { color: toCss(C.amber), letterSpacing: 2, }) - .setScrollFactor(0) - .setDepth(5); + .setScrollFactor(0); this.ttCont = new Phaser.GameObjects.Container(this.scene, 0, 0); + this.ttCont.add([this.ttPlate, this.ttName, this.ttSub, this.ttHint]); this.ttCont.setVisible(false).setDepth(5); this.add(this.ttCont);