diff --git a/README.md b/README.md index 5ab83beb..8b16757b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts index 23217e42..a7e55447 100644 --- a/src/core/commands/tilemapCommands.ts +++ b/src/core/commands/tilemapCommands.ts @@ -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); } } diff --git a/src/core/model/tilemapMemo.ts b/src/core/model/tilemapMemo.ts index eee0413e..38c61ed3 100644 --- a/src/core/model/tilemapMemo.ts +++ b/src/core/model/tilemapMemo.ts @@ -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, Seen>(); @@ -33,14 +37,25 @@ function unchanged(cells: Record, held: Seen): boolean { function look(cells: Record): 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): 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): number { return look(cells).version; diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index e54fc2c3..90713666 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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); diff --git a/src/editor/viewport/tilemapPainting.ts b/src/editor/viewport/tilemapPainting.ts index 3ba8c480..2836b2ac 100644 --- a/src/editor/viewport/tilemapPainting.ts +++ b/src/editor/viewport/tilemapPainting.ts @@ -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 diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 2839f36b..c2eea983 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -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(); + for (const e of this.entities) if (!alive.has(e.objectId)) alive.set(e.objectId, e); + const assets = new Map(); + 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; diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index 48402fe8..ca4ec2e5 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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])); diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index 050b0572..f8e1b7b3 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -245,9 +245,18 @@ export abstract class WorldViewport 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 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, }; diff --git a/src/viewport/drawOrder.ts b/src/viewport/drawOrder.ts index e63118b3..de0e4150 100644 --- a/src/viewport/drawOrder.ts +++ b/src/viewport/drawOrder.ts @@ -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 { - const rows = new Map(); +type LayerRows = ReadonlyMap; + +/** Per list of tiles, which is the same one for as long as the layer's cells are (`layerTiles`). */ +const rowsOf = new WeakMap(); + +/** + * 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(); 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 { diff --git a/src/viewport/tilemap.ts b/src/viewport/tilemap.ts index 43db7f3e..a3e6c4f0 100644 --- a/src/viewport/tilemap.ts +++ b/src/viewport/tilemap.ts @@ -36,6 +36,10 @@ export function layerAlpha(layerId: string, focus: LayerFocus | null): number { export class TilemapField { private readonly layers = new Map(); private readonly meshes = new Map(); + /** What each mesh's geometry was built from. */ + private readonly built = new Map(); + /** 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}`; diff --git a/src/viewport/tilemapMesh.ts b/src/viewport/tilemapMesh.ts index e7b75e3a..56847636 100644 --- a/src/viewport/tilemapMesh.ts +++ b/src/viewport/tilemapMesh.ts @@ -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 { +const grouped = new WeakMap>(); + +/** + * 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 { + const held = grouped.get(tiles); + if (held) return held; const groups = new Map(); 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; } diff --git a/tests/core/physics-system.test.ts b/tests/core/physics-system.test.ts index d4d8846e..7a5159b2 100644 --- a/tests/core/physics-system.test.ts +++ b/tests/core/physics-system.test.ts @@ -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'); diff --git a/tests/core/tilemap-memo.test.ts b/tests/core/tilemap-memo.test.ts index 5c7d40ef..3fc392d9 100644 --- a/tests/core/tilemap-memo.test.ts +++ b/tests/core/tilemap-memo.test.ts @@ -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 = { '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 = { '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(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); diff --git a/tests/viewport/sort-by-y.test.ts b/tests/viewport/sort-by-y.test.ts index 2e9a8316..6dae5382 100644 --- a/tests/viewport/sort-by-y.test.ts +++ b/tests/viewport/sort-by-y.test.ts @@ -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(); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 4fa292be..05d32e61 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -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(`