import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateTextureCommand, 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, tilemapTileIds, tilemapSize, } from '../../src/core/model/tilemap'; import { tilemapsUsing } from '../../src/core/model/usages'; import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap'; 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 { 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.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 }); }); it('counts as a use of the tiles painted on it', () => { const f = makeTilemap(); expect(tilemapsUsing(f.project, [f.tileId])).toHaveLength(0); f.paint([{ x: 0, y: 0 }]); expect(tilemapsUsing(f.project, [f.tileId]).map((a) => a.id)).toEqual([f.tilemapId]); expect(tilemapTileIds(f.assetOf())).toEqual([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); } }); });