Let ?w=&h= fix the editor canvas to a set size
A recording of the editor is as large as its canvas, and so far the canvas always followed the window. With ?w=1920&h=1080 the editor is laid out at exactly that size in CSS pixels, whatever the window. - src/ui/pageSize.ts reads w and h (both needed, 200 to 8192 each) and works out how the canvas fits the window. - A smaller window shows the canvas shrunk to fit, centred, with the same aspect. A larger one shows it 1:1, centred on a dark page. - Clicks still land right, since Pixi maps them over the canvas's on-screen box. Switching projects keeps w and h in the URL. - The pointer drawn into an editor recording now scales against the layout size, so a shrunk canvas still records a full-size pointer. Tests: - Unit page-size: 5 new, green. - e2e 62d: ?w=1600&h=900 in a 960x720 window gives a 1600x900 canvas shown at 960x540, y 90, and a click on the menu button opens it. Red without the shrinking. - Gate on melektaus-atx: 304 files, 4691 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d6a325978c
commit
846a8b70b3
6 files changed
+129
-6
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2039 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2040 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ class CanvasSource implements FrameSource {
|
|||||||
const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size);
|
const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size);
|
||||||
const x = at.x + ((pointer.x - rect.left) / rect.width) * at.w;
|
const x = at.x + ((pointer.x - rect.left) / rect.width) * at.w;
|
||||||
const y = at.y + ((pointer.y - rect.top) / rect.height) * at.h;
|
const y = at.y + ((pointer.y - rect.top) / rect.height) * at.h;
|
||||||
const scale = at.w / rect.width;
|
// Against the layout size: a canvas shrunk to fit (`?w=&h=`) still records a full-size pointer.
|
||||||
|
const scale = at.w / ui().app.screen.width;
|
||||||
let path = this.paths.get(shape);
|
let path = this.paths.get(shape);
|
||||||
if (!path) this.paths.set(shape, (path = new Path2D(shape.path)));
|
if (!path) this.paths.set(shape, (path = new Path2D(shape.path)));
|
||||||
const pen = this.framePen;
|
const pen = this.framePen;
|
||||||
@@ -263,8 +264,9 @@ class PointerTrack {
|
|||||||
this.attached = true;
|
this.attached = true;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const at = ui().app.renderer.events.pointer.global;
|
const at = ui().app.renderer.events.pointer.global;
|
||||||
this.x = rect.left + at.x;
|
const shown = rect.width / ui().app.screen.width;
|
||||||
this.y = rect.top + at.y;
|
this.x = rect.left + at.x * shown;
|
||||||
|
this.y = rect.top + at.y * shown;
|
||||||
this.inside = at.x > 0 || at.y > 0;
|
this.inside = at.x > 0 || at.y > 0;
|
||||||
window.addEventListener('pointermove', this.moved, { capture: true, passive: true });
|
window.addEventListener('pointermove', this.moved, { capture: true, passive: true });
|
||||||
window.addEventListener('pointerdown', this.moved, { capture: true, passive: true });
|
window.addEventListener('pointerdown', this.moved, { capture: true, passive: true });
|
||||||
|
|||||||
+19
-2
@@ -1,5 +1,6 @@
|
|||||||
import { Application, Container } from 'pixi.js';
|
import { Application, Container } from 'pixi.js';
|
||||||
import { initPixelGrid, initRenderLoop, initUI, loadEditorFonts, Theme, tieBreakFor, VStack } from './ui';
|
import { initPixelGrid, initRenderLoop, initUI, loadEditorFonts, Theme, tieBreakFor, VStack } from './ui';
|
||||||
|
import { fixedPageSize, type PageSize, placePage } from './ui/pageSize';
|
||||||
|
|
||||||
// `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may
|
// `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may
|
||||||
// tear; `?vsync` opts out. Pixi has no option for it, hence our own context.
|
// tear; `?vsync` opts out. Pixi has no option for it, hence our own context.
|
||||||
@@ -17,13 +18,14 @@ function lowLatencyContext(): { canvas?: HTMLCanvasElement; context?: WebGL2Rend
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The one canvas a page is: the editor's and a released game's alike (#266). `root` fills the
|
* The one canvas a page is: the editor's and a released game's alike (#266). `root` fills the
|
||||||
* window and follows it when it is resized.
|
* window and follows it when it is resized, unless `?w=&h=` give it a fixed size.
|
||||||
*/
|
*/
|
||||||
export async function createPage(params: URLSearchParams): Promise<{ app: Application; root: VStack }> {
|
export async function createPage(params: URLSearchParams): Promise<{ app: Application; root: VStack }> {
|
||||||
const fonts = loadEditorFonts();
|
const fonts = loadEditorFonts();
|
||||||
|
const fixed = fixedPageSize(params);
|
||||||
const app = new Application();
|
const app = new Application();
|
||||||
await app.init({
|
await app.init({
|
||||||
resizeTo: window,
|
...(fixed ? { width: fixed.w, height: fixed.h } : { resizeTo: window }),
|
||||||
background: Theme.colors.background,
|
background: Theme.colors.background,
|
||||||
antialias: true,
|
antialias: true,
|
||||||
resolution: Math.min(2, window.devicePixelRatio || 1),
|
resolution: Math.min(2, window.devicePixelRatio || 1),
|
||||||
@@ -33,6 +35,7 @@ export async function createPage(params: URLSearchParams): Promise<{ app: Applic
|
|||||||
});
|
});
|
||||||
initPixelGrid(app.renderer.resolution);
|
initPixelGrid(app.renderer.resolution);
|
||||||
document.body.appendChild(app.canvas);
|
document.body.appendChild(app.canvas);
|
||||||
|
if (fixed) pinCanvas(app.canvas, fixed);
|
||||||
await fonts;
|
await fonts;
|
||||||
|
|
||||||
const root = new VStack();
|
const root = new VStack();
|
||||||
@@ -52,6 +55,20 @@ export async function createPage(params: URLSearchParams): Promise<{ app: Applic
|
|||||||
return { app, root };
|
return { app, root };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keeps a canvas of a fixed size (`?w=&h=`) in the window: scaled down if it does not fit, centred. */
|
||||||
|
function pinCanvas(canvas: HTMLCanvasElement, size: PageSize): void {
|
||||||
|
const place = (): void => {
|
||||||
|
const at = placePage(size, { w: window.innerWidth, h: window.innerHeight });
|
||||||
|
canvas.style.position = 'absolute';
|
||||||
|
canvas.style.left = `${at.x}px`;
|
||||||
|
canvas.style.top = `${at.y}px`;
|
||||||
|
canvas.style.transformOrigin = '0 0';
|
||||||
|
canvas.style.transform = at.scale < 1 ? `scale(${at.scale})` : '';
|
||||||
|
};
|
||||||
|
place();
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
}
|
||||||
|
|
||||||
export function showFailure(error: unknown): void {
|
export function showFailure(error: unknown): void {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
document.body.innerHTML = `<pre style="color:#e0553f;padding:16px;font:13px monospace">${String(error)}</pre>`;
|
document.body.innerHTML = `<pre style="color:#e0553f;padding:16px;font:13px monospace">${String(error)}</pre>`;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export interface PageSize {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_SIDE = 200;
|
||||||
|
const MAX_SIDE = 8192;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `?w=1920&h=1080`: the page's canvas keeps this size in CSS pixels instead of following the window.
|
||||||
|
* Both are needed. A side outside 200…8192 is clamped.
|
||||||
|
*/
|
||||||
|
export function fixedPageSize(params: URLSearchParams): PageSize | null {
|
||||||
|
const w = side(params.get('w'));
|
||||||
|
const h = side(params.get('h'));
|
||||||
|
return w && h ? { w, h } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function side(raw: string | null): number | null {
|
||||||
|
if (raw === null || !/^\d+$/.test(raw.trim())) return null;
|
||||||
|
return Math.min(MAX_SIDE, Math.max(MIN_SIDE, Number(raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a fixed-size canvas sits in the window: shrunk to fit a smaller one, never enlarged, centred. */
|
||||||
|
export function placePage(size: PageSize, window: PageSize): { x: number; y: number; scale: number } {
|
||||||
|
const scale = Math.min(1, window.w / size.w, window.h / size.h);
|
||||||
|
return {
|
||||||
|
x: Math.round((window.w - size.w * scale) / 2),
|
||||||
|
y: Math.round((window.h - size.h * scale) / 2),
|
||||||
|
scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fixedPageSize, placePage } from '../../src/ui/pageSize';
|
||||||
|
|
||||||
|
const size = (search: string) => fixedPageSize(new URLSearchParams(search));
|
||||||
|
|
||||||
|
describe('fixedPageSize', () => {
|
||||||
|
it('reads ?w=&h=', () => {
|
||||||
|
expect(size('?w=1920&h=1080')).toEqual({ w: 1920, h: 1080 });
|
||||||
|
expect(size('?project=abc&h=720&w=1280')).toEqual({ w: 1280, h: 720 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs both sides as whole numbers', () => {
|
||||||
|
expect(size('')).toBeNull();
|
||||||
|
expect(size('?w=1920')).toBeNull();
|
||||||
|
expect(size('?w=1920&h=')).toBeNull();
|
||||||
|
expect(size('?w=1920&h=10.5')).toBeNull();
|
||||||
|
expect(size('?w=-5&h=1080')).toBeNull();
|
||||||
|
expect(size('?w=abc&h=1080')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps a side to 200…8192', () => {
|
||||||
|
expect(size('?w=50&h=100000')).toEqual({ w: 200, h: 8192 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placePage', () => {
|
||||||
|
it('centres a canvas smaller than the window at full size', () => {
|
||||||
|
expect(placePage({ w: 1920, h: 1080 }, { w: 2560, h: 1440 })).toEqual({ x: 320, y: 180, scale: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shrinks a canvas larger than the window to fit, keeping its aspect', () => {
|
||||||
|
expect(placePage({ w: 1920, h: 1080 }, { w: 960, h: 900 })).toEqual({ x: 0, y: 180, scale: 0.5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -94,6 +94,44 @@ export async function devicePixels(t) {
|
|||||||
);
|
);
|
||||||
await t.shot('62-device-pixels');
|
await t.shot('62-device-pixels');
|
||||||
|
|
||||||
|
// 62d. `?w=&h=` give the canvas a size of its own: the editor is laid out at that size, a window
|
||||||
|
// too small for it shows it shrunk to fit and centred, and a click lands where the picture is.
|
||||||
|
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||||
|
width: 960,
|
||||||
|
height: 720,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
mobile: false,
|
||||||
|
});
|
||||||
|
await cdp.send('Page.navigate', { url: `${t.baseUrl}?project=${t.projectId}&w=1600&h=900` });
|
||||||
|
await t.sleep(4000);
|
||||||
|
await t.waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(t.projectId)}`, {
|
||||||
|
timeout: 15000,
|
||||||
|
label: 'the editor at a fixed size is up',
|
||||||
|
});
|
||||||
|
const fixed = await json(`(() => {
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
const round = (v) => Math.round(v * 100) / 100;
|
||||||
|
return { w: canvas.width, h: canvas.height, shown: { x: round(r.x), y: round(r.y), w: round(r.width), h: round(r.height) } };
|
||||||
|
})()`);
|
||||||
|
const menu = await json('__gm.toolbarAt("menu")');
|
||||||
|
const factor = fixed.shown.w / fixed.w;
|
||||||
|
await t.click(fixed.shown.x + menu.x * factor, fixed.shown.y + menu.y * factor);
|
||||||
|
const rows = await json('__gm.menuRows()');
|
||||||
|
await t.press('Escape');
|
||||||
|
check(
|
||||||
|
'?w=1600&h=900 lays the editor out at 1600×900, shrinks it into a 960×720 window and still takes clicks',
|
||||||
|
fixed.w === 1600 &&
|
||||||
|
fixed.h === 900 &&
|
||||||
|
fixed.shown.x === 0 &&
|
||||||
|
fixed.shown.y === 90 &&
|
||||||
|
fixed.shown.w === 960 &&
|
||||||
|
fixed.shown.h === 540 &&
|
||||||
|
Array.isArray(rows) &&
|
||||||
|
rows.includes('Start recording'),
|
||||||
|
JSON.stringify({ fixed, menu, rows }),
|
||||||
|
);
|
||||||
|
|
||||||
await scale(1);
|
await scale(1);
|
||||||
await navigate();
|
await navigate();
|
||||||
const back = await json('__gm.pixelGrid()');
|
const back = await json('__gm.pixelGrid()');
|
||||||
|
|||||||
Reference in new issue
Block a user