import { beforeAll, describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands'; import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddTilePictureCommand } from '../../src/core/commands/tileCommands'; import { AddTilemapLayerCommand, PaintTilesCommand } from '../../src/core/commands/tilemapCommands'; import type { TextureAsset, TilemapAsset } from '../../src/core/model/Asset'; import { ALL_LAYERS, COLLIDES_WITH_KEY } from '../../src/core/model/collisionLayers'; import { findAsset } from '../../src/core/model/Project'; import { isTileSolid, isTileTrigger, TILE_COLLISION_SCALE_KEY, TILE_SOLID_KEY, TILE_TRIGGER_KEY, } from '../../src/core/model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap'; import { solidRects, type TriggerCell, triggerCells, triggerPolygons, triggerRects, } from '../../src/core/model/tilemapCollision'; import { schemaFor } from '../../src/core/properties/PropertySchema'; import { loadPhysics } from '../../src/runtime/physics/engine'; import type { CollisionPair, CollisionSource } from '../../src/runtime/systems/solids'; import type { System } from '../../src/runtime/systems/System'; import { COLLISION_SYSTEMS, graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld, } from '../helpers'; /** * 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, standing in row 4 left of a pond: * water in the cells 6, 7 and 8 of that row and lava in cell 9, both trigger tiles. */ 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 water = new CreateAssetCommand('tile', 'Water'); const lava = new CreateAssetCommand('tile', 'Lava'); const wall = new CreateAssetCommand('tile', 'Wall'); const map = new CreateAssetCommand('tilemap', 'Ground'); for (const command of [water, lava, wall, map]) f.stack.execute(command); f.stack.execute(new SetAssetPropertyCommand(water.asset.id, TILE_TRIGGER_KEY, true)); f.stack.execute(new SetAssetPropertyCommand(lava.asset.id, TILE_TRIGGER_KEY, true)); 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: -24 }); const collider = new AddColliderCommand(f.sceneId, hero); f.stack.execute(collider); const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset; const paint = (at: [number, number][], tileId: string, layerId = assetOf().layers[0]!.id) => f.stack.execute( new PaintTilesCommand( map.asset.id, layerId, at.map(([x, y]) => ({ x, y, tileId })), ), ); const s = { ...f, waterId: water.asset.id, lavaId: lava.asset.id, wallId: wall.asset.id, mapId: map.asset.id, mapObject, hero, heroCollider: collider.objectId, assetOf, paint, }; paint( [ [6, 4], [7, 4], [8, 4], ], s.waterId, ); paint([[9, 4]], s.lavaId); return s; } type Setup = ReturnType; const cellsOf = (s: Setup) => triggerCells(s.project, s.assetOf().props, s.assetOf()); const flat = (cells: ReadonlyMap) => [...cells.values()].flat(); /** A pair as a word: `area` or `cell`, the tile's name and the cell. */ function told(s: Setup, pairs: readonly CollisionPair[]): string[] { const name = (id: string) => (id === s.waterId ? 'water' : id === s.lavaId ? 'lava' : id); return pairs .filter((pair) => pair.tile) .map( ({ tile }) => `${tile!.perCell ? 'cell' : 'area'} ${name(tile!.tileId)} ${tile!.cell.x},${tile!.cell.y}`, ) .sort(); } describe('trigger tiles: which cells report (#160)', () => { it('a tile reports once Trigger is on, the switch survives a round trip, and it wins over Solid', () => { const s = setup(); expect(schemaFor('tile').find((d) => d.key === TILE_TRIGGER_KEY)).toMatchObject({ kind: 'boolean', default: false, }); const tile = (id: string, project = s.project) => { const found = findAsset(project, id); if (found?.type !== 'tile') throw new Error('no tile'); return found; }; expect(isTileTrigger(tile(s.wallId))).toBe(false); expect(isTileTrigger(tile(s.waterId, roundTrip(s.project)))).toBe(true); s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_SOLID_KEY, true)); expect(isTileTrigger(tile(s.waterId))).toBe(true); expect(isTileSolid(tile(s.waterId))).toBe(false); expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toEqual([]); }); it('merges into rectangles like solid cells, but never with them', () => { const s = setup(); s.paint([[5, 4]], s.wallId); const props = s.assetOf().props; // Water and lava are both triggers, so the outline runs round the two together. expect(triggerRects(s.project, props, s.assetOf())).toEqual([{ x: 6, y: 4, w: 4, h: 1 }]); expect(solidRects(s.project, props, s.assetOf())).toEqual([{ x: 5, y: 4, w: 1, h: 1 }]); }); it('an area is the cells of one tile touching side to side, named after its top-left cell', () => { const s = setup(); s.paint( [ [7, 5], [9, 5], [2, 2], [3, 3], ], s.waterId, ); const areas = Object.fromEntries( flat(cellsOf(s)).map((cell) => [ `${cell.tileId === s.waterId ? 'w' : 'l'} ${cell.x},${cell.y}`, cell.area, ]), ); expect(areas).toEqual({ 'w 6,4': '6,4', 'w 7,4': '6,4', 'w 8,4': '6,4', 'w 7,5': '6,4', // Lava lies between this one and the pond, and lava is not water. 'w 9,5': '9,5', 'l 9,4': '9,4', // Corner to corner is not side to side. 'w 2,2': '2,2', 'w 3,3': '3,3', }); }); it('a cell holding two trigger tiles on two layers reports both; outside the grid nothing does', () => { const s = setup(); const add = new AddTilemapLayerCommand(s.mapId); s.stack.execute(add); s.paint( [ [6, 4], [12, 4], ], s.lavaId, add.layerId, ); const cells = cellsOf(s); expect( cells .get('6,4')! .map((cell) => cell.tileId) .sort(), ).toEqual([s.lavaId, s.waterId].sort()); expect(cells.has('12,4')).toBe(false); }); it('the collision scale draws a cell in on its open sides only, as for a solid tile', () => { const s = setup(); s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_COLLISION_SCALE_KEY, 0.5)); const pieces = (x: number) => cellsOf(s) .get(`${x},4`)! .find((cell) => cell.tileId === s.waterId)!.rects; const span = (x: number) => { const rects = pieces(x); return [Math.min(...rects.map((r) => r.x)), Math.max(...rects.map((r) => r.x + r.w))]; }; // The left end is drawn in, the side towards the next water cell is flush; lava is no water. expect(span(6)).toEqual([6.25, 7]); expect(span(7)).toEqual([7, 8]); expect(span(8)).toEqual([8, 8.75]); expect(pieces(7).every((r) => r.y === 4.25 && r.h === 0.5)).toBe(true); }); it('a trigger tile with an outline reports with that polygon', () => { const s = setup(); const texture = new CreateAssetCommand('texture', 'Sheet'); s.stack.execute(texture); const sheet = findAsset(s.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 }); s.stack.execute(frame); s.stack.execute(new AddTilePictureCommand(s.lavaId, frame.frameId)); // The lower half of the tile. const half = [ { x: 0, y: 16 }, { x: 32, y: 16 }, { x: 32, y: 32 }, { x: 0, y: 32 }, ]; s.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [half])); const lava = cellsOf(s) .get('9,4')! .find((cell) => cell.tileId === s.lavaId)!; expect(lava.rects).toEqual([]); expect(lava.polygons.map((p) => p.outline)).toEqual([ [ { x: 9, y: 4.5 }, { x: 10, y: 4.5 }, { x: 10, y: 5 }, { x: 9, y: 5 }, ], ]); const props = s.assetOf().props; expect(triggerPolygons(s.project, props, s.assetOf())).toHaveLength(1); expect(triggerRects(s.project, props, s.assetOf())).toEqual([{ x: 6, y: 4, w: 3, h: 1 }]); }); }); describe.each(COLLISION_SYSTEMS)('$system: trigger tiles report and never block (#160)', ({ physics }) => { beforeAll(async () => { await loadPhysics(); }); const run = (s: Setup) => { const started = startWorld(s, { physics, collisions: true }); const hero = started.world.findByObjectId(s.hero)!; const map = started.world.findByObjectId(s.mapObject)!; const goTo = (x: number, y = -24) => { hero.x = x; hero.y = y; started.world.tick(1 / 60); }; return { ...started, hero, map, goTo }; }; it('walking through the pond: each cell and each area enters, stays and exits on its own', () => { const s = setup(); const { collisions, goTo } = run(s); goTo(0); expect(collisions.enters).toEqual([]); // 40 … 80: over the first water cell (48 … 96). goTo(60); expect(told(s, collisions.enters)).toEqual(['area water 6,4', 'cell water 6,4']); expect(told(s, collisions.stays)).toEqual([]); // 80 … 120: over cells 6 and 7, nearer to 7. The area is the one entered before. goTo(100); expect(told(s, collisions.enters)).toEqual(['cell water 7,4']); expect(told(s, collisions.stays)).toEqual(['area water 7,4', 'cell water 6,4']); expect(told(s, collisions.exits)).toEqual([]); // 160 … 200: over the last water cell and the lava next to it. goTo(180); expect(told(s, collisions.enters)).toEqual(['area lava 9,4', 'cell lava 9,4', 'cell water 8,4']); expect(told(s, collisions.stays)).toEqual(['area water 8,4']); expect(told(s, collisions.exits)).toEqual(['cell water 6,4', 'cell water 7,4']); goTo(0); expect(told(s, collisions.exits)).toEqual([ 'area lava 9,4', 'area water 8,4', 'cell lava 9,4', 'cell water 8,4', ]); goTo(0); expect(collisions.enters.length + collisions.stays.length + collisions.exits.length).toBe(0); }); it('the pair is the collider and the map, apart from the pair of the map’s solid cells', () => { const s = setup(); const add = new AddTilemapLayerCommand(s.mapId); s.stack.execute(add); s.paint([[6, 4]], s.wallId, add.layerId); // The same map once more, placed after the hero: the order in the scene has no say in the pair. const later = place(s, s.mapId, { x: 0, y: 0 }); const { collisions, world, goTo, map } = run(s); goTo(60); const collider = world.findByObjectId(s.heroCollider)!; const maps = [map, world.findByObjectId(later)!]; const tiles = collisions.enters.filter((pair) => pair.tile); expect(tiles).toHaveLength(4); expect(tiles.every((pair) => pair.a === collider && maps.includes(pair.b))).toBe(true); // The wall under the water is each map's own pair, and it names no tile. expect(collisions.enters.filter((pair) => !pair.tile)).toHaveLength(2); // Nothing presses on a trigger tile, whatever the map's solid cells say of the same collider. for (const pair of tiles) expect(collisions.contactOf(pair)).toBeNull(); }); it('touching says so on a trigger tile, and no more off it', () => { const s = setup(); const { collisions, world, hero, map, goTo } = run(s); goTo(0); expect(collisions.touching(world, hero, map)).toBe(false); goTo(100); expect(collisions.touching(world, hero, map)).toBe(true); expect(collisions.touching(world, map, hero)).toBe(true); goTo(100, 100); expect(collisions.touching(world, hero, map)).toBe(false); }); it('a move goes through it and a ray does not meet it, even with Solid ticked', () => { const s = setup(); s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_SOLID_KEY, true)); const { collisions, world, hero, goTo } = run(s); goTo(0); expect(collisions.moveAndSlide(world, hero, 150, 0)).toMatchObject({ blockedX: false, hit: false }); expect(hero.x).toBe(150); const ray = { ignore: hero, triggers: true, layers: ALL_LAYERS }; expect(collisions.raycast(world, { x: -200, y: -24 }, { x: 1, y: 0 }, 500, ray)).toBeNull(); }); it('nothing is told for a map switched off, a collider switched off, or layers that do not meet', () => { const s = setup(); const { collisions, world, map, goTo } = run(s); const collider = world.findByObjectId(s.heroCollider)!; goTo(100); expect(told(s, collisions.enters)).toHaveLength(3); map.props.enabled = false; goTo(100); expect(told(s, collisions.exits)).toHaveLength(3); map.props.enabled = true; collider.props.enabled = false; goTo(100); expect(told(s, collisions.enters)).toEqual([]); collider.props.enabled = true; map.props[COLLIDES_WITH_KEY] = 0; goTo(100); expect(told(s, collisions.enters)).toEqual([]); expect(collisions.touching(world, collider, map)).toBe(false); map.props[COLLIDES_WITH_KEY] = ALL_LAYERS; goTo(100); expect(told(s, collisions.enters)).toHaveLength(3); }); it('follows the placed map, and a tile set during the run', () => { const s = setup(); const { collisions, world, map, goTo } = run(s); goTo(0); // Cell 4,4 spans -48 … 0: the hero stands on it and on 5,4. expect(world.setTile(map, '', { x: 4, y: 4 }, s.lavaId)).toBe(true); goTo(0); expect(told(s, collisions.enters)).toEqual(['area lava 4,4', 'cell lava 4,4']); world.setTile(map, '', { x: 4, y: 4 }, ''); goTo(0); expect(told(s, collisions.exits)).toEqual(['area lava 4,4', 'cell lava 4,4']); // The map moved 48 to the left: the pond now begins at the hero's cell. map.x = -48; goTo(20); expect(told(s, collisions.enters)).toEqual(['area water 6,4', 'cell water 6,4']); }); it('a cell grown by its collision scale is met from the cell next to it', () => { const s = setup(); s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_COLLISION_SCALE_KEY, 2)); const { collisions, goTo } = run(s); // Cell 6 begins at 48 and reaches half a cell further out, to 24; the hero ends at 30, in cell 5. goTo(10); expect(told(s, collisions.enters)).toEqual(['area water 6,4', 'cell water 6,4']); }); it('a system stopped and started again forgets what it told: the pond is entered anew', () => { const s = setup(); const { collisions, world, goTo } = run(s); goTo(100); expect(told(s, collisions.enters)).toHaveLength(3); const system = collisions as CollisionSource & System; system.stop?.(world); expect(collisions.enters.length + collisions.stays.length).toBe(0); system.start?.(world); goTo(100); expect(told(s, collisions.enters)).toHaveLength(3); expect(told(s, collisions.stays)).toEqual([]); }); it('a collider under the map itself is told nothing', () => { const s = setup(); const own = new AddColliderCommand(s.sceneId, s.mapObject); s.stack.execute(own); s.stack.execute(new SetOverrideCommand(s.sceneId, own.objectId, 'width', 400)); const { collisions, world, goTo } = run(s); goTo(0); const collider = world.findByObjectId(own.objectId)!; expect(collisions.enters.filter((pair) => pair.tile && pair.a === collider)).toEqual([]); }); }); describe.each(COLLISION_SYSTEMS)('$system: the collision events tell the tile (#160)', ({ physics }) => { beforeAll(async () => { await loadPhysics(); }); /** A behavior on the hero logging five lines per event: the label, the tile, its cell and the other body. */ function logging(s: Setup, type: string, label: string, params = {}) { const behavior = makeBehavior(s, `${label} listener`); place(s, behavior.id, { x: 0, y: 0 }, s.hero); const edit = graphEditor(s, behavior); const event = edit.addNode(type, params); const logs = ['tile', 'cellX', 'cellY', 'other'].map(() => edit.addNode('Log')); const mark = edit.addNode('Log', { message: label }); edit.wire([ [event, 'exec', mark, 'exec'], [mark, 'exec', logs[0]!, 'exec'], [logs[0]!, 'exec', logs[1]!, 'exec'], [logs[1]!, 'exec', logs[2]!, 'exec'], [logs[2]!, 'exec', logs[3]!, 'exec'], [event, 'tile', logs[0]!, 'message'], [event, 'cellX', logs[1]!, 'message'], [event, 'cellY', logs[2]!, 'message'], [event, 'other', logs[3]!, 'message'], ]); return { edit, event }; } const walk = (s: Setup) => { const started = startWorld(s, { physics, collisions: true, behaviors: true }); const hero = started.world.findByObjectId(s.hero)!; const goTo = (x: number) => { hero.x = x; started.world.tick(1 / 60); const said = started.world.log.splice(0); // Five lines per event: the label, the tile, the cell and the other body. const events: string[] = []; for (let i = 0; i < said.length; i += 5) events.push(said.slice(i, i + 5).join(' ')); return events; }; return { ...started, hero, goTo }; }; it('enter and exit run once for the pond by default, and once per cell with "per cell"', () => { const s = setup(); logging(s, 'OnCollisionEnter', 'in'); logging(s, 'OnCollisionExit', 'out'); logging(s, 'OnCollisionEnter', 'in-cell', { perCell: true }); logging(s, 'OnCollisionExit', 'out-cell', { perCell: true }); const { goTo } = walk(s); const w = s.waterId; expect(goTo(0)).toEqual([]); expect(goTo(60)).toEqual([`in ${w} 6 4 Ground`, `in-cell ${w} 6 4 Ground`]); expect(goTo(100)).toEqual([`in-cell ${w} 7 4 Ground`]); expect(goTo(140)).toEqual([`in-cell ${w} 8 4 Ground`, `out-cell ${w} 6 4 Ground`]); // At 140 the hero was nearer to cell 7 than to 8, and that is the area's last word. expect(goTo(0)).toEqual([`out ${w} 7 4 Ground`, `out-cell ${w} 7 4 Ground`, `out-cell ${w} 8 4 Ground`]); }); it('stay tells the nearest cell of the area, or every cell lain on', () => { const s = setup(); logging(s, 'OnCollisionStay', 'on'); logging(s, 'OnCollisionStay', 'on-cell', { perCell: true }); const { goTo } = walk(s); const w = s.waterId; expect(goTo(100)).toEqual([]); expect(goTo(90)).toEqual([`on ${w} 6 4 Ground`, `on-cell ${w} 6 4 Ground`, `on-cell ${w} 7 4 Ground`]); expect(goTo(110)).toEqual([`on ${w} 7 4 Ground`, `on-cell ${w} 6 4 Ground`, `on-cell ${w} 7 4 Ground`]); }); it('"per cell" is read from what feeds it, each time the event comes', () => { const s = setup(); const { edit, event } = logging(s, 'OnCollisionEnter', 'in'); const held = edit.addNode('KeyIsDown', { key: 'c' }); edit.connect(held, 'down', event, 'perCell'); const { goTo, input } = walk(s); const w = s.waterId; expect(goTo(60)).toEqual([`in ${w} 6 4 Ground`]); input.press('c'); // With the key held the node hears cells: the area it stands in is not news to it, cell 7 is. expect(goTo(100)).toEqual([`in ${w} 7 4 Ground`]); }); it('a stay with an interval keeps one clock per cell', () => { const s = setup(); logging(s, 'OnCollisionStay', 'on-cell', { perCell: true, interval: 0.04 }); const { goTo } = walk(s); const w = s.waterId; // Cell 6 is entered two frames before cell 7, so its 0.04 s are up while cell 7 still waits. expect(goTo(60)).toEqual([]); expect(goTo(60)).toEqual([]); expect(goTo(100)).toEqual([]); expect(goTo(100)).toEqual([`on-cell ${w} 6 4 Ground`]); expect(goTo(100)).toEqual([]); // Three frames on, cell 7 is due for the first time and cell 6, with 0.01 s left over, again. expect(goTo(100)).toEqual([`on-cell ${w} 6 4 Ground`, `on-cell ${w} 7 4 Ground`]); }); it('another collider and the solid cells of a map tell no tile: empty and -1, -1', () => { const s = setup(); logging(s, 'OnCollisionEnter', 'in'); s.paint([[5, 4]], s.wallId); const crate = place(s, s.spriteId, { x: -200, y: -24 }); s.stack.execute(new AddColliderCommand(s.sceneId, crate)); const { goTo } = walk(s); // 0 … 40: over the wall in cell 5 (0 … 48), short of the water. expect(goTo(20)).toEqual(['in -1 -1 Ground']); expect(goTo(-170)).toEqual(['in -1 -1 Hero']); }); it('a behavior on the map hears who walks onto its tiles', () => { const s = setup(); const behavior = makeBehavior(s, 'Pond'); place(s, behavior.id, { x: 0, y: 0 }, s.mapObject); const edit = graphEditor(s, behavior); const enter = edit.addNode('OnCollisionEnter'); const who = edit.addNode('Log'); const where = edit.addNode('Log'); edit.wire([ [enter, 'exec', who, 'exec'], [who, 'exec', where, 'exec'], [enter, 'other', who, 'message'], [enter, 'cellX', where, 'message'], ]); const { world, hero } = walk(s); hero.x = 180; world.tick(1 / 60); // Water and lava are two areas, so two events. expect(world.log).toEqual(['Hero', '8', 'Hero', '9']); }); }); describe('a dynamic body falls through trigger tiles and is told (#160)', () => { beforeAll(async () => { await loadPhysics(); }); it('keeps falling, and enters and leaves the pond on the way', () => { const s = setup(); s.stack.execute(new AddRigidbodyCommand(s.sceneId, s.hero)); const { world, collisions } = startWorld(s, { physics: true, collisions: true }); const hero = world.findByObjectId(s.hero)!; // Above the pond's middle cell (96 … 144), which lies at -48 … 0. hero.x = 120; hero.y = -150; const seen: string[] = []; for (let i = 0; i < 90; i++) { world.tick(1 / 60); for (const pair of collisions.enters) if (pair.tile && !pair.tile.perCell) seen.push('enter'); for (const pair of collisions.exits) if (pair.tile && !pair.tile.perCell) seen.push('exit'); } expect(seen).toEqual(['enter', 'exit']); expect(hero.y).toBeGreaterThan(100); }); });