A project with three tilemap layers, two of them with collision on water and hill tiles, ran at 33 to 52 fps. A CPU profile showed that almost all of the time went into the collision geometry of the map. It was rebuilt from scratch every frame although nothing had changed. The animated water makes the views redraw every frame, so the cost was never skipped. - New module tilemapMemo keeps what is derived from the cells: the tiles of a layer, the blocking cells, the rectangles and polygons. It is reused as long as the content stays the same. - A brush stroke paints into the existing cells object. The memo therefore compares the content cell by cell, not the identity. The comparison walks two arrays in insertion order. - Clipped outlines are kept per outline list. The model always replaces that list instead of changing it. - PhysicsSystem no longer rebuilds the shapes of a tilemap each tick while its cells, placement and settings are unchanged. Before, it also built a JSON key from all world shapes every tick. - The overlaid layers after Set tile in a run stay stable. Otherwise the memo would miss on every call after the first edit. The #184 work only helped Move and raycasts, not this path. Measured on stein2 (i5-7500T, HD 630), script time per frame: - Scene, nothing selected: 17.7 ms, 52 fps -> 5.8 ms, 60 fps - Tilemap selected: 26.2 ms, 38 fps -> 8.6 ms, 60 fps - Play: 29.9 ms, 33 fps -> 8.7 ms, 60 fps About 3 ms per frame remain. The content comparison costs roughly 60 us per 700-cell layer and runs about 40 times a frame. The mesh geometries in TilemapField are still rebuilt every frame. Tests: - ./check.sh: 162 files, 2377 tests, CHECK EXIT 0 (before 2370) - e2e 1-5 and 39-43: ALL PASSED, 289 checks - 7 new unit tests in tests/core/tilemap-memo.test.ts - 5 counter checks (content ignored, no memo, outline by kind only, physics always rebuilds, layer settings ignored) each fail their test. All originals are back, checked with cmp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
11 KiB
TypeScript
234 lines
11 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 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([]);
|
|
});
|
|
});
|