Files
feuerwerk/tests/core/raycast.test.ts
T
adminandClaude Opus 5.5 056a622524
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Successful in 8m55s
Refactoring round 6: shared sheet canvas, fewer copies (#85)
Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).

- SheetCanvas is the new base of the texture, pixel art and vector art
  canvases. It holds background, mask, hint, panning, wheel zoom, fit,
  view save and restore and the screen mapping. Pixel art keeps its
  whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
  animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
  contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
  commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
  GeneratedPictures.has and SnapToggle.snapping. Tests now check the
  same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
  The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
  wiki pages.

Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
  has not been called since #128. The rectangle tool therefore records
  a no-op undo step when it paints the same tile over itself. Left as
  is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
  and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
  could become 10 without a red test. tests/core/raycast.test.ts now
  covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
  #164. The sum is now 1310 (1308 + 2), computed.

Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
  both new pan checks; unclamped mix fails 1 test, no blue channel 3;
  coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
  bent ray length 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 06:57:43 +02:00

538 lines
24 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 type { NodeDef } from '../../src/core/graph/NodeRegistry';
import { Raycast, RAY_LAYERS_PARAM } from '../../src/core/graph/nodes/action/Raycast';
import { RaycastAll } from '../../src/core/graph/nodes/action/RaycastAll';
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,
});
const castAll = (
from: Vec2,
dir: Vec2,
length = 1000,
{ ignore = null as string | null, triggers = false, layers = ALL_LAYERS, maxHits = 0 } = {},
) =>
started.collisions.raycastAll(
started.world,
from,
dir,
length,
{ ignore: ignore ? entity(ignore) : null, triggers, layers },
maxHits,
);
return { ...started, entity, cast, castAll };
}
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.each(COLLISION_SYSTEMS)('$system: every hit on the line (#157)', ({ physics }) => {
it('gives them nearest first, and the nearest of them is what a plain raycast says', () => {
const s = scene();
const far = s.add({ x: 300, y: 0 }, { width: 20, height: 100 }); // near face at 290
const near = s.add({ x: 100, y: 0 }, { shape: 'circle', radius: 10 }); // near face at 90
const middle = s.add({ x: 200, y: 0 }, { width: 20, height: 100 }); // near face at 190
const r = run(s, physics);
const hits = r.castAll({ x: 0, y: 0 }, RIGHT);
expect(hits.map((hit) => hit.distance)).toEqual([90, 190, 290]);
expect(hits.map((hit) => hit.collider)).toEqual([
r.entity(near.collider),
r.entity(middle.collider),
r.entity(far.collider),
]);
expect(hits[0]).toEqual(r.cast({ x: 0, y: 0 }, RIGHT));
// Every hit is where it says it is, with the face's normal pointing back at the ray.
expect(hits[1]).toMatchObject({ point: { x: 190, y: 0 }, normal: { x: -1, y: 0 } });
expect(r.castAll({ x: 0, y: 0 }, RIGHT, 200).map((hit) => hit.distance)).toEqual([90, 190]);
expect(r.castAll({ x: 0, y: -300 }, RIGHT)).toEqual([]);
});
it('counts a collider once however many convex pieces it is made of', () => {
const s = scene();
// A U on its side: the ray down the middle enters the left arm, the notch and the right arm.
const u = 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 r = run(s, physics);
const hits = r.castAll({ x: 0, y: -100 }, DOWN);
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ collider: r.entity(u.collider), distance: 130 });
});
it('keeps the filters of a plain cast: the owner, triggers and the layers (#158)', () => {
const s = scene();
const owner = s.add({ x: 100, y: 0 }, { width: 10, height: 100 });
const trigger = s.add({ x: 150, y: 0 }, { width: 10, height: 100, isTrigger: true });
const hidden = s.add({ x: 200, y: 0 }, { width: 10, height: 100, [COLLISION_LAYER_KEY]: 1 });
const wall = s.add({ x: 300, y: 0 }, { width: 10, height: 100 });
const r = run(s, physics);
const ids = (hits: { collider: unknown }[]) => hits.map((hit) => hit.collider);
expect(ids(r.castAll({ x: 0, y: 0 }, RIGHT))).toEqual([
r.entity(owner.collider),
r.entity(hidden.collider),
r.entity(wall.collider),
]);
expect(ids(r.castAll({ x: 0, y: 0 }, RIGHT, 1000, { triggers: true }))).toContain(
r.entity(trigger.collider),
);
expect(ids(r.castAll({ x: 0, y: 0 }, RIGHT, 1000, { ignore: owner.id }))).toEqual([
r.entity(hidden.collider),
r.entity(wall.collider),
]);
expect(ids(r.castAll({ x: 0, y: 0 }, RIGHT, 1000, { layers: maskWith(ALL_LAYERS, 1, false) }))).toEqual([
r.entity(owner.collider),
r.entity(wall.collider),
]);
});
it('meets a tilemap once per cell the ray goes through, the map being the collider each time', () => {
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: a lone cell in row 5 (top face at 0) and one in row 9 (top face at 192).
s.stack.execute(
new PaintTilesCommand(map.asset.id, layer, [
{ x: 5, y: 5, tileId: wall.asset.id },
{ x: 5, y: 9, tileId: wall.asset.id },
]),
);
const r = run(s, physics);
const hits = r.castAll({ x: 0, y: -100 }, DOWN);
expect(hits.map((hit) => hit.distance)).toEqual([100, 292]);
expect(hits.map((hit) => hit.collider)).toEqual([r.entity(mapObject), r.entity(mapObject)]);
});
it('"max hits" keeps that many of the nearest, and 0 keeps them all', () => {
const s = scene();
s.add({ x: 100, y: 0 }, { width: 20, height: 100 });
s.add({ x: 200, y: 0 }, { width: 20, height: 100 });
s.add({ x: 300, y: 0 }, { width: 20, height: 100 });
const r = run(s, physics);
const far = (maxHits: number) =>
r.castAll({ x: 0, y: 0 }, RIGHT, 1000, { maxHits }).map((h) => h.distance);
expect(far(0)).toEqual([90, 190, 290]);
expect(far(2)).toEqual([90, 190]);
expect(far(9)).toEqual([90, 190, 290]);
});
it('the node hands the hits to the graph as lists, and the objects in them are objects', () => {
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
s.add({ x: 0, y: 300 }, { width: 200, height: 20 }); // top face at 290
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('RaycastAll', { fromX: 0, fromY: 0, dirX: 0, dirY: 1, length: 500 });
const logCount = edit.addNode('Log');
const logDistances = edit.addNode('Log');
const second = edit.addNode('ItemAt', { index: 1 });
const where = edit.addNode('GetPosition');
const logSecond = edit.addNode('Log');
edit.wire([
[update, 'exec', ray, 'exec'],
[ray, 'exec', logCount, 'exec'],
[ray, 'count', logCount, 'message'],
[logCount, 'exec', logDistances, 'exec'],
[ray, 'distances', logDistances, 'message'],
[ray, 'objects', second, 'list'],
[second, 'item', where, 'object'],
[logDistances, 'exec', logSecond, 'exec'],
[where, 'worldY', logSecond, 'message'],
]);
const r = run(s, physics);
r.frame(1 / 60);
// It starts inside its own hero, which "Ignore owner" leaves out anyway. The last line is the
// proof that the list holds live objects and not their names: a name has no position (#157).
expect(r.world.log).toEqual(['2', '140, 290', '300']);
// The ray is drawn to the last thing it met, which is how far it looked.
expect(r.world.rays).toEqual([{ from: { x: 0, y: 0 }, to: { x: 0, y: 290 }, hit: true, time: 1 / 60 }]);
});
});
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]);
});
});
describe('the two ray nodes (#157)', () => {
it('start from the same inputs and settings, a ray to the right 1000 units long', () => {
const shared = (def: NodeDef) => ({
inputs: def.inputs.filter((port) => port.key !== 'maxHits'),
params: def.params,
});
expect(shared(RaycastAll)).toEqual(shared(Raycast));
const defaults = Object.fromEntries(Raycast.inputs.map((port) => [port.key, port.default]));
expect(defaults).toMatchObject({ dirX: 1, length: 1000 });
expect(Raycast.params.map((param) => [param.key, param.default])).toEqual([
['ignoreOwner', true],
['triggers', false],
[RAY_LAYERS_PARAM, ALL_LAYERS],
]);
});
});