372 lines
17 KiB
TypeScript
372 lines
17 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddColliderCommand } 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,
|
||
COLLIDES_WITH_KEY,
|
||
COLLISION_LAYER_KEY,
|
||
maskWith,
|
||
} from '../../src/core/model/collisionLayers';
|
||
import { IDENTITY, type Mat } from '../../src/core/model/matrix';
|
||
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { TILE_SOLID_KEY } from '../../src/core/model/tile';
|
||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
|
||
import { colliderShape, type ColliderShape } from '../../src/core/properties/collider';
|
||
import { RAY_LAYERS_PARAM } from '../../src/core/graph/nodes/action/Raycast';
|
||
import { rayCast } from '../../src/core/properties/ray';
|
||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||
import { RAY_SHOWN } from '../../src/runtime/World';
|
||
import {
|
||
COLLISION_SYSTEMS,
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
place,
|
||
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]! }));
|
||
|
||
const turned = (degrees: number, x = 0, y = 0): Mat => {
|
||
const a = (degrees * Math.PI) / 180;
|
||
return { a: Math.cos(a), b: Math.sin(a), c: -Math.sin(a), d: Math.cos(a), tx: x, ty: y };
|
||
};
|
||
|
||
const shape = (props: Record<string, PropertyValue>, m: Mat = IDENTITY): ColliderShape =>
|
||
colliderShape(props, m);
|
||
const RIGHT = { x: 1, y: 0 };
|
||
const DOWN = { x: 0, y: 1 };
|
||
|
||
describe('where a ray meets a shape (#153)', () => {
|
||
it('meets a box on the face it comes in through, from every side', () => {
|
||
const box = shape({ width: 40, height: 20 });
|
||
expect(rayCast(box, { x: -100, y: 5 }, RIGHT, 1000)).toEqual({ distance: 80, normal: { x: -1, y: 0 } });
|
||
expect(rayCast(box, { x: 3, y: -50 }, DOWN, 1000)).toEqual({ distance: 40, normal: { x: 0, y: -1 } });
|
||
expect(rayCast(box, { x: 100, y: 0 }, { x: -1, y: 0 }, 1000)).toEqual({
|
||
distance: 80,
|
||
normal: { x: 1, y: 0 },
|
||
});
|
||
expect(rayCast(box, { x: 0, y: 60 }, { x: 0, y: -1 }, 1000)).toEqual({
|
||
distance: 50,
|
||
normal: { x: 0, y: 1 },
|
||
});
|
||
});
|
||
|
||
it('meets a turned box on its slanted face', () => {
|
||
const box = shape({ width: 40, height: 40 }, turned(45));
|
||
const hit = rayCast(box, { x: -100, y: 0 }, RIGHT, 1000)!;
|
||
// The corner of the diamond points left, at x = −20·√2.
|
||
expect(hit.distance).toBeCloseTo(100 - 20 * Math.SQRT2, 9);
|
||
expect(Math.abs(hit.normal.y)).toBeCloseTo(Math.SQRT1_2, 9);
|
||
expect(hit.normal.x).toBeCloseTo(-Math.SQRT1_2, 9);
|
||
});
|
||
|
||
it('meets a circle where it is round, off-centre too', () => {
|
||
const circle = shape({ shape: 'circle', radius: 10 });
|
||
expect(rayCast(circle, { x: -50, y: 0 }, RIGHT, 1000)).toEqual({ distance: 40, normal: { x: -1, y: 0 } });
|
||
const hit = rayCast(circle, { x: -50, y: 6 }, RIGHT, 1000)!;
|
||
expect(hit.distance).toBeCloseTo(50 - 8, 9);
|
||
expect(hit.normal.x).toBeCloseTo(-0.8, 9);
|
||
expect(hit.normal.y).toBeCloseTo(0.6, 9);
|
||
});
|
||
|
||
it('meets a capsule on its flat side and on its round end', () => {
|
||
const capsule = shape({ shape: 'capsule', width: 20, height: 60 }); // round ends at y = ±20, radius 10
|
||
expect(rayCast(capsule, { x: -50, y: 0 }, RIGHT, 1000)).toEqual({
|
||
distance: 40,
|
||
normal: { x: -1, y: 0 },
|
||
});
|
||
expect(rayCast(capsule, { x: 0, y: -100 }, DOWN, 1000)).toEqual({
|
||
distance: 70,
|
||
normal: { x: 0, y: -1 },
|
||
});
|
||
const hit = rayCast(capsule, { x: 6, y: -100 }, DOWN, 1000)!;
|
||
expect(hit.distance).toBeCloseTo(100 - 20 - 8, 9);
|
||
expect(hit.normal.x).toBeCloseTo(0.6, 9);
|
||
});
|
||
|
||
it('goes down into the notch of a concave polygon and meets its inner floor, not the hull', () => {
|
||
const u = shape({
|
||
shape: 'polygon',
|
||
points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50),
|
||
});
|
||
expect(rayCast(u, { x: 0, y: -100 }, DOWN, 1000)).toEqual({ distance: 130, normal: { x: 0, y: -1 } });
|
||
expect(rayCast(u, { x: -50, y: -100 }, DOWN, 1000)).toEqual({ distance: 50, normal: { x: 0, y: -1 } });
|
||
});
|
||
|
||
it('a polygon square answers exactly as the box it stands for, for 900 rays in every direction', () => {
|
||
const box = shape({ width: 40, height: 30 }, turned(20, 5, -3));
|
||
const square = shape(
|
||
{ shape: 'polygon', points: P(-20, -15, 20, -15, 20, 15, -20, 15) },
|
||
turned(20, 5, -3),
|
||
);
|
||
let seed = 7;
|
||
const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647) * 2 - 1;
|
||
let hits = 0;
|
||
for (let i = 0; i < 900; i++) {
|
||
const from = { x: random() * 80, y: random() * 80 };
|
||
const a = random() * Math.PI;
|
||
const dir = { x: Math.cos(a), y: Math.sin(a) };
|
||
const p = rayCast(box, from, dir, 200);
|
||
const q = rayCast(square, from, dir, 200);
|
||
expect(q === null, `ray ${i}`).toBe(p === null);
|
||
if (!p || !q) continue;
|
||
hits++;
|
||
expect(q.distance, `ray ${i}`).toBeCloseTo(p.distance, 9);
|
||
expect(q.normal.x, `ray ${i}`).toBeCloseTo(p.normal.x, 9);
|
||
expect(q.normal.y, `ray ${i}`).toBeCloseTo(p.normal.y, 9);
|
||
}
|
||
expect(hits).toBeGreaterThan(80);
|
||
});
|
||
|
||
it('a ray starting inside a shape does not meet it; one on its outline meets it at 0 going in, not going out', () => {
|
||
const box = shape({ width: 40, height: 20 });
|
||
expect(rayCast(box, { x: 0, y: 0 }, RIGHT, 1000)).toBeNull();
|
||
expect(rayCast(shape({ shape: 'circle', radius: 10 }), { x: 2, y: 0 }, RIGHT, 1000)).toBeNull();
|
||
expect(rayCast(box, { x: -20, y: 0 }, RIGHT, 1000)).toEqual({ distance: 0, normal: { x: -1, y: 0 } });
|
||
expect(rayCast(box, { x: -20, y: 0 }, { x: -1, y: 0 }, 1000)).toBeNull();
|
||
});
|
||
|
||
it('inside a shape made of parts the ray does not stop at a seam between them', () => {
|
||
// Inside the capsule's straight part but outside its end circle: upwards it would enter the circle.
|
||
const capsule = shape({ shape: 'capsule', width: 20, height: 60 });
|
||
expect(rayCast(capsule, { x: 9, y: -12 }, { x: 0, y: -1 }, 1000)).toBeNull();
|
||
// Along the bottom bar of the U, across the seams of its convex pieces.
|
||
const u = shape({
|
||
shape: 'polygon',
|
||
points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50),
|
||
});
|
||
expect(rayCast(u, { x: -55, y: 40 }, RIGHT, 1000)).toBeNull();
|
||
});
|
||
|
||
it('a ray grazing a corner meets it; one passing by or falling short does not', () => {
|
||
const box = shape({ width: 40, height: 20 });
|
||
expect(rayCast(box, { x: -100, y: -10 }, RIGHT, 1000)?.distance).toBeCloseTo(80, 12);
|
||
expect(rayCast(box, { x: -100, y: -10.001 }, RIGHT, 1000)).toBeNull();
|
||
expect(rayCast(box, { x: -100, y: 0 }, RIGHT, 79.9)).toBeNull();
|
||
expect(rayCast(box, { x: -100, y: 0 }, RIGHT, 80)?.distance).toBe(80);
|
||
});
|
||
});
|
||
|
||
/** A scene of objects with one collider each; `add` returns the object and collider ids. */
|
||
function scene() {
|
||
const f = makeSpriteScene('Thing');
|
||
const add = (at: Vec2, collider: Record<string, PropertyValue>, parent?: string) => {
|
||
const id = place(f, f.spriteId, at, parent);
|
||
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));
|
||
return { id, collider: command.objectId };
|
||
};
|
||
return { ...f, add };
|
||
}
|
||
|
||
function run(f: SceneFixture, physics: boolean) {
|
||
const started = startWorld(f, { physics, collisions: true, behaviors: true });
|
||
const entity = (id: string) => started.world.findByObjectId(id)!;
|
||
const cast = (
|
||
from: Vec2,
|
||
dir: Vec2,
|
||
length = 1000,
|
||
ignore: string | null = null,
|
||
triggers = false,
|
||
layers = ALL_LAYERS,
|
||
) =>
|
||
started.collisions.raycast(started.world, from, dir, length, {
|
||
ignore: ignore ? entity(ignore) : null,
|
||
triggers,
|
||
layers,
|
||
});
|
||
return { ...started, entity, cast };
|
||
}
|
||
|
||
describe.each(COLLISION_SYSTEMS)('$system: a ray through the world (#153)', ({ physics }) => {
|
||
it('meets the nearest collider, and says which one, where and how far', () => {
|
||
const s = scene();
|
||
const far = s.add({ x: 300, y: 0 }, { width: 20, height: 100 });
|
||
const near = s.add({ x: 100, y: 0 }, { shape: 'circle', radius: 10 });
|
||
const r = run(s, physics);
|
||
expect(r.cast({ x: 0, y: 0 }, RIGHT)).toEqual({
|
||
collider: r.entity(near.collider),
|
||
point: { x: 90, y: 0 },
|
||
normal: { x: -1, y: 0 },
|
||
distance: 90,
|
||
});
|
||
expect(r.cast({ x: 0, y: 30 }, RIGHT)?.collider).toBe(r.entity(far.collider));
|
||
expect(r.cast({ x: 0, y: 30 }, RIGHT, 200)).toBeNull();
|
||
});
|
||
|
||
it('passes triggers unless asked, and what it ignores – with everything parented below it', () => {
|
||
const s = scene();
|
||
const trigger = s.add({ x: 50, y: 0 }, { width: 10, height: 100, isTrigger: true });
|
||
const owner = s.add({ x: 100, y: 0 }, { width: 10, height: 100 });
|
||
const child = s.add({ x: 150, y: 0 }, { width: 10, height: 100 }, owner.id);
|
||
const wall = s.add({ x: 300, y: 0 }, { width: 10, height: 100 });
|
||
const r = run(s, physics);
|
||
expect(r.cast({ x: 0, y: 0 }, RIGHT)?.collider).toBe(r.entity(owner.collider));
|
||
expect(r.cast({ x: 0, y: 0 }, RIGHT, 1000, null, true)?.collider).toBe(r.entity(trigger.collider));
|
||
expect(r.cast({ x: 0, y: 0 }, RIGHT, 1000, owner.id)?.collider).toBe(r.entity(wall.collider));
|
||
expect(r.cast({ x: 200, y: 0 }, { x: -1, y: 0 }, 1000, owner.id)).toBeNull();
|
||
expect(r.cast({ x: 120, y: 0 }, RIGHT)?.collider).toBe(r.entity(child.collider));
|
||
});
|
||
|
||
it('meets a one-way platform only from the side it blocks from (#155)', () => {
|
||
const s = scene();
|
||
s.add({ x: 0, y: 104 }, { width: 200, height: 8, oneWay: true });
|
||
const r = run(s, physics);
|
||
expect(r.cast({ x: 0, y: 0 }, DOWN)).toMatchObject({ distance: 100, normal: { x: 0, y: -1 } });
|
||
expect(r.cast({ x: 0, y: 200 }, { x: 0, y: -1 })).toBeNull();
|
||
expect(r.cast({ x: -300, y: 104 }, RIGHT)).toBeNull();
|
||
});
|
||
|
||
it('meets the cells of a tilemap, the map being the collider', () => {
|
||
const s = scene();
|
||
const wall = new CreateAssetCommand('tile', 'Wall');
|
||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||
s.stack.execute(wall);
|
||
s.stack.execute(map);
|
||
s.stack.execute(new SetAssetPropertyCommand(wall.asset.id, TILE_SOLID_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));
|
||
const mapObject = place(s, map.asset.id, { x: 0, y: 0 });
|
||
const layer = (findAsset(s.project, map.asset.id) as TilemapAsset).layers[0]!.id;
|
||
// Cells of 48, centred: the bottom row's top face is at 4·48 = 192, a pillar in column 7 from row 5.
|
||
const cells = [
|
||
...Array.from({ length: 10 }, (_, x) => ({ x, y: 9, tileId: wall.asset.id })),
|
||
...[5, 6, 7, 8].map((y) => ({ x: 7, y, tileId: wall.asset.id })),
|
||
];
|
||
s.stack.execute(new PaintTilesCommand(map.asset.id, layer, cells));
|
||
const r = run(s, physics);
|
||
expect(r.cast({ x: 0, y: 0 }, DOWN)).toEqual({
|
||
collider: r.entity(mapObject),
|
||
point: { x: 0, y: 192 },
|
||
normal: { x: 0, y: -1 },
|
||
distance: 192,
|
||
});
|
||
expect(r.cast({ x: 0, y: 100 }, RIGHT)).toMatchObject({
|
||
point: { x: 96, y: 100 },
|
||
normal: { x: -1, y: 0 },
|
||
});
|
||
});
|
||
|
||
it('passes what sits on a layer it does not look at, and meets the next one behind it (#158)', () => {
|
||
const s = scene();
|
||
// Near sits on layer 1, far on layer 0; the near one also collides with nothing at all, which
|
||
// is a collider's own business and must not decide what a ray may see.
|
||
s.add({ x: 100, y: 0 }, { width: 20, height: 100, [COLLISION_LAYER_KEY]: 1, [COLLIDES_WITH_KEY]: 0 });
|
||
const far = s.add({ x: 300, y: 0 }, { width: 20, height: 100 });
|
||
const r = run(s, physics);
|
||
expect(r.cast({ x: 0, y: 0 }, RIGHT)?.distance).toBe(90);
|
||
expect(r.cast({ x: 0, y: 0 }, RIGHT, 1000, null, false, maskWith(ALL_LAYERS, 1, false))).toMatchObject({
|
||
collider: r.entity(far.collider),
|
||
distance: 290,
|
||
});
|
||
expect(r.cast({ x: 0, y: 0 }, RIGHT, 1000, null, false, 0)).toBeNull();
|
||
});
|
||
|
||
it('a tilemap is met or passed by its own layer, like any collider (#158)', () => {
|
||
const s = scene();
|
||
const wall = new CreateAssetCommand('tile', 'Wall');
|
||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||
s.stack.execute(wall);
|
||
s.stack.execute(map);
|
||
s.stack.execute(new SetAssetPropertyCommand(wall.asset.id, TILE_SOLID_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));
|
||
const mapObject = place(s, map.asset.id, { x: 0, y: 0 });
|
||
s.stack.execute(new SetOverrideCommand(s.sceneId, mapObject, COLLISION_LAYER_KEY, 2));
|
||
const layer = (findAsset(s.project, map.asset.id) as TilemapAsset).layers[0]!.id;
|
||
s.stack.execute(
|
||
new PaintTilesCommand(
|
||
map.asset.id,
|
||
layer,
|
||
Array.from({ length: 10 }, (_, x) => ({ x, y: 9, tileId: wall.asset.id })),
|
||
),
|
||
);
|
||
const r = run(s, physics);
|
||
const down = (layers: number) => r.cast({ x: 0, y: 0 }, DOWN, 1000, null, false, layers);
|
||
expect(down(ALL_LAYERS)?.distance).toBe(192);
|
||
expect(down(1 << 2)?.collider).toBe(r.entity(mapObject));
|
||
expect(down(maskWith(ALL_LAYERS, 2, false))).toBeNull();
|
||
});
|
||
|
||
it('the Raycast node casts from the graph, skips its owner and hands on what it met', () => {
|
||
const s = scene();
|
||
const hero = s.add({ x: 0, y: 0 }, { width: 20, height: 20 });
|
||
s.add({ x: 0, y: 150 }, { width: 200, height: 20 }); // top face at 140
|
||
const behavior = makeBehavior(s, 'Eyes');
|
||
place(s, behavior.id, { x: 0, y: 0 }, hero.id);
|
||
const edit = graphEditor(s, behavior);
|
||
const update = edit.addNode('OnUpdate');
|
||
const ray = edit.addNode('Raycast', { fromX: 0, fromY: 0, dirX: 0, dirY: 1, length: 500 });
|
||
const logHit = edit.addNode('Log');
|
||
const logDistance = edit.addNode('Log');
|
||
edit.wire([
|
||
[update, 'exec', ray, 'exec'],
|
||
[ray, 'exec', logHit, 'exec'],
|
||
[ray, 'hit', logHit, 'message'],
|
||
[logHit, 'exec', logDistance, 'exec'],
|
||
[ray, 'distance', logDistance, 'message'],
|
||
]);
|
||
const r = run(s, physics);
|
||
r.frame(1 / 60);
|
||
// It starts inside its own hero, which it would not meet anyway; the floor is 140 down.
|
||
expect(r.world.log).toEqual(['true', '140']);
|
||
expect(r.world.rays).toEqual([{ from: { x: 0, y: 0 }, to: { x: 0, y: 140 }, hit: true, time: 1 / 60 }]);
|
||
});
|
||
|
||
it('the node looks only at the layers its setting names, and at every one without it (#158)', () => {
|
||
const cast = (layers?: number) => {
|
||
const s = scene();
|
||
const hero = s.add({ x: 0, y: 0 }, { width: 20, height: 20 });
|
||
s.add({ x: 0, y: 150 }, { width: 200, height: 20, [COLLISION_LAYER_KEY]: 1 }); // top face at 140
|
||
s.add({ x: 0, y: 300 }, { width: 200, height: 20 }); // top face at 290, layer 0
|
||
const behavior = makeBehavior(s, 'Eyes');
|
||
place(s, behavior.id, { x: 0, y: 0 }, hero.id);
|
||
const edit = graphEditor(s, behavior);
|
||
const update = edit.addNode('OnUpdate');
|
||
const params: Record<string, PropertyValue> = { fromX: 0, fromY: 0, dirX: 0, dirY: 1, length: 500 };
|
||
if (layers !== undefined) params[RAY_LAYERS_PARAM] = layers;
|
||
const ray = edit.addNode('Raycast', params);
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[update, 'exec', ray, 'exec'],
|
||
[ray, 'exec', log, 'exec'],
|
||
[ray, 'distance', log, 'message'],
|
||
]);
|
||
const r = run(s, physics);
|
||
r.frame(1 / 60);
|
||
return r.world.log;
|
||
};
|
||
// A graph from before the setting existed carries no `layers` at all and must answer as it did.
|
||
expect(cast()).toEqual(['140']);
|
||
expect(cast(ALL_LAYERS)).toEqual(['140']);
|
||
expect(cast(maskWith(ALL_LAYERS, 1, false))).toEqual(['290']);
|
||
expect(cast(0)).toEqual(['0']);
|
||
});
|
||
});
|
||
|
||
describe('the rays a run remembers for drawing (#153)', () => {
|
||
it('keeps those of the last moment only, and a ray that met nothing ends at its length', () => {
|
||
const s = scene();
|
||
const r = run(s, false);
|
||
r.world.noteRay({ x: 0, y: 0 }, { x: 10, y: 0 }, false);
|
||
r.frame(RAY_SHOWN / 2);
|
||
r.world.noteRay({ x: 0, y: 0 }, { x: 20, y: 0 }, true);
|
||
r.frame(RAY_SHOWN);
|
||
r.world.noteRay({ x: 0, y: 0 }, { x: 30, y: 0 }, false);
|
||
expect(r.world.rays.map((ray) => ray.to.x)).toEqual([20, 30]);
|
||
});
|
||
});
|