Keep a tilemap with outlined solids at 60 fps in scene and play
game-maker / gate (push) Successful in 3m1s
game-maker / suite (push) Failing after 8m33s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-25 12:23:18 +02:00
1 parent 021a6dcb56
commit 03e7a78c3d
10 files changed
+491 -79

No files matched your search

+30 -7
View File
@@ -8,6 +8,7 @@ import type { TilemapAsset, TilemapLayer } from './Asset';
import { apply, determinant, invert, type Mat } from './matrix';
import type { PropertyBag, Vec2 } from './Properties';
import { type Cell, cellAt, cellAtPoint, cellKey, cellOrigin, inGrid, tilemapTileSize } from './tilemap';
import { cellsVersion } from './tilemapMemo';
/**
* What a run changed on one placement: per layer id, the cells it set, `''` for one it emptied.
@@ -32,16 +33,38 @@ export function liveLayers(asset: TilemapAsset, edits: TileEdits | undefined): T
if (!edits) return asset.layers;
return asset.layers.map((layer) => {
const changed = edits[layer.id];
if (!changed) return layer;
const cells: Record<string, string> = { ...layer.cells };
for (const [key, tileId] of Object.entries(changed)) {
if (tileId) cells[key] = tileId;
else delete cells[key];
}
return { ...layer, cells };
return changed ? editedLayer(layer, changed) : layer;
});
}
/**
* Laid over once per edits and layer content: the same object each frame is what lets the tiles and
* solids worked out from it be remembered (`tilemapMemo.ts`).
*/
function editedLayer(layer: TilemapLayer, changed: Readonly<Record<string, string>>): TilemapLayer {
let byLayer = laidOver.get(changed);
if (!byLayer) laidOver.set(changed, (byLayer = new WeakMap()));
const version = cellsVersion(layer.cells);
const held = byLayer.get(layer);
if (held?.version === version && sameBesidesCells(held.layer, layer)) return held.layer;
const cells: Record<string, string> = { ...layer.cells };
for (const [key, tileId] of Object.entries(changed)) {
if (tileId) cells[key] = tileId;
else delete cells[key];
}
const edited = { ...layer, cells };
byLayer.set(layer, { version, layer: edited });
return edited;
}
/** A layer's settings (name, z, hidden …) are changed in place too, so they are compared one by one. */
function sameBesidesCells(a: TilemapLayer, b: TilemapLayer): boolean {
const keys = Object.keys(b) as (keyof TilemapLayer)[];
return keys.length === Object.keys(a).length && keys.every((key) => key === 'cells' || a[key] === b[key]);
}
const laidOver = new WeakMap<object, WeakMap<TilemapLayer, { version: number; layer: TilemapLayer }>>();
/** The asset as a run sees it: the same one when nothing was changed. */
export function liveTilemap(asset: TilemapAsset, edits: TileEdits | undefined): TilemapAsset {
return edits ? { ...asset, layers: liveLayers(asset, edits) } : asset;
+22 -9
View File
@@ -13,6 +13,7 @@ import { type Neighbours, type TileQuadrants, tileQuadrants } from './autotile';
import { type TileKind, tileKind } from './tile';
import type { PropertyBag } from './Properties';
import { findAssetOf, type Project } from './Project';
import { cellsTileIds, cellsVersion, KeyedMemo } from './tilemapMemo';
export const TILEMAP_COLUMNS_KEY = 'columns';
export const TILEMAP_ROWS_KEY = 'rows';
@@ -124,17 +125,29 @@ export interface TileDraw {
* so the result is the same list twice. Cells naming a deleted tile are skipped rather than
* dropped from the model: an undo brings the tile, and with it the drawing, back.
*/
export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): TileDraw[] {
export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): readonly TileDraw[] {
const kinds = new Map<string, TileKind>();
const kindOf = (id: string): TileKind | null => {
const known = kinds.get(id);
if (known) return known;
for (const id of cellsTileIds(layer.cells)) {
const tile: TileAsset | undefined = findAssetOf(project, id, 'tile');
if (!tile) return null;
const kind = tileKind(tile);
kinds.set(id, kind);
return kind;
};
if (tile) kinds.set(id, tileKind(tile));
}
const key = [
tilemapColumns(props),
tilemapRows(props),
cellsVersion(layer.cells),
[...kinds].map(([id, kind]) => `${id}:${kind}`).join(','),
].join('|');
return drawn.get(key, () => workOutLayerTiles(props, layer, kinds));
}
const drawn = new KeyedMemo<readonly TileDraw[]>();
function workOutLayerTiles(
props: PropertyBag,
layer: TilemapLayer,
kinds: Map<string, TileKind>,
): TileDraw[] {
const kindOf = (id: string): TileKind | null => kinds.get(id) ?? null;
const tiles: TileDraw[] = [];
for (const [key, tileId] of Object.entries(layer.cells)) {
const at = parseCellKey(key);
+111 -35
View File
@@ -39,7 +39,17 @@ import {
tileKind,
type TileKind,
} from './tile';
import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapSize, tilemapTileSize } from './tilemap';
import {
cellOrigin,
inGrid,
neighboursOf,
parseCellKey,
tilemapColumns,
tilemapRows,
tilemapSize,
tilemapTileSize,
} from './tilemap';
import { cellsTileIds, cellsVersion, identityToken, KeyedMemo } from './tilemapMemo';
import { type ColliderShape, oneWayAngle, type ShapeBounds } from '../properties/collider';
import { colliderMaterial } from '../properties/rigidbody';
@@ -126,19 +136,34 @@ export function tileShape(project: Project, tile: TileAsset): TileShape | null {
const frame = id ? findFrame(project, id)?.frame : undefined;
if (!frame?.collision || frame.w <= 0 || frame.h <= 0) return null;
const kind = tileKind(tile);
const quad = kind === 'none' ? 0 : readTile(project, tile).quad;
// Outlines are replaced, never edited in place, so the array stands for what is drawn.
let known = shapes.get(frame.collision);
if (!known) shapes.set(frame.collision, (known = new Map<string, TileShape | null>()));
const key = `${kind}|${quad}|${frame.w}|${frame.h}`;
if (!known.has(key)) known.set(key, workOutShape(frame.collision, frame.w, frame.h, kind, quad));
return known.get(key)!;
}
const shapes = new WeakMap<Vec2[][], Map<string, TileShape | null>>();
function workOutShape(
collision: Vec2[][],
w: number,
h: number,
kind: TileKind,
quad: number,
): TileShape | null {
if (kind === 'none') {
const across = frame.collision.map((o) => o.map((p) => ({ x: p.x / frame.w, y: p.y / frame.h })));
const across = collision.map((o) => o.map((p) => ({ x: p.x / w, y: p.y / h })));
const outlines = validOutlines(across);
return outlines.length > 0 ? { kind: 'plain', outlines } : null;
}
const { quad } = readTile(project, tile);
if (quad <= 0) return null;
const outlines = validOutlines(
frame.collision.map((o) => o.map((p) => ({ x: p.x / quad, y: p.y / quad }))),
);
const outlines = validOutlines(collision.map((o) => o.map((p) => ({ x: p.x / quad, y: p.y / quad }))));
if (outlines.length === 0) return null;
const columns = Math.round(frame.w / quad);
const rows = Math.round(frame.h / quad);
const columns = Math.round(w / quad);
const rows = Math.round(h / quad);
const quarters = new Map<string, QuarterBlock>();
for (let qy = 0; qy < rows; qy++)
for (let qx = 0; qx < columns; qx++) quarters.set(`${qx},${qy}`, quarterBlock(outlines, qx, qy));
@@ -228,8 +253,8 @@ export function tileMaterials(
const materials: TileMaterial[] = [];
const seen = new Set<string>();
for (const layer of asset.layers)
for (const tileId of Object.values(layer.cells)) {
if (!tileId || seen.has(tileId)) continue;
for (const tileId of cellsTileIds(layer.cells)) {
if (seen.has(tileId)) continue;
seen.add(tileId);
const tile = findAssetOf(project, tileId, 'tile');
if (!tile || !isTileSolid(tile) || !passesFilter(tile, oneWay)) continue;
@@ -244,8 +269,8 @@ export function oneWayAngles(project: Project, asset: TilemapAsset): number[] {
const angles = new Set<number>();
const seen = new Set<string>();
for (const layer of asset.layers)
for (const tileId of Object.values(layer.cells)) {
if (!tileId || seen.has(tileId)) continue;
for (const tileId of cellsTileIds(layer.cells)) {
if (seen.has(tileId)) continue;
seen.add(tileId);
const tile = findAssetOf(project, tileId, 'tile');
if (tile && isTileSolid(tile) && isTileOneWay(tile)) angles.add(tileOneWayAngle(tile));
@@ -253,24 +278,52 @@ export function oneWayAngles(project: Project, asset: TilemapAsset): number[] {
return [...angles].sort((a, b) => a - b);
}
type BlockingTile = { scale: number; shape: TileShape | null } | null;
const blocking = new KeyedMemo<readonly CellBlock[]>();
/**
* Worked out once per content: the key holds the grid, the filter, the version of every layer's
* cells and how each tile painted there blocks, which is all the answer depends on.
*/
function blockingCells(
project: Project,
props: PropertyBag,
asset: TilemapAsset,
oneWay: OneWayFilter,
material?: TileMaterial,
): readonly CellBlock[] {
const tiles = new Map<string, BlockingTile>();
for (const layer of asset.layers)
for (const id of cellsTileIds(layer.cells)) {
if (tiles.has(id)) continue;
const tile = findAssetOf(project, id, 'tile');
tiles.set(
id,
tile && isTileSolid(tile) && passesFilter(tile, oneWay, material)
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
: null,
);
}
const key = [
tilemapColumns(props),
tilemapRows(props),
oneWay,
material ? `${material.friction}/${material.bounciness}` : '',
asset.layers.map((layer) => cellsVersion(layer.cells)).join(','),
[...tiles]
.map(([id, t]) => (t ? `${id}:${t.scale}:${t.shape ? identityToken(t.shape) : 'box'}` : id))
.join(','),
].join('|');
return blocking.get(key, () => workOutBlockingCells(props, asset, tiles));
}
function workOutBlockingCells(
props: PropertyBag,
asset: TilemapAsset,
tiles: Map<string, BlockingTile>,
): CellBlock[] {
const tiles = new Map<string, { scale: number; shape: TileShape | null } | null>();
const tileOf = (id: string) => {
if (tiles.has(id)) return tiles.get(id)!;
const tile = findAssetOf(project, id, 'tile');
const value =
tile && isTileSolid(tile) && passesFilter(tile, oneWay, material)
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
: null;
tiles.set(id, value);
return value;
};
const tileOf = (id: string) => tiles.get(id) ?? null;
const found = new Map<string, CellBlock>();
for (const layer of asset.layers) {
for (const [key, tileId] of Object.entries(layer.cells)) {
@@ -324,9 +377,11 @@ export function solidCells(
oneWay: OneWayFilter = false,
material?: TileMaterial,
): Required<SolidCell>[] {
return blockingCells(project, props, asset, oneWay, material)
.filter((cell) => cell.box !== null)
.map(({ x, y, box }) => ({ x, y, scale: box! }));
return boxCells(blockingCells(project, props, asset, oneWay, material));
}
function boxCells(cells: readonly CellBlock[]): Required<SolidCell>[] {
return cells.filter((cell) => cell.box !== null).map(({ x, y, box }) => ({ x, y, scale: box! }));
}
/**
@@ -341,15 +396,34 @@ export function solidPolygons(
asset: TilemapAsset,
oneWay: OneWayFilter = false,
material?: TileMaterial,
): CellPolygon[] {
const polygons: CellPolygon[] = [];
for (const cell of blockingCells(project, props, asset, oneWay, material)) {
if (cell.box !== null) continue;
for (const { outline, pieces } of cell.polygons) polygons.push({ x: cell.x, y: cell.y, outline, pieces });
}
return polygons.sort((a, b) => a.y - b.y || a.x - b.x);
): readonly CellPolygon[] {
return derived(polygonsOf, blockingCells(project, props, asset, oneWay, material), (cells) => {
const polygons: CellPolygon[] = [];
for (const cell of cells) {
if (cell.box !== null) continue;
for (const { outline, pieces } of cell.polygons)
polygons.push({ x: cell.x, y: cell.y, outline, pieces });
}
return polygons.sort((a, b) => a.y - b.y || a.x - b.x);
});
}
/**
* The same cells hand out the same rectangles and polygons, the very same arrays – so whoever builds
* on them can tell by identity that nothing changed (the physics does, every tick).
*/
function derived<T>(
held: WeakMap<readonly CellBlock[], T>,
cells: readonly CellBlock[],
make: (cells: readonly CellBlock[]) => T,
): T {
if (!held.has(cells)) held.set(cells, make(cells));
return held.get(cells)!;
}
const polygonsOf = new WeakMap<readonly CellBlock[], readonly CellPolygon[]>();
const rectsOf = new WeakMap<readonly CellBlock[], readonly CellRect[]>();
/** A rectangle by its four sides, which is what the merge compares. */
interface Piece {
x0: number;
@@ -456,8 +530,10 @@ export function solidRects(
asset: TilemapAsset,
oneWay: OneWayFilter = false,
material?: TileMaterial,
): CellRect[] {
return mergeCells(solidCells(project, props, asset, oneWay, material));
): readonly CellRect[] {
return derived(rectsOf, blockingCells(project, props, asset, oneWay, material), (cells) =>
mergeCells(boxCells(cells)),
);
}
/**
+78
View File
@@ -0,0 +1,78 @@
/**
* Remembering what a tilemap's cells work out to. The scene view, the game view, the collision
* system and the physics all ask for the same tiles and solids every frame, and working them out
* again each time is what made a map with outlined tiles cost a whole frame.
*
* The cells are painted in place, so an object being the same says nothing about its content.
* `cellsVersion` compares the content with what it saw last and hands out a new number only when it
* differs – a number that then stands for exactly that content in a cache key.
*/
interface Seen {
keys: string[];
values: string[];
version: number;
tileIds: string[];
}
const seen = new WeakMap<Record<string, string>, Seen>();
let versions = 0;
/**
* Walked in the object's own order: the same content painted the same way comes back in the same
* order, and a cell taken out and put back only costs a new version, never a wrong answer.
*/
function unchanged(cells: Record<string, string>, held: Seen): boolean {
let i = 0;
for (const key in cells) {
if (held.keys[i] !== key || held.values[i] !== cells[key]) return false;
i++;
}
return i === held.keys.length;
}
function look(cells: Record<string, string>): Seen {
const held = seen.get(cells);
if (held && unchanged(cells, held)) return held;
const keys = Object.keys(cells);
const values = keys.map((key) => cells[key]!);
const fresh = { keys, values, version: ++versions, tileIds: [...new Set(values)].filter(Boolean) };
seen.set(cells, fresh);
return fresh;
}
/** A number that is the same exactly as long as the cells hold the same tiles in the same places. */
export function cellsVersion(cells: Record<string, string>): number {
return look(cells).version;
}
/** The tiles the cells name, each once. */
export function cellsTileIds(cells: Record<string, string>): readonly string[] {
return look(cells).tileIds;
}
const tokens = new WeakMap<object, number>();
let tokenCount = 0;
/** A number for an object, the same for as long as it lives – how a cached shape goes into a key. */
export function identityToken(value: object): number {
let token = tokens.get(value);
if (token === undefined) tokens.set(value, (token = ++tokenCount));
return token;
}
/** A small cache by key that forgets the oldest entry once it is full. */
export class KeyedMemo<T> {
private readonly held = new Map<string, T>();
constructor(private readonly size = 64) {}
get(key: string, make: () => T): T {
const known = this.held.get(key);
if (known !== undefined) return known;
const value = make();
if (this.held.size >= this.size) this.held.delete(this.held.keys().next().value as string);
this.held.set(key, value);
return value;
}
}
+48 -21
View File
@@ -83,6 +83,8 @@ interface Body {
solids: SolidShape[];
/** What the colliders were built from; they are built again when it changes (a scaled object). */
shapeKey: string;
/** For a tilemap, what its shapes were last worked out from (see `build`). */
source: string;
settingsKey: string;
/** Whether the engine may turn it – what `writeBack` hands on as the object's rotation. */
turns: boolean;
@@ -425,6 +427,7 @@ export class PhysicsSystem implements System, CollisionSource {
sources: [],
solids: [],
shapeKey: '',
source: '',
settingsKey: '',
turns: false,
ownMass: 0,
@@ -516,6 +519,36 @@ export class PhysicsSystem implements System, CollisionSource {
/** The body's colliders, built again whenever what they were built from changed. */
private build(body: Body, want: Wanted, key: Entity, world: World): void {
// A map's shapes follow from its cells, placement and settings alone. While none of those
// changed, its thousands of pieces are not worked out again only to find the same key.
const source = body.kind === 'tilemap' ? this.tilemapSource(key, world) : '';
const local = source !== '' && source === body.source ? [] : this.rebuild(body, want, key, world);
body.source = source;
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them. A
// tilemap's many shapes all stand for the one map, so its colliders keep the shape they were built from.
const solidOfEntity = new Map(local.map((solid) => [solid.entity, solid]));
body.colliders.forEach((collider, i) => {
const entity = body.sources[i]!;
const { shape, facing } = body.kind === 'tilemap' ? body.solids[i]! : solidOfEntity.get(entity)!;
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
const on = isHandleEnabled(entity);
if (collider.isEnabled() === on) return;
collider.setEnabled(on);
// A resting body sleeps, and losing what held it up does not wake it by itself.
body.body.wakeUp();
});
if (body.kind !== 'rigid') return;
// Without a collider the engine would weigh it nothing, and a thing of no weight never falls.
const weighed = body.sources.some((entity) => isSolid(entity) && isHandleEnabled(entity));
const own = weighed ? 0 : want.settings?.mass || 1;
if (own !== body.ownMass) {
body.ownMass = own;
body.body.setAdditionalMass(own, true);
}
}
/** Works out the body's shapes and builds its colliders anew when they differ from the last ones. */
private rebuild(body: Body, want: Wanted, key: Entity, world: World) {
const shapes = this.shapesWanted(want, key, world);
const origin = body.placed;
const local = shapes.map((solid) => ({ ...solid, pieces: piecesInBody(origin, solid.shape) }));
@@ -557,27 +590,21 @@ export class PhysicsSystem implements System, CollisionSource {
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
}
}
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them. A
// tilemap's many shapes all stand for the one map, so its colliders keep the shape they were built from.
const solidOfEntity = new Map(local.map((solid) => [solid.entity, solid]));
body.colliders.forEach((collider, i) => {
const entity = body.sources[i]!;
const { shape, facing } = body.kind === 'tilemap' ? body.solids[i]! : solidOfEntity.get(entity)!;
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
const on = isHandleEnabled(entity);
if (collider.isEnabled() === on) return;
collider.setEnabled(on);
// A resting body sleeps, and losing what held it up does not wake it by itself.
body.body.wakeUp();
});
if (body.kind !== 'rigid') return;
// Without a collider the engine would weigh it nothing, and a thing of no weight never falls.
const weighed = body.sources.some((entity) => isSolid(entity) && isHandleEnabled(entity));
const own = weighed ? 0 : want.settings?.mass || 1;
if (own !== body.ownMass) {
body.ownMass = own;
body.body.setAdditionalMass(own, true);
}
return local;
}
private tilemapSource(map: Entity, world: World): string {
const m = worldMatrixOf(map);
return [
this.solids.blocksToken(world, map),
m.a,
m.b,
m.c,
m.d,
m.tx,
m.ty,
JSON.stringify(map.props),
].join('|');
}
private shapesWanted(want: Wanted, key: Entity, world: World): (SolidShape & { entity: Entity })[] {
+17 -2
View File
@@ -4,6 +4,7 @@ import type { Vec2 } from '../../core/model/Properties';
import { dot } from '../../core/util/vec';
import { findAssetOf } from '../../core/model/Project';
import { liveTilemap, type TileEdits } from '../../core/model/liveTilemap';
import { identityToken } from '../../core/model/tilemapMemo';
import {
type CellPolygon,
cellPolygonShape,
@@ -227,6 +228,20 @@ export class Solids {
return held.blocks;
}
/**
* Stands for the map's blocking cells: the same as long as they are, since the same cells hand out
* the very same rectangles and polygons (`tilemapCollision.ts`).
*/
blocksToken(world: World, tilemap: Entity): string {
return this.blocksOf(world, tilemap)
.map(({ angle, material, rects, polygons }) =>
[angle, material.friction, material.bounciness, identityToken(rects), identityToken(polygons)].join(
':',
),
)
.join(',');
}
/**
* The shapes a body occupies in the world: one for a collider; for a tilemap one box per merged
* rectangle and one polygon per outlined cell (#141), the one-way cells blocking from the map's
@@ -573,8 +588,8 @@ function unitOf(v: Vec2): Vec2 {
type TilemapBlocks = {
angle: number | null;
material: TileMaterial;
rects: CellRect[];
polygons: CellPolygon[];
rects: readonly CellRect[];
polygons: readonly CellPolygon[];
}[];
export function shapeOf(collider: Entity): ColliderShape {
+3 -3
View File
@@ -86,16 +86,16 @@ export interface TilemapLayerDraw {
id: string;
z: number;
sortByY: boolean;
tiles: TileDraw[];
tiles: readonly TileDraw[];
}
export interface TilemapDraw {
layers: TilemapLayerDraw[];
tiles: Map<string, TilemapTile>;
/** Where the map blocks, in cells – the scene view outlines it like a collider (#132). */
solids: CellRect[];
solids: readonly CellRect[];
/** The cells that block with an outline (#141), outlined the same way. */
outlines: CellPolygon[];
outlines: readonly CellPolygon[];
/**
* The same for one-way tiles (#155), which block only from above; each rectangle with the angle
* its tiles land things within (#159), since tiles of two angles never merge.
+1 -1
View File
@@ -69,7 +69,7 @@ export class TilemapField {
const alive = new Set<string>();
const aliveLayers = new Set<string>();
for (const layer of draw?.layers ?? []) {
const parts: [number | null, TileDraw[]][] =
const parts: [number | null, readonly TileDraw[]][] =
draw && layer.sortByY ? [...layerRows(draw, layer)] : [[null, layer.tiles]];
const alpha = layerAlpha(layer.id, focus);
for (const [row, cells] of parts) {
+1 -1
View File
@@ -184,7 +184,7 @@ describe.each(COLLISION_SYSTEMS)('$system: a tile set in a run (#135)', ({ physi
});
describe('a tile set in a run is drawn (#135)', () => {
const cellsDrawn = (tilemap: { layers: { tiles: { x: number; y: number }[] }[] } | null) =>
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', () => {
+180
View File
@@ -0,0 +1,180 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands';
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
import type { TextureAsset, TilemapAsset } from '../../src/core/model/Asset';
import { liveLayers } from '../../src/core/model/liveTilemap';
import { findAsset } from '../../src/core/model/Project';
import { TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
import {
layerTiles,
TILEMAP_COLUMNS_KEY,
TILEMAP_ROWS_KEY,
TILEMAP_TILE_KEY,
} from '../../src/core/model/tilemap';
import { solidPolygons, solidRects } from '../../src/core/model/tilemapCollision';
import { cellsVersion, KeyedMemo } from '../../src/core/model/tilemapMemo';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { Solids } from '../../src/runtime/systems/solids';
import { makeSpriteScene, place, startWorld } from '../helpers';
beforeAll(async () => {
await loadPhysics();
});
afterEach(() => {
vi.restoreAllMocks();
});
const TRIANGLE = [
{ x: 0, y: 0 },
{ x: 32, y: 0 },
{ x: 0, y: 32 },
];
/**
* A 4 × 4 map of 32 px cells with two tiles: `box` blocks as a box, `cut` with a triangle drawn on
* its picture. Both lie on the one layer, `box` along the top row and `cut` at (1,2).
*/
function map() {
const f = makeSpriteScene('Thing');
const texture = new CreateAssetCommand('texture', 'Sheet');
const box = new CreateAssetCommand('tile', 'Box');
const cut = new CreateAssetCommand('tile', 'Cut');
const tilemap = new CreateAssetCommand('tilemap', 'Ground');
for (const command of [texture, box, cut, tilemap]) f.stack.execute(command);
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 32;
sheet.height = 32;
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 });
f.stack.execute(frame);
f.stack.execute(new AddTilePictureCommand(cut.asset.id, frame.frameId));
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [TRIANGLE]));
for (const tile of [box, cut]) {
f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, 'none'));
}
for (const [key, value] of [
[TILEMAP_COLUMNS_KEY, 4],
[TILEMAP_ROWS_KEY, 4],
[TILEMAP_TILE_KEY, 32],
] as const)
f.stack.execute(new SetAssetPropertyCommand(tilemap.asset.id, key, value));
const mapId = place(f, tilemap.asset.id, { x: 0, y: 0 });
const asset = findAsset(f.project, tilemap.asset.id) as TilemapAsset;
const layer = asset.layers[0]!;
const top = [0, 1, 2, 3].map((x) => ({ x, y: 0, tileId: box.asset.id }));
f.stack.execute(new PaintTilesCommand(asset.id, layer.id, [...top, { x: 1, y: 2, tileId: cut.asset.id }]));
return {
...f,
mapId,
asset,
layer,
boxId: box.asset.id,
cutId: cut.asset.id,
textureId: texture.asset.id,
frameId: frame.frameId,
props: asset.props,
};
}
describe('what a tilemap works out is remembered while its content stays', () => {
it('a version stays while the cells hold the same, and changes when one is painted in place', () => {
const cells: Record<string, string> = { '0,0': 'a', '1,0': 'b' };
const first = cellsVersion(cells);
expect(cellsVersion(cells)).toBe(first);
cells['2,0'] = 'a';
const added = cellsVersion(cells);
expect(added).not.toBe(first);
cells['2,0'] = 'b';
const changed = cellsVersion(cells);
expect(changed).not.toBe(added);
delete cells['0,0'];
expect(cellsVersion(cells)).not.toBe(changed);
});
it('the cache hands out what it made for a key, and forgets the oldest once full', () => {
const memo = new KeyedMemo<number[]>(2);
const make = vi.fn(() => [1]);
const a = memo.get('a', make);
expect(memo.get('a', make)).toBe(a);
expect(make).toHaveBeenCalledTimes(1);
memo.get('b', make);
memo.get('c', make);
expect(memo.get('a', make)).not.toBe(a);
expect(make).toHaveBeenCalledTimes(4);
});
it('asking again for an unchanged map hands out the very same tiles and solids', () => {
const m = map();
expect(layerTiles(m.project, m.props, m.layer)).toBe(layerTiles(m.project, m.props, m.layer));
expect(solidRects(m.project, m.props, m.asset)).toBe(solidRects(m.project, m.props, m.asset));
expect(solidPolygons(m.project, m.props, m.asset)).toBe(solidPolygons(m.project, m.props, m.asset));
});
it('a cell painted in place, as a stroke does before it is a command, is seen at once', () => {
const m = map();
const rects = solidRects(m.project, m.props, m.asset);
expect(rects).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]);
m.layer.cells['0,3'] = m.boxId;
expect(solidRects(m.project, m.props, m.asset)).toEqual([
{ x: 0, y: 0, w: 4, h: 1 },
{ x: 0, y: 3, w: 1, h: 1 },
]);
expect(layerTiles(m.project, m.props, m.layer).map((t) => `${t.x},${t.y}`)).toContain('0,3');
delete m.layer.cells['1,2'];
expect(solidPolygons(m.project, m.props, m.asset)).toEqual([]);
});
it('a tile no longer solid, a new outline and a new kind are each seen at once', () => {
const m = map();
const polygons = solidPolygons(m.project, m.props, m.asset);
expect(polygons).toHaveLength(1);
const other = [
{ x: 32, y: 0 },
{ x: 32, y: 32 },
{ x: 0, y: 32 },
];
m.stack.execute(new SetFrameCollisionCommand(m.textureId, m.frameId, [other]));
const turned = solidPolygons(m.project, m.props, m.asset);
expect(turned).not.toBe(polygons);
expect(turned[0]!.outline).not.toEqual(polygons[0]!.outline);
m.stack.execute(new SetAssetPropertyCommand(m.boxId, TILE_SOLID_KEY, false));
expect(solidRects(m.project, m.props, m.asset)).toEqual([]);
const before = layerTiles(m.project, m.props, m.layer).find((t) => t.tileId === m.boxId)!;
m.stack.execute(new SetAssetPropertyCommand(m.boxId, TILE_KIND_KEY, 'floor'));
const after = layerTiles(m.project, m.props, m.layer).find((t) => t.tileId === m.boxId)!;
expect(after.quadrants).not.toEqual(before.quadrants);
});
it('a run lays the same edits over a layer once, and again when the layer was painted meanwhile', () => {
const m = map();
const edits = { [m.layer.id]: { '3,3': m.boxId } };
const first = liveLayers(m.asset, edits)[0]!;
expect(liveLayers(m.asset, edits)[0]).toBe(first);
m.layer.cells['2,3'] = m.boxId;
const painted = liveLayers(m.asset, edits)[0]!;
expect(painted).not.toBe(first);
expect(painted.cells['2,3']).toBe(m.boxId);
expect(painted.cells['3,3']).toBe(m.boxId);
m.layer.z = 3;
expect(liveLayers(m.asset, edits)[0]!.z).toBe(3);
});
it('the physics works a still map out once, and again once it is moved', () => {
const m = map();
const shapesOf = vi.spyOn(Solids.prototype, 'solidShapesOf');
const s = startWorld(m, { physics: true, collisions: true });
const mapWorkedOut = () =>
shapesOf.mock.calls.filter(([, body, within]) => body.objectId === m.mapId && !within).length;
for (let i = 0; i < 5; i++) s.frame();
expect(mapWorkedOut()).toBe(1);
s.world.findByObjectId(m.mapId)!.x += 10;
s.frame();
expect(mapWorkedOut()).toBe(2);
s.frame();
expect(mapWorkedOut()).toBe(2);
});
});