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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 09:52:57 +02:00
1 parent 6491888f56
commit cfd288c4b7
15 files changed
+303 -43

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1610 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1613 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+3
View File
@@ -26,6 +26,7 @@ import {
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';
@@ -71,6 +72,7 @@ export class PaintTilesCommand implements Command {
if (edit.tileId) layer.cells[key] = edit.tileId;
else delete layer.cells[key];
}
cellsChanged(layer.cells);
}
undo(project: Project): void {
@@ -79,6 +81,7 @@ export class PaintTilesCommand implements Command {
if (value) layer.cells[key] = value;
else delete layer.cells[key];
}
cellsChanged(layer.cells);
}
}
+19 -4
View File
@@ -4,8 +4,10 @@
* again each time is what made a map with outlined tiles cost a whole frame.
*
* The cells are painted in place, so an object being the same says nothing about its content.
* `cellsVersion` compares the content with what it saw last and hands out a new number only when it
* differs – a number that then stands for exactly that content in a cache key.
* Whoever writes into them says so with `cellsChanged`; `cellsVersion` then compares the content
* 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).
*/
interface Seen {
@@ -13,6 +15,8 @@ interface Seen {
values: string[];
version: number;
tileIds: string[];
/** Written to since it was looked at. */
stale: boolean;
}
const seen = new WeakMap<Record<string, string>, Seen>();
@@ -33,14 +37,25 @@ function unchanged(cells: Record<string, string>, held: Seen): boolean {
function look(cells: Record<string, string>): Seen {
const held = seen.get(cells);
if (held && unchanged(cells, held)) return held;
if (held && !held.stale) return held;
if (held && unchanged(cells, held)) {
held.stale = false;
return held;
}
const keys = Object.keys(cells);
const values = keys.map((key) => cells[key]!);
const fresh = { keys, values, version: ++versions, tileIds: [...new Set(values)].filter(Boolean) };
const tileIds = [...new Set(values)].filter(Boolean);
const fresh = { keys, values, version: ++versions, tileIds, stale: false };
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 {
const held = seen.get(cells);
if (held) held.stale = true;
}
/** A number that is the same exactly as long as the cells hold the same tiles in the same places. */
export function cellsVersion(cells: Record<string, string>): number {
return look(cells).version;
+4 -2
View File
@@ -915,6 +915,8 @@ export class Editor implements PlayHost {
private tick(dt: number): void {
const started = performance.now();
const drawnBefore = this.gameView.drawTime;
const sceneDraws = this.viewport.redrawCount;
const gameDraws = this.gameView.redrawCount;
this.measureFps(dt);
// One step for everything below: a world gets exactly the seconds the particle, shader and
// animation clocks moved, so the global time scale cannot pull them apart.
@@ -922,8 +924,8 @@ export class Editor implements PlayHost {
const step = gameClock.advance(dt, this.state.paused || this.play.isGamePaused);
this.play.tick(step, timeStep(dt, gameClock.scale, this.state.paused));
this.editorRun.tick(step);
this.viewport.animate();
this.gameView.animate();
this.viewport.animate(sceneDraws);
this.gameView.animate(gameDraws);
this.console.refresh();
if (this.tab === 'animation') this.animationEditor.tick(step);
if (this.tab === 'tile') this.tileEditor.tick(step);
+7 -2
View File
@@ -4,6 +4,7 @@ import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset';
import { apply as applyPoint, invert } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { cellsChanged } from '../../core/model/tilemapMemo';
import {
cellAt,
cellAtPoint,
@@ -205,7 +206,9 @@ export class TilemapPainter {
else delete layer.cells[key];
changed = true;
}
if (changed) this.perFrame.request();
if (!changed) return;
cellsChanged(layer.cells);
this.perFrame.request();
}
/** Puts the preview back and lets the command do the edit, so one stroke is one undo step. */
@@ -215,11 +218,13 @@ export class TilemapPainter {
this.perFrame.cancel();
if (!stroke) return;
const layer = this.strokeLayer(stroke);
if (layer)
if (layer) {
for (const [key, value] of stroke.previous) {
if (value) layer.cells[key] = value;
else delete layer.cells[key];
}
cellsChanged(layer.cells);
}
const item = this.host.items().find((i) => i.id === stroke.objectId);
const edits =
stroke.tool === 'rect' && item
+9 -4
View File
@@ -318,18 +318,23 @@ export class World {
}
const reparented = this.movedUp.size > 0;
this.movedUp.clear();
const alive = new Set(this.entities.map((e) => e.objectId));
// Looked up once for the whole pass: asking the list per object made this grow with the square
// of the entities, every frame of a run (#284). The first of an id counts, as `findByObjectId` has it.
const alive = new Map<string, Entity>();
for (const e of this.entities) if (!alive.has(e.objectId)) alive.set(e.objectId, e);
const assets = new Map<string, Asset>();
for (const asset of this.project.assets) if (!assets.has(asset.id)) assets.set(asset.id, asset);
const before = scene.objects.length;
for (let i = scene.objects.length - 1; i >= 0; i--) {
const object = scene.objects[i]!;
const asset = findAsset(this.project, object.assetId);
const asset = assets.get(object.assetId);
if (asset && isPlaceableType(asset.type) && !alive.has(object.id)) scene.objects.splice(i, 1);
}
for (const object of resolveObjects(this.project, scene)) {
// A dragged object's place belongs to the drag, which reaches the entity on the next re-read.
if (this.held.has(object.id)) continue;
const entity = this.findByObjectId(object.id);
const asset = entity && findAsset(this.project, entity.assetId);
const entity = alive.get(object.id);
const asset = entity && assets.get(entity.assetId);
if (!entity || !asset) continue;
const p = object.position;
const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z;
+12 -1
View File
@@ -85,6 +85,8 @@ interface Body {
shapeKey: string;
/** For a tilemap, what its shapes were last worked out from (see `build`). */
source: string;
/** For a tilemap, whether its colliders were last switched on; null before they were looked at. */
switchedOn: boolean | null;
settingsKey: string;
/** Whether the engine may turn it – what `writeBack` hands on as the object's rotation. */
turns: boolean;
@@ -428,6 +430,7 @@ export class PhysicsSystem implements System, CollisionSource {
solids: [],
shapeKey: '',
source: '',
switchedOn: null,
settingsKey: '',
turns: false,
ownMass: 0,
@@ -522,8 +525,16 @@ export class PhysicsSystem implements System, CollisionSource {
// A map's shapes follow from its cells, placement and settings alone. While none of those
// changed, its thousands of pieces are not worked out again only to find the same key.
const source = body.kind === 'tilemap' ? this.tilemapSource(key, world) : '';
const local = source !== '' && source === body.source ? [] : this.rebuild(body, want, key, world);
const unchanged = source !== '' && source === body.source;
const local = unchanged ? [] : this.rebuild(body, want, key, world);
body.source = source;
// A map's colliders all stand for the one entity and are switched with it. While neither they
// nor that changed, the hundreds of them are not gone through one by one each tick (#284).
if (body.kind === 'tilemap') {
const on = isHandleEnabled(key);
if (unchanged && on === body.switchedOn) return;
body.switchedOn = on;
}
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them. A
// tilemap's many shapes all stand for the one map, so its colliders keep the shape they were built from.
const solidOfEntity = new Map(local.map((solid) => [solid.entity, solid]));
+18 -5
View File
@@ -245,9 +245,18 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
return this.animated;
}
/** Called every tick: redraws while the last redraw found a Time shader on screen. */
animate(): void {
if (this.visible && this.animated) this.redraw();
/** How often the view was drawn so far; `animate` is told the count its tick began with. */
get redrawCount(): number {
return this.redraws;
}
/**
* Called every tick: redraws while the last redraw found a Time shader on screen. A view drawn
* since the tick began, at `drawnBefore`, already shows this frame – a run draws its views
* itself, and drawing them again cost a second collect of the whole world each frame (#284).
*/
animate(drawnBefore: number): void {
if (this.visible && this.animated && this.redraws === drawnBefore) this.redraw();
}
layerOf(objectId: string): 'objects' | 'overlay' | null {
@@ -529,13 +538,17 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
return { x: origin.x + local.x, y: origin.y + local.y };
}
/** What a placed tilemap drew last redraw: how many meshes, and how many cells in them (test hook). */
tilemapInfo(objectId: string): { drawCalls: number; tiles: number; layers: number } | null {
/**
* What a placed tilemap drew last redraw: how many meshes, and how many cells in them; `builds`
* counts the geometries built since the map came on screen (test hook).
*/
tilemapInfo(objectId: string): { drawCalls: number; builds: number; tiles: number; layers: number } | null {
const field = this.tilemaps.get(objectId);
const item = this.lastItems.find((i) => i.id === objectId);
if (!field?.visible || !item?.tilemap) return null;
return {
drawCalls: field.drawCalls,
builds: field.builds,
tiles: item.tilemap.layers.reduce((sum, layer) => sum + layer.tiles.length, 0),
layers: item.tilemap.layers.length,
};
+20 -7
View File
@@ -28,16 +28,29 @@ export function sortRowOf(draw: TilemapDraw, tile: TileDraw): number {
return tile.y + (draw.tiles.get(tile.tileId)?.sortOffset ?? 0);
}
/** The cells of a layer sorted by Y, grouped by the row they sort by, top row first. */
export function layerRows(draw: TilemapDraw, layer: TilemapLayerDraw): Map<number, TileDraw[]> {
const rows = new Map<number, TileDraw[]>();
type LayerRows = ReadonlyMap<number, readonly TileDraw[]>;
/** Per list of tiles, which is the same one for as long as the layer's cells are (`layerTiles`). */
const rowsOf = new WeakMap<readonly TileDraw[], { offsets: string; rows: LayerRows }>();
/**
* The cells of a layer sorted by Y, grouped by the row they sort by, top row first. The same cells
* and offsets hand out the very same rows, so a mesh built from one can tell it need not be rebuilt.
*/
export function layerRows(draw: TilemapDraw, layer: TilemapLayerDraw): LayerRows {
const offsets = [...draw.tiles].map(([id, tile]) => `${id}:${tile.sortOffset}`).join(',');
const held = rowsOf.get(layer.tiles);
if (held?.offsets === offsets) return held.rows;
const grouped = new Map<number, TileDraw[]>();
for (const tile of layer.tiles) {
const row = sortRowOf(draw, tile);
const held = rows.get(row);
if (held) held.push(tile);
else rows.set(row, [tile]);
const cells = grouped.get(row);
if (cells) cells.push(tile);
else grouped.set(row, [tile]);
}
return new Map([...rows].sort(([a], [b]) => a - b));
const rows = new Map([...grouped].sort(([a], [b]) => a - b));
rowsOf.set(layer.tiles, { offsets, rows });
return rows;
}
interface Candidate extends DrawSlot {
+45 -10
View File
@@ -36,6 +36,10 @@ export function layerAlpha(layerId: string, focus: LayerFocus | null): number {
export class TilemapField {
private readonly layers = new Map<string, Container>();
private readonly meshes = new Map<string, Mesh>();
/** What each mesh's geometry was built from. */
private readonly built = new Map<string, MeshSource>();
/** How many geometries were built so far (test hook). */
builds = 0;
private shown = true;
/** True after a sync that drew a tile with more than one picture. */
animated = false;
@@ -88,13 +92,8 @@ export class TilemapField {
if (source.info.pictures.length > 1) this.animated = true;
const key = `${part}|${tileId}`;
alive.add(key);
const { positions, uvs, indices } = tileMesh(
tiles,
tile,
origin,
sheetOf(source, picture, texture),
);
const mesh = this.meshFor(key, new MeshGeometry({ positions, uvs, indices }), texture);
const sheet = sheetOf(source, picture, texture);
const mesh = this.meshFor(key, { tiles, tile, origin, texture, sheet });
// Per mesh rather than per layer: the meshes of one layer never overlap, so fading each
// one looks the same as fading the layer as a whole.
mesh.alpha = alpha;
@@ -106,6 +105,7 @@ export class TilemapField {
if (alive.has(key)) continue;
mesh.destroy();
this.meshes.delete(key);
this.built.delete(key);
}
for (const [id, layer] of this.layers) {
if (aliveLayers.has(id)) continue;
@@ -131,15 +131,24 @@ export class TilemapField {
return layer;
}
private meshFor(key: string, geometry: MeshGeometry, texture: Texture): Mesh {
/**
* The mesh of one tile on one part, with its geometry built only when what it is built from
* changed: a map that stands still was building every mesh anew each frame (#284).
*/
private meshFor(key: string, from: MeshSource): Mesh {
const held = this.meshes.get(key);
if (held && sameSource(this.built.get(key), from)) return held;
const { positions, uvs, indices } = tileMesh(from.tiles, from.tile, from.origin, from.sheet);
const geometry = new MeshGeometry({ positions, uvs, indices });
this.built.set(key, from);
this.builds++;
if (held) {
held.geometry.destroy();
held.geometry = geometry;
held.texture = texture;
held.texture = from.texture;
return held;
}
const mesh = new Mesh({ geometry, texture });
const mesh = new Mesh({ geometry, texture: from.texture });
mesh.eventMode = 'none';
this.meshes.set(key, mesh);
return mesh;
@@ -149,9 +158,35 @@ export class TilemapField {
for (const layer of this.layers.values()) layer.destroy({ children: true });
this.layers.clear();
this.meshes.clear();
this.built.clear();
}
}
/** Everything the geometry of one mesh follows from. */
interface MeshSource {
tiles: readonly TileDraw[];
tile: number;
origin: { x: number; y: number };
texture: Texture;
sheet: TileSheet;
}
/** The cells by identity (see `groupByTile`), the rest by value. */
function sameSource(built: MeshSource | undefined, from: MeshSource): boolean {
return (
built !== undefined &&
built.tiles === from.tiles &&
built.tile === from.tile &&
built.origin.x === from.origin.x &&
built.origin.y === from.origin.y &&
built.texture === from.texture &&
built.sheet.quad === from.sheet.quad &&
built.sheet.textureWidth === from.sheet.textureWidth &&
built.sheet.textureHeight === from.sheet.textureHeight &&
built.sheet.smooth === from.sheet.smooth
);
}
/** A whole layer is keyed by its id, as before; a row of a layer sorted by Y by id and row. */
function rowKey(layerId: string, row: number | null): string {
return row === null ? layerId : `${layerId}#${row}`;
+11 -2
View File
@@ -90,13 +90,22 @@ export function tileMesh(
return { positions, uvs, indices };
}
/** The cells of one layer grouped by tile – one group is one mesh, because it is one texture. */
export function groupByTile(tiles: readonly TileDraw[]): Map<string, TileDraw[]> {
const grouped = new WeakMap<readonly TileDraw[], ReadonlyMap<string, readonly TileDraw[]>>();
/**
* The cells of one layer grouped by tile – one group is one mesh, because it is one texture. A list
* of tiles is never edited, so the same list hands out the very same groups: that is how a mesh
* tells its cells are the ones it was built from (see `TilemapField.sync`).
*/
export function groupByTile(tiles: readonly TileDraw[]): ReadonlyMap<string, readonly TileDraw[]> {
const held = grouped.get(tiles);
if (held) return held;
const groups = new Map<string, TileDraw[]>();
for (const drawn of tiles) {
const group = groups.get(drawn.tileId);
if (group) group.push(drawn);
else groups.set(drawn.tileId, [drawn]);
}
grouped.set(tiles, groups);
return groups;
}
+30
View File
@@ -177,6 +177,36 @@ describe('falling and resting', () => {
expect(entity(box).y).toBeCloseTo(192 - 16, 0);
});
it('a map switched off by its parent holds nothing, holds once switched on, and lets go again', () => {
const f = physicsScene();
const solid = new CreateAssetCommand('tile', 'Wall');
const map = new CreateAssetCommand('tilemap', 'Ground');
f.stack.execute(solid);
f.stack.execute(map);
f.stack.execute(new SetAssetPropertyCommand(solid.asset.id, TILE_SOLID_KEY, true));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
const holder = place(f, f.spriteId, { x: 0, y: 0 });
place(f, map.asset.id, { x: 0, y: 0 }, holder);
const layer = (findAsset(f.project, map.asset.id) as TilemapAsset).layers[0]!.id;
const row = Array.from({ length: 10 }, (_, x) => ({ x, y: 9, tileId: solid.asset.id }));
f.stack.execute(new PaintTilesCommand(map.asset.id, layer, row));
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity, physics } = run(f);
// The map's own values stay as they are throughout: only what is above it changes.
entity(holder).props.enabled = false;
seconds(1);
expect(entity(box).y).toBeGreaterThan(300);
entity(holder).props.enabled = true;
entity(box).y = 0;
physics.setVelocity(entity(box), { x: 0, y: 0 });
seconds(3);
expect(entity(box).y).toBeCloseTo(192 - 16, 0);
entity(holder).props.enabled = false;
seconds(1);
expect(entity(box).y).toBeGreaterThan(300);
});
it('lands on a floor set during the run and falls on once it is taken away (#135)', () => {
const f = physicsScene();
const solid = new CreateAssetCommand('tile', 'Wall');
+39 -4
View File
@@ -14,7 +14,7 @@ import {
TILEMAP_TILE_KEY,
} from '../../src/core/model/tilemap';
import { solidPolygons, solidRects } from '../../src/core/model/tilemapCollision';
import { cellsVersion, KeyedMemo } from '../../src/core/model/tilemapMemo';
import { cellsChanged, cellsVersion, KeyedMemo } from '../../src/core/model/tilemapMemo';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { Solids } from '../../src/runtime/systems/solids';
import { makeSpriteScene, place, startWorld } from '../helpers';
@@ -83,18 +83,50 @@ function map() {
describe('what a tilemap works out is remembered while its content stays', () => {
it('a version stays while the cells hold the same, and changes when one is painted in place', () => {
const cells: Record<string, string> = { '0,0': 'a', '1,0': 'b' };
const paint = (key: string, tileId: string) => {
if (tileId) cells[key] = tileId;
else delete cells[key];
cellsChanged(cells);
};
const first = cellsVersion(cells);
expect(cellsVersion(cells)).toBe(first);
cells['2,0'] = 'a';
paint('2,0', 'a');
const added = cellsVersion(cells);
expect(added).not.toBe(first);
cells['2,0'] = 'b';
paint('2,0', 'b');
const changed = cellsVersion(cells);
expect(changed).not.toBe(added);
delete cells['0,0'];
paint('0,0', '');
expect(cellsVersion(cells)).not.toBe(changed);
});
it('cells are compared only when their painter says so, and keep their version when nothing differs', () => {
const cells: Record<string, string> = { '0,0': 'a', '1,0': 'b' };
const first = cellsVersion(cells);
// A stroke that ends where it began: written to, but the same content as before.
cells['2,0'] = 'a';
delete cells['2,0'];
cellsChanged(cells);
expect(cellsVersion(cells)).toBe(first);
// Not walked again on every question: a write nobody reported goes unseen until one is.
cells['2,0'] = 'a';
expect(cellsVersion(cells)).toBe(first);
cellsChanged(cells);
expect(cellsVersion(cells)).not.toBe(first);
});
it('a paint command and its undo are each seen at once', () => {
const m = map();
expect(solidRects(m.project, m.props, m.asset)).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]);
m.stack.execute(new PaintTilesCommand(m.asset.id, m.layer.id, [{ x: 0, y: 3, tileId: m.boxId }]));
expect(solidRects(m.project, m.props, m.asset)).toEqual([
{ x: 0, y: 0, w: 4, h: 1 },
{ x: 0, y: 3, w: 1, h: 1 },
]);
m.stack.undo();
expect(solidRects(m.project, m.props, m.asset)).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]);
});
it('the cache hands out what it made for a key, and forgets the oldest once full', () => {
const memo = new KeyedMemo<number[]>(2);
const make = vi.fn(() => [1]);
@@ -119,12 +151,14 @@ describe('what a tilemap works out is remembered while its content stays', () =>
const rects = solidRects(m.project, m.props, m.asset);
expect(rects).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]);
m.layer.cells['0,3'] = m.boxId;
cellsChanged(m.layer.cells);
expect(solidRects(m.project, m.props, m.asset)).toEqual([
{ x: 0, y: 0, w: 4, h: 1 },
{ x: 0, y: 3, w: 1, h: 1 },
]);
expect(layerTiles(m.project, m.props, m.layer).map((t) => `${t.x},${t.y}`)).toContain('0,3');
delete m.layer.cells['1,2'];
cellsChanged(m.layer.cells);
expect(solidPolygons(m.project, m.props, m.asset)).toEqual([]);
});
@@ -155,6 +189,7 @@ describe('what a tilemap works out is remembered while its content stays', () =>
const first = liveLayers(m.asset, edits)[0]!;
expect(liveLayers(m.asset, edits)[0]).toBe(first);
m.layer.cells['2,3'] = m.boxId;
cellsChanged(m.layer.cells);
const painted = liveLayers(m.asset, edits)[0]!;
expect(painted).not.toBe(first);
expect(painted.cells['2,3']).toBe(m.boxId);
+29 -1
View File
@@ -13,9 +13,10 @@ import { findAsset } from '../../src/core/model/Project';
import { frameSortLine } from '../../src/core/model/texture';
import { TILE_SORT_OFFSET_KEY, tileSortOffset } from '../../src/core/model/tile';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { type DrawSlot, drawSlots } from '../../src/viewport/drawOrder';
import { type DrawSlot, drawSlots, layerRows } from '../../src/viewport/drawOrder';
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
import { stackAt } from '../../src/viewport/pick';
import { groupByTile } from '../../src/viewport/tilemapMesh';
import { sortLineAt, sortLineY } from '../../src/editor/texture/sheetView';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
@@ -148,6 +149,33 @@ describe('a tilemap layer sorted by Y (#228)', () => {
expect(sceneOrder(plain)).toEqual(['map', 'Ground', 'Trees 4', 'box', 'Trees 5', 'Trees 8']);
});
it('hands out the very same rows while cells and offsets stay, and new ones when either changes', () => {
const s = makeScene(-80);
s.sortTrees();
const rows = () => {
const draw = collectHolderItems(s.f.project, s.f.sceneOf(), 0).items.find(
(item) => item.id === s.mapObject,
)!.tilemap!;
return layerRows(
draw,
draw.layers.find((layer) => layer.id === s.trees)!,
);
};
const first = rows();
expect(rows()).toBe(first);
expect([...first.keys()]).toEqual([5, 8]);
expect(groupByTile(first.get(5)!)).toBe(groupByTile(rows().get(5)!));
s.f.stack.execute(new SetAssetPropertyCommand(s.crown, TILE_SORT_OFFSET_KEY, 0));
const moved = rows();
expect(moved).not.toBe(first);
expect([...moved.keys()]).toEqual([4, 5, 8]);
s.f.stack.execute(new PaintTilesCommand(s.mapId, s.trees, [{ x: 3, y: 9, tileId: s.crown }]));
expect(rows()).not.toBe(moved);
expect([...rows().keys()]).toEqual([4, 5, 8, 9]);
});
it('sorts objects on that z among each other by their feet, too', () => {
const s = makeScene(100);
s.sortTrees();
+56
View File
@@ -310,6 +310,29 @@ export async function tilemap(t) {
JSON.stringify(await painted()),
);
// 40c-ii. The cells show while the stroke is still under way, before it is a command (#284): the
// brush writes them in place and has to say so, or the view keeps drawing the old map.
const drawnTiles = async () => (await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`))?.tiles;
const drawnBefore = await drawnTiles();
await t.mouse('mouseMoved', from.x, from.y);
await t.mouse('mousePressed', from.x, from.y);
for (let i = 1; i <= 12; i++) {
await t.mouse('mouseMoved', from.x + ((to.x - from.x) * i) / 12, from.y, 'left', { buttons: 1 });
await t.sleep(16);
}
await settle();
const drawnHeld = await drawnTiles();
await t.mouse('mouseReleased', to.x, to.y);
await settle();
const drawnReleased = await drawnTiles();
await undo();
await settle();
check(
'the cells of a stroke are drawn while the button is still held',
drawnBefore === 0 && drawnHeld === 3 && drawnReleased === 3 && Object.keys(await painted()).length === 0,
JSON.stringify({ drawnBefore, drawnHeld, drawnReleased, left: await painted() }),
);
// 40d. The rectangle tool fills the box a drag spans.
await clickInPanel(t, '__gm.tilemap.toolAt("rect")');
await settle();
@@ -956,6 +979,39 @@ export async function tilemap(t) {
game && game.tiles === 10 && game.layers === 2,
JSON.stringify(game),
);
// 40j-ii. A frame of the run draws the game view once and leaves a still map's meshes alone
// (#284): the animated grass used to have the view drawn a second time each frame, and
// every geometry of the map built anew with each drawing. Counted in the page over the
// same frames, so a slow machine changes neither ratio.
const drawing = JSON.parse(
await t.evaluate(`(async () => {
const read = () => ({
frames: __gm.renderStats().drawn,
redraws: __gm.gameInfo().redraws,
builds: __gm.tilemap.gameInfo(${JSON.stringify(MAP_OBJECT)}).builds,
});
const before = read();
await __gm.afterFrames(40, 4000);
const after = read();
return {
frames: after.frames - before.frames,
redraws: after.redraws - before.redraws,
builds: after.builds - before.builds,
meshes: __gm.tilemap.gameInfo(${JSON.stringify(MAP_OBJECT)}).drawCalls,
};
})().then(JSON.stringify)`),
);
check(
'a frame of the run draws the game view once, not twice',
drawing.frames >= 40 && drawing.redraws > 0 && drawing.redraws <= drawing.frames + 2,
JSON.stringify(drawing),
);
check(
'and builds a mesh again only when its animated tile steps on, not with every drawing',
drawing.builds < drawing.redraws * drawing.meshes * 0.5,
JSON.stringify(drawing),
);
await t.stop();
await settle();
await mutate(`