diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index 25095240..f8738ee5 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -223,20 +223,22 @@ export function cellsAround(keys: readonly string[], columns: number, rows: numb /** * 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. + * cell's new entries, none or several, from the ones it had. 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[], + make: (index: number, before: readonly T[]) => 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++]!); + const start = i; while (i < list.length && indexOf(list[i]!) === index) i++; - for (const entry of make(index)) out.push(entry); + for (const entry of make(index, list.slice(start, i))) out.push(entry); } while (i < list.length) out.push(list[i++]!); return out; @@ -317,23 +319,115 @@ function workOutLayerTiles( return tiles; } +/** + * A cell around the painted ones that still looks the same keeps its entry, so only what is drawn + * otherwise counts as changed (#424). Nothing drawn otherwise hands back the very same list. + */ function patchedLayerTiles( props: PropertyBag, layer: TilemapLayer, kinds: Map, tiles: readonly TileDraw[], changed: readonly string[], -): TileDraw[] { +): readonly TileDraw[] { const cells = layerCells(props, layer); - return patchedInOrder( + const removed: TileDraw[] = []; + const added: TileDraw[] = []; + const patched = patchedInOrder( tiles, (tile) => tile.y * cells.columns + tile.x, cellsAround(changed, cells.columns, cells.rows), - (index) => { + (index, [held]) => { const drawn = tileDrawAt(cells, kinds, index); + if (held && drawn && sameTileDraw(held, drawn)) return [held]; + if (held) removed.push(held); + if (drawn) added.push(drawn); return drawn ? [drawn] : []; }, ); + if (removed.length === 0 && added.length === 0) return tiles; + noteTilesPatch(patched, { from: tiles, removed, added }); + return patched; +} + +function sameTileDraw(a: TileDraw, b: TileDraw): boolean { + return ( + a.x === b.x && + a.y === b.y && + a.tileId === b.tileId && + a.quadrants.every((q, i) => q.x === b.quadrants[i]!.x && q.y === b.quadrants[i]!.y) + ); +} + +/** How a list of tiles came out of the one before it (#424): the entries gone and the ones new. */ +export interface TilesPatch { + from: readonly TileDraw[]; + removed: readonly TileDraw[]; + added: readonly TileDraw[]; +} + +const patches = new WeakMap(); + +/** Null for a list worked out whole, or one whose patch was passed on to a newer list. */ +export function tilesPatch(tiles: readonly TileDraw[]): TilesPatch | null { + return patches.get(tiles) ?? null; +} + +/** One step only: a chain of patched lists would otherwise keep every list before alive. */ +function noteTilesPatch(tiles: readonly TileDraw[], patch: TilesPatch): void { + patches.delete(patch.from); + patches.set(tiles, patch); +} + +/** + * `groups` of the list `patch` came from, grouped for the list it made: a group no changed entry + * falls into stays the very same array, the others are patched in cell order and note their own + * patch. In the order `order` gives, by default the one a fresh grouping has – by first cell. + */ +export function patchedGroups( + groups: ReadonlyMap, + patch: TilesPatch, + keyOf: (tile: TileDraw) => K, + order: (a: [K, readonly TileDraw[]], b: [K, readonly TileDraw[]]) => number = ([, a], [, b]) => + byCell(a[0]!, b[0]!), +): Map { + const touched = new Map(); + const changeOf = (key: K) => { + let change = touched.get(key); + if (!change) touched.set(key, (change = { removed: [], added: [] })); + return change; + }; + for (const tile of patch.removed) changeOf(keyOf(tile)).removed.push(tile); + for (const tile of patch.added) changeOf(keyOf(tile)).added.push(tile); + const out = new Map(groups); + for (const [key, { removed, added }] of touched) { + const from = groups.get(key) ?? []; + const gone = new Set(removed); + const merged = mergedInCellOrder( + from.filter((tile) => !gone.has(tile)), + [...added].sort(byCell), + ); + if (merged.length === 0) out.delete(key); + else { + out.set(key, merged); + noteTilesPatch(merged, { from, removed, added }); + } + } + return new Map([...out].sort(order)); +} + +function byCell(a: TileDraw, b: TileDraw): number { + return a.y - b.y || a.x - b.x; +} + +function mergedInCellOrder(a: readonly TileDraw[], b: readonly TileDraw[]): TileDraw[] { + const out: TileDraw[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) out.push(byCell(a[i]!, b[j]!) <= 0 ? a[i++]! : b[j++]!); + while (i < a.length) out.push(a[i++]!); + while (j < b.length) out.push(b[j++]!); + return out; } /** diff --git a/src/viewport/drawOrder.ts b/src/viewport/drawOrder.ts index 749c2389..6bb0534f 100644 --- a/src/viewport/drawOrder.ts +++ b/src/viewport/drawOrder.ts @@ -1,6 +1,6 @@ import { isSymbolType } from '../core/model/AssetTypes'; import { sortByZ } from '../core/model/Properties'; -import { cellOrigin, type TileDraw, tilemapTileSize } from '../core/model/tilemap'; +import { cellOrigin, patchedGroups, type TileDraw, tilemapTileSize, tilesPatch } from '../core/model/tilemap'; import { footprintExtent } from '../core/properties/bounds'; import { SORT_WITH_PARENT_KEY } from '../core/properties/PropertySchema'; import type { DrawItem, TilemapDraw, TilemapLayerDraw } from './items'; @@ -36,11 +36,24 @@ const rowsOf = new WeakMap `${id}:${tile.sortOffset}`).join(','); const held = rowsOf.get(layer.tiles); if (held?.offsets === offsets) return held.rows; + const patch = tilesPatch(layer.tiles); + const before = patch && rowsOf.get(patch.from); + if (patch && before?.offsets === offsets) { + const rows = patchedGroups( + before.rows, + patch, + (tile) => sortRowOf(draw, tile), + ([a], [b]) => a - b, + ); + rowsOf.set(layer.tiles, { offsets, rows }); + return rows; + } const grouped = new Map(); for (const tile of layer.tiles) { const row = sortRowOf(draw, tile); diff --git a/src/viewport/tilemapMesh.ts b/src/viewport/tilemapMesh.ts index 56847636..ad369340 100644 --- a/src/viewport/tilemapMesh.ts +++ b/src/viewport/tilemapMesh.ts @@ -5,7 +5,7 @@ * checked without a browser. */ import type { FrameRect } from '../core/model/texture'; -import type { TileDraw } from '../core/model/tilemap'; +import { patchedGroups, type TileDraw, tilesPatch } from '../core/model/tilemap'; /** Where a tile's quarters are cut from, in the pixels of the texture the mesh wears. */ export interface TileSheet { @@ -95,11 +95,19 @@ const grouped = new WeakMap { const held = grouped.get(tiles); if (held) return held; + const patch = tilesPatch(tiles); + const before = patch && grouped.get(patch.from); + if (before) { + const groups = patchedGroups(before, patch, (tile) => tile.tileId); + grouped.set(tiles, groups); + return groups; + } const groups = new Map(); for (const drawn of tiles) { const group = groups.get(drawn.tileId); diff --git a/tests/core/tilemap-patch.test.ts b/tests/core/tilemap-patch.test.ts index 75de4128..ede66716 100644 --- a/tests/core/tilemap-patch.test.ts +++ b/tests/core/tilemap-patch.test.ts @@ -23,6 +23,7 @@ import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY, + type TileDraw, } from '../../src/core/model/tilemap'; import { oneWayAngles, @@ -39,6 +40,9 @@ import { cellsTileIds, cellsVersion, } from '../../src/core/model/tilemapMemo'; +import { layerRows } from '../../src/viewport/drawOrder'; +import type { TilemapDraw, TilemapLayerDraw } from '../../src/viewport/items'; +import { groupByTile } from '../../src/viewport/tilemapMesh'; import { makeSpriteScene } from '../helpers'; const COLUMNS = 12; @@ -160,6 +164,32 @@ function workedOut(m: ReturnType, asset: TilemapAsset) { }; } +/** + * A layer's tiles as the meshes take them (#424): grouped by tile, and by sort row and tile with + * offsets that move some tiles a row up or down. Each next to the same grouping of a copy of the + * list, which nothing was patched from; `reused` counts the groups handed on from the last call. + */ +function grouping(m: ReturnType, layer: TilemapLayer, last: Set) { + const draw = { + tiles: new Map(m.tiles.map((id, i) => [id, { sortOffset: (i % 4) - 1 }])), + } as unknown as TilemapDraw; + const tiles = layerTiles(m.project, m.asset.props, layer); + const of = (list: readonly TileDraw[]) => { + const rows = layerRows(draw, { tiles: list } as TilemapLayerDraw); + return { + byTile: [...groupByTile(list)], + byRow: [...rows].map(([row, cells]) => [row, cells, [...groupByTile(cells)]] as const), + }; + }; + const patched = of(tiles); + const groups = [ + ...patched.byTile.map(([, group]) => group), + ...patched.byRow.flatMap(([, cells, byTile]) => [cells, ...byTile.map(([, group]) => group)]), + ]; + const reused = groups.filter((group) => last.has(group)).length; + return { patched, fresh: of([...tiles]), reused, groups: new Set(groups) }; +} + /** 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 } })) }; @@ -228,6 +258,8 @@ describe('a painted map works out only the cells around what changed (#348)', () const layerOf = (): TilemapLayer => pick(m.asset.layers); const counts = { paint: 0, undo: 0, named: 0, unnamed: 0 }; workedOut(m, m.asset); + const lastGroups = m.asset.layers.map((layer) => grouping(m, layer, new Set()).groups); + let reused = 0; for (let step = 0; step < 400; step++) { const roll = next(); if (roll < 0.5) { @@ -259,9 +291,16 @@ describe('a painted map works out only the cells around what changed (#348)', () const patched = workedOut(m, m.asset); const fresh = workedOut(m, afresh(m.asset)); expect(patched, `step ${step}`).toEqual(fresh); + m.asset.layers.forEach((layer, i) => { + const groups = grouping(m, layer, lastGroups[i]!); + expect(groups.patched, `step ${step}, layer ${i}`).toEqual(groups.fresh); + reused += groups.reused; + lastGroups[i] = groups.groups; + }); } // 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); + expect(reused).toBeGreaterThan(1000); const end = workedOut(m, m.asset); expect(end.whole.rects.length).toBeGreaterThan(5); expect(end.whole.polygons.length).toBeGreaterThan(5); @@ -310,4 +349,39 @@ describe('a painted map works out only the cells around what changed (#348)', () 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]); }); + + it('a painted cell regroups only the tiles and rows it touches, so only their meshes are built (#424)', () => { + const m = patchMap(); + const [slope, lake, , wall] = 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!, + })); + // A wall in a corner already: a tile new to the layer would work all of it out again (#348). + all[all.length - 1]!.tileId = wall!; + m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, all)); + const draw = { tiles: new Map(m.tiles.map((id) => [id, { sortOffset: 0 }])) } as unknown as TilemapDraw; + const tiles = layerTiles(m.project, m.asset.props, layer); + const groups = groupByTile(tiles); + const rows = layerRows(draw, { tiles } as TilemapLayerDraw); + // A wall on the slopes, away from the lake: the lake looks the same, so its group stays. + m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 10, y: 4, tileId: wall! }])); + const after = layerTiles(m.project, m.asset.props, layer); + const regrouped = groupByTile(after); + expect(regrouped.get(lake!)).toBe(groups.get(lake!)); + expect(regrouped.get(slope!)).not.toBe(groups.get(slope!)); + expect(regrouped.get(wall!)!.map((t) => [t.x, t.y])).toEqual([ + [10, 4], + [11, 8], + ]); + const rowsAfter = layerRows(draw, { tiles: after } as TilemapLayerDraw); + expect([...rowsAfter.keys()].filter((row) => rowsAfter.get(row) !== rows.get(row))).toEqual([4]); + // And back to a slope, a patch on a patch: still the same lake, and the same as grouped afresh. + m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 10, y: 4, tileId: slope! }])); + const back = layerTiles(m.project, m.asset.props, layer); + expect(groupByTile(back).get(lake!)).toBe(groups.get(lake!)); + expect([...groupByTile(back)]).toEqual([...groupByTile([...back])]); + }); });