Files
feuerwerk/tests/core/one-way.test.ts
T

255 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});