Colliders were the only placed part without a symbol in the scene view. A collider that sits exactly on its sprite was hard to find and could only be picked by its outline. - The symbol is the box with four corner handles from the asset panel. It stays upright and has a fixed size in world units. - colliderColor() holds the colour rule of the outline (green, trigger colour, red for a shape that collides with nothing). The outline and the symbol share it. - The symbol sits 28 units left of the collider centre. The sprite centre stays free for grabbing the sprite, and a rigidbody symbol on the same spot is not covered. - The symbol has its own 26x26 hit area and the outline's handler. A click selects the collider and a drag moves it. sceneStack counts it as a hit under the pointer. - The hook __gm.sceneIcon(id) also returns x, y, color, screen and clickable. Findings: - With the clickable symbol on the sprite centre, four checks in 01 and 09 dragged the collider instead of the crate. That is why the symbol moved beside the centre. Tests: - ./check.sh: 153 files, 2237 tests, CHECK EXIT 0 (was 152 / 2234). - New unit test for colliderColor. svg-icons.test.ts lists the collider symbol. - e2e 41b: 3 new checks (beside the centre without and with a rigidbody, click selects the collider). - e2e 1-16,39-43: ALL PASSED, 635 checks. e2e 41 alone: 20 checks. - Counter-probes: no symbol fails both placement checks. A symbol without hit area fails only the click check. - Check total 1348 + 3 = 1351, computed, not yet measured by CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
434 lines
19 KiB
JavaScript
434 lines
19 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 } = 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');
|
||
// A collider has an icon like every other symbol. It stands left of the collider's centre, so the
|
||
// middle of its sprite stays free, and so does a rigidbody's icon on the same spot.
|
||
const icons = {
|
||
ground: await json(`__gm.sceneIcon('phys-ground-col')`),
|
||
crate: await json(`__gm.sceneIcon('phys-crate-col')`),
|
||
ball: await json(`__gm.sceneIcon('phys-ball-col')`),
|
||
};
|
||
check(
|
||
'a collider without a rigidbody shows its icon left of its centre',
|
||
icons.ground?.visible === true &&
|
||
icons.ground.layer === 'overlay' &&
|
||
icons.ground.x === -28 &&
|
||
icons.ground.y === Y + 200 &&
|
||
icons.ground.color !== null,
|
||
JSON.stringify(icons.ground),
|
||
);
|
||
check(
|
||
"a collider's icon steps aside from the rigidbody on the same body, in the same colour",
|
||
[
|
||
[icons.crate, -100],
|
||
[icons.ball, 100],
|
||
].every(([icon, x]) => icon?.visible === true && icon.x === x - 28 && icon.y === Y) &&
|
||
icons.crate.color === icons.ground.color,
|
||
JSON.stringify(icons),
|
||
);
|
||
// The icon is clicked like the outline: it selects the collider, even over its sprite. On the
|
||
// middle of the sprite, beside the icon, the sprite is picked as before.
|
||
const selectedId = async () => {
|
||
const s = await state();
|
||
return s.selection?.kind === 'object' ? s.selection.objectId : null;
|
||
};
|
||
const picks = {};
|
||
for (const [name, id, dx] of [
|
||
['groundIcon', 'phys-ground-col', 0],
|
||
['crateIcon', 'phys-crate-col', 0],
|
||
['groundMiddle', 'phys-ground', null],
|
||
]) {
|
||
await select(null);
|
||
const at = await json(dx === null ? `__gm.objectScreen('${id}')` : `__gm.sceneIcon('${id}')?.screen`);
|
||
await click(at.x + (dx ?? 0), at.y);
|
||
picks[name] = { selected: await selectedId() };
|
||
}
|
||
check(
|
||
"a click on a collider's icon selects the collider, on the sprite's middle the sprite",
|
||
icons.ground.clickable === true &&
|
||
picks.groundIcon.selected === 'phys-ground-col' &&
|
||
picks.crateIcon.selected === 'phys-crate-col' &&
|
||
picks.groundMiddle.selected === 'phys-ground',
|
||
JSON.stringify(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,
|
||
);
|
||
|
||
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();
|
||
}
|