Files
feuerwerk/tools/e2e/41-physics.mjs
T
adminandClaude Opus 5.5 7dd3ae1131
game-maker / gate (push) Successful in 3m19s
game-maker / suite (push) Failing after 11m41s
Tie two objects with a hinge, a spring, a rope or for good (#154)
Two bodies could not be connected: no door on a hinge, no lamp on
a rope, no wheel on an axle. This is the first step of the issue,
the four joints without a motor. The slider and the motors follow
in a second step under the same issue.

- Joint is a new asset type, placed as a child of the object it
  holds, like a collider or a rigidbody. "Add joint…" in the
  object menu makes one or places one the project has. An object
  takes as many as it needs.
- Where the joint sits is the point it holds its object at. A
  hinge and a fixed joint have that one point only. A spring and a
  rope end at a connected anchor on the other object, counted in
  that object's own units, or in the scene's without one.
- The rows follow the type: length, stiffness and damping for a
  spring, length for a rope, an angle limit for a hinge. Length 0
  takes the distance the two ends were placed at. The limit counts
  from how the two were placed.
- The connected object is an object reference in the schema of the
  type. Pasting, containers and removing treat it as they treat
  every reference since #258.
- shownFor is a new schema option: a row is shown while an enum of
  the same bag holds one of the named values.
- The object needs a rigidbody, its own or one further up. Without
  one the joint holds nothing and the console says so once.
- A connected object without a rigidbody is a moving anchor: the
  joint has a kinematic body of its own that follows it.
- A connected object destroyed while playing makes the joint let go
  with a hint. Removed in the editor the reference empties, so the
  joint holds on to the scene. The issue asked for "inactive" there.
- "Add joint" ties two objects while the game runs, from a joint
  asset as the template. Destroy on the joint lets go, there is no
  Break joint node. Destroy with keep children takes the joints
  along with the object.
- The scene view draws a line from each joint to what it holds on
  to and rings the held point. A selected spring or rope has a
  round handle on its far end, and dragging it is one undo step.
- Joints hold in Play only. The editor run simulates nothing
  (#148), and the inspector of a placed joint says so.
- Rapier counts a revolute joint's limits absolutely, not from the
  start, so the placed angle is added. Measured with a probe.
- The + menu has 25 types now and no longer fits under its button
  on a canvas 800 high. It scrolls there, and e2e 04 scrolls to
  the last entry instead of expecting it in view.

Tests:
- Gate on melektaus-atx for this state: 218 files, 3390 tests
  (3336 + 54), exit 0.
- e2e 1-16,24,39-46,51,52,57,58 on melektaus-atx: 831 checks, all
  pass. Module 41 alone: 25 checks (21 + 4).
- 54 new unit tests: 40 in joints.test.ts (rope, spring, pendulum,
  limit, fixed, the other end, the node, containers, paste), 6 for
  the line the scene view draws, 4 catalog cases of the node. The
  remaining 4 come from tests that make a case per asset type.
- Four new e2e checks in module 41: Add joint from the menu, the
  rope to the hook with its line and handle, dragging the handle
  with one undo, the bob hanging in Play next to one that falls.
- e2e 04, 40 and 46 count the new type in their lists.
- 21 of 22 unit counter-probes fail their tests. The other one is
  equivalent: Rapier ignores removing a joint that left with its
  body, the guard stays.
- Three browser counter-probes fail their checks (2, 1 and 2).
- One screenshot looked at: icon, line, handle and the rope rows.
- The e2e sum of 1691 is counted (1687 + 4, 1687 measured by CI
  on e066613). The full suite and the smokes did not run.
- Not seen in the browser: spring, hinge with limit and fixed
  joint, the node Add joint, a connected object that moves, joints
  inside a container, the released game. Unit tests cover them
  but the last. Limits under a mirrored parent are not handled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:43:12 +02:00

672 lines
30 KiB
JavaScript
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 { edge, node } from '../lib/builders.mjs';
import { inView, LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/** Kicks its owner in On start, with the scene's gravity switched off first: it glides at 100 per second. */
const KICK = 200;
const KICKED_MASS = 2;
const kickGraph = {
nodes: [
node('pk-start', 'OnStart', 0, 0),
node('pk-gravity', 'SetGravity', 300, 0, { x: 0, y: 0 }),
node('pk-owner', 'Owner', 300, 160),
node('pk-kick', 'ApplyImpulse', 600, 0, { x: KICK, y: 0 }),
],
edges: [
edge('pk-e1', 'pk-start', 'exec', 'pk-gravity', 'exec'),
edge('pk-e2', 'pk-gravity', 'exec', 'pk-kick', 'exec'),
edge('pk-e3', 'pk-owner', 'object', 'pk-kick', 'object'),
],
};
/** Walks its owner right at 120 per second with "Collide with solids" on. */
const WALK = 120;
const walkGraph = {
nodes: [
node('pw-update', 'OnUpdate', 0, 0),
node('pw-owner', 'Owner', 0, 160),
node('pw-move', 'Move', 300, 0, { dx: 1, scale: WALK, collide: true }),
],
edges: [
edge('pw-e1', 'pw-update', 'exec', 'pw-move', 'exec'),
edge('pw-e2', 'pw-update', 'dt', 'pw-move', 'dt'),
edge('pw-e3', 'pw-owner', 'object', 'pw-move', 'object'),
],
};
/**
* Rolls its owner right at 120 per second with "Collide with solids" on, and logs Move's normal
* (x, then y) on every frame that hits something.
*/
const BALL_R = 20;
const CORNER_DROP = 12;
const rollGraph = {
nodes: [
node('pr-update', 'OnUpdate', 0, 0),
node('pr-owner', 'Owner', 0, 160),
node('pr-move', 'Move', 300, 0, { dx: 1, scale: WALK, collide: true }),
node('pr-hit', 'Branch', 600, 0),
node('pr-log-x', 'Log', 850, 0),
node('pr-log-y', 'Log', 1100, 0),
],
edges: [
edge('pr-e1', 'pr-update', 'exec', 'pr-move', 'exec'),
edge('pr-e2', 'pr-update', 'dt', 'pr-move', 'dt'),
edge('pr-e3', 'pr-owner', 'object', 'pr-move', 'object'),
edge('pr-e4', 'pr-move', 'exec', 'pr-hit', 'exec'),
edge('pr-e5', 'pr-move', 'hit', 'pr-hit', 'condition'),
edge('pr-e6', 'pr-hit', 'then', 'pr-log-x', 'exec'),
edge('pr-e7', 'pr-move', 'normalX', 'pr-log-x', 'message'),
edge('pr-e8', 'pr-log-x', 'exec', 'pr-log-y', 'exec'),
edge('pr-e9', 'pr-move', 'normalY', 'pr-log-y', 'message'),
],
};
/**
* The physics engine (Rapier) loads in the editor, in the background after the first project is
* open, from a chunk of its own – the editor bundle does not carry it. A played scene simulates its
* rigidbodies on it (#146).
*/
export async function physics(t) {
const { check, json, waitUntil, consoleLog, evaluate, mutate, select, click, clickText, press, typeText } =
t;
const { has, object, asset, objects, state, settle, sleep, play, stop, shot, mouse, showTab } = t;
const { drag, undo } = t;
await waitUntil(`['ready', 'failed'].includes(__gm.physics().state)`, { timeout: 15000 });
const status = await json('__gm.physics()');
check(
'Rapier loads in the editor without Play',
status.state === 'ready' && status.version === '0.20.0',
JSON.stringify(status),
);
const scripts = await json(
`performance.getEntriesByType('resource').map((e) => e.name).filter((n) => n.endsWith('.js')).map((n) => n.split('/').pop())`,
);
check(
'it comes from a chunk of its own',
scripts.some((name) => /^rapier-.+\.js$/.test(name)),
JSON.stringify(scripts),
);
check(
'loading it writes nothing to the console',
!consoleLog.some((line) => /\[physics\]|rapier/i.test(line)),
consoleLog.filter((line) => /\[physics\]|rapier/i.test(line)).join(' | '),
);
// 41b. A crate and a ball above a ground on a lane of their own, each with a collider and no
// rigidbody yet. The rigidbodies come from the hierarchy menu, the ball's bounciness is typed
// into its collider's field, and the played scene is sampled frame by frame.
const Y = LANES.physics;
const REST = Y + 200 - 20 - 20; // the ground's top face minus half of a 40-high body
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
const collider = (id, props) =>
assets.push({ id, type: 'collider', name: id, props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false, ...props } });
collider('phys-col-ground', { width: 600, height: 40 });
collider('phys-col-crate', {});
collider('phys-col-ball', { shape: 'circle' });
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
const o = make(id, assetId, parentId, y, name);
o.position.x = x;
o.overrides = overrides;
scene.objects.push(o);
};
put('phys-ground', 'crate', '', 0, ${Y + 200}, 'Physics ground', { width: 600, height: 40 });
put('phys-crate', 'crate', '', -100, ${Y}, 'Physics crate', { width: 40, height: 40 });
put('phys-ball', 'crate', '', 100, ${Y}, 'Physics ball', { width: 40, height: 40 });
put('phys-ground-col', 'phys-col-ground', 'phys-ground', 0, 0);
put('phys-crate-col', 'phys-col-crate', 'phys-crate', 0, 0);
put('phys-ball-col', 'phys-col-ball', 'phys-ball', 0, 0);
__gm.state.select(null)`);
const selectObject = (id) => select({ kind: 'object', holderId: 'scene1', objectId: id, objectIds: [id] });
const rigidbodyFromMenu = async (id) => {
await selectObject(id);
const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`);
const before = (await objects()).length;
await click(row.x, row.y, 'right');
await clickText('Add rigidbody…');
// An enabled entry opens the second menu (#222); a disabled one leaves the first standing.
if (await has('Create new rigidbody')) await clickText('Create new rigidbody');
await press('Escape');
const s = await state();
const selected = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
return { added: (await objects()).length - before, selected };
};
const crate = await rigidbodyFromMenu('phys-crate');
const crateBody = crate.selected;
check(
'Add rigidbody puts a dynamic rigidbody below the object and shows its settings',
crate.added === 1 &&
crateBody?.parentId === 'phys-crate' &&
(await asset(crateBody.assetId))?.type === 'rigidbody' &&
(await has('Body type')) &&
(await has('Dynamic')),
JSON.stringify(crate),
);
check(
'the rigidbody is drawn as an icon in the overlay',
crateBody && (await evaluate(`__gm.layerOf('${crateBody.id}')`)) === 'overlay',
);
const again = await rigidbodyFromMenu('phys-crate');
check('the menu offers no second rigidbody', again.added === 0, JSON.stringify(again));
const ballBody = (await rigidbodyFromMenu('phys-ball')).selected;
await evaluate(`__gm.lookAt(0, ${Y + 100})`);
await settle();
await shot('41-rigidbody');
// Every symbol has an icon and wants its object's position. One with room stands there; icons
// that want the same spot share it on a ring round it, whoever owns them – two stand left and
// right of it, in hierarchy order: the seeded collider first, the rigidbody added above second.
const icons = {
ground: await json(`__gm.sceneIcon('phys-ground-col')`),
crate: await json(`__gm.sceneIcon('phys-crate-col')`),
crateBody: await json(`__gm.sceneIcon('${crateBody?.id}')`),
ball: await json(`__gm.sceneIcon('phys-ball-col')`),
ballBody: await json(`__gm.sceneIcon('${ballBody?.id}')`),
};
const near = (icon, x, y) =>
icon?.visible === true && Math.abs(icon.x - x) < 0.01 && Math.abs(icon.y - y) < 0.01;
check(
'a symbol with room around it stands on its object, in the overlay',
near(icons.ground, 0, Y + 200) && icons.ground.layer === 'overlay',
JSON.stringify(icons.ground),
);
check(
'icons on one spot stand left and right of it, a symbol apart',
near(icons.crate, -110.5, Y) &&
near(icons.crateBody, -89.5, Y) &&
near(icons.ball, 89.5, Y) &&
near(icons.ballBody, 110.5, Y),
JSON.stringify(icons),
);
// An icon is clicked like its object: it selects it, even over a sprite. Beside the icon the
// sprite under it is picked as before.
const selectedId = async () => {
const s = await state();
return s.selection?.kind === 'object' ? s.selection.objectId : null;
};
const zoom = (icons.crateBody.screen.x - icons.crate.screen.x) / 21;
const picks = {};
for (const [name, id, dx] of [
['groundIcon', 'phys-ground-col', 0],
['crateIcon', 'phys-crate-col', 0],
['crateBodyIcon', crateBody?.id, 0],
['groundBeside', 'phys-ground-col', 40],
]) {
await select(null);
const at = await json(`__gm.sceneIcon('${id}')?.screen`);
await click(at.x + dx * zoom, at.y);
picks[name] = { selected: await selectedId() };
}
check(
'a click on an icon selects its object, beside it the sprite below',
icons.ground.clickable === true &&
picks.groundIcon.selected === 'phys-ground-col' &&
picks.crateIcon.selected === 'phys-crate-col' &&
picks.crateBodyIcon.selected === crateBody?.id &&
picks.groundBeside.selected === 'phys-ground',
JSON.stringify({ zoom, picks }),
);
await selectObject('phys-ball-col');
const field = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Bounciness')");
if (field) {
await click(field.x, field.y);
await press('a', { ctrl: true });
await typeText('0.8');
await press('Enter');
}
await settle();
check(
"the collider's Bounciness field takes 0.8",
(await object('phys-ball-col'))?.overrides?.bounciness === 0.8,
JSON.stringify({ field, overrides: (await object('phys-ball-col'))?.overrides }),
);
await select(null);
await play();
await evaluate(`(() => {
const out = (window.__physicsSamples = []);
const start = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const y = (id) => w.entities.find((e) => e.objectId === id)?.worldY;
out.push({ t: performance.now() - start, crate: y('phys-crate'), ball: y('phys-ball') });
if (performance.now() - start < 3000) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(3300);
const samples = await json('window.__physicsSamples');
await stop();
const last = samples.slice(-20).map((s) => s.crate);
check(
'the crate falls onto the ground and stays there',
samples.length > 100 && samples[0].crate < REST - 50 && last.every((y) => Math.abs(y - REST) < 1),
JSON.stringify({ frames: samples.length, first: samples[0], last: last.slice(-3), rest: REST }),
);
// The heights the ball climbs back to: every turn from rising to falling after the first landing.
// Turning points, not a threshold at the ground: the ball lands at ~9 px per frame, and whether a
// drawn frame comes within a pixel of the landing depends on where it falls between two steps.
const peaks = [];
let landed = false;
for (let i = 1; i < samples.length - 1; i++) {
const [before, y, after] = [samples[i - 1].ball, samples[i].ball, samples[i + 1].ball];
if (y >= before && y > after) landed = true;
else if (landed && y <= before && y < after && REST - y > 2) peaks.push(REST - y);
}
const drop = REST - Y;
check(
'the ball bounces back up and every bounce is lower than the one before',
peaks.length >= 2 &&
peaks[0] / drop > 0.4 &&
peaks[0] / drop < 0.8 &&
peaks.every((h, i) => i === 0 || h < peaks[i - 1]),
JSON.stringify({ drop, peaks: peaks.map((h) => Math.round(h * 10) / 10), frames: samples.length }),
);
check(
'playing left the scene as it was',
(await object('phys-crate'))?.position.y === Y && (await object('phys-ball'))?.position.y === Y,
);
// A body dragged in the scene view while the game runs sticks to the pointer as if it had no
// rigidbody: lifted off the ground it hangs where it is held, and is let go at rest.
const LIFT = 150;
await play();
await sleep(1500);
await showTab('scene');
await selectObject('phys-crate');
await settle();
const grab = await json(`__gm.objectRect('phys-crate')`);
const scale = grab.w / 40;
// Below the middle, where the collider's and the rigidbody's icons are not.
const from = { x: grab.x, y: grab.y + grab.h * 0.35 };
const to = { x: from.x, y: from.y - LIFT * scale };
await mouse('mouseMoved', from.x, from.y);
await mouse('mousePressed', from.x, from.y, 'left');
for (let i = 1; i <= 10; i++) {
await mouse('mouseMoved', from.x, from.y + ((to.y - from.y) * i) / 10, 'left', { buttons: 1 });
await sleep(16);
}
const target = REST - LIFT;
const crateY = "__gm.world()?.entities.find((e) => e.objectId === 'phys-crate')?.worldY";
// The last move reaches the body a few frames later: preview, physics step, drawn one step
// behind. Reading right away raced it and caught the ninth move's height (#282).
const arrived = await waitUntil(`Math.abs(${crateY} - ${target}) < 1.5`, {
timeout: 2000,
label: 'the crate reached the pointer',
}).then(
() => true,
() => false,
);
// Every frame recorded in the page, so the count follows the frames and not the round trips.
// The page also stamps the release itself: a stamp taken after the round trip came frames late,
// and the first sample after it was already 7 units into the fall on the WSL runner.
await evaluate(`(() => {
const out = (window.__dragSamples = []);
const sample = () => {
out.push({ t: performance.now(), y: ${crateY} });
if (!window.__dragSamplesDone) requestAnimationFrame(sample);
};
window.__dragSamplesDone = false;
window.__dragLetGo = undefined;
window.addEventListener('pointerup', () => (window.__dragLetGo = performance.now()), {
capture: true,
once: true,
});
requestAnimationFrame(sample);
})()`);
await sleep(1000);
await shot('41-drag-while-playing');
const dragInfo = { grab, selected: (await state()).selection?.objectId };
await mouse('mouseReleased', to.x, to.y, 'left');
await sleep(300);
await evaluate('window.__dragSamplesDone = true');
const dragSamples = await json('window.__dragSamples');
const letGo = await evaluate('window.__dragLetGo');
const log = (await json('__gm.world()'))?.log ?? [];
await stop();
const heldSamples = dragSamples.filter((s) => s.t < letGo);
const held = heldSamples.map((s) => s.y);
// How long the hold was watched, not how many frames that took: a busy runner drew 21 frames
// in the second a quiet one draws 50 in, and a count of 30 called that a failure.
const heldFor = held.length > 1 ? heldSamples.at(-1).t - heldSamples[0].t : 0;
const released = dragSamples.filter((s) => s.t >= letGo);
// 200 ms of free fall from rest is about 25 below the hold, a body let go at speed is not.
const falling = released.filter((s) => s.t - letGo <= 200).map((s) => s.y);
const fell = falling.at(-1);
check(
'a body dragged while playing hangs where the pointer holds it and is let go at rest',
arrived &&
held.length >= 5 &&
heldFor >= 800 &&
held.every((y) => Math.abs(y - target) < 1.5) &&
// Let go it only falls: no jump above the hold, no kick up. How far the first frame after
// the release has already fallen follows the frame time, so that is not a bound here.
falling.every((y, i) => y > target - 1.5 && (i === 0 || y >= falling[i - 1] - 0.01)) &&
fell > target + 1 &&
fell < target + 40 &&
!log.some((line) => line.includes('moved directly')),
JSON.stringify({
target,
arrived,
held: { frames: held.length, ms: Math.round(heldFor), first: held[0], last: held.at(-1) },
stamped: typeof letGo === 'number',
released: released.slice(0, 3).map((s) => [Math.round(s.t - letGo), s.y]),
fell,
log,
dragInfo,
}),
);
const bodies = [crateBody?.id, ballBody?.id].filter(Boolean);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const gone = new Set(${JSON.stringify(bodies)});
const assets = new Set(scene.objects.filter((o) => gone.has(o.id)).map((o) => o.assetId));
scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-') && !gone.has(o.id));
p.assets = p.assets.filter((a) => !a.id.startsWith('phys-col-') && !assets.has(a.id));
__gm.state.select(null)`);
// 41c. The graph drives the physics (#147): a crate kicked in On start with gravity switched off
// by Set gravity, and a walker whose Move with "Collide with solids" runs into a free crate.
const hintsBefore = consoleLog.filter((line) => line.includes('[physics]')).length;
await mutate(`
const scene = __gm.state.activeScene;
const p = __gm.state.project;
const assets = p.assets;
${MAKE_OBJECT}
const behavior = (id, name, graph) => {
const asset = structuredClone(assets.find((a) => a.id === 'mover'));
Object.assign(asset, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph });
assets.push(asset);
};
behavior('phys-kick', 'Physics kick', ${JSON.stringify(kickGraph)});
behavior('phys-walk', 'Physics walk', ${JSON.stringify(walkGraph)});
assets.push({ id: 'phys-col-box', type: 'collider', name: 'phys-col-box', props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false } });
assets.push({ id: 'phys-rb-kicked', type: 'rigidbody', name: 'phys-rb-kicked', props: { bodyType: 'dynamic', mass: ${KICKED_MASS} } });
assets.push({ id: 'phys-rb-free', type: 'rigidbody', name: 'phys-rb-free', props: { bodyType: 'dynamic', mass: 0 } });
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
const o = make(id, assetId, parentId, y, name);
o.position.x = x;
o.overrides = overrides;
scene.objects.push(o);
};
const box = { width: 40, height: 40 };
put('phys-kicked', 'crate', '', -200, ${Y}, 'Kicked crate', box);
put('phys-kicked-col', 'phys-col-box', 'phys-kicked', 0, 0);
put('phys-kicked-rb', 'phys-rb-kicked', 'phys-kicked', 0, 0);
put('phys-kicked-kick', 'phys-kick', 'phys-kicked', 0, 0);
put('phys-walker', 'crate', '', 0, ${Y + 100}, 'Walker', box);
put('phys-walker-col', 'phys-col-box', 'phys-walker', 0, 0);
put('phys-walker-walk', 'phys-walk', 'phys-walker', 0, 0);
put('phys-pushed', 'crate', '', 60, ${Y + 100}, 'Pushed crate', box);
put('phys-pushed-col', 'phys-col-box', 'phys-pushed', 0, 0);
put('phys-pushed-rb', 'phys-rb-free', 'phys-pushed', 0, 0);
__gm.state.select(null)`);
await play();
// A model event while playing re-reads the scene (CI had one in the first frames): what the run
// set with Set gravity has to outlive it.
await evaluate(`__gm.state.preview()`);
await evaluate(`(() => {
const out = (window.__physicsGraph = []);
const start = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const at = (id) => w.entities.find((e) => e.objectId === id);
const kicked = at('phys-kicked');
out.push({
t: (performance.now() - start) / 1000,
kickedX: kicked?.worldX,
kickedY: kicked?.worldY,
walker: at('phys-walker')?.worldX,
pushed: at('phys-pushed')?.worldX,
});
if (performance.now() - start < 2000) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(2300);
const run = await json('window.__physicsGraph');
await stop();
const at = (seconds) =>
run.reduce((best, s) => (Math.abs(s.t - seconds) < Math.abs(best.t - seconds) ? s : best));
const [early, late] = [at(0.5), at(1.5)];
const glide = (late.kickedX - early.kickedX) / (late.t - early.t);
const speed = KICK / KICKED_MASS;
check(
`Set gravity and Apply impulse in On start: the crate glides straight at impulse ÷ mass (${speed} per second)`,
run.length > 60 &&
Math.abs(glide - speed) < speed * 0.05 &&
run.every((s) => Math.abs(s.kickedY - Y) < 0.5),
JSON.stringify({ frames: run.length, glide: Math.round(glide * 10) / 10, early, late }),
);
const gap = Math.min(...run.map((s) => s.pushed - 20 - (s.walker + 20)));
const pushed = run.at(-1).pushed - 60;
check(
'Move with "Collide with solids" pushes the crate in its way and never gets into it',
pushed > 100 && gap > -0.01,
JSON.stringify({
pushed: Math.round(pushed * 10) / 10,
walked: Math.round(run.at(-1).walker * 10) / 10,
gap,
}),
);
const hints = consoleLog.filter((line) => line.includes('[physics]')).slice(hintsBefore);
check('nothing in the step made the physics hint at a no-op', hints.length === 0, hints.join(' | '));
const clearPhysics = () =>
mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-'));
p.assets = p.assets.filter((a) => !a.id.startsWith('phys-'));
__gm.state.select(null)`);
await clearPhysics();
// 41d. Move along the contact normal (#150): a ball walked straight right into the upper-left
// corner of a block, 12 below its centre, rolls up over it instead of hanging on the corner,
// and its graph logs the normal of the first contact from Move's hit / normal outputs.
const edgeLog = consoleLog.length;
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
const asset = structuredClone(assets.find((a) => a.id === 'mover'));
Object.assign(asset, { id: 'phys-roll', name: 'Physics roll', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(rollGraph)} });
assets.push(asset);
assets.push({ id: 'phys-col-ball', type: 'collider', name: 'phys-col-ball', props: { shape: 'circle', width: 40, height: 40, radius: ${BALL_R}, isTrigger: false } });
assets.push({ id: 'phys-col-block', type: 'collider', name: 'phys-col-block', props: { shape: 'box', width: 100, height: 60, radius: 20, isTrigger: false } });
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
const o = make(id, assetId, parentId, y, name);
o.position.x = x;
o.overrides = overrides;
scene.objects.push(o);
};
put('phys-ball', 'crate', '', 0, ${Y}, 'Rolling ball', { width: 40, height: 40 });
put('phys-ball-col', 'phys-col-ball', 'phys-ball', 0, 0);
put('phys-ball-roll', 'phys-roll', 'phys-ball', 0, 0, 'Roll');
put('phys-block', 'crate', '', 150, ${Y + CORNER_DROP + 30}, 'Edge block', { width: 100, height: 60 });
put('phys-block-col', 'phys-col-block', 'phys-block', 0, 0);
__gm.state.select(null)`);
await play();
await sleep(2000);
const ball = await json(
`(() => { const e = __gm.world()?.entities.find((x) => x.objectId === 'phys-ball'); return e && { x: e.worldX, y: e.worldY }; })()`,
);
await stop();
const top = Y + CORNER_DROP - BALL_R; // the ball's centre when it rolls along the block's top face
check(
'Move rolls a ball up over the edge it runs into, onto the top face',
ball && ball.x > 150 && ball.y <= top + 0.01 && ball.y > top - 3,
JSON.stringify({ ball, top }),
);
const lines = consoleLog.slice(edgeLog).filter((line) => line.startsWith('[behavior '));
const logged = lines
.filter((line) => line.startsWith('[behavior Roll]'))
.map((line) => Number(line.split(']').pop()));
const reach = Math.sqrt(BALL_R ** 2 - CORNER_DROP ** 2);
check(
"Move's hit and normal outputs: the first contact faces from the corner to the ball's centre",
logged.length >= 2 &&
Math.abs(logged[0] + reach / BALL_R) < 1e-6 &&
Math.abs(logged[1] + CORNER_DROP / BALL_R) < 1e-6,
JSON.stringify({
logged: logged.slice(0, 4),
expected: [-reach / BALL_R, -CORNER_DROP / BALL_R],
lines: lines.slice(0, 4),
}),
);
await clearPhysics();
// 41e. Joints (#154): a bob below a hook gets a joint from the hierarchy menu, is made a rope to
// the hook in the inspector, its far end is dragged by its handle in the scene view, and in
// the played scene the bob hangs where it would otherwise fall.
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
assets.push({ id: 'phys-col-box', type: 'collider', name: 'phys-col-box', props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false } });
assets.push({ id: 'phys-rb-free', type: 'rigidbody', name: 'phys-rb-free', props: { bodyType: 'dynamic', mass: 0 } });
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
const o = make(id, assetId, parentId, y, name);
o.position.x = x;
o.overrides = overrides;
scene.objects.push(o);
};
put('phys-hook', 'crate', '', 0, ${Y}, 'Physics hook', { width: 20, height: 20 });
put('phys-bob', 'crate', '', 0, ${Y + 100}, 'Physics bob', { width: 40, height: 40 });
put('phys-bob-col', 'phys-col-box', 'phys-bob', 0, 0);
put('phys-bob-rb', 'phys-rb-free', 'phys-bob', 0, 0);
put('phys-loose', 'crate', '', 200, ${Y + 100}, 'Physics loose', { width: 40, height: 40 });
put('phys-loose-col', 'phys-col-box', 'phys-loose', 0, 0);
put('phys-loose-rb', 'phys-rb-free', 'phys-loose', 0, 0);
__gm.state.select(null)`);
await showTab('scene');
await evaluate(`__gm.lookAt(0, ${Y + 50})`);
await selectObject('phys-bob');
const bobRow = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow('phys-bob')`);
const countBefore = (await objects()).length;
await click(bobRow.x, bobRow.y, 'right');
await clickText('Add joint…');
if (await has('Create new joint')) await clickText('Create new joint');
await press('Escape');
await settle();
const jointSelection = (await state()).selection;
const joint = jointSelection?.kind === 'object' ? await object(jointSelection.objectId) : null;
const jointAsset = joint && (await asset(joint.assetId));
check(
'Add joint puts a hinge below the object and shows its rows',
(await objects()).length === countBefore + 1 &&
joint?.parentId === 'phys-bob' &&
jointAsset?.type === 'joint' &&
jointAsset?.props?.jointType === 'hinge' &&
(await has('Connected object')) &&
(await has('Limit angle')) &&
!(await has('Stiffness')) &&
(await evaluate(`__gm.layerOf('${joint?.id}')`)) === 'overlay',
JSON.stringify({ joint, jointAsset }),
);
const pressAt = async (at, what) => {
if (!at) throw new Error(`41e: ${what} is not on screen`);
await click(at.x, at.y);
await settle();
};
await pressAt(await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Type')"), 'the Type field');
await pressAt(await json("__gm.locate('Rope', true)"), 'Rope in the menu');
await pressAt(
await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Connected object')"),
'the Connected object field',
);
// The menu lists every object of the scene: found by typing, as in a scene that has many.
await typeText('Physics hook');
await settle();
await pressAt(await json("__gm.locate('Physics hook', true)"), 'Physics hook in the menu');
const jointObject = async () => object(joint.id);
const links = async () => json('__gm.joints()');
const roped = await links();
const ropeLink = roped.links.find((l) => l.id === joint?.id);
check(
'a rope connected to the hook is drawn from the bob to it, with a handle on its far end',
(await jointObject())?.overrides?.jointType === 'rope' &&
(await jointObject())?.overrides?.connected?.$object === 'phys-hook' &&
ropeLink?.connectedId === 'phys-hook' &&
ropeLink.anchored === true &&
Math.abs(ropeLink.from.x) < 0.01 &&
Math.abs(ropeLink.from.y - (Y + 100)) < 0.01 &&
Math.abs(ropeLink.to.x) < 0.01 &&
Math.abs(ropeLink.to.y - Y) < 0.01 &&
roped.handle?.objectId === joint?.id &&
(await has('Connected anchor X')) &&
(await has('Length (0 = as placed)')) &&
!(await has('Limit angle')),
JSON.stringify({ roped, overrides: (await jointObject())?.overrides }),
);
await shot('41-joint');
// The scale of the view, read off the bob's 40 units.
const bobRect = await json(`__gm.objectRect('phys-bob')`);
const unit = bobRect.w / 40;
await drag(roped.handle.x, roped.handle.y, roped.handle.x + 30 * unit, roped.handle.y);
await settle();
const moved = (await jointObject())?.overrides;
const movedLink = (await links()).links.find((l) => l.id === joint?.id);
await undo();
await settle();
const undone = (await jointObject())?.overrides;
check(
"dragging the handle moves the rope's connected anchor, and one undo puts it back",
Math.abs(moved?.connectedAnchorX - 30) <= 1 &&
Math.abs(moved?.connectedAnchorY ?? 0) <= 1 &&
Math.abs(movedLink?.to.x - moved?.connectedAnchorX) < 0.01 &&
undone?.connectedAnchorX === undefined &&
undone?.jointType === 'rope',
JSON.stringify({ moved, movedLink, undone, unit }),
);
const jointHints = consoleLog.filter((line) => line.includes('[physics]')).length;
await select(null);
await play();
await evaluate(`(() => {
const out = (window.__jointSamples = []);
const start = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const y = (id) => w.entities.find((e) => e.objectId === id)?.worldY;
out.push({ t: performance.now() - start, bob: y('phys-bob'), loose: y('phys-loose') });
if (performance.now() - start < 1500) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(1800);
const hung = await json('window.__jointSamples');
await stop();
const bobYs = hung.map((s) => s.bob);
check(
'in the played scene the bob hangs on its rope while the one without a joint falls',
hung.length > 30 &&
bobYs.every((y) => Math.abs(y - (Y + 100)) < 2) &&
hung.at(-1).loose > Y + 400 &&
consoleLog.filter((line) => line.includes('[physics]')).length === jointHints,
JSON.stringify({
frames: hung.length,
bob: [Math.min(...bobYs), Math.max(...bobYs)],
loose: hung.at(-1).loose,
hints: consoleLog.filter((line) => line.includes('[physics]')).slice(jointHints),
}),
);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const joints = new Set(scene.objects.filter((o) => o.parentId === 'phys-bob').map((o) => o.assetId));
scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-') && o.parentId !== 'phys-bob');
p.assets = p.assets.filter((a) => !a.id.startsWith('phys-') && !(a.type === 'joint' && joints.has(a.id)));
__gm.state.select(null)`);
}