/** * Growing and shrinking a placed tilemap at any edge (#136): at the left and top edge the cells of * every layer move along, and the placement's origin moves so that what was painted stays where it * is in the scene – turned, scaled and mirrored maps included. One click is one undo step, and * removing an edge throws no cell away. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddTilemapLayerCommand, PaintTilesCommand, ResizeTilemapEdgeCommand, } from '../../src/core/commands/tilemapCommands'; import type { TilemapAsset } from '../../src/core/model/Asset'; import { findObject } from '../../src/core/model/hierarchy'; import { apply } from '../../src/core/model/matrix'; import { findAsset } from '../../src/core/model/Project'; import { cellOrigin, layerTiles, shiftedCells, TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, type TilemapEdge, } from '../../src/core/model/tilemap'; import { resolveSceneObjectProps } from '../../src/core/properties/resolve'; import { worldTransform } from '../../src/core/properties/transform'; import { makeSpriteScene, place } from '../helpers'; /** A 4 × 3 map of 10 px cells with two layers, placed at (100, 50). */ function setup() { const f = makeSpriteScene(); const grass = new CreateAssetCommand('tile', 'Grass'); const map = new CreateAssetCommand('tilemap', 'Ground'); f.stack.execute(grass); f.stack.execute(map); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 4)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 3)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, 'tileSize', 10)); const layer2 = new AddTilemapLayerCommand(map.asset.id); f.stack.execute(layer2); const mapObject = place(f, map.asset.id, { x: 100, y: 50 }); const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset; const paint = (layerId: string, ...at: [number, number][]) => f.stack.execute( new PaintTilesCommand( map.asset.id, layerId, at.map(([x, y]) => ({ x, y, tileId: grass.asset.id })), ), ); const [first, second] = assetOf().layers.map((layer) => layer.id) as [string, string]; paint(first, [0, 0], [3, 2]); paint(second, [1, 1]); const object = (id = mapObject) => findObject(f.viewOf(), id)!; const props = (id = mapObject) => resolveSceneObjectProps(f.project, assetOf(), object(id)); /** Where a cell's top-left corner is in the world. */ const corner = (x: number, y: number, id = mapObject) => apply(worldTransform(f.project, f.viewOf(), object(id)).m, cellOrigin(props(id), x, y)); const resize = (edge: TilemapEdge, count: number, id = mapObject) => f.stack.execute(new ResizeTilemapEdgeCommand(f.sceneId, id, edge, count)); const snapshot = () => structuredClone({ asset: assetOf(), object: object() }); return { ...f, mapId: map.asset.id, mapObject, grassId: grass.asset.id, assetOf, object, props, corner, resize, snapshot, }; } /** The cells as a layer holds them, per layer. */ function cellKeys(asset: TilemapAsset): string[][] { return asset.layers.map((layer) => Object.keys(layer.cells).sort()); } function expectNear(a: { x: number; y: number }, b: { x: number; y: number }): void { expect(a.x).toBeCloseTo(b.x, 9); expect(a.y).toBeCloseTo(b.y, 9); } describe('shifted cells', () => { it('moves every cell by whole cells, below zero included', () => { expect(shiftedCells({ '0,0': 'a', '3,1': 'b' }, -1, 2)).toEqual({ '-1,2': 'a', '2,3': 'b' }); expect(shiftedCells({}, 5, 5)).toEqual({}); }); }); describe('resizing a placed tilemap at an edge', () => { it('adds a column at the left: every layer moves along, and the cells stay put in the scene', () => { const s = setup(); const before = [s.corner(0, 0), s.corner(3, 2), s.corner(1, 1)]; s.resize('left', 2); expect(cellKeys(s.assetOf())).toEqual([['2,0', '5,2'], ['3,1']]); expect(s.object().overrides[TILEMAP_COLUMNS_KEY]).toBe(6); const after = [s.corner(2, 0), s.corner(5, 2), s.corner(3, 1)]; after.forEach((at, i) => expectNear(at, before[i]!)); expect(s.object().position).toEqual({ x: 90, y: 50, z: 0 }); }); it('adds rows at the top the same way', () => { const s = setup(); const before = s.corner(1, 1); s.resize('top', 1); expect(cellKeys(s.assetOf())).toEqual([['0,1', '3,3'], ['1,2']]); expect(s.object().overrides[TILEMAP_ROWS_KEY]).toBe(4); expectNear(s.corner(1, 2), before); }); it('adds at the right and bottom without moving a cell, and still holds the content', () => { const s = setup(); const before = s.corner(3, 2); s.resize('right', 3); s.resize('bottom', 2); expect(cellKeys(s.assetOf())).toEqual([['0,0', '3,2'], ['1,1']]); expect(s.props()).toMatchObject({ [TILEMAP_COLUMNS_KEY]: 7, [TILEMAP_ROWS_KEY]: 5 }); expectNear(s.corner(3, 2), before); }); it('holds still on a turned, scaled and mirrored map inside a turned parent', () => { const s = setup(); const parent = place(s, s.spriteId, { x: -30, y: 20 }); s.stack.execute(new SetOverrideCommand(s.sceneId, parent, 'rotation', 20)); s.stack.execute(new SetParentCommand(s.sceneId, s.mapObject, parent)); s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'rotation', 37)); s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'scale', 2.5)); s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'mirrorX', true)); // The painted cell (1,1), followed through all four edges. const at = { x: 1, y: 1 }; for (const [edge, count] of [ ['left', 3], ['top', -1], ['right', -2], ['bottom', 4], ] as const) { const before = s.corner(at.x, at.y); s.resize(edge, count); if (edge === 'left') at.x += count; if (edge === 'top') at.y += count; expectNear(s.corner(at.x, at.y), before); } expect(s.assetOf().layers[1]!.cells).toEqual({ [`${at.x},${at.y}`]: s.grassId }); }); it('removing a painted edge keeps its cells outside the grid, and adding it back returns them', () => { const s = setup(); const start = s.snapshot(); s.resize('left', -1); expect(cellKeys(s.assetOf())).toEqual([['-1,0', '2,2'], ['0,1']]); const drawn = layerTiles(s.project, s.props(), s.assetOf().layers[0]!).map((t) => `${t.x},${t.y}`); expect(drawn).toEqual(['2,2']); s.resize('left', 1); expect(cellKeys(s.assetOf())).toEqual(start.asset.layers.map((l) => Object.keys(l.cells).sort())); expect(s.object().position).toEqual(start.object.position); }); it('is one undo step that restores cells, size and position exactly, and redoes the same', () => { const s = setup(); s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'rotation', 33)); const start = s.snapshot(); s.resize('left', 2); const done = s.snapshot(); expect(s.stack.undo()).toBe(true); expect(s.snapshot()).toEqual(start); expect(s.stack.redo()).toBe(true); expect(s.snapshot()).toEqual(done); }); it('refuses to go below one column or above the limit, and changes nothing then', () => { const s = setup(); const start = s.snapshot(); expect(() => s.resize('left', -4)).toThrow(/at least one/); expect(() => s.resize('bottom', TILEMAP_MAX_CELLS)).toThrow(/at most/); expect(s.snapshot()).toEqual(start); }); it('moves the cells in another placement too, whose position it leaves alone', () => { const s = setup(); const other = place(s, s.mapId, { x: -200, y: 0 }); const before = s.object(other).position; s.resize('left', 1); expect(s.object(other).position).toEqual(before); expect(s.object(other).overrides[TILEMAP_COLUMNS_KEY]).toBeUndefined(); expect(cellKeys(s.assetOf())[0]).toEqual(['1,0', '4,2']); }); });