diff --git a/data/asteroids.json b/data/asteroids.json index 19fe46f..caf83bc 100644 --- a/data/asteroids.json +++ b/data/asteroids.json @@ -53,7 +53,7 @@ "bandLen": 46 }, "popup": { - "width": 212, + "width": 248, "padTop": 10, "padBottom": 12, "gap": 8, diff --git a/dev/popup-shot.html b/dev/popup-shot.html new file mode 100644 index 0000000..b0b53f8 --- /dev/null +++ b/dev/popup-shot.html @@ -0,0 +1,35 @@ + + + + + Orbit — dev: mining popup shot + + + + + + +
+ + + diff --git a/dev/popup-shot.mjs b/dev/popup-shot.mjs new file mode 100644 index 0000000..62601e9 --- /dev/null +++ b/dev/popup-shot.mjs @@ -0,0 +1,90 @@ +/** + * Dev: screenshot the mining context menu — in the REAL web font — and + * report whether the buttons fit inside the panel. + * + * The menu sizes itself from its buttons (js/ui/MiningPopup.js), and the + * buttons measure their label in whatever font is live at construction. + * index.html waits for the typefaces before boot (js/main.js → awaitFonts), + * so this probe does the same before creating the game. + * + * python3 -m http.server 8080 + * node dev/wdshot.mjs http://localhost:8080/dev/popup-shot.html /tmp/popup.png \ + * 'window.__POPUP_SHOT__(); return window.__POPUP_SHOT__RESULT__;' 40000 5000 + */ +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); + +// The game measures its UI text in the web font (Centauri) — wait for it +// BEFORE the scenes build any text (same contract as js/main.js). +const __fonts = globalThis.document?.fonts; +if (__fonts && typeof __fonts.load === 'function') { + const fams = ['header', 'body'] + .map((k) => config.get(`theme.fonts.${k}.family`)) + .filter((f) => typeof f === 'string' && f.length > 0); + if (fams.length > 0) { + await Promise.race([ + Promise.allSettled(fams.map((f) => __fonts.load(`16px "${f}"`).catch(() => {}))), + new Promise((r) => setTimeout(r, 2000)), + ]); + } +} + +// A deterministic galaxy (same clusters every run) + quiet audio in headless. +globalThis.__ORBIT_DEV_SEED = 'MINING'; +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; // GameScene boots first +if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true; + +const game = new Phaser.Game(gameConfig); +window.game = game; + +/** + * Frame the first cluster, open the mining menu on it, pump until the open + * tween is done, and report the panel/button geometry (px). + */ +window.__POPUP_SHOT__ = () => { + const g = window.game; + const s = g.scene.getScene('GameScene'); + if (!s || !Array.isArray(s.asteroidClusters) || s.asteroidClusters.length === 0) { + return { ready: false, note: s ? 'no clusters (yet?)' : 'no scene' }; + } + const c = s.asteroidClusters[0]; + const m = c.members[0]; + + // Park the ship by the rock so the camera frames it, and centre on it. + s.ship.stop(); + s.ship.x = m.wx + 34; + s.ship.y = m.wy - 22; + const cam = s.cameras.main; + cam.scrollX = m.wx - cam.width / 2; + cam.scrollY = m.wy - cam.height / 2; + + const rock = { cluster: c, member: m, x: m.wx, y: m.wy }; + s.openMiningMenu(rock, { x: cam.width / 2, y: m.wy - cam.scrollY }); + + // Pump until the open tween (150 ms) is done — this box freezes rAF. + for (let i = 0; i < 300 && s.miningPopup.alpha < 0.999; i++) g.loop.step(performance.now()); + + const pop = s.miningPopup; + const r = { + ready: true, + fontStatus: __fonts?.status ?? 'n/a', + header: pop.headerText.text, + headerW: Math.round(pop.headerText.width), + panelW: pop.W, + panelH: pop.H, + primary: { label: pop.primaryBtn.labelText.text, labelW: Math.round(pop.primaryBtn.labelText.width), btnW: pop.primaryBtn.style.width }, + cancel: { label: pop.cancelBtn.labelText.text, labelW: Math.round(pop.cancelBtn.labelText.width), btnW: pop.cancelBtn.style.width }, + primaryFits: pop.primaryBtn.style.width + 24 <= pop.W, + cancelFits: pop.cancelBtn.style.width + 24 <= pop.W, + headerFits: pop.headerText.width <= pop.W - 16, + }; + window.__POPUP_SHOT__RESULT__ = r; + return r; +}; diff --git a/dev/wdshot.mjs b/dev/wdshot.mjs index a0d86a8..7cf83d8 100644 --- a/dev/wdshot.mjs +++ b/dev/wdshot.mjs @@ -1,18 +1,23 @@ // One-shot headless-Firefox screenshot via geckodriver (W3C WebDriver). // -// node dev/wdshot.mjs [setupExpression] [timeoutMs=20000] +// node dev/wdshot.mjs [setupExpression] [timeoutMs=20000] [settleMs=0] // // The setup expression (optional) runs right before the capture — it may // return a Promise; the runner waits for it to settle (so scene state / // animations can be driven first). WebDriver's /screenshot endpoint is // used (not canvas.toDataURL — a WebGL canvas without // preserveDrawingBuffer captures black). +// +// settleMs (optional): a runner-side settle wait AFTER navigation, before +// the setup script runs. Pages with async module boot (top-level awaits — +// e.g. dev/popup-shot.mjs waiting for the web fonts) need a beat before +// their globals exist. import { spawn, execFileSync } from 'node:child_process'; import { existsSync, readdirSync, writeFileSync } from 'node:fs'; -const [url, out, setup, timeoutMs = '20000'] = process.argv.slice(2); +const [url, out, setup, timeoutMs = '20000', settleMs = '0'] = process.argv.slice(2); if (!url || !out) { - console.error('usage: node dev/wdshot.mjs [setupExpression] [timeoutMs]'); + console.error('usage: node dev/wdshot.mjs [setupExpression] [timeoutMs] [settleMs]'); process.exit(2); } @@ -103,6 +108,11 @@ try { await wd('POST', `/session/${sid}/url`, { url }); + // Settle: let the page finish its async boot (module top-level awaits) + // before the setup script runs — a runner-side wait, since the page's + // own timers may be starved in headless. + if (Number(settleMs) > 0) await sleep(Number(settleMs)); + if (setup) { // Multiple scripts, each held as a pending Promise under execute/sync // (geckodriver honours the session script timeout set above — 3 min). diff --git a/js/ui/MiningPopup.js b/js/ui/MiningPopup.js index 4e04966..31cd214 100644 --- a/js/ui/MiningPopup.js +++ b/js/ui/MiningPopup.js @@ -88,6 +88,16 @@ export class MiningPopup extends Phaser.GameObjects.Container { this.cancelBtn.style.neon = dim; this.cancelBtn.panel.on('pointerdown', () => this.fire('cancel')); + // The data width is a BASE, not a ceiling: MenuButton measures itself + // in the real (web) font and won't shrink below its label (width = + // max(requested, labelWidth + 20)). Keep the panel wide enough that the + // widest button still sits fully inside it, 12 px out from each edge. + this.W = Math.max( + this.W, + this.primaryBtn.style.width + 24, + this.cancelBtn.style.width + 24, + ); + const h1 = this.primaryBtn.style.height; const h2 = this.cancelBtn.style.height; this.H = this.padTop + this.headerH + this.gap + h1 + this.gap + h2 + this.padBottom; @@ -219,14 +229,22 @@ export class MiningPopup extends Phaser.GameObjects.Container { return !!r && wx >= r.x && wx <= r.x + r.w && wy >= r.y && wy <= r.y + r.h; } - /** Lay the panel out: dir −1 = above the anchor (default), +1 = below. */ + /** + * Lay the panel out: dir −1 = above the anchor (default), +1 = below. + * p() maps a distance from the PANEL'S TOP EDGE (0 = top edge) to a + * container offset: the panel's near edge sits at the anchor, so above + * it the whole panel hangs up (top edge at −H) and below it the panel + * grows down (top edge at 0). Top→bottom is always + * header · MINE ASTEROIDS · CANCEL, whichever side it opens on. + */ layout(dir) { + const p = (top) => (dir < 0 ? top - this.H : top); this.panelG.setPosition(0, dir * (this.H / 2)); - this.headerText.setPosition(0, dir * (this.H - this.padTop - this.headerH / 2)); - const y1 = this.H - this.padTop - this.headerH - this.gap - this.h1 / 2; - const y2 = y1 + this.h1 / 2 + this.gap + this.h2 / 2; - this.primaryBtn.setPosition(0, dir * y1); - this.cancelBtn.setPosition(0, dir * y2); + this.headerText.setPosition(0, p(this.padTop + this.headerH / 2)); + const top1 = this.padTop + this.headerH + this.gap + this.h1 / 2; // primary, from top + const top2 = top1 + this.h1 / 2 + this.gap + this.h2 / 2; // cancel, from top + this.primaryBtn.setPosition(0, p(top1)); + this.cancelBtn.setPosition(0, p(top2)); } /** Fire an action id to the scene (its handler closes the menu). */