Initial public release of PSX Adventure Engine

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.
This commit is contained in:
ryanfitzpatrickio
2026-07-31 06:32:43 -05:00
commit 8a96ede9f2
181 changed files with 25807 additions and 0 deletions
+290
View File
@@ -0,0 +1,290 @@
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();
}
}