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, 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, 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 = { 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('GetWorldPosition'); 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, 'y', 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], ]); }); });