1/**2 * Typed wrapper over the Box3D wasm shim (see shim.c / build_physics.sh).3 * Pure interface over an already-instantiated emscripten module so the4 * browser client and the headless Deno survivability tests share it.5 */6import type { CollisionProxy } from "../../gen/endless/proxies.ts";78/** The emscripten module surface the shim exports. */9/** Hit-event readback capacity per frame (plenty: a 240-body pack rarely10 * lands 20 threshold-beating knocks in one step). */11const HITS_CAP = 256;1213export interface PhysicsModule {14 _ph_init(gravityY: number): void;15 /** Added with native lifecycle teardown. Optional so a browser that has16 * a cached pre-teardown module can still regenerate instead of crashing. */17 _ph_dispose?(): void;18 _ph_begin_static(key: number): void;19 _ph_add_box(20 cx: number,21 cy: number,22 cz: number,23 hx: number,24 hy: number,25 hz: number,26 yawQuarter: number,27 ): void;28 _ph_add_slab(29 cx: number,30 cy: number,31 cz: number,32 hx: number,33 hy: number,34 hz: number,35 yawRad: number,36 pitchRad: number,37 ): void;38 _ph_end_static(): void;39 _ph_remove_static(key: number): void;40 _ph_spawn_marble(41 idx: number,42 x: number,43 y: number,44 z: number,45 vx: number,46 vy: number,47 vz: number,48 radius: number,49 ): void;50 _ph_destroy_marble(idx: number): void;51 _ph_marble_ghost(idx: number, ghost: number): void;52 /** Optional for previously cached native modules. */53 _ph_marble_segment?(idx: number, key: number): void;54 _ph_add_box_q(55 cx: number,56 cy: number,57 cz: number,58 hx: number,59 hy: number,60 hz: number,61 qx: number,62 qy: number,63 qz: number,64 qw: number,65 ): void;66 _ph_add_bumper_q(67 cx: number,68 cy: number,69 cz: number,70 radius: number,71 height: number,72 restitution: number,73 qx: number,74 qy: number,75 qz: number,76 qw: number,77 ): void;78 _ph_add_mesh(79 vertsPtr: number,80 vertCount: number,81 trisPtr: number,82 triCount: number,83 ): number;84 _ph_add_bumper(85 cx: number,86 cy: number,87 cz: number,88 radius: number,89 height: number,90 restitution: number,91 ): void;92 _ph_marble_force(idx: number, fx: number, fy: number, fz: number): void;93 _ph_marble_jump(94 idx: number,95 nx: number,96 ny: number,97 nz: number,98 target: number,99 ): void;100 _ph_marble_mass(idx: number): number;101 _ph_marble_restitution(idx: number, restitution: number): void;102 _ph_gravity(gx: number, gy: number, gz: number): void;103 _ph_step(dt: number, substeps: number): void;104 _ph_marble_state(idx: number, outPtr: number): void;105 _ph_states(outPtr: number, n: number): void;106 _ph_hits(outPtr: number, cap: number): number;107 _malloc(bytes: number): number;108 _free(ptr: number): void;109 HEAPF64: Float64Array;110 HEAPF32: Float32Array;111 HEAP32: Int32Array;112}113114export interface MarbleState {115 pos: [number, number, number];116 vel: [number, number, number];117 quat: [number, number, number, number];118}119120/** Physics marble radius — HALF the kit mesh's (user: "shrink the ball121 * 50%"); the render mesh scales by MARBLE_SCALE to match. */122export const MARBLE_RADIUS = 0.1;123/** Visual scale applied to the kit's marble piece. */124export const MARBLE_SCALE = 0.5;125/** Default marble restitution (shim spawn value); shock zones override. */126export const MARBLE_RESTITUTION = 0.35;127/** Marble Blast's gravity at our scale (MBHaxe: _gravity = 20) — adopted128 * together with the smooth ribbon, per docs/physics.md. */129export const GRAVITY_Y = -20;130/** Fixed physics clock: 60 Hz × 4 substeps (the spike's validated rate). */131export const FIXED_DT = 1 / 60;132export const SUBSTEPS = 4;133134/**135 * The assist law, shared by the client driver and the survivability tests136 * (ONE law, one truth): keep the marble rolling at cruising speed on137 * flats, carry it up climbs (the track lifts, like classic marble-run138 * conveyors — gravity-along-slope is cancelled plus a net push), and139 * brake gently when gravity has piled on too much speed.140 * Returns a world-space force or null.141 */142export function assistForce(143 vel: readonly [number, number, number],144 tangent: readonly [number, number, number],145 mass: number,146 /** Steepest upcoming slope (rail lookahead) — the climb carry must be147 * sized for the ramp AHEAD, not the base station, or the marble cycles148 * at the foot of every ramp (under-powered mid-climb, rolls back). */149 climbTy: number = tangent[1],150 /** Radians of heading change over the next ~4 units of rail — fast151 * arrivals into tight curves launch over the walls; brake to a safe152 * entry speed instead (the driver-side speed envelope). */153 curveAhead = 0,154 /** Per-tile speed modifier (M11): scales the cruise target AND the155 * assist authority — a boost zone actually accelerates the marble to156 * launch speed (force-scaling alone can't pass the target check). */157 speedScale = 1,158): [number, number, number] | null {159 // Near-vertical rail (drop shafts, steep chute dives ty ≥ 0.77): the160 // fall is gravity's job — an assist here just grinds the marble into161 // the cup. Climbs top out at ty ≈ 0.63 (ramp-long-c mid-cubic), so 0.7162 // keeps the climb carry alive through the WHOLE climb — at 0.6 it cut163 // out mid-slope and marbles cycled at the base of 1.0-climbs.164 if (Math.abs(tangent[1]) > 0.7) return null;165 const vAlong = vel[0] * tangent[0] + vel[1] * tangent[1] +166 vel[2] * tangent[2];167 // Cruise/brake tuned to the proven survivable envelope. M11 NOTE: a168 // global speed raise (4.2/10) launched marbles off curve seams and169 // crest lips — dynamic speed needs CURVATURE-AWARE braking (the speed170 // envelope) and banked/taller walls, not a bigger constant.171 const target = 3.2 * speedScale;172 const MAX = 9;173 const safe = Math.max(target, MAX - curveAhead * 5.5);174 if (vAlong > safe) {175 const f = mass * Math.min(10, 3 + (vAlong - safe) * 2);176 return [-tangent[0] * f, -tangent[1] * f, -tangent[2] * f];177 }178 const climbing = tangent[1] > 0.12 || climbTy > 0.12;179 if (climbing && vAlong < target) {180 // Cancel the along-slope gravity component and push a little extra.181 const a = -GRAVITY_Y * Math.max(tangent[1], climbTy) + 2.6;182 const f = mass * a;183 return [tangent[0] * f, tangent[1] * f, tangent[2] * f];184 }185 if (vAlong < target) {186 // Marble Blast responsiveness = authority (their angular accel ≈ 15187 // linear); scaled to our slalom widths.188 const f = mass * 6 * speedScale;189 return [tangent[0] * f, tangent[1] * f, tangent[2] * f];190 }191 return null;192}193194export class PhysicsEngine {195 private readonly out: number;196 private batchPtr = 0;197 private batchCap = 0;198 private accumulator = 0;199 /** Per-marble restitution cache (index → last set value). */200 private readonly restitutions = new Map<number, number>();201 /** Per-marble ghost cache (respawn immunity; spawn resets to solid). */202 private readonly ghosts = new Map<number, boolean>();203 private readonly trackSegments = new Map<number, number>();204 private disposed = false;205206 constructor(private readonly m: PhysicsModule) {207 // The C shim is a singleton world; ph_init tears down any forgotten208 // previous owner before creating the next one.209 m._ph_init(GRAVITY_Y);210 this.out = m._malloc(10 * 8);211 }212213 /** Release the singleton Box3D world and every JS-owned WASM buffer. */214 dispose(): void {215 if (this.disposed) return;216 this.disposed = true;217 if (this.batchPtr !== 0) this.m._free(this.batchPtr);218 if (this.hitsPtr !== 0) this.m._free(this.hitsPtr);219 this.m._free(this.out);220 this.batchPtr = 0;221 this.batchCap = 0;222 this.hitsPtr = 0;223 this.restitutions.clear();224 this.ghosts.clear();225 this.trackSegments.clear();226 this.m._ph_dispose?.();227 }228229 addSegment(key: number, proxies: readonly CollisionProxy[]): void {230 this.m._ph_begin_static(key);231 for (const p of proxies) {232 if (p.kind === "mesh") {233 // Box3D copies vertex data into its own BVH storage — the heap234 // staging buffers are freed immediately.235 const vp = this.m._malloc(p.vertices.length * 4);236 this.m.HEAPF32.set(p.vertices, vp / 4);237 const ip = this.m._malloc(p.indices.length * 4);238 this.m.HEAP32.set(p.indices, ip / 4);239 const ok = this.m._ph_add_mesh(240 vp,241 p.vertices.length / 3,242 ip,243 p.indices.length / 3,244 );245 this.m._free(vp);246 this.m._free(ip);247 if (ok !== 1) {248 throw new Error(`mesh creation failed for segment ${key} (${ok})`);249 }250 continue;251 }252 if (p.kind === "bumper") {253 if (p.quat) {254 this.m._ph_add_bumper_q(255 p.center[0],256 p.center[1],257 p.center[2],258 p.radius,259 p.height,260 p.restitution,261 p.quat[0],262 p.quat[1],263 p.quat[2],264 p.quat[3],265 );266 } else {267 this.m._ph_add_bumper(268 p.center[0],269 p.center[1],270 p.center[2],271 p.radius,272 p.height,273 p.restitution,274 );275 }276 continue;277 }278 // Reoriented chunks (M16) carry a full quaternion — box and slab279 // land on the same oriented-hull path (a slab IS a box hull; its280 // yaw/pitch were folded into the quat by worldifyProxies).281 if (p.quat) {282 this.m._ph_add_box_q(283 p.center[0],284 p.center[1],285 p.center[2],286 p.half[0],287 p.half[1],288 p.half[2],289 p.quat[0],290 p.quat[1],291 p.quat[2],292 p.quat[3],293 );294 } else if (p.kind === "box") {295 this.m._ph_add_box(296 p.center[0],297 p.center[1],298 p.center[2],299 p.half[0],300 p.half[1],301 p.half[2],302 0,303 );304 } else {305 this.m._ph_add_slab(306 p.center[0],307 p.center[1],308 p.center[2],309 p.half[0],310 p.half[1],311 p.half[2],312 p.yaw,313 p.pitch,314 );315 }316 }317 this.m._ph_end_static();318 }319320 removeSegment(key: number): void {321 this.m._ph_remove_static(key);322 }323324 /** Marble mass (kg — Box3D defaults to SI density 1000). */325 massOf(idx: number): number {326 return this.m._ph_marble_mass(idx);327 }328329 /** Marble 0's mass (single-marble drivers). */330 get mass(): number {331 return this.m._ph_marble_mass(0);332 }333334 /** Shock-absorber override (cached — the zone law calls this per step). */335 setRestitution(idx: number, value: number): void {336 if (this.restitutions.get(idx) === value) return;337 this.restitutions.set(idx, value);338 this.m._ph_marble_restitution(idx, value);339 }340341 /** Spawn (or respawn) marble `idx`. World gravity is FIXED at342 * [0, GRAVITY_Y, 0] — per-marble gravity zones are delta forces343 * (rail.ts stationGravityDelta), one global vector cannot serve N344 * marbles in different zones. */345 spawnMarble(346 idx: number,347 pos: readonly [number, number, number],348 vel: readonly [number, number, number],349 ): void {350 if (idx === 0) this.accumulator = 0;351 this.restitutions.set(idx, MARBLE_RESTITUTION); // shim recreates the shape352 this.ghosts.set(idx, false);353 this.trackSegments.delete(idx);354 this.m._ph_spawn_marble(355 idx,356 pos[0],357 pos[1],358 pos[2],359 vel[0],360 vel[1],361 vel[2],362 MARBLE_RADIUS,363 );364 }365366 destroyMarble(idx: number): void {367 this.m._ph_destroy_marble(idx);368 this.restitutions.delete(idx);369 this.ghosts.delete(idx);370 this.trackSegments.delete(idx);371 }372373 /** Limit track contact to this checkpoint and its connected neighbors.374 * Ghost changes retain this mask; respawns reset the cached ownership. */375 setTrackSegment(idx: number, key: number): void {376 if (this.trackSegments.get(idx) === key) return;377 this.trackSegments.set(idx, key);378 this.m._ph_marble_segment?.(idx, key);379 }380381 /** Respawn immunity (cached): a ghost marble keeps colliding with the382 * track but passes through other marbles. Spawn resets to solid. */383 setGhost(idx: number, ghost: boolean): void {384 if ((this.ghosts.get(idx) ?? false) === ghost) return;385 this.ghosts.set(idx, ghost);386 this.m._ph_marble_ghost(idx, ghost ? 1 : 0);387 }388389 /**390 * Advance by wall-clock dt on the fixed clock; `eachStep` runs before391 * every fixed step (assist forces are per-step so behavior is392 * frame-rate independent). Returns the number of fixed steps taken.393 */394 step(dt: number, eachStep?: () => void): number {395 this.accumulator = Math.min(this.accumulator + dt, 0.25);396 let steps = 0;397 while (this.accumulator >= FIXED_DT) {398 eachStep?.();399 this.m._ph_step(FIXED_DT, SUBSTEPS);400 this.accumulator -= FIXED_DT;401 steps++;402 }403 return steps;404 }405406 force(idx: number, fx: number, fy: number, fz: number): void {407 this.m._ph_marble_force(idx, fx, fy, fz);408 }409410 /** Top-up jump (M14b): velocity along `n` is SET to `target`, never411 * added — deterministic leave-speed (the MBHaxe jump contract), so the412 * autopilot can price an arc before committing. Safe to call repeatedly413 * while the condition holds; extra calls are no-ops. */414 jump(415 idx: number,416 n: readonly [number, number, number],417 target: number,418 ): void {419 this.m._ph_marble_jump(idx, n[0], n[1], n[2], target);420 }421422 /** Batched state readback for large packs: ONE wasm call for marbles423 * [0, n); returns a dense HEAP VIEW [i*10 .. i*10+9] = px py pz vx vy424 * vz qx qy qz qw. The view is invalidated by the next call and by any425 * heap growth — consume it within the step. */426 private hitsPtr = 0;427428 statesAll(n: number): Float64Array {429 if (n > this.batchCap) {430 if (this.batchPtr !== 0) this.m._free(this.batchPtr);431 this.batchCap = n;432 this.batchPtr = this.m._malloc(n * 10 * 8);433 }434 this.m._ph_states(this.batchPtr, n);435 return this.m.HEAPF64.subarray(436 this.batchPtr / 8,437 this.batchPtr / 8 + n * 10,438 );439 }440441 /** Contact hit events since the last step (contact-events round):442 * a dense HEAP VIEW of `count` × 6 floats [idxA, idxB (−1 = static),443 * approachSpeed, px, py, pz]. Hits report only above the world's444 * threshold (~1.5 u/s — real knocks, not rolling contact). Consume445 * within the frame; the view dies on the next call or heap growth. */446 hits(): { count: number; data: Float32Array } {447 if (this.hitsPtr === 0) {448 this.hitsPtr = this.m._malloc(HITS_CAP * 6 * 4);449 }450 const count = this.m._ph_hits(this.hitsPtr, HITS_CAP);451 return {452 count,453 data: this.m.HEAPF32.subarray(454 this.hitsPtr / 4,455 this.hitsPtr / 4 + count * 6,456 ),457 };458 }459460 state(idx = 0): MarbleState {461 this.m._ph_marble_state(idx, this.out);462 const h = this.m.HEAPF64;463 const i = this.out / 8;464 return {465 pos: [h[i], h[i + 1], h[i + 2]],466 vel: [h[i + 3], h[i + 4], h[i + 5]],467 quat: [h[i + 6], h[i + 7], h[i + 8], h[i + 9]],468 };469 }470}