From af6ac12ab5f70d1bf36a7cd3beac13359113a7d7 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 25 Sep 2026 06:18:41 +0200 Subject: [PATCH] Show sharp tile preview labels and paths with tunnels and crossings The mask labels under the tile preview sat inside the zoomed board. Pixi rendered them at 10 px and stretched them with the zoom, and each label was shrunk to the width of its tile on its own. They came out blurry and in different sizes. The example map also only showed a ring, so narrow paths and crossings were never previewed. - The labels live in their own layer in screen space and are re-rendered at the fitting font size on every zoom. - All labels share one size, at most the 10 px of the theme. Below 6 px they are hidden. - A label has two lines: the sides on top, the corners dimmed below. - The example map keeps the ring cell for cell and adds a network of 1 tile wide paths below it. It has straight 1xN and Nx1 pieces, curves, two T junctions, a crossing and a dead end. - The map stands next to the grid in a wide panel and below it in a tall one. Fit picks the layout with the larger tiles, and only Fit changes it, so zooming does not make the board jump. - The new hook __gm.tile.captions() reports size, scale and lines. Found on the way: the taller map made Fit zoom out further, and 3 pixel checks on the 16 px terrain tiles failed. The side by side layout fixed that. Tests: - ./check.sh: 160 files, 2364 tests, CHECK EXIT 0 (was 2363). - e2e 39: ALL PASSED, 29 checks (was 28). - New unit test: the example map has straights, a curve, a crossing with four notched corners, a T junction and a dead end. - Counter check: with the labels back in the zoomed board the new caption check fails (scales [0.648, 1]). - The e2e sum in the README is now 1381. It is calculated only. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/model/autotile.ts | 10 +- src/editor/debug/documentHooks.ts | 2 + src/editor/tile/TileCanvas.ts | 183 ++++++++++++++++++++++++++---- tests/core/tile.test.ts | 21 ++++ tools/e2e/39-tile.mjs | 27 +++++ 6 files changed, 218 insertions(+), 27 deletions(-) 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()');