import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddTilemapLayerCommand, PaintTilesCommand, SetTilemapLayerHiddenCommand, } from '../../src/core/commands/tilemapCommands'; import type { TilemapAsset } from '../../src/core/model/Asset'; import { transformMatrix, translation } from '../../src/core/model/matrix'; import { findAsset } from '../../src/core/model/Project'; import { isTileSolid, TILE_SOLID_KEY } from '../../src/core/model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap'; import { type CellRect, cellRectShape, mergeCells, solidCells, solidRects, } from '../../src/core/model/tilemapCollision'; import { schemaFor } from '../../src/core/properties/PropertySchema'; import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y })); /** Every cell of a rectangle list, so a check can see nothing was lost and nothing doubled. */ function covered(rects: CellRect[]): string[] { const out: string[] = []; for (const r of rects) for (let y = r.y; y < r.y + r.h; y++) for (let x = r.x; x < r.x + r.w; x++) out.push(`${x},${y}`); return out.sort(); } describe('merging solid cells into rectangles', () => { it('nothing, one cell, a run and a block', () => { expect(mergeCells([])).toEqual([]); expect(mergeCells(cells([3, 4]))).toEqual([{ x: 3, y: 4, w: 1, h: 1 }]); expect(mergeCells(cells([2, 0], [0, 0], [1, 0]))).toEqual([{ x: 0, y: 0, w: 3, h: 1 }]); expect(mergeCells(cells([0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1]))).toEqual([ { x: 0, y: 0, w: 3, h: 2 }, ]); }); it('a column is one rectangle; a gap in the rows or a different run starts a new one', () => { expect(mergeCells(cells([5, 0], [5, 1], [5, 2], [5, 3]))).toEqual([{ x: 5, y: 0, w: 1, h: 4 }]); expect(mergeCells(cells([5, 0], [5, 1], [5, 3]))).toEqual([ { x: 5, y: 0, w: 1, h: 2 }, { x: 5, y: 3, w: 1, h: 1 }, ]); // An L: the upright and the foot are two rectangles, the foot spanning its whole row. expect(mergeCells(cells([0, 0], [0, 1], [0, 2], [1, 2], [2, 2]))).toEqual([ { x: 0, y: 0, w: 1, h: 2 }, { x: 0, y: 2, w: 3, h: 1 }, ]); }); it('a ring keeps its hole; negative cells and doubles are fine', () => { const ring = cells([0, 0], [1, 0], [2, 0], [0, 1], [2, 1], [0, 2], [1, 2], [2, 2]); expect(mergeCells(ring)).toEqual([ { x: 0, y: 0, w: 3, h: 1 }, { x: 0, y: 1, w: 1, h: 1 }, { x: 2, y: 1, w: 1, h: 1 }, { x: 0, y: 2, w: 3, h: 1 }, ]); expect(mergeCells(cells([-2, -1], [-1, -1], [-2, -1]))).toEqual([{ x: -2, y: -1, w: 2, h: 1 }]); }); it('covers every cell exactly once, for any scatter', () => { let seed = 7; const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647; for (let round = 0; round < 20; round++) { const scatter = Array.from({ length: 150 }, () => ({ x: Math.floor(random() * 20) - 5, y: Math.floor(random() * 20) - 5, })); const unique = [...new Set(scatter.map((c) => `${c.x},${c.y}`))].sort(); const rects = mergeCells(scatter); expect(covered(rects)).toEqual(unique); // Fewer shapes than cells, or the merge did nothing. expect(rects.length).toBeLessThan(unique.length); } }); }); /** * A scene with a 10 × 10 map of 48 px cells placed at the origin – cell (x, y) spans * x·48 − 240 … x·48 − 192 – and a 40 × 40 hero with a collider at the origin. */ 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 grass = new CreateAssetCommand('tile', 'Grass'); const map = new CreateAssetCommand('tilemap', 'Ground'); for (const command of [wall, grass, 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 mapObject = place(f, map.asset.id, { x: 0, 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; const paint = (at: [number, number][], tileId = wall.asset.id, layerId = assetOf().layers[0]!.id) => f.stack.execute( new PaintTilesCommand( map.asset.id, layerId, cells(...at).map((c) => ({ ...c, tileId })), ), ); return { ...f, wallId: wall.asset.id, grassId: grass.asset.id, mapId: map.asset.id, mapObject, hero, assetOf, paint, }; } /** Cell column 6, all ten rows: its left face lies at x = 48. */ const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => [6, y] as [number, number]); describe('which cells block', () => { it('a tile is not solid until switched on, and the switch survives a round trip', () => { const s = setup(); expect(schemaFor('tile').find((d) => d.key === TILE_SOLID_KEY)).toMatchObject({ kind: 'boolean', default: false, }); const grass = findAsset(s.project, s.grassId); expect(grass?.type === 'tile' && isTileSolid(grass)).toBe(false); const wall = findAsset(roundTrip(s.project), s.wallId); expect(wall?.type === 'tile' && isTileSolid(wall)).toBe(true); }); it('solid tiles on every layer, a hidden one too; not grass, not outside the grid, not a deleted tile', () => { const s = setup(); s.paint([ [0, 0], [1, 0], [12, 0], [-1, 3], ]); // two outside the 10 × 10 grid s.paint([[2, 0]], s.grassId); const add = new AddTilemapLayerCommand(s.mapId); s.stack.execute(add); s.paint( [ [1, 0], [0, 5], ], s.wallId, add.layerId, ); // (1, 0) doubles the first layer s.stack.execute(new SetTilemapLayerHiddenCommand(s.mapId, add.layerId, true)); s.paint([[4, 4]], 'no-such-tile'); const props = s.assetOf().props; const found = solidCells(s.project, props, s.assetOf()) .map((c) => `${c.x},${c.y}`) .sort(); expect(found).toEqual(['0,0', '0,5', '1,0']); expect(solidRects(s.project, props, s.assetOf())).toEqual([ { x: 0, y: 0, w: 2, h: 1 }, { x: 0, y: 5, w: 1, h: 1 }, ]); }); it('a rectangle sits in the world as the placed map does: moved, grown and turned', () => { const props = { [TILEMAP_COLUMNS_KEY]: 10, [TILEMAP_ROWS_KEY]: 10 }; const rect = { x: 6, y: 0, w: 1, h: 10 }; expect(cellRectShape(props, rect, translation(0, 0))).toEqual({ kind: 'box', x: 72, y: 0, w: 48, h: 480, angle: 0, }); expect(cellRectShape(props, rect, translation(100, -50))).toMatchObject({ x: 172, y: -50 }); const turned = cellRectShape(props, rect, transformMatrix(0, 0, 90, 2, false, false)); expect(turned).toMatchObject({ kind: 'box', w: 96, h: 960 }); // Turned a quarter clockwise and doubled, the column's centre (72, 0) lands at (0, 144). expect(turned.x).toBeCloseTo(0); expect(turned.y).toBeCloseTo(144); }); }); describe('a tilemap in the CollisionSystem', () => { it('stops a move flush at a solid column and lets it leave again', () => { const s = setup(); s.paint(COLUMN_SIX); const { world, collisions } = startWorld(s, { collisions: true }); const hero = world.findByObjectId(s.hero)!; // Right edge at 20, the wall's face at 48: 28 of travel. expect(collisions.moveAndSlide(world, hero, 97.3, 0)).toEqual({ blockedX: true, blockedY: false }); expect(hero.x).toBeCloseTo(28, 9); expect(collisions.moveAndSlide(world, hero, 5, 0)).toEqual({ blockedX: true, blockedY: false }); expect(hero.x).toBeCloseTo(28, 9); expect(collisions.moveAndSlide(world, hero, -30, 0)).toEqual({ blockedX: false, blockedY: false }); expect(hero.x).toBeCloseTo(-2, 9); }); it('a step larger than a cell does not jump over the wall', () => { const s = setup(); s.paint(COLUMN_SIX); const { world, collisions } = startWorld(s, { collisions: true }); const hero = world.findByObjectId(s.hero)!; expect(collisions.moveAndSlide(world, hero, 5000, 0)).toEqual({ blockedX: true, blockedY: false }); expect(hero.x).toBeCloseTo(28, 6); }); it('slides along a wall made of several rectangles without catching on their seams', () => { const s = setup(); // The column with a bump on its far side in row 5: three rectangles stacked along the face. s.paint([...COLUMN_SIX, [7, 5]]); expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toHaveLength(3); const { world, collisions } = startWorld(s, { collisions: true }); const hero = world.findByObjectId(s.hero)!; hero.y = -200; collisions.moveAndSlide(world, hero, 97.3, 0); for (let i = 0; i < 60; i++) { // Pressed against the face while walking down past both seams. const { blockedX, blockedY } = collisions.moveAndSlide(world, hero, 3.1, 6.7); expect(blockedX).toBe(true); expect(blockedY).toBe(false); } expect(hero.x).toBeCloseTo(28, 6); expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6); }); it('grass blocks nothing, a switched-off map blocks nothing', () => { const s = setup(); s.paint(COLUMN_SIX, s.grassId); let started = startWorld(s, { collisions: true }); let hero = started.world.findByObjectId(s.hero)!; expect(started.collisions.moveAndSlide(started.world, hero, 100, 0).blockedX).toBe(false); s.paint(COLUMN_SIX); s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'enabled', false)); started = startWorld(s, { collisions: true }); hero = started.world.findByObjectId(s.hero)!; expect(started.collisions.moveAndSlide(started.world, hero, 100, 0).blockedX).toBe(false); }); it('follows the placed map: moved, grown and turned', () => { const s = setup(); s.paint([[6, 5]]); // one cell: x 48…96, y 0…48 at scale 1 s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'scale', 2)); // Doubled: the cell spans x 96…192, y 0…96. The hero at y = 40 is in its rows. const { world, collisions } = startWorld(s, { collisions: true }); const hero = world.findByObjectId(s.hero)!; hero.y = 40; collisions.moveAndSlide(world, hero, 300, 0); expect(hero.x).toBeCloseTo(76, 6); // The map moved on by 100 takes its cell along at once: the shapes follow its current place. const map = world.findByObjectId(s.mapObject)!; map.x = 100; collisions.moveAndSlide(world, hero, 300, 0); expect(hero.x).toBeCloseTo(176, 6); // Turned a quarter clockwise about its centre, the cell lies at x −96…0, y 96…192: out of the way. map.x = 0; map.props.rotation = 90; hero.x = 0; expect(collisions.moveAndSlide(world, hero, 300, 0).blockedX).toBe(false); }); it('picks up cells painted while running on the next tick', () => { const s = setup(); const { world, collisions } = startWorld(s, { collisions: true }); const hero = world.findByObjectId(s.hero)!; world.tick(0.1); s.paint(COLUMN_SIX); world.tick(0.1); expect(collisions.moveAndSlide(world, hero, 100, 0).blockedX).toBe(true); }); it('reports enter, stay and exit with the tilemap as a body of its own; two maps never meet', () => { const s = setup(); s.paint(COLUMN_SIX); place(s, s.mapId, { x: 0, y: 0 }); // a second placement right on top of the first const { world, collisions } = startWorld(s, { collisions: true }); const hero = world.findByObjectId(s.hero)!; world.tick(0.1); expect(collisions.enters).toHaveLength(0); hero.x = 40; world.tick(0.1); expect(collisions.enters).toHaveLength(2); // one per placed map, none between the maps expect(collisions.enters.every((p) => p.a.type === 'tilemap' || p.b.type === 'tilemap')).toBe(true); world.tick(0.1); expect(collisions.stays).toHaveLength(2); const map = world.findByObjectId(s.mapObject)!; expect(collisions.touching(world, hero, map)).toBe(true); hero.x = 0; world.tick(0.1); expect(collisions.exits).toHaveLength(2); expect(collisions.touching(world, hero, map)).toBe(false); }); }); describe('tilemap collisions in behaviors', () => { it('On collision enter names the tilemap as the other body; Is touching agrees', () => { const s = setup(); s.paint(COLUMN_SIX); const behavior = makeBehavior(s, 'Bump'); place(s, behavior.id, { x: 0, y: 0 }, s.hero); const edit = graphEditor(s, behavior); const enter = edit.addNode('OnCollisionEnter'); const logEnter = edit.addNode('Log'); const update = edit.addNode('OnUpdate'); const owner = edit.addNode('Owner'); const byName = edit.addNode('ObjectByName', { name: 'Ground' }); const touching = edit.addNode('IsTouching'); const logTouch = edit.addNode('Log'); edit.wire([ [enter, 'exec', logEnter, 'exec'], [enter, 'other', logEnter, 'message'], [update, 'exec', logTouch, 'exec'], [owner, 'object', touching, 'a'], [byName, 'object', touching, 'b'], [touching, 'result', logTouch, 'message'], ]); const { world } = startWorld(s, { collisions: true, behaviors: true }); world.tick(0.1); expect(world.log).toEqual(['false']); world.findByObjectId(s.hero)!.x = 40; world.tick(0.1); expect(world.log).toEqual(['false', 'Ground', 'true']); }); it('the Move node with "Collide with solids" stops at the painted wall', () => { const s = setup(); s.paint(COLUMN_SIX); const behavior = makeBehavior(s, 'Walk'); place(s, behavior.id, { x: 0, y: 0 }, s.hero); const edit = graphEditor(s, behavior); const update = edit.addNode('OnUpdate'); const owner = edit.addNode('Owner'); const move = edit.addNode('Move', { dx: 200, collide: true }); edit.wire([ [update, 'exec', move, 'exec'], [update, 'dt', move, 'dt'], [owner, 'object', move, 'object'], ]); const { world } = startWorld(s, { collisions: true, behaviors: true }); const hero = world.findByObjectId(s.hero)!; world.tick(0.1); expect(hero.x).toBeCloseTo(20, 9); world.tick(0.1); expect(hero.x).toBeCloseTo(28, 9); }); });