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