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 { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap'; import { solidCells, solidPolygons, tileOutline } 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 map = new CreateAssetCommand('tilemap', 'Ground'); for (const command of [texture, tile, plain, 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 = 64; 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)); for (const id of [tile.asset.id, plain.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 = (points: Vec2[]) => f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, points)); const tileAsset = () => findAsset(f.project, tile.asset.id) as TileAsset; return { ...f, mapId: map.asset.id, mapObject, mapAsset, paint, outline, tileAsset, plainId: plain.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 }, ] as Vec2[]; 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 plain tile blocks with its first picture’s outline; an autotile, or an outline that is none, as a box', () => { const m = outlinedMap(); expect(tileOutline(m.project, m.tileAsset())).toBeNull(); m.outline(WEDGE); expect(tileOutline(m.project, m.tileAsset())).toEqual({ outline: P(0, 1, 1, 0, 1, 1), pieces: [P(0, 1, 1, 0, 1, 1)], }); m.outline(P(0, 0, 32, 32, 32, 0, 0, 32)); // a bow tie expect(tileOutline(m.project, m.tileAsset())).toBeNull(); m.outline(WEDGE); m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_KIND_KEY, 'floor')); expect(tileOutline(m.project, m.tileAsset())).toBeNull(); }); 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); }); }); }); 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 }); });