Rapier 2D (@dimforge/rapier2d-compat 0.20) comes in as the physics engine: the editor loads it once in the background right after opening a project from its own chunk, so the main bundle grows by 0.19 KB gzip and the first frame does not wait; loadPhysics, physicsStatus and the 100 px = 1 m conversion live in src/runtime/physics/engine.ts, __gm.physics() reports the state, unit tests pin free fall and a box resting on a box, and e2e module 41 pins that Rapier is ready from its own chunk without console noise (#145)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d7d0005cf
commit
d02bcca266
9 files changed
+143
-1
No files matched your search
@@ -38,7 +38,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, 1042 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1045 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
|
||||
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "game-maker",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier2d-compat": "^0.20.0",
|
||||
"pixi.js": "^8.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -47,6 +48,12 @@
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier2d-compat": {
|
||||
"version": "0.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier2d-compat/-/rapier2d-compat-0.20.0.tgz",
|
||||
"integrity": "sha512-FFYwGrfJov7d5kn4fxblG/ip5C+sYQL+Jl/hqLvDvu4BcMD/Mhrz7cTvnZTtuIRDXY8CLuraObqB/prip4wdWQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dimforge/rapier2d-compat": "^0.20.0",
|
||||
"pixi.js": "^8.6.0"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Application } from 'pixi.js';
|
||||
import { HttpFileStore } from '../core/store/HttpFileStore';
|
||||
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
||||
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
|
||||
import { loadPhysics } from '../runtime/physics/engine';
|
||||
import { requestRender, type VStack } from '../ui';
|
||||
import { fontLibrary } from '../viewport/FontLibrary';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
@@ -42,5 +43,6 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
fontLibrary.onChange(() => requestRender());
|
||||
installDebugHook(app, state, editor);
|
||||
await editor.boot();
|
||||
loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error));
|
||||
await startPlugins(API_ENDPOINT, state, editor);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project'
|
||||
import type { FrameRect } from '../core/model/texture';
|
||||
import { findLayer } from '../core/model/tilemap';
|
||||
import { effectiveScale, gameClock } from '../core/time';
|
||||
import { physicsStatus } from '../runtime/physics/engine';
|
||||
import {
|
||||
activeDrag,
|
||||
afterFrames,
|
||||
@@ -641,6 +642,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
sceneSelect: () => ({ ...centreOf(editor.sceneButton), enabled: editor.sceneButton.enabled }),
|
||||
/** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
|
||||
editorRun: () => editor.editorRunInfo(),
|
||||
physics: () => physicsStatus(),
|
||||
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
|
||||
hierarchyOrder: () => hierarchy.rowOrder(),
|
||||
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type RAPIER from '@dimforge/rapier2d-compat';
|
||||
|
||||
export type Rapier = typeof RAPIER;
|
||||
|
||||
/** Everything outside the engine is in pixels; Rapier is tuned for metres. */
|
||||
export const PIXELS_PER_METRE = 100;
|
||||
|
||||
export const toMetres = (pixels: number): number => pixels / PIXELS_PER_METRE;
|
||||
export const toPixels = (metres: number): number => metres * PIXELS_PER_METRE;
|
||||
|
||||
export type PhysicsStatus =
|
||||
| { state: 'idle' | 'loading' }
|
||||
| { state: 'ready'; ms: number; version: string }
|
||||
| { state: 'failed'; error: string };
|
||||
|
||||
let status: PhysicsStatus = { state: 'idle' };
|
||||
let loading: Promise<Rapier> | null = null;
|
||||
|
||||
/**
|
||||
* Rapier is its own chunk with the WebAssembly inside. Every caller shares the one load, and a
|
||||
* failed load stays failed rather than being tried again on each call.
|
||||
*/
|
||||
export function loadPhysics(): Promise<Rapier> {
|
||||
if (loading) return loading;
|
||||
const start = performance.now();
|
||||
status = { state: 'loading' };
|
||||
loading = import('@dimforge/rapier2d-compat').then(async ({ default: rapier }) => {
|
||||
await rapier.init();
|
||||
status = { state: 'ready', ms: Math.round(performance.now() - start), version: rapier.version() };
|
||||
return rapier;
|
||||
});
|
||||
loading.catch((error: unknown) => {
|
||||
status = { state: 'failed', error: error instanceof Error ? error.message : String(error) };
|
||||
});
|
||||
return loading;
|
||||
}
|
||||
|
||||
export function physicsStatus(): PhysicsStatus {
|
||||
return status;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
loadPhysics,
|
||||
PIXELS_PER_METRE,
|
||||
physicsStatus,
|
||||
type Rapier,
|
||||
toMetres,
|
||||
toPixels,
|
||||
} from '../../src/runtime/physics/engine';
|
||||
|
||||
const STEP = 1 / 60;
|
||||
const GRAVITY = 980;
|
||||
|
||||
/** A 400 × 20 px floor whose top is at y = 290, and a 32 px crate dropped from y = 0. */
|
||||
function crateOverFloor(rapier: Rapier) {
|
||||
const world = new rapier.World({ x: 0, y: toMetres(GRAVITY) });
|
||||
world.timestep = STEP;
|
||||
const floor = world.createRigidBody(rapier.RigidBodyDesc.fixed().setTranslation(0, toMetres(300)));
|
||||
world.createCollider(rapier.ColliderDesc.cuboid(toMetres(200), toMetres(10)), floor);
|
||||
const crate = world.createRigidBody(rapier.RigidBodyDesc.dynamic().setTranslation(0, 0));
|
||||
world.createCollider(rapier.ColliderDesc.cuboid(toMetres(16), toMetres(16)), crate);
|
||||
const run = (steps: number) => {
|
||||
for (let i = 0; i < steps; i++) world.step();
|
||||
};
|
||||
const y = () => toPixels(crate.translation().y);
|
||||
return { world, crate, run, y };
|
||||
}
|
||||
|
||||
describe('physics engine', () => {
|
||||
it('loads once and reports the Rapier version', async () => {
|
||||
const first = loadPhysics();
|
||||
expect(loadPhysics()).toBe(first);
|
||||
const rapier = await first;
|
||||
expect(rapier.version()).toBe('0.20.0');
|
||||
expect(physicsStatus()).toMatchObject({ state: 'ready', version: '0.20.0' });
|
||||
});
|
||||
|
||||
it('converts at 100 px to the metre', () => {
|
||||
expect(PIXELS_PER_METRE).toBe(100);
|
||||
expect(toMetres(980)).toBe(9.8);
|
||||
expect(toPixels(toMetres(123.5))).toBeCloseTo(123.5, 10);
|
||||
});
|
||||
|
||||
it('lets a crate fall freely in pixels per second squared', async () => {
|
||||
const { run, y, crate } = crateOverFloor(await loadPhysics());
|
||||
run(30);
|
||||
// ½ g t² = 122.5 px after half a second; the integrator lands about a pixel past it.
|
||||
expect(Math.abs(y() - 0.5 * GRAVITY * 0.25)).toBeLessThan(1.5);
|
||||
expect(toPixels(crate.linvel().y)).toBeCloseTo(GRAVITY * 0.5, 3);
|
||||
});
|
||||
|
||||
it('keeps a crate resting on the floor', async () => {
|
||||
const { run, y, crate } = crateOverFloor(await loadPhysics());
|
||||
run(180);
|
||||
expect(Math.abs(y() - 274)).toBeLessThan(0.5);
|
||||
expect(Math.abs(toPixels(crate.linvel().y))).toBeLessThan(0.5);
|
||||
run(120);
|
||||
expect(Math.abs(y() - 274)).toBeLessThan(0.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function physics(t) {
|
||||
const { check, json, waitUntil, consoleLog } = 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(' | '),
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import { nineSlice } from './37-nine-slice.mjs';
|
||||
import { plugins } from './38-plugins.mjs';
|
||||
import { tile } from './39-tile.mjs';
|
||||
import { tilemap } from './40-tilemap.mjs';
|
||||
import { physics } from './41-physics.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -85,4 +86,5 @@ export const STEPS = [
|
||||
plugins,
|
||||
tile,
|
||||
tilemap,
|
||||
physics,
|
||||
];
|
||||
Reference in new issue
Block a user