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_COLLISION_SCALE_KEY, TILE_SOLID_KEY, tileCollisionScale, } 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); } }); }); /** How many rectangles hold the point – 1 inside, 0 outside, more where they overlap. */ function hits(rects: CellRect[], px: number, py: number): number { return rects.filter((r) => px > r.x && px < r.x + r.w && py > r.y && py < r.y + r.h).length; } const scaled = (scale: number, ...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y, scale })); describe('collision scale: drawn in or out, flush towards blocking neighbours (#140)', () => { it('one cell, a run and a block at 0.5 are one rectangle each, drawn in only on the outside', () => { expect(mergeCells(scaled(0.5, [3, 4]))).toEqual([{ x: 3.25, y: 4.25, w: 0.5, h: 0.5 }]); expect(mergeCells(scaled(0.5, [0, 0], [1, 0], [2, 0]))).toEqual([{ x: 0.25, y: 0.25, w: 2.5, h: 0.5 }]); const block = Array.from({ length: 9 }, (_, i) => [i % 3, Math.floor(i / 3)] as [number, number]); expect(mergeCells(scaled(0.5, ...block))).toEqual([{ x: 0.25, y: 0.25, w: 2.5, h: 2.5 }]); }); it('the inner corner of an L closes cleanly: no notch filled, no gap', () => { const rects = mergeCells(scaled(0.5, [0, 0], [0, 1], [1, 1])); expect(rects).toEqual([ { x: 0.25, y: 0.25, w: 0.5, h: 1 }, { x: 0.25, y: 1.25, w: 1.5, h: 0.5 }, ]); expect(hits(rects, 0.9, 1.1)).toBe(0); // the inner corner stays open … expect(hits(rects, 0.7, 1.1)).toBe(1); // … the upright runs down into the foot expect(hits(rects, 0.9, 1.3)).toBe(1); }); it('two tiles with different values keep their common side flush', () => { const rects = mergeCells([ { x: 0, y: 0, scale: 0.5 }, { x: 1, y: 0, scale: 1 }, ]); expect(rects).toEqual([ { x: 1, y: 0, w: 1, h: 1 }, { x: 0.25, y: 0.25, w: 0.75, h: 0.5 }, ]); expect(hits(rects, 0.99, 0.5) + hits(rects, 1.01, 0.5)).toBe(2); }); it('above 1 a cell grows on its open sides; a run stays one rectangle', () => { expect(mergeCells(scaled(1.5, [0, 0]))).toEqual([{ x: -0.25, y: -0.25, w: 1.5, h: 1.5 }]); expect(mergeCells(scaled(1.5, [0, 0], [1, 0]))).toEqual([{ x: -0.25, y: -0.25, w: 2.5, h: 1.5 }]); }); it('cells meeting only at a corner are not joined: the corner stays drawn in', () => { expect(mergeCells(scaled(0.5, [0, 0], [1, 1]))).toEqual([ { x: 0.25, y: 0.25, w: 0.5, h: 0.5 }, { x: 1.25, y: 1.25, w: 0.5, h: 0.5 }, ]); }); /** * For one value on every cell the rule is exactly erosion (below 1) or growth (above 1) of the * painted area by the inset: a point blocks when the square of that half-width around it lies * wholly on blocking cells – or, growing, touches any. Sampled off every cut line. */ it('matches erosion below 1 and growth above 1 for any scatter, without overlap when drawn in', () => { let seed = 11; const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647; for (const scale of [0.5, 1.5]) { const e = Math.abs(1 - scale) / 2; for (let round = 0; round < 10; round++) { const scatter = Array.from({ length: 40 }, () => ({ x: Math.floor(random() * 8), y: Math.floor(random() * 8), scale, })); const solid = new Set(scatter.map((c) => `${c.x},${c.y}`)); const rects = mergeCells(scatter); for (let py = -1 + 1 / 16; py < 9; py += 1 / 8) { for (let px = -1 + 1 / 16; px < 9; px += 1 / 8) { const touched: boolean[] = []; for (let cy = Math.floor(py - e); cy <= Math.floor(py + e); cy++) for (let cx = Math.floor(px - e); cx <= Math.floor(px + e); cx++) touched.push(solid.has(`${cx},${cy}`)); const want = scale < 1 ? touched.every(Boolean) : touched.some(Boolean); const got = hits(rects, px, py); if (scale < 1) expect(got, `${px},${py}`).toBe(want ? 1 : 0); else expect(got > 0, `${px},${py}`).toBe(want); } } } } }); }); /** * 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('collision scale: 1 by default, held to its range, surviving a round trip; the largest of two layers wins', () => { const s = setup(); expect(schemaFor('tile').find((d) => d.key === TILE_COLLISION_SCALE_KEY)).toMatchObject({ kind: 'number', default: 1, min: 0.1, max: 2, }); const wallOf = (project = s.project) => { const wall = findAsset(project, s.wallId); if (wall?.type !== 'tile') throw new Error('no wall'); return wall; }; expect(tileCollisionScale(wallOf())).toBe(1); // The field is held to the schema's range; a value written into the file by hand is held too. s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 7)); expect(tileCollisionScale(wallOf())).toBe(2); for (const [value, read] of [ [0.5, 0.5], [0, 0.1], [7, 2], ['x', 1], ] as const) { wallOf().props[TILE_COLLISION_SCALE_KEY] = value; expect(tileCollisionScale(wallOf())).toBe(read); } s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 0.5)); expect(tileCollisionScale(wallOf(roundTrip(s.project)))).toBe(0.5); // A second solid tile at 1.5 on another layer over the same cell. const rock = new CreateAssetCommand('tile', 'Rock'); s.stack.execute(rock); s.stack.execute(new SetAssetPropertyCommand(rock.asset.id, TILE_SOLID_KEY, true)); s.stack.execute(new SetAssetPropertyCommand(rock.asset.id, TILE_COLLISION_SCALE_KEY, 1.5)); const add = new AddTilemapLayerCommand(s.mapId); s.stack.execute(add); s.paint([[2, 2]]); s.paint([[2, 2]], rock.asset.id, add.layerId); expect(solidCells(s.project, s.assetOf().props, s.assetOf())).toEqual([{ x: 2, y: 2, scale: 1.5 }]); }); 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('a drawn-in wall lets the hero closer and stays one rectangle; slides past a bump on it', () => { const s = setup(); s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 0.5)); s.paint(COLUMN_SIX); // Inset a quarter cell (12 px) on the open sides: the face moves from 48 to 60. expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toHaveLength(1); let started = startWorld(s, { collisions: true }); let hero = started.world.findByObjectId(s.hero)!; expect(started.collisions.moveAndSlide(started.world, hero, 97.3, 0).blockedX).toBe(true); expect(hero.x).toBeCloseTo(40, 9); // A bump on the far side cuts the column into several rectangles; the face stays one plane. s.paint([[7, 5]]); expect(solidRects(s.project, s.assetOf().props, s.assetOf()).length).toBeGreaterThan(1); started = startWorld(s, { collisions: true }); hero = started.world.findByObjectId(s.hero)!; hero.y = -200; started.collisions.moveAndSlide(started.world, hero, 97.3, 0); for (let i = 0; i < 60; i++) { const { blockedX, blockedY } = started.collisions.moveAndSlide(started.world, hero, 3.1, 6.7); expect(blockedX).toBe(true); expect(blockedY).toBe(false); } expect(hero.x).toBeCloseTo(40, 6); expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6); }); it('a grown wall keeps the hero further off', () => { const s = setup(); s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 1.5)); s.paint(COLUMN_SIX); const { world, collisions } = startWorld(s, { collisions: true }); const hero = world.findByObjectId(s.hero)!; collisions.moveAndSlide(world, hero, 97.3, 0); expect(hero.x).toBeCloseTo(16, 9); // face at 48 − 12 = 36 }); 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); }); });