217 lines
9.9 KiB
TypeScript
217 lines
9.9 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 { ALL_LAYERS } from '../../src/core/model/collisionLayers';
|
||
import { IDENTITY } from '../../src/core/model/matrix';
|
||
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { TILE_ONE_WAY_ANGLE_KEY, 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 { oneWayAngles, solidRects } from '../../src/core/model/tilemapCollision';
|
||
import { landsOn, oneWayAngle, oneWayCone, oneWayFacing } from '../../src/core/properties/collider';
|
||
import { isRowShown, schemaFor } from '../../src/core/properties/PropertySchema';
|
||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||
import { COLLISION_SYSTEMS, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
|
||
|
||
beforeAll(async () => {
|
||
await loadPhysics();
|
||
});
|
||
|
||
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) => {
|
||
for (let i = 0; i < Math.round(s * 60); i++) started.frame(1 / 60);
|
||
};
|
||
return { ...started, entity, move, seconds };
|
||
}
|
||
|
||
/**
|
||
* A one-way wedge blocking from above whose slope leans 25° from level: the contact on the slope
|
||
* has a normal 25° from straight up, so it lands within any angle of 25° or more. At x = 0 the slope
|
||
* is at y = 50 − 100·tan 25° ≈ 3.37. Gentler than friction 0.5 lets slide (tan 25° ≈ 0.47).
|
||
*/
|
||
const SLOPE = (25 * Math.PI) / 180;
|
||
const WEDGE_POINTS = [
|
||
{ x: -100, y: 50 },
|
||
{ x: 100, y: 50 - 200 * Math.tan(SLOPE) },
|
||
{ x: 100, y: 50 },
|
||
];
|
||
const wedge = (angle?: number) => ({
|
||
shape: 'polygon',
|
||
points: WEDGE_POINTS,
|
||
oneWay: true,
|
||
...(angle === undefined ? {} : { oneWayAngle: angle }),
|
||
});
|
||
const SLOPE_AT_0 = 50 - 100 * Math.tan(SLOPE);
|
||
|
||
describe('the one-way angle (#159)', () => {
|
||
it('is 45° when not set, so projects from before it keep landing exactly as they did', () => {
|
||
const facing = oneWayFacing({ oneWay: true }, IDENTITY)!;
|
||
expect(facing.cone).toBe(Math.cos(Math.PI / 4) - 1e-9);
|
||
expect(schemaFor('collider').find((d) => d.key === 'oneWayAngle')?.default).toBe(45);
|
||
expect(schemaFor('tile').find((d) => d.key === TILE_ONE_WAY_ANGLE_KEY)?.default).toBe(45);
|
||
});
|
||
|
||
it('is held to 0 … 90°, and anything unreadable is the default', () => {
|
||
expect([-10, 0, 30, 90, 120].map(oneWayAngle)).toEqual([0, 0, 30, 90, 90]);
|
||
expect([undefined, 'wide', true, Number.NaN].map((v) => oneWayAngle(v as PropertyValue))).toEqual([
|
||
45, 45, 45, 45,
|
||
]);
|
||
});
|
||
|
||
it('lands a normal exactly on the edge of the cone, and not one a hair beyond it', () => {
|
||
const up = { x: 0, y: -1 };
|
||
const at = (deg: number) => ({ x: Math.sin((deg * Math.PI) / 180), y: -Math.cos((deg * Math.PI) / 180) });
|
||
for (const angle of [0, 20, 45, 90]) {
|
||
const side = { ...up, cone: oneWayCone(angle) };
|
||
expect(landsOn(at(angle), side)).toBe(true);
|
||
expect(landsOn(at(angle + 0.01), side)).toBe(false);
|
||
}
|
||
});
|
||
|
||
it('the inspector shows the angle and "Blocks from" only while One-way is on', () => {
|
||
const collider = schemaFor('collider');
|
||
const shown = (values: Record<string, PropertyValue>) =>
|
||
collider.filter((d) => isRowShown(d, values)).map((d) => d.key);
|
||
expect(shown({ oneWay: false })).not.toContain('oneWayAngle');
|
||
expect(shown({ oneWay: false })).not.toContain('oneWayDirection');
|
||
expect(shown({ oneWay: true })).toEqual(expect.arrayContaining(['oneWayAngle', 'oneWayDirection']));
|
||
const tile = schemaFor('tile');
|
||
const angleRow = tile.find((d) => d.key === TILE_ONE_WAY_ANGLE_KEY)!;
|
||
expect(isRowShown(angleRow, { [TILE_ONE_WAY_KEY]: false })).toBe(false);
|
||
expect(isRowShown(angleRow, { [TILE_ONE_WAY_KEY]: true })).toBe(true);
|
||
// Rows without a switch are always there.
|
||
expect(tile.filter((d) => !isRowShown(d, {})).map((d) => d.key)).toEqual([TILE_ONE_WAY_ANGLE_KEY]);
|
||
});
|
||
});
|
||
|
||
describe.each(COLLISION_SYSTEMS)('$system: Move and the one-way angle (#159)', ({ physics }) => {
|
||
it('a box falling onto a 25° slope lands at the default 45° and at 25°, and falls through at 20°', () => {
|
||
for (const [angle, lands] of [
|
||
[undefined, true],
|
||
[25, true],
|
||
[20, false],
|
||
] as const) {
|
||
const s = scene();
|
||
s.add({ x: 0, y: 0 }, wedge(angle));
|
||
const box = s.add({ x: 0, y: -100 }, { width: 20, height: 20 });
|
||
const r = run(s, physics);
|
||
let hit = false;
|
||
for (let i = 0; i < 60; i++) hit = r.move(box, 0, 5).hit || hit;
|
||
expect({ angle, hit }).toEqual({ angle, hit: lands });
|
||
if (lands) expect(r.entity(box).y).toBeLessThan(SLOPE_AT_0 - 10 + 1e-6);
|
||
else expect(r.entity(box).y).toBeCloseTo(200, 9);
|
||
}
|
||
});
|
||
|
||
it('at 90° the sides of a platform block too, while at 45° a box walks through them', () => {
|
||
for (const [angle, blocks] of [
|
||
[45, false],
|
||
[90, true],
|
||
] as const) {
|
||
const s = scene();
|
||
s.add({ x: 0, y: 104 }, { width: 200, height: 8, oneWay: true, oneWayAngle: angle });
|
||
const walker = s.add({ x: -200, y: 104 }, { width: 20, height: 20 });
|
||
const r = run(s, physics);
|
||
for (let i = 0; i < 20; i++) r.move(walker, 10, 0);
|
||
expect(r.entity(walker).x).toBeCloseTo(blocks ? -110 : 0, 9);
|
||
}
|
||
});
|
||
|
||
it('a ray straight down meets the slope within the angle and passes it beyond', () => {
|
||
for (const [angle, meets] of [
|
||
[45, true],
|
||
[20, false],
|
||
] as const) {
|
||
const s = scene();
|
||
s.add({ x: 0, y: 0 }, wedge(angle));
|
||
const r = run(s, physics);
|
||
const hit = r.collisions.raycast(r.world, { x: 0, y: -100 }, { x: 0, y: 1 }, 500, {
|
||
ignore: null,
|
||
triggers: false,
|
||
layers: ALL_LAYERS,
|
||
});
|
||
if (meets) expect(hit?.point.y).toBeCloseTo(SLOPE_AT_0, 9);
|
||
else expect(hit).toBeNull();
|
||
}
|
||
});
|
||
|
||
it('one-way tiles of two angles stay apart, and each blocks by its own', () => {
|
||
const s = scene();
|
||
const [steep, wide] = [new CreateAssetCommand('tile', 'Steep'), new CreateAssetCommand('tile', 'Wide')];
|
||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||
for (const command of [steep, wide, map]) s.stack.execute(command);
|
||
for (const tile of [steep, wide]) {
|
||
s.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
|
||
s.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_ONE_WAY_KEY, true));
|
||
}
|
||
s.stack.execute(new SetAssetPropertyCommand(wide.asset.id, TILE_ONE_WAY_ANGLE_KEY, 90));
|
||
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 (y 0 … 48): the default tile at
|
||
// x = 0 … 2, the 90° one at x = 5 … 7.
|
||
const cells = [
|
||
...[0, 1, 2].map((x) => ({ x, y: 5, tileId: steep.asset.id })),
|
||
...[5, 6, 7].map((x) => ({ x, y: 5, tileId: wide.asset.id })),
|
||
...[3, 4].map((x) => ({ x, y: 7, tileId: wide.asset.id })),
|
||
];
|
||
s.stack.execute(new PaintTilesCommand(map.asset.id, asset().layers[0]!.id, cells));
|
||
expect(oneWayAngles(s.project, asset())).toEqual([45, 90]);
|
||
expect(solidRects(s.project, {}, asset(), 45)).toEqual([{ x: 0, y: 5, w: 3, h: 1 }]);
|
||
expect(solidRects(s.project, {}, asset(), 90)).toEqual([
|
||
{ x: 5, y: 5, w: 3, h: 1 },
|
||
{ x: 3, y: 7, w: 2, h: 1 },
|
||
]);
|
||
// Walking in along row 5 from the left: through the 45° tiles, stopped by the side of the 90° ones.
|
||
const walker = s.add({ x: -300, y: 24 }, { width: 20, height: 20 });
|
||
const r = run(s, physics);
|
||
for (let i = 0; i < 40; i++) r.move(walker, 10, 0);
|
||
expect(r.entity(walker).x).toBeCloseTo(-10, 9);
|
||
});
|
||
});
|
||
|
||
describe('the physics engine and the one-way angle (#159)', () => {
|
||
it('a dynamic box rests on the 25° slope at 45°, and falls through onto the ground at 20°', () => {
|
||
for (const [angle, rests] of [
|
||
[45, true],
|
||
[20, false],
|
||
] as const) {
|
||
const s = scene();
|
||
s.add({ x: 0, y: 0 }, wedge(angle));
|
||
s.add({ x: 0, y: 304 }, { width: 400, height: 8 });
|
||
const box = s.add({ x: 0, y: -100 }, { width: 20, height: 20 }, { fixedRotation: true });
|
||
const r = run(s, true);
|
||
r.seconds(2);
|
||
if (rests) expect(r.entity(box).y).toBeLessThan(SLOPE_AT_0);
|
||
else expect(r.entity(box).y).toBeCloseTo(290, 1);
|
||
}
|
||
});
|
||
});
|