import Box3D from "box3d-wasm"; import type { B3Body, B3Filter, B3Shape, B3World, Box3DModule } from "box3d-wasm"; import type { ColliderDef, ColliderMaterial, Quat, Vec3 } from "@psx/shared"; import { CollisionGroup, QUAT_IDENTITY, VEC3_ZERO } from "@psx/shared"; /** * Thin ownership layer over box3d-wasm. * * box3d-wasm identifies everything in its event arrays by a numeric `userData` * tag, and it keeps *separate* counters for bodies and shapes — so an * auto-assigned body tag and shape tag can collide numerically. We therefore * assign every tag ourselves from one counter and keep the reverse maps here. */ export interface PhysicsEntity { /** Gameplay-side identifier, e.g. `zone:hallway-north` or `player`. */ id: string; body: B3Body; shapes: B3Shape[]; } export interface RayHit { point: Vec3; normal: Vec3; fraction: number; distance: number; entityId: string | null; } export interface SensorTransition { sensorId: string; visitorId: string; } export interface SensorEvents { begin: SensorTransition[]; end: SensorTransition[]; } export interface StaticBodyOptions { id: string; position?: Vec3; rotation?: Quat; colliders: ColliderDef[]; material?: ColliderMaterial; group?: number; } export interface SensorOptions { id: string; position: Vec3; halfExtents: Vec3; group?: number; } export interface KinematicCapsuleOptions { id: string; position: Vec3; radius: number; height: number; group?: number; } export class PhysicsWorld { private module!: Box3DModule; private world!: B3World; private nextTag = 1; private readonly entitiesByShapeTag = new Map(); private readonly entitiesById = new Map(); /** True when the threaded "deluxe" wasm build was loaded (needs COOP/COEP). */ threaded = false; async init(gravity: Vec3 = { x: 0, y: -18, z: 0 }): Promise { this.module = await Box3D(); this.threaded = this.module.threaded; this.world = new this.module.World({ gravity, enableSleep: true, enableContinuous: true, ...(this.threaded ? { workerCount: Math.min(4, this.module.maxWorkers) } : {}), }); } step(dt: number, subSteps = 4): void { this.world.step(dt, subSteps); } private allocTag(): number { return this.nextTag++; } private register(entity: PhysicsEntity): PhysicsEntity { this.entitiesById.set(entity.id, entity); for (const shape of entity.shapes) { this.entitiesByShapeTag.set(shape.getUserData(), entity); } return entity; } /** * Static level geometry. Box3D in this build has no triangle-mesh shape, so a * room is a compound of boxes and convex hulls — which is also cheaper and * closer to how PSX-era collision was actually authored. */ createStatic(options: StaticBodyOptions): PhysicsEntity { const group = options.group ?? CollisionGroup.WORLD; const body = this.world.createBody({ type: "static", position: options.position ?? VEC3_ZERO, rotation: options.rotation ?? QUAT_IDENTITY, userData: this.allocTag(), name: options.id, }); const filter: B3Filter = { categoryBits: group, maskBits: 0xffffffff }; const shapes = options.colliders.map((collider) => this.addCollider(body, collider, filter, options.material), ); return this.register({ id: options.id, body, shapes }); } /** A non-colliding trigger volume: camera zones, interaction reach, doorways. */ createSensor(options: SensorOptions): PhysicsEntity { const group = options.group ?? CollisionGroup.TRIGGER; const body = this.world.createBody({ type: "static", position: options.position, userData: this.allocTag(), name: options.id, }); const shape = body.createBox({ halfExtents: options.halfExtents, isSensor: true, userData: this.allocTag(), filter: { categoryBits: group, maskBits: CollisionGroup.PLAYER | CollisionGroup.ENEMY }, }); shape.enableSensorEvents(true); return this.register({ id: options.id, body, shapes: [shape] }); } /** * The player capsule. Kinematic rather than dynamic: the mover computes the * exact position each tick, so we want zero solver interference — no bouncing * off walls, no sliding down slopes we did not ask to slide down. */ createKinematicCapsule(options: KinematicCapsuleOptions): PhysicsEntity { const group = options.group ?? CollisionGroup.PLAYER; const body = this.world.createBody({ type: "kinematic", position: options.position, userData: this.allocTag(), name: options.id, }); const shape = body.createCapsule({ radius: options.radius, height: options.height, density: 1, friction: 0, userData: this.allocTag(), filter: { categoryBits: group, maskBits: 0xffffffff }, }); // Needed for the capsule to show up as a `visitor` in sensor events. shape.enableSensorEvents(true); shape.enableContactEvents(true); return this.register({ id: options.id, body, shapes: [shape] }); } private addCollider( body: B3Body, collider: ColliderDef, filter: B3Filter, material?: ColliderMaterial, ): B3Shape { const common = { userData: this.allocTag(), filter, friction: material?.friction ?? 0.6, restitution: material?.restitution ?? 0, density: material?.density ?? 1, }; switch (collider.kind) { case "box": return body.createBox({ ...common, halfExtents: collider.halfExtents, center: collider.offset ?? VEC3_ZERO, rotation: collider.rotation ?? QUAT_IDENTITY, }); case "sphere": return body.createSphere({ ...common, radius: collider.radius, center: collider.offset ?? VEC3_ZERO, }); case "capsule": return body.createCapsule({ ...common, radius: collider.radius, height: collider.height, center: collider.offset ?? VEC3_ZERO, }); case "hull": { const offset = collider.offset; const points = offset ? collider.points.map((p) => ({ x: p.x + offset.x, y: p.y + offset.y, z: p.z + offset.z })) : collider.points; return body.createHull({ ...common, points }); } } } /** * Closest hit along a ray. This is the only spatial query box3d-wasm@0.2.0 * binds — there is no shape cast or overlap test — so the character mover is * built entirely out of ray probes. */ raycast(origin: Vec3, direction: Vec3, maxDistance: number, mask: number): RayHit | null { const translation = { x: direction.x * maxDistance, y: direction.y * maxDistance, z: direction.z * maxDistance, }; const result = this.world.castRayClosest(origin, translation, { categoryBits: 0xffffffff, maskBits: mask, }); if (!result.hit) return null; return { point: result.point, normal: result.normal, fraction: result.fraction, distance: result.fraction * maxDistance, entityId: this.entitiesByShapeTag.get(result.shapeUserData)?.id ?? null, }; } /** Sensor begin/end transitions since the last call, resolved to entity ids. */ drainSensorEvents(): SensorEvents { const raw = this.world.getSensorEvents(); const resolve = (events: { sensorUserData: number; visitorUserData: number }[]) => events.flatMap((event) => { const sensor = this.entitiesByShapeTag.get(event.sensorUserData); const visitor = this.entitiesByShapeTag.get(event.visitorUserData); return sensor && visitor ? [{ sensorId: sensor.id, visitorId: visitor.id }] : []; }); return { begin: resolve(raw.begin), end: resolve(raw.end) }; } get(id: string): PhysicsEntity | undefined { return this.entitiesById.get(id); } remove(id: string): void { const entity = this.entitiesById.get(id); if (!entity) return; for (const shape of entity.shapes) { this.entitiesByShapeTag.delete(shape.getUserData()); shape.delete(); } entity.body.destroy(); entity.body.delete(); this.entitiesById.delete(id); } /** Tear down every registered entity but keep the world alive (room swap). */ clear(): void { for (const id of [...this.entitiesById.keys()]) this.remove(id); } dispose(): void { this.clear(); this.world.destroy(); this.world.delete(); } get awakeBodyCount(): number { return this.world.getAwakeBodyCount(); } }