Work out only the cells around a painted one on a tilemap (#348)
A painted cell worked out the tiles and blocking cells of the whole map again, so painting got slower the bigger the map. One cell cost 16.8 ms on 60x60 and 54.6 ms on 120x120. Now it costs what is around it: 3.5 ms and 4.0 ms, against 3.4 ms without painting. - The brush, the paint command and its undo name the keys they changed. Each new version of the cells keeps them for the last 16 versions (cellsChangedSince). - The cell index, the tiles of a layer and the blocking cells patch only the changed cells and their eight neighbours. Everything else stays the same object as before. - Both lists are in cell order now, so a patch is one walk that keeps every other entry. - The rectangles are merged row by row. A patch joins again only the rows around it, one row further than the changed cells, and stacks the rest as they were. - A writer that names no keys costs one walk over the cells. A replaced cell object (load, resize, edits at run time) is worked out in full as before. - The tiles a map names come sorted by id. Two trigger tiles on one cell now keep that order, whichever way they were painted. - resolveTilemap walked all tiles every frame, even without painting. It now does so only when the list changed. Found on the way: deleting an outlined autotile can turn the one below into a full box, which cuts a scaled box two rows further down. Merging only the patched rows missed that. It is a test of its own now. A brush stroke on the map doubled to 120x120 runs at 42 fps instead of 28, p95 of a frame 52 ms instead of 92 ms. What still grows with the map is the mesh of a layer, which is built again on every paint. Tests: - tests/core/tilemap-patch.test.ts (new, 7): 400 random steps on two layers with every kind of tile, each compared with a fresh full run - Unit, tilemap and viewport: 1284 green in 57 files - e2e 39 + 40 (atx): ALL PASSED, 107 checks - Gate (atx): 312 files, 4831 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a49392d7f
commit
88e1cf1c7d
7 files changed
+747
-86
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+128
-20
@@ -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<number, string>;
|
||||
}
|
||||
|
||||
const indexed = new WeakMap<Record<string, string>, 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<number, string>();
|
||||
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<Record<string, string>, 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<number, string>();
|
||||
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<number>();
|
||||
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<T>(
|
||||
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<readonly TileDraw[]>();
|
||||
const lastDrawn = new KeyedMemo<{ version: number; tiles: readonly TileDraw[] | null }>();
|
||||
|
||||
function tileDrawAt(cells: LayerCells, kinds: Map<string, TileKind>, 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<string, TileKind>,
|
||||
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.
|
||||
|
||||
@@ -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,34 +317,48 @@ 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;
|
||||
});
|
||||
}
|
||||
|
||||
function workOutBlockingCells(
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
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<string, BlockingTile>,
|
||||
): CellBlock[] {
|
||||
const found = new Map<number, CellBlock>();
|
||||
for (const layer of asset.layers) {
|
||||
const cells = layerCells(props, layer);
|
||||
for (const { x, y, tileId } of cells.list) {
|
||||
const tile = tiles.get(tileId);
|
||||
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;
|
||||
const index = y * cells.columns + x;
|
||||
let cell = found.get(index);
|
||||
if (!cell) found.set(index, (cell = { x, y, box: null, polygons: [] }));
|
||||
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);
|
||||
@@ -367,8 +384,56 @@ function workOutBlockingCells(
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** In cell order, whichever layers block there. */
|
||||
function workOutBlockingCells(
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
tiles: Map<string, BlockingTile>,
|
||||
): CellBlock[] {
|
||||
const layers = asset.layers.map((layer) => layerCells(props, layer));
|
||||
const indices = new Set<number>();
|
||||
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<string, BlockingTile>,
|
||||
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<number, CellBlock>();
|
||||
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<number>();
|
||||
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<T>(
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -496,15 +575,18 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] {
|
||||
const unique = new Map<string, SolidCell>();
|
||||
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<string, Piece[]>();
|
||||
for (const cell of unique.values()) {
|
||||
for (const piece of cellPieces(cell, blocks)) {
|
||||
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()) {
|
||||
strip.sort((a, b) => a.x0 - b.x0);
|
||||
@@ -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<string, Piece>();
|
||||
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<number, readonly Piece[]>;
|
||||
rects: readonly CellRect[];
|
||||
}
|
||||
|
||||
const mergedOf = new WeakMap<readonly CellBlock[], MergedRows>();
|
||||
|
||||
/**
|
||||
* 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<number, CellBlock>;
|
||||
}
|
||||
|
||||
const patchOf = new WeakMap<readonly CellBlock[], BlockPatch>();
|
||||
|
||||
/** 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<number>(), new Set<number>(), new Set<number>()];
|
||||
const row: Required<SolidCell>[] = [];
|
||||
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) => {
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** How many cells name each tile. */
|
||||
counts: Map<string, number>;
|
||||
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<string> | '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<Record<string, string>, Seen>();
|
||||
let versions = 0;
|
||||
|
||||
function count(counts: Map<string, number>, 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<string, string>, held: Seen): boolean {
|
||||
let i = 0;
|
||||
function idsOf(counts: Map<string, number>): 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<string, string>, 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<string, string>): 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<string, number>();
|
||||
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<string, string>): 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<string, string>, keys?: Iterable<string>): 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<string, string>): 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<string, string>): 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<string, string>, 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<string>();
|
||||
for (const entry of held.log.slice(start)) for (const key of entry.keys) keys.add(key);
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
const tokens = new WeakMap<object, number>();
|
||||
let tokenCount = 0;
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+13
-4
@@ -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<readonly TileDraw[], readonly string[]>();
|
||||
|
||||
/** 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 {
|
||||
|
||||
@@ -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<string, PropertyValue>, 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<typeof patchMap>, 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<string, string> = { '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<string, string> = { '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<number, ReturnType<typeof entry>[]> = {
|
||||
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 = <T>(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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user