diff --git a/src/core/model/liveTilemap.ts b/src/core/model/liveTilemap.ts index d774f385..7a13d332 100644 --- a/src/core/model/liveTilemap.ts +++ b/src/core/model/liveTilemap.ts @@ -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 = { ...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>): 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 = { ...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>(); + /** 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; diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index f24300e8..047ff8d1 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -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(); - 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(); + +function workOutLayerTiles( + props: PropertyBag, + layer: TilemapLayer, + kinds: Map, +): 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); diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index 59856d0b..1e35164d 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -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())); + 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>(); + +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(); 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(); 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(); const seen = new Set(); 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(); + +/** + * 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(); + 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, ): CellBlock[] { - const tiles = new Map(); - 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(); 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[] { - 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[] { + 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( + held: WeakMap, + 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(); +const rectsOf = new WeakMap(); + /** 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)), + ); } /** diff --git a/src/core/model/tilemapMemo.ts b/src/core/model/tilemapMemo.ts new file mode 100644 index 00000000..eee0413e --- /dev/null +++ b/src/core/model/tilemapMemo.ts @@ -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, 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, 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): 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): number { + return look(cells).version; +} + +/** The tiles the cells name, each once. */ +export function cellsTileIds(cells: Record): readonly string[] { + return look(cells).tileIds; +} + +const tokens = new WeakMap(); +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 { + private readonly held = new Map(); + + 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; + } +} diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index caee2ffc..48402fe8 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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 })[] { diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index 6c11d740..89cfac3f 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -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 { diff --git a/src/viewport/items.ts b/src/viewport/items.ts index d09f4991..f5506920 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -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; /** 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. diff --git a/src/viewport/tilemap.ts b/src/viewport/tilemap.ts index d8f8a090..43db7f3e 100644 --- a/src/viewport/tilemap.ts +++ b/src/viewport/tilemap.ts @@ -69,7 +69,7 @@ export class TilemapField { const alive = new Set(); const aliveLayers = new Set(); 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) { diff --git a/tests/core/tile-access.test.ts b/tests/core/tile-access.test.ts index d9f98755..2405166b 100644 --- a/tests/core/tile-access.test.ts +++ b/tests/core/tile-access.test.ts @@ -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', () => { diff --git a/tests/core/tilemap-memo.test.ts b/tests/core/tilemap-memo.test.ts new file mode 100644 index 00000000..5c7d40ef --- /dev/null +++ b/tests/core/tilemap-memo.test.ts @@ -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 = { '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(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); + }); +});