Files
feuerwerk/tests/core/tilemap.test.ts
T
adminandClaude Opus 5.5 9b5eeaab57 Empty a deleted tile's cells on every tilemap (#268)
Deleting a tile left the cells painted with it in the tilemaps. They
drew nothing, blocked nothing and did not show in the delete dialog,
but still read as a tile for "Tile at" and stopped a fill.

- DeleteAssetCommand empties the cells of a deleted tile on every
  layer of every tilemap in the same step. Undo puts the tile and its
  cells back together.
- The delete dialog counts them, for example "3 cell(s) on 1
  tilemap(s) are painted with it and become empty." (cellsPainted).
- presentTile treats a cell naming a missing tile as empty. "Tile at"
  (World.tileAt) and floodFill use it, so cells already in existing
  files read and fill as empty. There is no migration.
- The brush, the rectangle and the fill check the tile in hand. A
  tile deleted after it was picked no longer paints new invisible
  cells, the editor says "Choose a tile to paint with." instead.
- Unchanged on purpose: the picker still reports such a cell as
  naming a tile that no longer exists, and erasing removes it.

Tests:
- ./check.sh: 195 files, 2823 tests, CHECK EXIT 0 (+6).
- GM_STEPS=39,40: ALL PASSED, 100 checks (4 new in 40t).
- Full e2e sum is now 1590 (1586 confirmed by CI on round 8, plus 4).
- Counter-checks, all fail as expected: 5 in unit tests (delete
  without emptying, undo without cells, fill and "Tile at" without
  presentTile, wrong map count) and 3 in the browser (dialog without
  count, delete without emptying, brush without the check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:00:22 +02:00

622 lines
23 KiB
TypeScript
Raw 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,
layerTiles,
neighboursOf,
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 { 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 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);
});
});