Files
feuerwerk/src/core/commands/tilemapCommands.ts
T
adminandClaude Opus 5.5 cfd288c4b7 Halve the frame time of a playing scene with a tilemap (#284)
The performance panel showed Physics and Editor as the large parts of
a frame in a scene of 222 entities. Nearly all of it came from a
tilemap that only stands there.

- Whoever writes cells in place now says so with cellsChanged. Only
  then does cellsVersion compare the content. The comparison ran about
  60 times per frame and was the largest single item at 14 percent.
- The four writers call it: the painter during and at the end of a
  stroke, PaintTilesCommand on execute and on undo. A new writer that
  forgets it stays invisible.
- The game view is drawn once per frame in play. With an animated tile
  in view it was drawn twice, once by the run and once by animate().
  animate() now gets the redraw count from the start of the tick.
- TilemapField builds the geometry of a mesh only when its source
  changes: the cells, the tile size, the origin or the image. For that
  groupByTile and layerRows return the same objects for the same list.
- PhysicsSystem skips a tilemap whose source and switch are unchanged.
  The switch is kept because a map that starts switched off would
  otherwise have dead colliders once it is switched on.
- World.syncToModel looks entities and assets up in a map per pass
  instead of a linear search per object.

Measured on stein2 with a copy of the reference project, ms per frame:
Physics 2.64 to 0.97, Drawing 6.89 to 2.56, Editor 1.95 to 1.31.

Left open: syncToModel still mirrors every entity every frame. That
is its own issue.

Tests:
- Gate: 198 files, 2887 tests (2883 + 4), exit 0
- e2e 1-17,28,33,39-43,59: 810 checks passed, 40 has +3
- Smokes: 24 / 19 / 21, exit 0
- Three unit tests wrote cells directly and now call cellsChanged
- Eleven counter-probes fail. Two did not at first: the stroke needed
  a check of its own, the physics test has to start with the map
  switched off. That last one was repeated on stein2 only.
- e2e total 1613 is computed, not measured in a full run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:52:57 +02:00

368 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Painting a tilemap and managing its layers (#128). One stroke is one command whatever it covers:
* the pointer decides where a stroke ends, so an undo has to take back the whole line and not the
* last cell of it. The grid size and the cell size are ordinary properties and go through
* `SetAssetPropertyCommand`; on a placed tilemap the grid size goes through
* `ResizeTilemapEdgeCommand`, which keeps the painted cells where they are in the scene (#136).
*/
import { newId } from '../ids';
import type { Asset, TilemapLayer } from '../model/Asset';
import type { ObjectRef } from '../model/instance';
import { apply } from '../model/matrix';
import type { Position } from '../model/Properties';
import type { SceneObject } from '../model/SceneObject';
import {
type Cell,
cellAt,
cellKey,
edgeCompensation,
edgeSizeKey,
shiftedCells,
TILEMAP_COLUMNS_KEY,
TILEMAP_MAX_CELLS,
type TilemapEdge,
tilemapColumns,
tilemapRows,
tilemapTileSize,
} from '../model/tilemap';
import type { Project } from '../model/Project';
import { cellsChanged } from '../model/tilemapMemo';
import { resolveSceneObjectProps } from '../properties/resolve';
import { localMatrixOf, transformOfObject } from '../properties/transform';
import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { copyName } from '../util/names';
import { nextNumberedName } from './naming';
import { type Command, CommandError, MoveCommand } from './Command';
import { requireTilemap, requireTilemapLayer } from './require';
import { ObjectEditCommand } from './sceneObjectCommands';
/** One cell as a stroke wants it: `tileId` empty erases. */
export interface CellEdit extends Cell {
tileId: string;
}
/**
* Sets a run of cells at once. What each one held before is remembered per cell, so an undo puts
* a stroke over another tile back exactly – not merely empty.
*/
export class PaintTilesCommand implements Command {
readonly label: string;
private previous = new Map<string, string>();
constructor(
private readonly assetId: string,
private readonly layerId: string,
private readonly edits: readonly CellEdit[],
label?: string,
) {
this.label = label ?? 'Paint tiles';
}
/** Nothing to do when every cell already holds what the stroke would write – no empty undo step. */
static changes(layer: TilemapLayer, edits: readonly CellEdit[]): boolean {
return edits.some((edit) => cellAt(layer, edit.x, edit.y) !== edit.tileId);
}
execute(project: Project): void {
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
this.previous = new Map();
for (const edit of this.edits) {
const key = cellKey(edit.x, edit.y);
if (!this.previous.has(key)) this.previous.set(key, layer.cells[key] ?? '');
if (edit.tileId) layer.cells[key] = edit.tileId;
else delete layer.cells[key];
}
cellsChanged(layer.cells);
}
undo(project: Project): void {
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
for (const [key, value] of this.previous) {
if (value) layer.cells[key] = value;
else delete layer.cells[key];
}
cellsChanged(layer.cells);
}
}
/**
* The command for a finished stroke or fill, or null when it would change no cell: such a step
* would undo nothing and still count as an edit of the project (#212).
*/
export function paintTiles(
layer: TilemapLayer,
assetId: string,
edits: readonly CellEdit[],
label?: string,
): PaintTilesCommand | null {
return PaintTilesCommand.changes(layer, edits)
? new PaintTilesCommand(assetId, layer.id, edits, label)
: null;
}
/**
* Where a placed tilemap's origin goes when `edge` gains `count` cells, starting from `from`, so the
* painted cells stay where they are in the scene (#136). The shift is worked out in the tilemap's
* own frame and carried through its turn, size and mirror, so a rotated map holds still as well.
*/
export function heldPosition(
asset: Asset,
object: SceneObject,
from: Position,
edge: TilemapEdge,
count: number,
tileSize: number,
): Position {
const at = apply(
localMatrixOf(from, transformOfObject(asset, object)),
edgeCompensation(edge, count, tileSize),
);
return { x: at.x, y: at.y, z: from.z };
}
/** The placed tilemap's current columns or rows, whichever `key` names. */
export function placedGridSize(project: Project, asset: Asset, object: SceneObject, key: string): number {
const props = resolveSceneObjectProps(project, asset, object);
return key === TILEMAP_COLUMNS_KEY ? tilemapColumns(props) : tilemapRows(props);
}
/**
* Adds (`count` > 0) or removes columns or rows at one edge of a placed tilemap (#136), as one undo
* step. The size is the placement's own, as when Columns and Rows are edited in the inspector. At the
* left and top edge the cells of every layer move along, because cell `0,0` is always the top-left
* corner – and cells live on the asset, so they move in every other placement too. Removing throws
* nothing away: what falls off lies outside the grid and comes back with the edge.
*/
export class ResizeTilemapEdgeCommand extends ObjectEditCommand {
readonly label: string;
private shifted: { layer: TilemapLayer; cells: Record<string, string> }[] = [];
constructor(
holderId: string,
objectId: string,
private readonly edge: TilemapEdge,
private readonly count: number,
) {
super(holderId, objectId);
const what = edgeSizeKey(edge) === TILEMAP_COLUMNS_KEY ? 'column' : 'row';
this.label = `${count >= 0 ? 'Add' : 'Remove'} ${what}${Math.abs(count) === 1 ? '' : 's'} (${edge})`;
}
protected override apply(ref: ObjectRef, project: Project): void {
const asset = requireTilemap(project, ref.object.assetId);
const key = edgeSizeKey(this.edge);
const size = placedGridSize(project, asset, ref.object, key) + this.count;
if (size < 1) throw new CommandError('A tilemap keeps at least one column and one row');
if (size > TILEMAP_MAX_CELLS)
throw new CommandError(`A tilemap has at most ${TILEMAP_MAX_CELLS} columns and rows`);
const tile = tilemapTileSize(resolveSceneObjectProps(project, asset, ref.object));
ref.setPosition(heldPosition(asset, ref.object, ref.object.position, this.edge, this.count, tile));
ref.setOverride(key, size);
this.shifted = [];
if (this.edge !== 'left' && this.edge !== 'top') return;
const dx = this.edge === 'left' ? this.count : 0;
const dy = this.edge === 'top' ? this.count : 0;
for (const layer of asset.layers) {
this.shifted.push({ layer, cells: layer.cells });
layer.cells = shiftedCells(layer.cells, dx, dy);
}
}
protected override revert(): void {
for (const { layer, cells } of this.shifted) layer.cells = cells;
}
}
export class AddTilemapLayerCommand implements Command {
readonly label = 'Add layer';
readonly layerId = newId();
constructor(private readonly assetId: string) {}
execute(project: Project): void {
const asset = requireTilemap(project, this.assetId);
const name = nextNumberedName(
asset.layers.map((layer) => layer.name),
'Layer',
);
asset.layers.push({ id: this.layerId, name, cells: {} });
}
undo(project: Project): void {
const asset = requireTilemap(project, this.assetId);
asset.layers = removeById(asset.layers, this.layerId);
}
}
/** A copy of a layer right above it, with everything painted on it, its z and whether it is shown. */
export class DuplicateTilemapLayerCommand implements Command {
readonly label = 'Duplicate layer';
readonly layerId = newId();
constructor(
private readonly assetId: string,
private readonly sourceId: string,
) {}
execute(project: Project): void {
const asset = requireTilemap(project, this.assetId);
const index = asset.layers.findIndex((layer) => layer.id === this.sourceId);
const source = asset.layers[index];
if (!source) throw new CommandError(`Layer ${this.sourceId} not found`);
const name = copyName(
asset.layers.map((layer) => layer.name),
source.name,
);
asset.layers.splice(index + 1, 0, { ...source, id: this.layerId, name, cells: { ...source.cells } });
}
undo(project: Project): void {
const asset = requireTilemap(project, this.assetId);
asset.layers = removeById(asset.layers, this.layerId);
}
}
/** Takes a layer out with everything painted on it; the last layer stays, there must be one to paint on. */
export class RemoveTilemapLayerCommand implements Command {
readonly label = 'Remove layer';
private removed: Removed<TilemapLayer> | undefined;
constructor(
private readonly assetId: string,
private readonly layerId: string,
) {}
execute(project: Project): void {
const asset = requireTilemap(project, this.assetId);
if (asset.layers.length <= 1) throw new CommandError('A tilemap keeps at least one layer');
const removed = removeFirst(asset.layers, (layer) => layer.id === this.layerId);
if (!removed) throw new CommandError(`Layer ${this.layerId} not found`);
this.removed = removed;
}
undo(project: Project): void {
if (this.removed) restoreAt(requireTilemap(project, this.assetId).layers, [this.removed]);
}
}
/** Moves a layer in the drawing order; the last layer in the list is drawn on top. */
export class MoveTilemapLayerCommand extends MoveCommand {
constructor(
private readonly assetId: string,
from: number,
to: number,
) {
super('Reorder layers', from, to);
}
protected move(project: Project, from: number, to: number): void {
if (!moveItem(requireTilemap(project, this.assetId).layers, from, to))
throw new CommandError('No such layer');
}
}
export class RenameTilemapLayerCommand implements Command {
readonly label = 'Rename layer';
private previous = '';
constructor(
private readonly assetId: string,
private readonly layerId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
const name = this.name.trim();
if (!name) throw new CommandError('A layer needs a name');
this.previous = layer.name;
layer.name = name;
}
undo(project: Project): void {
requireTilemapLayer(project, this.assetId, this.layerId).name = this.previous;
}
}
/** A layer's `z` above its tilemap's (#174); 0 is stored as nothing, the way a layer starts out. */
export class SetTilemapLayerZCommand implements Command {
readonly label = 'Set layer z';
private previous: number | undefined;
constructor(
private readonly assetId: string,
private readonly layerId: string,
private readonly z: number,
) {}
execute(project: Project): void {
if (!Number.isFinite(this.z)) throw new CommandError('A layer z is a number');
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
this.previous = layer.z;
setLayerZ(layer, this.z);
}
undo(project: Project): void {
setLayerZ(requireTilemapLayer(project, this.assetId, this.layerId), this.previous ?? 0);
}
}
function setLayerZ(layer: TilemapLayer, z: number): void {
if (z === 0) delete layer.z;
else layer.z = z;
}
/** Whether a layer is drawn row by row and sorted in by Y (#228); off is stored as nothing. */
export class SetTilemapLayerSortByYCommand implements Command {
readonly label: string;
constructor(
private readonly assetId: string,
private readonly layerId: string,
private readonly sortByY: boolean,
) {
this.label = sortByY ? 'Sort layer by Y' : 'Stop sorting layer by Y';
}
execute(project: Project): void {
this.apply(project, this.sortByY);
}
undo(project: Project): void {
this.apply(project, !this.sortByY);
}
private apply(project: Project, sortByY: boolean): void {
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
if (sortByY) layer.sortByY = true;
else delete layer.sortByY;
}
}
/** A hidden layer keeps its cells and is left out of the drawing, in the editor and in the game alike. */
export class SetTilemapLayerHiddenCommand implements Command {
readonly label: string;
constructor(
private readonly assetId: string,
private readonly layerId: string,
private readonly hidden: boolean,
) {
this.label = hidden ? 'Hide layer' : 'Show layer';
}
execute(project: Project): void {
this.apply(project, this.hidden);
}
undo(project: Project): void {
this.apply(project, !this.hidden);
}
private apply(project: Project, hidden: boolean): void {
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
if (hidden) layer.hidden = true;
else delete layer.hidden;
}
}