Files
feuerwerk/tests/core/shapes.test.ts
T

366 lines
17 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 { 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<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, 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
});
});