diff --git a/README.md b/README.md index 2ad63a54..5806c756 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, 1380 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1381 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/core/model/autotile.ts b/src/core/model/autotile.ts index bd87df10..9514c612 100644 --- a/src/core/model/autotile.ts +++ b/src/core/model/autotile.ts @@ -254,8 +254,9 @@ function combinationLabel(around: Neighbours, kind: TileKind): string { const NOTCH_ORDER = [1, 3, 2, 0]; /** - * The little map the tab composes as a sample: a ring with a hole and a step, which is every - * interesting case at once – outer corners, straight edges and, in the hole, the notches. + * The little map the tab composes as a sample: a ring with a hole and a step – outer corners, + * straight edges and, in the hole, the notches – and below it paths one tile wide: a tunnel + * leaving the ring, both straights, bends, T-junctions, a crossing and a dead end. */ export const SAMPLE_PATTERN: readonly string[] = [ '..........', @@ -265,7 +266,10 @@ export const SAMPLE_PATTERN: readonly string[] = [ '.###..###.', '..######..', '...####...', - '..........', + '.....#....', + '..#######.', + '..#..#..#.', + '#########.', ]; export function patternCell(pattern: readonly string[], row: number, column: number): boolean { diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts index 864720d4..9a125560 100644 --- a/src/editor/debug/documentHooks.ts +++ b/src/editor/debug/documentHooks.ts @@ -73,6 +73,8 @@ export function documentHooks({ state, editor }: HookContext) { tile: (index: number) => editor.tileEditor.canvas.tileDrawn(index), /** Which board tile a cell of the sample map became – `-1` when that cell is empty. */ sampleTile: (row: number, column: number) => editor.tileEditor.canvas.sampleTile(row, column), + /** The captions under the grid: size, text and whether the zoom reaches their raster. */ + captions: () => editor.tileEditor.canvas.captionInfo(), kindPickerAt: () => centreOf(editor.tileEditor.kindPicker), addPictureAt: () => centreOf(editor.tileEditor.strip.addButton), removePictureAt: () => centreOf(editor.tileEditor.strip.removeButton), diff --git a/src/editor/tile/TileCanvas.ts b/src/editor/tile/TileCanvas.ts index 4364609b..2bcf78ee 100644 --- a/src/editor/tile/TileCanvas.ts +++ b/src/editor/tile/TileCanvas.ts @@ -1,4 +1,4 @@ -import { Container, Graphics, Sprite, Text } from 'pixi.js'; +import { Container, Graphics, Sprite, Text, type TextStyle } from 'pixi.js'; import type { TileAsset } from '../../core/model/Asset'; import { autotileCombinations, @@ -29,7 +29,13 @@ import type { EditorState } from '../state'; const GRID_COLUMNS = 8; /** Gaps and caption room, in tile widths – so the board keeps its shape at any tile size. */ const GAP = 0.34; -const CAPTION = 0.3; +const CAPTION = 0.6; +/** Between a tile and its caption, in tile widths. */ +const CAPTION_GAP = 0.04; +/** Below this a caption is no longer read but guessed at, so the captions go. */ +const MIN_CAPTION_SIZE = 6; +/** Room left between a caption and the tiles on either side, in screen pixels. */ +const CAPTION_MARGIN = 4; const SECTION_GAP = 1.2; const FIT_MARGIN = 16; /** Wider than the graph's: a 48 px tile is small, and its quarters are what one wants to look at. */ @@ -45,19 +51,40 @@ interface BoardTile { cell?: { row: number; column: number }; } +/** The caption under a grid tile: the joined sides, and below them the notched corners. */ +interface Caption { + /** Middle of the tile's bottom edge, in image pixels of the board. */ + x: number; + y: number; + sides: Text; + notches: Text | null; +} + /** * The Tile tab's picture (#118): every tile the block can produce, captioned with the - * neighbours that ask for it, and below them a sample map composed from the same quarters – the + * neighbours that ask for it, and below or beside them a sample map from the same quarters – the * one place that shows whether the edges really continue across tile borders. A drag pans, the * wheel zooms, nothing is edited here. */ export class TileCanvas extends Widget { private readonly bg = new Graphics(); private readonly content = new Container(); + /** + * The captions stay in screen space: drawn inside the zoomed board they were blown up from a + * 10 px raster and went soft, and each was shrunk to fit its tile on its own, so a one-letter + * caption came out several times the size of a long one. + */ + private readonly captionLayer = new Container(); + private readonly clipped = new Container(); private readonly clip = new Graphics(); private readonly hint = new Text({ text: '', style: textStyle('muted') }); private readonly tiles: BoardTile[] = []; - private readonly labels: Text[] = []; + private readonly captions: Caption[] = []; + /** Own copies, since the size follows the zoom here and nowhere else. */ + private readonly sidesStyle: TextStyle = textStyle('viewportLabel').clone(); + private readonly notchesStyle: TextStyle = textStyle('viewportLabelMuted').clone(); + /** The widest caption line and the height of a caption, measured at the theme's size. */ + private captionBox = { w: 0, h: 0 }; private view: GraphView = { pan: { x: 0, y: 0 }, zoom: 1 }; private assetId = ''; /** Seconds the preview has been running – the only thing that moves without an edit. */ @@ -66,6 +93,8 @@ export class TileCanvas extends Widget { private built = ''; private shownPicture = -1; private board = { w: 0, h: 0 }; + /** Whether the sample map stands beside the grid rather than below it. */ + private beside = false; private info: TileInfo | null = null; private needsFit = true; private readonly stopPictures: () => void; @@ -73,9 +102,11 @@ export class TileCanvas extends Widget { constructor(private readonly state: EditorState) { super(); this.content.eventMode = 'none'; + this.captionLayer.eventMode = 'none'; this.hint.eventMode = 'none'; - this.addChild(this.bg, this.content, this.hint, this.clip); - this.content.mask = this.clip; + this.clipped.addChild(this.content, this.captionLayer); + this.addChild(this.bg, this.clipped, this.hint, this.clip); + this.clipped.mask = this.clip; panAndZoom( this, @@ -98,6 +129,8 @@ export class TileCanvas extends Widget { override destroy(options?: Parameters[0]): void { this.stopPictures(); super.destroy(options); + this.sidesStyle.destroy(); + this.notchesStyle.destroy(); } override measure(): Size { @@ -157,6 +190,7 @@ export class TileCanvas extends Widget { pictures: number; tile: number; problem: string; + beside: boolean; zoom: number; pan: { x: number; y: number }; rect: { x: number; y: number; w: number; h: number }; @@ -172,6 +206,7 @@ export class TileCanvas extends Widget { pictures: info?.pictures.length ?? 0, tile: info?.tile ?? 0, problem: info?.problem ?? '', + beside: this.beside, zoom: this.view.zoom, pan: { ...this.view.pan }, rect: { x: origin.x, y: origin.y, w: this.w, h: this.h }, @@ -198,6 +233,31 @@ export class TileCanvas extends Widget { }; } + /** + * The captions as drawn: their text, the font size they are set in and how far the board's zoom + * reaches them – `1` means screen space, anything else means a scaled raster. + */ + captionInfo(): { + shown: boolean; + fontSize: number; + scales: number[]; + texts: string[]; + boxes: { x: number; y: number; w: number; h: number }[]; + } { + const texts = this.captions.map((c) => (c.notches ? `${c.sides.text}-${c.notches.text}` : c.sides.text)); + const lines = this.captions.flatMap((c) => (c.notches ? [c.sides, c.notches] : [c.sides])); + return { + shown: this.captionLayer.visible && this.captions.length > 0, + fontSize: Number(this.sidesStyle.fontSize), + scales: [...new Set(lines.map((line) => line.worldTransform.a))], + texts, + boxes: lines.map((line) => { + const bounds = line.getBounds(); + return { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height }; + }), + }; + } + /** The board tile a cell of the sample map became, so a check can name one without counting. */ sampleTile(row: number, column: number): number { return this.tiles.findIndex((tile) => tile.cell?.row === row && tile.cell.column === column); @@ -221,6 +281,10 @@ export class TileCanvas extends Widget { this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); const asset = this.asset(); this.info = asset ? readTile(this.state.project, asset) : null; + // Chosen only when fitting: a board that rearranged itself under a zoomed-in view would lose + // the place one was looking at. + if (this.needsFit && this.info && this.w > 0 && this.h > 0) + this.beside = this.fitsBetterBeside(this.info); this.build(asset); this.paint(asset); this.syncHint(asset); @@ -236,13 +300,18 @@ export class TileCanvas extends Widget { /** Rebuilds the sprites when the board's shape changed – the kind, the tile size or the asset. */ private build(asset: TileAsset | undefined): void { const info = this.info; - const signature = asset && info && info.problem === '' ? `${asset.id}:${info.kind}:${info.tile}` : ''; + const signature = + asset && info && info.problem === '' ? `${asset.id}:${info.kind}:${info.tile}:${this.beside}` : ''; if (signature === this.built) return; this.built = signature; this.shownPicture = -1; for (const tile of this.tiles.splice(0)) for (const sprite of tile.sprites) sprite.destroy(); - for (const label of this.labels.splice(0)) label.destroy(); + for (const caption of this.captions.splice(0)) { + caption.sides.destroy(); + caption.notches?.destroy(); + } this.content.removeChildren(); + this.captionLayer.removeChildren(); if (!info || signature === '') { this.board = { w: 0, h: 0 }; return; @@ -250,11 +319,32 @@ export class TileCanvas extends Widget { this.layoutBoard(info); } - /** The combination grid, then the sample map below it – all in image pixels of the tile. */ + /** Grid and sample map in tile widths, and the board they make below or beside each other. */ + private static arrangement(info: TileInfo, beside: boolean) { + const grid = { + w: GRID_COLUMNS * (1 + GAP) - GAP, + h: Math.ceil(autotileCombinations(info.kind).length / GRID_COLUMNS) * (1 + GAP + CAPTION), + }; + const sample = { w: SAMPLE_PATTERN[0]?.length ?? 0, h: SAMPLE_PATTERN.length }; + const at = beside ? { x: grid.w + SECTION_GAP, y: 0 } : { x: 0, y: grid.h + SECTION_GAP }; + return { at, w: Math.max(grid.w, at.x + sample.w), h: Math.max(grid.h, at.y + sample.h) }; + } + + /** Side by side in a wide view, one below the other in a tall one – whichever Fit draws larger. */ + private fitsBetterBeside(info: TileInfo): boolean { + const scale = (beside: boolean) => { + const board = TileCanvas.arrangement(info, beside); + return Math.min((this.w - FIT_MARGIN * 2) / board.w, (this.h - FIT_MARGIN * 2) / board.h); + }; + return scale(true) > scale(false); + } + + /** The combination grid, then the sample map – all in image pixels of the tile. */ private layoutBoard(info: TileInfo): void { const tile = info.tile; const step = tile * (1 + GAP); const row = tile * (1 + GAP + CAPTION); + const arrangement = TileCanvas.arrangement(info, this.beside); const combinations = autotileCombinations(info.kind); combinations.forEach((combination, index) => { const column = index % GRID_COLUMNS; @@ -262,22 +352,23 @@ export class TileCanvas extends Widget { const x = column * step; const y = line * row; this.addTile(x, y, combination.quadrants); - this.addLabel(combination.label, x, y + tile + tile * 0.06, tile); + this.addCaption(combination.label, x + tile / 2, y + tile); }); - const gridHeight = Math.ceil(combinations.length / GRID_COLUMNS) * row; - const sampleTop = gridHeight + tile * SECTION_GAP; + this.measureCaptions(); + const left = arrangement.at.x * tile; + const top = arrangement.at.y * tile; for (let line = 0; line < SAMPLE_PATTERN.length; line++) for (let column = 0; column < (SAMPLE_PATTERN[line]?.length ?? 0); column++) { if (!patternCell(SAMPLE_PATTERN, line, column)) continue; const around = neighboursAt(SAMPLE_PATTERN, line, column); - this.addTile(column * tile, sampleTop + line * tile, tileQuadrants(info.kind, around), { + this.addTile(left + column * tile, top + line * tile, tileQuadrants(info.kind, around), { row: line, column, }); } this.board = { - w: Math.max(GRID_COLUMNS * step - tile * GAP, (SAMPLE_PATTERN[0]?.length ?? 0) * tile), - h: sampleTop + SAMPLE_PATTERN.length * tile, + w: arrangement.w * tile, + h: arrangement.h * tile, }; } @@ -297,14 +388,59 @@ export class TileCanvas extends Widget { this.tiles.push(cell ? { x, y, quadrants, sprites, cell } : { x, y, quadrants, sprites }); } - private addLabel(text: string, x: number, y: number, tile: number): void { - const label = new Text({ text, style: textStyle('viewportLabel') }); - // Scaled with the tile so a caption never overruns the tile it belongs to. - const scale = Math.min(1, tile / Math.max(label.width, 1)); - label.scale.set(scale); - label.position.set(x, y); - this.content.addChild(label); - this.labels.push(label); + /** `NESW-ne,se` becomes two lines, so the long captions need no more width than a tile. */ + private addCaption(label: string, x: number, y: number): void { + const [sides = label, notches] = label.split('-'); + const sidesText = new Text({ text: sides, style: this.sidesStyle, anchor: { x: 0.5, y: 0 } }); + const notchesText = notches + ? new Text({ text: notches, style: this.notchesStyle, anchor: { x: 0.5, y: 0 } }) + : null; + this.captionLayer.addChild(sidesText); + if (notchesText) this.captionLayer.addChild(notchesText); + this.captions.push({ x, y, sides: sidesText, notches: notchesText }); + } + + /** Taken at the theme's size; `placeCaptions` scales from there. */ + private measureCaptions(): void { + const size = Theme.viewportLabelSize; + this.sidesStyle.fontSize = size; + this.notchesStyle.fontSize = size; + let w = 0; + let h = 0; + for (const caption of this.captions) { + w = Math.max(w, caption.sides.width, caption.notches?.width ?? 0); + h = Math.max(h, caption.sides.height + (caption.notches?.height ?? 0)); + } + this.captionBox = { w, h }; + } + + /** + * One size for every caption, the theme's at most: as large as the narrowest room allows – the + * tile's width and the gap below it – and set anew as text, so it stays sharp at any zoom. + */ + private placeCaptions(): void { + const info = this.info; + const { w, h } = this.captionBox; + if (!info || this.captions.length === 0 || w === 0 || h === 0) { + this.captionLayer.visible = false; + return; + } + const tile = info.tile * this.view.zoom; + const room = Math.min((tile * (1 + GAP) - CAPTION_MARGIN) / w, (tile * (CAPTION - CAPTION_GAP)) / h, 1); + const size = Math.floor(Theme.viewportLabelSize * room * 2) / 2; + this.captionLayer.visible = size >= MIN_CAPTION_SIZE; + if (!this.captionLayer.visible) return; + if (this.sidesStyle.fontSize !== size) { + this.sidesStyle.fontSize = size; + this.notchesStyle.fontSize = size; + } + const origin = this.content.position; + for (const caption of this.captions) { + const x = Math.round(origin.x + caption.x * this.view.zoom); + const y = Math.round(origin.y + caption.y * this.view.zoom + tile * CAPTION_GAP); + caption.sides.position.set(x, y); + caption.notches?.position.set(x, Math.round(y + caption.sides.height)); + } } /** Puts the current picture's quarters into the sprites; a picture still loading leaves them blank. */ @@ -343,5 +479,6 @@ export class TileCanvas extends Widget { private applyView(): void { this.content.position.set(Math.round(this.view.pan.x), Math.round(this.view.pan.y)); this.content.scale.set(this.view.zoom); + this.placeCaptions(); } } diff --git a/tests/core/tile.test.ts b/tests/core/tile.test.ts index eab1ad80..f3b21ff8 100644 --- a/tests/core/tile.test.ts +++ b/tests/core/tile.test.ts @@ -251,6 +251,27 @@ describe('autotile blocks', () => { expect(tileQuadrants('floor', corner)[1]).toEqual({ x: 3, y: 0 }); }); + it('puts paths one tile wide into the sample map: straights, bends, junctions, a crossing', () => { + const sides = (row: number, column: number) => { + const around = neighboursAt(SAMPLE_PATTERN, row, column); + return (['n', 'e', 's', 'w'] as const) + .filter((side) => around[side]) + .join('') + .toUpperCase(); + }; + expect(sides(7, 5)).toBe('NS'); // the tunnel leaving the ring + expect(sides(9, 2)).toBe('NS'); + expect(sides(8, 3)).toBe('EW'); + expect(sides(8, 2)).toBe('ES'); // a bend + expect(sides(8, 5)).toBe('NESW'); // the crossing + expect(sides(10, 5)).toBe('NEW'); // a T-junction + expect(sides(10, 0)).toBe('E'); // a dead end + // A crossing of one-wide paths has none of its diagonals: all four corners are notched. + const crossing = neighboursAt(SAMPLE_PATTERN, 8, 5); + expect([crossing.ne, crossing.se, crossing.sw, crossing.nw]).toEqual([false, false, false, false]); + expect(SAMPLE_PATTERN.every((line) => line.length === SAMPLE_PATTERN[0]!.length)).toBe(true); + }); + it('shows each kind in the palette as its lone tile, the four convex corners (#139)', () => { expect(loneTileQuadrants('floor')).toEqual([ { x: 0, y: 2 }, diff --git a/tools/e2e/39-tile.mjs b/tools/e2e/39-tile.mjs index 18db4ac0..d4884f52 100644 --- a/tools/e2e/39-tile.mjs +++ b/tools/e2e/39-tile.mjs @@ -220,6 +220,33 @@ export async function tile(t) { await settle(); board = await json('__gm.tile.board()'); check('the wheel zooms it', board.zoom > fitted.zoom, JSON.stringify({ fitted, now: board.zoom })); + // The captions are text in screen space at one size for all: before, each was shrunk to its + // tile on its own inside the zoomed board, so `N` came out large and soft and `NESW-ne,se,sw,nw` + // tiny. Two lines for a notched tile, none of them running into another. + // Too small to read, they are left out, so the check zooms in until there is room for them. + let captions = await json('__gm.tile.captions()'); + for (let i = 0; i < 6 && !captions.shown; i++) { + await wheel(centre.x, centre.y, -300); + await settle(); + captions = await json('__gm.tile.captions()'); + } + board = await json('__gm.tile.board()'); + const overlap = (a, b) => + Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 1 && + Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 1; + const crowded = captions.boxes.some((a, i) => captions.boxes.slice(i + 1).some((b) => overlap(a, b))); + check( + 'the captions are sharp text of one size, split in two lines and clear of each other', + captions.shown && + JSON.stringify(captions.scales) === '[1]' && + captions.fontSize >= 6 && + captions.fontSize <= 10 && + captions.texts.includes('NESW-ne,se,sw,nw') && + captions.boxes.length > captions.texts.length && + !crowded, + JSON.stringify({ ...captions, boxes: captions.boxes.length, crowded, zoom: board.zoom }), + ); + await shot('39-captions'); await t.click(...Object.values(await json('__gm.tile.fitAt()'))); await settle(); board = await json('__gm.tile.board()');