import { beforeAll, describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; import { AddTilemapLayerCommand, PaintTilesCommand, RenameTilemapLayerCommand, } from '../../src/core/commands/tilemapCommands'; import type { TilemapAsset } from '../../src/core/model/Asset'; import { cellToWorld, layerNamed, liveLayers, liveTileAt, withTile, worldToCell, } from '../../src/core/model/liveTilemap'; import { transformMatrix, translation } from '../../src/core/model/matrix'; import { findAsset } from '../../src/core/model/Project'; import { TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, tilemapLayerNames } from '../../src/core/model/tilemap'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { collectHolderItems, collectWorldItems } from '../../src/viewport/items'; import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers'; const layer = (id: string, name: string, cells: Record = {}) => ({ id, name, cells }); const asset = (): TilemapAsset => ({ id: 'map', type: 'tilemap', name: 'Map', props: {}, layers: [layer('l1', 'Ground', { '0,0': 'grass', '1,0': 'grass' }), layer('l2', 'Top'), layer('l3', 'Top')], }); const GRID = { [TILEMAP_COLUMNS_KEY]: 4, [TILEMAP_ROWS_KEY]: 2, tileSize: 10 }; describe('the cells a run sees (#135)', () => { it('a layer is found by its name, no name is the first, two of one name are the upper one', () => { const map = asset(); expect(layerNamed(map, 'Ground')?.id).toBe('l1'); expect(layerNamed(map, '')?.id).toBe('l1'); expect(layerNamed(map, 'Top')?.id).toBe('l2'); expect(layerNamed(map, 'ground')).toBeUndefined(); expect(layerNamed({ ...map, layers: [] }, '')).toBeUndefined(); }); it('edits lie over the cells, an empty one erasing, and leave the asset alone', () => { const map = asset(); let edits = withTile(map, undefined, GRID, 'Ground', { x: 2, y: 1 }, 'water')!; edits = withTile(map, edits, GRID, '', { x: 0, y: 0 }, '')!; expect(edits).toEqual({ l1: { '2,1': 'water', '0,0': '' } }); expect(liveLayers(map, edits)[0]!.cells).toEqual({ '1,0': 'grass', '2,1': 'water' }); // The other layers are the asset's own, untouched. expect(liveLayers(map, edits)[1]).toBe(map.layers[1]); expect(map.layers[0]!.cells).toEqual({ '0,0': 'grass', '1,0': 'grass' }); expect(liveTileAt(map, edits, GRID, 'Ground', { x: 2, y: 1 })).toBe('water'); expect(liveTileAt(map, edits, GRID, 'Ground', { x: 0, y: 0 })).toBe(''); expect(liveTileAt(map, edits, GRID, 'Ground', { x: 1, y: 0 })).toBe('grass'); }); it('a cell off the grid or on a missing layer cannot be read or set; setting what is there changes nothing', () => { const map = asset(); for (const cell of [ { x: 4, y: 0 }, { x: 0, y: 2 }, { x: -1, y: 0 }, ]) { expect(liveTileAt(map, undefined, GRID, '', cell)).toBeNull(); expect(withTile(map, undefined, GRID, '', cell, 'water')).toBeNull(); } expect(withTile(map, undefined, GRID, 'Sky', { x: 0, y: 0 }, 'water')).toBeNull(); const edits = withTile(map, undefined, GRID, '', { x: 3, y: 1 }, 'water')!; expect(withTile(map, edits, GRID, '', { x: 3, y: 1 }, 'water')).toBe(edits); }); it('offers the layer names of every tilemap, each once', () => { const f = makeSpriteScene('Hero'); const a = new CreateAssetCommand('tilemap', 'A'); const b = new CreateAssetCommand('tilemap', 'B'); f.stack.execute(a); f.stack.execute(b); const add = new AddTilemapLayerCommand(b.asset.id); f.stack.execute(add); f.stack.execute(new RenameTilemapLayerCommand(b.asset.id, add.layerId, 'Walls')); expect(tilemapLayerNames(f.project)).toEqual(['Layer 1', 'Walls']); }); }); describe('world and cell (#135)', () => { it('a map moved by (100, 50): cells from the top-left corner, the middle of each cell back', () => { const m = translation(100, 50); // 4 x 2 cells of 10 around (100, 50): the grid runs from (80, 40) to (120, 60). expect(worldToCell(GRID, m, { x: 80, y: 40 })).toEqual({ x: 0, y: 0, inside: true }); expect(worldToCell(GRID, m, { x: 119.9, y: 59.9 })).toEqual({ x: 3, y: 1, inside: true }); expect(worldToCell(GRID, m, { x: 120, y: 50 })).toEqual({ x: 4, y: 1, inside: false }); expect(worldToCell(GRID, m, { x: 79, y: 39 })).toEqual({ x: -1, y: -1, inside: false }); expect(cellToWorld(GRID, m, { x: 0, y: 0 })).toEqual({ x: 85, y: 45 }); expect(cellToWorld(GRID, m, { x: 3, y: 1 })).toEqual({ x: 115, y: 55 }); }); it('a turned, scaled and mirrored map: every cell middle comes back to its cell', () => { for (const m of [ transformMatrix(30, -20, 90, 2, false, false), transformMatrix(-7, 3, 33, 0.5, true, false), transformMatrix(0, 0, 180, 3, false, true), ]) for (let y = 0; y < 2; y++) for (let x = 0; x < 4; x++) expect(worldToCell(GRID, m, cellToWorld(GRID, m, { x, y }))).toEqual({ x, y, inside: true }); }); it('turned a quarter clockwise and doubled, the top-left cell lies to the right of the origin, at the top', () => { const m = transformMatrix(0, 0, 90, 2, false, false); // Its middle (-15, -5) in the map's frame turns to (5, -15) and doubles to (10, -30). const middle = cellToWorld(GRID, m, { x: 0, y: 0 }); expect(middle.x).toBeCloseTo(10); expect(middle.y).toBeCloseTo(-30); expect(worldToCell(GRID, m, { x: 10, y: -30 })).toEqual({ x: 0, y: 0, inside: true }); // Scaled to nothing, there are no cells to fall into. expect(worldToCell(GRID, transformMatrix(0, 0, 0, 0, false, false), { x: 0, y: 0 }).inside).toBe(false); }); }); /** A 10 x 10 map of cells of 48 around the origin, placed twice, and a hero with a collider on it. */ function setup() { const f = makeSpriteScene('Hero'); f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 40)); f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 40)); const wall = new CreateAssetCommand('tile', 'Wall'); const map = new CreateAssetCommand('tilemap', 'Ground'); for (const command of [wall, map]) f.stack.execute(command); f.stack.execute(new SetAssetPropertyCommand(wall.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 first = place(f, map.asset.id, { x: 0, y: 0 }); const second = place(f, map.asset.id, { x: 2000, y: 0 }); const hero = place(f, f.spriteId, { x: 0, y: 0 }); f.stack.execute(new AddColliderCommand(f.sceneId, hero)); const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset; return { ...f, wallId: wall.asset.id, mapId: map.asset.id, first, second, hero, assetOf }; } /** Cell column 6: its left face lies at x = 48, 28 to the right of the hero's edge. */ const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => ({ x: 6, y })); describe.each(COLLISION_SYSTEMS)('$system: a tile set in a run (#135)', ({ physics }) => { beforeAll(async () => { await loadPhysics(); }); it('blocks at once, only on its own placement, and never reaches the asset', () => { const s = setup(); const { world, collisions } = startWorld(s, { physics, collisions: true }); const map = world.findByObjectId(s.first)!; const other = world.findByObjectId(s.second)!; const hero = world.findByObjectId(s.hero)!; for (const cell of COLUMN_SIX) expect(world.setTile(map, '', cell, s.wallId)).toBe(true); // No tick in between: the move already meets the new wall. expect(collisions.moveAndSlide(world, hero, 97.3, 0).blockedX).toBe(true); expect(hero.x).toBeCloseTo(28, 9); expect(world.tileAt(map, 'Layer 1', { x: 6, y: 3 })).toBe(s.wallId); expect(world.tileAt(other, 'Layer 1', { x: 6, y: 3 })).toBe(''); expect(s.assetOf().layers[0]!.cells).toEqual({}); // Emptied again, the way is free. for (const cell of COLUMN_SIX) world.setTile(map, '', cell, ''); expect(collisions.moveAndSlide(world, hero, 97.3, 0).blockedX).toBe(false); }); it('refuses what it cannot set: no tile, no tilemap, no such layer, off the grid', () => { const s = setup(); const { world } = startWorld(s, { physics, collisions: true }); const map = world.findByObjectId(s.first)!; const hero = world.findByObjectId(s.hero)!; expect(world.setTile(map, '', { x: 0, y: 0 }, s.spriteId)).toBe(false); expect(world.setTile(hero, '', { x: 0, y: 0 }, s.wallId)).toBe(false); expect(world.setTile(map, 'Sky', { x: 0, y: 0 }, s.wallId)).toBe(false); expect(world.setTile(map, '', { x: 10, y: 0 }, s.wallId)).toBe(false); expect(world.tileAt(hero, '', { x: 0, y: 0 })).toBeNull(); expect(world.isTilemap(map) && !world.isTilemap(hero)).toBe(true); expect(world.takeTileChange()).toBe(false); world.setTile(map, '', { x: 0, y: 0 }, s.wallId); expect(world.takeTileChange()).toBe(true); expect(world.takeTileChange()).toBe(false); }); }); describe('a cell naming a tile the project does not have (#268)', () => { it('reads as empty in a run, the way it is drawn', () => { const s = setup(); const layerId = s.assetOf().layers[0]!.id; s.stack.execute( new PaintTilesCommand(s.mapId, layerId, [ { x: 1, y: 1, tileId: 'gone' }, { x: 2, y: 1, tileId: s.wallId }, ]), ); const { world } = startWorld(s); const map = world.findByObjectId(s.first)!; expect(world.tileAt(map, '', { x: 1, y: 1 })).toBe(''); expect(world.tileAt(map, '', { x: 2, y: 1 })).toBe(s.wallId); }); }); describe('a tile set in a run is drawn (#135)', () => { const cellsDrawn = (tilemap: { layers: { tiles: readonly { x: number; y: number }[] }[] } | null) => tilemap?.layers[0]!.tiles.map(({ x, y }) => `${x},${y}`); it('in the played world, on its own placement only', () => { const s = setup(); s.stack.execute( new PaintTilesCommand(s.mapId, s.assetOf().layers[0]!.id, [{ x: 1, y: 1, tileId: s.wallId }]), ); const { world } = startWorld(s); world.setTile(world.findByObjectId(s.first)!, '', { x: 2, y: 1 }, s.wallId); world.setTile(world.findByObjectId(s.first)!, '', { x: 1, y: 1 }, ''); const items = collectWorldItems(world).items; expect(cellsDrawn(items.find((i) => i.id === s.first)!.tilemap)).toEqual(['2,1']); expect(cellsDrawn(items.find((i) => i.id === s.second)!.tilemap)).toEqual(['1,1']); }); it('the autotile edges of the cells around it follow the live cells', () => { const s = setup(); s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_KIND_KEY, 'floor')); const { world } = startWorld(s); const map = world.findByObjectId(s.first)!; const quarters = () => collectWorldItems(world) .items.find((i) => i.id === s.first)! .tilemap!.layers[0]!.tiles.find((t) => t.x === 2 && t.y === 1)!.quadrants; world.setTile(map, '', { x: 2, y: 1 }, s.wallId); const alone = quarters(); world.setTile(map, '', { x: 3, y: 1 }, s.wallId); // A neighbour to the east turns the two right quarters from an edge into a joint. expect(quarters()).not.toEqual(alone); expect([quarters()[0], quarters()[2]]).toEqual([alone[0], alone[2]]); world.setTile(map, '', { x: 3, y: 1 }, ''); expect(quarters()).toEqual(alone); }); it('in the editor run over the scene it runs on, and nowhere else', () => { const s = setup(); const { world } = startWorld(s); world.setTile(world.findByObjectId(s.first)!, '', { x: 4, y: 4 }, s.wallId); const drawn = (run: typeof world | null) => cellsDrawn( collectHolderItems(s.project, s.sceneOf(), 0, run).items.find((i) => i.id === s.first)!.tilemap, ); expect(drawn(world)).toEqual(['4,4']); expect(drawn(null)).toEqual([]); }); });