255 lines
12 KiB
TypeScript
255 lines
12 KiB
TypeScript
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<string, PropertyValue>, body?: Record<string, PropertyValue>) => {
|
||
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<typeof scene>) {
|
||
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);
|
||
});
|
||
});
|