diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts index a7e55447..12303cc8 100644 --- a/src/core/commands/tilemapCommands.ts +++ b/src/core/commands/tilemapCommands.ts @@ -72,7 +72,7 @@ export class PaintTilesCommand implements Command { if (edit.tileId) layer.cells[key] = edit.tileId; else delete layer.cells[key]; } - cellsChanged(layer.cells); + cellsChanged(layer.cells, this.previous.keys()); } undo(project: Project): void { @@ -81,7 +81,7 @@ export class PaintTilesCommand implements Command { if (value) layer.cells[key] = value; else delete layer.cells[key]; } - cellsChanged(layer.cells); + cellsChanged(layer.cells, this.previous.keys()); } } diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index a7edcd29..25095240 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -13,7 +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'; +import { cellsChangedSince, cellsTileIds, cellsVersion, identityToken, KeyedMemo } from './tilemapMemo'; export const TILEMAP_COLUMNS_KEY = 'columns'; export const TILEMAP_ROWS_KEY = 'rows'; @@ -145,33 +145,103 @@ function joinedBy(same: (dx: number, dy: number) => boolean): Neighbours { export interface LayerCells { columns: number; rows: number; - list: readonly { x: number; y: number; tileId: string }[]; + readonly list: readonly { x: number; y: number; tileId: string }[]; /** Tile id by `y * columns + x`. */ at: ReadonlyMap; } -const indexed = new WeakMap, LayerCells & { version: number }>(); +/** + * Patched in place when cells are painted (#348), so it holds for the cells as they are now: whoever + * asks works from it at once and keeps it no longer. + */ +class CellIndex implements LayerCells { + readonly at = new Map(); + private ordered: LayerCells['list'] | null = null; + + constructor( + readonly columns: number, + readonly rows: number, + public version: number, + ) {} + + get list(): LayerCells['list'] { + const { at, columns } = this; + this.ordered ??= [...at.keys()] + .sort((a, b) => a - b) + .map((i) => ({ x: i % columns, y: Math.floor(i / columns), tileId: at.get(i)! })); + return this.ordered; + } + + read(key: string, tileId: string | undefined): void { + const cell = parseCellKey(key); + if (!cell || cell.x < 0 || cell.y < 0 || cell.x >= this.columns || cell.y >= this.rows) return; + const index = cell.y * this.columns + cell.x; + if (tileId) this.at.set(index, tileId); + else this.at.delete(index); + this.ordered = null; + } +} + +const indexed = new WeakMap, CellIndex>(); export function layerCells(props: PropertyBag, layer: TilemapLayer): LayerCells { const columns = tilemapColumns(props); const rows = tilemapRows(props); const version = cellsVersion(layer.cells); const held = indexed.get(layer.cells); - if (held && held.version === version && held.columns === columns && held.rows === rows) return held; - const at = new Map(); - for (const [key, tileId] of Object.entries(layer.cells)) { - const cell = parseCellKey(key); - if (!tileId || !cell || cell.x < 0 || cell.y < 0 || cell.x >= columns || cell.y >= rows) continue; - at.set(cell.y * columns + cell.x, tileId); + if (held && held.columns === columns && held.rows === rows) { + if (held.version === version) return held; + const changed = cellsChangedSince(layer.cells, held.version); + if (changed) { + for (const key of changed) held.read(key, layer.cells[key]); + held.version = version; + return held; + } } - const list = [...at.keys()] - .sort((a, b) => a - b) - .map((i) => ({ x: i % columns, y: Math.floor(i / columns), tileId: at.get(i)! })); - const fresh = { columns, rows, list, at, version }; + const fresh = new CellIndex(columns, rows, version); + for (const [key, tileId] of Object.entries(layer.cells)) fresh.read(key, tileId); indexed.set(layer.cells, fresh); return fresh; } +/** + * The cells that may show or block otherwise once `keys` are painted: those cells and their eight + * neighbours inside the grid, by `y * columns + x`, ascending. Further off nothing changes – a + * cell's quarters and its blocking shape follow its neighbours alone. + */ +export function cellsAround(keys: readonly string[], columns: number, rows: number): number[] { + const near = new Set(); + for (const key of keys) { + const cell = parseCellKey(key); + if (!cell) continue; + for (let y = Math.max(0, cell.y - 1); y <= Math.min(rows - 1, cell.y + 1); y++) + for (let x = Math.max(0, cell.x - 1); x <= Math.min(columns - 1, cell.x + 1); x++) + near.add(y * columns + x); + } + return [...near].sort((a, b) => a - b); +} + +/** + * A list in cell order with the entries at `indices` (ascending) worked out again: `make` gives a + * cell's new entries, none or several. A new list – the old one may still be held by identity. + */ +export function patchedInOrder( + list: readonly T[], + indexOf: (entry: T) => number, + indices: readonly number[], + make: (index: number) => readonly T[], +): T[] { + const out: T[] = []; + let i = 0; + for (const index of indices) { + while (i < list.length && indexOf(list[i]!) < index) out.push(list[i++]!); + while (i < list.length && indexOf(list[i]!) === index) i++; + for (const entry of make(index)) out.push(entry); + } + while (i < list.length) out.push(list[i++]!); + return out; +} + /** `neighboursOf`, read from the index. */ export function neighboursIn(cells: LayerCells, x: number, y: number, tileId: string): Neighbours { const { columns, rows, at } = cells; @@ -202,16 +272,36 @@ export function layerTiles(project: Project, props: PropertyBag, layer: TilemapL const tile: TileAsset | undefined = findAssetOf(project, id, 'tile'); if (tile) kinds.set(id, tileKind(tile)); } - const key = [ + const kept = [ tilemapColumns(props), tilemapRows(props), - cellsVersion(layer.cells), [...kinds].map(([id, kind]) => `${id}:${kind}`).join(','), ].join('|'); - return drawn.get(key, () => workOutLayerTiles(props, layer, kinds)); + const version = cellsVersion(layer.cells); + return drawn.get(`${kept}|${version}`, () => { + // The last list for these cells and tiles, patched where they were painted since (#348). + const last = lastDrawn.get(`${kept}|${identityToken(layer.cells)}`, () => ({ version: 0, tiles: null })); + const changed = last.tiles && cellsChangedSince(layer.cells, last.version); + const tiles = changed + ? patchedLayerTiles(props, layer, kinds, last.tiles!, changed) + : workOutLayerTiles(props, layer, kinds); + last.version = version; + last.tiles = tiles; + return tiles; + }); } const drawn = new KeyedMemo(); +const lastDrawn = new KeyedMemo<{ version: number; tiles: readonly TileDraw[] | null }>(); + +function tileDrawAt(cells: LayerCells, kinds: Map, index: number): TileDraw | null { + const tileId = cells.at.get(index); + const kind = tileId && kinds.get(tileId); + if (!kind) return null; + const x = index % cells.columns; + const y = Math.floor(index / cells.columns); + return { x, y, tileId, quadrants: tileQuadrants(kind, neighboursIn(cells, x, y, tileId)) }; +} function workOutLayerTiles( props: PropertyBag, @@ -220,14 +310,32 @@ function workOutLayerTiles( ): TileDraw[] { const cells = layerCells(props, layer); const tiles: TileDraw[] = []; - for (const { x, y, tileId } of cells.list) { - const kind = kinds.get(tileId); - if (!kind) continue; - tiles.push({ x, y, tileId, quadrants: tileQuadrants(kind, neighboursIn(cells, x, y, tileId)) }); + for (const { x, y } of cells.list) { + const drawn = tileDrawAt(cells, kinds, y * cells.columns + x); + if (drawn) tiles.push(drawn); } return tiles; } +function patchedLayerTiles( + props: PropertyBag, + layer: TilemapLayer, + kinds: Map, + tiles: readonly TileDraw[], + changed: readonly string[], +): TileDraw[] { + const cells = layerCells(props, layer); + return patchedInOrder( + tiles, + (tile) => tile.y * cells.columns + tile.x, + cellsAround(changed, cells.columns, cells.rows), + (index) => { + const drawn = tileDrawAt(cells, kinds, index); + return drawn ? [drawn] : []; + }, + ); +} + /** * The layer names of every tilemap in the project, each once, in asset and layer order. A node * picks a layer by name before it knows which map it will be given (#135), so it is offered them all. diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index c2398130..6f508b6c 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -45,14 +45,17 @@ import { } from './tile'; import { cellOrigin, + cellsAround, layerCells, + type LayerCells, neighboursIn, + patchedInOrder, tilemapColumns, tilemapRows, tilemapSize, tilemapTileSize, } from './tilemap'; -import { cellsTileIds, cellsVersion, identityToken, KeyedMemo } from './tilemapMemo'; +import { cellsChangedSince, cellsTileIds, cellsVersion, identityToken, KeyedMemo } from './tilemapMemo'; import { type ColliderShape, oneWayAngle, type ShapeBounds } from '../properties/collider'; import { colliderMaterial } from '../properties/rigidbody'; @@ -258,7 +261,7 @@ function picks(tile: TileAsset, pick: TilePick): boolean { return isTileSolid(tile) && passesFilter(tile, pick.oneWay, pick.material); } -/** The solid tiles painted on the map, each once, in the order the layers first name them. */ +/** The solid tiles painted on the map, each once: layer by layer, by id within one. */ function solidTiles(project: Project, asset: TilemapAsset): TileAsset[] { const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells))); return [...ids].flatMap((id) => { @@ -314,61 +317,123 @@ function blockingCells( : null, ); } - const key = [ + const kept = [ tilemapColumns(props), tilemapRows(props), 'trigger' in pick ? `trigger:${pick.trigger ?? ''}` : `${pick.oneWay}|${pick.material ? `${pick.material.friction}/${pick.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)); + const versions = asset.layers.map((layer) => cellsVersion(layer.cells)); + return blocking.get(`${kept}|${versions.join(',')}`, () => { + // The last cells for these layers and tiles, patched where they were painted since (#348). + const layersKey = asset.layers.map((layer) => identityToken(layer.cells)).join(','); + const last = lastBlocking.get(`${kept}|${layersKey}`, () => ({ versions: [], cells: null })); + const changed = last.cells + ? asset.layers.map((layer, i) => cellsChangedSince(layer.cells, last.versions[i]!)) + : []; + const cells = + last.cells && changed.every((keys) => keys !== null) + ? patchedBlockingCells(props, asset, tiles, last.cells, changed.flat()) + : workOutBlockingCells(props, asset, tiles); + last.versions = versions; + last.cells = cells; + return cells; + }); } +const lastBlocking = new KeyedMemo<{ versions: number[]; cells: readonly CellBlock[] | null }>(); + +/** What blocks in one cell, from what each layer holds there – null when nothing does. */ +function blockAt( + layers: readonly LayerCells[], + tiles: Map, + index: number, +): CellBlock | null { + let cell: CellBlock | null = null; + for (const cells of layers) { + const tileId = cells.at.get(index); + const tile = tileId && tiles.get(tileId); + if (!tile) continue; + cell ??= { x: index % cells.columns, y: Math.floor(index / cells.columns), box: null, polygons: [] }; + const { shape, scale } = tile; + if (!shape) { + cell.box = Math.max(cell.box ?? 0, scale); + } else if (shape.kind === 'plain') { + // The collision scale draws the outline in or out about the cell's centre (#140). + const at = 0.5 - scale / 2; + for (const outline of shape.outlines) + cell.polygons.push(placed(outline, cell.x + at, cell.y + at, scale)); + } else { + const around = neighboursIn(cells, cell.x, cell.y, tileId); + const quarters = tileQuadrants(shape.tileKind, around).map( + (q) => shape.quarters.get(`${q.x},${q.y}`) ?? [], + ); + if (quarters.every((q) => q === 'full')) { + cell.box = Math.max(cell.box ?? 0, 1); + continue; + } + for (const [corner, quarter] of quarters.entries()) { + const x = cell.x + (corner % 2) / 2; + const y = cell.y + Math.floor(corner / 2) / 2; + for (const outline of quarter === 'full' ? [SQUARE] : quarter) + cell.polygons.push(placed(outline, x, y, 0.5)); + } + } + } + return cell; +} + +/** In cell order, whichever layers block there. */ function workOutBlockingCells( props: PropertyBag, asset: TilemapAsset, tiles: Map, ): CellBlock[] { - const found = new Map(); - for (const layer of asset.layers) { - const cells = layerCells(props, layer); - for (const { x, y, tileId } of cells.list) { - const tile = tiles.get(tileId); - if (!tile) continue; - const index = y * cells.columns + x; - let cell = found.get(index); - if (!cell) found.set(index, (cell = { x, y, box: null, polygons: [] })); - const { shape, scale } = tile; - if (!shape) { - cell.box = Math.max(cell.box ?? 0, scale); - } else if (shape.kind === 'plain') { - // The collision scale draws the outline in or out about the cell's centre (#140). - const at = 0.5 - scale / 2; - for (const outline of shape.outlines) - cell.polygons.push(placed(outline, cell.x + at, cell.y + at, scale)); - } else { - const around = neighboursIn(cells, cell.x, cell.y, tileId); - const quarters = tileQuadrants(shape.tileKind, around).map( - (q) => shape.quarters.get(`${q.x},${q.y}`) ?? [], - ); - if (quarters.every((q) => q === 'full')) { - cell.box = Math.max(cell.box ?? 0, 1); - continue; - } - for (const [corner, quarter] of quarters.entries()) { - const x = cell.x + (corner % 2) / 2; - const y = cell.y + Math.floor(corner / 2) / 2; - for (const outline of quarter === 'full' ? [SQUARE] : quarter) - cell.polygons.push(placed(outline, x, y, 0.5)); - } - } - } + const layers = asset.layers.map((layer) => layerCells(props, layer)); + const indices = new Set(); + for (const cells of layers) for (const index of cells.at.keys()) indices.add(index); + const found: CellBlock[] = []; + for (const index of [...indices].sort((a, b) => a - b)) { + const cell = blockAt(layers, tiles, index); + if (cell) found.push(cell); } - return [...found.values()]; + return found; +} + +function patchedBlockingCells( + props: PropertyBag, + asset: TilemapAsset, + tiles: Map, + blocks: readonly CellBlock[], + changed: readonly string[], +): CellBlock[] { + const layers = asset.layers.map((layer) => layerCells(props, layer)); + const columns = tilemapColumns(props); + const indices = cellsAround(changed, columns, tilemapRows(props)); + const made = new Map(); + const patched = patchedInOrder( + blocks, + (cell) => cell.y * columns + cell.x, + indices, + (index) => { + const cell = blockAt(layers, tiles, index); + if (!cell) return []; + made.set(index, cell); + return [cell]; + }, + ); + // A block worked out again may block otherwise, which cuts its neighbours' pieces otherwise too. + const rows = new Set(); + for (const index of indices) { + const y = Math.floor(index / columns); + for (let near = y - 1; near <= y + 1; near++) rows.add(near); + } + patchOf.set(patched, { from: blocks, rows: [...rows], indices, columns, made }); + return patched; } /** @@ -407,18 +472,33 @@ export function solidPolygons( return polygonCells(blockingCells(project, props, asset, { oneWay, material })); } +/** + * The blocks come in cell order, so the polygons do too: top-down, then left to right. A block a + * patch kept is the same object, and so are its polygons (#348). + */ function polygonCells(blocks: readonly CellBlock[]): readonly CellPolygon[] { return derived(polygonsOf, blocks, (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); + const patch = patchOf.get(cells); + const before = patch && polygonsOf.get(patch.from); + if (before) + return patchedInOrder( + before, + (polygon) => polygon.y * patch.columns + polygon.x, + patch.indices, + (index) => { + const cell = patch.made.get(index); + return cell ? polygonsOfBlock(cell) : []; + }, + ); + return cells.flatMap(polygonsOfBlock); }); } +function polygonsOfBlock(cell: CellBlock): readonly CellPolygon[] { + if (cell.box !== null) return []; + return cell.polygons.map(({ outline, pieces }) => ({ x: cell.x, y: cell.y, outline, pieces })); +} + /** * 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). @@ -433,7 +513,6 @@ function derived( } const polygonsOf = new WeakMap(); -const rectsOf = new WeakMap(); /** A rectangle by its four sides, which is what the merge compares. */ interface Piece { @@ -496,14 +575,17 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] { const unique = new Map(); for (const cell of cells) unique.set(`${cell.x},${cell.y}`, cell); const blocks = (x: number, y: number) => unique.has(`${x},${y}`); + return stacked(joinedStrips([...unique.values()].flatMap((cell) => cellPieces(cell, blocks)))); +} + +/** Pieces on the same strip – same top and bottom – that touch or overlap, joined into runs. */ +function joinedStrips(pieces: readonly Piece[]): Piece[] { const strips = new Map(); - for (const cell of unique.values()) { - for (const piece of cellPieces(cell, blocks)) { - const key = `${piece.y0},${piece.y1}`; - const strip = strips.get(key); - if (strip) strip.push(piece); - else strips.set(key, [piece]); - } + for (const piece of pieces) { + const key = `${piece.y0},${piece.y1}`; + const strip = strips.get(key); + if (strip) strip.push(piece); + else strips.set(key, [piece]); } const runs: Piece[] = []; for (const strip of strips.values()) { @@ -514,11 +596,15 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] { else runs.push((run = { ...piece })); } } - runs.sort((a, b) => a.y0 - b.y0 || a.x0 - b.x0); + return runs; +} + +/** Runs of exactly the same left and right that meet top to bottom, joined into taller rectangles. */ +function stacked(runs: readonly Piece[]): CellRect[] { const done: Piece[] = []; // Rectangles still open at the bottom, by the left and right they span. const open = new Map(); - for (const run of runs) { + for (const run of [...runs].sort((a, b) => a.y0 - b.y0 || a.x0 - b.x0)) { const key = `${run.x0},${run.x1}`; const above = open.get(key); if (above && above.y1 === run.y0) { @@ -526,7 +612,7 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] { continue; } if (above) done.push(above); - open.set(key, run); + open.set(key, { ...run }); } done.push(...open.values()); return done @@ -534,6 +620,57 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] { .map((p) => ({ x: p.x0, y: p.y0, w: p.x1 - p.x0, h: p.y1 - p.y0 })); } +/** + * A map's rectangles, merged row by row: a strip never spans two rows – its top and bottom name the + * row – so the runs of a row follow from that row and the two around it alone. A patched list of + * blocks (#348) joins only the rows near its patch again and stacks the rest as they were. + */ +interface MergedRows { + runs: ReadonlyMap; + rects: readonly CellRect[]; +} + +const mergedOf = new WeakMap(); + +/** + * How a patched list of blocks came from the one before: the cells worked out again, what blocks + * there now, and the rows whose runs that may change. + */ +interface BlockPatch { + from: readonly CellBlock[]; + rows: readonly number[]; + indices: readonly number[]; + columns: number; + made: ReadonlyMap; +} + +const patchOf = new WeakMap(); + +/** The index of the first block in row `y` or below; the blocks are in cell order. */ +function firstInRow(blocks: readonly CellBlock[], y: number): number { + let low = 0; + let high = blocks.length; + while (low < high) { + const mid = (low + high) >> 1; + if (blocks[mid]!.y < y) low = mid + 1; + else high = mid; + } + return low; +} + +function rowRuns(blocks: readonly CellBlock[], y: number): Piece[] { + const near = [new Set(), new Set(), new Set()]; + const row: Required[] = []; + for (let i = firstInRow(blocks, y - 1); i < blocks.length && blocks[i]!.y <= y + 1; i++) { + const { x, y: at, box } = blocks[i]!; + if (box === null) continue; + near[at - y + 1]!.add(x); + if (at === y) row.push({ x, y, scale: box }); + } + const blocksAt = (x: number, at: number) => near[at - y + 1]?.has(x) ?? false; + return joinedStrips(row.flatMap((cell) => cellPieces(cell, blocksAt))); +} + /** The blocking rectangles of a map, in cells. */ export function solidRects( project: Project, @@ -546,10 +683,27 @@ export function solidRects( } function rectCells(blocks: readonly CellBlock[]): readonly CellRect[] { - return derived(rectsOf, blocks, (cells) => mergeCells(boxCells(cells))); + let merged = mergedOf.get(blocks); + if (!merged) { + const patch = patchOf.get(blocks); + const before = patch && mergedOf.get(patch.from); + const runs = new Map(before?.runs); + const rows = before + ? patch.rows + : new Set(blocks.filter((cell) => cell.box !== null).map((cell) => cell.y)); + for (const y of rows) { + const found = rowRuns(blocks, y); + if (found.length > 0) runs.set(y, found); + else runs.delete(y); + } + const order = [...runs.keys()].sort((a, b) => a - b); + merged = { runs, rects: stacked(order.flatMap((y) => runs.get(y)!)) }; + mergedOf.set(blocks, merged); + } + return merged.rects; } -/** The trigger tiles painted on the map (#160), each once, in the order the layers first name them. */ +/** The trigger tiles painted on the map (#160), each once: layer by layer, by id within one. */ function triggerTiles(project: Project, asset: TilemapAsset): TileAsset[] { const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells))); return [...ids].flatMap((id) => { diff --git a/src/core/model/tilemapMemo.ts b/src/core/model/tilemapMemo.ts index 38c61ed3..3bc64d40 100644 --- a/src/core/model/tilemapMemo.ts +++ b/src/core/model/tilemapMemo.ts @@ -8,52 +8,115 @@ * with what it saw last and hands out a new number only when it differs – a number that stands for * exactly that content in a cache key. Without that word nothing is compared: a map is asked some * sixty times a frame, and walking every cell each time was the largest single cost of a run (#284). + * + * Each new version also keeps which cells it changed, so whoever worked out the one before can + * patch those cells instead of the whole map (#348, `cellsChangedSince`). */ interface Seen { - keys: string[]; - values: string[]; + /** The content as it was last looked at. */ + held: Map; + /** How many cells name each tile. */ + counts: Map; version: number; tileIds: string[]; - /** Written to since it was looked at. */ - stale: boolean; + /** Written to since it was looked at: the keys the painter named, or every key when it named none. */ + stale: Set | 'all' | null; + /** The cells each of the last versions changed, oldest first. */ + log: { from: number; to: number; keys: string[] }[]; } +/** How many versions back a change can be asked for; further back is worked out afresh. */ +const LOG_LENGTH = 16; + const seen = new WeakMap, Seen>(); let versions = 0; +function count(counts: Map, tileId: string | undefined, by: number): boolean { + if (!tileId) return false; + const now = (counts.get(tileId) ?? 0) + by; + if (now > 0) counts.set(tileId, now); + else counts.delete(tileId); + return now === 0 || now === by; +} + /** - * 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. + * Sorted, so the same content gives the same list whichever way it was painted: the order of an + * object's keys follows its history. */ -function unchanged(cells: Record, held: Seen): boolean { - let i = 0; +function idsOf(counts: Map): string[] { + return [...counts.keys()].sort(); +} + +/** The keys whose tile differs from what was held: the ones named, or every key of both. */ +function differing(cells: Record, held: Seen): string[] { + const own = (key: string) => (Object.hasOwn(cells, key) ? cells[key] : undefined); + if (held.stale !== 'all') return [...held.stale!].filter((key) => held.held.get(key) !== own(key)); + const keys: string[] = []; + let kept = 0; for (const key in cells) { - if (held.keys[i] !== key || held.values[i] !== cells[key]) return false; - i++; + const before = held.held.get(key); + if (before === undefined) keys.push(key); + else { + kept++; + if (before !== cells[key]) keys.push(key); + } } - return i === held.keys.length; + if (kept < held.held.size) + for (const key of held.held.keys()) if (!Object.hasOwn(cells, key)) keys.push(key); + return keys; } function look(cells: Record): Seen { const held = seen.get(cells); if (held && !held.stale) return held; - if (held && unchanged(cells, held)) { - held.stale = false; + if (held) { + const keys = differing(cells, held); + held.stale = null; + if (keys.length === 0) return held; + let idsChanged = false; + for (const key of keys) { + const now = Object.hasOwn(cells, key) ? cells[key] : undefined; + idsChanged = count(held.counts, held.held.get(key), -1) || idsChanged; + idsChanged = count(held.counts, now, 1) || idsChanged; + if (now === undefined) held.held.delete(key); + else held.held.set(key, now); + } + if (idsChanged) held.tileIds = idsOf(held.counts); + const to = ++versions; + held.log.push({ from: held.version, to, keys }); + if (held.log.length > LOG_LENGTH) held.log.shift(); + held.version = to; return held; } - const keys = Object.keys(cells); - const values = keys.map((key) => cells[key]!); - const tileIds = [...new Set(values)].filter(Boolean); - const fresh = { keys, values, version: ++versions, tileIds, stale: false }; + const content = new Map(Object.entries(cells)); + const counts = new Map(); + for (const tileId of content.values()) count(counts, tileId, 1); + const fresh: Seen = { + held: content, + counts, + version: ++versions, + tileIds: idsOf(counts), + stale: null, + log: [], + }; seen.set(cells, fresh); return fresh; } -/** Says that cells were written to in place; a replaced object needs no word, it is a new one. */ -export function cellsChanged(cells: Record): void { +/** + * Says that cells were written to in place; a replaced object needs no word, it is a new one. + * Naming the keys written saves walking all of them. + */ +export function cellsChanged(cells: Record, keys?: Iterable): void { const held = seen.get(cells); - if (held) held.stale = true; + if (!held || held.stale === 'all') return; + if (!keys) { + held.stale = 'all'; + return; + } + held.stale ??= new Set(); + for (const key of keys) held.stale.add(key); } /** A number that is the same exactly as long as the cells hold the same tiles in the same places. */ @@ -61,11 +124,25 @@ export function cellsVersion(cells: Record): number { return look(cells).version; } -/** The tiles the cells name, each once. */ +/** The tiles the cells name, each once, sorted. */ export function cellsTileIds(cells: Record): readonly string[] { return look(cells).tileIds; } +/** + * The keys whose tile changed since `version` of these cells, each once – none when it is the + * current one. Null when that is not known: another object's version, or one too far back. + */ +export function cellsChangedSince(cells: Record, version: number): readonly string[] | null { + const held = look(cells); + if (version === held.version) return []; + const start = held.log.findIndex((entry) => entry.from === version); + if (start < 0) return null; + const keys = new Set(); + for (const entry of held.log.slice(start)) for (const key of entry.keys) keys.add(key); + return [...keys]; +} + const tokens = new WeakMap(); let tokenCount = 0; diff --git a/src/editor/viewport/tilemapPainting.ts b/src/editor/viewport/tilemapPainting.ts index 12174fae..b50f1a42 100644 --- a/src/editor/viewport/tilemapPainting.ts +++ b/src/editor/viewport/tilemapPainting.ts @@ -200,7 +200,7 @@ export class TilemapPainter { const stroke = this.stroke; const layer = stroke && this.strokeLayer(stroke); if (!stroke || !layer) return; - let changed = false; + const changed: string[] = []; for (const at of cells) { if (!inGrid(item.props, at.x, at.y)) continue; const key = cellKey(at.x, at.y); @@ -209,10 +209,10 @@ export class TilemapPainter { stroke.edits.push({ x: at.x, y: at.y, tileId: stroke.tileId }); if (stroke.tileId) layer.cells[key] = stroke.tileId; else delete layer.cells[key]; - changed = true; + changed.push(key); } - if (!changed) return; - cellsChanged(layer.cells); + if (changed.length === 0) return; + cellsChanged(layer.cells, changed); this.perFrame.request(); } @@ -229,7 +229,7 @@ export class TilemapPainter { if (value) layer.cells[key] = value; else delete layer.cells[key]; } - cellsChanged(layer.cells); + cellsChanged(layer.cells, stroke.previous.keys()); } const item = this.host.items().find((i) => i.id === stroke.objectId); const edits = diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 8eab5c0f..f5adab54 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -220,11 +220,11 @@ function resolveTilemap( if (!isLayerVisible(layer)) continue; const tiles = layerTiles(project, props, layer); layers.push({ id: layer.id, z: layerZ(layer), sortByY: layer.sortByY === true, tiles }); - for (const tile of tiles) { - if (used.has(tile.tileId)) continue; - const source = findAssetOf(project, tile.tileId, 'tile'); + for (const tileId of tileIdsIn(tiles)) { + if (used.has(tileId)) continue; + const source = findAssetOf(project, tileId, 'tile'); if (!source) continue; - used.set(tile.tileId, { + used.set(tileId, { info: readTile(project, source), playback: tilePlayback(source), fps: tileFps(source), @@ -241,6 +241,15 @@ function resolveTilemap( }; } +const idsOfTiles = new WeakMap(); + +/** The tiles a layer draws, each once, in the order they first appear – read once per list, not per frame. */ +function tileIdsIn(tiles: readonly TileDraw[]): readonly string[] { + let ids = idsOfTiles.get(tiles); + if (!ids) idsOfTiles.set(tiles, (ids = [...new Set(tiles.map((tile) => tile.tileId))])); + return ids; +} + /** Both kinds of object that name a picture in `texture`: the sprite that wears it and the source * that throws it. */ function wearsPicture(type: AssetType): boolean { diff --git a/tests/core/tilemap-patch.test.ts b/tests/core/tilemap-patch.test.ts new file mode 100644 index 00000000..75de4128 --- /dev/null +++ b/tests/core/tilemap-patch.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } 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, TilemapLayer } from '../../src/core/model/Asset'; +import { findAsset } from '../../src/core/model/Project'; +import type { PropertyValue } from '../../src/core/model/Properties'; +import { + TILE_COLLISION_SCALE_KEY, + TILE_FRICTION_KEY, + TILE_KIND_KEY, + TILE_ONE_WAY_ANGLE_KEY, + TILE_ONE_WAY_KEY, + TILE_SOLID_KEY, + TILE_TRIGGER_KEY, +} from '../../src/core/model/tile'; +import { + cellKey, + cellsAround, + layerTiles, + patchedInOrder, + TILEMAP_COLUMNS_KEY, + TILEMAP_ROWS_KEY, + TILEMAP_TILE_KEY, +} from '../../src/core/model/tilemap'; +import { + oneWayAngles, + solidPolygons, + solidRects, + tileMaterials, + triggerCells, + triggerPolygons, + triggerRects, +} from '../../src/core/model/tilemapCollision'; +import { + cellsChanged, + cellsChangedSince, + cellsTileIds, + cellsVersion, +} from '../../src/core/model/tilemapMemo'; +import { makeSpriteScene } from '../helpers'; + +const COLUMNS = 12; +const ROWS = 9; + +/** + * A 12 × 9 map of two layers and a tile of every kind that works out differently: plain and + * autotiles, with and without outlines, drawn in and out, one-way at two angles, of another + * surface, triggers, one that draws only – and an id no tile has. + */ +function patchMap() { + const f = makeSpriteScene('Thing'); + const texture = new CreateAssetCommand('texture', 'Sheet'); + f.stack.execute(texture); + const sheet = findAsset(f.project, texture.asset.id) as TextureAsset; + sheet.file = `${'a'.repeat(64)}.png`; + sheet.width = 128; + sheet.height = 128; + const frame = (x: number, y: number, w: number, h: number) => { + const command = new AddFrameCommand(texture.asset.id, { x, y, w, h }); + f.stack.execute(command); + return command.frameId; + }; + const single = frame(0, 0, 32, 32); + const outlined = frame(0, 32, 64, 96); + const bare = frame(64, 32, 64, 96); + f.stack.execute( + new SetFrameCollisionCommand(texture.asset.id, single, [ + [ + { x: 0, y: 0 }, + { x: 32, y: 0 }, + { x: 0, y: 32 }, + ], + ]), + ); + f.stack.execute( + new SetFrameCollisionCommand(texture.asset.id, outlined, [ + [ + { x: 0, y: 0 }, + { x: 64, y: 0 }, + { x: 0, y: 96 }, + ], + ]), + ); + const tile = (name: string, props: Record, picture?: string) => { + const command = new CreateAssetCommand('tile', name); + f.stack.execute(command); + const id = command.asset.id; + if (picture) f.stack.execute(new AddTilePictureCommand(id, picture)); + f.stack.execute(new SetAssetPropertyCommand(id, TILE_KIND_KEY, 'none')); + for (const [key, value] of Object.entries(props)) + f.stack.execute(new SetAssetPropertyCommand(id, key, value)); + return id; + }; + const solid = { [TILE_SOLID_KEY]: true }; + const floor = { [TILE_KIND_KEY]: 'floor' }; + const tiles = [ + tile('Slope', solid, single), + tile('Lake', { ...solid, ...floor }, outlined), + tile('Grass', { ...solid, ...floor, [TILE_COLLISION_SCALE_KEY]: 0.5 }, bare), + tile('Wall', solid), + tile('Bush', { ...solid, [TILE_COLLISION_SCALE_KEY]: 0.5 }), + tile('Big', { ...solid, [TILE_COLLISION_SCALE_KEY]: 1.5 }), + tile('Ledge', { ...solid, [TILE_ONE_WAY_KEY]: true, [TILE_ONE_WAY_ANGLE_KEY]: 30 }), + tile('Steep', { ...solid, [TILE_ONE_WAY_KEY]: true, [TILE_ONE_WAY_ANGLE_KEY]: 60 }, bare), + tile('Ice', { ...solid, [TILE_FRICTION_KEY]: 0 }), + tile('Water', { [TILE_TRIGGER_KEY]: true, ...floor }, bare), + tile('Spikes', { [TILE_TRIGGER_KEY]: true }, single), + tile('Deco', floor, bare), + 'no-such-tile', + ]; + const map = new CreateAssetCommand('tilemap', 'Ground'); + f.stack.execute(map); + for (const [key, value] of [ + [TILEMAP_COLUMNS_KEY, COLUMNS], + [TILEMAP_ROWS_KEY, ROWS], + [TILEMAP_TILE_KEY, 32], + ] as const) + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, key, value)); + const asset = findAsset(f.project, map.asset.id) as TilemapAsset; + asset.layers.push({ id: 'upper', name: 'Upper', cells: {} }); + return { ...f, asset, tiles }; +} + +/** A small seeded generator, so a failing walk can be walked again. */ +function random(seed: number): () => number { + return () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Everything a map works out, as plain data: tiles per layer, solids per filter, triggers. */ +function workedOut(m: ReturnType, asset: TilemapAsset) { + const props = m.asset.props; + const solidsOf = (of: TilemapAsset) => ({ + rects: solidRects(m.project, props, of), + polygons: solidPolygons(m.project, props, of), + oneWay: oneWayAngles(m.project, of).map((angle) => ({ + angle, + rects: solidRects(m.project, props, of, angle), + polygons: solidPolygons(m.project, props, of, angle), + })), + materials: tileMaterials(m.project, of).map((material) => ({ + material, + rects: solidRects(m.project, props, of, false, material), + polygons: solidPolygons(m.project, props, of, false, material), + })), + triggerRects: triggerRects(m.project, props, of), + triggerPolygons: triggerPolygons(m.project, props, of), + triggerCells: [...triggerCells(m.project, props, of)], + }); + return { + tiles: asset.layers.map((layer) => layerTiles(m.project, props, layer)), + whole: solidsOf(asset), + perLayer: asset.layers.map((layer) => solidsOf({ ...asset, layers: [layer] })), + }; +} + +/** The same map on cells never seen before, so nothing of it is patched. */ +function afresh(asset: TilemapAsset): TilemapAsset { + return { ...asset, layers: asset.layers.map((layer) => ({ ...layer, cells: { ...layer.cells } })) }; +} + +describe('a painted map works out only the cells around what changed (#348)', () => { + it('knows which cells changed since a version, and when it does not know', () => { + const cells: Record = { '0,0': 'a', '1,0': 'b' }; + const first = cellsVersion(cells); + expect(cellsChangedSince(cells, first)).toEqual([]); + cells['2,0'] = 'a'; + cellsChanged(cells, ['2,0']); + const second = cellsVersion(cells); + delete cells['0,0']; + cells['1,0'] = 'c'; + // Named and not, the same: one walk finds what the painter left out. + cellsChanged(cells); + expect([...cellsChangedSince(cells, first)!].sort()).toEqual(['0,0', '1,0', '2,0']); + expect([...cellsChangedSince(cells, second)!].sort()).toEqual(['0,0', '1,0']); + expect(cellsTileIds(cells)).toEqual(['a', 'c']); + expect(cellsChangedSince(cells, -1)).toBeNull(); + expect(cellsChangedSince({ ...cells }, second)).toBeNull(); + }); + + it('a key named but written back as it was is no change', () => { + const cells: Record = { '0,0': 'a' }; + const first = cellsVersion(cells); + cells['0,0'] = 'b'; + cells['0,0'] = 'a'; + cellsChanged(cells, ['0,0', '5,5']); + expect(cellsVersion(cells)).toBe(first); + }); + + it('the cells around a change stay in the grid', () => { + expect(cellsAround(['0,0'], 3, 2)).toEqual([0, 1, 3, 4]); + expect(cellsAround(['2,1', '1,1'], 3, 2)).toEqual([0, 1, 2, 3, 4, 5]); + expect(cellsAround(['9,9', 'nonsense'], 3, 2)).toEqual([]); + }); + + it('a patch replaces the entries of the cells it works out, keeps the rest and their order', () => { + const entry = (at: number, name: string) => ({ at, name }); + const list = [entry(1, 'a'), entry(3, 'b'), entry(3, 'c'), entry(7, 'd')]; + const made: Record[]> = { + 0: [entry(0, 'new')], + 3: [], + 5: [entry(5, 'x'), entry(5, 'y')], + }; + const patched = patchedInOrder( + list, + (e) => e.at, + [0, 3, 5], + (at) => made[at]!, + ); + expect(patched.map((e) => e.name)).toEqual(['new', 'a', 'x', 'y', 'd']); + expect(patched[1]).toBe(list[0]); + }); + + it('patched and worked out afresh agree after every step of a long walk of painting', () => { + const m = patchMap(); + const next = random(348); + const pick = (list: readonly T[]) => list[Math.floor(next() * list.length)]!; + const somewhere = () => ({ + x: Math.floor(next() * (COLUMNS + 2)) - 1, + y: Math.floor(next() * (ROWS + 2)) - 1, + }); + const layerOf = (): TilemapLayer => pick(m.asset.layers); + const counts = { paint: 0, undo: 0, named: 0, unnamed: 0 }; + workedOut(m, m.asset); + for (let step = 0; step < 400; step++) { + const roll = next(); + if (roll < 0.5) { + const layer = layerOf(); + const edits = Array.from({ length: 1 + Math.floor(next() * 4) }, () => { + const at = somewhere(); + return { ...at, tileId: next() < 0.15 ? '' : pick(m.tiles) }; + }); + m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, edits)); + counts.paint++; + } else if (roll < 0.65 && m.stack.canUndo) { + m.stack.undo(); + counts.undo++; + } else { + // In place, as a stroke paints before it is a command: with its keys, or without. + const layer = layerOf(); + const keys = Array.from({ length: 1 + Math.floor(next() * 3) }, () => { + const at = somewhere(); + const key = cellKey(at.x, at.y); + const tileId = next() < 0.3 ? '' : pick(m.tiles); + if (tileId) layer.cells[key] = tileId; + else delete layer.cells[key]; + return key; + }); + const named = next() < 0.5; + cellsChanged(layer.cells, named ? keys : undefined); + counts[named ? 'named' : 'unnamed']++; + } + const patched = workedOut(m, m.asset); + const fresh = workedOut(m, afresh(m.asset)); + expect(patched, `step ${step}`).toEqual(fresh); + } + // The walk went everywhere it should: enough of each step, and maps that block in every way. + expect(Math.min(...Object.values(counts))).toBeGreaterThan(20); + const end = workedOut(m, m.asset); + expect(end.whole.rects.length).toBeGreaterThan(5); + expect(end.whole.polygons.length).toBeGreaterThan(5); + expect(end.whole.oneWay).toHaveLength(2); + expect(end.whole.triggerCells.length).toBeGreaterThan(3); + }); + + it('a cell two rows off is cut again when the autotile between turns into a box', () => { + const m = patchMap(); + const [, lake, , , bush, big] = m.tiles; + const layer = m.asset.layers[0]!; + // Found by a search: erasing (3,1) makes the lake at (3,2) block whole, so the big box at (3,3) + // – drawn out on every side without a blocking neighbour – no longer grows upwards. + const rows = [ + [bush, lake, lake, lake], + [lake, '', lake, lake], + ['', lake, lake, lake], + [lake, '', lake, big], + ]; + const edits = rows.flatMap((row, y) => row.map((tileId, x) => ({ x, y, tileId: tileId! }))); + m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, edits)); + solidRects(m.project, m.asset.props, m.asset); + m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 3, y: 1, tileId: '' }])); + expect(solidRects(m.project, m.asset.props, m.asset)).toEqual( + solidRects(m.project, m.asset.props, afresh(m.asset)), + ); + }); + + it('a painted cell leaves what is drawn and what blocks far from it the very same objects', () => { + const m = patchMap(); + const [slope, lake] = m.tiles; + const layer = m.asset.layers[0]!; + const all = Array.from({ length: COLUMNS * ROWS }, (_, i) => ({ + x: i % COLUMNS, + y: Math.floor(i / COLUMNS), + tileId: i % COLUMNS < 6 ? lake! : slope!, + })); + m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, all)); + const tiles = layerTiles(m.project, m.asset.props, layer); + const polygons = solidPolygons(m.project, m.asset.props, m.asset); + m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 2, y: 4, tileId: '' }])); + const after = layerTiles(m.project, m.asset.props, layer); + expect(after).not.toBe(tiles); + expect(after.find((t) => t.x === 10 && t.y === 0)).toBe(tiles.find((t) => t.x === 10 && t.y === 0)); + const far = (list: readonly { x: number; y: number }[]) => list.filter((p) => p.y === 8 && p.x === 0); + expect(far(solidPolygons(m.project, m.asset.props, m.asset))).toEqual(far(polygons)); + expect(far(solidPolygons(m.project, m.asset.props, m.asset))[0]).toBe(far(polygons)[0]); + }); +});