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, TileAsset } from '../../src/core/model/Asset'; import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { findAsset, type Project } from '../../src/core/model/Project'; import { TILE_COLLISION_SCALE_KEY, TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile'; import { insideOutline } from '../../src/editor/polygonEdit'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap'; import { solidBoundary, solidCells, solidPolygons, solidRects, tileShape, } from '../../src/core/model/tilemapCollision'; import { contactBetween, shapesOverlap } from '../../src/core/properties/collider'; import type { Entity } from '../../src/runtime/Entity'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { shapeOf, Solids } from '../../src/runtime/systems/solids'; import { COLLISION_SYSTEMS, makeSpriteScene, place, roundTrip, type SceneFixture, startWorld, } from '../helpers'; beforeAll(async () => { await loadPhysics(); }); const P = (...xy: number[]) => Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! })); /** A scene of objects with one collider each; `add` sets the collider's properties and returns the ids. */ function scene() { const f = makeSpriteScene('Thing'); const add = (at: Vec2, collider: Record, body?: Record) => { const id = place(f, f.spriteId, at); const command = new AddColliderCommand(f.sceneId, id); f.stack.execute(command); for (const [key, value] of Object.entries(collider)) f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value)); if (body) { const rigidbody = new AddRigidbodyCommand(f.sceneId, id); f.stack.execute(rigidbody); for (const [key, value] of Object.entries(body)) f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, key, value)); } return { id, collider: command.objectId }; }; return { ...f, add }; } function run(f: SceneFixture, physics: boolean) { const started = startWorld(f, { physics, collisions: true }); const entity = (id: string) => started.world.findByObjectId(id)!; const colliderOf = (e: Entity) => started.world.childrenOf(e).find((c) => c.type === 'collider')!; const move = (e: Entity, dx: number, dy: number) => started.collisions.moveAndSlide(started.world, e, dx, dy); const seconds = (s: number) => { for (let i = 0; i < Math.round(s * 60); i++) started.frame(1 / 60); }; return { ...started, entity, colliderOf, move, seconds }; } /** A ramp at 30°, falling to the right: its top face runs through the origin. */ const TAN30 = Math.tan(Math.PI / 6); const RAMP = P(-500, -500 * TAN30, 500, 500 * TAN30, 500, 400, -500, 400); describe.each(COLLISION_SYSTEMS)('$system: Move against the new shapes (#151)', ({ physics }) => { it('a circle pressed onto a polygon ramp slides as far as friction lets it: g·sin − μ·g·cos', () => { const sin = Math.sin(Math.PI / 6); const cos = Math.cos(Math.PI / 6); for (const friction of [0, 0.5, 0.6]) { const s = scene(); const ramp = s.add({ x: 0, y: 0 }, { shape: 'polygon', points: RAMP, friction }); const ball = s.add({ x: 0, y: -100 }, { shape: 'circle', radius: 10, friction }); const r = run(s, physics); const mover = r.entity(ball.id); for (let i = 0; i < 30; i++) r.move(mover, 0, 5); const from = { x: mover.x, y: mover.y }; const results = Array.from({ length: 10 }, () => r.move(mover, 0, 5)); const along = 10 * Math.max(0, 5 * sin - friction * 5 * cos); expect(mover.x - from.x, `friction ${friction}`).toBeCloseTo(along * cos, 6); expect(mover.y - from.y, `friction ${friction}`).toBeCloseTo(along * sin, 6); expect(results[0]!.normal.x).toBeCloseTo(sin, 9); expect(results[0]!.normal.y).toBeCloseTo(-cos, 9); const surface = shapeOf(r.colliderOf(r.entity(ramp.id))); const gap = contactBetween(shapeOf(r.colliderOf(mover)), surface, { x: 0, y: 1 }).separation; expect(Math.abs(gap)).toBeLessThan(1e-6); } }); it('a capsule runs into a box wall, stops flush and slides down it', () => { const s = scene(); s.add({ x: 100, y: 0 }, { width: 20, height: 400 }); // face at x = 90 const hero = s.add({ x: 0, y: 0 }, { shape: 'capsule', width: 20, height: 60, friction: 0 }); const r = run(s, physics); const mover = r.entity(hero.id); const hit = r.move(mover, 100, 0); expect(hit).toMatchObject({ hit: true, blockedX: true, blockedY: false }); expect(hit.normal.x).toBeCloseTo(-1, 12); expect(hit.normal.y).toBeCloseTo(0, 12); expect(mover.x).toBeCloseTo(80, 9); r.move(mover, 3, 4); // the wall's friction is the default 0.5, the capsule's 0: the pair has 0.25 expect(mover.x).toBeCloseTo(80, 9); expect(mover.y).toBeCloseTo(4 - 0.25 * 3, 9); }); it('a box dropped into a concave U lands on its inner floor, clear of the walls', () => { const s = scene(); s.add( { x: 0, y: 0 }, { shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) }, ); const crate = s.add({ x: 0, y: -120 }, { width: 40, height: 40 }); const r = run(s, physics); const mover = r.entity(crate.id); for (let i = 0; i < 40; i++) r.move(mover, 0, 5); expect(mover.y).toBeCloseTo(10, 9); // bottom face on the inner floor at 30 expect(mover.x).toBe(0); }); }); describe('the physics engine with the new shapes (#151)', () => { it('a capsule falls onto a box floor and comes to rest standing on its round end', () => { const s = scene(); s.add({ x: 0, y: 200 }, { width: 400, height: 40 }); // top at 180 const hero = s.add({ x: 0, y: 0 }, { shape: 'capsule', width: 20, height: 60 }, { fixedRotation: true }); const r = run(s, true); r.seconds(3); expect(r.entity(hero.id).y).toBeCloseTo(180 - 30, 0); }); it('a crate falls into a concave U and rests on its inner floor – not on the hull over the notch', () => { const s = scene(); s.add( { x: 0, y: 200 }, { shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) }, ); const crate = s.add({ x: 0, y: 0 }, { width: 40, height: 40 }, { fixedRotation: true }); const r = run(s, true); r.seconds(3); expect(r.entity(crate.id).y).toBeCloseTo(200 + 30 - 20, 0); expect(Math.abs(r.entity(crate.id).x)).toBeLessThan(0.5); }); it('a ball rolls down a polygon ramp; a polygon body falls and rests with its own mass', () => { const s = scene(); const ramp = s.add({ x: 0, y: 300 }, { shape: 'polygon', points: RAMP }); const ball = s.add({ x: -100, y: 0 }, { shape: 'circle', radius: 10 }, {}); const triangle = s.add({ x: 300, y: -200 }, { shape: 'polygon', points: P(0, -20, 20, 20, -20, 20) }, {}); const r = run(s, true); const start = r.entity(ball.id).x; r.seconds(1); expect(r.entity(ball.id).x).toBeGreaterThan(start + 50); // downhill is to the right r.seconds(4); // The triangle came to rest on the ramp, and did not sink into it. const surface = shapeOf(r.colliderOf(r.entity(ramp.id))); const resting = r.entity(triangle.id); expect(resting.y).toBeGreaterThan(-100); // it fell expect( contactBetween(shapeOf(r.colliderOf(resting)), surface, { x: 0, y: 1 }).separation, ).toBeGreaterThan(-1); }); }); /** * A texture with one 32 × 32 frame, a plain tile showing it, a solid switch on that tile and a * 12 × 4 map of 32 px cells at the origin – cell (x, y) spans x·32 − 192 … and y·32 − 64 …. */ function outlinedMap() { const f = scene(); const texture = new CreateAssetCommand('texture', 'Sheet'); const tile = new CreateAssetCommand('tile', 'Slope'); const plain = new CreateAssetCommand('tile', 'Wall'); const lake = new CreateAssetCommand('tile', 'Lake'); const map = new CreateAssetCommand('tilemap', 'Ground'); for (const command of [texture, tile, plain, lake, map]) f.stack.execute(command); const sheet = findAsset(f.project, texture.asset.id) as TextureAsset; sheet.file = `${'a'.repeat(64)}.png`; sheet.width = 64; sheet.height = 128; const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 }); f.stack.execute(frame); f.stack.execute(new AddTilePictureCommand(tile.asset.id, frame.frameId)); // A floor block of 32 px tiles below it: 2 × 3 tiles, quarters of 16 px (#141). const block = new AddFrameCommand(texture.asset.id, { x: 0, y: 32, w: 64, h: 96 }); f.stack.execute(block); f.stack.execute(new AddTilePictureCommand(lake.asset.id, block.frameId)); f.stack.execute(new SetAssetPropertyCommand(lake.asset.id, TILE_KIND_KEY, 'floor')); for (const id of [tile.asset.id, plain.asset.id, lake.asset.id]) f.stack.execute(new SetAssetPropertyCommand(id, TILE_SOLID_KEY, true)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 12)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 4)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_TILE_KEY, 32)); const mapObject = place(f, map.asset.id, { x: 0, y: 0 }); const mapAsset = () => findAsset(f.project, map.asset.id) as TilemapAsset; const paint = (cells: [number, number][], tileId = tile.asset.id, layer = 0) => f.stack.execute( new PaintTilesCommand( map.asset.id, mapAsset().layers[layer]!.id, cells.map(([x, y]) => ({ x, y, tileId })), ), ); const outline = (...outlines: Vec2[][]) => f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, outlines)); const blockOutline = (...outlines: Vec2[][]) => f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, block.frameId, outlines)); const tileAsset = () => findAsset(f.project, tile.asset.id) as TileAsset; return { ...f, mapId: map.asset.id, mapObject, mapAsset, paint, outline, blockOutline, tileAsset, plainId: plain.asset.id, lakeId: lake.asset.id, textureId: texture.asset.id, frameId: frame.frameId, }; } /** The lower right half of a cell: a slope rising to the right, as a frame outline in pixels. */ const WEDGE = P(0, 32, 32, 0, 32, 32); describe('collision outlines of tiles (#141)', () => { it('a frame keeps its outline through a round trip; broken corners and short outlines fall away', () => { const m = outlinedMap(); m.outline(WEDGE); const frameOf = (project: Project) => (findAsset(project, m.textureId) as TextureAsset).frames.find((f) => f.id === m.frameId)!; expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]); frameOf(m.project).collision = [ [ { x: 1, y: 1 }, { x: Number.NaN, y: 0 }, { x: 3, y: 3 }, ], ]; expect(frameOf(roundTrip(m.project))).not.toHaveProperty('collision'); m.stack.undo(); expect(frameOf(m.project)).not.toHaveProperty('collision'); m.stack.redo(); expect(frameOf(m.project).collision).toEqual([WEDGE]); }); it('a frame holds several outlines; one saved before that, as a plain corner list, loads as one', () => { const m = outlinedMap(); const SQUARE_AT_0 = P(0, 0, 8, 0, 8, 8, 0, 8); m.outline(WEDGE, SQUARE_AT_0); const frameOf = (project: Project) => (findAsset(project, m.textureId) as TextureAsset).frames.find((f) => f.id === m.frameId)!; expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE, SQUARE_AT_0]); // Of two outlines, the one with too few corners falls away on load and the other stays. frameOf(m.project).collision = [P(1, 1, 2, 2), WEDGE]; expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]); // An outline emptied by the editor is dropped, and the frame without any keeps saying so. m.outline([], []); expect(frameOf(m.project)).not.toHaveProperty('collision'); (frameOf(m.project) as { collision: unknown }).collision = WEDGE; expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]); }); it('a plain tile blocks with its first picture’s outlines; an outline that is none, or a block of the wrong size, as a box', () => { const m = outlinedMap(); expect(tileShape(m.project, m.tileAsset())).toBeNull(); m.outline(WEDGE); expect(tileShape(m.project, m.tileAsset())).toEqual({ kind: 'plain', outlines: [{ outline: P(0, 1, 1, 0, 1, 1), pieces: [P(0, 1, 1, 0, 1, 1)] }], }); const BOW_TIE = P(0, 0, 32, 32, 32, 0, 0, 32); m.outline(BOW_TIE); expect(tileShape(m.project, m.tileAsset())).toBeNull(); m.outline(BOW_TIE, WEDGE); // the bow tie collides as none, the wedge still blocks expect(tileShape(m.project, m.tileAsset())).toMatchObject({ kind: 'plain', outlines: [{ outline: P(0, 1, 1, 0, 1, 1) }], }); m.outline(WEDGE); // A floor needs a block of 2 × 3 tiles; the 32 px frame is none, so it blocks as a box. m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_KIND_KEY, 'floor')); expect(tileShape(m.project, m.tileAsset())).toBeNull(); }); it('a plain tile with two outlines blocks with both in every cell', () => { const m = outlinedMap(); m.outline(P(0, 0, 16, 0, 16, 16, 0, 16), P(16, 16, 32, 16, 32, 32, 16, 32)); m.paint([[2, 3]]); const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 }; expect(solidPolygons(m.project, props, m.mapAsset()).map((p) => p.outline)).toEqual([ P(2, 3, 2.5, 3, 2.5, 3.5, 2, 3.5), P(2.5, 3.5, 3, 3.5, 3, 4, 2.5, 4), ]); }); it('outlined cells are polygons, not rectangles; a plain solid tile on the same cell covers it whole', () => { const m = outlinedMap(); m.outline(WEDGE); m.paint([ [2, 3], [3, 3], ]); m.paint([[5, 3]], m.plainId); const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 }; expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 5, y: 3, scale: 1 }]); expect(solidPolygons(m.project, props, m.mapAsset()).map((p) => p.outline)).toEqual([ P(2, 4, 3, 3, 3, 4), P(3, 4, 4, 3, 4, 4), ]); m.stack.execute(new AddTilemapLayerCommand(m.mapId)); m.paint([[2, 3]], m.plainId, 1); expect(solidPolygons(m.project, props, m.mapAsset())).toHaveLength(1); expect(solidCells(m.project, props, m.mapAsset())).toHaveLength(2); // The collision scale draws the outline in about the cell's centre (#140). m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_COLLISION_SCALE_KEY, 0.5)); expect(solidPolygons(m.project, props, m.mapAsset())[0]!.outline).toEqual( P(3.25, 3.75, 3.75, 3.25, 3.75, 3.75), ); }); describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { it('a crate on a floor of outlined cells slides over every seam', () => { const m = outlinedMap(); m.outline(P(0, 0, 32, 0, 32, 32, 0, 32)); // every cell outlined as a whole square: twelve polygons m.paint(Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number])); // top face at y = 32 const crate = m.add({ x: -180, y: 0 }, { width: 30, height: 30 }); const r = run(m, physics); const mover = r.entity(crate.id); const steps: { dx: number; y: number; blockedX: boolean }[] = []; for (let i = 0; i < 100; i++) { const x = mover.x; const { blockedX } = r.move(mover, 3, 1.5); steps.push({ dx: mover.x - x, y: mover.y, blockedX }); } for (const step of steps.slice(20)) { expect(step.dx).toBeCloseTo(3 - 0.5 * 1.5, 9); expect(step.y).toBeCloseTo(32 - 15, 9); expect(step.blockedX).toBe(false); } expect(mover.x).toBeGreaterThan(-180 + 5 * 32); }); it('a crate walking along the floor into the foot of a wedge tile climbs it instead of stopping there', () => { const m = outlinedMap(); m.outline(WEDGE); m.paint([[6, 2]]); // x 0 … 32, its foot on the floor at (0, 32), its top at (32, 0) m.paint( Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]), m.plainId, ); const crate = m.add({ x: -100, y: 22 }, { width: 20, height: 20, friction: 0 }); const r = run(m, physics); const mover = r.entity(crate.id); let highest = mover.y; for (let i = 0; i < 300; i++) { r.move(mover, 2, 0.6); highest = Math.min(highest, mover.y); } // At the foot the crate's corner meets the wedge's corner: the slope, not a wall, is the contact. // Its bottom reached the wedge's top at y = 0; the rest of that frame's slide ran on up-slope. expect(highest).toBeLessThanOrEqual(-10 + 1e-9); expect(highest).toBeGreaterThan(-10 - 0.5); expect(mover.x).toBeGreaterThan(45); // and it went on past the wedge's right side at x = 32 }); it('a ball rolls up and over a wedge tile instead of stopping at it', () => { const m = outlinedMap(); m.outline(WEDGE); m.paint([[6, 2]]); // x 0 … 32, rising from the floor at its left to its top at its right m.paint( Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]), m.plainId, ); // floor top at 32 const ball = m.add({ x: -100, y: 22 }, { shape: 'circle', radius: 10, friction: 0 }); const r = run(m, physics); const mover = r.entity(ball.id); let highest = mover.y; for (let i = 0; i < 200; i++) { r.move(mover, 3, 1); highest = Math.min(highest, mover.y); } expect(highest).toBeLessThan(0); // it climbed to the wedge's top at y = 0 expect(mover.x).toBeGreaterThan(60); // and went on past its right side at x = 32 const map = r.world.entities.find((e) => e.type === 'tilemap')!; const own = shapeOf(r.colliderOf(mover)); expect(new Solids().shapesOf(r.world, map).some((shape) => shapesOverlap(own, shape))).toBe(false); }); }); }); /** * The floor block's bordered 2 × 2 tile (frame y 32 … 96) outlined 8 px in from its edges – half a * quarter. A cell of it blocks from a quarter of a cell in wherever it has no neighbour. */ const POND = P(8, 40, 56, 40, 56, 88, 8, 88); describe('collision outlines of autotiles (#141)', () => { const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 }; /** Whether a point of the map, in cells, lies inside anything the map blocks with. */ const blocksAt = (m: ReturnType, p: Vec2) => solidRects(m.project, props, m.mapAsset()).some( (r) => p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h, ) || solidPolygons(m.project, props, m.mapAsset()).some((polygon) => polygon.pieces.some((piece) => insideOutline(piece, p)), ); const cells = (x0: number, y0: number, x1: number, y1: number, skip = '') => Array.from( { length: (x1 - x0 + 1) * (y1 - y0 + 1) }, (_, i) => [x0 + (i % (x1 - x0 + 1)), y0 + Math.floor(i / (x1 - x0 + 1))] as [number, number], ).filter(([x, y]) => `${x},${y}` !== skip); it('a lake blocks with the block’s outline pieced together like its picture: in by a quarter cell all round', () => { const m = outlinedMap(); m.blockOutline(POND); m.paint(cells(2, 0, 4, 2), m.lakeId); const wrong: Vec2[] = []; for (let x = 1.037; x < 6; x += 0.1) for (let y = 0.037; y < 4; y += 0.1) { const expected = x > 2.25 && x < 4.75 && y > 0.25 && y < 2.75; if (blocksAt(m, { x, y }) !== expected) wrong.push({ x, y }); } expect(wrong).toEqual([]); // The middle cell is drawn from inside quarters only, which block whole: a box, not polygons. expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 3, y: 1, scale: 1 }]); expect(solidPolygons(m.project, props, m.mapAsset()).some((p) => p.x === 3 && p.y === 1)).toBe(false); }); it('a notch tile with nothing drawn in it blocks whole; once anything is drawn there, only that', () => { const m = outlinedMap(); m.blockOutline(POND); m.paint(cells(2, 0, 4, 2, '3,1'), m.lakeId); // a ring round an empty cell: four inner corners // Cell (2, 0)'s bottom right is a notch – the block's top right tile, which holds no outline. expect(blocksAt(m, { x: 2.9, y: 0.9 })).toBe(true); expect(blocksAt(m, { x: 3.5, y: 1.5 })).toBe(false); // A small triangle in the notch tile's top-left quarter: the inner corner of cell (4, 2). m.blockOutline(POND, P(32, 0, 40, 0, 32, 8)); expect(blocksAt(m, { x: 2.9, y: 0.9 })).toBe(false); expect(blocksAt(m, { x: 4.05, y: 2.05 })).toBe(true); expect(blocksAt(m, { x: 4.2, y: 2.2 })).toBe(false); }); it('an autotile block without an outline still blocks as boxes, with its collision scale', () => { const m = outlinedMap(); m.stack.execute(new SetAssetPropertyCommand(m.lakeId, TILE_COLLISION_SCALE_KEY, 0.5)); m.paint([[2, 3]], m.lakeId); expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 2, y: 3, scale: 0.5 }]); expect(solidPolygons(m.project, props, m.mapAsset())).toEqual([]); }); describe('as the scene view outlines it (#173)', () => { const boundary = (m: ReturnType) => solidBoundary( solidRects(m.project, props, m.mapAsset()), solidPolygons(m.project, props, m.mapAsset()), ); const sorted = (points: Vec2[]) => points.map((p) => `${p.x},${p.y}`).sort(); it('a lake of 3 × 3 cells is one closed outline, a quarter cell in all round', () => { const m = outlinedMap(); m.blockOutline(POND); m.paint(cells(2, 0, 4, 2), m.lakeId); // Drawn piece by piece it was the middle box and 32 quarter pieces. expect(solidPolygons(m.project, props, m.mapAsset()).length).toBeGreaterThan(8); const lines = boundary(m); expect(lines).toHaveLength(1); expect(lines[0]!.closed).toBe(true); expect(sorted(lines[0]!.points)).toEqual(sorted(P(2.25, 0.25, 4.75, 0.25, 4.75, 2.75, 2.25, 2.75))); }); it('a ring round an empty cell is two closed outlines, the hole a cross where the notches block whole', () => { const m = outlinedMap(); m.blockOutline(POND); m.paint(cells(2, 0, 4, 2, '3,1'), m.lakeId); const lines = boundary(m); expect(lines.map((line) => line.closed)).toEqual([true, true]); const hole = lines.find((line) => line.points.every((p) => p.x > 2.5 && p.x < 4.5))!; expect(sorted(hole.points)).toEqual( sorted( P(3, 0.75, 4, 0.75, 4, 1, 4.25, 1, 4.25, 2, 4, 2, 4, 2.25, 3, 2.25, 3, 2, 2.75, 2, 2.75, 1, 3, 1), ), ); }); it('a merged wall of plain boxes is one outline too, not its rectangles', () => { const m = outlinedMap(); m.paint( [ [0, 0], [1, 0], [2, 0], [0, 1], [0, 2], ], m.plainId, ); // an L: three rectangles merged const lines = boundary(m); expect(lines).toHaveLength(1); expect(sorted(lines[0]!.points)).toEqual(sorted(P(0, 0, 3, 0, 3, 1, 1, 1, 1, 3, 0, 3))); }); }); describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { it('a crate slides along the shore of a row of lake cells without catching on a quarter seam', () => { const m = outlinedMap(); m.blockOutline(POND); m.paint(cells(0, 3, 11, 3), m.lakeId); // shore at y = 3.25 cells, 40 in the world const crate = m.add({ x: -170, y: 0 }, { width: 30, height: 30 }); const r = run(m, physics); const mover = r.entity(crate.id); const steps: { dx: number; y: number; blockedX: boolean }[] = []; for (let i = 0; i < 100; i++) { const x = mover.x; const { blockedX } = r.move(mover, 3, 1.5); steps.push({ dx: mover.x - x, y: mover.y, blockedX }); } for (const step of steps.slice(20)) { expect(step.dx).toBeCloseTo(3 - 0.5 * 1.5, 9); expect(step.y).toBeCloseTo(40 - 15, 9); expect(step.blockedX).toBe(false); } expect(mover.x).toBeGreaterThan(-170 + 5 * 32); }); }); }); describe('outlined tiles in the engine (#141)', () => { it('a crate falls onto a wedge tile and slides down it to the floor', () => { const m = outlinedMap(); m.outline(P(0, 0, 32, 32, 0, 32)); // falling to the right m.paint([[5, 2]]); // x −32 … 0: from (−32, 0) down to (0, 32) m.paint( Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]), m.plainId, ); const crate = m.add({ x: -26, y: -30 }, { width: 10, height: 10, friction: 0 }, {}); const r = run(m, true); r.seconds(3); expect(r.entity(crate.id).x).toBeGreaterThan(5); // off the wedge's foot at x = 0 expect(r.entity(crate.id).y).toBeCloseTo(32 - 5, 0); // on the floor }); });