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)
game-maker / gate (push) Successful in 1m41s
game-maker / suite (push) Successful in 6m15s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 10:15:51 +02:00
1 parent 4c411c1ff3
commit bfbcbe604c
9 files changed
+327 -24

No files matched your search

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+8
View File
@@ -118,6 +118,14 @@ const NO_NEIGHBOURS: Neighbours = {
nw: false, 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 SIDES = ['n', 'e', 's', 'w'] as const;
const DIAGONALS = ['ne', 'se', 'sw', 'nw'] as const; const DIAGONALS = ['ne', 'se', 'sw', 'nw'] as const;
+7 -3
View File
@@ -559,9 +559,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const button = currentTilemapSection()?.tools.get(tool as TilemapTool); const button = currentTilemapSection()?.tools.get(tool as TilemapTool);
return button ? centreOf(button) : null; return button ? centreOf(button) : null;
}, },
tileButtonAt: (assetId: string) => { tileButtonAt: (assetId: string) =>
const button = currentTilemapSection()?.tiles.get(assetId); currentTilemapSection()?.palette?.swatchGlobal(assetId)?.centre ?? null,
return button ? centreOf(button) : 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: () => { addLayerAt: () => {
const button = currentTilemapSection()?.addLayer; const button = currentTilemapSection()?.addLayer;
+21 -17
View File
@@ -16,6 +16,7 @@ import type { TilemapAsset } from '../../core/model/Asset';
import { isLayerVisible } from '../../core/model/tilemap'; import { isLayerVisible } from '../../core/model/tilemap';
import { Button, Checkbox, HStack, Theme } from '../../ui'; import { Button, Checkbox, HStack, Theme } from '../../ui';
import type { EditorState, TilemapTool } from '../state'; import type { EditorState, TilemapTool } from '../state';
import { TilePalette } from '../tile/TilePalette';
import { addGap, addNote, addTitle, type InspectorHost } from './host'; import { addGap, addNote, addTitle, type InspectorHost } from './host';
const TOOLS: { tool: TilemapTool; label: string; hint: string }[] = [ const TOOLS: { tool: TilemapTool; label: string; hint: string }[] = [
@@ -42,7 +43,8 @@ export function paintedLayerId(state: EditorState, asset: TilemapAsset): string
*/ */
export interface TilemapSectionWidgets { export interface TilemapSectionWidgets {
tools: Map<TilemapTool, Button>; tools: Map<TilemapTool, Button>;
tiles: Map<string, Button>; /** Null while the project has no tile. */
palette: TilePalette | null;
layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[]; layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[];
addLayer: Button; addLayer: Button;
} }
@@ -58,7 +60,7 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v
const paint = state.tilemapPaint; const paint = state.tilemapPaint;
const widgets: TilemapSectionWidgets = { const widgets: TilemapSectionWidgets = {
tools: new Map(), tools: new Map(),
tiles: new Map(), palette: null,
layers: [], layers: [],
addLayer: new Button('Add layer', { addLayer: new Button('Add layer', {
onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)), 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'); addNote(host, 'Shift-drag still pans the view.', 'small');
addTitle(host, 'TILES'); 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.'); if (tiles.length === 0) addNote(host, 'The project has no tile yet.');
for (const tile of tiles) { else {
const button = new Button(tile.name, {
align: 'left',
// Choosing a tile is how one starts painting, so it reaches for the brush by itself. // Choosing a tile is how one starts painting, so it reaches for the brush by itself.
onClick: () => widgets.palette = new TilePalette(state, tiles, paint.tileId, (tileId) =>
state.setTilemapPaint({ state.setTilemapPaint({
tileId: tile.id, tileId,
tool: paint.tool === 'select' || paint.tool === 'erase' ? 'brush' : paint.tool, tool: paint.tool === 'select' || paint.tool === 'erase' ? 'brush' : paint.tool,
}), }),
}); );
button.toggled = paint.tileId === tile.id; host.body.add(widgets.palette);
widgets.tiles.set(tile.id, button);
host.body.add(button);
} }
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'); addNote(host, 'Choose a tile to paint with.', 'small');
addTitle(host, 'LAYERS'); addTitle(host, 'LAYERS');
@@ -140,13 +138,19 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v
host.body.add(widgets.addLayer); 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 * What the section is built from, for the inspector's rebuild check: the layers, the tiles on offer
* painted with. The cells themselves are deliberately absent – a stroke must not rebuild the * and what is painted with. The cells themselves are deliberately absent – a stroke must not
* panel under the pointer that is drawing it. * 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 { export function tilemapSignature(state: EditorState, asset: TilemapAsset): string {
const paint = state.tilemapPaint; const paint = state.tilemapPaint;
const layers = asset.layers.map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}`).join(','); 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}`;
} }
+14 -3
View File
@@ -26,10 +26,21 @@ export interface GridMetrics {
height: number; height: number;
} }
export function gridMetrics(width: number, count: number, gap = TILE_GAP): GridMetrics { /** How small and how large a tile may get; the tile palette of a tilemap wants smaller ones (#139). */
const columns = Math.max(1, Math.floor((width + gap) / (TILE_MIN + gap))); 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 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 rows = Math.ceil(count / columns);
const height = rows * (tile + CAPTION_HEIGHT) + Math.max(0, rows - 1) * gap; const height = rows * (tile + CAPTION_HEIGHT) + Math.max(0, rows - 1) * gap;
return { columns, tile, rows, height }; return { columns, tile, rows, height };
+213
View File
@@ -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 });
}
}
+23
View File
@@ -14,6 +14,7 @@ import type { TileAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { import {
autotileCombinations, autotileCombinations,
loneTileQuadrants,
type Neighbours, type Neighbours,
neighboursAt, neighboursAt,
SAMPLE_PATTERN, SAMPLE_PATTERN,
@@ -249,6 +250,28 @@ describe('autotile blocks', () => {
expect([corner.n, corner.e, corner.ne]).toEqual([true, true, false]); expect([corner.n, corner.e, corner.ne]).toEqual([true, true, false]);
expect(tileQuadrants('floor', corner)[1]).toEqual({ x: 3, y: 0 }); 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', () => { describe('tile asset', () => {
+13
View File
@@ -28,6 +28,19 @@ describe('gridMetrics', () => {
expect(gridMetrics(140, 1).tile).toBe(TILE_MAX); 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', () => { it('reports the height the scroll container has to give it', () => {
const m = gridMetrics(WIDTH, 7); const m = gridMetrics(WIDTH, 7);
expect(m.rows).toBe(2); expect(m.rows).toBe(2);
+27
View File
@@ -112,6 +112,33 @@ export async function tilemap(t) {
section.tools && section.grass && Boolean(section.layers), section.tools && section.grass && Boolean(section.layers),
JSON.stringify(section), 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 // 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. // reads "Grass" as well, and a click there would only select the tile.
check( check(