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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user