Physics stage 1a runs on Rapier: a new Rigidbody child object (body type, mass, gravity scale, damping, fixed rotation, CCD) makes its parent fall, bounce and come to rest in play, colliders gain friction, bounciness and density, the scene gains gravity (0, 980), the new PhysicsSystem steps at a fixed 60 Hz with interpolation in world coordinates and treats a tilemap as static ground, On collision enter gains normal and impulse, play waits for Rapier and does not start without it, and the editor run keeps the old CollisionSystem; the existing collision tests run against both systems unchanged (#146)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d02bcca266
commit
78899b1ace
34 files changed
+2291
-676
No files matched your search
+164
-2
@@ -1,9 +1,32 @@
|
||||
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* A point of a panel (`panel` and `point` are hook expressions), scrolled into view first: a
|
||||
* collider's material rows sit below the fold of the inspector, and after the earlier steps the
|
||||
* hierarchy is long enough to hide this step's objects. A click beside the panel would land on
|
||||
* whatever is drawn there instead.
|
||||
*/
|
||||
async function inView(t, panel, point) {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const rect = await t.json(panel);
|
||||
const at = await t.json(point);
|
||||
if (!rect) return null;
|
||||
if (at && at.y > rect.y + 8 && at.y < rect.y + rect.h - 8) return at;
|
||||
await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, at && at.y < rect.y ? -200 : 200);
|
||||
await t.settle();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 } = 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()');
|
||||
@@ -25,4 +48,143 @@ export async function physics(t) {
|
||||
!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');
|
||||
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');
|
||||
|
||||
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: the highest point between two landings.
|
||||
const peaks = [];
|
||||
let landings = 0;
|
||||
let down = false;
|
||||
let top = Infinity;
|
||||
for (const { ball } of samples) {
|
||||
if (ball > REST - 1) {
|
||||
if (!down && landings++ > 0 && Number.isFinite(top)) peaks.push(REST - top);
|
||||
down = true;
|
||||
top = Infinity;
|
||||
} else {
|
||||
down = false;
|
||||
if (landings > 0) top = Math.min(top, ball);
|
||||
}
|
||||
}
|
||||
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)`);
|
||||
}
|
||||
Reference in new issue
Block a user