From bfbcbe604c4e5ff9b9f23b42815679363d2e6f5e Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 18 Sep 2026 10:15:51 +0200 Subject: [PATCH] The tile list in the tilemap section is a palette of pictures: each tile shows its lone tile (the four convex corners for floor and wall, the picture itself for none) with its name below, laid out with the image-picker grid (#139) Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/core/model/autotile.ts | 8 + src/editor/debugHook.ts | 10 +- src/editor/inspector/tilemapSection.ts | 46 +++--- src/editor/texture/imageGrid.ts | 17 +- src/editor/tile/TilePalette.ts | 213 +++++++++++++++++++++++++ tests/core/tile.test.ts | 23 +++ tests/editor/image-grid.test.ts | 13 ++ tools/e2e/40-tilemap.mjs | 27 ++++ 9 files changed, 331 insertions(+), 28 deletions(-) create mode 100644 src/editor/tile/TilePalette.ts diff --git a/README.md b/README.md index d5c5eab0..f2f6eb1e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,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, 1021 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1023 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/model/autotile.ts b/src/core/model/autotile.ts index 83aea7c3..61433e71 100644 --- a/src/core/model/autotile.ts +++ b/src/core/model/autotile.ts @@ -118,6 +118,14 @@ const NO_NEIGHBOURS: Neighbours = { nw: false, }; +/** + * The tile standing for its kind in a palette (#139): the lone tile, all four convex corners. It is + * the one RPG Maker shows too – the block's top-left tile, which is pixel for pixel the same. + */ +export function loneTileQuadrants(kind: TileKind): TileQuadrants { + return tileQuadrants(kind, NO_NEIGHBOURS); +} + const SIDES = ['n', 'e', 's', 'w'] as const; const DIAGONALS = ['ne', 'se', 'sw', 'nw'] as const; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index f02a82f0..276b226b 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -559,9 +559,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const button = currentTilemapSection()?.tools.get(tool as TilemapTool); return button ? centreOf(button) : null; }, - tileButtonAt: (assetId: string) => { - const button = currentTilemapSection()?.tiles.get(assetId); - return button ? centreOf(button) : null; + tileButtonAt: (assetId: string) => + currentTilemapSection()?.palette?.swatchGlobal(assetId)?.centre ?? null, + /** A tile's box in the palette: its centre, where its picture sits and the palette's columns (#139). */ + tileSwatch: (assetId: string) => { + const palette = currentTilemapSection()?.palette; + const swatch = palette?.swatchGlobal(assetId); + return palette && swatch ? { ...swatch, columns: palette.columns } : null; }, addLayerAt: () => { const button = currentTilemapSection()?.addLayer; diff --git a/src/editor/inspector/tilemapSection.ts b/src/editor/inspector/tilemapSection.ts index ad4d18b3..9af708af 100644 --- a/src/editor/inspector/tilemapSection.ts +++ b/src/editor/inspector/tilemapSection.ts @@ -16,6 +16,7 @@ import type { TilemapAsset } from '../../core/model/Asset'; import { isLayerVisible } from '../../core/model/tilemap'; import { Button, Checkbox, HStack, Theme } from '../../ui'; import type { EditorState, TilemapTool } from '../state'; +import { TilePalette } from '../tile/TilePalette'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; const TOOLS: { tool: TilemapTool; label: string; hint: string }[] = [ @@ -42,7 +43,8 @@ export function paintedLayerId(state: EditorState, asset: TilemapAsset): string */ export interface TilemapSectionWidgets { tools: Map; - tiles: Map; + /** Null while the project has no tile. */ + palette: TilePalette | null; layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[]; addLayer: Button; } @@ -58,7 +60,7 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v const paint = state.tilemapPaint; const widgets: TilemapSectionWidgets = { tools: new Map(), - tiles: new Map(), + palette: null, layers: [], addLayer: new Button('Add layer', { onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)), @@ -79,23 +81,19 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v addNote(host, 'Shift-drag still pans the view.', 'small'); addTitle(host, 'TILES'); - const tiles = state.project.assets.filter((a) => a.type === 'tile'); + const tiles = tileIdsOf(state); if (tiles.length === 0) addNote(host, 'The project has no tile yet.'); - for (const tile of tiles) { - const button = new Button(tile.name, { - align: 'left', - // Choosing a tile is how one starts painting, so it reaches for the brush by itself. - onClick: () => - state.setTilemapPaint({ - tileId: tile.id, - tool: paint.tool === 'select' || paint.tool === 'erase' ? 'brush' : paint.tool, - }), - }); - button.toggled = paint.tileId === tile.id; - widgets.tiles.set(tile.id, button); - host.body.add(button); + else { + // Choosing a tile is how one starts painting, so it reaches for the brush by itself. + widgets.palette = new TilePalette(state, tiles, paint.tileId, (tileId) => + state.setTilemapPaint({ + tileId, + tool: paint.tool === 'select' || paint.tool === 'erase' ? 'brush' : paint.tool, + }), + ); + host.body.add(widgets.palette); } - if (tiles.length > 0 && !tiles.some((a) => a.id === paint.tileId)) + if (tiles.length > 0 && !tiles.includes(paint.tileId)) addNote(host, 'Choose a tile to paint with.', 'small'); addTitle(host, 'LAYERS'); @@ -140,13 +138,19 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v host.body.add(widgets.addLayer); } +function tileIdsOf(state: EditorState): string[] { + return state.project.assets.filter((a) => a.type === 'tile').map((a) => a.id); +} + /** - * What the section is built from, for the inspector's rebuild check: the layers and what is - * painted with. The cells themselves are deliberately absent – a stroke must not rebuild the - * panel under the pointer that is drawing it. + * What the section is built from, for the inspector's rebuild check: the layers, the tiles on offer + * and what is painted with. The cells themselves are deliberately absent – a stroke must not + * rebuild the panel under the pointer that is drawing it – and so are the tiles' pictures and + * names, which the palette repaints by itself. */ export function tilemapSignature(state: EditorState, asset: TilemapAsset): string { const paint = state.tilemapPaint; const layers = asset.layers.map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}`).join(','); - return `${paint.tool}|${paint.tileId}|${paintedLayerId(state, asset)}|${layers}`; + const tiles = tileIdsOf(state).join(','); + return `${paint.tool}|${paint.tileId}|${paintedLayerId(state, asset)}|${layers}|${tiles}`; } diff --git a/src/editor/texture/imageGrid.ts b/src/editor/texture/imageGrid.ts index ebcd09f9..6229c1c7 100644 --- a/src/editor/texture/imageGrid.ts +++ b/src/editor/texture/imageGrid.ts @@ -26,10 +26,21 @@ export interface GridMetrics { height: number; } -export function gridMetrics(width: number, count: number, gap = TILE_GAP): GridMetrics { - const columns = Math.max(1, Math.floor((width + gap) / (TILE_MIN + gap))); +/** How small and how large a tile may get; the tile palette of a tilemap wants smaller ones (#139). */ +export interface TileSizes { + min: number; + max: number; +} + +export function gridMetrics( + width: number, + count: number, + gap = TILE_GAP, + sizes: TileSizes = { min: TILE_MIN, max: TILE_MAX }, +): GridMetrics { + const columns = Math.max(1, Math.floor((width + gap) / (sizes.min + gap))); const spread = (width - gap * (columns - 1)) / columns; - const tile = Math.max(TILE_MIN, Math.min(TILE_MAX, Math.floor(spread))); + const tile = Math.max(sizes.min, Math.min(sizes.max, Math.floor(spread))); const rows = Math.ceil(count / columns); const height = rows * (tile + CAPTION_HEIGHT) + Math.max(0, rows - 1) * gap; return { columns, tile, rows, height }; diff --git a/src/editor/tile/TilePalette.ts b/src/editor/tile/TilePalette.ts new file mode 100644 index 00000000..020a315a --- /dev/null +++ b/src/editor/tile/TilePalette.ts @@ -0,0 +1,213 @@ +import { Container, type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js'; +import { loneTileQuadrants } from '../../core/model/autotile'; +import { findAssetOf } from '../../core/model/Project'; +import { quadrantRef, readTile } from '../../core/model/tile'; +import { + drawControlBox, + ScrollContainer, + type Size, + Stack, + Theme, + textStyle, + truncate, + Widget, +} from '../../ui'; +import { textureLibrary } from '../../viewport/TextureLibrary'; +import { gridMetrics, type GridMetrics, tileAt, tileRect } from '../texture/imageGrid'; +import type { EditorState } from '../state'; + +/** Smaller than the image picker's: a tile is 48 px, and a palette should show several in a row. */ +const SIZES = { min: 56, max: 72 }; +const GAP = 6; +/** Inset of the picture inside its box, so the selection frame stays visible around it. */ +const PAD = 4; + +interface Swatch { + box: Container; + background: Graphics; + sprites: [Sprite, Sprite, Sprite, Sprite]; + /** Stands where the picture would be while the tile has none that fits. */ + empty: Text; + caption: Text; +} + +/** + * The tiles of the project as a grid of pictures (#139), in the painting section of a tilemap: one + * sees what one paints with, and it takes a fraction of the room a list of buttons did. Each tile is + * shown alone – for an autotile the lone tile of its block, the one RPG Maker shows as well – and + * as its first picture: the inspector has no clock, and a still picture names a tile well enough. + * + * The tiles are fixed when it is built; the section rebuilds when that list changes. A picture + * edited or a texture arriving only repaints. + */ +export class TilePalette extends Widget { + private metrics: GridMetrics = gridMetrics(0, 0, GAP, SIZES); + private readonly swatches: Swatch[] = []; + private hoveredIndex = -1; + private pressedIndex = -1; + /** The width a relayout was last asked for, so a height that will not settle cannot loop. */ + private askedAt = -1; + private readonly stops: (() => void)[]; + + constructor( + private readonly state: EditorState, + private readonly tileIds: readonly string[], + private readonly selectedId: string, + private readonly onPick: (tileId: string) => void, + ) { + super(); + this.cursor = Theme.hoverCursor; + for (const _ of tileIds) this.swatches.push(this.buildSwatch()); + this.stops = [ + state.on('project', () => this.invalidate()), + textureLibrary.onChange(() => this.invalidate()), + ]; + + this.on('pointermove', (e: FederatedPointerEvent) => this.hover(this.indexUnder(e))); + this.on('pointerout', () => { + this.pressedIndex = -1; + this.hover(-1); + }); + this.on('pointerdown', (e: FederatedPointerEvent) => { + if (e.button === 0) this.pressedIndex = this.indexUnder(e); + }); + this.on('pointerup', (e: FederatedPointerEvent) => { + const index = this.indexUnder(e); + const pressed = this.pressedIndex; + this.pressedIndex = -1; + if (e.button === 0 && index >= 0 && index === pressed) this.onPick(this.tileIds[index]!); + }); + } + + /** Where a tile's box and its picture sit on screen (test hook); null when it is not offered. */ + swatchGlobal( + tileId: string, + ): { centre: { x: number; y: number }; picture: { x: number; y: number; w: number } } | null { + const index = this.tileIds.indexOf(tileId); + if (index < 0) return null; + const rect = tileRect(index, this.metrics, GAP); + const origin = this.getGlobalPosition(); + const x = origin.x + rect.x; + const y = origin.y + rect.y; + return { + centre: { x: x + rect.w / 2, y: y + this.metrics.tile / 2 }, + picture: { x: x + PAD, y: y + PAD, w: this.metrics.tile - PAD * 2 }, + }; + } + + /** How many tiles share a row – the check scripts read this. */ + get columns(): number { + return this.metrics.columns; + } + + override measure(): Size { + const width = this.w || this.expectedWidth(); + return { w: this.minWidth, h: gridMetrics(width, this.tileIds.length, GAP, SIZES).height }; + } + + /** + * The height depends on the width, and the first measure comes before any width was given. So + * the palette guesses it from the scroll area it sits in, less the padding of the stacks between. + */ + private expectedWidth(): number { + let inset = 0; + for (let parent = this.parent; parent; parent = parent.parent) { + if (parent instanceof ScrollContainer) return Math.max(0, parent.w - inset); + if (parent instanceof Stack) inset += parent.padding * 2; + } + return 0; + } + + override layout(): void { + this.metrics = gridMetrics(this.w, this.tileIds.length, GAP, SIZES); + this.swatches.forEach((swatch, index) => { + const rect = tileRect(index, this.metrics, GAP); + swatch.box.position.set(rect.x, rect.y); + }); + // The guess was off – a scrollbar took its width, or the column was dragged narrower – so the + // grid needs another number of rows than it was given. The scroll area measures again. + if (this.metrics.height !== this.h && this.askedAt !== this.w) { + this.askedAt = this.w; + queueMicrotask(() => { + if (!this.destroyed) this.scrollArea()?.relayoutContent(); + }); + } + } + + private scrollArea(): ScrollContainer | null { + for (let parent = this.parent; parent; parent = parent.parent) + if (parent instanceof ScrollContainer) return parent; + return null; + } + + override redraw(): void { + const side = this.metrics.tile; + this.swatches.forEach((swatch, index) => this.drawSwatch(swatch, this.tileIds[index]!, index, side)); + } + + private drawSwatch(swatch: Swatch, tileId: string, index: number, side: number): void { + const selected = tileId === this.selectedId; + const hovered = index === this.hoveredIndex; + swatch.background.clear(); + drawControlBox(swatch.background, side, side, { + fill: selected ? Theme.colors.selection : hovered ? Theme.colors.controlHover : Theme.colors.control, + border: selected ? Theme.colors.accent : Theme.colors.border, + }); + + const asset = findAssetOf(this.state.project, tileId, 'tile'); + const info = asset ? readTile(this.state.project, asset) : null; + const picture = info?.problem === '' ? info.pictures[0] : undefined; + const quadrants = info ? loneTileQuadrants(info.kind) : null; + const half = (side - PAD * 2) / 2; + swatch.sprites.forEach((sprite, corner) => { + const texture = + picture && info && quadrants + ? textureLibrary.get(quadrantRef(picture, info.quad, quadrants[corner]!)) + : null; + sprite.visible = texture !== null; + if (!texture) return; + sprite.texture = texture; + sprite.position.set(PAD + (corner % 2) * half, PAD + Math.floor(corner / 2) * half); + sprite.width = half; + sprite.height = half; + }); + // A tile without a usable picture still has to be pickable, and look like it is missing something. + swatch.empty.visible = !picture; + swatch.empty.position.set( + Math.round((side - swatch.empty.width) / 2), + Math.round((side - swatch.empty.height) / 2), + ); + + swatch.caption.style = textStyle(selected ? 'accent' : 'muted'); + swatch.caption.text = truncate(asset?.name ?? '', swatch.caption.style, side); + swatch.caption.position.set(0, side + 1); + } + + private buildSwatch(): Swatch { + const box = new Container(); + const background = new Graphics(); + const sprites = [new Sprite(), new Sprite(), new Sprite(), new Sprite()] as Swatch['sprites']; + const empty = new Text({ text: '?', style: textStyle('muted') }); + const caption = new Text({ style: textStyle('small') }); + box.addChild(background, ...sprites, empty, caption); + box.eventMode = 'none'; + this.addChild(box); + return { box, background, sprites, empty, caption }; + } + + private indexUnder(e: FederatedPointerEvent): number { + const local = this.toLocal(e.global); + return tileAt(local.x, local.y, this.tileIds.length, this.metrics, GAP); + } + + private hover(index: number): void { + if (index === this.hoveredIndex) return; + this.hoveredIndex = index; + this.invalidate(); + } + + override destroy(): void { + for (const stop of this.stops) stop(); + super.destroy({ children: true }); + } +} diff --git a/tests/core/tile.test.ts b/tests/core/tile.test.ts index f5ae35fb..a66b2f70 100644 --- a/tests/core/tile.test.ts +++ b/tests/core/tile.test.ts @@ -14,6 +14,7 @@ import type { TileAsset } from '../../src/core/model/Asset'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { autotileCombinations, + loneTileQuadrants, type Neighbours, neighboursAt, SAMPLE_PATTERN, @@ -249,6 +250,28 @@ describe('autotile blocks', () => { expect([corner.n, corner.e, corner.ne]).toEqual([true, true, false]); expect(tileQuadrants('floor', corner)[1]).toEqual({ x: 3, y: 0 }); }); + + it('shows each kind in the palette as its lone tile, the four convex corners (#139)', () => { + expect(loneTileQuadrants('floor')).toEqual([ + { x: 0, y: 2 }, + { x: 3, y: 2 }, + { x: 0, y: 5 }, + { x: 3, y: 5 }, + ]); + expect(loneTileQuadrants('wall')).toEqual([ + { x: 0, y: 0 }, + { x: 3, y: 0 }, + { x: 0, y: 3 }, + { x: 3, y: 3 }, + ]); + // A plain tile is its picture as it is, each corner its own quarter. + expect(loneTileQuadrants('none')).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + ]); + }); }); describe('tile asset', () => { diff --git a/tests/editor/image-grid.test.ts b/tests/editor/image-grid.test.ts index 000d0c67..b74dc561 100644 --- a/tests/editor/image-grid.test.ts +++ b/tests/editor/image-grid.test.ts @@ -28,6 +28,19 @@ describe('gridMetrics', () => { expect(gridMetrics(140, 1).tile).toBe(TILE_MAX); }); + it('takes other limits for the tile size, as the tile palette of a tilemap does (#139)', () => { + const sizes = { min: 56, max: 72 }; + // A 300 px inspector column: four boxes of 56 fit, spread to 70 each. + const m = gridMetrics(300, 5, 6, sizes); + expect(m.columns).toBe(4); + expect(m.tile).toBe(70); + expect(m.rows).toBe(2); + expect(m.height).toBe(2 * (70 + CAPTION_HEIGHT) + 6); + // 170 px hold two columns of 82 – capped at the upper limit, not at the picker's. + expect(gridMetrics(170, 5, 6, sizes).tile).toBe(72); + expect(tileAt(tileRect(1, m, 6).x + 2, 2, 5, m, 6)).toBe(1); + }); + it('reports the height the scroll container has to give it', () => { const m = gridMetrics(WIDTH, 7); expect(m.rows).toBe(2); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 1b717d67..4f320e73 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -112,6 +112,33 @@ export async function tilemap(t) { section.tools && section.grass && Boolean(section.layers), JSON.stringify(section), ); + // The tiles are a grid of pictures (#139), and each shows its block's lone tile: the four convex + // corners, read off the palette in one call like a cell of the map. + await inInspector(t, '__gm.tilemap.tileButtonAt("at-grass")'); + const swatch = await json(`(() => { + const s = __gm.tilemap.tileSwatch("at-grass"); + if (!s) return null; + const q = s.picture.w / 4; + const points = [[q, q], [3 * q, q], [q, 3 * q], [3 * q, 3 * q]] + .map(([x, y]) => [Math.round(s.picture.x + x), Math.round(s.picture.y + y)]); + return { columns: s.columns, read: points.map(([x, y]) => __gm.pixelAt(x, y)) }; + })()`); + const lone = [ + [0, 2], + [3, 2], + [0, 5], + [3, 5], + ].map(([qx, qy]) => quadColour('floor', qx, qy)); + check( + 'the palette shows a tile as its picture, the lone tile of the block', + swatch !== null && swatch.read.every((colour, i) => colourDistance(colour, lone[i]) <= 12), + JSON.stringify({ swatch, want: lone }), + ); + check( + 'and puts several tiles side by side in the inspector', + swatch !== null && swatch.columns >= 3, + JSON.stringify(swatch), + ); // Clicked through the hook rather than by its text: the tile's own row in the asset panel // reads "Grass" as well, and a click there would only select the tile. check(