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
RapierTerrainsingleton 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 soclear()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-rolledterrainRestitution=1.09behavior (9% bounce → Rapierrestitution=0.09withCoefficientCombineRule.Max). - The
_createTerrainBody(piece, restitution, friction)internal — creates aRigidBodyDesc.fixed()translated topiece.x, piece.y, flattenspiece.worldVerts({x,y} entries in piece-local space) into aFloat32Array, and builds aColliderDesc.convexHull. Falls back toColliderDesc.ball(piece.boundingRadius || piece.radius || 30)when the hull is degenerate or non-convex. Returnsnullif 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)andsetAllFriction(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 withrestitution=0,CoefficientCombineRule.Min,friction=0, inCOLLISION_GROUP_TERRAIN, and pushes it toarenaWallBodies.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()andRapierWorld.isReady()from./rapier-world— the RapierWorldhandle for body/collider creation and removal, and the readiness gate guardingclear(),addArenaWall(), andclearArenaWalls().COLLISION_GROUP_TERRAINfrom./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/floaterargument: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) forRigidBodyDesc,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 bysetDefaults,setAllRestitution,setAllFrictionso 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/removePiecebased 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/setAllFrictionmutations 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 byengine/bridge.tsafter a chunk pixel-traces a terrain piece’sworldVerts. Guarded byhasPiece/hasFloaterto avoid double-register.RapierTerrain.removePiece(piece)/removeFloater(floater)— called byengine/bridge.ts,engine/boss/boss-room.ts, andengine/boss/sealed-arena.tswhen a chunk unloads or a boss room/arena consumes terrain.RapierTerrain.clear()— called byengine/bridge.tson full level transition / reset (two call sites).RapierTerrain.addArenaWall(cx, cy, halfW, halfH)— called four times byengine/boss/sealed-arena.tsto build the impenetrable boss-room box (top, bottom, left, right walls).RapierTerrain.clearArenaWalls()— called byengine/boss/sealed-arena.tsduring arena teardown.RapierTerrain.setAllRestitution(value)/setAllFriction(value)— driven byscreens/playground/PhysicsTab.tsxwhen the tuner adjuststerrainRestitution/terrainFrictionsliders.RapierTerrain.getTerrainCount()/getFloaterCount()— read byscreens/playground/PhysicsTab.tsxandengine/physics/rapier-debug-render.tsfor live overlay counts.
Pattern notes
- Identity-keyed maps over IDs. Both
terrainBodiesandfloaterBodieskey 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
worldVertsare flattened as-is into the convex hull (in piece-local coordinates). The rigid body’ssetTranslation(piece.x, piece.y)provides the world placement. Pieces withworldVertsalready pre-translated into world space would silently double-offset. - Convex-hull-or-circle fallback.
ColliderDesc.convexHullreturns 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)plusCoefficientCombineRule.Maxreproduces the legacyterrainRestitution=1.09rule (subtract dot then adddot × 1.09= 9% net bounce). Arena walls userestitution=0withCoefficientCombineRule.Min— they absorb velocity rather than punting the ship back through itself. isReady()guards on the destructive paths.clear(),addArenaWall(), andclearArenaWalls()checkRapierWorld.isReady()before touching the world; the wall-clear path still emptiesarenaWallBodieseven when Rapier isn’t ready, so the array can’t outlive a world reset.addPiece/removePiece/addFloater/removeFloaterdo not guard — they assume the bridge only calls them while the world is up.- Live tuning mirrors to defaults.
setAllRestitution/setAllFrictionwalk every collider on every body viabody.numColliders()+body.collider(i)and also overwrite the module-local default, so subsequentaddPiececalls inherit the tuned value instead of snapping back to the original. - Group filter is one-way ship-only.
COLLISION_GROUP_TERRAINismember=0x0002, filter=0x0001— terrain only collides with the ship (group0x0001), not enemies or other terrain. Enemies pass through asteroids.