import { afterEach, beforeAll, describe, expect, it, vi } 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 } from '../../src/core/model/Asset'; import { liveLayers } from '../../src/core/model/liveTilemap'; import { findAsset } from '../../src/core/model/Project'; import { TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile'; import { layerTiles, TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY, } from '../../src/core/model/tilemap'; import { solidPolygons, solidRects } from '../../src/core/model/tilemapCollision'; 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'; beforeAll(async () => { await loadPhysics(); }); afterEach(() => { vi.restoreAllMocks(); }); const TRIANGLE = [ { x: 0, y: 0 }, { x: 32, y: 0 }, { x: 0, y: 32 }, ]; /** * A 4 × 4 map of 32 px cells with two tiles: `box` blocks as a box, `cut` with a triangle drawn on * its picture. Both lie on the one layer, `box` along the top row and `cut` at (1,2). */ function map() { const f = makeSpriteScene('Thing'); const texture = new CreateAssetCommand('texture', 'Sheet'); const box = new CreateAssetCommand('tile', 'Box'); const cut = new CreateAssetCommand('tile', 'Cut'); const tilemap = new CreateAssetCommand('tilemap', 'Ground'); for (const command of [texture, box, cut, tilemap]) f.stack.execute(command); const sheet = findAsset(f.project, texture.asset.id) as TextureAsset; sheet.file = `${'a'.repeat(64)}.png`; sheet.width = 32; sheet.height = 32; const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 }); f.stack.execute(frame); f.stack.execute(new AddTilePictureCommand(cut.asset.id, frame.frameId)); f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [TRIANGLE])); for (const tile of [box, cut]) { f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true)); f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, 'none')); } for (const [key, value] of [ [TILEMAP_COLUMNS_KEY, 4], [TILEMAP_ROWS_KEY, 4], [TILEMAP_TILE_KEY, 32], ] as const) f.stack.execute(new SetAssetPropertyCommand(tilemap.asset.id, key, value)); const mapId = place(f, tilemap.asset.id, { x: 0, y: 0 }); const asset = findAsset(f.project, tilemap.asset.id) as TilemapAsset; const layer = asset.layers[0]!; const top = [0, 1, 2, 3].map((x) => ({ x, y: 0, tileId: box.asset.id })); f.stack.execute(new PaintTilesCommand(asset.id, layer.id, [...top, { x: 1, y: 2, tileId: cut.asset.id }])); return { ...f, mapId, asset, layer, boxId: box.asset.id, cutId: cut.asset.id, textureId: texture.asset.id, frameId: frame.frameId, props: asset.props, }; } 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); paint('2,0', 'a'); const added = cellsVersion(cells); expect(added).not.toBe(first); paint('2,0', 'b'); const changed = cellsVersion(cells); expect(changed).not.toBe(added); 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]); const a = memo.get('a', make); expect(memo.get('a', make)).toBe(a); expect(make).toHaveBeenCalledTimes(1); memo.get('b', make); memo.get('c', make); expect(memo.get('a', make)).not.toBe(a); expect(make).toHaveBeenCalledTimes(4); }); it('asking again for an unchanged map hands out the very same tiles and solids', () => { const m = map(); expect(layerTiles(m.project, m.props, m.layer)).toBe(layerTiles(m.project, m.props, m.layer)); expect(solidRects(m.project, m.props, m.asset)).toBe(solidRects(m.project, m.props, m.asset)); expect(solidPolygons(m.project, m.props, m.asset)).toBe(solidPolygons(m.project, m.props, m.asset)); }); it('a cell painted in place, as a stroke does before it is a command, is seen at once', () => { const m = map(); 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([]); }); it('a tile no longer solid, a new outline and a new kind are each seen at once', () => { const m = map(); const polygons = solidPolygons(m.project, m.props, m.asset); expect(polygons).toHaveLength(1); const other = [ { x: 32, y: 0 }, { x: 32, y: 32 }, { x: 0, y: 32 }, ]; m.stack.execute(new SetFrameCollisionCommand(m.textureId, m.frameId, [other])); const turned = solidPolygons(m.project, m.props, m.asset); expect(turned).not.toBe(polygons); expect(turned[0]!.outline).not.toEqual(polygons[0]!.outline); m.stack.execute(new SetAssetPropertyCommand(m.boxId, TILE_SOLID_KEY, false)); expect(solidRects(m.project, m.props, m.asset)).toEqual([]); const before = layerTiles(m.project, m.props, m.layer).find((t) => t.tileId === m.boxId)!; m.stack.execute(new SetAssetPropertyCommand(m.boxId, TILE_KIND_KEY, 'floor')); const after = layerTiles(m.project, m.props, m.layer).find((t) => t.tileId === m.boxId)!; expect(after.quadrants).not.toEqual(before.quadrants); }); it('a run lays the same edits over a layer once, and again when the layer was painted meanwhile', () => { const m = map(); const edits = { [m.layer.id]: { '3,3': m.boxId } }; 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); expect(painted.cells['3,3']).toBe(m.boxId); m.layer.z = 3; expect(liveLayers(m.asset, edits)[0]!.z).toBe(3); }); it('the physics works a still map out once, and again once it is moved', () => { const m = map(); const shapesOf = vi.spyOn(Solids.prototype, 'solidShapesOf'); const s = startWorld(m, { physics: true, collisions: true }); const mapWorkedOut = () => shapesOf.mock.calls.filter(([, body, within]) => body.objectId === m.mapId && !within).length; for (let i = 0; i < 5; i++) s.frame(); expect(mapWorkedOut()).toBe(1); s.world.findByObjectId(m.mapId)!.x += 10; s.frame(); expect(mapWorkedOut()).toBe(2); s.frame(); expect(mapWorkedOut()).toBe(2); }); });