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>
This commit is contained in:
1 parent
ae3aa7d3bd
commit
0fdde6cb1e
9 files changed
+214
-12
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1148 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1153 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -158,13 +158,14 @@ export interface NodeContext {
|
||||
moveAndSlide(target: EntityHandle, dx: number, dy: number): MoveOutcome;
|
||||
/**
|
||||
* The first solid a ray from (x, y) along (dx, dy) meets within `length`, all in world units;
|
||||
* null for none. `ignore` and whatever is parented below it are not met.
|
||||
* null for none. `ignore` and whatever is parented below it are not met, and neither is anything
|
||||
* off the layers of `layers` (a 16-bit mask, #158).
|
||||
*/
|
||||
raycast(
|
||||
from: { x: number; y: number },
|
||||
direction: { x: number; y: number },
|
||||
length: number,
|
||||
options: { ignore: EntityHandle | null; triggers: boolean },
|
||||
options: { ignore: EntityHandle | null; triggers: boolean; layers: number },
|
||||
): RayOutcome | null;
|
||||
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
|
||||
ignoreOneWay(target: EntityHandle, seconds: number): void;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { ALL_LAYERS, layerMaskOf } from '../../../model/collisionLayers';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asNumber } from '../../values';
|
||||
import { bool, exec, num, obj, withDefault } from '../shared';
|
||||
|
||||
/** The mask of layers a ray looks at – shared with `Raycast all` (#157) once that exists. */
|
||||
export const RAY_LAYERS_PARAM = 'layers';
|
||||
|
||||
const MISSED = {
|
||||
hit: false,
|
||||
object: null,
|
||||
@@ -20,9 +24,10 @@ export const Raycast: NodeDef = {
|
||||
description:
|
||||
'Casts a ray from (from x, from y) along (dir x, dir y) for up to length world units and says what ' +
|
||||
'it meets first: the object, its collider (or the tilemap), where, the normal of the face (pointing ' +
|
||||
'out of it) and how far. Solid colliders and tilemap cells are met, triggers only with "Hit ' +
|
||||
'triggers", a one-way one only from the side it blocks from. What the ray starts inside is not met, ' +
|
||||
'and with "Ignore owner" neither is the owner or anything parented below it.',
|
||||
'out of it) and how far. Solid colliders and tilemap cells are met as long as they sit on one of ' +
|
||||
'"Layers", triggers only with "Hit triggers", a one-way one only from the side it blocks from. What ' +
|
||||
'the ray starts inside is not met, and with "Ignore owner" neither is the owner or anything parented ' +
|
||||
'below it.',
|
||||
inputs: [
|
||||
exec(),
|
||||
num('fromX', 'from x'),
|
||||
@@ -45,6 +50,8 @@ export const Raycast: NodeDef = {
|
||||
params: [
|
||||
{ key: 'ignoreOwner', label: 'Ignore owner', kind: 'boolean', default: true },
|
||||
{ key: 'triggers', label: 'Hit triggers', kind: 'boolean', default: false },
|
||||
// Every layer, so a graph written before there was a choice answers as it did (#158).
|
||||
{ key: RAY_LAYERS_PARAM, label: 'Layers', kind: 'layers', default: ALL_LAYERS },
|
||||
],
|
||||
run: (ctx, inputs, params) => {
|
||||
const hit = ctx.raycast(
|
||||
@@ -54,6 +61,7 @@ export const Raycast: NodeDef = {
|
||||
{
|
||||
ignore: asBoolean(params.ignoreOwner ?? true) ? (ctx.owner ?? ctx.self) : null,
|
||||
triggers: asBoolean(params.triggers ?? false),
|
||||
layers: layerMaskOf(params[RAY_LAYERS_PARAM] ?? ALL_LAYERS),
|
||||
},
|
||||
);
|
||||
if (!hit) return { next: 'exec', outputs: MISSED };
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type PortType,
|
||||
type Value,
|
||||
} from '../../core/graph';
|
||||
import { layerIndexOf, layerMaskOf, layerName, maskLabel } from '../../core/model/collisionLayers';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import { isList, type PropertyValue } from '../../core/model/Properties';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
@@ -477,6 +478,10 @@ function formatParam(param: PropertyDef, value: PropertyValue | undefined, proje
|
||||
function formatField(def: PropertyDef, value: Value, project: Project | null): string {
|
||||
if (def.kind === 'key') return keyLabel(asString(value));
|
||||
if (def.kind === 'color') return toHex(asNumber(value));
|
||||
// Layers read as names, not as the bits they are stored in (#152, #158).
|
||||
const layers = project?.collisionLayers ?? [];
|
||||
if (def.kind === 'layer') return layerName(layers, layerIndexOf(value));
|
||||
if (def.kind === 'layers') return maskLabel(layerMaskOf(value), layers);
|
||||
if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value);
|
||||
return referenceLabel(def, project, asString(value)) ?? formatValue(value);
|
||||
}
|
||||
|
||||
@@ -610,10 +610,11 @@ export class BehaviorSystem implements System {
|
||||
found.y += dy;
|
||||
return NOT_BLOCKED;
|
||||
},
|
||||
raycast: (from, direction, length, { ignore, triggers }) => {
|
||||
raycast: (from, direction, length, { ignore, triggers, layers }) => {
|
||||
const hit = this.collisions?.raycast(world, from, direction, length, {
|
||||
ignore: resolve(ignore) ?? null,
|
||||
triggers,
|
||||
layers,
|
||||
});
|
||||
world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit));
|
||||
return hit ? { ...hit, object: bodyOf(hit.collider) } : null;
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
solidRects,
|
||||
} from '../../core/model/tilemapCollision';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
import { type CollisionFilter, collisionFilterOf, layersMeet } from '../../core/model/collisionLayers';
|
||||
import {
|
||||
type CollisionFilter,
|
||||
collisionFilterOf,
|
||||
layersMeet,
|
||||
maskHas,
|
||||
} from '../../core/model/collisionLayers';
|
||||
import {
|
||||
type ColliderShape,
|
||||
colliderShape,
|
||||
@@ -116,10 +121,15 @@ export interface RayResult {
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/** Which colliders a ray looks at: all but those of `ignore` and below it, triggers only when asked. */
|
||||
/**
|
||||
* Which colliders a ray looks at: all but those of `ignore` and below it, triggers only when asked,
|
||||
* and only what sits on a layer of `layers` (#158). That last one is one-sided on purpose – a ray is
|
||||
* not a collider, so the collider's own "Collides with" has no say over who may look at it.
|
||||
*/
|
||||
export interface RayFilter {
|
||||
ignore: Entity | null;
|
||||
triggers: boolean;
|
||||
layers: number;
|
||||
}
|
||||
|
||||
/** Where a sweep stopped: what it met, with which own collider, the normal, and the move left over. */
|
||||
@@ -222,8 +232,8 @@ export class Solids {
|
||||
|
||||
/**
|
||||
* The first solid the ray from `from` along `direction` meets within `length` (world units): any
|
||||
* blocking collider or tilemap cell, triggers when asked, a one-way one only on the side it blocks
|
||||
* from. What the ray starts inside is not met (see `rayCast`).
|
||||
* blocking collider or tilemap cell on one of the filter's layers (#158), triggers when asked, a
|
||||
* one-way one only on the side it blocks from. What the ray starts inside is not met (see `rayCast`).
|
||||
*/
|
||||
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
|
||||
const d = unitOf(direction);
|
||||
@@ -233,6 +243,7 @@ export class Solids {
|
||||
if (!isCollisionBody(body) || !isHandleEnabled(body)) continue;
|
||||
if (body.type === 'collider' && isTrigger(body.props) && !filter.triggers) continue;
|
||||
if (filter.ignore && travelsWith(body, filter.ignore)) continue;
|
||||
if (!maskHas(filter.layers, collisionFilterOf(body.props).layer)) continue;
|
||||
for (const { shape, facing } of this.solidShapesOf(world, body)) {
|
||||
const hit = rayCast(shape, from, d, best?.distance ?? length);
|
||||
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands
|
||||
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';
|
||||
@@ -153,6 +154,7 @@ describe.each(COLLISION_SYSTEMS)('$system: Move and the one-way angle (#159)', (
|
||||
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();
|
||||
|
||||
@@ -4,12 +4,19 @@ 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';
|
||||
@@ -168,10 +175,18 @@ function scene() {
|
||||
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) =>
|
||||
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 };
|
||||
}
|
||||
@@ -245,6 +260,47 @@ describe.each(COLLISION_SYSTEMS)('$system: a ray through the world (#153)', ({ p
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -269,6 +325,36 @@ describe.each(COLLISION_SYSTEMS)('$system: a ray through the world (#153)', ({ p
|
||||
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)', () => {
|
||||
|
||||
@@ -57,6 +57,21 @@ const eyeGraph = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Casts downwards like `eyeGraph` and logs how far it reached, so the answer needs no drawing. */
|
||||
const layerEyeGraph = {
|
||||
nodes: [
|
||||
...eyeGraph.nodes,
|
||||
node('qe-log', 'Log', 750, 0),
|
||||
node('qe-timer', 'OnTimer', 0, 320, { interval: 0.2 }),
|
||||
],
|
||||
edges: [
|
||||
...eyeGraph.edges.filter((e) => e.from.node !== 'qe-update'),
|
||||
edge('qe-e5', 'qe-timer', 'exec', 'qe-ray', 'exec'),
|
||||
edge('qe-e6', 'qe-ray', 'exec', 'qe-log', 'exec'),
|
||||
edge('qe-e7', 'qe-ray', 'distance', 'qe-log', 'message'),
|
||||
],
|
||||
};
|
||||
|
||||
/** Logs how its owner touches the ground (normal y, then impulse) each half second it stays on it. */
|
||||
const STAY = 0.5;
|
||||
const feelGraph = {
|
||||
@@ -271,4 +286,77 @@ export async function queries(t) {
|
||||
JSON.stringify({ pairs, interval: STAY }),
|
||||
);
|
||||
await clear();
|
||||
|
||||
// 43d. Which layers a ray looks at (#158). A shield on a layer of its own stands between the
|
||||
// caster and the ground: with every layer picked the ray stops at the shield, and once that
|
||||
// layer is unticked in the node's "Layers" setting it goes straight through to the ground.
|
||||
const SHIELD_TOP = Y + 145;
|
||||
const reached = () => {
|
||||
const seen = consoleLog.filter((line) => line.startsWith('[behavior Eye logic]'));
|
||||
return seen.length === 0 ? null : Number(seen.at(-1).split(']').pop());
|
||||
};
|
||||
await setup(`
|
||||
if (__gm.state.project.collisionLayers.length < 2) __gm.state.project.collisionLayers = ['Default', 'Shots'];
|
||||
collider('q-col-ground', { width: 800, height: 40 });
|
||||
collider('q-col-shield', { width: 300, height: 10, layer: 1 });
|
||||
collider('q-col-box', {});
|
||||
behavior('q-eye', 'Query eye', ${JSON.stringify(layerEyeGraph)});
|
||||
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
|
||||
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
|
||||
put('q-shield', 'crate', '', 0, ${Y + 150}, 'Shield', { width: 300, height: 10 });
|
||||
put('q-shield-col', 'q-col-shield', 'q-shield', 0, 0);
|
||||
put('q-eyebox', 'crate', '', 0, ${Y}, 'Eye', { width: 40, height: 40 });
|
||||
put('q-eyebox-col', 'q-col-box', 'q-eyebox', 0, 0);
|
||||
put('q-eyebox-go', 'q-eye', 'q-eyebox', 0, 0, 'Eye logic', { runInEditor: true });`);
|
||||
await evaluate(`__gm.lookAt(0, ${Y + 150})`);
|
||||
await sleep(500);
|
||||
const blocked = reached();
|
||||
check(
|
||||
'a ray looking at every layer stops at the shield, whatever layer it is on',
|
||||
blocked === SHIELD_TOP - Y,
|
||||
JSON.stringify({ reached: blocked, toShield: SHIELD_TOP - Y, toGround: GROUND_TOP - Y }),
|
||||
);
|
||||
await select({ kind: 'node', assetId: 'q-eye', nodeId: 'qe-ray', nodeIds: ['qe-ray'] });
|
||||
const layersRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Layers')");
|
||||
check(
|
||||
'the Raycast node carries a Layers setting, reading as every layer',
|
||||
Boolean(layersRow) && (await t.has('All layers')),
|
||||
JSON.stringify({ layersRow, allLayers: await t.has('All layers') }),
|
||||
);
|
||||
await click(layersRow.x, layersRow.y);
|
||||
const layerTicks = await json('__gm.menuRows()');
|
||||
const names = await json('__gm.state.project.collisionLayers');
|
||||
check(
|
||||
'the setting lists the layers of the project, all of them ticked',
|
||||
layerTicks.length === names.length && layerTicks.every((row) => row.startsWith('☑')),
|
||||
JSON.stringify({ layerTicks, names }),
|
||||
);
|
||||
await t.clickText(layerTicks[1]);
|
||||
await shot('43-ray-layers');
|
||||
await t.press('Escape');
|
||||
await settle();
|
||||
const settings = await json("__gm.graph.nodeSettings('qe-ray')");
|
||||
check(
|
||||
'the node itself spells the mask out by name rather than as the bits it is stored in',
|
||||
settings.includes(`Layers: ${names[0]}`),
|
||||
JSON.stringify(settings),
|
||||
);
|
||||
// A running instance keeps the graph it started with – measured here: the setting stands in the
|
||||
// project at once, what the node casts does not. So the behavior is switched off and on, which
|
||||
// ends the instance and builds a new one on the graph as it now stands.
|
||||
await select({ kind: 'object', holderId: 'scene1', objectId: 'q-eyebox-go', objectIds: ['q-eyebox-go'] });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const runRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Run in editor')");
|
||||
await click(runRow.x, runRow.y);
|
||||
await settle();
|
||||
}
|
||||
await sleep(600);
|
||||
const through = reached();
|
||||
check(
|
||||
"unticking the shield's layer lets the ray pass it and meet the ground behind it",
|
||||
through === GROUND_TOP - Y,
|
||||
JSON.stringify({ reached: through, toGround: GROUND_TOP - Y, toShield: SHIELD_TOP - Y }),
|
||||
);
|
||||
await select(null);
|
||||
await clear();
|
||||
}
|
||||
Reference in new issue
Block a user