Painting on the 60x60 map of a project with fifty objects dropped the editor to 20 to 39 fps. A profile of a brush stroke showed four causes, none of them the brush itself. - Each frame of a stroke previews the model. The asset and hierarchy panels then built every row anew, so every label became a new text and a new upload to the GPU. One run spent 46 % of the main thread in those uploads. ListView.setItems now keeps a row whose item draws the same (sameListItem) and builds only the rows that changed. - The brush drew the whole scene on every pointer event, on top of the preview. Now the preview draws what the stroke changed, once a frame. The rectangle tool asks for one drawing a frame for its box. - An animated tile made the view draw in the tick and the preview drew it again in the same frame. animate now asks just before the frame is drawn and stays out when the view was drawn since the tick began. - layoutSymbols ran on every drawing and costs up to the cube of the symbols. SymbolLayoutMemo keeps the spots while no anchor moves. - A painted cell made the map read every "x,y" key again, and once more for each of the eight neighbours of every cell. layerCells reads a layer once per content into an index by place. layerTiles and the blocking cells take their neighbours from there (neighboursIn). parseCellKey reads a key without split, with the same results. The test hook sceneRedraws counts how often the scene view was drawn. Not done: a painted cell still works out the tiles and blocking cells of the whole map again, so that part grows with the map. Tests: - Brush stroke on a copy of the project (stein2, Chrome): 20 to 39 fps before, 47.5 after, 55 to 57 without painting. Labels drawn again during the stroke 2012 before, none after. Scene drawings per frame 1.32 before, 1 after. - One painted cell, headless: 28 to 16 ms on 60x60, 109 to 49 ms on the same map doubled to 120x120. - frame-preview: 2 new, red with the old pointer redraw. - tilemap: 3 new (key reading, index, refresh), list-ping 1 new, symbol-layout 1 new. 5 single reversals turned each red. - e2e 39,40 (melektaus-atx): ALL PASSED, 107 checks (1 new, a fast stroke: 6 drawings in 6 frames, no label drawn again). With the row reuse and the pointer redraw reverted it fails: 24 drawings in 3 frames, and the panels draw their labels again. - Gate (melektaus-atx): CHECK EXIT 0, 260 files, 4038 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
/**
|
||
* A tilemap is a grid of cells, and every cell names the tile lying there. Which of that tile's
|
||
* quarters a cell shows is *not* stored: it follows from the neighbours and is worked out
|
||
* while drawing (#128), so painting one cell fixes the edges of the ones around it by itself.
|
||
*
|
||
* Cells are sparse – a map from `"x,y"` to a tile id – rather than one entry per grid square.
|
||
* A map is what painting, erasing and reading a neighbour all want, and changing the grid size then
|
||
* moves nothing: what falls outside is simply not drawn and comes back when the grid grows again.
|
||
* The project file stores them more compactly (#134, `store/tilemapCells.ts`).
|
||
*/
|
||
import type { TileAsset, TilemapLayer } from './Asset';
|
||
import { type Neighbours, type TileQuadrants, tileQuadrants } from './autotile';
|
||
import { type TileKind, tileKind } from './tile';
|
||
import type { PropertyBag } from './Properties';
|
||
import { findAssetOf, type Project } from './Project';
|
||
import { cellsTileIds, cellsVersion, KeyedMemo } from './tilemapMemo';
|
||
|
||
export const TILEMAP_COLUMNS_KEY = 'columns';
|
||
export const TILEMAP_ROWS_KEY = 'rows';
|
||
export const TILEMAP_TILE_KEY = 'tileSize';
|
||
/** The most columns or rows a grid may have – the schema's limit, and the edge buttons' too. */
|
||
export const TILEMAP_MAX_CELLS = 1000;
|
||
|
||
/**
|
||
* The grid is read from a property bag rather than from the asset: a placed tilemap may override
|
||
* its size like any other row, and the footprint the scene view picks and frames by has nothing
|
||
* but the bag (see `properties/bounds.ts`).
|
||
*/
|
||
export function tilemapColumns(props: PropertyBag): number {
|
||
return wholeAbove(props[TILEMAP_COLUMNS_KEY], 20);
|
||
}
|
||
|
||
export function tilemapRows(props: PropertyBag): number {
|
||
return wholeAbove(props[TILEMAP_ROWS_KEY], 15);
|
||
}
|
||
|
||
/** Side of one cell in world units; the tile's own pictures are scaled onto it. */
|
||
export function tilemapTileSize(props: PropertyBag): number {
|
||
const size = Number(props[TILEMAP_TILE_KEY]);
|
||
return Number.isFinite(size) && size > 0 ? size : 48;
|
||
}
|
||
|
||
function wholeAbove(value: unknown, fallback: number): number {
|
||
const count = Math.floor(Number(value));
|
||
return Number.isFinite(count) && count >= 1 ? count : fallback;
|
||
}
|
||
|
||
export function tilemapSize(props: PropertyBag): { w: number; h: number } {
|
||
const tile = tilemapTileSize(props);
|
||
return { w: tilemapColumns(props) * tile, h: tilemapRows(props) * tile };
|
||
}
|
||
|
||
/** A cell of the grid by column and row; `0,0` is the top-left corner. */
|
||
export interface Cell {
|
||
x: number;
|
||
y: number;
|
||
}
|
||
|
||
export function cellKey(x: number, y: number): string {
|
||
return `${x},${y}`;
|
||
}
|
||
|
||
/** Read without `split`: a map reads every key of a layer again after each painted cell (#347). */
|
||
export function parseCellKey(key: string): Cell | null {
|
||
const comma = key.indexOf(',');
|
||
if (comma < 0) return null;
|
||
const end = key.indexOf(',', comma + 1);
|
||
const x = Number(key.slice(0, comma));
|
||
const y = Number(end < 0 ? key.slice(comma + 1) : key.slice(comma + 1, end));
|
||
return Number.isInteger(x) && Number.isInteger(y) ? { x, y } : null;
|
||
}
|
||
|
||
export function cellAt(layer: TilemapLayer, x: number, y: number): string {
|
||
return layer.cells[cellKey(x, y)] ?? '';
|
||
}
|
||
|
||
/**
|
||
* The tile a cell holds as far as anything but the file is concerned: `''` for an empty cell and for
|
||
* one naming a tile the project does not have (#268). Such a cell draws and blocks nothing, so it
|
||
* reads and fills as empty too.
|
||
*/
|
||
export function presentTile(project: Project, tileId: string): string {
|
||
return tileId !== '' && findAssetOf(project, tileId, 'tile') ? tileId : '';
|
||
}
|
||
|
||
/** The cells without those naming `tileId`, or null when none does. */
|
||
export function cellsWithout(cells: Record<string, string>, tileId: string): Record<string, string> | null {
|
||
if (!Object.values(cells).includes(tileId)) return null;
|
||
return Object.fromEntries(Object.entries(cells).filter(([, id]) => id !== tileId));
|
||
}
|
||
|
||
export function isLayerVisible(layer: TilemapLayer): boolean {
|
||
return layer.hidden !== true;
|
||
}
|
||
|
||
/** How far a layer is drawn above its tilemap's own `z` (#174); a layer never given one sits on it. */
|
||
export function layerZ(layer: TilemapLayer): number {
|
||
return layer.z ?? 0;
|
||
}
|
||
|
||
export function findLayer(asset: { layers: TilemapLayer[] }, layerId: string): TilemapLayer | undefined {
|
||
return asset.layers.find((layer) => layer.id === layerId);
|
||
}
|
||
|
||
export function inGrid(props: PropertyBag, x: number, y: number): boolean {
|
||
return x >= 0 && y >= 0 && x < tilemapColumns(props) && y < tilemapRows(props);
|
||
}
|
||
|
||
/**
|
||
* Which neighbours a cell is joined to. Only the *same* tile joins: a different one counts as
|
||
* empty, so every autotile draws its own border against everything else – what the RPG-Maker sheets
|
||
* are drawn for. Outside the grid counts as empty too, so a map ends with a proper edge.
|
||
*/
|
||
export function neighboursOf(
|
||
props: PropertyBag,
|
||
layer: TilemapLayer,
|
||
x: number,
|
||
y: number,
|
||
tileId: string,
|
||
): Neighbours {
|
||
const same = (dx: number, dy: number): boolean =>
|
||
inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === tileId;
|
||
return {
|
||
n: same(0, -1),
|
||
e: same(1, 0),
|
||
s: same(0, 1),
|
||
w: same(-1, 0),
|
||
ne: same(1, -1),
|
||
se: same(1, 1),
|
||
sw: same(-1, 1),
|
||
nw: same(-1, -1),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A layer's painted cells inside the grid, read once per content and grid size: in reading order,
|
||
* and by place. Reading the `x,y` keys again for every cell and each of its neighbours was most of
|
||
* what one painted cell cost a big map (#347).
|
||
*/
|
||
export interface LayerCells {
|
||
columns: number;
|
||
rows: number;
|
||
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 }>();
|
||
|
||
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);
|
||
}
|
||
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 };
|
||
indexed.set(layer.cells, fresh);
|
||
return fresh;
|
||
}
|
||
|
||
/** `neighboursOf`, read from the index. */
|
||
export function neighboursIn(cells: LayerCells, x: number, y: number, tileId: string): Neighbours {
|
||
const { columns, rows, at } = cells;
|
||
const same = (dx: number, dy: number): boolean => {
|
||
const nx = x + dx;
|
||
const ny = y + dy;
|
||
return nx >= 0 && ny >= 0 && nx < columns && ny < rows && at.get(ny * columns + nx) === tileId;
|
||
};
|
||
return {
|
||
n: same(0, -1),
|
||
e: same(1, 0),
|
||
s: same(0, 1),
|
||
w: same(-1, 0),
|
||
ne: same(1, -1),
|
||
se: same(1, 1),
|
||
sw: same(-1, 1),
|
||
nw: same(-1, -1),
|
||
};
|
||
}
|
||
|
||
/** One cell as it is drawn: where it sits, which tile it is and which quarters build it. */
|
||
export interface TileDraw {
|
||
x: number;
|
||
y: number;
|
||
tileId: string;
|
||
quadrants: TileQuadrants;
|
||
}
|
||
|
||
/**
|
||
* Every cell of one layer that is inside the grid and names a tile that exists, in reading order
|
||
* so the result is the same list twice. A cell naming a tile the project does not have is skipped
|
||
* (see `presentTile`).
|
||
*/
|
||
export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): readonly TileDraw[] {
|
||
const kinds = new Map<string, TileKind>();
|
||
for (const id of cellsTileIds(layer.cells)) {
|
||
const tile: TileAsset | undefined = findAssetOf(project, id, 'tile');
|
||
if (tile) kinds.set(id, tileKind(tile));
|
||
}
|
||
const key = [
|
||
tilemapColumns(props),
|
||
tilemapRows(props),
|
||
cellsVersion(layer.cells),
|
||
[...kinds].map(([id, kind]) => `${id}:${kind}`).join(','),
|
||
].join('|');
|
||
return drawn.get(key, () => workOutLayerTiles(props, layer, kinds));
|
||
}
|
||
|
||
const drawn = new KeyedMemo<readonly TileDraw[]>();
|
||
|
||
function workOutLayerTiles(
|
||
props: PropertyBag,
|
||
layer: TilemapLayer,
|
||
kinds: Map<string, TileKind>,
|
||
): TileDraw[] {
|
||
const 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)) });
|
||
}
|
||
return tiles;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
export function tilemapLayerNames(project: Project): string[] {
|
||
const names = project.assets.flatMap((asset) =>
|
||
asset.type === 'tilemap' ? asset.layers.map((layer) => layer.name) : [],
|
||
);
|
||
return [...new Set(names)];
|
||
}
|
||
|
||
/**
|
||
* The cell a point in the tilemap's own frame falls into. The grid is centred on the object's
|
||
* position, as every footprint is, so the top-left corner lies half a map up and to the left.
|
||
*/
|
||
export function cellAtPoint(props: PropertyBag, x: number, y: number): Cell {
|
||
const tile = tilemapTileSize(props);
|
||
const { w, h } = tilemapSize(props);
|
||
return { x: Math.floor((x + w / 2) / tile), y: Math.floor((y + h / 2) / tile) };
|
||
}
|
||
|
||
/** Where the top-left corner of a cell sits in the tilemap's own frame, in world units. */
|
||
export function cellOrigin(props: PropertyBag, x: number, y: number): { x: number; y: number } {
|
||
const tile = tilemapTileSize(props);
|
||
const { w, h } = tilemapSize(props);
|
||
return { x: x * tile - w / 2, y: y * tile - h / 2 };
|
||
}
|
||
|
||
/** A side of the grid that grows or shrinks (#136). */
|
||
export type TilemapEdge = 'left' | 'top' | 'right' | 'bottom';
|
||
|
||
export const TILEMAP_EDGES: readonly TilemapEdge[] = ['left', 'top', 'right', 'bottom'];
|
||
|
||
/** Left and right change the columns, top and bottom the rows. */
|
||
export function edgeSizeKey(edge: TilemapEdge): string {
|
||
return edge === 'left' || edge === 'right' ? TILEMAP_COLUMNS_KEY : TILEMAP_ROWS_KEY;
|
||
}
|
||
|
||
/**
|
||
* One layer's cells moved by whole cells. Growing at the left or top edge is this: cell `0,0` is
|
||
* always the top-left corner, so what was painted moves one column or row further in. Nothing is
|
||
* dropped – a cell pushed below zero lies outside the grid and comes back when the edge grows again.
|
||
*/
|
||
export function shiftedCells(cells: Record<string, string>, dx: number, dy: number): Record<string, string> {
|
||
const moved: Record<string, string> = {};
|
||
for (const [key, tileId] of Object.entries(cells)) {
|
||
const at = parseCellKey(key);
|
||
if (at) moved[cellKey(at.x + dx, at.y + dy)] = tileId;
|
||
}
|
||
return moved;
|
||
}
|
||
|
||
/**
|
||
* How far the origin moves, in the tilemap's own frame, for the painted cells to stay where they are
|
||
* when `edge` gains `count` cells (negative removes them). The grid is centred on the origin, so
|
||
* every added column pushes the content half a cell away from that edge; the origin follows by as
|
||
* much the other way.
|
||
*/
|
||
export function edgeCompensation(
|
||
edge: TilemapEdge,
|
||
count: number,
|
||
tileSize: number,
|
||
): { x: number; y: number } {
|
||
const half = (count * tileSize) / 2;
|
||
switch (edge) {
|
||
case 'left':
|
||
return { x: -half, y: 0 };
|
||
case 'right':
|
||
return { x: half, y: 0 };
|
||
case 'top':
|
||
return { x: 0, y: -half };
|
||
case 'bottom':
|
||
return { x: 0, y: half };
|
||
}
|
||
}
|
||
|
||
/** The cells of the rectangle between two corners, both included and clipped to the grid. */
|
||
export function cellsInRect(props: PropertyBag, a: Cell, b: Cell): Cell[] {
|
||
const cells: Cell[] = [];
|
||
const x0 = Math.max(0, Math.min(a.x, b.x));
|
||
const x1 = Math.min(tilemapColumns(props) - 1, Math.max(a.x, b.x));
|
||
const y0 = Math.max(0, Math.min(a.y, b.y));
|
||
const y1 = Math.min(tilemapRows(props) - 1, Math.max(a.y, b.y));
|
||
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) cells.push({ x, y });
|
||
return cells;
|
||
}
|
||
|
||
/**
|
||
* The cells a fill from `start` changes (#133): every cell reached through its four edge
|
||
* neighbours that holds what `start` holds – empty included – clipped to the grid. Diagonals do not
|
||
* count, or a fill would leak through the gap between two corners. Filling with the tile already
|
||
* there changes nothing, so that returns no cells. A cell naming a missing tile is empty here.
|
||
*/
|
||
export function floodFill(
|
||
project: Project,
|
||
props: PropertyBag,
|
||
layer: TilemapLayer,
|
||
start: Cell,
|
||
tileId: string,
|
||
): Cell[] {
|
||
if (!inGrid(props, start.x, start.y)) return [];
|
||
const present = new Map<string, string>();
|
||
const tileAt = (x: number, y: number): string => {
|
||
const id = cellAt(layer, x, y);
|
||
let shown = present.get(id);
|
||
if (shown === undefined) present.set(id, (shown = presentTile(project, id)));
|
||
return shown;
|
||
};
|
||
const from = tileAt(start.x, start.y);
|
||
if (from === tileId) return [];
|
||
// The list is its own queue: everything in it is filled, and it is walked while it grows.
|
||
const cells = [{ x: start.x, y: start.y }];
|
||
const seen = new Set([cellKey(start.x, start.y)]);
|
||
for (let index = 0; index < cells.length; index++) {
|
||
const cell = cells[index]!;
|
||
for (const [dx, dy] of [
|
||
[0, -1],
|
||
[1, 0],
|
||
[0, 1],
|
||
[-1, 0],
|
||
] as const) {
|
||
const x = cell.x + dx;
|
||
const y = cell.y + dy;
|
||
const key = cellKey(x, y);
|
||
if (seen.has(key) || !inGrid(props, x, y) || tileAt(x, y) !== from) continue;
|
||
seen.add(key);
|
||
cells.push({ x, y });
|
||
}
|
||
}
|
||
return cells;
|
||
}
|
||
|
||
/**
|
||
* The cells a stroke from one cell to another covers. A pointer jumps between events, so without
|
||
* this a fast drag would paint dots instead of a line.
|
||
*/
|
||
export function cellsAlong(from: Cell, to: Cell): Cell[] {
|
||
const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y));
|
||
if (steps === 0) return [{ ...to }];
|
||
const cells: Cell[] = [];
|
||
for (let i = 1; i <= steps; i++)
|
||
cells.push({
|
||
x: Math.round(from.x + ((to.x - from.x) * i) / steps),
|
||
y: Math.round(from.y + ((to.y - from.y) * i) / steps),
|
||
});
|
||
return cells;
|
||
}
|