Files
feuerwerk/tests/core/raycast.test.ts
T
adminandClaude Opus 5 0fdde6cb1e
game-maker / gate (push) Successful in 2m7s
game-maker / suite (push) Successful in 7m22s
The Raycast node filters by collision layers: the node gained a "Layers" setting, a tickbox per collision layer of the project - the same field kind the collider uses for "Collides with" - and the ray only hits what sits on a ticked layer, tilemaps counting as colliders; the default is every layer, and a graph without the param answers exactly as before, which is its own unit test; the check is one-sided on purpose, unlike the symmetric rule of #152: a ray is not a collider, so it never reads the collidesWith of the thing it hits - a collider that meets nothing is still seen by a ray - because who a ray may look at is the ray's business, and a unit test holds that case; the filter sits in Solids.raycast in front of the intersection test, which is the one path the editor run and Play both take, so every unit test runs through describe.each against both systems; NodeView did not know the field kinds layer/layers, so the node would have read "Layers: 65533" - it now shows the name of the layer, or "All layers"; 5 new e2e checks in the new block 43d (a shield on its own layer between the caster and the floor stops the ray, the "Layers" row on the node, the tickbox menu with the project's layers, the ray reaching 280 instead of 145 units once the shield's layer is unticked, and the mask read as a name) and 6 new unit tests, three against each system; two counter-checks hold: disarming the filter line drops 6 unit tests and the unticking check in module 43, and switching the formatting off makes the node check fail with the raw bits; two findings from measuring came out of it and stand in the wiki and the notes - __gm.rays() only measures while the Scene tab is in front, so a node selection blinds it and the block measures through a Log node on distance instead, and a running behavior instance keeps the graph it started with, so the editor run casts with the old mask until the instance is built anew (#158)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:15:35 +02:00

372 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 } 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]);
});
});