diff --git a/README.md b/README.md index b7ce02d2..7d0f1811 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,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, 1382 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1384 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/framing.ts b/src/editor/framing.ts index 2f3493dd..273ab674 100644 --- a/src/editor/framing.ts +++ b/src/editor/framing.ts @@ -148,21 +148,24 @@ export function boundsOf(boxes: readonly Box[], seed?: Bounds): Bounds | null { /** * The view that shows every box at once, centred, with `margin` screen pixels to spare. - * Never zooms past 1: a two-node graph blown up to fill the screen helps nobody. The scene view - * deliberately does not share that cap, which is why only the bounds sum is common. + * Never zooms past `cap`, 1 by default: a two-node graph blown up to fill the screen helps nobody. + * The Tile tab lifts it, since its board is measured in image pixels of a tile – 16 px of them + * at 100 % is a stamp in the corner. The scene view does not share the cap at all, which is why + * only the bounds sum is common. */ export function frameBoxes( boxes: readonly Box[], view: { w: number; h: number }, margin: number, limits: { min: number; max: number }, + cap = 1, ): Framing { const bounds = boundsOf(boxes); if (!bounds) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 }; const { minX, minY, maxX, maxY } = bounds; const width = Math.max(maxX - minX, 1); const height = Math.max(maxY - minY, 1); - const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1); + const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, cap); const zoom = Math.min(Math.max(fitted, limits.min), limits.max); return { zoom, diff --git a/src/editor/tile/TileCanvas.ts b/src/editor/tile/TileCanvas.ts index 2bcf78ee..590f55ab 100644 --- a/src/editor/tile/TileCanvas.ts +++ b/src/editor/tile/TileCanvas.ts @@ -97,6 +97,15 @@ export class TileCanvas extends Widget { private beside = false; private info: TileInfo | null = null; private needsFit = true; + /** + * Whether the view is still the one Fit chose. A panel that settles on its size after the tab + * opened – or is resized later – then fits again, instead of keeping a board framed for the + * first, smaller size; the same when the board itself changes size. A pan or a zoom by hand + * ends it until the next Fit. + */ + private followsFit = true; + /** The view size and the board a fit was made for. */ + private fittedFor = { w: 0, h: 0, board: { w: 0, h: 0 } }; private readonly stopPictures: () => void; constructor(private readonly state: EditorState) { @@ -114,6 +123,7 @@ export class TileCanvas extends Widget { (view, gesture) => { this.view = view; if (gesture === 'zoom') this.needsFit = false; + this.followsFit = false; this.applyView(); }, ZOOM_LIMITS, @@ -147,11 +157,13 @@ export class TileCanvas extends Widget { this.assetId = assetId; this.time = 0; this.needsFit = true; + this.followsFit = true; this.redraw(); } fit(): void { this.needsFit = true; + this.followsFit = true; this.redraw(); } @@ -164,6 +176,7 @@ export class TileCanvas extends Widget { if (needsFit) return; this.view = { pan: { ...view.pan }, zoom: view.zoom }; this.needsFit = false; + this.followsFit = false; this.redraw(); } @@ -194,6 +207,8 @@ export class TileCanvas extends Widget { zoom: number; pan: { x: number; y: number }; rect: { x: number; y: number; w: number; h: number }; + /** Grid and sample map together, in image pixels of the tile. */ + size: { w: number; h: number }; } { const info = this.info; const origin = this.getGlobalPosition(); @@ -210,6 +225,7 @@ export class TileCanvas extends Widget { zoom: this.view.zoom, pan: { ...this.view.pan }, rect: { x: origin.x, y: origin.y, w: this.w, h: this.h }, + size: { ...this.board }, }; } @@ -273,6 +289,8 @@ export class TileCanvas extends Widget { } override layout(): void { + const resized = this.w !== this.fittedFor.w || this.h !== this.fittedFor.h; + if (this.followsFit && resized) this.needsFit = true; if (this.needsFit) this.redraw(); } @@ -288,11 +306,15 @@ export class TileCanvas extends Widget { this.build(asset); this.paint(asset); this.syncHint(asset); + // Another picture with smaller tiles would otherwise leave the board a third of its size. + const regrown = this.board.w !== this.fittedFor.board.w || this.board.h !== this.fittedFor.board.h; + if (this.followsFit && regrown) this.needsFit = true; if (this.needsFit && this.w > 0 && this.h > 0 && this.tiles.length > 0) { const box: Box = { id: 'board', x: 0, y: 0, w: this.board.w, h: this.board.h }; - const framing = frameBoxes([box], { w: this.w, h: this.h }, FIT_MARGIN, ZOOM_LIMITS); + const framing = frameBoxes([box], { w: this.w, h: this.h }, FIT_MARGIN, ZOOM_LIMITS, ZOOM_LIMITS.max); this.view = { pan: { x: framing.panX, y: framing.panY }, zoom: framing.zoom }; this.needsFit = false; + this.fittedFor = { w: this.w, h: this.h, board: { ...this.board } }; } this.applyView(); } diff --git a/tests/editor/graph-framing.test.ts b/tests/editor/graph-framing.test.ts index eec73c25..e4813bcd 100644 --- a/tests/editor/graph-framing.test.ts +++ b/tests/editor/graph-framing.test.ts @@ -141,6 +141,13 @@ describe('fit', () => { expect(f.zoom).toBe(1); }); + it('fills the view up to a raised cap – the Tile tab, whose board is a few tiles of 16 px', () => { + const board: Box[] = [{ id: 'board', x: 0, y: 0, w: 345, h: 186 }]; + const f = frameBoxes(board, { w: 1000, h: 600 }, 16, { min: 0.1, max: 6 }, 6); + expect(f.zoom).toBeCloseTo((1000 - 32) / 345); + expect(frameBoxes(board, { w: 10000, h: 6000 }, 16, { min: 0.1, max: 6 }, 6).zoom).toBe(6); + }); + it('never zooms out past the limit, however far the nodes are apart', () => { const far: Box[] = [ { id: 'a', x: 0, y: 0, w: 100, h: 60 }, diff --git a/tools/e2e/39-tile.mjs b/tools/e2e/39-tile.mjs index d4884f52..094cdec7 100644 --- a/tools/e2e/39-tile.mjs +++ b/tools/e2e/39-tile.mjs @@ -208,6 +208,17 @@ export async function tile(t) { await settle(); board = await json('__gm.tile.board()'); const fitted = { zoom: board.zoom, pan: board.pan }; + // Fit used to stop at 100 %, which left a board measured in 16 px tiles a stamp in the corner: + // framed, it reaches the margin on one side (or the zoom limit). + const fills = (b) => + b.zoom === 6 || + Math.abs(Math.max((b.size.w * b.zoom) / (b.rect.w - 32), (b.size.h * b.zoom) / (b.rect.h - 32)) - 1) < + 0.01; + check( + 'Fit fills the view with the board', + fills(board), + JSON.stringify({ zoom: board.zoom, size: board.size, rect: board.rect }), + ); const centre = { x: board.rect.x + board.rect.w / 2, y: board.rect.y + board.rect.h - 40 }; await drag(centre.x, centre.y, centre.x + 60, centre.y - 40); board = await json('__gm.tile.board()'); @@ -396,6 +407,13 @@ export async function tile(t) { board.kind === 'terrain' && board.combinations === 47 && board.tile === 16 && board.problem === '', JSON.stringify(board), ); + // A third of the tile size, and the view untouched since the last Fit: the board frames itself + // again, past 100 %, instead of shrinking to a third where it stood. + check( + 'smaller tiles are framed again, past 100 %', + board.zoom > 1 && fills(board), + JSON.stringify({ zoom: board.zoom, size: board.size, rect: board.rect }), + ); const wholeTile = (tx, ty) => [ [2 * tx, 2 * ty], [2 * tx + 1, 2 * ty],