The suite to46c620a(#294) was red by one check: e2e 41, "a body dragged while playing hangs where the pointer holds it and is let go at rest". The body hung where it should, on -3490 in every frame, and fell from rest. The check failed on its own bound of 30 frames while held: that run drew 21 in the second of holding, the green runs of the day drew 37 to 54. - The bound is now the time the hold was watched: 800 ms between the first and the last sample before the release, and five samples at least. - The detail of the check names that time next to the frames. - Nothing in src/ changes. This is not the wobble of9cff528and543b052(#282) once more. Those were about when the samples are taken, this one about how many a runner manages. Why that run was so slow is not known. Tests: - e2e 41 on melektaus-atx: 21 checks, all pass, 66 frames in 1114 ms while held. - No counter-probe: a run as slow as the red one was not reproduced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
527 lines
23 KiB
JavaScript
527 lines
23 KiB
JavaScript
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;
|
||
|
||
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();
|
||
}
|