import { beforeAll, describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands'; import type { TilemapAsset } from '../../src/core/model/Asset'; import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; import { TILE_ONE_WAY_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap'; import { solidRects } from '../../src/core/model/tilemapCollision'; import { loadPhysics } from '../../src/runtime/physics/engine'; import type { PhysicsSystem } from '../../src/runtime/physics/PhysicsSystem'; import { COLLISION_SYSTEMS, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers'; beforeAll(async () => { await loadPhysics(); }); /** A scene of objects with one collider each; `add` sets the collider's (and rigidbody's) properties. */ 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; }; 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 move = (id: string, dx: number, dy: number) => started.collisions.moveAndSlide(started.world, entity(id), dx, dy); const seconds = (s: number, each?: () => void) => { for (let i = 0; i < Math.round(s * 60); i++) { started.frame(1 / 60); each?.(); } }; return { ...started, entity, move, seconds }; } /** A platform 200 wide and 8 thick with its top face at y = 100, blocking from above. */ const PLATFORM = { width: 200, height: 8, oneWay: true }; const PLATFORM_AT = { x: 0, y: 104 }; describe.each(COLLISION_SYSTEMS)('$system: Move and one-way colliders (#155)', ({ physics }) => { it('a box falling onto the platform lands flush on its top and the move reports the hit', () => { const s = scene(); s.add(PLATFORM_AT, PLATFORM); const box = s.add({ x: 0, y: 0 }, { width: 20, height: 20 }); const r = run(s, physics); let last = r.move(box, 0, 7); for (let i = 0; i < 20; i++) last = r.move(box, 0, 7); expect(r.entity(box).y).toBeCloseTo(90, 9); expect(last).toMatchObject({ hit: true, blockedY: true, normal: { x: 0, y: -1 } }); }); it('a box rising from below passes through, and lands on top once it falls back', () => { const s = scene(); s.add(PLATFORM_AT, PLATFORM); const box = s.add({ x: 0, y: 150 }, { width: 20, height: 20 }); const r = run(s, physics); for (let i = 0; i < 15; i++) expect(r.move(box, 0, -6).hit).toBe(false); expect(r.entity(box).y).toBeCloseTo(60, 9); // clear above the top at 100 for (let i = 0; i < 20; i++) r.move(box, 0, 6); expect(r.entity(box).y).toBeCloseTo(90, 9); }); it('a box running into its side passes through, and one stuck halfway falls back out below', () => { const s = scene(); s.add(PLATFORM_AT, PLATFORM); const walker = s.add({ x: -200, y: 104 }, { width: 20, height: 20 }); const stuck = s.add({ x: 60, y: 100 }, { width: 20, height: 20 }); const r = run(s, physics); for (let i = 0; i < 20; i++) expect(r.move(walker, 10, 0).hit).toBe(false); expect(r.entity(walker).x).toBeCloseTo(0, 9); for (let i = 0; i < 10; i++) r.move(stuck, 0, 5); expect(r.entity(stuck).y).toBeCloseTo(150, 9); }); it('a fast fall does not tunnel: 150 px in one move still lands on the 8 px platform', () => { const s = scene(); s.add(PLATFORM_AT, PLATFORM); const box = s.add({ x: 0, y: -200 }, { width: 20, height: 20 }); const r = run(s, physics); r.move(box, 0, 150); r.move(box, 0, 150); expect(r.entity(box).y).toBeCloseTo(90, 9); }); it('"Blocks from" turns the blocking side: left stops a mover coming from the left only', () => { const s = scene(); s.add({ x: 0, y: 0 }, { width: 8, height: 200, oneWay: true, oneWayDirection: 'left' }); const fromLeft = s.add({ x: -100, y: -50 }, { width: 20, height: 20 }); const fromRight = s.add({ x: 100, y: 50 }, { width: 20, height: 20 }); const r = run(s, physics); for (let i = 0; i < 20; i++) r.move(fromLeft, 10, 0); for (let i = 0; i < 20; i++) r.move(fromRight, -10, 0); expect(r.entity(fromLeft).x).toBeCloseTo(-14, 9); expect(r.entity(fromRight).x).toBeCloseTo(-100, 9); }); it('the side turns with the object: a platform turned 90° blocks from the right', () => { const s = scene(); const wall = s.add({ x: 0, y: 0 }, { width: 200, height: 8, oneWay: true }); s.stack.execute(new SetOverrideCommand(s.sceneId, wall, 'rotation', 90)); const fromRight = s.add({ x: 100, y: 0 }, { width: 20, height: 20 }); const r = run(s, physics); for (let i = 0; i < 20; i++) r.move(fromRight, -10, 0); expect(r.entity(fromRight).x).toBeCloseTo(14, 9); }); it('Ignore one-way lets an object standing on it drop through, then it lands on the next one', () => { const s = scene(); s.add(PLATFORM_AT, PLATFORM); s.add({ x: 0, y: 304 }, PLATFORM); // a second platform 200 lower const box = s.add({ x: 0, y: 90 }, { width: 20, height: 20 }); const r = run(s, physics); expect(r.move(box, 0, 5).hit).toBe(true); r.world.ignoreOneWay(r.entity(box), 0.1); r.frame(1 / 60); expect(r.move(box, 0, 5).hit).toBe(false); r.seconds(0.2); // the time is up while the box is still inside: it goes on through for (let i = 0; i < 60; i++) r.move(box, 0, 5); expect(r.entity(box).y).toBeCloseTo(290, 9); }); it('a row of one-way tiles blocks from above only and never merges with the solid tiles', () => { const s = scene(); const platform = new CreateAssetCommand('tile', 'Platform'); const wall = new CreateAssetCommand('tile', 'Wall'); const map = new CreateAssetCommand('tilemap', 'Ground'); for (const command of [platform, wall, map]) s.stack.execute(command); for (const id of [platform.asset.id, wall.asset.id]) s.stack.execute(new SetAssetPropertyCommand(id, TILE_SOLID_KEY, true)); s.stack.execute(new SetAssetPropertyCommand(platform.asset.id, TILE_ONE_WAY_KEY, true)); s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10)); s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10)); place(s, map.asset.id, { x: 0, y: 0 }); const asset = () => findAsset(s.project, map.asset.id) as TilemapAsset; // Cells of 48, centred: cell (x, y) spans x·48 − 240. Row 5 is a platform from x = 0 to 5, // with wall tiles right next to it in the same row. const cells = [ ...[0, 1, 2, 3, 4, 5].map((x) => ({ x, y: 5, tileId: platform.asset.id })), ...[6, 7].map((x) => ({ x, y: 5, tileId: wall.asset.id })), ]; s.stack.execute(new PaintTilesCommand(map.asset.id, asset().layers[0]!.id, cells)); expect(solidRects(s.project, {}, asset())).toEqual([{ x: 6, y: 5, w: 2, h: 1 }]); expect(solidRects(s.project, {}, asset(), true)).toEqual([{ x: 0, y: 5, w: 6, h: 1 }]); const lander = s.add({ x: -100, y: -40 }, { width: 20, height: 20 }); const jumper = s.add({ x: -150, y: 100 }, { width: 20, height: 20 }); const r = run(s, physics); for (let i = 0; i < 20; i++) r.move(lander, 0, 5); expect(r.entity(lander).y).toBeCloseTo(-10, 9); // on the top at 0 for (let i = 0; i < 20; i++) r.move(jumper, 0, -10); expect(r.entity(jumper).y).toBeCloseTo(-100, 9); }); }); describe('the physics engine and one-way colliders (#155)', () => { function engine(s: ReturnType) { const r = run(s, true); const physics = r.collisions as PhysicsSystem; return { ...r, physics }; } it('a dynamic box falls onto the platform and comes to rest on its top', () => { const s = scene(); s.add(PLATFORM_AT, PLATFORM); const box = s.add({ x: 0, y: 0 }, { width: 20, height: 20 }, {}); const r = engine(s); r.seconds(2); expect(r.entity(box).y).toBeCloseTo(90, 1); }); it('one shot up from below passes through and lands on top when it comes down', () => { const s = scene(); s.add(PLATFORM_AT, PLATFORM); const box = s.add({ x: 0, y: 160 }, { width: 20, height: 20 }, { fixedRotation: true }); const r = engine(s); r.physics.setVelocity(r.entity(box), { x: 0, y: -600 }); let highest = Infinity; r.seconds(3, () => (highest = Math.min(highest, r.entity(box).y))); expect(highest).toBeLessThan(70); expect(r.entity(box).y).toBeCloseTo(90, 1); }); it('one sliding along without gravity passes through its side', () => { const s = scene(); s.add(PLATFORM_AT, PLATFORM); const box = s.add({ x: -200, y: 104 }, { width: 20, height: 20 }, { gravityScale: 0 }); const r = engine(s); r.physics.setVelocity(r.entity(box), { x: 300, y: 0 }); r.seconds(1.5); expect(r.entity(box).x).toBeGreaterThan(200); }); it('a fast one does not tunnel through the 8 px platform', () => { const s = scene(); s.add(PLATFORM_AT, PLATFORM); const box = s.add({ x: 0, y: -400 }, { width: 20, height: 20 }, { fixedRotation: true }); const r = engine(s); r.physics.setVelocity(r.entity(box), { x: 0, y: 2000 }); r.seconds(2); expect(r.entity(box).y).toBeCloseTo(90, 1); }); it('Ignore one-way lets a resting body drop through onto the platform below', () => { const s = scene(); s.add(PLATFORM_AT, PLATFORM); s.add({ x: 0, y: 304 }, PLATFORM); const box = s.add({ x: 0, y: 80 }, { width: 20, height: 20 }, { fixedRotation: true }); const r = engine(s); r.seconds(1); expect(r.entity(box).y).toBeCloseTo(90, 1); r.world.ignoreOneWay(r.entity(box), 0.25); r.seconds(2); expect(r.entity(box).y).toBeCloseTo(290, 1); }); it('a dynamic box rests on a row of one-way tiles', () => { const s = scene(); const platform = new CreateAssetCommand('tile', 'Platform'); const map = new CreateAssetCommand('tilemap', 'Ground'); for (const command of [platform, map]) s.stack.execute(command); s.stack.execute(new SetAssetPropertyCommand(platform.asset.id, TILE_SOLID_KEY, true)); s.stack.execute(new SetAssetPropertyCommand(platform.asset.id, TILE_ONE_WAY_KEY, true)); s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10)); s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10)); place(s, map.asset.id, { x: 0, y: 0 }); const layer = (findAsset(s.project, map.asset.id) as TilemapAsset).layers[0]!.id; const row = Array.from({ length: 10 }, (_, x) => ({ x, y: 5, tileId: platform.asset.id })); s.stack.execute(new PaintTilesCommand(map.asset.id, layer, row)); const falls = s.add({ x: 0, y: -100 }, { width: 20, height: 20 }, { fixedRotation: true }); const rises = s.add({ x: 100, y: 100 }, { width: 20, height: 20 }, { fixedRotation: true }); const r = engine(s); r.physics.setVelocity(r.entity(rises), { x: 0, y: -600 }); r.seconds(3); expect(r.entity(falls).y).toBeCloseTo(-10, 1); expect(r.entity(rises).y).toBeCloseTo(-10, 1); }); });