diff --git a/README.md b/README.md index 8b3238ee..a2d591c9 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/EditorRecorder.ts b/src/editor/EditorRecorder.ts index 23d28114..f04ad214 100644 --- a/src/editor/EditorRecorder.ts +++ b/src/editor/EditorRecorder.ts @@ -207,7 +207,8 @@ class CanvasSource implements FrameSource { const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size); 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 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); if (!path) this.paths.set(shape, (path = new Path2D(shape.path))); const pen = this.framePen; @@ -263,8 +264,9 @@ class PointerTrack { this.attached = true; const rect = canvas.getBoundingClientRect(); const at = ui().app.renderer.events.pointer.global; - this.x = rect.left + at.x; - this.y = rect.top + at.y; + const shown = rect.width / ui().app.screen.width; + this.x = rect.left + at.x * shown; + this.y = rect.top + at.y * shown; this.inside = at.x > 0 || at.y > 0; window.addEventListener('pointermove', this.moved, { capture: true, passive: true }); window.addEventListener('pointerdown', this.moved, { capture: true, passive: true }); diff --git a/src/page.ts b/src/page.ts index 5dcb484f..ee163eb1 100644 --- a/src/page.ts +++ b/src/page.ts @@ -1,5 +1,6 @@ import { Application, Container } from 'pixi.js'; 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 // 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 - * 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 }> { const fonts = loadEditorFonts(); + const fixed = fixedPageSize(params); const app = new Application(); await app.init({ - resizeTo: window, + ...(fixed ? { width: fixed.w, height: fixed.h } : { resizeTo: window }), background: Theme.colors.background, antialias: true, resolution: Math.min(2, window.devicePixelRatio || 1), @@ -33,6 +35,7 @@ export async function createPage(params: URLSearchParams): Promise<{ app: Applic }); initPixelGrid(app.renderer.resolution); document.body.appendChild(app.canvas); + if (fixed) pinCanvas(app.canvas, fixed); await fonts; const root = new VStack(); @@ -52,6 +55,20 @@ export async function createPage(params: URLSearchParams): Promise<{ app: Applic 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 { console.error(error); document.body.innerHTML = `
${String(error)}`;
diff --git a/src/ui/pageSize.ts b/src/ui/pageSize.ts
new file mode 100644
index 00000000..35ae08e8
--- /dev/null
+++ b/src/ui/pageSize.ts
@@ -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,
+ };
+}
diff --git a/tests/ui/page-size.test.ts b/tests/ui/page-size.test.ts
new file mode 100644
index 00000000..78b368ae
--- /dev/null
+++ b/tests/ui/page-size.test.ts
@@ -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 });
+ });
+});
diff --git a/tools/e2e/62-device-pixels.mjs b/tools/e2e/62-device-pixels.mjs
index a9ef510f..f79f7f26 100644
--- a/tools/e2e/62-device-pixels.mjs
+++ b/tools/e2e/62-device-pixels.mjs
@@ -94,6 +94,44 @@ export async function devicePixels(t) {
);
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 navigate();
const back = await json('__gm.pixelGrid()');