Fix mining popup width and layout to fit real font metrics
- Widen the data-driven base width and clamp the panel to at least the widest button plus padding, since MenuButton measures itself in the live web font and won't shrink below its label. - Rewrite layout() with a top-edge-relative p() helper so the header/MINE/CANCEL order stays consistent whether the popup opens above or below the anchor. - Add dev/popup-shot.html/.mjs to boot the game headless, await the typefaces (same contract as js/main.js), open the mining menu on a deterministic cluster, and report panel/button geometry plus fit booleans. - Extend dev/wdshot.mjs with an optional settleMs argument so pages with top-level awaits (font loading) can finish their async boot before the setup script runs.
This commit is contained in:
parent
b4b656f6e4
commit
cff8fa335e
|
|
@ -53,7 +53,7 @@
|
|||
"bandLen": 46
|
||||
},
|
||||
"popup": {
|
||||
"width": 212,
|
||||
"width": 248,
|
||||
"padTop": 10,
|
||||
"padBottom": 12,
|
||||
"gap": 8,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Orbit — dev: mining popup shot</title>
|
||||
<!-- This page lives in /dev, but the game's relative asset paths are
|
||||
rooted at the project root — resolve them against it. -->
|
||||
<base href="../" />
|
||||
<style>
|
||||
/* The game typefaces (see data/theme.json) — the menu measures its
|
||||
buttons in the WEB font, so the probe must load them too (the same
|
||||
contract as index.html / js/main.js). -->
|
||||
@font-face {
|
||||
font-family: 'Ethnocentric';
|
||||
src: url('assets/fonts/Ethnocentric-Regular.otf') format('opentype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Centauri';
|
||||
src: url('assets/fonts/FontsFree-Net-Centauri.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||
</style>
|
||||
<script src="lib/phaser.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<script type="module" src="dev/popup-shot.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -1,18 +1,23 @@
|
|||
// One-shot headless-Firefox screenshot via geckodriver (W3C WebDriver).
|
||||
//
|
||||
// node dev/wdshot.mjs <url> <out.png> [setupExpression] [timeoutMs=20000]
|
||||
// node dev/wdshot.mjs <url> <out.png> [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 <url> <out.png> [setupExpression] [timeoutMs]');
|
||||
console.error('usage: node dev/wdshot.mjs <url> <out.png> [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).
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Loading…
Reference in New Issue