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 { 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 { 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 { 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"; } }