Files
adminandClaude Opus 5.5 4c7861b5b8 Keep the frame rate up while painting a big tilemap (#347)
Painting on the 60x60 map of a project with fifty objects dropped the
editor to 20 to 39 fps. A profile of a brush stroke showed four causes,
none of them the brush itself.

- Each frame of a stroke previews the model. The asset and hierarchy
  panels then built every row anew, so every label became a new text
  and a new upload to the GPU. One run spent 46 % of the main thread
  in those uploads. ListView.setItems now keeps a row whose item draws
  the same (sameListItem) and builds only the rows that changed.
- The brush drew the whole scene on every pointer event, on top of the
  preview. Now the preview draws what the stroke changed, once a frame.
  The rectangle tool asks for one drawing a frame for its box.
- An animated tile made the view draw in the tick and the preview drew
  it again in the same frame. animate now asks just before the frame
  is drawn and stays out when the view was drawn since the tick began.
- layoutSymbols ran on every drawing and costs up to the cube of the
  symbols. SymbolLayoutMemo keeps the spots while no anchor moves.
- A painted cell made the map read every "x,y" key again, and once
  more for each of the eight neighbours of every cell. layerCells reads
  a layer once per content into an index by place. layerTiles and the
  blocking cells take their neighbours from there (neighboursIn).
  parseCellKey reads a key without split, with the same results.

The test hook sceneRedraws counts how often the scene view was drawn.

Not done: a painted cell still works out the tiles and blocking cells
of the whole map again, so that part grows with the map.

Tests:
- Brush stroke on a copy of the project (stein2, Chrome): 20 to 39 fps
  before, 47.5 after, 55 to 57 without painting. Labels drawn again
  during the stroke 2012 before, none after. Scene drawings per frame
  1.32 before, 1 after.
- One painted cell, headless: 28 to 16 ms on 60x60, 109 to 49 ms on
  the same map doubled to 120x120.
- frame-preview: 2 new, red with the old pointer redraw.
- tilemap: 3 new (key reading, index, refresh), list-ping 1 new,
  symbol-layout 1 new. 5 single reversals turned each red.
- e2e 39,40 (melektaus-atx): ALL PASSED, 107 checks (1 new, a fast
  stroke: 6 drawings in 6 frames, no label drawn again). With the row
  reuse and the pointer redraw reverted it fails: 24 drawings in
  3 frames, and the panels draw their labels again.
- Gate (melektaus-atx): CHECK EXIT 0, 260 files, 4038 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:54:59 +02:00

683 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateTextureCommand,
DeleteAssetCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
import {
AddTilemapLayerCommand,
DuplicateTilemapLayerCommand,
MoveTilemapLayerCommand,
PaintTilesCommand,
paintTiles,
RemoveTilemapLayerCommand,
RenameTilemapLayerCommand,
SetTilemapLayerHiddenCommand,
SetTilemapLayerZCommand,
} from '../../src/core/commands/tilemapCommands';
import type { TilemapAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { TILE_KIND_KEY, type TileKind } from '../../src/core/model/tile';
import { findAsset, type Project } from '../../src/core/model/Project';
import {
cellAt,
cellAtPoint,
cellKey,
cellOrigin,
cellsAlong,
cellsInRect,
floodFill,
inGrid,
layerCells,
layerTiles,
neighboursIn,
neighboursOf,
parseCellKey,
TILEMAP_COLUMNS_KEY,
TILEMAP_ROWS_KEY,
TILEMAP_TILE_KEY,
tilemapSize,
} from '../../src/core/model/tilemap';
import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap';
import { cellsPainted } from '../../src/core/model/usages';
import { cellsChanged } from '../../src/core/model/tilemapMemo';
import { groupByTile, tileMesh } from '../../src/viewport/tilemapMesh';
import { makeProject, roundTrip } from '../helpers';
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
/** A tilemap with a floor autotile to paint with, both sized like the RPG-Maker sheets. */
function makeTilemap(kind: TileKind = 'floor') {
const fixture = makeProject();
const texture = new CreateTextureCommand('Outside', {
file: FILE,
width: 96,
height: kind === 'floor' ? 144 : 96,
});
fixture.stack.execute(texture);
const tile = new CreateAssetCommand('tile', 'Grass');
fixture.stack.execute(tile);
fixture.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, kind));
fixture.stack.execute(new AddTilePictureCommand(tile.asset.id, texture.asset.id));
const tilemap = new CreateAssetCommand('tilemap', 'Ground');
fixture.stack.execute(tilemap);
const assetOf = () => findAsset(fixture.project, tilemap.asset.id) as TilemapAsset;
return {
...fixture,
tileId: tile.asset.id,
tilemapId: tilemap.asset.id,
assetOf,
layerId: () => assetOf().layers[0]!.id,
paint: (cells: { x: number; y: number }[], tileId = tile.asset.id) =>
fixture.stack.execute(
new PaintTilesCommand(
tilemap.asset.id,
assetOf().layers[0]!.id,
cells.map((at) => ({ ...at, tileId })),
),
),
};
}
function cellsOf(project: Project, assetId: string): Record<string, string> {
const asset = findAsset(project, assetId) as TilemapAsset;
return asset.layers[0]!.cells;
}
describe('the tilemap asset', () => {
it('is placed, unlike the tiles it draws with', () => {
expect(isPlaceableType('tilemap')).toBe(true);
expect(isPlaceableType('tile')).toBe(false);
});
it('starts with one layer and no cells', () => {
const f = makeTilemap();
expect(f.assetOf().layers).toHaveLength(1);
expect(f.assetOf().layers[0]!.cells).toEqual({});
});
it('is as big as its grid says, centred on its position', () => {
const f = makeTilemap();
const props = f.assetOf().props;
expect(tilemapSize(props)).toEqual({ w: 20 * 48, h: 15 * 48 });
expect(cellOrigin(props, 0, 0)).toEqual({ x: -480, y: -360 });
// The centre of the map is the corner of the four middle cells.
expect(cellAtPoint(props, 0, 0)).toEqual({ x: 10, y: 7 });
expect(cellAtPoint(props, -480, -360)).toEqual({ x: 0, y: 0 });
expect(cellAtPoint(props, -1, -1)).toEqual({ x: 9, y: 7 });
});
it('follows the grid and the cell size when they change', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 4));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 3));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_TILE_KEY, 10));
expect(tilemapSize(f.assetOf().props)).toEqual({ w: 40, h: 30 });
expect(inGrid(f.assetOf().props, 3, 2)).toBe(true);
expect(inGrid(f.assetOf().props, 4, 2)).toBe(false);
expect(inGrid(f.assetOf().props, -1, 0)).toBe(false);
});
});
describe('painting a tilemap', () => {
it('paints, erases and takes both back as one step', () => {
const f = makeTilemap();
f.paint([
{ x: 1, y: 1 },
{ x: 2, y: 1 },
]);
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId });
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: '' }]));
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '2,1': f.tileId });
f.stack.undo();
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId });
f.stack.undo();
expect(cellsOf(f.project, f.tilemapId)).toEqual({});
});
it('puts back what a stroke painted over, not merely empty', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('tile', 'Sand');
f.stack.execute(other);
f.paint([{ x: 3, y: 3 }]);
f.paint([{ x: 3, y: 3 }], other.asset.id);
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(other.asset.id);
f.stack.undo();
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(f.tileId);
});
it('knows when a stroke would change nothing', () => {
const f = makeTilemap();
f.paint([{ x: 0, y: 0 }]);
const layer = f.assetOf().layers[0]!;
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: f.tileId }])).toBe(false);
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: '' }])).toBe(true);
expect(PaintTilesCommand.changes(layer, [{ x: 5, y: 5, tileId: f.tileId }])).toBe(true);
});
it('makes no undo step for a rectangle over cells that already hold its tile', () => {
const f = makeTilemap();
const box = cellsInRect(f.assetOf().props, { x: 0, y: 0 }, { x: 2, y: 2 });
f.paint(box);
const again = box.map((at) => ({ ...at, tileId: f.tileId }));
expect(paintTiles(f.assetOf().layers[0]!, f.tilemapId, again)).toBeNull();
// The one undo left takes back the first rectangle, not an empty second one.
f.stack.undo();
expect(cellsOf(f.project, f.tilemapId)).toEqual({});
});
it('still makes one when a single cell of the rectangle changes', () => {
const f = makeTilemap();
f.paint(cellsInRect(f.assetOf().props, { x: 0, y: 0 }, { x: 1, y: 1 }));
const wider = cellsInRect(f.assetOf().props, { x: 0, y: 0 }, { x: 2, y: 1 });
const command = paintTiles(
f.assetOf().layers[0]!,
f.tilemapId,
wider.map((at) => ({ ...at, tileId: f.tileId })),
);
expect(command).not.toBeNull();
f.stack.execute(command!);
expect(Object.keys(cellsOf(f.project, f.tilemapId))).toHaveLength(6);
f.stack.undo();
expect(Object.keys(cellsOf(f.project, f.tilemapId)).sort()).toEqual(['0,0', '0,1', '1,0', '1,1']);
});
it('makes no undo step for a fill that finds nothing to fill', () => {
const f = makeTilemap();
f.paint([{ x: 0, y: 0 }]);
const layer = f.assetOf().layers[0]!;
expect(floodFill(f.project, f.assetOf().props, layer, { x: 0, y: 0 }, f.tileId)).toEqual([]);
expect(paintTiles(layer, f.tilemapId, [])).toBeNull();
});
it('covers the cells between two pointer events', () => {
expect(cellsAlong({ x: 0, y: 0 }, { x: 3, y: 0 })).toEqual([
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 3, y: 0 },
]);
expect(cellsAlong({ x: 0, y: 0 }, { x: 2, y: 2 })).toEqual([
{ x: 1, y: 1 },
{ x: 2, y: 2 },
]);
expect(cellsAlong({ x: 4, y: 2 }, { x: 4, y: 2 })).toEqual([{ x: 4, y: 2 }]);
});
it('clips a rectangle to the grid', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 3));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
const cells = cellsInRect(f.assetOf().props, { x: -2, y: 1 }, { x: 9, y: 9 });
expect(cells).toEqual([
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
]);
});
});
describe('what a painted cell shows', () => {
it('joins only cells holding the same tile', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('tile', 'Sand');
f.stack.execute(other);
f.paint([
{ x: 5, y: 5 },
{ x: 6, y: 5 },
]);
f.paint([{ x: 5, y: 4 }], other.asset.id);
const layer = f.assetOf().layers[0]!;
const props = f.assetOf().props;
const around = neighboursOf(props, layer, 5, 5, f.tileId);
expect(around.e).toBe(true);
// The cell above holds a *different* tile, so this one closes against it.
expect(around.n).toBe(false);
expect(around.w).toBe(false);
});
it('counts anything outside the grid as empty', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
f.paint([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
]);
const asset = f.assetOf();
const around = neighboursOf(asset.props, asset.layers[0]!, 1, 1, f.tileId);
expect(around.n).toBe(true);
expect(around.w).toBe(true);
expect(around.e).toBe(false);
expect(around.s).toBe(false);
});
it('gives a lone cell the four convex corners and a joined one the inside', () => {
const f = makeTilemap();
f.paint([{ x: 2, y: 2 }]);
const asset = f.assetOf();
const lone = layerTiles(f.project, asset.props, asset.layers[0]!)[0]!;
// The block's convex corners, as `FLOOR_CORNERS` names them.
expect(lone.quadrants).toEqual([
{ x: 0, y: 2 },
{ x: 3, y: 2 },
{ x: 0, y: 5 },
{ x: 3, y: 5 },
]);
f.paint([
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 3, y: 1 },
{ x: 1, y: 2 },
{ x: 3, y: 2 },
{ x: 1, y: 3 },
{ x: 2, y: 3 },
{ x: 3, y: 3 },
]);
const after = layerTiles(f.project, f.assetOf().props, f.assetOf().layers[0]!);
const middle = after.find((tile) => tile.x === 2 && tile.y === 2)!;
expect(middle.quadrants).toEqual([
{ x: 2, y: 4 },
{ x: 1, y: 4 },
{ x: 2, y: 3 },
{ x: 1, y: 3 },
]);
});
it('draws a plain tile the same in every cell, whatever lies around it', () => {
const f = makeTilemap('none');
f.paint([
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 1, y: 2 },
{ x: 5, y: 5 },
]);
const asset = f.assetOf();
const tiles = layerTiles(f.project, asset.props, asset.layers[0]!);
expect(tiles).toHaveLength(4);
for (const tile of tiles)
expect(tile.quadrants).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
]);
});
it('leaves out cells outside the grid and cells naming nothing', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
f.paint([
{ x: 0, y: 0 },
{ x: 5, y: 0 },
]);
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: 'gone' }]));
const asset = f.assetOf();
const tiles = layerTiles(f.project, asset.props, asset.layers[0]!);
expect(tiles.map((t) => `${t.x},${t.y}`)).toEqual(['0,0']);
// The cell naming a deleted tile is kept in the model – an undo may bring it back.
expect(asset.layers[0]!.cells[cellKey(1, 1)]).toBe('gone');
});
it('reads the same tiles twice, in reading order', () => {
const f = makeTilemap();
f.paint([
{ x: 4, y: 2 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
]);
const asset = f.assetOf();
const order = layerTiles(f.project, asset.props, asset.layers[0]!).map((t) => `${t.x},${t.y}`);
expect(order).toEqual(['1,1', '2,1', '4,2']);
});
});
describe('the cells of a layer, read once per content (#347)', () => {
it('reads a key the way it always was read', () => {
const before = (key: string) => {
const [x, y] = key.split(',').map(Number);
return Number.isInteger(x) && Number.isInteger(y) ? { x, y } : null;
};
for (const key of ['3,4', '-1,12', '0,0', '1,2,3', ',5', '5,', '1.5,2', 'a,1', '1', '', ' 2,3'])
expect(parseCellKey(key)).toEqual(before(key));
});
it('holds the cells inside the grid in reading order, and knows the same neighbours', () => {
const f = makeTilemap();
const sand = new CreateAssetCommand('tile', 'Sand');
f.stack.execute(sand);
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 5));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 4));
f.paint([
{ x: 4, y: 3 },
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 2 },
{ x: 3, y: 3 },
]);
f.paint([{ x: 2, y: 1 }], sand.asset.id);
const asset = f.assetOf();
const layer = asset.layers[0]!;
// Outside the grid: kept in the file, but neither drawn nor anyone's neighbour.
layer.cells['5,0'] = f.tileId;
layer.cells['-1,1'] = f.tileId;
cellsChanged(layer.cells);
const cells = layerCells(asset.props, layer);
expect(cells.list.map((c) => `${c.x},${c.y}`)).toEqual(['0,0', '1,0', '1,1', '2,1', '2,2', '3,3', '4,3']);
expect(cells.list.find((c) => c.x === 2 && c.y === 1)?.tileId).toBe(sand.asset.id);
for (let y = -1; y <= 4; y++)
for (let x = -1; x <= 5; x++)
for (const tileId of [f.tileId, sand.asset.id])
expect(neighboursIn(cells, x, y, tileId)).toEqual(neighboursOf(asset.props, layer, x, y, tileId));
});
it('reads the cells again once they are said to have changed, or the grid is resized', () => {
const f = makeTilemap();
f.paint([{ x: 1, y: 1 }]);
const asset = f.assetOf();
const layer = asset.layers[0]!;
const first = layerCells(asset.props, layer);
expect(layerCells(asset.props, layer)).toBe(first);
layer.cells['2,1'] = f.tileId;
cellsChanged(layer.cells);
const painted = layerCells(asset.props, layer);
expect(painted.list).toHaveLength(2);
expect(neighboursIn(painted, 1, 1, f.tileId).e).toBe(true);
const narrow = layerCells({ ...asset.props, [TILEMAP_COLUMNS_KEY]: 2 }, layer);
expect(narrow.list.map((c) => `${c.x},${c.y}`)).toEqual(['1,1']);
});
});
describe('the layers of a tilemap', () => {
it('adds, renames, reorders, hides and removes, each with an undo', () => {
const f = makeTilemap();
const add = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(add);
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']);
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, add.layerId, 'Roofs'));
expect(f.assetOf().layers[1]!.name).toBe('Roofs');
f.stack.execute(new MoveTilemapLayerCommand(f.tilemapId, 1, 0));
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Roofs', 'Layer 1']);
f.stack.undo();
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']);
f.stack.execute(new SetTilemapLayerHiddenCommand(f.tilemapId, add.layerId, true));
expect(f.assetOf().layers[1]!.hidden).toBe(true);
f.stack.undo();
expect(f.assetOf().layers[1]!.hidden).toBeUndefined();
f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId));
expect(f.assetOf().layers).toHaveLength(1);
f.stack.undo();
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']);
});
it('duplicates a layer right above it, with its cells, its z and a name of its own', () => {
const f = makeTilemap();
f.paint([{ x: 1, y: 1 }]);
f.stack.execute(new SetTilemapLayerZCommand(f.tilemapId, f.layerId(), 3));
f.stack.execute(new AddTilemapLayerCommand(f.tilemapId));
const copy = new DuplicateTilemapLayerCommand(f.tilemapId, f.layerId());
f.stack.execute(copy);
const layers = f.assetOf().layers;
expect(layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 1 copy', 'Layer 2']);
expect(layers[1]).toMatchObject({ id: copy.layerId, z: 3, cells: layers[0]!.cells });
// Cells of its own: painting on the copy leaves the original alone.
f.stack.execute(new PaintTilesCommand(f.tilemapId, copy.layerId, [{ x: 1, y: 1, tileId: '' }]));
expect(f.assetOf().layers[0]!.cells[cellKey(1, 1)]).toBeTruthy();
f.stack.undo();
f.stack.undo();
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']);
});
it('renames a layer without the spaces around it, refuses a blank name and undoes to the old one', () => {
const f = makeTilemap();
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' Ground '));
expect(f.assetOf().layers[0]!.name).toBe('Ground');
expect(() => f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' '))).toThrow(
'A layer needs a name',
);
expect(f.assetOf().layers[0]!.name).toBe('Ground');
f.stack.undo();
expect(f.assetOf().layers[0]!.name).toBe('Layer 1');
});
it('keeps the last layer, because there has to be one to paint on', () => {
const f = makeTilemap();
expect(() => f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, f.layerId()))).toThrow(
'A tilemap keeps at least one layer',
);
expect(f.assetOf().layers).toHaveLength(1);
});
it('takes the cells of a removed layer with it and brings them back', () => {
const f = makeTilemap();
const add = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(add);
f.stack.execute(new PaintTilesCommand(f.tilemapId, add.layerId, [{ x: 2, y: 2, tileId: f.tileId }]));
f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId));
expect(f.assetOf().layers).toHaveLength(1);
f.stack.undo();
expect(f.assetOf().layers[1]!.cells).toEqual({ '2,2': f.tileId });
});
});
describe('how the scene view shows the layers next to the painted one', () => {
it('draws all of them whole, fades the others or leaves them out', () => {
expect(layerAlpha('a', null)).toBe(1);
expect(layerAlpha('b', { layerId: 'a', mode: 'all' })).toBe(1);
expect(layerAlpha('a', { layerId: 'a', mode: 'dim' })).toBe(1);
expect(layerAlpha('b', { layerId: 'a', mode: 'dim' })).toBe(DIMMED_LAYER_ALPHA);
expect(layerAlpha('a', { layerId: 'a', mode: 'solo' })).toBe(1);
expect(layerAlpha('b', { layerId: 'a', mode: 'solo' })).toBe(0);
});
});
describe('a tilemap in the document', () => {
it('survives a round trip with its layers and cells', () => {
const f = makeTilemap();
const add = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(add);
f.stack.execute(new SetTilemapLayerHiddenCommand(f.tilemapId, add.layerId, true));
f.paint([{ x: 1, y: 2 }]);
const back = roundTrip(f.project);
const asset = findAsset(back, f.tilemapId) as TilemapAsset;
expect(asset.layers).toHaveLength(2);
expect(asset.layers[0]!.cells).toEqual({ '1,2': f.tileId });
expect(asset.layers[1]!.hidden).toBe(true);
});
it('drops cells that are not a pair of whole numbers, and doubled layers', () => {
const f = makeTilemap();
const asset = f.assetOf();
asset.layers[0]!.cells['nonsense'] = f.tileId;
asset.layers[0]!.cells['1.5,2'] = f.tileId;
asset.layers[0]!.cells['3,3'] = f.tileId;
asset.layers.push({ ...asset.layers[0]!, name: 'Twin' });
const back = findAsset(roundTrip(f.project), f.tilemapId) as TilemapAsset;
expect(back.layers).toHaveLength(1);
expect(back.layers[0]!.cells).toEqual({ '3,3': f.tileId });
});
});
describe('the mesh a layer becomes', () => {
const sheet = { quad: 24, block: { x: 0, y: 0, w: 96, h: 144 }, textureWidth: 96, textureHeight: 144 };
it('is one mesh per tile, not one per cell', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('tile', 'Sand');
f.stack.execute(other);
f.paint([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0 },
]);
f.paint([{ x: 4, y: 0 }], other.asset.id);
const asset = f.assetOf();
const groups = groupByTile(layerTiles(f.project, asset.props, asset.layers[0]!));
expect(groups.size).toBe(2);
expect(groups.get(f.tileId)).toHaveLength(3);
});
it('builds four quads per cell, at the cell it belongs to', () => {
const tiles = [
{
x: 1,
y: 0,
tileId: 'a',
quadrants: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
] as [
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
],
},
];
const { positions, uvs, indices } = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
expect(positions).toHaveLength(4 * 4 * 2);
expect(indices).toHaveLength(4 * 6);
// The cell starts at x = 48, and its top-left quarter is 24 wide.
expect([positions[0], positions[1]]).toEqual([48, 0]);
expect([positions[2], positions[3]]).toEqual([72, 0]);
// Quadrant (0, 0) of a 96 × 144 sheet cut into 24 px quarters is the first quarter of the uvs.
expect(uvs[0]).toBeCloseTo(0, 2);
expect(uvs[1]).toBeCloseTo(0, 2);
expect(uvs[2]).toBeCloseTo(24 / 96, 2);
expect(uvs[3]).toBeCloseTo(0, 2);
});
it('pulls the uvs half a texel in when the picture is sampled smoothly', () => {
const tiles = [
{
x: 0,
y: 0,
tileId: 'a',
quadrants: [
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
] as [
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
],
},
];
const hard = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
const soft = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: true });
expect(hard.uvs[0]).toBeCloseTo(0.1 / 96, 6);
expect(soft.uvs[0]).toBeCloseTo(0.5 / 96, 6);
// The positions are untouched: only what is sampled moves, never where it lands.
expect([...soft.positions]).toEqual([...hard.positions]);
});
it('keeps nearest uvs a sliver inside the quarter so odd zooms never pick the neighbour', () => {
const tiles = [
{
x: 0,
y: 0,
tileId: 'a',
quadrants: [
{ x: 1, y: 1 },
{ x: 1, y: 1 },
{ x: 1, y: 1 },
{ x: 1, y: 1 },
] as [
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
],
},
];
const { uvs } = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
// Quarter (1, 1) spans texels 24..48 on both axes. Every corner sits strictly inside it,
// but by far less than half a texel, so the texel picked at any pixel centre stays the same.
const us = [uvs[0]!, uvs[2]!, uvs[4]!, uvs[6]!].map((u) => u * 96);
const vs = [uvs[1]!, uvs[3]!, uvs[5]!, uvs[7]!].map((v) => v * 144);
for (const t of [...us, ...vs]) {
expect(t).toBeGreaterThan(24);
expect(t).toBeLessThan(48);
expect(Math.min(t - 24, 48 - t)).toBeLessThan(0.25);
}
});
});
describe('deleting a tile that is painted (#268)', () => {
/** Grass on two layers of one map and on a second map, with a second tile beside it. */
function painted() {
const f = makeTilemap();
const stone = new CreateAssetCommand('tile', 'Stone');
f.stack.execute(stone);
f.paint([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
]);
f.paint([{ x: 2, y: 0 }], stone.asset.id);
const upper = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(upper);
f.stack.execute(new PaintTilesCommand(f.tilemapId, upper.layerId, [{ x: 5, y: 5, tileId: f.tileId }]));
const other = new CreateAssetCommand('tilemap', 'Cave');
f.stack.execute(other);
const otherLayer = (findAsset(f.project, other.asset.id) as TilemapAsset).layers[0]!.id;
f.stack.execute(new PaintTilesCommand(other.asset.id, otherLayer, [{ x: 3, y: 3, tileId: f.tileId }]));
const allCells = () =>
f.project.assets.flatMap((a) => (a.type === 'tilemap' ? a.layers.map((l) => ({ ...l.cells })) : []));
return { ...f, stoneId: stone.asset.id, allCells };
}
it('counts the cells and the maps they are on', () => {
const f = painted();
expect(cellsPainted(f.project, f.tileId)).toEqual({ tilemaps: 2, cells: 4 });
expect(cellsPainted(f.project, f.stoneId)).toEqual({ tilemaps: 1, cells: 1 });
});
it('empties those cells in the same step and leaves every other tile where it was', () => {
const f = painted();
f.stack.execute(new DeleteAssetCommand(f.tileId));
expect(findAsset(f.project, f.tileId)).toBeUndefined();
expect(f.allCells()).toEqual([{ '2,0': f.stoneId }, {}, {}]);
expect(cellsPainted(f.project, f.tileId)).toEqual({ tilemaps: 0, cells: 0 });
});
it('an undo brings the tile and its cells back together, a redo takes both again', () => {
const f = painted();
const before = f.allCells();
f.stack.execute(new DeleteAssetCommand(f.tileId));
f.stack.undo();
expect(findAsset(f.project, f.tileId)?.type).toBe('tile');
expect(f.allCells()).toEqual(before);
const grass = layerTiles(f.project, f.assetOf().props, f.assetOf().layers[0]!).filter(
(tile) => tile.tileId === f.tileId,
);
expect(grass.map(({ x, y }) => `${x},${y}`)).toEqual(['0,0', '1,0']);
f.stack.redo();
expect(f.allCells()).toEqual([{ '2,0': f.stoneId }, {}, {}]);
});
it('deleting a tile nobody painted leaves the maps alone', () => {
const f = painted();
const lone = new CreateAssetCommand('tile', 'Lone');
f.stack.execute(lone);
const layers = f.project.assets.flatMap((a) => (a.type === 'tilemap' ? a.layers : []));
const cellObjects = layers.map((l) => l.cells);
f.stack.execute(new DeleteAssetCommand(lone.asset.id));
expect(layers.every((l, i) => l.cells === cellObjects[i])).toBe(true);
});
});