Browser reference stack for PSX-era third-person adventure: fixed cameras, inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular character harness. Ships the Ashgrove Precinct Level 1 investigation demo with a full cast and nine linked rooms.
291 lines
8.4 KiB
TypeScript
291 lines
8.4 KiB
TypeScript
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<number, PhysicsEntity>();
|
|
private readonly entitiesById = new Map<string, PhysicsEntity>();
|
|
|
|
/** 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<void> {
|
|
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();
|
|
}
|
|
}
|