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
+849
View File
@@ -0,0 +1,849 @@
import { Scene, Vector3, WebGLRenderer } from "three";
import type {
ClientMessage,
HostMessage,
InteractableDef,
PeerId,
PlayerSnapshot,
RoomDef,
Vec3,
} from "@psx/shared";
import { emptySave, SAVE_FORMAT_VERSION, SNAPSHOT_INTERVAL, type SaveData } from "@psx/shared";
import { HostAuthority } from "../net/HostAuthority.js";
import type { NetSession } from "../net/NetSession.js";
import { RemotePlayers } from "../net/RemotePlayers.js";
import { CameraManager } from "../systems/CameraManager.js";
import { CharacterMover, DEFAULT_MOVER_CONFIG } from "../systems/CharacterMover.js";
import { FlagStore } from "../systems/FlagStore.js";
import { InputManager } from "../systems/InputManager.js";
import { InteractionSystem } from "../systems/InteractionSystem.js";
import { InventorySystem } from "../systems/InventorySystem.js";
import { PlayerController, type ControlScheme } from "../systems/PlayerController.js";
import { PSXPipeline } from "../post/PSXPipeline.js";
import { RoomBuilder, type BuiltRoom } from "../world/RoomBuilder.js";
import { PlayerAvatar } from "../world/PlayerAvatar.js";
import { CharacterModel } from "../world/CharacterModel.js";
import {
CHARACTER_CATALOGUE,
PLAYER_CHARACTER_ID,
placementsForRoom,
} from "../world/characters.js";
import { getRoom } from "../world/rooms/index.js";
import { COMBINATIONS, ITEM_CATALOGUE } from "../world/items.js";
import { GameLoop, TICK_RATE } from "./GameLoop.js";
import { PhysicsWorld } from "./PhysicsWorld.js";
export const PLAYER_BODY_ID = "player";
export interface HudItem {
id: string;
name: string;
description: string;
quantity: number;
isKeyItem: boolean;
color: number;
}
export interface HudSnapshot {
prompt: { label: string; verb: string } | null;
message: string | null;
items: HudItem[];
capacity: number;
usedSlots: number;
controlScheme: ControlScheme;
}
export interface DebugSnapshot {
fps: number;
activeZone: string | null;
grounded: boolean;
position: { x: number; y: number; z: number };
threadedPhysics: boolean;
awakeBodies: number;
net: { role: "solo" | "host" | "guest"; peers: number; drift: number } | null;
}
/**
* Divergence between a guest's predicted position and the host's authoritative
* one. Below `SOFT`, prediction was right and we leave it alone. Between the
* two we ease across rather than snap, which hides ordinary jitter. Above
* `HARD` prediction has genuinely failed — usually a collision the guest
* resolved differently — and a visible jump beats staying wrong.
*/
const DRIFT_SOFT_CORRECT = 0.12;
const DRIFT_HARD_SNAP = 1.5;
const MESSAGE_DURATION = 4.5;
const DEBUG_INTERVAL = 0.25;
/**
* Milestone 1 runtime: wires the systems together and owns the frame.
*
* Everything gameplay-relevant happens in `fixedUpdate` at 60 Hz; `render` only
* interpolates and draws. Keeping that split clean now is what makes the
* host-authoritative networking in Milestone 2 tractable — the host will run
* exactly this `fixedUpdate` and ship the results.
*/
export class Game {
private readonly renderer: WebGLRenderer;
private readonly scene = new Scene();
private readonly physics = new PhysicsWorld();
private readonly flags = new FlagStore();
private readonly inventory: InventorySystem;
private readonly input = new InputManager();
private readonly loop: GameLoop;
private cameraManager!: CameraManager;
private pipeline!: PSXPipeline;
private roomBuilder!: RoomBuilder;
private builtRoom: BuiltRoom | null = null;
private interactions!: InteractionSystem;
private mover!: CharacterMover;
private controller!: PlayerController;
private avatar!: PlayerAvatar;
/** Harness-generated player character. Null until it loads, or if loading fails. */
private character: CharacterModel | null = null;
/** Ambient cast members for the active room (bodies, NPCs). */
private ambientCharacters: CharacterModel[] = [];
private ambientLoadToken = 0;
private room: RoomDef | null = null;
private detachInput: (() => void) | null = null;
private resizeObserver: ResizeObserver | null = null;
private message: string | null = null;
private messageTimer = 0;
private debugTimer = 0;
private playtimeSeconds = 0;
private hudDirty = true;
private lastPromptId: string | null = null;
// --- multiplayer (null in single player) ---------------------------------
private net: NetSession | null = null;
private hostAuthority: HostAuthority | null = null;
private remotePlayers: RemotePlayers | null = null;
private inputSendTimer = 0;
private guestInputTick = 0;
private lastDrift = 0;
/** Guest-side read-only view of the host's party inventory. */
private mirroredItems: Array<{ itemId: string; quantity: number }> | null = null;
/** Set by the UI layer to receive state pushes. */
onHud: ((snapshot: HudSnapshot) => void) | null = null;
onDebug: ((snapshot: DebugSnapshot) => void) | null = null;
/** Interpolation anchors, so rendering stays smooth between 60 Hz ticks. */
private readonly previousPosition = new Vector3();
private readonly renderPosition = new Vector3();
private previousYaw = 0;
constructor(private readonly canvas: HTMLCanvasElement) {
this.renderer = new WebGLRenderer({
canvas,
// Antialiasing would sand off exactly the stair-stepping we are after.
antialias: false,
powerPreference: "high-performance",
});
// A low-res target upscaled by a fractional device ratio reintroduces
// blurring, so we pin to 1 and let the PSX pass own the pixel grid.
this.renderer.setPixelRatio(1);
this.inventory = new InventorySystem(ITEM_CATALOGUE, COMBINATIONS, 8);
this.loop = new GameLoop({
fixedUpdate: (dt, tick) => this.fixedUpdate(dt, tick),
render: (alpha, frameDt) => this.render(alpha, frameDt),
});
}
async start(room: RoomDef, spawnPointId = "start"): Promise<void> {
await this.physics.init({ x: 0, y: DEFAULT_MOVER_CONFIG.gravity, z: 0 });
const { clientWidth, clientHeight } = this.canvas;
this.cameraManager = new CameraManager(Math.max(1, clientWidth) / Math.max(1, clientHeight));
this.pipeline = new PSXPipeline(this.renderer, this.scene, this.cameraManager.camera);
this.roomBuilder = new RoomBuilder(this.scene, this.physics);
this.interactions = new InteractionSystem(this.physics, this.inventory, this.flags);
this.loadRoom(room, spawnPointId);
this.cameraManager.onCut = () => this.controller.onCameraCut();
this.inventory.subscribe(() => {
this.hudDirty = true;
});
this.flags.subscribe((flag, value) => {
if (value) this.applyFlagToWorld(flag);
});
this.detachInput = this.input.attach(window);
this.observeResize();
this.handleResize();
this.loop.start();
this.pushHud();
// Loaded after the loop starts so a missing or malformed GLB costs a
// placeholder avatar rather than the whole game.
void this.loadCharacter();
}
private async loadCharacter(): Promise<void> {
const def = CHARACTER_CATALOGUE.get(PLAYER_CHARACTER_ID);
if (!def) return;
try {
const character = await CharacterModel.load(def.url, {
snapResolution: this.pipeline.snapResolution,
});
this.character = character;
this.scene.add(character.group);
// Keep the block figure around as the fallback, just hidden.
this.avatar.group.visible = false;
} catch (cause) {
console.warn("harness character failed to load; using placeholder avatar", cause);
}
}
private clearAmbientCharacters(): void {
for (const model of this.ambientCharacters) {
this.scene.remove(model.group);
model.dispose();
}
this.ambientCharacters = [];
}
/** Spawns harness cast members that belong in this room. */
private async loadAmbientCharacters(roomId: string): Promise<void> {
const token = ++this.ambientLoadToken;
this.clearAmbientCharacters();
const placements = placementsForRoom(roomId);
if (placements.length === 0) return;
const loaded: CharacterModel[] = [];
await Promise.all(
placements.map(async (placement) => {
const def = CHARACTER_CATALOGUE.get(placement.characterId);
if (!def) return;
try {
const model = await CharacterModel.load(def.url, {
snapResolution: this.pipeline.snapResolution,
// Living NPCs idle; crime-scene bodies use pose: "slumped".
initialState: placement.pose ?? "idle",
});
model.setTransform(
placement.position.x,
placement.position.y,
placement.position.z,
placement.yaw,
);
// Pose can lift or drop the mesh; pin the soles to the floor.
model.groundToFloor();
loaded.push(model);
} catch (cause) {
console.warn(`ambient character ${placement.characterId} failed to load`, cause);
}
}),
);
// A faster room transition may have superseded this load.
if (token !== this.ambientLoadToken) {
for (const model of loaded) model.dispose();
return;
}
for (const model of loaded) this.scene.add(model.group);
this.ambientCharacters = loaded;
}
private loadRoom(room: RoomDef, spawnPointId: string): void {
this.room = room;
this.builtRoom?.dispose();
this.cameraManager.clear();
this.interactions.clear();
this.clearAmbientCharacters();
if (this.avatar) {
this.scene.remove(this.avatar.group);
this.avatar.dispose();
}
// The player character is not room-specific, so it survives a
// transition — it is only re-parented, never rebuilt.
if (this.character) this.scene.remove(this.character.group);
// Drops the previous room's statics and sensors *and* the old player
// capsule. Without this a transition leaves orphaned bodies in the world
// that the mover's ray probes would still collide with.
this.physics.clear();
const activeFlags = new Set(this.flags.serialise());
this.builtRoom = this.roomBuilder.build(room, activeFlags);
this.cameraManager.loadZones(this.physics, room.cameraZones);
this.interactions.load(room.interactables);
const spawn =
room.spawnPoints.find((point) => point.id === spawnPointId) ?? room.spawnPoints[0];
if (!spawn) throw new Error(`Room ${room.id} has no spawn points`);
const spawnPosition = new Vector3(spawn.position.x, spawn.position.y, spawn.position.z);
this.physics.createKinematicCapsule({
id: PLAYER_BODY_ID,
position: {
x: spawnPosition.x,
y: spawnPosition.y + (DEFAULT_MOVER_CONFIG.cylinderHeight + DEFAULT_MOVER_CONFIG.radius * 2) / 2,
z: spawnPosition.z,
},
radius: DEFAULT_MOVER_CONFIG.radius,
height: DEFAULT_MOVER_CONFIG.cylinderHeight,
});
this.mover = new CharacterMover(this.physics, PLAYER_BODY_ID, spawnPosition);
this.controller = new PlayerController(this.mover, this.input);
this.controller.yaw = spawn.yaw;
this.avatar = new PlayerAvatar({ totalHeight: this.mover.totalHeight });
this.avatar.group.visible = this.character === null;
this.scene.add(this.avatar.group);
if (this.character) this.scene.add(this.character.group);
this.previousPosition.copy(spawnPosition);
this.renderPosition.copy(spawnPosition);
this.previousYaw = spawn.yaw;
void this.loadAmbientCharacters(room.id);
}
/** Resolve a door transition into a room from the campaign catalogue. */
private transitionTo(roomId: string, spawnPointId: string): void {
const next = getRoom(roomId);
if (!next) {
console.warn(`unknown room: ${roomId}`);
this.setMessage("The door won't open.");
return;
}
this.loadRoom(next, spawnPointId);
this.setMessage(next.displayName);
}
/**
* Enter co-op. Safe to call after `start`; the world is already built, and
* remote players simply begin appearing in it.
*/
attachNet(net: NetSession): void {
this.net = net;
this.remotePlayers = new RemotePlayers(this.scene, this.mover.totalHeight);
if (net.isHost) {
this.hostAuthority = new HostAuthority(this.physics);
}
net.onPeerJoinedWorld = (peerId) => {
if (!this.hostAuthority || !this.room) return;
const spawn = this.spawnFor(peerId);
this.hostAuthority.addPlayer(peerId, spawn);
// Bring the newcomer up to date on everything it missed.
net.sendToPeer(peerId, {
t: "sync",
roomId: this.room.id,
flags: this.flags.serialise(),
despawned: this.interactions.serialise(),
players: this.hostAuthority.buildSnapshot(this.hostPose()).players,
});
};
net.onPeerLeftWorld = (peerId) => {
this.hostAuthority?.removePlayer(peerId);
this.remotePlayers?.remove(peerId);
};
this.hudDirty = true;
}
/** Peers spawn on distinct points so nobody materialises inside anyone else. */
private spawnFor(peerId: PeerId): Vector3 {
const points = this.room?.spawnPoints ?? [];
const point = points[peerId % Math.max(1, points.length)];
const base = point?.position ?? { x: 0, y: 0, z: 0 };
// Fan out around the point when peers share one.
const angle = (peerId / 4) * Math.PI * 2;
return new Vector3(base.x + Math.sin(angle) * 0.6, base.y, base.z + Math.cos(angle) * 0.6);
}
private hostPose() {
const state = this.mover.state;
return {
position: state.position,
yaw: this.controller.yaw,
speed: state.lastMoveDistance * 60,
grounded: state.grounded,
};
}
/** Host: apply a validated guest message. */
handleClientMessage(message: ClientMessage, from: PeerId): void {
if (!this.hostAuthority) return;
switch (message.t) {
case "input":
this.hostAuthority.applyInput(from, message);
break;
case "interact":
this.handleGuestInteract(from, message.interactableId);
break;
case "combine": {
const text = this.combineItems(message.itemA, message.itemB);
this.net?.sendToPeer(from, { t: "notice", text });
this.broadcastInventory();
break;
}
case "chat":
this.net?.broadcastEvent({ t: "chat", from, text: message.text });
break;
}
}
/**
* The host re-checks reach itself. Sensors only track the local player, so
* proximity is verified against the guest's authoritative mover position —
* a guest cannot open a door from across the map by naming its id.
*/
private handleGuestInteract(peerId: PeerId, interactableId: string): void {
const player = this.hostAuthority?.getPlayer(peerId);
const def = this.room?.interactables.find((entry) => entry.id === interactableId);
if (!player || !def || !this.net) return;
if (!withinReach(player.mover.state.position, def)) {
this.net.sendToPeer(peerId, { t: "notice", text: "You're too far away." });
return;
}
const result = this.interactions.activateById(interactableId);
if (!result) return;
if (result.type !== "blocked") this.roomBuilder.removeInteractableVisual(interactableId);
switch (result.type) {
case "picked-up":
case "flag-set":
case "message":
case "blocked":
this.net.sendToPeer(peerId, { t: "notice", text: result.text });
break;
case "transition":
break;
}
// World state is shared, so everyone hears about it, including the actor.
this.net.broadcastEvent({ t: "flags", set: this.flags.serialise(), unset: [] });
this.net.broadcastEvent({ t: "despawn", interactableIds: this.interactions.serialise() });
this.broadcastInventory();
this.hudDirty = true;
}
/**
* Co-op shares one party inventory rather than giving each peer their own.
* That matches how the genre's co-op modes work, and it keeps a single
* authoritative copy on the host instead of four that can disagree.
*/
private broadcastInventory(): void {
this.net?.broadcastEvent({
t: "inventory",
peerId: 0,
items: this.inventory.list().map((entry) => ({
itemId: entry.def.id,
quantity: entry.quantity,
})),
});
}
/** Guest: apply validated authoritative state from the host. */
handleHostMessage(message: HostMessage): void {
switch (message.t) {
case "snapshot": {
const now = performance.now() / 1000;
this.remotePlayers?.ingestSnapshot(message.players, this.net?.localPeerId ?? -1, now);
this.reconcileLocalPlayer(message.players);
break;
}
case "sync":
this.flags.restore(message.flags);
this.interactions.restore(message.despawned);
for (const id of message.despawned) this.roomBuilder.removeInteractableVisual(id);
for (const flag of message.flags) this.applyFlagToWorld(flag);
break;
case "flags":
for (const flag of message.set) {
this.flags.set(flag);
}
break;
case "despawn":
this.interactions.restore(message.interactableIds);
for (const id of message.interactableIds) this.roomBuilder.removeInteractableVisual(id);
break;
case "inventory":
this.mirroredItems = message.items;
this.hudDirty = true;
break;
case "notice":
this.setMessage(message.text);
break;
case "chat":
this.setMessage(`Player ${message.from}: ${message.text}`);
break;
}
}
/** Guest-side prediction error correction. */
private reconcileLocalPlayer(players: PlayerSnapshot[]): void {
const localPeerId = this.net?.localPeerId ?? -1;
const mine = players.find((player) => player.peerId === localPeerId);
if (!mine) return;
const local = this.mover.state.position;
const drift = Math.hypot(local.x - mine.p.x, local.y - mine.p.y, local.z - mine.p.z);
this.lastDrift = drift;
if (drift > DRIFT_HARD_SNAP) {
this.mover.teleport(new Vector3(mine.p.x, mine.p.y, mine.p.z));
} else if (drift > DRIFT_SOFT_CORRECT) {
// Ease a fraction of the way each snapshot; repeated corrections converge
// without the visible stutter of a hard set.
local.lerp(new Vector3(mine.p.x, mine.p.y, mine.p.z), 0.25);
}
}
private fixedUpdate(dt: number, _tick: number): void {
this.playtimeSeconds += dt;
this.previousPosition.copy(this.mover.state.position);
this.previousYaw = this.controller.yaw;
if (this.input.wasPressed("toggleControls")) {
this.controller.toggleScheme();
this.hudDirty = true;
}
this.controller.update(dt, this.cameraManager.getLookDirection());
// Step physics after the mover has written its target transform, so sensor
// overlaps are computed against where the player actually ended up.
this.physics.step(dt);
const sensorEvents = this.physics.drainSensorEvents();
this.cameraManager.handleSensorEvents(sensorEvents, PLAYER_BODY_ID);
this.interactions.handleSensorEvents(sensorEvents, PLAYER_BODY_ID);
this.cameraManager.update(dt, this.mover.state.position);
// Advance the walk cycle on the fixed tick, not per rendered frame, or the
// stride runs at double speed on a 120 Hz display.
this.avatar.update(this.mover.state.lastMoveDistance, dt);
if (this.character) {
// Edge-trigger on the same Space press the controller used, so the clip
// starts once rather than re-arming its unlock timer every fixed tick.
if (this.input.wasPressed("quickTurn")) {
this.character.playQuickTurn(this.controller.config.quickTurnDuration);
} else {
this.character.setSpeed(this.mover.state.lastMoveDistance * TICK_RATE);
}
this.character.update(dt);
}
for (const ambient of this.ambientCharacters) ambient.update(dt);
if (this.input.wasPressed("interact")) this.activateInteraction();
if (this.hostAuthority) this.remotePlayers?.syncFromAuthority(this.hostAuthority, dt);
const promptId = this.interactions.current?.id ?? null;
if (promptId !== this.lastPromptId) {
this.lastPromptId = promptId;
this.hudDirty = true;
}
if (this.messageTimer > 0) {
this.messageTimer = Math.max(0, this.messageTimer - dt);
if (this.messageTimer === 0) {
this.message = null;
this.hudDirty = true;
}
}
this.updateNetworking(dt);
this.input.endTick();
if (this.hudDirty) this.pushHud();
}
private updateNetworking(dt: number): void {
if (!this.net) return;
if (this.hostAuthority) {
// Host: simulate every guest, then publish at SNAPSHOT_HZ.
this.hostAuthority.update(dt);
if (this.hostAuthority.shouldSnapshot(dt)) {
const { tick, ack, players } = this.hostAuthority.buildSnapshot(this.hostPose());
this.net.broadcastState({ t: "snapshot", tick, ack, players });
}
return;
}
// Guest: publish intent at the same rate the host publishes state. Sending
// at 60 Hz would quadruple upstream traffic for no extra authority.
this.inputSendTimer += dt;
if (this.inputSendTimer < SNAPSHOT_INTERVAL) return;
this.inputSendTimer -= SNAPSHOT_INTERVAL;
const strafe = this.input.axis("left", "right");
const advance = this.input.axis("back", "forward");
const move =
this.controller.scheme === "tank"
? { x: Math.sin(this.controller.yaw) * advance, z: Math.cos(this.controller.yaw) * advance }
: { x: strafe, z: advance };
this.net.sendInput({
t: "input",
tick: this.guestInputTick++,
move,
run: this.input.isHeld("run"),
yaw: this.controller.yaw,
});
}
private activateInteraction(): void {
const target = this.interactions.current;
// A guest owns no world state: it asks the host, which re-checks reach and
// broadcasts the outcome back through `handleHostMessage`.
if (this.net && !this.hostAuthority) {
if (target) this.net.sendAction({ t: "interact", interactableId: target.id });
return;
}
const result = this.interactions.activate();
if (!result) return;
switch (result.type) {
case "message":
case "blocked":
this.setMessage(result.text);
break;
case "picked-up":
if (target) this.roomBuilder.removeInteractableVisual(target.id);
this.setMessage(result.text);
break;
case "flag-set":
if (target) this.roomBuilder.removeInteractableVisual(target.id);
this.setMessage(result.text);
break;
case "transition":
this.transitionTo(result.roomId, result.spawnPointId);
break;
}
// A host acting locally still owes the party an update.
if (this.hostAuthority && this.net) {
this.net.broadcastEvent({ t: "flags", set: this.flags.serialise(), unset: [] });
this.net.broadcastEvent({ t: "despawn", interactableIds: this.interactions.serialise() });
this.broadcastInventory();
}
}
/** Flag-gated geometry despawns here so saves and live play take one path. */
private applyFlagToWorld(flag: string): void {
if (!this.room) return;
for (const def of this.room.geometry) {
if (def.removeOnFlag === flag) this.roomBuilder.removeGeometry(def.id);
}
}
private setMessage(text: string): void {
this.message = text;
this.messageTimer = MESSAGE_DURATION;
this.hudDirty = true;
}
private render(alpha: number, frameDt: number): void {
// Interpolate between the last two ticks so motion is smooth above 60 Hz.
this.renderPosition.lerpVectors(this.previousPosition, this.mover.state.position, alpha);
const yaw = this.previousYaw + (this.controller.yaw - this.previousYaw) * alpha;
this.avatar.setTransform(this.renderPosition.x, this.renderPosition.y, this.renderPosition.z, yaw);
this.character?.setTransform(
this.renderPosition.x,
this.renderPosition.y,
this.renderPosition.z,
yaw,
);
// Guests interpolate remote peers; the host already placed them from its
// own simulation during fixedUpdate.
if (this.net && !this.hostAuthority) {
this.remotePlayers?.update(performance.now() / 1000, frameDt);
}
this.pipeline.render();
this.debugTimer += frameDt;
if (this.debugTimer >= DEBUG_INTERVAL) {
this.debugTimer = 0;
this.onDebug?.({
fps: Math.round(this.loop.fps),
activeZone: this.cameraManager.activeZone,
grounded: this.mover.state.grounded,
position: {
x: +this.mover.state.position.x.toFixed(2),
y: +this.mover.state.position.y.toFixed(2),
z: +this.mover.state.position.z.toFixed(2),
},
threadedPhysics: this.physics.threaded,
awakeBodies: this.physics.awakeBodyCount,
net: this.net
? {
role: this.hostAuthority ? "host" : "guest",
peers: this.net.connectedPeerCount,
drift: +this.lastDrift.toFixed(2),
}
: null,
});
}
}
private pushHud(): void {
this.hudDirty = false;
const current = this.interactions.current;
this.onHud?.({
prompt: current ? { label: current.label, verb: promptVerb(current.action.kind) } : null,
message: this.message,
// A guest shows the host's party inventory, since the host holds the
// only authoritative copy.
items: this.mirroredItems
? this.mirroredItems.flatMap(({ itemId, quantity }) => {
const def = ITEM_CATALOGUE.get(itemId);
return def
? [
{
id: def.id,
name: def.name,
description: def.description,
quantity,
isKeyItem: def.isKeyItem ?? false,
color: def.iconColor ?? 0x888888,
},
]
: [];
})
: this.inventory.list().map(({ def, quantity }) => ({
id: def.id,
name: def.name,
description: def.description,
quantity,
isKeyItem: def.isKeyItem ?? false,
color: def.iconColor ?? 0x888888,
})),
capacity: this.inventory.capacity,
usedSlots: this.inventory.usedSlots,
controlScheme: this.controller.scheme,
});
}
/** Called by the inventory UI. Returns a human-readable outcome. */
combineItems(itemA: string, itemB: string): string {
const result = this.inventory.combine(itemA, itemB);
if (!result) return "Those two don't go together.";
const def = ITEM_CATALOGUE.get(result.itemId);
this.setMessage(`Created ${def?.name ?? result.itemId}.`);
return `Created ${def?.name ?? result.itemId}.`;
}
setControlScheme(scheme: ControlScheme): void {
if (this.controller.scheme !== scheme) this.controller.toggleScheme();
this.hudDirty = true;
}
private observeResize(): void {
this.resizeObserver = new ResizeObserver(() => this.handleResize());
if (this.canvas.parentElement) this.resizeObserver.observe(this.canvas.parentElement);
}
private handleResize(): void {
const width = this.canvas.clientWidth || window.innerWidth;
const height = this.canvas.clientHeight || window.innerHeight;
this.cameraManager.setAspect(width / Math.max(1, height));
this.pipeline.setSize(width, height);
// Snap grid follows the virtual framebuffer so vertex wobble stays constant
// in virtual pixels rather than scaling with the window.
const snap = this.pipeline.snapResolution;
this.roomBuilder.setSnapResolution(snap.x, snap.y);
}
serialise(): SaveData {
const state = this.mover.state;
return {
version: SAVE_FORMAT_VERSION,
campaign: "demo",
player: {
roomId: this.room?.id ?? "unknown",
position: { x: state.position.x, y: state.position.y, z: state.position.z },
yaw: this.controller.yaw,
health: 100,
inventory: this.inventory.serialise(),
},
flags: this.flags.serialise(),
consumedInteractables: this.interactions.serialise(),
playtimeSeconds: Math.round(this.playtimeSeconds),
savedAt: new Date().toISOString(),
};
}
/** Local-only for Milestone 1; Milestone 2 swaps the transport for Neon. */
saveToLocalStorage(slot = 0): void {
localStorage.setItem(`psx-save-${slot}`, JSON.stringify(this.serialise()));
this.setMessage("Progress recorded.");
}
static blankSave(roomId: string): SaveData {
return emptySave("demo", roomId);
}
dispose(): void {
this.loop.stop();
this.detachInput?.();
this.resizeObserver?.disconnect();
this.net?.close();
this.hostAuthority?.clear();
this.remotePlayers?.clear();
this.builtRoom?.dispose();
this.character?.dispose();
this.clearAmbientCharacters();
this.avatar.dispose();
this.pipeline.dispose();
this.physics.dispose();
this.renderer.dispose();
}
}
/** Host-side reach check, mirroring the sensor volume the local player uses. */
function withinReach(position: Vec3, def: InteractableDef): boolean {
return (
Math.abs(position.x - def.position.x) <= def.halfExtents.x &&
Math.abs(position.y - def.position.y) <= def.halfExtents.y + 1 &&
Math.abs(position.z - def.position.z) <= def.halfExtents.z
);
}
function promptVerb(kind: string): string {
switch (kind) {
case "pickup":
return "Take";
case "door":
return "Open";
case "useItem":
return "Use";
default:
return "Examine";
}
}