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.
850 lines
29 KiB
TypeScript
850 lines
29 KiB
TypeScript
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";
|
|
}
|
|
}
|