Files
feuerwerk/tests/core/tilemap-resize.test.ts
T

194 lines
7.9 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.
/**
* 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']);
});
});