engine/physics/rapier-terrain.ts

PURPOSE

Owns the Rapier2D static collider lifecycle for terrain pieces (asteroids), floaters, and sealed-arena wall cuboids. Builds fixed rigid bodies with convex-hull colliders from each piece’s pixel-traced worldVerts, tracks them in per-category maps so chunk unload and arena teardown can remove the right bodies, and exposes live-tuning hooks for restitution and friction. All bodies are fixed (no velocity, no rotation), members of the COLLISION_GROUP_TERRAIN group, and only collide with the ship.

OWNS

  • The RapierTerrain singleton object — the only public surface of the module.
  • Three internal trackers:
    • terrainBodies: Map<piece, RAPIER.RigidBody> — biome terrain pieces (asteroids).
    • floaterBodies: Map<floater, RAPIER.RigidBody> — floater pieces (separate so clear() can target categories independently).
    • arenaWallBodies: RAPIER.RigidBody[] — sealed-arena cuboid walls, tracked as an array since arena teardown drops them as a set.
  • Default physics constants for new pieces: defaultRestitution = 0.09, defaultFriction = 0.01. These match the legacy hand-rolled terrainRestitution=1.09 behavior (9% bounce → Rapier restitution=0.09 with CoefficientCombineRule.Max).
  • The _createTerrainBody(piece, restitution, friction) internal — creates a RigidBodyDesc.fixed() translated to piece.x, piece.y, flattens piece.worldVerts ({x,y} entries in piece-local space) into a Float32Array, and builds a ColliderDesc.convexHull. Falls back to ColliderDesc.ball(piece.boundingRadius || piece.radius || 30) when the hull is degenerate or non-convex. Returns null if fewer than 3 verts.
  • Terrain-piece API: addPiece(piece), removePiece(piece), hasPiece(piece).
  • Floater API: addFloater(floater), removeFloater(floater), hasFloater(floater).
  • Bulk teardown: clear() — removes every terrain, floater, and arena-wall body and resets all three trackers. Safe to call before Rapier is ready (still empties the arena-wall array).
  • Live-tuning: setDefaults(restitution, friction) updates defaults for new pieces; setAllRestitution(value) and setAllFriction(value) walk every collider on every terrain + floater body and update the live values plus the default.
  • Debug counts: getTerrainCount(), getFloaterCount().
  • Sealed-arena cuboid walls: addArenaWall(cx, cy, halfW, halfH) builds a fixed cuboid with restitution=0, CoefficientCombineRule.Min, friction=0, in COLLISION_GROUP_TERRAIN, and pushes it to arenaWallBodies. clearArenaWalls() removes only the wall bodies, leaving biome terrain alone. Both are no-ops when Rapier isn’t ready (the clear path still empties the array).

READS FROM

  • RapierWorld.getWorld() and RapierWorld.isReady() from ./rapier-world — the Rapier World handle for body/collider creation and removal, and the readiness gate guarding clear(), addArenaWall(), and clearArenaWalls().
  • COLLISION_GROUP_TERRAIN from ./rapier-ship — applied to every terrain, floater, and arena-wall collider so they only interact with the ship body.
  • Per-piece fields read off the piece/floater argument: worldVerts (Array<{x,y}> in local space), x, y (world-space center for the rigid-body translation), boundingRadius, radius (fallback hull radius).
  • The Rapier2D library (@dimforge/rapier2d-compat) for RigidBodyDesc, ColliderDesc, CoefficientCombineRule.

PUSHES TO

  • The shared Rapier World — creates/removes rigid bodies and colliders. There is no other shared mutable state; entity positions are owned by the gameplay layer, not by this module.
  • Internal trackers (terrainBodies, floaterBodies, arenaWallBodies) — populated on add, drained on remove/clear so they always mirror what currently lives in the Rapier world.
  • Module-local defaultRestitution / defaultFriction — overwritten by setDefaults, setAllRestitution, setAllFriction so future pieces inherit the latest tuned values.

DOES NOT

  • Does not move terrain. All bodies are RigidBodyDesc.fixed() — no velocity integration, no rotation.
  • Does not generate worldVerts. The pixel-trace happens upstream; this module just consumes the array.
  • Does not own the Rapier world or step the simulation — that’s rapier-world.ts.
  • Does not handle ship/enemy collisions or contact events — only registers static geometry; collision response is Rapier’s job and ship-side reads are in rapier-ship.ts.
  • Does not chunk-walk or decide which pieces to load — the bridge layer drives addPiece / removePiece based on streaming state.
  • Does not garbage-collect dropped pieces — the caller must invoke removePiece / removeFloater; orphaned entries would leak.
  • Does not persist defaults — setDefaults / setAllRestitution / setAllFriction mutations are in-memory only.
  • Does not distinguish biome-terrain from arena-wall in clear(); level-transition teardown drops everything.

Signals

This module is signal-free — no event bus, no addEventListener, no observer pattern. All synchronization with the rest of the engine is direct method calls in/out.

Entry points

  • RapierTerrain.addPiece(piece) / addFloater(floater) — called by engine/bridge.ts after a chunk pixel-traces a terrain piece’s worldVerts. Guarded by hasPiece / hasFloater to avoid double-register.
  • RapierTerrain.removePiece(piece) / removeFloater(floater) — called by engine/bridge.ts, engine/boss/boss-room.ts, and engine/boss/sealed-arena.ts when a chunk unloads or a boss room/arena consumes terrain.
  • RapierTerrain.clear() — called by engine/bridge.ts on full level transition / reset (two call sites).
  • RapierTerrain.addArenaWall(cx, cy, halfW, halfH) — called four times by engine/boss/sealed-arena.ts to build the impenetrable boss-room box (top, bottom, left, right walls).
  • RapierTerrain.clearArenaWalls() — called by engine/boss/sealed-arena.ts during arena teardown.
  • RapierTerrain.setAllRestitution(value) / setAllFriction(value) — driven by screens/playground/PhysicsTab.tsx when the tuner adjusts terrainRestitution / terrainFriction sliders.
  • RapierTerrain.getTerrainCount() / getFloaterCount() — read by screens/playground/PhysicsTab.tsx and engine/physics/rapier-debug-render.ts for live overlay counts.

Pattern notes

  • Identity-keyed maps over IDs. Both terrainBodies and floaterBodies key by the piece object reference itself (Map<any, RigidBody>). Add/remove/has rely on referential identity, so the caller must hold onto the same object it registered.
  • Category split for surgical teardown. Terrain, floaters, and arena walls live in three separate containers. clear() wipes all three; clearArenaWalls() only wipes arena walls, leaving biome terrain untouched — this is what lets sealed-arena teardown not blow away the surrounding world.
  • Local-space verts, world-space translation. Each piece’s worldVerts are flattened as-is into the convex hull (in piece-local coordinates). The rigid body’s setTranslation(piece.x, piece.y) provides the world placement. Pieces with worldVerts already pre-translated into world space would silently double-offset.
  • Convex-hull-or-circle fallback. ColliderDesc.convexHull returns null for degenerate/non-convex point sets; the fallback ball collider (boundingRadius || radius || 30) keeps every piece collidable rather than skipping silently.
  • Restitution model. Per-collider setRestitution(0.09) plus CoefficientCombineRule.Max reproduces the legacy terrainRestitution=1.09 rule (subtract dot then add dot × 1.09 = 9% net bounce). Arena walls use restitution=0 with CoefficientCombineRule.Min — they absorb velocity rather than punting the ship back through itself.
  • isReady() guards on the destructive paths. clear(), addArenaWall(), and clearArenaWalls() check RapierWorld.isReady() before touching the world; the wall-clear path still empties arenaWallBodies even when Rapier isn’t ready, so the array can’t outlive a world reset. addPiece / removePiece / addFloater / removeFloater do not guard — they assume the bridge only calls them while the world is up.
  • Live tuning mirrors to defaults. setAllRestitution / setAllFriction walk every collider on every body via body.numColliders() + body.collider(i) and also overwrite the module-local default, so subsequent addPiece calls inherit the tuned value instead of snapping back to the original.
  • Group filter is one-way ship-only. COLLISION_GROUP_TERRAIN is member=0x0002, filter=0x0001 — terrain only collides with the ship (group 0x0001), not enemies or other terrain. Enemies pass through asteroids.