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>
622 lines
23 KiB
TypeScript
622 lines
23 KiB
TypeScript
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);
|
||
});
|
||
});
|