Deleting a tile left the cells painted with it in the tilemaps. They drew nothing, blocked nothing and did not show in the delete dialog, but still read as a tile for "Tile at" and stopped a fill. - DeleteAssetCommand empties the cells of a deleted tile on every layer of every tilemap in the same step. Undo puts the tile and its cells back together. - The delete dialog counts them, for example "3 cell(s) on 1 tilemap(s) are painted with it and become empty." (cellsPainted). - presentTile treats a cell naming a missing tile as empty. "Tile at" (World.tileAt) and floodFill use it, so cells already in existing files read and fill as empty. There is no migration. - The brush, the rectangle and the fill check the tile in hand. A tile deleted after it was picked no longer paints new invisible cells, the editor says "Choose a tile to paint with." instead. - Unchanged on purpose: the picker still reports such a cell as naming a tile that no longer exists, and erasing removes it. Tests: - ./check.sh: 195 files, 2823 tests, CHECK EXIT 0 (+6). - GM_STEPS=39,40: ALL PASSED, 100 checks (4 new in 40t). - Full e2e sum is now 1590 (1586 confirmed by CI on round 8, plus 4). - Counter-checks, all fail as expected: 5 in unit tests (delete without emptying, undo without cells, fill and "Tile at" without presentTile, wrong map count) and 3 in the browser (dialog without count, delete without emptying, brush without the check). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
12 KiB
TypeScript
251 lines
12 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest';
|
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
|
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
|
import {
|
|
AddTilemapLayerCommand,
|
|
PaintTilesCommand,
|
|
RenameTilemapLayerCommand,
|
|
} from '../../src/core/commands/tilemapCommands';
|
|
import type { TilemapAsset } from '../../src/core/model/Asset';
|
|
import {
|
|
cellToWorld,
|
|
layerNamed,
|
|
liveLayers,
|
|
liveTileAt,
|
|
withTile,
|
|
worldToCell,
|
|
} from '../../src/core/model/liveTilemap';
|
|
import { transformMatrix, translation } from '../../src/core/model/matrix';
|
|
import { findAsset } from '../../src/core/model/Project';
|
|
import { TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
|
|
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, tilemapLayerNames } from '../../src/core/model/tilemap';
|
|
import { loadPhysics } from '../../src/runtime/physics/engine';
|
|
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
|
|
import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers';
|
|
|
|
const layer = (id: string, name: string, cells: Record<string, string> = {}) => ({ id, name, cells });
|
|
const asset = (): TilemapAsset => ({
|
|
id: 'map',
|
|
type: 'tilemap',
|
|
name: 'Map',
|
|
props: {},
|
|
layers: [layer('l1', 'Ground', { '0,0': 'grass', '1,0': 'grass' }), layer('l2', 'Top'), layer('l3', 'Top')],
|
|
});
|
|
const GRID = { [TILEMAP_COLUMNS_KEY]: 4, [TILEMAP_ROWS_KEY]: 2, tileSize: 10 };
|
|
|
|
describe('the cells a run sees (#135)', () => {
|
|
it('a layer is found by its name, no name is the first, two of one name are the upper one', () => {
|
|
const map = asset();
|
|
expect(layerNamed(map, 'Ground')?.id).toBe('l1');
|
|
expect(layerNamed(map, '')?.id).toBe('l1');
|
|
expect(layerNamed(map, 'Top')?.id).toBe('l2');
|
|
expect(layerNamed(map, 'ground')).toBeUndefined();
|
|
expect(layerNamed({ ...map, layers: [] }, '')).toBeUndefined();
|
|
});
|
|
|
|
it('edits lie over the cells, an empty one erasing, and leave the asset alone', () => {
|
|
const map = asset();
|
|
let edits = withTile(map, undefined, GRID, 'Ground', { x: 2, y: 1 }, 'water')!;
|
|
edits = withTile(map, edits, GRID, '', { x: 0, y: 0 }, '')!;
|
|
expect(edits).toEqual({ l1: { '2,1': 'water', '0,0': '' } });
|
|
expect(liveLayers(map, edits)[0]!.cells).toEqual({ '1,0': 'grass', '2,1': 'water' });
|
|
// The other layers are the asset's own, untouched.
|
|
expect(liveLayers(map, edits)[1]).toBe(map.layers[1]);
|
|
expect(map.layers[0]!.cells).toEqual({ '0,0': 'grass', '1,0': 'grass' });
|
|
expect(liveTileAt(map, edits, GRID, 'Ground', { x: 2, y: 1 })).toBe('water');
|
|
expect(liveTileAt(map, edits, GRID, 'Ground', { x: 0, y: 0 })).toBe('');
|
|
expect(liveTileAt(map, edits, GRID, 'Ground', { x: 1, y: 0 })).toBe('grass');
|
|
});
|
|
|
|
it('a cell off the grid or on a missing layer cannot be read or set; setting what is there changes nothing', () => {
|
|
const map = asset();
|
|
for (const cell of [
|
|
{ x: 4, y: 0 },
|
|
{ x: 0, y: 2 },
|
|
{ x: -1, y: 0 },
|
|
]) {
|
|
expect(liveTileAt(map, undefined, GRID, '', cell)).toBeNull();
|
|
expect(withTile(map, undefined, GRID, '', cell, 'water')).toBeNull();
|
|
}
|
|
expect(withTile(map, undefined, GRID, 'Sky', { x: 0, y: 0 }, 'water')).toBeNull();
|
|
const edits = withTile(map, undefined, GRID, '', { x: 3, y: 1 }, 'water')!;
|
|
expect(withTile(map, edits, GRID, '', { x: 3, y: 1 }, 'water')).toBe(edits);
|
|
});
|
|
|
|
it('offers the layer names of every tilemap, each once', () => {
|
|
const f = makeSpriteScene('Hero');
|
|
const a = new CreateAssetCommand('tilemap', 'A');
|
|
const b = new CreateAssetCommand('tilemap', 'B');
|
|
f.stack.execute(a);
|
|
f.stack.execute(b);
|
|
const add = new AddTilemapLayerCommand(b.asset.id);
|
|
f.stack.execute(add);
|
|
f.stack.execute(new RenameTilemapLayerCommand(b.asset.id, add.layerId, 'Walls'));
|
|
expect(tilemapLayerNames(f.project)).toEqual(['Layer 1', 'Walls']);
|
|
});
|
|
});
|
|
|
|
describe('world and cell (#135)', () => {
|
|
it('a map moved by (100, 50): cells from the top-left corner, the middle of each cell back', () => {
|
|
const m = translation(100, 50);
|
|
// 4 x 2 cells of 10 around (100, 50): the grid runs from (80, 40) to (120, 60).
|
|
expect(worldToCell(GRID, m, { x: 80, y: 40 })).toEqual({ x: 0, y: 0, inside: true });
|
|
expect(worldToCell(GRID, m, { x: 119.9, y: 59.9 })).toEqual({ x: 3, y: 1, inside: true });
|
|
expect(worldToCell(GRID, m, { x: 120, y: 50 })).toEqual({ x: 4, y: 1, inside: false });
|
|
expect(worldToCell(GRID, m, { x: 79, y: 39 })).toEqual({ x: -1, y: -1, inside: false });
|
|
expect(cellToWorld(GRID, m, { x: 0, y: 0 })).toEqual({ x: 85, y: 45 });
|
|
expect(cellToWorld(GRID, m, { x: 3, y: 1 })).toEqual({ x: 115, y: 55 });
|
|
});
|
|
|
|
it('a turned, scaled and mirrored map: every cell middle comes back to its cell', () => {
|
|
for (const m of [
|
|
transformMatrix(30, -20, 90, 2, false, false),
|
|
transformMatrix(-7, 3, 33, 0.5, true, false),
|
|
transformMatrix(0, 0, 180, 3, false, true),
|
|
])
|
|
for (let y = 0; y < 2; y++)
|
|
for (let x = 0; x < 4; x++)
|
|
expect(worldToCell(GRID, m, cellToWorld(GRID, m, { x, y }))).toEqual({ x, y, inside: true });
|
|
});
|
|
|
|
it('turned a quarter clockwise and doubled, the top-left cell lies to the right of the origin, at the top', () => {
|
|
const m = transformMatrix(0, 0, 90, 2, false, false);
|
|
// Its middle (-15, -5) in the map's frame turns to (5, -15) and doubles to (10, -30).
|
|
const middle = cellToWorld(GRID, m, { x: 0, y: 0 });
|
|
expect(middle.x).toBeCloseTo(10);
|
|
expect(middle.y).toBeCloseTo(-30);
|
|
expect(worldToCell(GRID, m, { x: 10, y: -30 })).toEqual({ x: 0, y: 0, inside: true });
|
|
// Scaled to nothing, there are no cells to fall into.
|
|
expect(worldToCell(GRID, transformMatrix(0, 0, 0, 0, false, false), { x: 0, y: 0 }).inside).toBe(false);
|
|
});
|
|
});
|
|
|
|
/** A 10 x 10 map of cells of 48 around the origin, placed twice, and a hero with a collider on it. */
|
|
function setup() {
|
|
const f = makeSpriteScene('Hero');
|
|
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 40));
|
|
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 40));
|
|
const wall = new CreateAssetCommand('tile', 'Wall');
|
|
const map = new CreateAssetCommand('tilemap', 'Ground');
|
|
for (const command of [wall, map]) f.stack.execute(command);
|
|
f.stack.execute(new SetAssetPropertyCommand(wall.asset.id, TILE_SOLID_KEY, true));
|
|
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
|
|
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
|
|
const first = place(f, map.asset.id, { x: 0, y: 0 });
|
|
const second = place(f, map.asset.id, { x: 2000, y: 0 });
|
|
const hero = place(f, f.spriteId, { x: 0, y: 0 });
|
|
f.stack.execute(new AddColliderCommand(f.sceneId, hero));
|
|
const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset;
|
|
return { ...f, wallId: wall.asset.id, mapId: map.asset.id, first, second, hero, assetOf };
|
|
}
|
|
|
|
/** Cell column 6: its left face lies at x = 48, 28 to the right of the hero's edge. */
|
|
const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => ({ x: 6, y }));
|
|
|
|
describe.each(COLLISION_SYSTEMS)('$system: a tile set in a run (#135)', ({ physics }) => {
|
|
beforeAll(async () => {
|
|
await loadPhysics();
|
|
});
|
|
|
|
it('blocks at once, only on its own placement, and never reaches the asset', () => {
|
|
const s = setup();
|
|
const { world, collisions } = startWorld(s, { physics, collisions: true });
|
|
const map = world.findByObjectId(s.first)!;
|
|
const other = world.findByObjectId(s.second)!;
|
|
const hero = world.findByObjectId(s.hero)!;
|
|
for (const cell of COLUMN_SIX) expect(world.setTile(map, '', cell, s.wallId)).toBe(true);
|
|
// No tick in between: the move already meets the new wall.
|
|
expect(collisions.moveAndSlide(world, hero, 97.3, 0).blockedX).toBe(true);
|
|
expect(hero.x).toBeCloseTo(28, 9);
|
|
expect(world.tileAt(map, 'Layer 1', { x: 6, y: 3 })).toBe(s.wallId);
|
|
expect(world.tileAt(other, 'Layer 1', { x: 6, y: 3 })).toBe('');
|
|
expect(s.assetOf().layers[0]!.cells).toEqual({});
|
|
// Emptied again, the way is free.
|
|
for (const cell of COLUMN_SIX) world.setTile(map, '', cell, '');
|
|
expect(collisions.moveAndSlide(world, hero, 97.3, 0).blockedX).toBe(false);
|
|
});
|
|
|
|
it('refuses what it cannot set: no tile, no tilemap, no such layer, off the grid', () => {
|
|
const s = setup();
|
|
const { world } = startWorld(s, { physics, collisions: true });
|
|
const map = world.findByObjectId(s.first)!;
|
|
const hero = world.findByObjectId(s.hero)!;
|
|
expect(world.setTile(map, '', { x: 0, y: 0 }, s.spriteId)).toBe(false);
|
|
expect(world.setTile(hero, '', { x: 0, y: 0 }, s.wallId)).toBe(false);
|
|
expect(world.setTile(map, 'Sky', { x: 0, y: 0 }, s.wallId)).toBe(false);
|
|
expect(world.setTile(map, '', { x: 10, y: 0 }, s.wallId)).toBe(false);
|
|
expect(world.tileAt(hero, '', { x: 0, y: 0 })).toBeNull();
|
|
expect(world.isTilemap(map) && !world.isTilemap(hero)).toBe(true);
|
|
expect(world.takeTileChange()).toBe(false);
|
|
world.setTile(map, '', { x: 0, y: 0 }, s.wallId);
|
|
expect(world.takeTileChange()).toBe(true);
|
|
expect(world.takeTileChange()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('a cell naming a tile the project does not have (#268)', () => {
|
|
it('reads as empty in a run, the way it is drawn', () => {
|
|
const s = setup();
|
|
const layerId = s.assetOf().layers[0]!.id;
|
|
s.stack.execute(
|
|
new PaintTilesCommand(s.mapId, layerId, [
|
|
{ x: 1, y: 1, tileId: 'gone' },
|
|
{ x: 2, y: 1, tileId: s.wallId },
|
|
]),
|
|
);
|
|
const { world } = startWorld(s);
|
|
const map = world.findByObjectId(s.first)!;
|
|
expect(world.tileAt(map, '', { x: 1, y: 1 })).toBe('');
|
|
expect(world.tileAt(map, '', { x: 2, y: 1 })).toBe(s.wallId);
|
|
});
|
|
});
|
|
|
|
describe('a tile set in a run is drawn (#135)', () => {
|
|
const cellsDrawn = (tilemap: { layers: { tiles: readonly { x: number; y: number }[] }[] } | null) =>
|
|
tilemap?.layers[0]!.tiles.map(({ x, y }) => `${x},${y}`);
|
|
|
|
it('in the played world, on its own placement only', () => {
|
|
const s = setup();
|
|
s.stack.execute(
|
|
new PaintTilesCommand(s.mapId, s.assetOf().layers[0]!.id, [{ x: 1, y: 1, tileId: s.wallId }]),
|
|
);
|
|
const { world } = startWorld(s);
|
|
world.setTile(world.findByObjectId(s.first)!, '', { x: 2, y: 1 }, s.wallId);
|
|
world.setTile(world.findByObjectId(s.first)!, '', { x: 1, y: 1 }, '');
|
|
const items = collectWorldItems(world).items;
|
|
expect(cellsDrawn(items.find((i) => i.id === s.first)!.tilemap)).toEqual(['2,1']);
|
|
expect(cellsDrawn(items.find((i) => i.id === s.second)!.tilemap)).toEqual(['1,1']);
|
|
});
|
|
|
|
it('the autotile edges of the cells around it follow the live cells', () => {
|
|
const s = setup();
|
|
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_KIND_KEY, 'floor'));
|
|
const { world } = startWorld(s);
|
|
const map = world.findByObjectId(s.first)!;
|
|
const quarters = () =>
|
|
collectWorldItems(world)
|
|
.items.find((i) => i.id === s.first)!
|
|
.tilemap!.layers[0]!.tiles.find((t) => t.x === 2 && t.y === 1)!.quadrants;
|
|
world.setTile(map, '', { x: 2, y: 1 }, s.wallId);
|
|
const alone = quarters();
|
|
world.setTile(map, '', { x: 3, y: 1 }, s.wallId);
|
|
// A neighbour to the east turns the two right quarters from an edge into a joint.
|
|
expect(quarters()).not.toEqual(alone);
|
|
expect([quarters()[0], quarters()[2]]).toEqual([alone[0], alone[2]]);
|
|
world.setTile(map, '', { x: 3, y: 1 }, '');
|
|
expect(quarters()).toEqual(alone);
|
|
});
|
|
|
|
it('in the editor run over the scene it runs on, and nowhere else', () => {
|
|
const s = setup();
|
|
const { world } = startWorld(s);
|
|
world.setTile(world.findByObjectId(s.first)!, '', { x: 4, y: 4 }, s.wallId);
|
|
const drawn = (run: typeof world | null) =>
|
|
cellsDrawn(
|
|
collectHolderItems(s.project, s.sceneOf(), 0, run).items.find((i) => i.id === s.first)!.tilemap,
|
|
);
|
|
expect(drawn(world)).toEqual(['4,4']);
|
|
expect(drawn(null)).toEqual([]);
|
|
});
|
|
});
|