import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateTextureCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; import { AddTilePictureCommand, MoveTilePictureCommand, RemoveTilePictureCommand, } from '../../src/core/commands/tileCommands'; import { AddFrameCommand } from '../../src/core/commands/frameCommands'; import type { TileAsset } from '../../src/core/model/Asset'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { autotileCombinations, loneTileQuadrants, type Neighbours, neighboursAt, SAMPLE_PATTERN, tileQuadrants, } from '../../src/core/model/autotile'; import { pictureIndexAt, quadrantRect, readTile, TILE_KIND_KEY, type TileKind, tileKind, tilePlayback, } from '../../src/core/model/tile'; import { deserializeProject } from '../../src/core/store/serialize'; import { findAsset, type Project } from '../../src/core/model/Project'; import { tilesUsing } from '../../src/core/model/usages'; import { makeProject, roundTrip } from '../helpers'; const FILE = `${'a1b2c3d4'.repeat(8)}.png`; /** * A sheet the size of the RPG-Maker sheets #118 was written from: 48 px tiles, 8 × 4 blocks. The * tile is switched to a floor block, which is what most of these tests are about. */ function makeTile(width = 768, height = 576, kind: TileKind = 'floor') { const fixture = makeProject(); const texture = new CreateTextureCommand('Outside', { file: FILE, width, height }); fixture.stack.execute(texture); const tile = new CreateAssetCommand('tile', 'Grass'); fixture.stack.execute(tile); if (kind !== 'none') fixture.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, kind)); return { ...fixture, texture: texture.asset, tileId: tile.asset.id, assetOf: () => findAsset(fixture.project, tile.asset.id) as TileAsset, }; } /** Cuts one block out of the sheet and returns the frame id. */ function cutBlock( fixture: ReturnType, rect: { x: number; y: number; w: number; h: number }, ): string { const command = new AddFrameCommand(fixture.texture.id, rect); fixture.stack.execute(command); return command.frameId; } /** * `Tilemap.FLOOR_AUTOTILE_TABLE` and `Tilemap.WALL_AUTOTILE_TABLE` of RPG Maker MZ, generated from * its `js/rmmz_core.js` and not typed by hand. Each row is one tile as four quarters – top-left, * top-right, bottom-left, bottom-right – in quadrant units from the block's top-left corner. */ // prettier-ignore const MZ_FLOOR: readonly (readonly (readonly number[])[])[] = [ [[2, 4], [1, 4], [2, 3], [1, 3]], [[2, 0], [1, 4], [2, 3], [1, 3]], [[2, 4], [3, 0], [2, 3], [1, 3]], [[2, 0], [3, 0], [2, 3], [1, 3]], [[2, 4], [1, 4], [2, 3], [3, 1]], [[2, 0], [1, 4], [2, 3], [3, 1]], [[2, 4], [3, 0], [2, 3], [3, 1]], [[2, 0], [3, 0], [2, 3], [3, 1]], [[2, 4], [1, 4], [2, 1], [1, 3]], [[2, 0], [1, 4], [2, 1], [1, 3]], [[2, 4], [3, 0], [2, 1], [1, 3]], [[2, 0], [3, 0], [2, 1], [1, 3]], [[2, 4], [1, 4], [2, 1], [3, 1]], [[2, 0], [1, 4], [2, 1], [3, 1]], [[2, 4], [3, 0], [2, 1], [3, 1]], [[2, 0], [3, 0], [2, 1], [3, 1]], [[0, 4], [1, 4], [0, 3], [1, 3]], [[0, 4], [3, 0], [0, 3], [1, 3]], [[0, 4], [1, 4], [0, 3], [3, 1]], [[0, 4], [3, 0], [0, 3], [3, 1]], [[2, 2], [1, 2], [2, 3], [1, 3]], [[2, 2], [1, 2], [2, 3], [3, 1]], [[2, 2], [1, 2], [2, 1], [1, 3]], [[2, 2], [1, 2], [2, 1], [3, 1]], [[2, 4], [3, 4], [2, 3], [3, 3]], [[2, 4], [3, 4], [2, 1], [3, 3]], [[2, 0], [3, 4], [2, 3], [3, 3]], [[2, 0], [3, 4], [2, 1], [3, 3]], [[2, 4], [1, 4], [2, 5], [1, 5]], [[2, 0], [1, 4], [2, 5], [1, 5]], [[2, 4], [3, 0], [2, 5], [1, 5]], [[2, 0], [3, 0], [2, 5], [1, 5]], [[0, 4], [3, 4], [0, 3], [3, 3]], [[2, 2], [1, 2], [2, 5], [1, 5]], [[0, 2], [1, 2], [0, 3], [1, 3]], [[0, 2], [1, 2], [0, 3], [3, 1]], [[2, 2], [3, 2], [2, 3], [3, 3]], [[2, 2], [3, 2], [2, 1], [3, 3]], [[2, 4], [3, 4], [2, 5], [3, 5]], [[2, 0], [3, 4], [2, 5], [3, 5]], [[0, 4], [1, 4], [0, 5], [1, 5]], [[0, 4], [3, 0], [0, 5], [1, 5]], [[0, 2], [3, 2], [0, 3], [3, 3]], [[0, 2], [1, 2], [0, 5], [1, 5]], [[0, 4], [3, 4], [0, 5], [3, 5]], [[2, 2], [3, 2], [2, 5], [3, 5]], [[0, 2], [3, 2], [0, 5], [3, 5]], [[0, 0], [1, 0], [0, 1], [1, 1]], ]; // prettier-ignore const MZ_WALL: readonly (readonly (readonly number[])[])[] = [ [[2, 2], [1, 2], [2, 1], [1, 1]], [[0, 2], [1, 2], [0, 1], [1, 1]], [[2, 0], [1, 0], [2, 1], [1, 1]], [[0, 0], [1, 0], [0, 1], [1, 1]], [[2, 2], [3, 2], [2, 1], [3, 1]], [[0, 2], [3, 2], [0, 1], [3, 1]], [[2, 0], [3, 0], [2, 1], [3, 1]], [[0, 0], [3, 0], [0, 1], [3, 1]], [[2, 2], [1, 2], [2, 3], [1, 3]], [[0, 2], [1, 2], [0, 3], [1, 3]], [[2, 0], [1, 0], [2, 3], [1, 3]], [[0, 0], [1, 0], [0, 3], [1, 3]], [[2, 2], [3, 2], [2, 3], [3, 3]], [[0, 2], [3, 2], [0, 3], [3, 3]], [[2, 0], [3, 0], [2, 3], [3, 3]], [[0, 0], [3, 0], [0, 3], [3, 3]], ]; const FLOOR_BLOCK = { x: 96, y: 0, w: 96, h: 144 }; const WALL_BLOCK = { x: 0, y: 0, w: 96, h: 96 }; function around(present: Partial): Neighbours { return { n: false, e: false, s: false, w: false, ne: false, se: false, sw: false, nw: false, ...present }; } describe('autotile blocks', () => { it('is a library asset like a texture: nothing places it, the tilemap of #128 does', () => { expect(isPlaceableType('tile')).toBe(false); }); it('produces 47 tiles from a floor block and 16 from a wall, each of them different', () => { const floor = autotileCombinations('floor'); const wall = autotileCombinations('wall'); expect(floor).toHaveLength(47); expect(wall).toHaveLength(16); const signature = (quadrants: { x: number; y: number }[]) => quadrants.map((q) => `${q.x},${q.y}`).join(' '); expect(new Set(floor.map((c) => signature(c.quadrants))).size).toBe(47); expect(new Set(wall.map((c) => signature(c.quadrants))).size).toBe(16); }); /** * The whole point of #118: the tiles must come out of the block the way RPG Maker MZ takes them, * or a seam runs through every joint. `MZ_FLOOR` and `MZ_WALL` below are its tables verbatim, so * this test fails the moment the model drifts away from them. */ it('produces exactly the quarters of the RPG-Maker tables', () => { const signature = (quadrants: readonly (readonly number[])[]) => quadrants.map((q) => `${q[0]},${q[1]}`).join(' '); const mine = (kind: 'floor' | 'wall') => new Set(autotileCombinations(kind).map((c) => signature(c.quadrants.map((q) => [q.x, q.y])))); // MZ's floor table has one row more than there are neighbourhoods: its last one is the block's // top-left tile, which is the same picture as the lone tile of row 46. expect(mine('floor')).toEqual(new Set(MZ_FLOOR.slice(0, 47).map(signature))); expect(mine('wall')).toEqual(new Set(MZ_WALL.map(signature))); }); it('takes a lone tile from the four convex corners and a surrounded one from the inside', () => { // Row 46 of MZ's table. expect(tileQuadrants('floor', around({}))).toEqual([ { x: 0, y: 2 }, { x: 3, y: 2 }, { x: 0, y: 5 }, { x: 3, y: 5 }, ]); // Row 0, and the reason a joint is seamless: the inside is taken turned by half a tile, so the // top-left corner of the tile is the quarter at the bottom right of the block's inside. const all = { n: true, e: true, s: true, w: true, ne: true, se: true, sw: true, nw: true }; expect(tileQuadrants('floor', all)).toEqual([ { x: 2, y: 4 }, { x: 1, y: 4 }, { x: 2, y: 3 }, { x: 1, y: 3 }, ]); }); it('draws a straight edge towards the side that is missing', () => { // Only the north neighbour: the top half is inside, the bottom half is the lower border. const north = tileQuadrants('floor', around({ n: true })); expect(north[0]).toEqual({ x: 0, y: 4 }); expect(north[1]).toEqual({ x: 3, y: 4 }); expect(north[2]).toEqual({ x: 0, y: 5 }); expect(north[3]).toEqual({ x: 3, y: 5 }); }); it('puts a notch where both sides are there but the diagonal between them is not', () => { const withDiagonal = tileQuadrants('floor', around({ n: true, w: true, nw: true })); const without = tileQuadrants('floor', around({ n: true, w: true })); // Only the corner the diagonal belongs to changes, and it changes into the notch – which sits // in the block's top *right* tile, not the top left one. expect(withDiagonal[0]).toEqual({ x: 2, y: 4 }); expect(without[0]).toEqual({ x: 2, y: 0 }); expect(without.slice(1)).toEqual(withDiagonal.slice(1)); }); it('ignores the diagonals on a wall, which has no notch to draw', () => { const bare = tileQuadrants('wall', around({ n: true, w: true })); const diagonal = tileQuadrants('wall', around({ n: true, w: true, nw: true })); expect(bare).toEqual(diagonal); expect(bare[0]).toEqual({ x: 2, y: 2 }); // Every quarter a wall can use lies in its own 2 × 2 block: four quadrant rows, no more. for (const combination of autotileCombinations('wall')) for (const quadrant of combination.quadrants) expect(quadrant.y).toBeLessThan(4); }); it('labels a tile by the sides it joins and the corners it notches', () => { const floor = autotileCombinations('floor'); expect(floor[0]?.label).toBe('·'); const surrounded = floor.find( (c) => c.neighbours.n && c.neighbours.e && c.neighbours.s && c.neighbours.w, ); expect(surrounded?.label).toBe('NESW-ne,se,sw,nw'); }); it('reads the sample map so that the hole in it asks for notches', () => { // Row 2, column 4 sits right above the hole: joined to the north, open to the south. const above = neighboursAt(SAMPLE_PATTERN, 2, 4); expect(above.n).toBe(true); expect(above.s).toBe(false); expect(above.se).toBe(false); // Row 5, column 3 is the cell below the hole's bottom-left corner: joined north and east, // with the hole itself sitting in the north-east – exactly the case the notch is drawn for. const corner = neighboursAt(SAMPLE_PATTERN, 5, 3); expect([corner.n, corner.e, corner.ne]).toEqual([true, true, false]); expect(tileQuadrants('floor', corner)[1]).toEqual({ x: 3, y: 0 }); }); it('shows each kind in the palette as its lone tile, the four convex corners (#139)', () => { expect(loneTileQuadrants('floor')).toEqual([ { x: 0, y: 2 }, { x: 3, y: 2 }, { x: 0, y: 5 }, { x: 3, y: 5 }, ]); expect(loneTileQuadrants('wall')).toEqual([ { x: 0, y: 0 }, { x: 3, y: 0 }, { x: 0, y: 3 }, { x: 3, y: 3 }, ]); // A plain tile is its picture as it is, each corner its own quarter. expect(loneTileQuadrants('none')).toEqual([ { x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 1, y: 1 }, ]); }); }); /** The four quarters of the whole tile at tile column `tx`, row `ty`, written out as [x, y]. */ const whole = (tx: number, ty: number) => [ [2 * tx, 2 * ty], [2 * tx + 1, 2 * ty], [2 * tx, 2 * ty + 1], [2 * tx + 1, 2 * ty + 1], ]; const pairs = (quadrants: { x: number; y: number }[]) => quadrants.map((q) => [q.x, q.y]); const ALL_SIDES = { n: true, e: true, s: true, w: true }; const ALL_AROUND = { ...ALL_SIDES, ne: true, se: true, sw: true, nw: true }; describe('terrain block', () => { it('produces 47 different tiles, all inside the 6 × 4 block and none from its two empty tiles', () => { const terrain = autotileCombinations('terrain'); expect(terrain).toHaveLength(47); for (const { quadrants } of terrain) for (const q of quadrants) { expect(q.x).toBeLessThan(12); expect(q.y).toBeLessThan(8); expect(q.x >= 8 && q.y >= 6).toBe(false); } }); it('takes each of the 16 ways the sides join as a whole tile of the 4 × 4 on the left', () => { const drawn = new Set(); for (let mask = 0; mask < 16; mask++) { const sides = { n: !!(mask & 1), e: !!(mask & 2), s: !!(mask & 4), w: !!(mask & 8) }; const quadrants = pairs( tileQuadrants('terrain', around({ ...sides, ne: true, se: true, sw: true, nw: true })), ); const [x, y] = quadrants[0]!; expect(quadrants).toEqual(whole(x! / 2, y! / 2)); expect(x).toBeLessThan(8); drawn.add(`${x},${y}`); } expect(drawn.size).toBe(16); expect(pairs(tileQuadrants('terrain', around({})))).toEqual(whole(0, 3)); expect(pairs(tileQuadrants('terrain', around({ n: true, s: true })))).toEqual(whole(0, 1)); expect(pairs(tileQuadrants('terrain', around({ e: true, w: true })))).toEqual(whole(2, 3)); expect(pairs(tileQuadrants('terrain', around(ALL_AROUND)))).toEqual(whole(2, 1)); expect(pairs(loneTileQuadrants('terrain'))).toEqual(whole(0, 3)); }); it('takes a single notch and the two diagonal pairs whole from the columns on the right', () => { expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, se: false })))).toEqual(whole(4, 0)); expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, sw: false })))).toEqual(whole(5, 0)); expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, ne: false })))).toEqual(whole(4, 1)); expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, nw: false })))).toEqual(whole(5, 1)); expect(pairs(tileQuadrants('terrain', around({ ...ALL_SIDES, ne: true, sw: true })))).toEqual( whole(4, 2), ); expect(pairs(tileQuadrants('terrain', around({ ...ALL_SIDES, nw: true, se: true })))).toEqual( whole(5, 2), ); }); it('puts the notch into an edge tile and brings the quarter over or under it along', () => { // Joined north, east and south, open to the west and the north-east: the left edge tile, with // the top-right notch and, below it, the quarter of the same notch tile. const edge = pairs(tileQuadrants('terrain', around({ n: true, e: true, s: true, se: true }))); expect(edge).toEqual([ [2, 2], [9, 2], [2, 3], [9, 3], ]); // Two notches side by side, the south-east and the south-west: each brings its top quarter. const both = pairs(tileQuadrants('terrain', around({ ...ALL_SIDES, ne: true, nw: true }))); expect(both).toEqual([ [10, 0], [9, 0], [10, 1], [9, 1], ]); }); it('leaves an edge above a notch alone – only a plain inside quarter is brought along', () => { // Open to the north: the top edge tile keeps its top half, only the south-east notch goes in. const topEdge = pairs(tileQuadrants('terrain', around({ e: true, s: true, w: true, sw: true }))); expect(topEdge).toEqual([ [4, 0], [5, 0], [4, 1], [9, 1], ]); }); it('labels the notched corners like a floor does', () => { const labels = autotileCombinations('terrain').map((c) => c.label); expect(labels[0]).toBe('·'); expect(labels).toContain('NESW-ne,se,sw,nw'); expect(labels).toContain('NES-ne'); }); it('reads a 6 × 4 block as a terrain tile and says so when the block is the wrong shape', () => { const fixture = makeTile(96, 192, 'terrain'); fixture.stack.execute( new AddTilePictureCommand(fixture.tileId, cutBlock(fixture, { x: 0, y: 0, w: 96, h: 64 })), ); const info = readTile(fixture.project, fixture.assetOf()); expect(info.problem).toBe(''); expect(info.tile).toBe(16); const wrong = makeTile(96, 192, 'terrain'); wrong.stack.execute(new AddTilePictureCommand(wrong.tileId, wrong.texture.id)); expect(readTile(wrong.project, wrong.assetOf()).problem).toContain('terrain block is 6 × 4 tiles'); }); }); describe('tile asset', () => { it('derives the tile size from the block and accepts a whole texture as one', () => { const fixture = makeTile(96, 144); fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id)); const info = readTile(fixture.project, fixture.assetOf()); expect(info.problem).toBe(''); expect(info.tile).toBe(48); expect(info.quad).toBe(24); expect(info.pictures).toHaveLength(1); }); it('cuts a quarter tile out of the frame the picture names', () => { const fixture = makeTile(); const frame = cutBlock(fixture, FLOOR_BLOCK); fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame)); const info = readTile(fixture.project, fixture.assetOf()); expect(info.tile).toBe(48); // The block starts at x = 96, so its third quadrant column starts 48 px further right. expect(quadrantRect(info.pictures[0]!, info.quad, { x: 2, y: 5 })).toEqual({ x: 96 + 48, y: 120, w: 24, h: 24, }); }); it('complains about a block that does not fit the kind, without dropping it', () => { const fixture = makeTile(); const wall = cutBlock(fixture, WALL_BLOCK); fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, wall)); const asFloor = readTile(fixture.project, fixture.assetOf()); expect(asFloor.problem).toContain('floor block'); expect(asFloor.tile).toBe(0); expect(fixture.assetOf().pictures).toEqual([wall]); // The same picture is a wall block, so switching the kind is all it takes. fixture.stack.execute(new SetAssetPropertyCommand(fixture.tileId, TILE_KIND_KEY, 'wall')); const asWall = readTile(fixture.project, fixture.assetOf()); expect(asWall.problem).toBe(''); expect(asWall.tile).toBe(48); }); it('rejects an animation whose steps are not the same size, and a picture that is gone', () => { const fixture = makeTile(); const big = cutBlock(fixture, FLOOR_BLOCK); const small = cutBlock(fixture, { x: 0, y: 0, w: 32, h: 48 }); fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, big)); fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, small)); expect(readTile(fixture.project, fixture.assetOf()).problem).toContain('different sizes'); // A dangling id is named as missing rather than silently skipped. fixture.assetOf().pictures = ['gone']; expect(readTile(fixture.project, fixture.assetOf()).problem).toContain('no longer exist'); fixture.assetOf().pictures = []; expect(readTile(fixture.project, fixture.assetOf()).problem).toContain('No picture yet'); }); it('steps through the pictures, loop and ping-pong, and holds still on a single one', () => { // Three pictures at 4 fps: a quarter second each. const loop = [0, 0.26, 0.51, 0.76, 1.01].map((t) => pictureIndexAt(3, 'loop', 4, t)); expect(loop).toEqual([0, 1, 2, 0, 1]); const pingpong = [0, 0.26, 0.51, 0.76, 1.01, 1.26].map((t) => pictureIndexAt(3, 'pingpong', 4, t)); // Turns on the last picture, so the middle one is never shown twice in a row. expect(pingpong).toEqual([0, 1, 2, 1, 0, 1]); expect(pictureIndexAt(1, 'loop', 4, 99)).toBe(0); expect(pictureIndexAt(3, 'loop', 0, 99)).toBe(0); }); }); describe('tile pictures', () => { it('adds, removes and reorders with undo', () => { const fixture = makeTile(); const a = cutBlock(fixture, FLOOR_BLOCK); const b = cutBlock(fixture, { ...FLOOR_BLOCK, x: 192 }); const c = cutBlock(fixture, { ...FLOOR_BLOCK, x: 288 }); for (const id of [a, b, c]) fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, id)); expect(fixture.assetOf().pictures).toEqual([a, b, c]); fixture.stack.execute(new MoveTilePictureCommand(fixture.tileId, 2, 0)); expect(fixture.assetOf().pictures).toEqual([c, a, b]); fixture.stack.undo(); expect(fixture.assetOf().pictures).toEqual([a, b, c]); fixture.stack.execute(new RemoveTilePictureCommand(fixture.tileId, 1)); expect(fixture.assetOf().pictures).toEqual([a, c]); fixture.stack.undo(); expect(fixture.assetOf().pictures).toEqual([a, b, c]); // Inserting somewhere other than the end, and taking it out again. fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, c, 1)); expect(fixture.assetOf().pictures).toEqual([a, c, b, c]); fixture.stack.undo(); expect(fixture.assetOf().pictures).toEqual([a, b, c]); }); it('takes only textures and frames, and only places that exist', () => { const fixture = makeTile(); const sprite = new CreateAssetCommand('sprite', 'Box'); fixture.stack.execute(sprite); expect(() => new AddTilePictureCommand(fixture.tileId, sprite.asset.id).execute(fixture.project)).toThrow( /not a texture or a frame/, ); expect(() => new RemoveTilePictureCommand(fixture.tileId, 0).execute(fixture.project)).toThrow( /no picture 1/, ); fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id)); expect(() => new MoveTilePictureCommand(fixture.tileId, 0, 3).execute(fixture.project)).toThrow( /no place 4/, ); }); it('counts as a use of the texture, so deleting it is not silent', () => { const fixture = makeTile(); const frame = cutBlock(fixture, FLOOR_BLOCK); fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame)); expect(tilesUsing(fixture.project, [frame]).map((a) => a.name)).toEqual(['Grass']); expect(tilesUsing(fixture.project, ['somewhere else'])).toEqual([]); }); }); describe('tile in the document', () => { it('survives a save and keeps only what it can read', () => { const fixture = makeTile(); const frame = cutBlock(fixture, FLOOR_BLOCK); fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame)); fixture.stack.execute(new SetAssetPropertyCommand(fixture.tileId, TILE_KIND_KEY, 'wall')); const asset = tileOf(roundTrip(fixture.project)); expect(asset.pictures).toEqual([frame]); expect(tileKind(asset)).toBe('wall'); // Junk in the list is dropped, and an unreadable kind or playback falls back to the default – // for the kind that is the plain tile. const raw = JSON.parse(JSON.stringify(fixture.project)) as { assets: Record[]; }; const stored = raw.assets.find((a) => a.type === 'tile')!; stored.pictures = [frame, 7, '', null, 'gone']; (stored.props as Record).kind = 'diagonal'; (stored.props as Record).playback = 'once'; const read = tileOf(roundTrip(raw as unknown as Project)); expect(read.pictures).toEqual([frame, 'gone']); expect(tileKind(read)).toBe('none'); expect(tilePlayback(read)).toBe('loop'); }); // No migration any more (#137): `autotile`, the name before #131, is an unknown type like any other. it('drops an asset of the old type autotile', () => { const raw = { id: 'p', assets: [ { id: 's', type: 'scene', name: 'Main', props: {}, objects: [] }, { id: 'a', type: 'autotile', name: 'Old grass', props: { kind: 'floor' }, pictures: ['x'] }, { id: 'c', type: 'tile', name: 'New', props: {}, pictures: [] }, ], }; expect(deserializeProject(raw).assets.map((a) => a.id)).toEqual(['s', 'c']); }); }); describe('plain tile', () => { it('is what a new tile is', () => { const fixture = makeTile(96, 96, 'none'); expect(tileKind(fixture.assetOf())).toBe('none'); }); it('is one tile that ignores its neighbours', () => { expect(autotileCombinations('none')).toHaveLength(1); const lone = tileQuadrants('none', around({})); const all = { n: true, e: true, s: true, w: true, ne: true, se: true, sw: true, nw: true }; // Every corner takes its own quarter of the picture – the picture drawn as it is. expect(lone).toEqual([ { x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 1, y: 1 }, ]); expect(tileQuadrants('none', all)).toEqual(lone); expect(tileQuadrants('none', around({ n: true, w: true }))).toEqual(lone); }); it('takes any square picture as the tile, an odd side too', () => { const fixture = makeTile(768, 576, 'none'); const square = cutBlock(fixture, { x: 48, y: 0, w: 48, h: 48 }); fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, square)); const info = readTile(fixture.project, fixture.assetOf()); expect(info.problem).toBe(''); expect(info.tile).toBe(48); expect(quadrantRect(info.pictures[0]!, info.quad, { x: 1, y: 1 })).toEqual({ x: 72, y: 24, w: 24, h: 24, }); const odd = makeTile(25, 25, 'none'); odd.stack.execute(new AddTilePictureCommand(odd.tileId, odd.texture.id)); expect(readTile(odd.project, odd.assetOf()).tile).toBe(25); }); it('complains about a picture that is not square', () => { const fixture = makeTile(96, 144, 'none'); fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id)); const info = readTile(fixture.project, fixture.assetOf()); expect(info.problem).toContain('one square tile'); expect(info.problem).toContain('96 × 144'); expect(info.tile).toBe(0); }); }); function tileOf(project: Project): TileAsset { const asset = project.assets.find((a): a is TileAsset => a.type === 'tile'); if (!asset) throw new Error('no tile in the project'); return asset; }