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,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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user