diff --git a/README.md b/README.md index 370ce69b..72fc0b9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 33cae88a..8d9bf398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 233da6e5..dc6dc1af 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "vitest": "^3.0.0" }, "dependencies": { + "@dimforge/rapier2d-compat": "^0.20.0", "pixi.js": "^8.6.0" } } diff --git a/src/editor/App.ts b/src/editor/App.ts index 71495273..1ffc41b7 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -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 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); } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 1ea04fc4..a2f15f84 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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), diff --git a/src/runtime/physics/engine.ts b/src/runtime/physics/engine.ts new file mode 100644 index 00000000..49c1fd58 --- /dev/null +++ b/src/runtime/physics/engine.ts @@ -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 | 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 { + 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; +} diff --git a/tests/core/physics-engine.test.ts b/tests/core/physics-engine.test.ts new file mode 100644 index 00000000..b8fa4d71 --- /dev/null +++ b/tests/core/physics-engine.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs new file mode 100644 index 00000000..ec993de2 --- /dev/null +++ b/tools/e2e/41-physics.mjs @@ -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(' | '), + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 3c93c44d..b68285ec 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ];