import { describe, expect, it } 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, TilemapLayer } from '../../src/core/model/Asset'; import { findAsset } from '../../src/core/model/Project'; import type { PropertyValue } from '../../src/core/model/Properties'; import { TILE_COLLISION_SCALE_KEY, TILE_FRICTION_KEY, TILE_KIND_KEY, TILE_ONE_WAY_ANGLE_KEY, TILE_ONE_WAY_KEY, TILE_SOLID_KEY, TILE_TRIGGER_KEY, } from '../../src/core/model/tile'; import { cellKey, cellsAround, layerTiles, patchedInOrder, TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY, type TileDraw, } from '../../src/core/model/tilemap'; import { oneWayAngles, solidPolygons, solidRects, tileMaterials, triggerCells, triggerPolygons, triggerRects, } from '../../src/core/model/tilemapCollision'; import { cellsChanged, cellsChangedSince, cellsTileIds, cellsVersion, } from '../../src/core/model/tilemapMemo'; import { layerRows } from '../../src/viewport/drawOrder'; import type { TilemapDraw, TilemapLayerDraw } from '../../src/viewport/items'; import { groupByTile } from '../../src/viewport/tilemapMesh'; import { makeSpriteScene } from '../helpers'; const COLUMNS = 12; const ROWS = 9; /** * A 12 × 9 map of two layers and a tile of every kind that works out differently: plain and * autotiles, with and without outlines, drawn in and out, one-way at two angles, of another * surface, triggers, one that draws only – and an id no tile has. */ function patchMap() { const f = makeSpriteScene('Thing'); const texture = new CreateAssetCommand('texture', 'Sheet'); f.stack.execute(texture); const sheet = findAsset(f.project, texture.asset.id) as TextureAsset; sheet.file = `${'a'.repeat(64)}.png`; sheet.width = 128; sheet.height = 128; const frame = (x: number, y: number, w: number, h: number) => { const command = new AddFrameCommand(texture.asset.id, { x, y, w, h }); f.stack.execute(command); return command.frameId; }; const single = frame(0, 0, 32, 32); const outlined = frame(0, 32, 64, 96); const bare = frame(64, 32, 64, 96); f.stack.execute( new SetFrameCollisionCommand(texture.asset.id, single, [ [ { x: 0, y: 0 }, { x: 32, y: 0 }, { x: 0, y: 32 }, ], ]), ); f.stack.execute( new SetFrameCollisionCommand(texture.asset.id, outlined, [ [ { x: 0, y: 0 }, { x: 64, y: 0 }, { x: 0, y: 96 }, ], ]), ); const tile = (name: string, props: Record, picture?: string) => { const command = new CreateAssetCommand('tile', name); f.stack.execute(command); const id = command.asset.id; if (picture) f.stack.execute(new AddTilePictureCommand(id, picture)); f.stack.execute(new SetAssetPropertyCommand(id, TILE_KIND_KEY, 'none')); for (const [key, value] of Object.entries(props)) f.stack.execute(new SetAssetPropertyCommand(id, key, value)); return id; }; const solid = { [TILE_SOLID_KEY]: true }; const floor = { [TILE_KIND_KEY]: 'floor' }; const tiles = [ tile('Slope', solid, single), tile('Lake', { ...solid, ...floor }, outlined), tile('Grass', { ...solid, ...floor, [TILE_COLLISION_SCALE_KEY]: 0.5 }, bare), tile('Wall', solid), tile('Bush', { ...solid, [TILE_COLLISION_SCALE_KEY]: 0.5 }), tile('Big', { ...solid, [TILE_COLLISION_SCALE_KEY]: 1.5 }), tile('Ledge', { ...solid, [TILE_ONE_WAY_KEY]: true, [TILE_ONE_WAY_ANGLE_KEY]: 30 }), tile('Steep', { ...solid, [TILE_ONE_WAY_KEY]: true, [TILE_ONE_WAY_ANGLE_KEY]: 60 }, bare), tile('Ice', { ...solid, [TILE_FRICTION_KEY]: 0 }), tile('Water', { [TILE_TRIGGER_KEY]: true, ...floor }, bare), tile('Spikes', { [TILE_TRIGGER_KEY]: true }, single), tile('Deco', floor, bare), 'no-such-tile', ]; const map = new CreateAssetCommand('tilemap', 'Ground'); f.stack.execute(map); for (const [key, value] of [ [TILEMAP_COLUMNS_KEY, COLUMNS], [TILEMAP_ROWS_KEY, ROWS], [TILEMAP_TILE_KEY, 32], ] as const) f.stack.execute(new SetAssetPropertyCommand(map.asset.id, key, value)); const asset = findAsset(f.project, map.asset.id) as TilemapAsset; asset.layers.push({ id: 'upper', name: 'Upper', cells: {} }); return { ...f, asset, tiles }; } /** A small seeded generator, so a failing walk can be walked again. */ function random(seed: number): () => number { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Everything a map works out, as plain data: tiles per layer, solids per filter, triggers. */ function workedOut(m: ReturnType, asset: TilemapAsset) { const props = m.asset.props; const solidsOf = (of: TilemapAsset) => ({ rects: solidRects(m.project, props, of), polygons: solidPolygons(m.project, props, of), oneWay: oneWayAngles(m.project, of).map((angle) => ({ angle, rects: solidRects(m.project, props, of, angle), polygons: solidPolygons(m.project, props, of, angle), })), materials: tileMaterials(m.project, of).map((material) => ({ material, rects: solidRects(m.project, props, of, false, material), polygons: solidPolygons(m.project, props, of, false, material), })), triggerRects: triggerRects(m.project, props, of), triggerPolygons: triggerPolygons(m.project, props, of), triggerCells: [...triggerCells(m.project, props, of)], }); return { tiles: asset.layers.map((layer) => layerTiles(m.project, props, layer)), whole: solidsOf(asset), perLayer: asset.layers.map((layer) => solidsOf({ ...asset, layers: [layer] })), }; } /** * A layer's tiles as the meshes take them (#424): grouped by tile, and by sort row and tile with * offsets that move some tiles a row up or down. Each next to the same grouping of a copy of the * list, which nothing was patched from; `reused` counts the groups handed on from the last call. */ function grouping(m: ReturnType, layer: TilemapLayer, last: Set) { const draw = { tiles: new Map(m.tiles.map((id, i) => [id, { sortOffset: (i % 4) - 1 }])), } as unknown as TilemapDraw; const tiles = layerTiles(m.project, m.asset.props, layer); const of = (list: readonly TileDraw[]) => { const rows = layerRows(draw, { tiles: list } as TilemapLayerDraw); return { byTile: [...groupByTile(list)], byRow: [...rows].map(([row, cells]) => [row, cells, [...groupByTile(cells)]] as const), }; }; const patched = of(tiles); const groups = [ ...patched.byTile.map(([, group]) => group), ...patched.byRow.flatMap(([, cells, byTile]) => [cells, ...byTile.map(([, group]) => group)]), ]; const reused = groups.filter((group) => last.has(group)).length; return { patched, fresh: of([...tiles]), reused, groups: new Set(groups) }; } /** The same map on cells never seen before, so nothing of it is patched. */ function afresh(asset: TilemapAsset): TilemapAsset { return { ...asset, layers: asset.layers.map((layer) => ({ ...layer, cells: { ...layer.cells } })) }; } describe('a painted map works out only the cells around what changed (#348)', () => { it('knows which cells changed since a version, and when it does not know', () => { const cells: Record = { '0,0': 'a', '1,0': 'b' }; const first = cellsVersion(cells); expect(cellsChangedSince(cells, first)).toEqual([]); cells['2,0'] = 'a'; cellsChanged(cells, ['2,0']); const second = cellsVersion(cells); delete cells['0,0']; cells['1,0'] = 'c'; // Named and not, the same: one walk finds what the painter left out. cellsChanged(cells); expect([...cellsChangedSince(cells, first)!].sort()).toEqual(['0,0', '1,0', '2,0']); expect([...cellsChangedSince(cells, second)!].sort()).toEqual(['0,0', '1,0']); expect(cellsTileIds(cells)).toEqual(['a', 'c']); expect(cellsChangedSince(cells, -1)).toBeNull(); expect(cellsChangedSince({ ...cells }, second)).toBeNull(); }); it('a key named but written back as it was is no change', () => { const cells: Record = { '0,0': 'a' }; const first = cellsVersion(cells); cells['0,0'] = 'b'; cells['0,0'] = 'a'; cellsChanged(cells, ['0,0', '5,5']); expect(cellsVersion(cells)).toBe(first); }); it('the cells around a change stay in the grid', () => { expect(cellsAround(['0,0'], 3, 2)).toEqual([0, 1, 3, 4]); expect(cellsAround(['2,1', '1,1'], 3, 2)).toEqual([0, 1, 2, 3, 4, 5]); expect(cellsAround(['9,9', 'nonsense'], 3, 2)).toEqual([]); }); it('a patch replaces the entries of the cells it works out, keeps the rest and their order', () => { const entry = (at: number, name: string) => ({ at, name }); const list = [entry(1, 'a'), entry(3, 'b'), entry(3, 'c'), entry(7, 'd')]; const made: Record[]> = { 0: [entry(0, 'new')], 3: [], 5: [entry(5, 'x'), entry(5, 'y')], }; const patched = patchedInOrder( list, (e) => e.at, [0, 3, 5], (at) => made[at]!, ); expect(patched.map((e) => e.name)).toEqual(['new', 'a', 'x', 'y', 'd']); expect(patched[1]).toBe(list[0]); }); it('patched and worked out afresh agree after every step of a long walk of painting', () => { const m = patchMap(); const next = random(348); const pick = (list: readonly T[]) => list[Math.floor(next() * list.length)]!; const somewhere = () => ({ x: Math.floor(next() * (COLUMNS + 2)) - 1, y: Math.floor(next() * (ROWS + 2)) - 1, }); const layerOf = (): TilemapLayer => pick(m.asset.layers); const counts = { paint: 0, undo: 0, named: 0, unnamed: 0 }; workedOut(m, m.asset); const lastGroups = m.asset.layers.map((layer) => grouping(m, layer, new Set()).groups); let reused = 0; for (let step = 0; step < 400; step++) { const roll = next(); if (roll < 0.5) { const layer = layerOf(); const edits = Array.from({ length: 1 + Math.floor(next() * 4) }, () => { const at = somewhere(); return { ...at, tileId: next() < 0.15 ? '' : pick(m.tiles) }; }); m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, edits)); counts.paint++; } else if (roll < 0.65 && m.stack.canUndo) { m.stack.undo(); counts.undo++; } else { // In place, as a stroke paints before it is a command: with its keys, or without. const layer = layerOf(); const keys = Array.from({ length: 1 + Math.floor(next() * 3) }, () => { const at = somewhere(); const key = cellKey(at.x, at.y); const tileId = next() < 0.3 ? '' : pick(m.tiles); if (tileId) layer.cells[key] = tileId; else delete layer.cells[key]; return key; }); const named = next() < 0.5; cellsChanged(layer.cells, named ? keys : undefined); counts[named ? 'named' : 'unnamed']++; } const patched = workedOut(m, m.asset); const fresh = workedOut(m, afresh(m.asset)); expect(patched, `step ${step}`).toEqual(fresh); m.asset.layers.forEach((layer, i) => { const groups = grouping(m, layer, lastGroups[i]!); expect(groups.patched, `step ${step}, layer ${i}`).toEqual(groups.fresh); reused += groups.reused; lastGroups[i] = groups.groups; }); } // The walk went everywhere it should: enough of each step, and maps that block in every way. expect(Math.min(...Object.values(counts))).toBeGreaterThan(20); expect(reused).toBeGreaterThan(1000); const end = workedOut(m, m.asset); expect(end.whole.rects.length).toBeGreaterThan(5); expect(end.whole.polygons.length).toBeGreaterThan(5); expect(end.whole.oneWay).toHaveLength(2); expect(end.whole.triggerCells.length).toBeGreaterThan(3); }); it('a cell two rows off is cut again when the autotile between turns into a box', () => { const m = patchMap(); const [, lake, , , bush, big] = m.tiles; const layer = m.asset.layers[0]!; // Found by a search: erasing (3,1) makes the lake at (3,2) block whole, so the big box at (3,3) // – drawn out on every side without a blocking neighbour – no longer grows upwards. const rows = [ [bush, lake, lake, lake], [lake, '', lake, lake], ['', lake, lake, lake], [lake, '', lake, big], ]; const edits = rows.flatMap((row, y) => row.map((tileId, x) => ({ x, y, tileId: tileId! }))); m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, edits)); solidRects(m.project, m.asset.props, m.asset); m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 3, y: 1, tileId: '' }])); expect(solidRects(m.project, m.asset.props, m.asset)).toEqual( solidRects(m.project, m.asset.props, afresh(m.asset)), ); }); it('a painted cell leaves what is drawn and what blocks far from it the very same objects', () => { const m = patchMap(); const [slope, lake] = m.tiles; const layer = m.asset.layers[0]!; const all = Array.from({ length: COLUMNS * ROWS }, (_, i) => ({ x: i % COLUMNS, y: Math.floor(i / COLUMNS), tileId: i % COLUMNS < 6 ? lake! : slope!, })); m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, all)); const tiles = layerTiles(m.project, m.asset.props, layer); const polygons = solidPolygons(m.project, m.asset.props, m.asset); m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 2, y: 4, tileId: '' }])); const after = layerTiles(m.project, m.asset.props, layer); expect(after).not.toBe(tiles); expect(after.find((t) => t.x === 10 && t.y === 0)).toBe(tiles.find((t) => t.x === 10 && t.y === 0)); const far = (list: readonly { x: number; y: number }[]) => list.filter((p) => p.y === 8 && p.x === 0); expect(far(solidPolygons(m.project, m.asset.props, m.asset))).toEqual(far(polygons)); expect(far(solidPolygons(m.project, m.asset.props, m.asset))[0]).toBe(far(polygons)[0]); }); it('a painted cell regroups only the tiles and rows it touches, so only their meshes are built (#424)', () => { const m = patchMap(); const [slope, lake, , wall] = m.tiles; const layer = m.asset.layers[0]!; const all = Array.from({ length: COLUMNS * ROWS }, (_, i) => ({ x: i % COLUMNS, y: Math.floor(i / COLUMNS), tileId: i % COLUMNS < 6 ? lake! : slope!, })); // A wall in a corner already: a tile new to the layer would work all of it out again (#348). all[all.length - 1]!.tileId = wall!; m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, all)); const draw = { tiles: new Map(m.tiles.map((id) => [id, { sortOffset: 0 }])) } as unknown as TilemapDraw; const tiles = layerTiles(m.project, m.asset.props, layer); const groups = groupByTile(tiles); const rows = layerRows(draw, { tiles } as TilemapLayerDraw); // A wall on the slopes, away from the lake: the lake looks the same, so its group stays. m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 10, y: 4, tileId: wall! }])); const after = layerTiles(m.project, m.asset.props, layer); const regrouped = groupByTile(after); expect(regrouped.get(lake!)).toBe(groups.get(lake!)); expect(regrouped.get(slope!)).not.toBe(groups.get(slope!)); expect(regrouped.get(wall!)!.map((t) => [t.x, t.y])).toEqual([ [10, 4], [11, 8], ]); const rowsAfter = layerRows(draw, { tiles: after } as TilemapLayerDraw); expect([...rowsAfter.keys()].filter((row) => rowsAfter.get(row) !== rows.get(row))).toEqual([4]); // And back to a slope, a patch on a patch: still the same lake, and the same as grouped afresh. m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 10, y: 4, tileId: slope! }])); const back = layerTiles(m.project, m.asset.props, layer); expect(groupByTile(back).get(lake!)).toBe(groups.get(lake!)); expect([...groupByTile(back)]).toEqual([...groupByTile([...back])]); }); });