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
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PSX Adventure Engine</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@psx/client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
"@psx/shared": "workspace:*",
"box3d-wasm": "^0.2.0",
"solid-js": "^1.9.14",
"three": "^0.185.1"
},
"devDependencies": {
"@types/three": "^0.185.1",
"typescript": "^5.9.3",
"vite": "^8.2.0",
"vite-plugin-solid": "^2.11.13",
"vitest": "^4.1.10"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+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";
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Fixed-timestep loop with a render interpolation factor.
*
* The simulation ticks at a constant 60 Hz regardless of display refresh. That
* matters for two reasons beyond the usual determinism argument: the character
* mover's ray-probe budget is tuned per tick, and the host-authoritative
* networking in Milestone 2 needs a stable tick index to tag state snapshots.
*/
export const TICK_RATE = 60;
export const FIXED_DT = 1 / TICK_RATE;
/** Beyond this, we stop trying to catch up and let time slip (tab was hidden). */
const MAX_FRAME_TIME = 0.25;
export interface LoopCallbacks {
/** Advance the simulation exactly one fixed step. */
fixedUpdate(dt: number, tick: number): void;
/** Draw. `alpha` is the 0..1 blend between the previous and current tick. */
render(alpha: number, frameDt: number): void;
}
export class GameLoop {
private running = false;
private rafHandle = 0;
private lastTime = 0;
private accumulator = 0;
private tick = 0;
/** Smoothed frames per second, for the debug overlay. */
fps = 0;
constructor(private readonly callbacks: LoopCallbacks) {}
start(): void {
if (this.running) return;
this.running = true;
this.lastTime = performance.now();
this.accumulator = 0;
this.rafHandle = requestAnimationFrame(this.frame);
}
stop(): void {
this.running = false;
cancelAnimationFrame(this.rafHandle);
}
private readonly frame = (now: number): void => {
if (!this.running) return;
this.rafHandle = requestAnimationFrame(this.frame);
const frameDt = Math.min((now - this.lastTime) / 1000, MAX_FRAME_TIME);
this.lastTime = now;
if (frameDt > 0) this.fps = this.fps * 0.9 + (1 / frameDt) * 0.1;
this.accumulator += frameDt;
while (this.accumulator >= FIXED_DT) {
this.callbacks.fixedUpdate(FIXED_DT, this.tick++);
this.accumulator -= FIXED_DT;
}
this.callbacks.render(this.accumulator / FIXED_DT, frameDt);
};
get currentTick(): number {
return this.tick;
}
}
+290
View File
@@ -0,0 +1,290 @@
import Box3D from "box3d-wasm";
import type { B3Body, B3Filter, B3Shape, B3World, Box3DModule } from "box3d-wasm";
import type { ColliderDef, ColliderMaterial, Quat, Vec3 } from "@psx/shared";
import { CollisionGroup, QUAT_IDENTITY, VEC3_ZERO } from "@psx/shared";
/**
* Thin ownership layer over box3d-wasm.
*
* box3d-wasm identifies everything in its event arrays by a numeric `userData`
* tag, and it keeps *separate* counters for bodies and shapes — so an
* auto-assigned body tag and shape tag can collide numerically. We therefore
* assign every tag ourselves from one counter and keep the reverse maps here.
*/
export interface PhysicsEntity {
/** Gameplay-side identifier, e.g. `zone:hallway-north` or `player`. */
id: string;
body: B3Body;
shapes: B3Shape[];
}
export interface RayHit {
point: Vec3;
normal: Vec3;
fraction: number;
distance: number;
entityId: string | null;
}
export interface SensorTransition {
sensorId: string;
visitorId: string;
}
export interface SensorEvents {
begin: SensorTransition[];
end: SensorTransition[];
}
export interface StaticBodyOptions {
id: string;
position?: Vec3;
rotation?: Quat;
colliders: ColliderDef[];
material?: ColliderMaterial;
group?: number;
}
export interface SensorOptions {
id: string;
position: Vec3;
halfExtents: Vec3;
group?: number;
}
export interface KinematicCapsuleOptions {
id: string;
position: Vec3;
radius: number;
height: number;
group?: number;
}
export class PhysicsWorld {
private module!: Box3DModule;
private world!: B3World;
private nextTag = 1;
private readonly entitiesByShapeTag = new Map<number, PhysicsEntity>();
private readonly entitiesById = new Map<string, PhysicsEntity>();
/** True when the threaded "deluxe" wasm build was loaded (needs COOP/COEP). */
threaded = false;
async init(gravity: Vec3 = { x: 0, y: -18, z: 0 }): Promise<void> {
this.module = await Box3D();
this.threaded = this.module.threaded;
this.world = new this.module.World({
gravity,
enableSleep: true,
enableContinuous: true,
...(this.threaded ? { workerCount: Math.min(4, this.module.maxWorkers) } : {}),
});
}
step(dt: number, subSteps = 4): void {
this.world.step(dt, subSteps);
}
private allocTag(): number {
return this.nextTag++;
}
private register(entity: PhysicsEntity): PhysicsEntity {
this.entitiesById.set(entity.id, entity);
for (const shape of entity.shapes) {
this.entitiesByShapeTag.set(shape.getUserData(), entity);
}
return entity;
}
/**
* Static level geometry. Box3D in this build has no triangle-mesh shape, so a
* room is a compound of boxes and convex hulls — which is also cheaper and
* closer to how PSX-era collision was actually authored.
*/
createStatic(options: StaticBodyOptions): PhysicsEntity {
const group = options.group ?? CollisionGroup.WORLD;
const body = this.world.createBody({
type: "static",
position: options.position ?? VEC3_ZERO,
rotation: options.rotation ?? QUAT_IDENTITY,
userData: this.allocTag(),
name: options.id,
});
const filter: B3Filter = { categoryBits: group, maskBits: 0xffffffff };
const shapes = options.colliders.map((collider) =>
this.addCollider(body, collider, filter, options.material),
);
return this.register({ id: options.id, body, shapes });
}
/** A non-colliding trigger volume: camera zones, interaction reach, doorways. */
createSensor(options: SensorOptions): PhysicsEntity {
const group = options.group ?? CollisionGroup.TRIGGER;
const body = this.world.createBody({
type: "static",
position: options.position,
userData: this.allocTag(),
name: options.id,
});
const shape = body.createBox({
halfExtents: options.halfExtents,
isSensor: true,
userData: this.allocTag(),
filter: { categoryBits: group, maskBits: CollisionGroup.PLAYER | CollisionGroup.ENEMY },
});
shape.enableSensorEvents(true);
return this.register({ id: options.id, body, shapes: [shape] });
}
/**
* The player capsule. Kinematic rather than dynamic: the mover computes the
* exact position each tick, so we want zero solver interference — no bouncing
* off walls, no sliding down slopes we did not ask to slide down.
*/
createKinematicCapsule(options: KinematicCapsuleOptions): PhysicsEntity {
const group = options.group ?? CollisionGroup.PLAYER;
const body = this.world.createBody({
type: "kinematic",
position: options.position,
userData: this.allocTag(),
name: options.id,
});
const shape = body.createCapsule({
radius: options.radius,
height: options.height,
density: 1,
friction: 0,
userData: this.allocTag(),
filter: { categoryBits: group, maskBits: 0xffffffff },
});
// Needed for the capsule to show up as a `visitor` in sensor events.
shape.enableSensorEvents(true);
shape.enableContactEvents(true);
return this.register({ id: options.id, body, shapes: [shape] });
}
private addCollider(
body: B3Body,
collider: ColliderDef,
filter: B3Filter,
material?: ColliderMaterial,
): B3Shape {
const common = {
userData: this.allocTag(),
filter,
friction: material?.friction ?? 0.6,
restitution: material?.restitution ?? 0,
density: material?.density ?? 1,
};
switch (collider.kind) {
case "box":
return body.createBox({
...common,
halfExtents: collider.halfExtents,
center: collider.offset ?? VEC3_ZERO,
rotation: collider.rotation ?? QUAT_IDENTITY,
});
case "sphere":
return body.createSphere({
...common,
radius: collider.radius,
center: collider.offset ?? VEC3_ZERO,
});
case "capsule":
return body.createCapsule({
...common,
radius: collider.radius,
height: collider.height,
center: collider.offset ?? VEC3_ZERO,
});
case "hull": {
const offset = collider.offset;
const points = offset
? collider.points.map((p) => ({ x: p.x + offset.x, y: p.y + offset.y, z: p.z + offset.z }))
: collider.points;
return body.createHull({ ...common, points });
}
}
}
/**
* Closest hit along a ray. This is the only spatial query box3d-wasm@0.2.0
* binds — there is no shape cast or overlap test — so the character mover is
* built entirely out of ray probes.
*/
raycast(origin: Vec3, direction: Vec3, maxDistance: number, mask: number): RayHit | null {
const translation = {
x: direction.x * maxDistance,
y: direction.y * maxDistance,
z: direction.z * maxDistance,
};
const result = this.world.castRayClosest(origin, translation, {
categoryBits: 0xffffffff,
maskBits: mask,
});
if (!result.hit) return null;
return {
point: result.point,
normal: result.normal,
fraction: result.fraction,
distance: result.fraction * maxDistance,
entityId: this.entitiesByShapeTag.get(result.shapeUserData)?.id ?? null,
};
}
/** Sensor begin/end transitions since the last call, resolved to entity ids. */
drainSensorEvents(): SensorEvents {
const raw = this.world.getSensorEvents();
const resolve = (events: { sensorUserData: number; visitorUserData: number }[]) =>
events.flatMap((event) => {
const sensor = this.entitiesByShapeTag.get(event.sensorUserData);
const visitor = this.entitiesByShapeTag.get(event.visitorUserData);
return sensor && visitor ? [{ sensorId: sensor.id, visitorId: visitor.id }] : [];
});
return { begin: resolve(raw.begin), end: resolve(raw.end) };
}
get(id: string): PhysicsEntity | undefined {
return this.entitiesById.get(id);
}
remove(id: string): void {
const entity = this.entitiesById.get(id);
if (!entity) return;
for (const shape of entity.shapes) {
this.entitiesByShapeTag.delete(shape.getUserData());
shape.delete();
}
entity.body.destroy();
entity.body.delete();
this.entitiesById.delete(id);
}
/** Tear down every registered entity but keep the world alive (room swap). */
clear(): void {
for (const id of [...this.entitiesById.keys()]) this.remove(id);
}
dispose(): void {
this.clear();
this.world.destroy();
this.world.delete();
}
get awakeBodyCount(): number {
return this.world.getAwakeBodyCount();
}
}
+9
View File
@@ -0,0 +1,9 @@
/* @refresh reload */
import { render } from "solid-js/web";
import App from "./ui/App.jsx";
import "./styles/index.css";
const root = document.getElementById("root");
if (!root) throw new Error("Missing #root element");
render(() => <App />, root);
+122
View File
@@ -0,0 +1,122 @@
/**
* REST client for auth, lobbies and cloud saves (GDD §9).
*
* The token lives in memory rather than localStorage: an XSS in a game that
* loads user-generated content later should not hand over the session too.
*/
export interface PublicUser {
id: string;
username: string;
displayName: string | null;
}
export interface LobbyPlayer {
peerId: number;
displayName: string;
isReady: boolean;
isHost: boolean;
}
export interface Lobby {
code: string;
status: "waiting" | "starting" | "in_game" | "closed";
maxPlayers: number;
players: LobbyPlayer[];
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "ApiError";
}
}
export class ApiClient {
private token: string | null = null;
user: PublicUser | null = null;
constructor(readonly baseUrl = import.meta.env["VITE_API_URL"] ?? "http://localhost:8787") {}
get isAuthenticated(): boolean {
return this.token !== null;
}
get sessionToken(): string | null {
return this.token;
}
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
"content-type": "application/json",
...((init.headers as Record<string, string>) ?? {}),
};
if (this.token) headers["authorization"] = `Bearer ${this.token}`;
const response = await fetch(`${this.baseUrl}${path}`, { ...init, headers });
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (!response.ok) {
throw new ApiError(
typeof body["error"] === "string" ? body["error"] : response.statusText,
response.status,
);
}
return body as T;
}
async register(username: string, email: string, password: string): Promise<PublicUser> {
const result = await this.request<{ token: string; user: PublicUser }>("/auth/register", {
method: "POST",
body: JSON.stringify({ username, email, password }),
});
this.token = result.token;
this.user = result.user;
return result.user;
}
async login(username: string, password: string): Promise<PublicUser> {
const result = await this.request<{ token: string; user: PublicUser }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
this.token = result.token;
this.user = result.user;
return result.user;
}
createLobby(): Promise<Lobby> {
return this.request<Lobby>("/lobbies", { method: "POST", body: "{}" });
}
joinLobby(code: string): Promise<Lobby> {
return this.request<Lobby>(`/lobbies/${code.toUpperCase()}/join`, { method: "POST" });
}
getLobby(code: string): Promise<Lobby> {
return this.request<Lobby>(`/lobbies/${code.toUpperCase()}`);
}
setReady(code: string, isReady: boolean): Promise<Lobby> {
return this.request<Lobby>(`/lobbies/${code.toUpperCase()}/ready`, {
method: "POST",
body: JSON.stringify({ isReady }),
});
}
startLobby(code: string): Promise<Lobby> {
return this.request<Lobby>(`/lobbies/${code.toUpperCase()}/start`, { method: "POST" });
}
leaveLobby(code: string): Promise<Lobby> {
return this.request<Lobby>(`/lobbies/${code.toUpperCase()}/leave`, { method: "POST" });
}
signOut(): void {
this.token = null;
this.user = null;
}
}
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import { Vector3 } from "three";
import { validateClientMessage } from "@psx/shared";
import { PhysicsWorld } from "../engine/PhysicsWorld.js";
import { FIXED_DT } from "../engine/GameLoop.js";
import { DEFAULT_MOVER_CONFIG } from "../systems/CharacterMover.js";
import { HostAuthority } from "./HostAuthority.js";
/**
* The host is the only thing standing between a modified client and the world
* state, so these tests are about what a hostile guest *cannot* do.
*/
async function makeHost() {
const physics = new PhysicsWorld();
await physics.init({ x: 0, y: DEFAULT_MOVER_CONFIG.gravity, z: 0 });
physics.createStatic({
id: "floor",
position: { x: 0, y: -0.15, z: 0 },
colliders: [{ kind: "box", halfExtents: { x: 30, y: 0.15, z: 30 } }],
});
return { physics, authority: new HostAuthority(physics) };
}
/** Runs the host loop for `seconds`, exactly as `Game.fixedUpdate` does. */
function run(physics: PhysicsWorld, authority: HostAuthority, seconds: number) {
for (let i = 0; i < Math.round(seconds / FIXED_DT); i++) {
authority.update(FIXED_DT);
physics.step(FIXED_DT);
}
}
describe("HostAuthority", () => {
it("simulates a guest from movement intent", async () => {
const { physics, authority } = await makeHost();
authority.addPlayer(1, new Vector3(0, 0, 0));
authority.applyInput(1, { t: "input", tick: 1, move: { x: 0, z: -1 }, run: false, yaw: 0 });
run(physics, authority, 1);
const state = authority.getPlayer(1)!.mover.state;
expect(state.position.z).toBeLessThan(-1);
expect(state.grounded).toBe(true);
physics.dispose();
});
it("collides a guest against walls it does not control", async () => {
const { physics, authority } = await makeHost();
physics.createStatic({
id: "wall",
position: { x: 0, y: 1.5, z: -3 },
colliders: [{ kind: "box", halfExtents: { x: 10, y: 1.5, z: 0.15 } }],
});
authority.addPlayer(1, new Vector3(0, 0, 0));
authority.applyInput(1, { t: "input", tick: 1, move: { x: 0, z: -1 }, run: true, yaw: 0 });
run(physics, authority, 4);
// The guest asked to keep walking; the host stopped it at the wall.
expect(authority.getPlayer(1)!.mover.state.position.z).toBeGreaterThan(-2.9);
physics.dispose();
});
it("gains no speed from a diagonal input claiming length greater than one", async () => {
const { physics, authority } = await makeHost();
authority.addPlayer(1, new Vector3(0, 0, 0));
authority.addPlayer(2, new Vector3(10, 0, 0));
// Peer 1 moves straight; peer 2 tries to out-run it diagonally.
authority.applyInput(1, { t: "input", tick: 1, move: { x: 0, z: -1 }, run: false, yaw: 0 });
authority.applyInput(2, { t: "input", tick: 1, move: { x: 1, z: -1 }, run: false, yaw: 0 });
run(physics, authority, 1);
const straight = authority.getPlayer(1)!.mover.state.position;
const diagonal = authority.getPlayer(2)!.mover.state.position;
const straightDistance = Math.hypot(straight.x, straight.z);
const diagonalDistance = Math.hypot(diagonal.x - 10, diagonal.z);
expect(diagonalDistance).toBeLessThanOrEqual(straightDistance + 0.05);
physics.dispose();
});
it("clamps a hostile input vector rather than teleporting", async () => {
const { physics, authority } = await makeHost();
authority.addPlayer(1, new Vector3(0, 0, 0));
// What a modified client would send to cross the map in one tick.
const hostile = validateClientMessage({
t: "input",
tick: 1,
move: { x: 0, z: -1e9 },
run: true,
yaw: 0,
});
expect(hostile.ok).toBe(true);
authority.applyInput(1, hostile.ok ? (hostile.value as never) : (undefined as never));
run(physics, authority, 1);
// One second of running is a few metres, not a few million.
expect(authority.getPlayer(1)!.mover.state.position.z).toBeGreaterThan(-10);
physics.dispose();
});
it("discards inputs that arrive out of order", async () => {
const { physics, authority } = await makeHost();
authority.addPlayer(1, new Vector3(0, 0, 0));
authority.applyInput(1, { t: "input", tick: 10, move: { x: 0, z: -1 }, run: false, yaw: 1 });
// Stale frame reordered by the unreliable channel.
authority.applyInput(1, { t: "input", tick: 4, move: { x: 1, z: 0 }, run: false, yaw: 99 });
expect(authority.getPlayer(1)!.yaw).toBe(1);
expect(authority.getPlayer(1)!.lastInputTick).toBe(10);
physics.dispose();
});
it("coasts a silent peer to a stop", async () => {
const { physics, authority } = await makeHost();
authority.addPlayer(1, new Vector3(0, 0, 0));
authority.applyInput(1, { t: "input", tick: 1, move: { x: 0, z: -1 }, run: true, yaw: 0 });
run(physics, authority, 5); // well past the silence limit
const settled = authority.getPlayer(1)!.mover.state.position.z;
run(physics, authority, 2);
expect(authority.getPlayer(1)!.mover.state.position.z).toBeCloseTo(settled, 1);
physics.dispose();
});
it("emits snapshots at the advertised rate", async () => {
const { physics, authority } = await makeHost();
let emitted = 0;
for (let i = 0; i < 60; i++) {
if (authority.shouldSnapshot(FIXED_DT)) emitted++;
}
// 60 ticks is one second, so SNAPSHOT_HZ snapshots.
expect(emitted).toBe(15);
physics.dispose();
});
it("removes a departed peer's body from the world", async () => {
const { physics, authority } = await makeHost();
authority.addPlayer(1, new Vector3(0, 0, 0));
expect(physics.get("player:1")).toBeDefined();
authority.removePlayer(1);
expect(physics.get("player:1")).toBeUndefined();
expect(authority.getPlayer(1)).toBeUndefined();
physics.dispose();
});
});
+169
View File
@@ -0,0 +1,169 @@
import { Vector3 } from "three";
import type { ClientMessage, PeerId, PlayerSnapshot } from "@psx/shared";
import { SNAPSHOT_INTERVAL } from "@psx/shared";
import type { PhysicsWorld } from "../engine/PhysicsWorld.js";
import { CharacterMover, DEFAULT_MOVER_CONFIG } from "../systems/CharacterMover.js";
import { DEFAULT_PLAYER_CONFIG } from "../systems/PlayerController.js";
/**
* Host-side simulation of every remote player — GDD §8.
*
* The host runs a full `CharacterMover` per guest against its own physics
* world. Guests send *intent* (a movement vector), never position, so a
* modified client cannot walk through walls or cross the map: the worst it can
* do is hold a direction the host will collision-resolve like any other.
*
* Inputs are consumed at the host's fixed tick rate, not at the rate they
* arrive. A guest flooding inputs therefore gains no speed advantage — the
* extra frames simply overwrite the pending intent.
*/
export interface RemotePlayerState {
peerId: PeerId;
mover: CharacterMover;
/** Latest intent, replaced rather than queued. */
desiredMove: Vector3;
run: boolean;
yaw: number;
lastInputTick: number;
/** Ticks since we last heard anything; drives idle-out. */
silentTicks: number;
}
/** ~2 seconds at 60 Hz. A peer this quiet has stalled or dropped. */
const SILENT_TICK_LIMIT = 120;
export class HostAuthority {
private readonly players = new Map<PeerId, RemotePlayerState>();
private snapshotTimer = 0;
private tick = 0;
constructor(private readonly physics: PhysicsWorld) {}
/** Spawn a mover for a guest that just connected. */
addPlayer(peerId: PeerId, spawn: Vector3): RemotePlayerState {
const existing = this.players.get(peerId);
if (existing) return existing;
const bodyId = `player:${peerId}`;
const totalHeight = DEFAULT_MOVER_CONFIG.cylinderHeight + DEFAULT_MOVER_CONFIG.radius * 2;
this.physics.createKinematicCapsule({
id: bodyId,
position: { x: spawn.x, y: spawn.y + totalHeight / 2, z: spawn.z },
radius: DEFAULT_MOVER_CONFIG.radius,
height: DEFAULT_MOVER_CONFIG.cylinderHeight,
});
const state: RemotePlayerState = {
peerId,
mover: new CharacterMover(this.physics, bodyId, spawn.clone()),
desiredMove: new Vector3(),
run: false,
yaw: 0,
lastInputTick: 0,
silentTicks: 0,
};
this.players.set(peerId, state);
return state;
}
removePlayer(peerId: PeerId): void {
if (!this.players.delete(peerId)) return;
this.physics.remove(`player:${peerId}`);
}
/** Record a guest's latest intent. Already validated by `NetSession`. */
applyInput(peerId: PeerId, message: Extract<ClientMessage, { t: "input" }>): void {
const player = this.players.get(peerId);
if (!player) return;
// Out-of-order arrival on the unreliable channel: keep the newest only.
if (message.tick < player.lastInputTick) return;
player.lastInputTick = message.tick;
player.silentTicks = 0;
player.run = message.run;
player.yaw = message.yaw;
// Re-normalise: the validator clamps each axis, so a diagonal could still
// arrive at length √2 and out-run an axis-aligned one.
const move = player.desiredMove.set(message.move.x, 0, message.move.z);
if (move.lengthSq() > 1) move.normalize();
}
/** Advance every remote player one fixed tick. */
update(dt: number): void {
this.tick++;
for (const player of this.players.values()) {
player.silentTicks++;
// A silent peer coasts to a stop rather than sliding on forever.
if (player.silentTicks > SILENT_TICK_LIMIT) {
player.desiredMove.set(0, 0, 0);
}
const speed = player.run ? DEFAULT_PLAYER_CONFIG.runSpeed : DEFAULT_PLAYER_CONFIG.walkSpeed;
player.mover.update(
new Vector3(player.desiredMove.x * speed, 0, player.desiredMove.z * speed),
dt,
);
}
}
/** True when it is time to emit a snapshot, at `SNAPSHOT_HZ`. */
shouldSnapshot(dt: number): boolean {
this.snapshotTimer += dt;
if (this.snapshotTimer < SNAPSHOT_INTERVAL) return false;
this.snapshotTimer -= SNAPSHOT_INTERVAL;
return true;
}
/** Build the authoritative snapshot, including the host's own pose. */
buildSnapshot(hostPose: { position: Vector3; yaw: number; speed: number; grounded: boolean }): {
tick: number;
ack: Record<PeerId, number>;
players: PlayerSnapshot[];
} {
const players: PlayerSnapshot[] = [
{
peerId: 0,
p: { x: round(hostPose.position.x), y: round(hostPose.position.y), z: round(hostPose.position.z) },
yaw: round(hostPose.yaw),
spd: round(hostPose.speed),
grounded: hostPose.grounded,
},
];
const ack: Record<PeerId, number> = {};
for (const player of this.players.values()) {
const state = player.mover.state;
players.push({
peerId: player.peerId,
p: { x: round(state.position.x), y: round(state.position.y), z: round(state.position.z) },
yaw: round(player.yaw),
spd: round(state.lastMoveDistance * 60),
grounded: state.grounded,
});
ack[player.peerId] = player.lastInputTick;
}
return { tick: this.tick, ack, players };
}
getPlayer(peerId: PeerId): RemotePlayerState | undefined {
return this.players.get(peerId);
}
get peerIds(): PeerId[] {
return [...this.players.keys()];
}
clear(): void {
for (const peerId of [...this.players.keys()]) this.removePlayer(peerId);
}
}
/** Three decimals is well under a PSX pixel and roughly halves snapshot size. */
const round = (value: number): number => Math.round(value * 1000) / 1000;
+186
View File
@@ -0,0 +1,186 @@
import type { ClientMessage, GameMessage, HostMessage, PeerId, PeerInfo } from "@psx/shared";
import { validateClientMessage, validateHostMessage } from "@psx/shared";
import { PeerLink, type PeerLinkState } from "./PeerLink.js";
import { SignalingClient } from "./SignalingClient.js";
/**
* Transport layer for a co-op session — GDD §8.
*
* Topology is a star centred on the host, not a mesh. The host is
* authoritative, so a guest-to-guest link would carry nothing either side is
* permitted to act on; every guest holds exactly one link, to peer 0.
*
* This class is role-agnostic: it moves validated messages and reports
* membership. What to *do* with a message is `HostAuthority`'s or the guest
* session's problem.
*/
export const HOST_PEER_ID = 0;
export class NetSession {
private readonly signaling: SignalingClient;
private readonly links = new Map<PeerId, PeerLink>();
private readonly peers = new Map<PeerId, PeerInfo>();
localPeerId: PeerId = -1;
isHost = false;
/** Host side: a guest sent input or an action. Already validated. */
onClientMessage: ((message: ClientMessage, from: PeerId) => void) | null = null;
/** Guest side: the host sent authoritative state. Already validated. */
onHostMessage: ((message: HostMessage) => void) | null = null;
onPeersChanged: ((peers: PeerInfo[]) => void) | null = null;
onLinkStateChanged: ((peerId: PeerId, state: PeerLinkState) => void) | null = null;
onFatal: ((message: string) => void) | null = null;
/**
* World-level peer lifecycle, distinct from the transport-level membership
* in `onPeersChanged`. `Game` uses these to spawn and despawn bodies, and
* only wants them once the peer is actually reachable.
*/
onPeerJoinedWorld: ((peerId: PeerId) => void) | null = null;
onPeerLeftWorld: ((peerId: PeerId) => void) | null = null;
constructor(
signalingUrl: string,
private readonly iceServers?: RTCIceServer[],
) {
this.signaling = new SignalingClient(signalingUrl, {
onWelcome: (peerId, isHost, peers) => {
this.localPeerId = peerId;
this.isHost = isHost;
for (const peer of peers) this.peers.set(peer.peerId, peer);
// The host opens connections to everyone already present; a guest
// waits to be offered, so both sides never offer simultaneously.
if (isHost) {
for (const peer of peers) {
if (peer.peerId !== peerId) this.openLink(peer.peerId, true);
}
}
this.onPeersChanged?.(this.peerList());
},
onPeerJoined: (peer) => {
this.peers.set(peer.peerId, peer);
if (this.isHost) this.openLink(peer.peerId, true);
this.onPeersChanged?.(this.peerList());
},
onPeerLeft: (peerId) => {
this.peers.delete(peerId);
this.links.get(peerId)?.close();
this.links.delete(peerId);
this.onPeerLeftWorld?.(peerId);
this.onPeersChanged?.(this.peerList());
},
onPeerUpdated: (peer) => {
this.peers.set(peer.peerId, peer);
this.onPeersChanged?.(this.peerList());
},
onSignal: (from, payload) => {
// A guest learns of the host by being offered, so create on demand.
const link = this.links.get(from) ?? this.openLink(from, false);
void link.handleSignal(payload);
},
onStart: () => {
/* Room transitions are driven by the host over the data channel. */
},
onError: (message) => this.onFatal?.(message),
onClose: () => {
/* Data channels outlive the signalling socket; not fatal on its own. */
},
});
}
connect(token: string, lobbyCode: string): Promise<void> {
return this.signaling.connect(token, lobbyCode);
}
private openLink(peerId: PeerId, isInitiator: boolean): PeerLink {
const existing = this.links.get(peerId);
if (existing) return existing;
const link = new PeerLink({
peerId,
isInitiator,
sendSignal: (payload) => this.signaling.sendSignal(peerId, payload),
...(this.iceServers ? { iceServers: this.iceServers } : {}),
});
link.onMessage = (message, from) => this.route(message, from);
link.onStateChange = (state, id) => {
// Spawn a body only once the channel is actually usable — announcing on
// `peer-joined` would create a body for a peer that may never connect.
if (state === "connected" && this.isHost) this.onPeerJoinedWorld?.(id);
if (state === "closed" || state === "failed") this.onPeerLeftWorld?.(id);
this.onLinkStateChanged?.(id, state);
};
this.links.set(peerId, link);
return link;
}
/**
* Validate before dispatch. The host must never act on unvalidated guest
* input, and a guest should not be crashable by a malformed host frame.
*/
private route(message: GameMessage, from: PeerId): void {
if (this.isHost) {
const result = validateClientMessage(message);
if (result.ok) this.onClientMessage?.(result.value, from);
return;
}
// Guests accept authoritative state only from the host's link.
if (from !== HOST_PEER_ID) return;
const result = validateHostMessage(message);
if (result.ok) this.onHostMessage?.(result.value);
}
/** Host: unreliable broadcast, for state superseded by the next frame. */
broadcastState(message: HostMessage): void {
for (const link of this.links.values()) link.sendState(message);
}
/** Host: reliable broadcast, for state that must not be dropped. */
broadcastEvent(message: HostMessage): void {
for (const link of this.links.values()) link.sendEvent(message);
}
sendToPeer(peerId: PeerId, message: HostMessage): void {
this.links.get(peerId)?.sendEvent(message);
}
/** Guest: input goes on the unreliable channel, actions on the reliable one. */
sendInput(message: ClientMessage): void {
this.links.get(HOST_PEER_ID)?.sendState(message);
}
sendAction(message: ClientMessage): void {
this.links.get(HOST_PEER_ID)?.sendEvent(message);
}
setReady(isReady: boolean): void {
this.signaling.setReady(isReady);
}
peerList(): PeerInfo[] {
return [...this.peers.values()].sort((a, b) => a.peerId - b.peerId);
}
get connectedPeerCount(): number {
return [...this.links.values()].filter((link) => link.isOpen).length;
}
close(): void {
for (const link of this.links.values()) link.close();
this.links.clear();
this.peers.clear();
this.signaling.close();
}
}
+194
View File
@@ -0,0 +1,194 @@
import type { GameMessage, PeerId, SignalPayload } from "@psx/shared";
/**
* One WebRTC connection to one peer.
*
* GDD §4 and §8 name PeerJS or simple-peer, but both are a poor fit here:
* simple-peer depends on Node's `buffer` and `readable-stream` (polyfill pain
* under Vite), and PeerJS is built around its own broker, which would replace
* the custom WebSocket signalling §4 explicitly calls for. What both libraries
* would give us is a data channel after an SDP exchange, and that is a thin
* wrapper over `RTCPeerConnection` — so this is that wrapper, with no
* dependencies.
*
* Two channels, because gameplay traffic has two different delivery needs:
*
* - `state` unreliable, unordered — position snapshots. A snapshot that
* arrives late has already been superseded, so retransmitting it
* adds latency and tells us nothing new.
* - `event` reliable, ordered — flags, despawns, inventory, chat. Dropping
* "the shutter opened" would desynchronise the world permanently.
*/
export interface PeerLinkOptions {
peerId: PeerId;
/** The offerer. In our star topology the host initiates to each guest. */
isInitiator: boolean;
sendSignal(payload: SignalPayload): void;
iceServers?: RTCIceServer[];
}
export const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
{ urls: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] },
];
export type PeerLinkState = "connecting" | "connected" | "closed" | "failed";
export class PeerLink {
readonly peerId: PeerId;
private readonly connection: RTCPeerConnection;
private readonly isInitiator: boolean;
private readonly sendSignal: (payload: SignalPayload) => void;
private stateChannel: RTCDataChannel | null = null;
private eventChannel: RTCDataChannel | null = null;
/** ICE can arrive before the remote description is set; queue until it is. */
private pendingCandidates: RTCIceCandidateInit[] = [];
private remoteDescriptionSet = false;
state: PeerLinkState = "connecting";
onMessage: ((message: GameMessage, from: PeerId) => void) | null = null;
onStateChange: ((state: PeerLinkState, peerId: PeerId) => void) | null = null;
constructor(options: PeerLinkOptions) {
this.peerId = options.peerId;
this.isInitiator = options.isInitiator;
this.sendSignal = options.sendSignal;
this.connection = new RTCPeerConnection({
iceServers: options.iceServers ?? DEFAULT_ICE_SERVERS,
});
this.connection.onicecandidate = (event) => {
if (!event.candidate) return;
this.sendSignal({
ice: {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
},
});
};
this.connection.onconnectionstatechange = () => {
switch (this.connection.connectionState) {
case "connected":
this.setState("connected");
break;
case "failed":
this.setState("failed");
break;
case "closed":
case "disconnected":
this.setState("closed");
break;
}
};
if (this.isInitiator) {
this.stateChannel = this.setupChannel(
this.connection.createDataChannel("state", {
ordered: false,
maxRetransmits: 0,
}),
);
this.eventChannel = this.setupChannel(
this.connection.createDataChannel("event", { ordered: true }),
);
void this.createOffer();
} else {
this.connection.ondatachannel = (event) => {
if (event.channel.label === "state") this.stateChannel = this.setupChannel(event.channel);
else this.eventChannel = this.setupChannel(event.channel);
};
}
}
private setState(state: PeerLinkState): void {
if (this.state === state) return;
this.state = state;
this.onStateChange?.(state, this.peerId);
}
private setupChannel(channel: RTCDataChannel): RTCDataChannel {
channel.onmessage = (event) => {
try {
this.onMessage?.(JSON.parse(event.data as string) as GameMessage, this.peerId);
} catch {
// A peer sending unparseable data is not worth tearing the link down.
}
};
return channel;
}
private async createOffer(): Promise<void> {
const offer = await this.connection.createOffer();
await this.connection.setLocalDescription(offer);
this.sendSignal({ sdp: { type: "offer", sdp: offer.sdp ?? "" } });
}
/** Feed a relayed signalling payload from this peer. */
async handleSignal(payload: SignalPayload): Promise<void> {
if (payload.sdp) {
await this.connection.setRemoteDescription({
type: payload.sdp.type,
sdp: payload.sdp.sdp,
});
this.remoteDescriptionSet = true;
// Candidates that raced ahead of the description can now be applied.
for (const candidate of this.pendingCandidates) {
await this.connection.addIceCandidate(candidate).catch(() => undefined);
}
this.pendingCandidates = [];
if (payload.sdp.type === "offer") {
const answer = await this.connection.createAnswer();
await this.connection.setLocalDescription(answer);
this.sendSignal({ sdp: { type: "answer", sdp: answer.sdp ?? "" } });
}
return;
}
if (payload.ice) {
const candidate: RTCIceCandidateInit = {
candidate: payload.ice.candidate,
sdpMid: payload.ice.sdpMid,
sdpMLineIndex: payload.ice.sdpMLineIndex,
};
if (!this.remoteDescriptionSet) {
this.pendingCandidates.push(candidate);
return;
}
await this.connection.addIceCandidate(candidate).catch(() => undefined);
}
}
/** Snapshots and other superseded-on-arrival traffic. */
sendState(message: GameMessage): void {
if (this.stateChannel?.readyState === "open") {
this.stateChannel.send(JSON.stringify(message));
}
}
/** World state that must not be dropped. */
sendEvent(message: GameMessage): void {
if (this.eventChannel?.readyState === "open") {
this.eventChannel.send(JSON.stringify(message));
}
}
get isOpen(): boolean {
return this.eventChannel?.readyState === "open";
}
close(): void {
this.stateChannel?.close();
this.eventChannel?.close();
this.connection.close();
this.setState("closed");
}
}
+124
View File
@@ -0,0 +1,124 @@
import { Color, Scene } from "three";
import type { PeerId, PlayerSnapshot } from "@psx/shared";
import { PlayerAvatar } from "../world/PlayerAvatar.js";
import { SnapshotBuffer } from "./SnapshotBuffer.js";
import type { HostAuthority } from "./HostAuthority.js";
/**
* Avatars for everyone who is not the local player.
*
* Host and guest reach the same visual result by different routes:
*
* - The **host** already simulates every guest at 60 Hz, so it reads poses
* straight out of `HostAuthority`. Routing them through the interpolation
* buffer would add ~133ms of lag to information it already has exactly.
* - A **guest** only receives 15 Hz snapshots, so it interpolates.
*/
/** Distinct enough to read at 240p without a palette that fights the fog. */
const PEER_COLORS = [0x9aa3b2, 0xb2907a, 0x7fa382, 0xa88bb0];
interface RemotePlayer {
peerId: PeerId;
avatar: PlayerAvatar;
buffer: SnapshotBuffer;
lastSpeed: number;
}
export class RemotePlayers {
private readonly players = new Map<PeerId, RemotePlayer>();
constructor(
private readonly scene: Scene,
private readonly avatarHeight: number,
) {}
private ensure(peerId: PeerId): RemotePlayer {
const existing = this.players.get(peerId);
if (existing) return existing;
const avatar = new PlayerAvatar({
totalHeight: this.avatarHeight,
color: PEER_COLORS[peerId % PEER_COLORS.length]!,
});
this.scene.add(avatar.group);
const player: RemotePlayer = { peerId, avatar, buffer: new SnapshotBuffer(), lastSpeed: 0 };
this.players.set(peerId, player);
return player;
}
/** Guest path: buffer an incoming snapshot for later interpolation. */
ingestSnapshot(snapshots: PlayerSnapshot[], localPeerId: PeerId, now: number): void {
const seen = new Set<PeerId>();
for (const snapshot of snapshots) {
if (snapshot.peerId === localPeerId) continue;
seen.add(snapshot.peerId);
this.ensure(snapshot.peerId).buffer.push(snapshot, now);
}
// A peer absent from the authoritative snapshot has left the session.
for (const peerId of [...this.players.keys()]) {
if (!seen.has(peerId)) this.remove(peerId);
}
}
/** Host path: read exact poses out of its own simulation. */
syncFromAuthority(authority: HostAuthority, dt: number): void {
const seen = new Set<PeerId>();
for (const peerId of authority.peerIds) {
const remote = authority.getPlayer(peerId);
if (!remote) continue;
seen.add(peerId);
const player = this.ensure(peerId);
const state = remote.mover.state;
player.avatar.setTransform(state.position.x, state.position.y, state.position.z, remote.yaw);
player.avatar.update(state.lastMoveDistance, dt);
}
for (const peerId of [...this.players.keys()]) {
if (!seen.has(peerId)) this.remove(peerId);
}
}
/** Guest path: sample the buffers and drive the avatars. */
update(now: number, dt: number): void {
for (const player of this.players.values()) {
const pose = player.buffer.sample(now);
if (!pose) continue;
player.avatar.setTransform(pose.position.x, pose.position.y, pose.position.z, pose.yaw);
// Speed is metres/second; the walk cycle wants distance for this frame.
player.avatar.update(pose.fresh ? pose.speed * dt : 0, dt);
player.lastSpeed = pose.speed;
}
}
remove(peerId: PeerId): void {
const player = this.players.get(peerId);
if (!player) return;
this.scene.remove(player.avatar.group);
player.avatar.dispose();
this.players.delete(peerId);
}
clear(): void {
for (const peerId of [...this.players.keys()]) this.remove(peerId);
}
get count(): number {
return this.players.size;
}
/** Peer colour, so the lobby list and the HUD agree with the world. */
static colorFor(peerId: PeerId): number {
return PEER_COLORS[peerId % PEER_COLORS.length]!;
}
static cssColorFor(peerId: PeerId): string {
return `#${new Color(RemotePlayers.colorFor(peerId)).getHexString()}`;
}
}
+101
View File
@@ -0,0 +1,101 @@
import type { PeerId, PeerInfo, SignalMessage, SignalPayload } from "@psx/shared";
import { PROTOCOL_VERSION } from "@psx/shared";
/**
* WebSocket link to the signalling server (GDD §8).
*
* Carries SDP/ICE and lobby membership only — never gameplay. Once the data
* channels are open this socket is idle apart from join/leave notifications,
* which is why losing it mid-session is survivable and not treated as fatal.
*/
export interface SignalingHandlers {
onWelcome(peerId: PeerId, isHost: boolean, peers: PeerInfo[]): void;
onPeerJoined(peer: PeerInfo): void;
onPeerLeft(peerId: PeerId): void;
onPeerUpdated(peer: PeerInfo): void;
onSignal(from: PeerId, payload: SignalPayload): void;
onStart(roomId: string, spawnPointId: string): void;
onError(message: string): void;
onClose(): void;
}
export class SignalingClient {
private socket: WebSocket | null = null;
constructor(
private readonly url: string,
private readonly handlers: SignalingHandlers,
) {}
connect(token: string, lobbyCode: string): Promise<void> {
return new Promise((resolve, reject) => {
const socket = new WebSocket(this.url);
this.socket = socket;
socket.onopen = () => {
socket.send(
JSON.stringify({ t: "hello", token, lobbyCode, protocol: PROTOCOL_VERSION }),
);
};
socket.onmessage = (event) => {
let message: SignalMessage;
try {
message = JSON.parse(event.data as string) as SignalMessage;
} catch {
return;
}
switch (message.t) {
case "welcome":
this.handlers.onWelcome(message.peerId, message.isHost, message.peers);
resolve();
break;
case "peer-joined":
this.handlers.onPeerJoined(message.peer);
break;
case "peer-left":
this.handlers.onPeerLeft(message.peerId);
break;
case "peer-updated":
this.handlers.onPeerUpdated(message.peer);
break;
case "signal":
this.handlers.onSignal(message.from, message.payload);
break;
case "start":
this.handlers.onStart(message.roomId, message.spawnPointId);
break;
case "error":
this.handlers.onError(message.message);
// An error before `welcome` means the handshake failed outright.
reject(new Error(message.message));
break;
}
};
socket.onerror = () => reject(new Error("signalling connection failed"));
socket.onclose = () => this.handlers.onClose();
});
}
sendSignal(to: PeerId, payload: SignalPayload): void {
this.send({ t: "signal", from: -1, to, payload });
}
setReady(isReady: boolean): void {
this.send({ t: "ready", isReady });
}
private send(message: SignalMessage): void {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
}
}
close(): void {
this.socket?.close();
this.socket = null;
}
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { PlayerSnapshot } from "@psx/shared";
import { INTERPOLATION_DELAY, SnapshotBuffer } from "./SnapshotBuffer.js";
/**
* Interpolation is where a networked game visibly succeeds or fails, and it is
* pure arithmetic over a buffer — so it is worth testing directly rather than
* discovering the bugs by watching an avatar stutter.
*/
const snap = (peerId: number, x: number, yaw = 0, spd = 0): PlayerSnapshot => ({
peerId,
p: { x, y: 0, z: 0 },
yaw,
spd,
grounded: true,
});
describe("SnapshotBuffer", () => {
it("returns nothing while empty", () => {
expect(new SnapshotBuffer().sample(10)).toBeNull();
});
it("interpolates halfway between two snapshots", () => {
const buffer = new SnapshotBuffer();
buffer.push(snap(1, 0), 1);
buffer.push(snap(1, 10), 2);
// Render time sits exactly between the two samples.
const pose = buffer.sample(1.5 + INTERPOLATION_DELAY);
expect(pose).not.toBeNull();
expect(pose!.position.x).toBeCloseTo(5, 5);
expect(pose!.fresh).toBe(true);
});
it("takes the short way around when yaw wraps past PI", () => {
const buffer = new SnapshotBuffer();
// 170° → -170° is a 20° step forward, not 340° backward.
buffer.push(snap(1, 0, (170 * Math.PI) / 180), 1);
buffer.push(snap(1, 0, (-170 * Math.PI) / 180), 2);
const pose = buffer.sample(1.5 + INTERPOLATION_DELAY)!;
const degrees = (pose.yaw * 180) / Math.PI;
// Halfway is 180° (equivalently -180°), never near 0°.
expect(Math.abs(Math.abs(degrees) - 180)).toBeLessThan(1);
});
it("holds the oldest pose before the buffer has filled", () => {
const buffer = new SnapshotBuffer();
buffer.push(snap(1, 42), 100);
// Render time is well before anything we hold.
const pose = buffer.sample(100)!;
expect(pose.position.x).toBe(42);
});
it("marks a pose stale once the sender stops updating", () => {
const buffer = new SnapshotBuffer();
buffer.push(snap(1, 7), 1);
const soon = buffer.sample(1 + INTERPOLATION_DELAY + 0.05)!;
expect(soon.fresh).toBe(true);
// Half a second past the newest snapshot: the peer has clearly stalled.
const late = buffer.sample(1 + INTERPOLATION_DELAY + 0.5)!;
expect(late.fresh).toBe(false);
expect(late.position.x).toBe(7);
});
it("ignores snapshots that arrive out of order", () => {
const buffer = new SnapshotBuffer();
buffer.push(snap(1, 0), 1);
buffer.push(snap(1, 10), 2);
// Late arrival from before the newest; interpolating through it would
// rewind the avatar.
buffer.push(snap(1, 999), 1.5);
expect(buffer.size).toBe(2);
const pose = buffer.sample(1.5 + INTERPOLATION_DELAY)!;
expect(pose.position.x).toBeCloseTo(5, 5);
});
it("bounds memory under a long session", () => {
const buffer = new SnapshotBuffer();
for (let i = 0; i < 5000; i++) buffer.push(snap(1, i), i);
expect(buffer.size).toBeLessThanOrEqual(32);
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { PlayerSnapshot, Vec3 } from "@psx/shared";
import { angleDelta, SNAPSHOT_INTERVAL } from "@psx/shared";
/**
* Entity interpolation for remote players — GDD §8.
*
* Snapshots arrive at 15 Hz over an unreliable channel, so rendering them
* directly would be visibly steppy and would stutter on every dropped packet.
* Instead we render remote peers slightly in the past — far enough back that
* two snapshots almost always bracket the render time — and interpolate.
*
* The delay is two snapshot intervals (~133ms). One interval is the minimum
* that can bracket at all; two absorbs a single dropped packet without the
* avatar freezing, which is the common case on a home connection.
*/
export const INTERPOLATION_DELAY = SNAPSHOT_INTERVAL * 2;
/** Past this far behind, we stop interpolating and hold the last known pose. */
const MAX_EXTRAPOLATION = 0.25;
interface TimedSnapshot {
time: number;
snapshot: PlayerSnapshot;
}
export interface InterpolatedPose {
position: Vec3;
yaw: number;
speed: number;
grounded: boolean;
/** False when the buffer has run dry and the pose is stale. */
fresh: boolean;
}
export class SnapshotBuffer {
private readonly buffer: TimedSnapshot[] = [];
/** Two intervals of history is all interpolation can reach back through. */
private readonly capacity = 32;
push(snapshot: PlayerSnapshot, time: number): void {
// Reordering is expected on an unordered channel; drop anything older than
// what we already hold rather than interpolating backwards through it.
const newest = this.buffer[this.buffer.length - 1];
if (newest && time <= newest.time) return;
this.buffer.push({ time, snapshot });
if (this.buffer.length > this.capacity) this.buffer.shift();
}
/** Sample the pose to render at wall-clock `now`. */
sample(now: number): InterpolatedPose | null {
if (this.buffer.length === 0) return null;
const renderTime = now - INTERPOLATION_DELAY;
const oldest = this.buffer[0]!;
const newest = this.buffer[this.buffer.length - 1]!;
// Buffer has not filled yet — show the oldest we have rather than nothing.
if (renderTime <= oldest.time) return pose(oldest.snapshot, true);
// Ahead of everything we hold: the sender stalled or packets were lost.
if (renderTime >= newest.time) {
const staleness = renderTime - newest.time;
return pose(newest.snapshot, staleness < MAX_EXTRAPOLATION);
}
for (let i = this.buffer.length - 1; i > 0; i--) {
const after = this.buffer[i]!;
const before = this.buffer[i - 1]!;
if (renderTime < before.time || renderTime > after.time) continue;
const span = after.time - before.time;
const t = span > 1e-6 ? (renderTime - before.time) / span : 0;
return interpolate(before.snapshot, after.snapshot, t);
}
return pose(newest.snapshot, true);
}
clear(): void {
this.buffer.length = 0;
}
get size(): number {
return this.buffer.length;
}
}
function pose(snapshot: PlayerSnapshot, fresh: boolean): InterpolatedPose {
return {
position: { ...snapshot.p },
yaw: snapshot.yaw,
speed: snapshot.spd,
grounded: snapshot.grounded,
fresh,
};
}
function interpolate(a: PlayerSnapshot, b: PlayerSnapshot, t: number): InterpolatedPose {
return {
position: {
x: a.p.x + (b.p.x - a.p.x) * t,
y: a.p.y + (b.p.y - a.p.y) * t,
z: a.p.z + (b.p.z - a.p.z) * t,
},
// Shortest path, so a peer turning through ±π does not spin the long way.
yaw: a.yaw + angleDelta(a.yaw, b.yaw) * t,
speed: a.spd + (b.spd - a.spd) * t,
grounded: t < 0.5 ? a.grounded : b.grounded,
fresh: true,
};
}
+111
View File
@@ -0,0 +1,111 @@
import { Vector2 } from "three";
import type { Material, Texture, WebGLProgramParametersWithUniforms } from "three";
/**
* Per-material half of the PSX look — GDD §11.
*
* Two effects, both applied by patching the built-in shaders rather than
* writing new ones, so materials keep three's lighting, fog and shadow chunks:
*
* 1. **Vertex snapping.** The PSX GTE had no sub-pixel precision, so vertices
* landed on a coarse screen grid and geometry visibly wobbled. We quantise
* NDC xy after projection.
*
* 2. **Affine texture mapping.** The console had no perspective divide per
* pixel, so textures warped across large triangles. GLSL ES 3.00 has no
* `noperspective` qualifier, so we defeat the hardware's perspective-correct
* interpolation arithmetically: pass `uv * w` and `w` as varyings, then
* divide in the fragment shader. The interpolator's own 1/w factors cancel
* and what survives is screen-linear — exactly the artefact we want.
*/
export interface PSXMaterialOptions {
/** Snap grid, in virtual pixels. Lower is wobblier. */
snapResolution?: Vector2;
vertexSnapping?: boolean;
affineMapping?: boolean;
}
const DEFAULT_SNAP = new Vector2(160, 120);
interface PatchedMaterial extends Material {
userData: { psx?: { snapResolution: Vector2 } };
}
export function applyPSXMaterial<T extends Material & { map?: Texture | null }>(
material: T,
options: PSXMaterialOptions = {},
): T {
const snapResolution = options.snapResolution?.clone() ?? DEFAULT_SNAP.clone();
const vertexSnapping = options.vertexSnapping ?? true;
// Only meaningful when there is a texture to warp; without `map` the `uv`
// attribute may not even be declared by three's shader prefix.
const affineMapping = (options.affineMapping ?? true) && material.map != null;
const patched = material as unknown as PatchedMaterial;
patched.userData.psx = { snapResolution };
material.onBeforeCompile = (shader: WebGLProgramParametersWithUniforms) => {
shader.uniforms["uSnapResolution"] = { value: snapResolution };
const vertexDeclarations = [
"uniform vec2 uSnapResolution;",
affineMapping ? "varying vec2 vAffineUv;\nvarying float vAffineW;" : "",
]
.filter(Boolean)
.join("\n");
const vertexBody = [
vertexSnapping
? `
{
vec4 psxPos = gl_Position;
psxPos.xyz /= psxPos.w;
// Half-resolution because NDC spans -1..1 across the full width.
vec2 grid = uSnapResolution * 0.5;
psxPos.xy = floor(psxPos.xy * grid) / grid;
psxPos.xyz *= psxPos.w;
gl_Position = psxPos;
}`
: "",
affineMapping
? `
vAffineUv = uv * gl_Position.w;
vAffineW = gl_Position.w;`
: "",
]
.filter(Boolean)
.join("\n");
shader.vertexShader = shader.vertexShader
.replace("#include <common>", `#include <common>\n${vertexDeclarations}`)
.replace("#include <project_vertex>", `#include <project_vertex>\n${vertexBody}`);
if (affineMapping) {
shader.fragmentShader = shader.fragmentShader
.replace(
"#include <common>",
"#include <common>\nvarying vec2 vAffineUv;\nvarying float vAffineW;",
)
.replace(
"#include <map_fragment>",
`
#ifdef USE_MAP
diffuseColor *= texture2D( map, vAffineUv / vAffineW );
#endif`,
);
}
};
// Distinct patch variants must not share a compiled program.
material.customProgramCacheKey = () => `psx:${vertexSnapping ? 1 : 0}:${affineMapping ? 1 : 0}`;
material.needsUpdate = true;
return material;
}
/** Retune the snap grid at runtime — the uniform is shared by reference. */
export function setSnapResolution(material: Material, width: number, height: number): void {
const psx = (material as PatchedMaterial).userData.psx;
if (psx) psx.snapResolution.set(width, height);
}
+113
View File
@@ -0,0 +1,113 @@
import {
LinearSRGBColorSpace,
NearestFilter,
NoToneMapping,
RGBAFormat,
Vector2,
WebGLRenderTarget,
} from "three";
import type { Camera, Scene, WebGLRenderer } from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
import { PSXShader } from "./PSXShader.js";
/**
* Low-resolution render target + PSX pass + nearest-neighbour upscale.
*
* The scene is drawn into a small target (240p by default). The final pass
* samples it with NearestFilter and blits to the full-size canvas, which is
* what produces the chunky upscale — doing this with CSS instead would let the
* browser's smoothing round off exactly the edges we want to keep.
*/
export interface PSXPipelineOptions {
/** Virtual framebuffer height. Width follows the canvas aspect ratio. */
virtualHeight?: number;
colorLevels?: number;
ditherStrength?: number;
scanlineStrength?: number;
vignetteStrength?: number;
}
export class PSXPipeline {
private readonly composer: EffectComposer;
private readonly psxPass: ShaderPass;
private readonly renderTarget: WebGLRenderTarget;
private readonly virtualHeight: number;
private readonly virtualSize = new Vector2(320, 240);
constructor(
private readonly renderer: WebGLRenderer,
scene: Scene,
camera: Camera,
options: PSXPipelineOptions = {},
) {
this.virtualHeight = options.virtualHeight ?? 240;
// The PSX pass owns tone mapping and the linear→sRGB encode (see PSXShader).
// Leaving the renderer on its defaults would either double-encode or leave
// the canvas in linear space — both look like a black room.
this.renderer.outputColorSpace = LinearSRGBColorSpace;
this.renderer.toneMapping = NoToneMapping;
this.renderTarget = new WebGLRenderTarget(320, this.virtualHeight, {
minFilter: NearestFilter,
magFilter: NearestFilter,
format: RGBAFormat,
depthBuffer: true,
stencilBuffer: false,
// Scene lights render in linear; keep the intermediate buffer linear so
// the quantiser sees true radiometric values before the sRGB encode.
colorSpace: LinearSRGBColorSpace,
});
this.composer = new EffectComposer(renderer, this.renderTarget);
this.composer.addPass(new RenderPass(scene, camera));
this.psxPass = new ShaderPass(PSXShader);
this.psxPass.renderToScreen = true;
// Custom fragment already wrote display-referred sRGB — do not tone-map again.
this.psxPass.material.toneMapped = false;
this.composer.addPass(this.psxPass);
const uniforms = this.psxPass.uniforms;
uniforms["uColorLevels"]!.value = options.colorLevels ?? 32;
uniforms["uDitherStrength"]!.value = options.ditherStrength ?? 1;
uniforms["uScanlineStrength"]!.value = options.scanlineStrength ?? 0.12;
uniforms["uVignetteStrength"]!.value = options.vignetteStrength ?? 0.25;
}
/** Call on canvas resize. `width`/`height` are CSS pixels. */
setSize(width: number, height: number): void {
this.renderer.setSize(width, height, false);
// Keep virtual pixels square by deriving width from the canvas aspect.
const aspect = width / Math.max(1, height);
const virtualWidth = Math.max(2, Math.round((this.virtualHeight * aspect) / 2) * 2);
this.virtualSize.set(virtualWidth, this.virtualHeight);
this.composer.setSize(virtualWidth, this.virtualHeight);
this.psxPass.uniforms["uResolution"]!.value = this.virtualSize;
}
/** The grid the vertex snapper should quantise to — one snap per virtual pixel. */
get snapResolution(): Vector2 {
return this.virtualSize;
}
setCamera(camera: Camera): void {
for (const pass of this.composer.passes) {
if (pass instanceof RenderPass) pass.camera = camera;
}
}
render(): void {
this.composer.render();
}
dispose(): void {
this.composer.dispose();
this.renderTarget.dispose();
}
}
+109
View File
@@ -0,0 +1,109 @@
import { Vector2 } from "three";
/**
* Screen-space half of the PSX look — GDD §11.
*
* Runs on a low-resolution render target, so `uResolution` is the *virtual*
* framebuffer size (e.g. 320x240), not the canvas size. Order matters: dither
* first, then quantise. Adding the ordered-dither offset before rounding is
* what turns banding into the PSX's characteristic cross-hatch texture; doing
* it the other way round just adds noise.
*/
export const PSXShader = {
name: "PSXShader",
uniforms: {
tDiffuse: { value: null as unknown },
/** Virtual framebuffer size, in pixels. */
uResolution: { value: new Vector2(320, 240) },
/** Levels per channel. 32 reproduces the console's 15-bit colour. */
uColorLevels: { value: 32 },
uDitherStrength: { value: 1 },
uScanlineStrength: { value: 0.12 },
uVignetteStrength: { value: 0.25 },
},
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`,
fragmentShader: /* glsl */ `
uniform sampler2D tDiffuse;
uniform vec2 uResolution;
uniform float uColorLevels;
uniform float uDitherStrength;
uniform float uScanlineStrength;
uniform float uVignetteStrength;
varying vec2 vUv;
// Standard 4x4 ordered (Bayer) matrix, normalised to 0..1.
float bayer4x4( vec2 pixel ) {
int x = int( mod( pixel.x, 4.0 ) );
int y = int( mod( pixel.y, 4.0 ) );
int index = y * 4 + x;
if ( index == 0 ) return 0.0 / 16.0;
if ( index == 1 ) return 8.0 / 16.0;
if ( index == 2 ) return 2.0 / 16.0;
if ( index == 3 ) return 10.0 / 16.0;
if ( index == 4 ) return 12.0 / 16.0;
if ( index == 5 ) return 4.0 / 16.0;
if ( index == 6 ) return 14.0 / 16.0;
if ( index == 7 ) return 6.0 / 16.0;
if ( index == 8 ) return 3.0 / 16.0;
if ( index == 9 ) return 11.0 / 16.0;
if ( index == 10 ) return 1.0 / 16.0;
if ( index == 11 ) return 9.0 / 16.0;
if ( index == 12 ) return 15.0 / 16.0;
if ( index == 13 ) return 7.0 / 16.0;
if ( index == 14 ) return 13.0 / 16.0;
return 5.0 / 16.0;
}
// MeshLambert renders into the composer target in linear space. This pass
// blits straight to the canvas, so without an sRGB encode the image is
// displayed as if it were already gamma-corrected — which reads as near-
// black on a modern display (the classic EffectComposer + color-management
// trap in three r152+).
vec3 linearToSRGB( vec3 value ) {
return mix(
value * 12.92,
1.055 * pow( value, vec3( 1.0 / 2.4 ) ) - 0.055,
step( vec3( 0.0031308 ), value )
);
}
void main() {
vec4 texel = texture2D( tDiffuse, vUv );
vec3 color = texel.rgb;
// Dither in low-res texel space, not screen space, or the pattern
// stretches into visible blobs once the image is upscaled.
vec2 virtualPixel = vUv * uResolution;
float threshold = bayer4x4( virtualPixel ) - 0.5;
color += threshold * uDitherStrength / uColorLevels;
color = floor( color * uColorLevels + 0.5 ) / uColorLevels;
if ( uScanlineStrength > 0.0 ) {
float scanline = sin( virtualPixel.y * 3.14159265 );
color *= 1.0 - uScanlineStrength * scanline * scanline;
}
if ( uVignetteStrength > 0.0 ) {
vec2 centered = vUv - 0.5;
float vignette = 1.0 - dot( centered, centered ) * uVignetteStrength * 2.0;
color *= clamp( vignette, 0.0, 1.0 );
}
color = linearToSRGB( clamp( color, 0.0, 1.0 ) );
gl_FragColor = vec4( color, texel.a );
}
`,
};
+51
View File
@@ -0,0 +1,51 @@
:root {
--ink: #c8d0c0;
--ink-dim: #7d8878;
--ink-bright: #eef2e6;
--bg: #05060a;
--panel: rgba(10, 14, 12, 0.92);
--border: #47513f;
--accent: #c9a227;
--danger: #a33b2e;
/* Period-appropriate: chunky, limited palette, no font smoothing. */
--mono: "Courier New", ui-monospace, monospace;
color-scheme: dark;
font-family: var(--mono);
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: none;
font-smooth: never;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--bg);
}
.viewport {
position: relative;
width: 100%;
height: 100%;
}
.game-canvas {
display: block;
width: 100%;
height: 100%;
/* The PSX pass already upscaled from the low-res target; keep the browser
from smoothing the result a second time. */
image-rendering: pixelated;
}
+216
View File
@@ -0,0 +1,216 @@
import { PerspectiveCamera, Vector3 } from "three";
import type { CameraZoneDef } from "@psx/shared";
import { CollisionGroup, damp } from "@psx/shared";
import type { PhysicsWorld, SensorEvents } from "../engine/PhysicsWorld.js";
/**
* Fixed camera zones — GDD §6.1.
*
* Each zone is a box sensor. While the player capsule is inside it, the camera
* takes that zone's transform. Overlapping zones resolve by priority, then by
* most-recently-entered, so authoring redundant overlap at doorways is safe.
*/
export const CAMERA_ZONE_PREFIX = "zone:";
interface ActiveZone {
def: CameraZoneDef;
enteredAtTick: number;
}
interface CameraPose {
position: Vector3;
target: Vector3;
fov: number;
}
const DEFAULT_FOV = 55;
/** Seconds of damping used when a zone tracks the player. */
const TRACK_SMOOTHING = 0.0005;
export class CameraManager {
readonly camera: PerspectiveCamera;
private readonly zones = new Map<string, CameraZoneDef>();
private readonly occupied = new Map<string, ActiveZone>();
private activeZoneId: string | null = null;
private readonly current: CameraPose;
private readonly blendFrom: CameraPose;
private blendRemaining = 0;
private blendDuration = 0;
private readonly lookDirection = new Vector3(0, 0, -1);
private readonly scratchTarget = new Vector3();
private tick = 0;
/** Fired on every camera change so the player controller can lock its basis. */
onCut: (() => void) | null = null;
constructor(aspect: number) {
this.camera = new PerspectiveCamera(DEFAULT_FOV, aspect, 0.1, 120);
this.current = {
position: new Vector3(0, 3, 5),
target: new Vector3(0, 1, 0),
fov: DEFAULT_FOV,
};
this.blendFrom = {
position: this.current.position.clone(),
target: this.current.target.clone(),
fov: DEFAULT_FOV,
};
}
/** Build sensors for a room's zones. Call `clear` first when swapping rooms. */
loadZones(physics: PhysicsWorld, zoneDefs: CameraZoneDef[]): void {
for (const def of zoneDefs) {
this.zones.set(def.id, def);
physics.createSensor({
id: CAMERA_ZONE_PREFIX + def.id,
position: def.trigger.center,
halfExtents: def.trigger.halfExtents,
group: CollisionGroup.CAMERA_ZONE,
});
}
}
clear(): void {
this.zones.clear();
this.occupied.clear();
this.activeZoneId = null;
}
/** Feed the tick's sensor transitions. `visitorId` filters to the player. */
handleSensorEvents(events: SensorEvents, visitorId: string): void {
for (const event of events.begin) {
if (event.visitorId !== visitorId) continue;
const def = this.zoneFromSensorId(event.sensorId);
if (def) this.occupied.set(def.id, { def, enteredAtTick: this.tick });
}
for (const event of events.end) {
if (event.visitorId !== visitorId) continue;
const def = this.zoneFromSensorId(event.sensorId);
if (def) this.occupied.delete(def.id);
}
}
private zoneFromSensorId(sensorId: string): CameraZoneDef | undefined {
if (!sensorId.startsWith(CAMERA_ZONE_PREFIX)) return undefined;
return this.zones.get(sensorId.slice(CAMERA_ZONE_PREFIX.length));
}
update(dt: number, playerPosition: Vector3): void {
this.tick++;
this.selectZone();
const zone = this.activeZoneId ? this.zones.get(this.activeZoneId) : undefined;
if (zone) this.applyZone(zone, dt, playerPosition);
if (this.blendRemaining > 0) {
this.blendRemaining = Math.max(0, this.blendRemaining - dt);
const t = this.blendDuration > 0 ? 1 - this.blendRemaining / this.blendDuration : 1;
// Smoothstep keeps blends from starting and stopping abruptly.
const eased = t * t * (3 - 2 * t);
this.camera.position.lerpVectors(this.blendFrom.position, this.current.position, eased);
this.scratchTarget.lerpVectors(this.blendFrom.target, this.current.target, eased);
this.camera.fov = this.blendFrom.fov + (this.current.fov - this.blendFrom.fov) * eased;
this.camera.updateProjectionMatrix();
} else {
this.camera.position.copy(this.current.position);
this.scratchTarget.copy(this.current.target);
if (this.camera.fov !== this.current.fov) {
this.camera.fov = this.current.fov;
this.camera.updateProjectionMatrix();
}
}
this.camera.lookAt(this.scratchTarget);
this.camera.getWorldDirection(this.lookDirection);
}
/** Highest priority wins; ties break toward the zone entered most recently. */
private selectZone(): void {
let best: ActiveZone | null = null;
for (const candidate of this.occupied.values()) {
if (best === null) {
best = candidate;
continue;
}
const candidatePriority = candidate.def.priority ?? 0;
const bestPriority = best.def.priority ?? 0;
if (
candidatePriority > bestPriority ||
(candidatePriority === bestPriority && candidate.enteredAtTick > best.enteredAtTick)
) {
best = candidate;
}
}
const nextId = best?.def.id ?? null;
if (nextId === this.activeZoneId) return;
// Leaving every zone keeps the last camera rather than snapping to origin —
// a gap in zone coverage should look like an authoring bug, not a crash.
if (nextId === null) return;
this.beginTransition(this.zones.get(nextId)!);
this.activeZoneId = nextId;
}
private beginTransition(zone: CameraZoneDef): void {
const blend = zone.blend ?? { mode: "cut" as const };
if (blend.mode === "blend" && blend.duration > 0) {
this.blendFrom.position.copy(this.camera.position);
this.blendFrom.target.copy(this.scratchTarget);
this.blendFrom.fov = this.camera.fov;
this.blendDuration = blend.duration;
this.blendRemaining = blend.duration;
} else {
this.blendRemaining = 0;
this.blendDuration = 0;
}
this.onCut?.();
}
private applyZone(zone: CameraZoneDef, dt: number, playerPosition: Vector3): void {
const { camera: settings } = zone;
this.current.fov = settings.fov ?? DEFAULT_FOV;
this.current.position.set(settings.position.x, settings.position.y, settings.position.z);
if (!settings.trackPlayer) {
this.current.target.set(settings.lookAt.x, settings.lookAt.y, settings.lookAt.z);
return;
}
// Track the player, but never let the aim drift further than trackRadius
// from the authored look-at — that anchor is what keeps the shot composed.
this.scratchTarget.set(playerPosition.x, playerPosition.y + 0.9, playerPosition.z);
const anchor = settings.lookAt;
const radius = settings.trackRadius ?? 2.5;
const offsetX = this.scratchTarget.x - anchor.x;
const offsetY = this.scratchTarget.y - anchor.y;
const offsetZ = this.scratchTarget.z - anchor.z;
const distance = Math.hypot(offsetX, offsetY, offsetZ);
const scale = distance > radius ? radius / distance : 1;
this.current.target.set(
damp(this.current.target.x, anchor.x + offsetX * scale, TRACK_SMOOTHING, dt),
damp(this.current.target.y, anchor.y + offsetY * scale, TRACK_SMOOTHING, dt),
damp(this.current.target.z, anchor.z + offsetZ * scale, TRACK_SMOOTHING, dt),
);
}
/** World-space direction the camera faces — the basis for camera-relative movement. */
getLookDirection(): Vector3 {
return this.lookDirection;
}
get activeZone(): string | null {
return this.activeZoneId;
}
setAspect(aspect: number): void {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
}
+219
View File
@@ -0,0 +1,219 @@
import { beforeAll, describe, expect, it } from "vitest";
import { Vector3 } from "three";
import { PhysicsWorld } from "../engine/PhysicsWorld.js";
import { CharacterMover, DEFAULT_MOVER_CONFIG } from "./CharacterMover.js";
import { FIXED_DT } from "../engine/GameLoop.js";
/**
* The mover replaces a Box3D feature that box3d-wasm does not bind, so its
* behaviour is not covered by any upstream test. These run in Node against the
* real wasm build — box3d-wasm is isomorphic, so this is the actual physics the
* browser will run, not a stub.
*/
const PLAYER = "player";
async function makeWorld(): Promise<PhysicsWorld> {
const physics = new PhysicsWorld();
await physics.init({ x: 0, y: DEFAULT_MOVER_CONFIG.gravity, z: 0 });
return physics;
}
function addFloor(physics: PhysicsWorld, id = "floor"): void {
physics.createStatic({
id,
position: { x: 0, y: -0.15, z: 0 },
colliders: [{ kind: "box", halfExtents: { x: 20, y: 0.15, z: 20 } }],
});
}
function spawn(physics: PhysicsWorld, at: Vector3): CharacterMover {
const height = DEFAULT_MOVER_CONFIG.cylinderHeight + DEFAULT_MOVER_CONFIG.radius * 2;
physics.createKinematicCapsule({
id: PLAYER,
position: { x: at.x, y: at.y + height / 2, z: at.z },
radius: DEFAULT_MOVER_CONFIG.radius,
height: DEFAULT_MOVER_CONFIG.cylinderHeight,
});
return new CharacterMover(physics, PLAYER, at.clone());
}
/** Drive the mover for `seconds`, stepping physics exactly as the game loop does. */
function simulate(physics: PhysicsWorld, mover: CharacterMover, velocity: Vector3, seconds: number) {
const ticks = Math.round(seconds / FIXED_DT);
for (let i = 0; i < ticks; i++) {
mover.update(velocity, FIXED_DT);
physics.step(FIXED_DT);
}
return mover.state;
}
describe("CharacterMover", () => {
beforeAll(() => {
// Fail loudly rather than silently testing a half-initialised world.
expect(DEFAULT_MOVER_CONFIG.stepHeight).toBeGreaterThan(0);
});
it("falls and settles on the floor", async () => {
const physics = await makeWorld();
addFloor(physics);
const mover = spawn(physics, new Vector3(0, 3, 0));
const state = simulate(physics, mover, new Vector3(0, 0, 0), 2);
expect(state.grounded).toBe(true);
expect(state.position.y).toBeCloseTo(0, 2);
physics.dispose();
});
it("walks across open floor", async () => {
const physics = await makeWorld();
addFloor(physics);
const mover = spawn(physics, new Vector3(0, 0, 0));
simulate(physics, mover, new Vector3(0, 0, 0), 0.5); // settle
const state = simulate(physics, mover, new Vector3(0, 0, -2), 1);
expect(state.position.z).toBeLessThan(-1.5);
expect(state.grounded).toBe(true);
physics.dispose();
});
it("is stopped by a wall instead of passing through it", async () => {
const physics = await makeWorld();
addFloor(physics);
physics.createStatic({
id: "wall",
position: { x: 0, y: 1.5, z: -3 },
colliders: [{ kind: "box", halfExtents: { x: 5, y: 1.5, z: 0.15 } }],
});
const mover = spawn(physics, new Vector3(0, 0, 0));
simulate(physics, mover, new Vector3(0, 0, 0), 0.5);
const state = simulate(physics, mover, new Vector3(0, 0, -3), 3);
// Stopped at the wall face (z = -2.85) plus the capsule radius.
expect(state.position.z).toBeGreaterThan(-2.9);
expect(state.position.z).toBeLessThan(-2.0);
physics.dispose();
});
it("slides along a wall hit at an angle rather than sticking", async () => {
const physics = await makeWorld();
addFloor(physics);
physics.createStatic({
id: "wall",
position: { x: 0, y: 1.5, z: -3 },
colliders: [{ kind: "box", halfExtents: { x: 8, y: 1.5, z: 0.15 } }],
});
const mover = spawn(physics, new Vector3(0, 0, 0));
simulate(physics, mover, new Vector3(0, 0, 0), 0.5);
// Push diagonally into the wall; the -Z component is absorbed, +X survives.
const state = simulate(physics, mover, new Vector3(2, 0, -2), 2);
expect(state.position.x).toBeGreaterThan(1.5);
physics.dispose();
});
it("steps up a ledge below step height", async () => {
const physics = await makeWorld();
addFloor(physics);
physics.createStatic({
id: "step",
// 0.3 tall, under the 0.35 step height.
position: { x: 0, y: 0.15, z: -3 },
colliders: [{ kind: "box", halfExtents: { x: 4, y: 0.15, z: 2 } }],
});
const mover = spawn(physics, new Vector3(0, 0, 0));
simulate(physics, mover, new Vector3(0, 0, 0), 0.5);
const state = simulate(physics, mover, new Vector3(0, 0, -2), 2);
expect(state.position.y).toBeCloseTo(0.3, 1);
expect(state.position.z).toBeLessThan(-2);
physics.dispose();
});
it("is blocked by a ledge above step height", async () => {
const physics = await makeWorld();
addFloor(physics);
physics.createStatic({
id: "block",
// 1.2 tall, well over the step height.
position: { x: 0, y: 0.6, z: -3 },
colliders: [{ kind: "box", halfExtents: { x: 4, y: 0.6, z: 2 } }],
});
const mover = spawn(physics, new Vector3(0, 0, 0));
simulate(physics, mover, new Vector3(0, 0, 0), 0.5);
const state = simulate(physics, mover, new Vector3(0, 0, -2), 2);
expect(state.position.y).toBeLessThan(0.1);
expect(state.position.z).toBeGreaterThan(-1.5);
physics.dispose();
});
it("walks up a hull ramp and gains height", async () => {
const physics = await makeWorld();
addFloor(physics);
physics.createStatic({
id: "ramp",
position: { x: 0, y: 0, z: -3 },
colliders: [
{
kind: "hull",
points: [
{ x: -2, y: 0, z: 2 },
{ x: 2, y: 0, z: 2 },
{ x: -2, y: 0, z: -2 },
{ x: 2, y: 0, z: -2 },
{ x: -2, y: 1.4, z: -2 },
{ x: 2, y: 1.4, z: -2 },
],
},
],
});
const mover = spawn(physics, new Vector3(0, 0, 0));
simulate(physics, mover, new Vector3(0, 0, 0), 0.5);
const state = simulate(physics, mover, new Vector3(0, 0, -1.5), 3);
expect(state.position.y).toBeGreaterThan(0.5);
expect(state.grounded).toBe(true);
physics.dispose();
});
});
describe("PhysicsWorld sensors", () => {
it("reports the player entering and leaving a trigger volume", async () => {
const physics = await makeWorld();
addFloor(physics);
physics.createSensor({
id: "zone:test",
position: { x: 0, y: 1, z: -3 },
halfExtents: { x: 1.5, y: 1.5, z: 1.5 },
});
const mover = spawn(physics, new Vector3(0, 0, 0));
let entered = false;
let left = false;
for (let i = 0; i < 400; i++) {
mover.update(new Vector3(0, 0, -2), FIXED_DT);
physics.step(FIXED_DT);
const events = physics.drainSensorEvents();
if (events.begin.some((e) => e.sensorId === "zone:test" && e.visitorId === PLAYER)) {
entered = true;
}
if (entered && events.end.some((e) => e.sensorId === "zone:test")) {
left = true;
break;
}
}
expect(entered).toBe(true);
expect(left).toBe(true);
physics.dispose();
});
});
+338
View File
@@ -0,0 +1,338 @@
import { Vector3 } from "three";
import { MOVER_SOLID_MASK } from "@psx/shared";
import type { PhysicsWorld } from "../engine/PhysicsWorld.js";
/**
* Kinematic capsule character controller.
*
* GDD §6.2 assumes "the Box3D character mover", but box3d-wasm@0.2.0 binds no
* mover — and no shape cast or overlap query either. `castRayClosest` is the
* only spatial query available, so this is a collide-and-slide mover built out
* of ray probes: a fan of horizontal rays approximates the capsule sweep, and a
* downward probe handles grounding, snapping and step-up.
*
* The capsule body in the physics world is kinematic and follows the position
* we compute, so the solver never fights us — it exists so that sensors see the
* player and so dynamic props get pushed correctly.
*/
export interface MoverConfig {
radius: number;
/** Height of the cylindrical section; total height is this + 2 * radius. */
cylinderHeight: number;
/** Ledges up to this tall are walked over rather than blocked. */
stepHeight: number;
maxSlopeDegrees: number;
gravity: number;
/** Keeps probes from starting exactly on a surface and self-intersecting. */
skin: number;
/** How far below the feet we look for ground before declaring a fall. */
groundSnapDistance: number;
/** Downward bias while grounded, so we stay glued walking down slopes. */
groundStick: number;
}
export const DEFAULT_MOVER_CONFIG: MoverConfig = {
radius: 0.3,
cylinderHeight: 1.1,
stepHeight: 0.35,
maxSlopeDegrees: 50,
gravity: -18,
skin: 0.02,
groundSnapDistance: 0.5,
groundStick: -2,
};
export interface MoverState {
/** Feet position — the point the capsule stands on. */
position: Vector3;
verticalVelocity: number;
grounded: boolean;
groundNormal: Vector3;
/** Horizontal distance actually covered last tick, for animation blending. */
lastMoveDistance: number;
}
/** How many times we re-project and retry after hitting a wall. */
const SLIDE_ITERATIONS = 4;
/** Lateral fan offsets, as a fraction of radius, for the horizontal probes. */
const PROBE_LATERAL = [0, 0.7, -0.7];
const scratchA = new Vector3();
const scratchB = new Vector3();
const scratchC = new Vector3();
const scratchD = new Vector3();
export class CharacterMover {
readonly state: MoverState;
private readonly minGroundY: number;
constructor(
private readonly physics: PhysicsWorld,
private readonly bodyId: string,
startPosition: Vector3,
readonly config: MoverConfig = DEFAULT_MOVER_CONFIG,
) {
this.state = {
position: startPosition.clone(),
verticalVelocity: 0,
grounded: false,
groundNormal: new Vector3(0, 1, 0),
lastMoveDistance: 0,
};
this.minGroundY = Math.cos((config.maxSlopeDegrees * Math.PI) / 180);
}
get totalHeight(): number {
return this.config.cylinderHeight + this.config.radius * 2;
}
/** Physics-world position of the capsule centre, given the current feet position. */
get capsuleCenter(): Vector3 {
return scratchD.copy(this.state.position).setY(this.state.position.y + this.totalHeight * 0.5);
}
/**
* Advance one fixed tick.
* `desiredVelocity` is a world-space horizontal velocity in units/second;
* its Y component is ignored.
*/
update(desiredVelocity: Vector3, dt: number): MoverState {
const { config, state } = this;
// --- vertical integration -------------------------------------------------
if (state.grounded && state.verticalVelocity <= 0) {
state.verticalVelocity = config.groundStick;
} else {
state.verticalVelocity += config.gravity * dt;
}
// --- horizontal collide and slide ----------------------------------------
const displacement = scratchA.set(desiredVelocity.x * dt, 0, desiredVelocity.z * dt);
const startPosition = scratchB.copy(state.position);
this.slide(displacement);
// A blocked move on flat ground may just be a low ledge — retry stepped up.
if (state.grounded) {
const covered = scratchC.copy(state.position).sub(startPosition).setY(0).length();
const wanted = Math.hypot(desiredVelocity.x * dt, desiredVelocity.z * dt);
if (wanted > 1e-5 && covered < wanted * 0.7) {
this.tryStepUp(startPosition, desiredVelocity, dt, covered);
}
}
state.lastMoveDistance = Math.hypot(
state.position.x - startPosition.x,
state.position.z - startPosition.z,
);
// --- vertical resolve -----------------------------------------------------
this.resolveVertical(dt);
this.syncBody(dt);
return state;
}
/** Move along `displacement`, re-projecting onto each wall we touch. */
private slide(displacement: Vector3): void {
const remaining = displacement.clone();
for (let i = 0; i < SLIDE_ITERATIONS; i++) {
const distance = remaining.length();
if (distance < 1e-6) return;
const direction = remaining.clone().divideScalar(distance);
const hit = this.probeHorizontal(direction, distance);
if (!hit) {
this.state.position.add(remaining);
return;
}
// Advance up to the contact point, then slide along the wall plane.
const travel = Math.max(0, hit.distance - this.config.skin);
if (travel > 0) this.state.position.addScaledVector(direction, travel);
remaining.addScaledVector(direction, -travel);
const normal = hit.normal.setY(0).normalize();
if (normal.lengthSq() < 1e-8) return;
remaining.addScaledVector(normal, -remaining.dot(normal));
}
}
/**
* Fan of rays approximating a capsule sweep: three heights (ankle, waist,
* shoulder) by three lateral offsets. Returns the nearest obstruction, with
* `distance` already converted to free travel along `direction`.
*/
private probeHorizontal(
direction: Vector3,
distance: number,
): { distance: number; normal: Vector3 } | null {
const { radius, skin, cylinderHeight, stepHeight } = this.config;
const feet = this.state.position;
const tangent = new Vector3(-direction.z, 0, direction.x);
// Ankle probe sits above stepHeight so low ledges fall through to step-up.
const heights = [
stepHeight + skin,
radius + cylinderHeight * 0.5,
radius + cylinderHeight - skin,
];
let best: { distance: number; normal: Vector3 } | null = null;
for (const height of heights) {
for (const lateral of PROBE_LATERAL) {
const lateralOffset = lateral * radius;
// Forward extent from this ray's origin to the capsule surface.
const forwardClearance = Math.sqrt(Math.max(0, radius * radius - lateralOffset * lateralOffset));
const origin = {
x: feet.x + tangent.x * lateralOffset,
y: feet.y + height,
z: feet.z + tangent.z * lateralOffset,
};
const maxDistance = distance + forwardClearance + skin;
const hit = this.physics.raycast(origin, direction, maxDistance, MOVER_SOLID_MASK);
if (!hit || hit.entityId === this.bodyId) continue;
const free = hit.distance - forwardClearance;
if (best === null || free < best.distance) {
best = { distance: free, normal: new Vector3(hit.normal.x, hit.normal.y, hit.normal.z) };
}
}
}
return best !== null && best.distance < distance ? best : null;
}
/**
* Re-run the blocked move from `stepHeight` above the start, then drop back
* down. Accepted only if we end up further along and land on walkable ground.
*/
private tryStepUp(
startPosition: Vector3,
desiredVelocity: Vector3,
dt: number,
coveredFlat: number,
): void {
const { config, state } = this;
const flatResult = state.position.clone();
// Bail if there is no headroom to step into.
const ceiling = this.physics.raycast(
{ x: startPosition.x, y: startPosition.y + this.totalHeight - config.skin, z: startPosition.z },
{ x: 0, y: 1, z: 0 },
config.stepHeight + config.skin,
MOVER_SOLID_MASK,
);
if (ceiling) return;
state.position.copy(startPosition).setY(startPosition.y + config.stepHeight);
this.slide(new Vector3(desiredVelocity.x * dt, 0, desiredVelocity.z * dt));
const steppedFlat = Math.hypot(
state.position.x - startPosition.x,
state.position.z - startPosition.z,
);
if (steppedFlat <= coveredFlat + config.skin) {
state.position.copy(flatResult);
return;
}
// Find the surface we stepped onto.
const drop = this.physics.raycast(
{ x: state.position.x, y: state.position.y + config.skin, z: state.position.z },
{ x: 0, y: -1, z: 0 },
config.stepHeight * 2 + config.skin,
MOVER_SOLID_MASK,
);
if (!drop || drop.normal.y < this.minGroundY) {
state.position.copy(flatResult);
return;
}
state.position.setY(drop.point.y);
state.grounded = true;
state.groundNormal.set(drop.normal.x, drop.normal.y, drop.normal.z);
state.verticalVelocity = 0;
}
/** Ground probe, snap-to-floor and ceiling clamp. */
private resolveVertical(dt: number): void {
const { config, state } = this;
const wasGrounded = state.grounded;
const verticalStep = state.verticalVelocity * dt;
if (verticalStep > 0) {
// Rising: stop at the ceiling.
const headroom = this.physics.raycast(
{ x: state.position.x, y: state.position.y + this.totalHeight - config.skin, z: state.position.z },
{ x: 0, y: 1, z: 0 },
verticalStep + config.skin,
MOVER_SOLID_MASK,
);
if (headroom) {
state.position.y += Math.max(0, headroom.distance - config.skin);
state.verticalVelocity = 0;
} else {
state.position.y += verticalStep;
}
state.grounded = false;
return;
}
// Falling or glued: probe from just above the feet.
const probeStart = config.stepHeight;
// While grounded we look a little further so downhill slopes stay attached.
const reach = probeStart + Math.abs(verticalStep) + (wasGrounded ? config.groundSnapDistance : config.skin);
const ground = this.physics.raycast(
{ x: state.position.x, y: state.position.y + probeStart, z: state.position.z },
{ x: 0, y: -1, z: 0 },
reach,
MOVER_SOLID_MASK,
);
if (ground && ground.normal.y >= this.minGroundY) {
const groundY = ground.point.y;
const wouldFallTo = state.position.y + verticalStep;
if (wouldFallTo <= groundY) {
state.position.y = groundY;
state.verticalVelocity = 0;
state.grounded = true;
state.groundNormal.set(ground.normal.x, ground.normal.y, ground.normal.z);
return;
}
}
state.position.y += verticalStep;
state.grounded = false;
state.groundNormal.set(0, 1, 0);
}
/** Drive the kinematic body so sensors and dynamic props see the player. */
private syncBody(dt: number): void {
const entity = this.physics.get(this.bodyId);
if (!entity) return;
const center = this.capsuleCenter;
entity.body.setTargetTransform(
{ position: { x: center.x, y: center.y, z: center.z }, rotation: { x: 0, y: 0, z: 0, w: 1 } },
dt,
);
}
/** Hard placement — room transitions, loading a save, respawn. */
teleport(position: Vector3): void {
this.state.position.copy(position);
this.state.verticalVelocity = 0;
this.state.grounded = false;
const entity = this.physics.get(this.bodyId);
if (entity) {
const center = this.capsuleCenter;
entity.body.setTransform({
position: { x: center.x, y: center.y, z: center.z },
rotation: { x: 0, y: 0, z: 0, w: 1 },
});
}
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* World state flags — the backbone of GDD §6.4 puzzles and door logic.
*
* Deliberately a flat string set rather than structured state: it serialises
* into the save JSONB unchanged, and in Milestone 2 the host can broadcast a
* flag delta to peers without either side knowing what the flag means.
*/
export class FlagStore {
private readonly flags = new Set<string>();
private readonly listeners = new Set<(flag: string, value: boolean) => void>();
subscribe(listener: (flag: string, value: boolean) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
set(flag: string, value = true): void {
const changed = value ? !this.flags.has(flag) : this.flags.has(flag);
if (!changed) return;
if (value) this.flags.add(flag);
else this.flags.delete(flag);
for (const listener of this.listeners) listener(flag, value);
}
has(flag: string): boolean {
return this.flags.has(flag);
}
/**
* Evaluates a requirement list. A leading `!` negates, so
* `["power-on", "!alarm-tripped"]` reads as written.
*/
satisfies(requirements: readonly string[] | undefined): boolean {
if (!requirements || requirements.length === 0) return true;
return requirements.every((requirement) =>
requirement.startsWith("!")
? !this.flags.has(requirement.slice(1))
: this.flags.has(requirement),
);
}
serialise(): string[] {
return [...this.flags];
}
restore(flags: readonly string[]): void {
this.flags.clear();
for (const flag of flags) this.flags.add(flag);
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Keyboard input, mapped to named actions.
*
* Reads `KeyboardEvent.code` rather than `.key` so the bindings are physical
* positions — WASD stays WASD on an AZERTY layout.
*/
export type GameAction =
| "forward"
| "back"
| "left"
| "right"
| "run"
| "interact"
| "quickTurn"
| "aim"
| "inventory"
| "cancel"
| "toggleControls"
| "toggleDebug";
const DEFAULT_BINDINGS: Record<string, GameAction> = {
KeyW: "forward",
ArrowUp: "forward",
KeyS: "back",
ArrowDown: "back",
KeyA: "left",
ArrowLeft: "left",
KeyD: "right",
ArrowRight: "right",
ShiftLeft: "run",
ShiftRight: "run",
KeyE: "interact",
Enter: "interact",
Space: "quickTurn",
KeyQ: "aim",
Tab: "inventory",
Escape: "cancel",
KeyC: "toggleControls",
Backquote: "toggleDebug",
};
/** Actions where a stray browser default (scroll, focus change) would hurt. */
const SWALLOW_DEFAULT: ReadonlySet<GameAction> = new Set<GameAction>([
"forward",
"back",
"left",
"right",
"quickTurn",
"inventory",
]);
export class InputManager {
private readonly held = new Set<GameAction>();
private readonly pressedThisTick = new Set<GameAction>();
private readonly bindings: Record<string, GameAction>;
private attached = false;
constructor(bindings: Record<string, GameAction> = DEFAULT_BINDINGS) {
this.bindings = { ...bindings };
}
attach(target: Window | HTMLElement = window): () => void {
if (this.attached) throw new Error("InputManager is already attached");
this.attached = true;
const onKeyDown = (event: Event) => {
const action = this.bindings[(event as KeyboardEvent).code];
if (!action) return;
if (SWALLOW_DEFAULT.has(action)) event.preventDefault();
// Ignore auto-repeat so `wasPressed` stays edge-triggered.
if (!(event as KeyboardEvent).repeat && !this.held.has(action)) {
this.pressedThisTick.add(action);
}
this.held.add(action);
};
const onKeyUp = (event: Event) => {
const action = this.bindings[(event as KeyboardEvent).code];
if (action) this.held.delete(action);
};
// Losing focus mid-key would otherwise leave the player walking forever.
const onBlur = () => this.held.clear();
target.addEventListener("keydown", onKeyDown);
target.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
target.removeEventListener("keydown", onKeyDown);
target.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
this.attached = false;
};
}
isHeld(action: GameAction): boolean {
return this.held.has(action);
}
/** True only on the tick the key went down. Call `endTick` to clear. */
wasPressed(action: GameAction): boolean {
return this.pressedThisTick.has(action);
}
/** Signed axis helper: -1, 0 or 1. */
axis(negative: GameAction, positive: GameAction): number {
return (this.held.has(positive) ? 1 : 0) - (this.held.has(negative) ? 1 : 0);
}
endTick(): void {
this.pressedThisTick.clear();
}
}
+164
View File
@@ -0,0 +1,164 @@
import type { InteractableDef } from "@psx/shared";
import { CollisionGroup } from "@psx/shared";
import type { PhysicsWorld, SensorEvents } from "../engine/PhysicsWorld.js";
import type { FlagStore } from "./FlagStore.js";
import type { InventorySystem } from "./InventorySystem.js";
/**
* Examine / pick up / use-item-on-object — GDD §6.4.
*
* Reach is a sensor volume rather than a distance check so that authoring can
* shape it: a wide desk gets a wide trigger, a keyhole gets a narrow one.
*/
export const INTERACTABLE_PREFIX = "item:";
export type InteractionResult =
| { type: "message"; text: string }
| { type: "picked-up"; itemId: string; quantity: number; text: string }
| { type: "blocked"; text: string }
| { type: "flag-set"; flag: string; text: string }
| { type: "transition"; roomId: string; spawnPointId: string };
export class InteractionSystem {
private readonly definitions = new Map<string, InteractableDef>();
private readonly inReach = new Set<string>();
private readonly consumed = new Set<string>();
constructor(
private readonly physics: PhysicsWorld,
private readonly inventory: InventorySystem,
private readonly flags: FlagStore,
) {}
load(defs: InteractableDef[]): void {
for (const def of defs) {
this.definitions.set(def.id, def);
this.physics.createSensor({
id: INTERACTABLE_PREFIX + def.id,
position: def.position,
halfExtents: def.halfExtents,
group: CollisionGroup.TRIGGER,
});
}
}
clear(): void {
this.definitions.clear();
this.inReach.clear();
}
handleSensorEvents(events: SensorEvents, visitorId: string): void {
for (const event of events.begin) {
if (event.visitorId !== visitorId) continue;
if (event.sensorId.startsWith(INTERACTABLE_PREFIX)) {
this.inReach.add(event.sensorId.slice(INTERACTABLE_PREFIX.length));
}
}
for (const event of events.end) {
if (event.visitorId !== visitorId) continue;
if (event.sensorId.startsWith(INTERACTABLE_PREFIX)) {
this.inReach.delete(event.sensorId.slice(INTERACTABLE_PREFIX.length));
}
}
}
/** The interactable the prompt should name, or null when nothing is in reach. */
get current(): InteractableDef | null {
for (const id of this.inReach) {
const def = this.definitions.get(id);
if (!def) continue;
if (this.consumed.has(id) && def.repeatable === false) continue;
if (!this.flags.satisfies(def.requiresFlags)) continue;
return def;
}
return null;
}
/** Runs the interaction under the cursor. Returns null if there is nothing to do. */
activate(): InteractionResult | null {
const def = this.current;
return def ? this.run(def) : null;
}
/**
* Runs a named interaction regardless of local reach.
*
* Used by the host on behalf of a guest: sensors only ever track the local
* player, so the host verifies the guest's proximity itself before calling
* this (see `Game.handleGuestInteract`).
*/
activateById(interactableId: string): InteractionResult | null {
const def = this.definitions.get(interactableId);
if (!def) return null;
if (this.consumed.has(def.id) && def.repeatable === false) return null;
if (!this.flags.satisfies(def.requiresFlags)) return null;
return this.run(def);
}
private run(def: InteractableDef): InteractionResult {
const result = this.resolve(def);
if (result.type !== "blocked") this.consumed.add(def.id);
return result;
}
private resolve(def: InteractableDef): InteractionResult {
const action = def.action;
switch (action.kind) {
case "examine":
return { type: "message", text: action.text };
case "pickup": {
const quantity = action.quantity ?? 1;
if (!this.inventory.add(action.itemId, quantity)) {
return { type: "blocked", text: "You have no room to carry that." };
}
// Pickups vanish once taken unless explicitly marked repeatable.
if (def.repeatable !== true) this.despawn(def.id);
return {
type: "picked-up",
itemId: action.itemId,
quantity,
text: `Obtained ${def.label}.`,
};
}
case "door": {
if (action.requiresItemId && !this.inventory.has(action.requiresItemId)) {
return { type: "blocked", text: "It's locked." };
}
return {
type: "transition",
roomId: action.targetRoomId,
spawnPointId: action.spawnPointId,
};
}
case "useItem": {
if (!this.inventory.has(action.acceptsItemId)) {
return { type: "blocked", text: action.text };
}
this.inventory.remove(action.acceptsItemId);
this.flags.set(action.onUseFlag);
this.despawn(def.id);
return { type: "flag-set", flag: action.onUseFlag, text: `Used the ${def.label}.` };
}
}
}
private despawn(id: string): void {
this.definitions.delete(id);
this.inReach.delete(id);
this.physics.remove(INTERACTABLE_PREFIX + id);
}
serialise(): string[] {
return [...this.consumed];
}
restore(consumed: readonly string[]): void {
this.consumed.clear();
for (const id of consumed) this.consumed.add(id);
}
}
+182
View File
@@ -0,0 +1,182 @@
import type { CombinationDef, InventoryState, ItemDef, ItemStack } from "@psx/shared";
/**
* Classic list-style inventory — GDD §6.3.
*
* Fixed slot count, because running out of space is a design lever in this
* genre rather than a limitation. Key items are held in a separate unbounded
* pouch so a full inventory can never soft-lock a puzzle.
*/
export type InventoryEvent =
| { type: "added"; itemId: string; quantity: number }
| { type: "removed"; itemId: string; quantity: number }
| { type: "full"; itemId: string }
| { type: "combined"; inputs: [string, string]; output: ItemStack };
export class InventorySystem {
private state: InventoryState;
private readonly keyItems = new Set<string>();
private readonly listeners = new Set<(event: InventoryEvent) => void>();
constructor(
private readonly catalogue: ReadonlyMap<string, ItemDef>,
private readonly combinations: readonly CombinationDef[] = [],
capacity = 8,
) {
this.state = { slots: Array<ItemStack | null>(capacity).fill(null), capacity, keyItems: [] };
}
subscribe(listener: (event: InventoryEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private emit(event: InventoryEvent): void {
for (const listener of this.listeners) listener(event);
}
add(itemId: string, quantity = 1): boolean {
const def = this.catalogue.get(itemId);
if (!def) throw new Error(`Unknown item: ${itemId}`);
if (def.isKeyItem) {
this.keyItems.add(itemId);
this.emit({ type: "added", itemId, quantity: 1 });
return true;
}
let remaining = quantity;
if (def.stackable) {
const maxStack = def.maxStack ?? 99;
for (const slot of this.state.slots) {
if (remaining <= 0) break;
if (slot?.itemId !== itemId || slot.quantity >= maxStack) continue;
const room = maxStack - slot.quantity;
const moved = Math.min(room, remaining);
slot.quantity += moved;
remaining -= moved;
}
}
while (remaining > 0) {
const free = this.state.slots.indexOf(null);
if (free === -1) {
this.emit({ type: "full", itemId });
return false;
}
const moved = def.stackable ? Math.min(def.maxStack ?? 99, remaining) : 1;
this.state.slots[free] = { itemId, quantity: moved };
remaining -= moved;
}
this.emit({ type: "added", itemId, quantity });
return true;
}
has(itemId: string, quantity = 1): boolean {
if (this.keyItems.has(itemId)) return true;
return this.count(itemId) >= quantity;
}
count(itemId: string): number {
return this.state.slots.reduce(
(total, slot) => (slot?.itemId === itemId ? total + slot.quantity : total),
0,
);
}
remove(itemId: string, quantity = 1): boolean {
if (this.keyItems.has(itemId)) {
// Key items are consumed only by explicit puzzle logic, never by capacity
// pressure — see the soft-lock note above.
this.keyItems.delete(itemId);
this.emit({ type: "removed", itemId, quantity: 1 });
return true;
}
if (this.count(itemId) < quantity) return false;
let remaining = quantity;
for (let i = 0; i < this.state.slots.length && remaining > 0; i++) {
const slot = this.state.slots[i];
if (slot?.itemId !== itemId) continue;
const taken = Math.min(slot.quantity, remaining);
slot.quantity -= taken;
remaining -= taken;
if (slot.quantity === 0) this.state.slots[i] = null;
}
this.emit({ type: "removed", itemId, quantity });
return true;
}
/** GDD §6.3 item combination. Input order does not matter. */
combine(itemA: string, itemB: string): ItemStack | null {
const recipe = this.combinations.find(
({ inputs }) =>
(inputs[0] === itemA && inputs[1] === itemB) ||
(inputs[0] === itemB && inputs[1] === itemA),
);
if (!recipe || !this.has(itemA) || !this.has(itemB)) return null;
// `consumes` is expressed in recipe order, which may be swapped from ours.
const swapped = recipe.inputs[0] !== itemA;
const consumes = recipe.consumes ?? [true, true];
const consumeA = swapped ? consumes[1] : consumes[0];
const consumeB = swapped ? consumes[0] : consumes[1];
if (consumeA) this.remove(itemA);
if (consumeB) this.remove(itemB);
this.add(recipe.output.itemId, recipe.output.quantity);
this.emit({ type: "combined", inputs: [itemA, itemB], output: recipe.output });
return recipe.output;
}
/** Snapshot for the HUD and for cloud saves. Key items appear first. */
list(): Array<{ def: ItemDef; quantity: number; slot: number | null }> {
const entries: Array<{ def: ItemDef; quantity: number; slot: number | null }> = [];
for (const itemId of this.keyItems) {
const def = this.catalogue.get(itemId);
if (def) entries.push({ def, quantity: 1, slot: null });
}
this.state.slots.forEach((slot, index) => {
if (!slot) return;
const def = this.catalogue.get(slot.itemId);
if (def) entries.push({ def, quantity: slot.quantity, slot: index });
});
return entries;
}
get capacity(): number {
return this.state.capacity;
}
get usedSlots(): number {
return this.state.slots.filter((slot) => slot !== null).length;
}
serialise(): InventoryState {
return {
capacity: this.state.capacity,
slots: this.state.slots.map((slot) => (slot ? { ...slot } : null)),
keyItems: [...this.keyItems],
};
}
restore(state: InventoryState): void {
this.state = {
capacity: state.capacity,
slots: state.slots.map((slot) => (slot ? { ...slot } : null)),
keyItems: [...(state.keyItems ?? [])],
};
this.keyItems.clear();
for (const itemId of state.keyItems ?? []) {
if (this.catalogue.has(itemId)) this.keyItems.add(itemId);
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import { Vector3 } from "three";
import { angleDelta } from "@psx/shared";
import type { CharacterMover } from "./CharacterMover.js";
import type { InputManager } from "./InputManager.js";
/** GDD §6.2 — classic tank controls, or modern camera-relative movement. */
export type ControlScheme = "tank" | "cameraRelative";
export interface PlayerControllerConfig {
walkSpeed: number;
runSpeed: number;
/** Tank turn rate, radians/second. */
turnSpeed: number;
/** How fast the body swings to face the stick in camera-relative mode. */
facingSmoothing: number;
quickTurnDuration: number;
/** Backing up is deliberately slow — it is a horror game. */
backpedalFactor: number;
}
export const DEFAULT_PLAYER_CONFIG: PlayerControllerConfig = {
walkSpeed: 1.9,
runSpeed: 3.6,
turnSpeed: 2.6,
facingSmoothing: 0.0001,
quickTurnDuration: 0.22,
backpedalFactor: 0.55,
};
const UP = new Vector3(0, 1, 0);
export class PlayerController {
scheme: ControlScheme = "tank";
yaw = 0;
private quickTurnRemaining = 0;
private quickTurnFrom = 0;
private quickTurnTo = 0;
/**
* Camera basis frozen at the moment of a camera cut.
*
* GDD §6.1 asks that "up" still feels correct after a cut. If the basis
* swapped mid-hold the player would veer off the moment a new zone triggered,
* so we keep using the pre-cut basis until the stick returns to neutral —
* the behaviour the RE remakes settled on.
*/
private lockedBasis: { forward: Vector3; right: Vector3 } | null = null;
private inputWasNeutral = true;
private readonly desiredVelocity = new Vector3();
private readonly moveDirection = new Vector3();
private readonly cameraForward = new Vector3(0, 0, 1);
private readonly cameraRight = new Vector3(1, 0, 0);
constructor(
private readonly mover: CharacterMover,
private readonly input: InputManager,
readonly config: PlayerControllerConfig = DEFAULT_PLAYER_CONFIG,
) {}
/** Called by CameraManager whenever the active camera changes. */
onCameraCut(): void {
if (!this.inputWasNeutral) {
this.lockedBasis = {
forward: this.cameraForward.clone(),
right: this.cameraRight.clone(),
};
}
}
get isQuickTurning(): boolean {
return this.quickTurnRemaining > 0;
}
get speed(): number {
return this.mover.state.lastMoveDistance;
}
/** `cameraLook` is the world-space direction the active camera faces. */
update(dt: number, cameraLook: Vector3): void {
this.cameraForward.copy(cameraLook).setY(0);
if (this.cameraForward.lengthSq() < 1e-8) this.cameraForward.set(0, 0, 1);
this.cameraForward.normalize();
this.cameraRight.copy(this.cameraForward).cross(UP).normalize();
const strafe = this.input.axis("left", "right");
const advance = this.input.axis("back", "forward");
const neutral = strafe === 0 && advance === 0;
if (neutral) {
this.lockedBasis = null;
}
this.inputWasNeutral = neutral;
if (this.input.wasPressed("quickTurn") && !this.isQuickTurning) {
this.beginQuickTurn();
}
if (this.isQuickTurning) {
this.advanceQuickTurn(dt);
this.mover.update(this.desiredVelocity.set(0, 0, 0), dt);
return;
}
if (this.scheme === "tank") {
this.updateTank(dt, strafe, advance);
} else {
this.updateCameraRelative(dt, strafe, advance);
}
this.mover.update(this.desiredVelocity, dt);
}
private updateTank(dt: number, strafe: number, advance: number): void {
// Facing +Z at yaw 0; turning right sweeps toward -X, so yaw decreases.
this.yaw -= strafe * this.config.turnSpeed * dt;
const running = this.input.isHeld("run") && advance > 0;
const speed =
(running ? this.config.runSpeed : this.config.walkSpeed) *
(advance < 0 ? this.config.backpedalFactor : 1);
this.moveDirection.set(Math.sin(this.yaw), 0, Math.cos(this.yaw));
this.desiredVelocity.copy(this.moveDirection).multiplyScalar(advance * speed);
}
private updateCameraRelative(dt: number, strafe: number, advance: number): void {
const basis = this.lockedBasis ?? { forward: this.cameraForward, right: this.cameraRight };
this.moveDirection
.copy(basis.right)
.multiplyScalar(strafe)
.addScaledVector(basis.forward, advance);
if (this.moveDirection.lengthSq() < 1e-8) {
this.desiredVelocity.set(0, 0, 0);
return;
}
this.moveDirection.normalize();
const targetYaw = Math.atan2(this.moveDirection.x, this.moveDirection.z);
const delta = angleDelta(this.yaw, targetYaw);
this.yaw += delta * (1 - Math.pow(this.config.facingSmoothing, dt));
const running = this.input.isHeld("run");
const speed = running ? this.config.runSpeed : this.config.walkSpeed;
this.desiredVelocity.copy(this.moveDirection).multiplyScalar(speed);
}
private beginQuickTurn(): void {
this.quickTurnFrom = this.yaw;
this.quickTurnTo = this.yaw + Math.PI;
this.quickTurnRemaining = this.config.quickTurnDuration;
}
private advanceQuickTurn(dt: number): void {
this.quickTurnRemaining = Math.max(0, this.quickTurnRemaining - dt);
const t = 1 - this.quickTurnRemaining / this.config.quickTurnDuration;
// Ease-out so the spin lands with weight instead of stopping dead.
const eased = 1 - Math.pow(1 - t, 3);
this.yaw = this.quickTurnFrom + (this.quickTurnTo - this.quickTurnFrom) * eased;
if (this.quickTurnRemaining === 0) this.yaw = this.quickTurnTo;
}
toggleScheme(): void {
this.scheme = this.scheme === "tank" ? "cameraRelative" : "tank";
this.lockedBasis = null;
}
}
+277
View File
@@ -0,0 +1,277 @@
/**
* Ambient types for box3d-wasm@0.2.0, which ships no `.d.ts`.
*
* Written against the runtime API as actually exposed by the embind bindings
* (verified by enumerating prototypes on the built wasm module), not from the
* README — the README documents a slightly larger surface than is bound.
*
* Notably ABSENT from the binding, contrary to the design doc:
* - no character mover / controller (see `systems/CharacterMover.ts`)
* - no triangle-mesh shape (rooms are box/hull compounds)
* - no shape casting or overlap queries (only ray casts)
*/
declare module "box3d-wasm" {
export interface B3Vec3 {
x: number;
y: number;
z: number;
}
export interface B3Quat {
x: number;
y: number;
z: number;
w: number;
}
export interface B3Transform {
position: B3Vec3;
rotation: B3Quat;
}
export interface B3Filter {
categoryBits?: number;
maskBits?: number;
groupIndex?: number;
}
export interface B3RayResult {
hit: boolean;
point: B3Vec3;
normal: B3Vec3;
fraction: number;
shapeUserData: number;
bodyUserData: number;
shape: B3Shape;
}
export interface B3ShapeOptionsBase {
density?: number;
friction?: number;
restitution?: number;
isSensor?: boolean;
filter?: B3Filter;
userData?: number;
enableContactEvents?: boolean;
enableSensorEvents?: boolean;
enableHitEvents?: boolean;
}
export interface B3BoxOptions extends B3ShapeOptionsBase {
halfExtents: B3Vec3;
center?: B3Vec3;
rotation?: B3Quat;
}
export interface B3SphereOptions extends B3ShapeOptionsBase {
radius: number;
center?: B3Vec3;
}
export interface B3CapsuleOptions extends B3ShapeOptionsBase {
radius: number;
height: number;
center?: B3Vec3;
rotation?: B3Quat;
}
export interface B3HullOptions extends B3ShapeOptionsBase {
points: B3Vec3[];
}
export class B3Shape {
getType(): string;
getUserData(): number;
setUserData(tag: number): void;
getAABB(): { lowerBound: B3Vec3; upperBound: B3Vec3 };
getFilter(): Required<B3Filter>;
setFilter(filter: B3Filter): void;
getFriction(): number;
setFriction(friction: number): void;
getRestitution(): number;
setRestitution(restitution: number): void;
getDensity(): number;
setDensity(density: number): void;
isSensor(): boolean;
isValid(): boolean;
rayCast(origin: B3Vec3, translation: B3Vec3): B3RayResult;
enableContactEvents(enable: boolean): void;
enableSensorEvents(enable: boolean): void;
enableHitEvents(enable: boolean): void;
destroy(): void;
delete(): void;
}
export type B3BodyType = "static" | "kinematic" | "dynamic";
export interface B3BodyOptions {
type?: B3BodyType;
position?: B3Vec3;
rotation?: B3Quat;
linearVelocity?: B3Vec3;
angularVelocity?: B3Vec3;
linearDamping?: number;
angularDamping?: number;
gravityScale?: number;
enableSleep?: boolean;
isAwake?: boolean;
isEnabled?: boolean;
isBullet?: boolean;
userData?: number;
name?: string;
motionLocks?: {
linearX?: boolean;
linearY?: boolean;
linearZ?: boolean;
angularX?: boolean;
angularY?: boolean;
angularZ?: boolean;
};
}
export class B3Body {
createBox(options: B3BoxOptions): B3Shape;
createSphere(options: B3SphereOptions): B3Shape;
createCapsule(options: B3CapsuleOptions): B3Shape;
createHull(options: B3HullOptions): B3Shape;
getPosition(): B3Vec3;
getRotation(): B3Quat;
getTransform(): B3Transform;
setTransform(transform: B3Transform): void;
/** Kinematic drive: moves the body to `transform` over `dt`, deriving velocity. */
setTargetTransform(transform: B3Transform, dt: number): void;
getLinearVelocity(): B3Vec3;
setLinearVelocity(v: B3Vec3): void;
getAngularVelocity(): B3Vec3;
setAngularVelocity(v: B3Vec3): void;
applyForce(force: B3Vec3, worldPoint: B3Vec3, wake: boolean): void;
applyForceToCenter(force: B3Vec3, wake: boolean): void;
applyLinearImpulse(impulse: B3Vec3, worldPoint: B3Vec3, wake: boolean): void;
applyLinearImpulseToCenter(impulse: B3Vec3, wake: boolean): void;
applyTorque(torque: B3Vec3, wake: boolean): void;
applyAngularImpulse(impulse: B3Vec3, wake: boolean): void;
applyMassFromShapes(): void;
getMass(): number;
getType(): B3BodyType;
setType(type: B3BodyType): void;
getUserData(): number;
setUserData(tag: number): void;
getName(): string;
setName(name: string): void;
getShapeCount(): number;
computeAABB(): { lowerBound: B3Vec3; upperBound: B3Vec3 };
getWorldPoint(local: B3Vec3): B3Vec3;
getLocalPoint(world: B3Vec3): B3Vec3;
getWorldCenterOfMass(): B3Vec3;
getLocalCenterOfMass(): B3Vec3;
getMotionLocks(): Record<string, boolean>;
setMotionLocks(locks: B3BodyOptions["motionLocks"]): void;
getLinearDamping(): number;
setLinearDamping(d: number): void;
getAngularDamping(): number;
setAngularDamping(d: number): void;
getGravityScale(): number;
setGravityScale(s: number): void;
isAwake(): boolean;
setAwake(awake: boolean): void;
enableSleep(enable: boolean): void;
isEnabled(): boolean;
setEnabled(enabled: boolean): void;
isBullet(): boolean;
setBullet(bullet: boolean): void;
isValid(): boolean;
destroy(): void;
delete(): void;
}
export interface B3SensorEvent {
sensorUserData: number;
visitorUserData: number;
}
export interface B3ContactEvent {
shapeUserDataA: number;
shapeUserDataB: number;
point?: B3Vec3;
normal?: B3Vec3;
approachSpeed?: number;
}
export interface B3BodyEvent {
userData: number;
position: B3Vec3;
rotation: B3Quat;
fellAsleep: boolean;
}
export interface B3WorldOptions {
gravity?: B3Vec3;
enableSleep?: boolean;
enableContinuous?: boolean;
/** Deluxe (threaded) build only; clamped to [1, maxWorkers]. */
workerCount?: number;
}
export class B3World {
constructor(options?: B3WorldOptions);
step(timeStep: number, subStepCount: number): void;
createBody(options?: B3BodyOptions): B3Body;
castRayClosest(origin: B3Vec3, translation: B3Vec3, filter?: B3Filter): B3RayResult;
explode(options: {
position: B3Vec3;
radius: number;
falloff?: number;
impulsePerArea?: number;
}): void;
getGravity(): B3Vec3;
setGravity(gravity: B3Vec3): void;
getAwakeBodyCount(): number;
getWorkerCount(): number;
getProfile(): Record<string, number>;
getBodyEvents(): B3BodyEvent[];
getContactEvents(): { begin: B3ContactEvent[]; end: B3ContactEvent[]; hit: B3ContactEvent[] };
getSensorEvents(): { begin: B3SensorEvent[]; end: B3SensorEvent[] };
enableSleeping(enable: boolean): void;
enableContinuous(enable: boolean): void;
isValid(): boolean;
destroy(): void;
delete(): void;
createDistanceJoint(a: B3Body, b: B3Body, options?: object): unknown;
createRevoluteJoint(a: B3Body, b: B3Body, options?: object): unknown;
createSphericalJoint(a: B3Body, b: B3Body, options?: object): unknown;
createPrismaticJoint(a: B3Body, b: B3Body, options?: object): unknown;
createWeldJoint(a: B3Body, b: B3Body, options?: object): unknown;
createMotorJoint(a: B3Body, b: B3Body, options?: object): unknown;
createWheelJoint(a: B3Body, b: B3Body, options?: object): unknown;
createParallelJoint(a: B3Body, b: B3Body, options?: object): unknown;
createFilterJoint(a: B3Body, b: B3Body, options?: object): unknown;
}
export interface Box3DModule {
World: typeof B3World;
Body: typeof B3Body;
Shape: typeof B3Shape;
/** True when the threaded "deluxe" build was loaded. */
threaded: boolean;
maxWorkers: number;
HEAPF32: Float32Array;
HEAPU32: Uint32Array;
HEAPU8: Uint8Array;
}
const Box3D: () => Promise<Box3DModule>;
export default Box3D;
}
declare module "box3d-wasm/standard" {
export { default } from "box3d-wasm";
}
declare module "box3d-wasm/deluxe" {
export { default } from "box3d-wasm";
}
+165
View File
@@ -0,0 +1,165 @@
import { createSignal, onCleanup, onMount, Show } from "solid-js";
import { Game, type DebugSnapshot, type HudSnapshot } from "../engine/Game.js";
import { ApiClient } from "../net/ApiClient.js";
import { NetSession } from "../net/NetSession.js";
import { START_ROOM, START_SPAWN } from "../world/rooms/index.js";
import { DebugPanel } from "./DebugPanel.jsx";
import { InventoryPanel } from "./InventoryPanel.jsx";
import { LobbyScreen } from "./LobbyScreen.jsx";
import { Prompt } from "./Prompt.jsx";
import "./ui.css";
/** Derives the signalling endpoint from the API origin. */
function signalingUrlFrom(apiBaseUrl: string): string {
return `${apiBaseUrl.replace(/^http/, "ws")}/ws`;
}
export default function App() {
let canvas!: HTMLCanvasElement;
let game: Game | null = null;
const [ready, setReady] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [hud, setHud] = createSignal<HudSnapshot | null>(null);
const [debug, setDebug] = createSignal<DebugSnapshot | null>(null);
const [inventoryOpen, setInventoryOpen] = createSignal(false);
const [debugOpen, setDebugOpen] = createSignal(false);
const [lobbyOpen, setLobbyOpen] = createSignal(false);
const [netNotice, setNetNotice] = createSignal<string | null>(null);
const api = new ApiClient();
/**
* Builds the session and hands it to the running Game. The world is already
* simulating, so joining co-op is additive — remote players just appear.
*/
const connectSession = async (lobbyCode: string): Promise<NetSession> => {
const instance = game;
if (!instance) throw new Error("game is not running yet");
const net = new NetSession(signalingUrlFrom(api.baseUrl));
net.onClientMessage = (message, from) => instance.handleClientMessage(message, from);
net.onHostMessage = (message) => instance.handleHostMessage(message);
net.onFatal = (message) => setNetNotice(message);
const token = api.sessionToken;
if (!token) throw new Error("sign in first");
await net.connect(token, lobbyCode);
instance.attachNet(net);
return net;
};
onMount(async () => {
const instance = new Game(canvas);
game = instance;
instance.onHud = setHud;
instance.onDebug = setDebug;
try {
await instance.start(START_ROOM, START_SPAWN);
setReady(true);
} catch (cause) {
// Most likely a WebGL2 or wasm failure. Surface it rather than leaving a
// black canvas with no explanation.
setError(cause instanceof Error ? cause.message : String(cause));
}
});
const onKeyDown = (event: KeyboardEvent) => {
switch (event.code) {
case "Tab":
event.preventDefault();
setInventoryOpen((open) => !open);
break;
case "Backquote":
setDebugOpen((open) => !open);
break;
case "Escape":
setInventoryOpen(false);
setLobbyOpen(false);
break;
case "KeyM":
setLobbyOpen((open) => !open);
break;
case "F5":
if (event.shiftKey) {
event.preventDefault();
game?.saveToLocalStorage();
}
break;
}
};
window.addEventListener("keydown", onKeyDown);
onCleanup(() => {
window.removeEventListener("keydown", onKeyDown);
game?.dispose();
game = null;
});
return (
<div class="viewport">
<canvas ref={canvas} class="game-canvas" />
<Show when={!ready() && !error()}>
<div class="overlay overlay--center">
<p class="loading">Loading physics core</p>
</div>
</Show>
<Show when={error()}>
{(message) => (
<div class="overlay overlay--center">
<div class="panel panel--error">
<h2>Failed to start</h2>
<p>{message()}</p>
</div>
</div>
)}
</Show>
<Show when={ready()}>
<div class="hud">
<Prompt prompt={hud()?.prompt ?? null} message={hud()?.message ?? null} />
<div class="hud__corner">
<span class="hud__scheme">
{hud()?.controlScheme === "tank" ? "TANK" : "CAMERA-RELATIVE"}
</span>
<span class="hud__hint">
C switch · TAB inventory · M co-op · ` debug · SHIFT+F5 save
</span>
</div>
<Show when={netNotice()}>
{(notice) => <div class="hud__net-notice">{notice()}</div>}
</Show>
<Show when={lobbyOpen()}>
<LobbyScreen
api={api}
onConnect={connectSession}
onClose={() => setLobbyOpen(false)}
/>
</Show>
<Show when={inventoryOpen()}>
<InventoryPanel
items={hud()?.items ?? []}
capacity={hud()?.capacity ?? 0}
usedSlots={hud()?.usedSlots ?? 0}
onCombine={(a, b) => game?.combineItems(a, b) ?? "Nothing happens."}
onClose={() => setInventoryOpen(false)}
/>
</Show>
<Show when={debugOpen()}>
<DebugPanel debug={debug()} />
</Show>
</div>
</Show>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Show } from "solid-js";
import type { DebugSnapshot } from "../engine/Game.js";
export interface DebugPanelProps {
debug: DebugSnapshot | null;
}
export function DebugPanel(props: DebugPanelProps) {
return (
<Show when={props.debug}>
{(debug) => (
<dl class="debug">
<dt>fps</dt>
<dd>{debug().fps}</dd>
<dt>camera zone</dt>
<dd>{debug().activeZone ?? "—"}</dd>
<dt>grounded</dt>
<dd>{debug().grounded ? "yes" : "no"}</dd>
<dt>position</dt>
<dd>
{debug().position.x} {debug().position.y} {debug().position.z}
</dd>
<dt>awake bodies</dt>
<dd>{debug().awakeBodies}</dd>
<dt>physics</dt>
<dd>{debug().threadedPhysics ? "box3d threaded" : "box3d single-thread"}</dd>
<dt>session</dt>
<dd>{debug().net ? `${debug().net!.role} · ${debug().net!.peers} peers` : "solo"}</dd>
<Show when={debug().net?.role === "guest"}>
<dt>drift</dt>
<dd>{debug().net!.drift}m</dd>
</Show>
</dl>
)}
</Show>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { createSignal, For, Show } from "solid-js";
import type { HudItem } from "../engine/Game.js";
export interface InventoryPanelProps {
items: HudItem[];
capacity: number;
usedSlots: number;
onCombine(itemA: string, itemB: string): string;
onClose(): void;
}
const hex = (color: number) => `#${color.toString(16).padStart(6, "0")}`;
/**
* Classic list-style inventory (GDD §6.3).
*
* Combining is two clicks: select a first item, then a second. That mirrors the
* original games' flow and avoids drag-and-drop, which never reads correctly at
* this UI scale.
*/
export function InventoryPanel(props: InventoryPanelProps) {
const [selected, setSelected] = createSignal<HudItem | null>(null);
const [combining, setCombining] = createSignal<HudItem | null>(null);
const [result, setResult] = createSignal<string | null>(null);
const handleClick = (item: HudItem) => {
const pending = combining();
if (pending) {
setResult(pending.id === item.id ? "Pick a different item." : props.onCombine(pending.id, item.id));
setCombining(null);
setSelected(null);
return;
}
setSelected(item);
setResult(null);
};
return (
<div class="panel panel--inventory">
<header class="panel__header">
<h2>Inventory</h2>
<span class="panel__count">
{props.usedSlots} / {props.capacity}
</span>
<button type="button" class="panel__close" onClick={() => props.onClose()}>
ESC
</button>
</header>
<div class="inventory__body">
<ul class="inventory__list">
<For each={props.items} fallback={<li class="inventory__empty">Empty</li>}>
{(item) => (
<li>
<button
type="button"
classList={{
inventory__item: true,
"inventory__item--selected": selected()?.id === item.id,
"inventory__item--combining": combining()?.id === item.id,
}}
onClick={() => handleClick(item)}
>
<span class="inventory__icon" style={{ background: hex(item.color) }} />
<span class="inventory__name">{item.name}</span>
<Show when={item.quantity > 1}>
<span class="inventory__qty">x{item.quantity}</span>
</Show>
<Show when={item.isKeyItem}>
<span class="inventory__key">KEY</span>
</Show>
</button>
</li>
)}
</For>
</ul>
<aside class="inventory__detail">
<Show
when={selected()}
fallback={<p class="inventory__hint">Select an item to examine it.</p>}
>
{(item) => (
<>
<h3>{item().name}</h3>
<p>{item().description}</p>
<button
type="button"
class="inventory__combine"
onClick={() => {
setCombining(item());
setResult("Choose an item to combine with.");
}}
>
Combine
</button>
</>
)}
</Show>
<Show when={result()}>{(text) => <p class="inventory__result">{text()}</p>}</Show>
</aside>
</div>
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
import { createSignal, For, Show } from "solid-js";
import type { PeerInfo } from "@psx/shared";
import { ApiError, type ApiClient, type Lobby } from "../net/ApiClient.js";
import { NetSession } from "../net/NetSession.js";
import { RemotePlayers } from "../net/RemotePlayers.js";
export interface LobbyScreenProps {
api: ApiClient;
/** Builds the session once a lobby is joined; returns the live NetSession. */
onConnect(lobbyCode: string): Promise<NetSession>;
onClose(): void;
}
type Stage = "auth" | "menu" | "lobby";
/** Co-op lobby — GDD §8, §9. */
export function LobbyScreen(props: LobbyScreenProps) {
const [stage, setStage] = createSignal<Stage>(props.api.isAuthenticated ? "menu" : "auth");
const [busy, setBusy] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [username, setUsername] = createSignal("");
const [password, setPassword] = createSignal("");
const [isRegistering, setIsRegistering] = createSignal(false);
const [joinCode, setJoinCode] = createSignal("");
const [lobby, setLobby] = createSignal<Lobby | null>(null);
const [peers, setPeers] = createSignal<PeerInfo[]>([]);
const [session, setSession] = createSignal<NetSession | null>(null);
/** Wraps an async action with the shared busy/error handling. */
const run = async (action: () => Promise<void>) => {
setBusy(true);
setError(null);
try {
await action();
} catch (cause) {
setError(cause instanceof ApiError ? cause.message : String(cause));
} finally {
setBusy(false);
}
};
const authenticate = () =>
run(async () => {
if (isRegistering()) {
await props.api.register(username(), `${username()}@local.test`, password());
} else {
await props.api.login(username(), password());
}
setStage("menu");
});
const enterLobby = async (joined: Lobby) => {
setLobby(joined);
const net = await props.onConnect(joined.code);
net.onPeersChanged = (list) => setPeers(list);
setPeers(net.peerList());
setSession(net);
setStage("lobby");
};
const hostGame = () => run(async () => enterLobby(await props.api.createLobby()));
const joinGame = () =>
run(async () => enterLobby(await props.api.joinLobby(joinCode().trim().toUpperCase())));
const toggleReady = () => {
const me = peers().find((peer) => peer.peerId === session()?.localPeerId);
session()?.setReady(!(me?.isReady ?? false));
};
const isHost = () => session()?.isHost ?? false;
return (
<div class="panel panel--lobby">
<header class="panel__header">
<h2>Co-op</h2>
<button type="button" class="panel__close" onClick={() => props.onClose()}>
ESC
</button>
</header>
<Show when={error()}>{(message) => <p class="lobby__error">{message()}</p>}</Show>
<Show when={stage() === "auth"}>
<form
class="lobby__form"
onSubmit={(event) => {
event.preventDefault();
void authenticate();
}}
>
<label>
Username
<input
type="text"
value={username()}
autocomplete="username"
onInput={(event) => setUsername(event.currentTarget.value)}
/>
</label>
<label>
Password
<input
type="password"
value={password()}
autocomplete="current-password"
onInput={(event) => setPassword(event.currentTarget.value)}
/>
</label>
<div class="lobby__actions">
<button type="submit" disabled={busy()}>
{isRegistering() ? "Create account" : "Sign in"}
</button>
<button type="button" class="lobby__link" onClick={() => setIsRegistering((v) => !v)}>
{isRegistering() ? "I already have an account" : "Create one instead"}
</button>
</div>
</form>
</Show>
<Show when={stage() === "menu"}>
<div class="lobby__menu">
<button type="button" disabled={busy()} onClick={() => void hostGame()}>
Host a session
</button>
<div class="lobby__join">
<input
type="text"
placeholder="CODE"
maxlength="5"
value={joinCode()}
onInput={(event) => setJoinCode(event.currentTarget.value.toUpperCase())}
/>
<button type="button" disabled={busy() || joinCode().length < 4} onClick={() => void joinGame()}>
Join
</button>
</div>
</div>
</Show>
<Show when={stage() === "lobby"}>
<div class="lobby__room">
<p class="lobby__code">
Room code <strong>{lobby()?.code}</strong>
</p>
<ul class="lobby__players">
<For each={peers()} fallback={<li class="lobby__waiting">Waiting for players</li>}>
{(peer) => (
<li>
<span
class="lobby__swatch"
style={{ background: RemotePlayers.cssColorFor(peer.peerId) }}
/>
<span class="lobby__name">{peer.displayName}</span>
<Show when={peer.isHost}>
<span class="lobby__tag">HOST</span>
</Show>
<span classList={{ lobby__ready: true, "lobby__ready--on": peer.isReady }}>
{peer.isReady ? "READY" : "…"}
</span>
</li>
)}
</For>
</ul>
<div class="lobby__actions">
<button type="button" onClick={toggleReady}>
Toggle ready
</button>
<Show when={isHost()}>
<button type="button" onClick={() => props.onClose()}>
Begin
</button>
</Show>
</div>
<p class="lobby__hint">
Everyone spawns into the room already running behind this menu. Close it to play.
</p>
</div>
</Show>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Show } from "solid-js";
export interface PromptProps {
prompt: { label: string; verb: string } | null;
message: string | null;
}
/** Interaction prompt and transient message line. */
export function Prompt(props: PromptProps) {
return (
<>
<Show when={props.prompt}>
{(prompt) => (
<div class="prompt">
<span class="prompt__key">E</span>
<span class="prompt__verb">{prompt().verb}</span>
<span class="prompt__label">{prompt().label}</span>
</div>
)}
</Show>
<Show when={props.message}>
{(message) => <div class="message">{message()}</div>}
</Show>
</>
);
}
+481
View File
@@ -0,0 +1,481 @@
.overlay {
position: absolute;
inset: 0;
display: flex;
pointer-events: none;
}
.overlay--center {
align-items: center;
justify-content: center;
}
.loading {
color: var(--ink-dim);
letter-spacing: 0.2em;
text-transform: uppercase;
font-size: 0.85rem;
}
/* The HUD layer itself never eats clicks; individual panels opt back in. */
.hud {
position: absolute;
inset: 0;
pointer-events: none;
}
.hud__corner {
position: absolute;
left: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.7rem;
}
.hud__scheme {
color: var(--accent);
letter-spacing: 0.18em;
}
.hud__hint {
color: var(--ink-dim);
letter-spacing: 0.08em;
}
/* --- interaction prompt --------------------------------------------------- */
.prompt {
position: absolute;
left: 50%;
bottom: 4.5rem;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.9rem;
background: var(--panel);
border: 2px solid var(--border);
letter-spacing: 0.1em;
font-size: 0.85rem;
}
.prompt__key {
display: inline-grid;
place-items: center;
width: 1.4rem;
height: 1.4rem;
border: 2px solid var(--accent);
color: var(--accent);
font-weight: bold;
}
.prompt__verb {
color: var(--ink-dim);
text-transform: uppercase;
}
.prompt__label {
color: var(--ink-bright);
}
.message {
position: absolute;
left: 50%;
bottom: 1.5rem;
transform: translateX(-50%);
max-width: 44rem;
padding: 0.6rem 1.1rem;
background: var(--panel);
border-left: 3px solid var(--accent);
color: var(--ink-bright);
font-size: 0.85rem;
line-height: 1.5;
}
/* --- panels --------------------------------------------------------------- */
.panel {
pointer-events: auto;
background: var(--panel);
border: 2px solid var(--border);
color: var(--ink);
}
.panel--error {
max-width: 30rem;
padding: 1.25rem 1.5rem;
border-color: var(--danger);
}
.panel--error h2 {
margin: 0 0 0.5rem;
color: var(--danger);
font-size: 1rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.panel--error p {
margin: 0;
font-size: 0.8rem;
line-height: 1.6;
color: var(--ink-dim);
}
.panel--inventory {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(46rem, 90vw);
}
.panel__header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
border-bottom: 2px solid var(--border);
}
.panel__header h2 {
margin: 0;
flex: 1;
font-size: 0.9rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-bright);
}
.panel__count {
color: var(--ink-dim);
font-size: 0.8rem;
}
.panel__close,
.inventory__combine {
background: transparent;
border: 2px solid var(--border);
color: var(--ink-dim);
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.12em;
padding: 0.2rem 0.5rem;
cursor: pointer;
}
.panel__close:hover,
.inventory__combine:hover {
border-color: var(--accent);
color: var(--accent);
}
/* --- inventory ------------------------------------------------------------ */
.inventory__body {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 16rem;
}
.inventory__list {
margin: 0;
padding: 0.5rem;
list-style: none;
border-right: 2px solid var(--border);
}
.inventory__empty,
.inventory__hint {
padding: 0.6rem;
color: var(--ink-dim);
font-size: 0.78rem;
}
.inventory__item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.45rem 0.6rem;
background: transparent;
border: 2px solid transparent;
color: var(--ink);
font-family: var(--mono);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.inventory__item:hover {
border-color: var(--border);
}
.inventory__item--selected {
border-color: var(--accent);
color: var(--ink-bright);
}
.inventory__item--combining {
border-color: var(--danger);
}
.inventory__icon {
width: 0.9rem;
height: 0.9rem;
border: 1px solid rgba(0, 0, 0, 0.6);
flex: none;
}
.inventory__name {
flex: 1;
}
.inventory__qty {
color: var(--ink-dim);
}
.inventory__key {
color: var(--accent);
font-size: 0.6rem;
letter-spacing: 0.1em;
}
.inventory__detail {
padding: 1rem;
}
.inventory__detail h3 {
margin: 0 0 0.5rem;
font-size: 0.85rem;
letter-spacing: 0.12em;
color: var(--ink-bright);
text-transform: uppercase;
}
.inventory__detail p {
margin: 0 0 1rem;
font-size: 0.78rem;
line-height: 1.65;
color: var(--ink-dim);
}
.inventory__result {
color: var(--accent);
}
/* --- debug ---------------------------------------------------------------- */
.debug {
position: absolute;
top: 1rem;
right: 1rem;
margin: 0;
padding: 0.7rem 0.9rem;
display: grid;
grid-template-columns: auto auto;
gap: 0.15rem 0.9rem;
background: var(--panel);
border: 2px solid var(--border);
font-size: 0.7rem;
}
.debug dt {
color: var(--ink-dim);
letter-spacing: 0.08em;
}
.debug dd {
margin: 0;
color: var(--ink-bright);
text-align: right;
}
/* --- multiplayer ---------------------------------------------------------- */
.hud__net-notice {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
padding: 0.4rem 0.9rem;
background: var(--panel);
border: 2px solid var(--danger);
color: var(--danger);
font-size: 0.75rem;
letter-spacing: 0.08em;
}
.panel--lobby {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(28rem, 92vw);
}
.lobby__error {
margin: 0;
padding: 0.6rem 1rem;
color: var(--danger);
font-size: 0.75rem;
border-bottom: 2px solid var(--border);
}
.lobby__form,
.lobby__menu,
.lobby__room {
display: flex;
flex-direction: column;
gap: 0.8rem;
padding: 1rem;
}
.lobby__form label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-dim);
}
.lobby__form input,
.lobby__join input {
background: rgba(0, 0, 0, 0.4);
border: 2px solid var(--border);
color: var(--ink-bright);
font-family: var(--mono);
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
.lobby__form input:focus,
.lobby__join input:focus {
outline: none;
border-color: var(--accent);
}
.lobby__actions {
display: flex;
align-items: center;
gap: 0.6rem;
}
.lobby__actions button,
.lobby__menu > button,
.lobby__join button {
background: transparent;
border: 2px solid var(--border);
color: var(--ink);
font-family: var(--mono);
font-size: 0.75rem;
letter-spacing: 0.1em;
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.lobby__actions button:hover:not(:disabled),
.lobby__menu > button:hover:not(:disabled),
.lobby__join button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.lobby__actions button:disabled,
.lobby__menu > button:disabled,
.lobby__join button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.lobby__link {
border: none !important;
color: var(--ink-dim) !important;
text-decoration: underline;
padding: 0 !important;
}
.lobby__join {
display: flex;
gap: 0.6rem;
}
.lobby__join input {
flex: 1;
letter-spacing: 0.4em;
text-align: center;
}
.lobby__code {
margin: 0;
font-size: 0.8rem;
color: var(--ink-dim);
}
.lobby__code strong {
color: var(--accent);
font-size: 1.4rem;
letter-spacing: 0.3em;
display: block;
margin-top: 0.3rem;
}
.lobby__players {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.lobby__players li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0.5rem;
border: 2px solid var(--border);
font-size: 0.78rem;
}
.lobby__swatch {
width: 0.8rem;
height: 0.8rem;
border: 1px solid rgba(0, 0, 0, 0.6);
}
.lobby__name {
flex: 1;
color: var(--ink-bright);
}
.lobby__tag {
color: var(--accent);
font-size: 0.6rem;
letter-spacing: 0.1em;
}
.lobby__ready {
color: var(--ink-dim);
font-size: 0.65rem;
letter-spacing: 0.1em;
}
.lobby__ready--on {
color: #7fa382;
}
.lobby__waiting,
.lobby__hint {
color: var(--ink-dim);
font-size: 0.72rem;
line-height: 1.5;
}
.lobby__hint {
margin: 0;
}
+190
View File
@@ -0,0 +1,190 @@
import {
AnimationMixer,
Box3,
Group,
LoopOnce,
LoopRepeat,
Mesh,
MeshLambertMaterial,
NearestFilter,
SkinnedMesh,
Vector2,
type AnimationAction,
type AnimationClip,
} from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { applyPSXMaterial } from "../post/PSXMaterial.js";
/**
* Runtime side of the Milestone 3 harness: loads a generated character and
* drives its canonical clips.
*
* Clips are addressed by name. The harness emits Idle / Walk / Run / QuickTurn
* for the player and Slumped for ambient bodies, so this controller works with
* any character the pipeline produces.
*/
export type CharacterState = "idle" | "walk" | "run" | "quickTurn" | "slumped";
const CLIP_FOR_STATE: Record<CharacterState, string> = {
idle: "Idle",
walk: "Walk",
run: "Run",
quickTurn: "QuickTurn",
slumped: "Slumped",
};
/** Seconds to cross-fade between locomotion states. */
const BLEND_DURATION = 0.18;
export interface CharacterModelOptions {
/** Vertex snap grid, matched to the PSX pipeline's virtual framebuffer. */
snapResolution?: Vector2;
/**
* Clip to start in. Ambient corpses use `"slumped"` so they don't stand in
* the breathing Idle pose.
*/
initialState?: CharacterState;
}
export class CharacterModel {
readonly group = new Group();
private readonly mixer: AnimationMixer;
private readonly actions = new Map<string, AnimationAction>();
private current: CharacterState = "idle";
private locked = false;
private constructor(
scene: Group,
clips: AnimationClip[],
options: CharacterModelOptions,
) {
this.group.add(scene);
this.mixer = new AnimationMixer(scene);
scene.traverse((object) => {
const mesh = object as Mesh;
if (!mesh.isMesh) return;
// Vertex-colour cast: COLOR_0 only. Textured cast: baseColor map from
// the Grok UV pipeline (nearest-filtered in the GLB sampler).
const loaded = mesh.material as MeshLambertMaterial;
const loadedMap = !Array.isArray(loaded) && loaded.map ? loaded.map : null;
const material = new MeshLambertMaterial({
vertexColors: !loadedMap,
flatShading: true,
...(loadedMap ? { map: loadedMap } : {}),
});
if (loadedMap) {
loadedMap.magFilter = NearestFilter;
loadedMap.minFilter = NearestFilter;
loadedMap.needsUpdate = true;
}
applyPSXMaterial(
material,
options.snapResolution ? { snapResolution: options.snapResolution } : {},
);
mesh.material = material;
// Generated characters are small in screen space and always near the
// camera; skipping frustum culling avoids popping during camera cuts.
if ((mesh as SkinnedMesh).isSkinnedMesh) mesh.frustumCulled = false;
});
for (const clip of clips) {
const action = this.mixer.clipAction(clip);
const once = clip.name === "QuickTurn" || clip.name === "Slumped";
action.setLoop(once ? LoopOnce : LoopRepeat, Infinity);
action.clampWhenFinished = once;
this.actions.set(clip.name, action);
}
this.play(options.initialState ?? "idle");
// Apply the first frame immediately so a slumped body is posed before
// the next rendered frame (otherwise they flash in T/A-pose for a tick).
this.mixer.update(0);
}
static async load(url: string, options: CharacterModelOptions = {}): Promise<CharacterModel> {
const loader = new GLTFLoader();
const gltf = await loader.loadAsync(url);
return new CharacterModel(gltf.scene as Group, gltf.animations, options);
}
/**
* Pick a locomotion state from speed. The thresholds sit slightly below the
* controller's walk and run speeds so the animation commits rather than
* flickering between clips at the boundary.
*/
setSpeed(metresPerSecond: number): void {
if (this.locked || this.current === "slumped") return;
if (metresPerSecond < 0.15) this.play("idle");
else if (metresPerSecond < 2.6) this.play("walk");
else this.play("run");
}
play(state: CharacterState): void {
if (state === this.current && this.actions.get(CLIP_FOR_STATE[state])?.isRunning()) return;
const next = this.actions.get(CLIP_FOR_STATE[state]);
const previous = this.actions.get(CLIP_FOR_STATE[this.current]);
if (!next) return;
next.reset().play();
if (state === "slumped") {
next.setEffectiveWeight(1);
// Hold the collapsed pose; no blend back to idle.
this.locked = true;
}
if (previous && previous !== next) {
previous.crossFadeTo(next, state === "slumped" ? 0 : BLEND_DURATION, false);
}
this.current = state;
}
/** Plays the quick-turn once and holds locomotion until it finishes. */
playQuickTurn(durationSeconds: number): void {
if (this.locked && this.current === "quickTurn") return;
if (this.current === "slumped") return;
this.play("quickTurn");
this.locked = true;
setTimeout(() => {
this.locked = false;
this.current = "idle";
}, durationSeconds * 1000);
}
update(dt: number): void {
this.mixer.update(dt);
}
setTransform(x: number, y: number, z: number, yaw: number): void {
this.group.position.set(x, y, z);
this.group.rotation.y = yaw;
}
/**
* Snap the lowest skinned vertex to y=0 in world space after pose/animation
* so ambient bodies don't float above the floor or sink through it.
*/
groundToFloor(): void {
this.group.updateWorldMatrix(true, true);
const box = new Box3().setFromObject(this.group);
if (!Number.isFinite(box.min.y)) return;
this.group.position.y -= box.min.y;
}
dispose(): void {
this.mixer.stopAllAction();
this.group.traverse((object) => {
const mesh = object as Mesh;
if (!mesh.isMesh) return;
mesh.geometry.dispose();
const material = mesh.material;
if (Array.isArray(material)) material.forEach((entry) => entry.dispose());
else material.dispose();
});
}
}
+99
View File
@@ -0,0 +1,99 @@
import { BoxGeometry, Color, Group, Mesh, MeshLambertMaterial } from "three";
import { applyPSXMaterial } from "../post/PSXMaterial.js";
/**
* Fallback player mesh: a blocky figure roughly matching the mover capsule.
*
* The harness character (`CharacterModel` + `assets/characters/survivor.glb`)
* is the primary avatar. This remains as a drop-in if the GLB fails to load,
* and for remote peers until they share the same skinned model. Proportions
* still matter — the silhouette has to read at 240p (GDD §14).
*/
export interface PlayerAvatarOptions {
totalHeight: number;
color?: number;
}
export class PlayerAvatar {
readonly group = new Group();
private readonly leftArm: Mesh;
private readonly rightArm: Mesh;
private readonly leftLeg: Mesh;
private readonly rightLeg: Mesh;
private stride = 0;
constructor(options: PlayerAvatarOptions) {
const height = options.totalHeight;
const color = options.color ?? 0x9aa3b2;
const skin = () =>
applyPSXMaterial(new MeshLambertMaterial({ color, flatShading: true }));
const dark = () =>
applyPSXMaterial(
new MeshLambertMaterial({ color: new Color(color).multiplyScalar(0.55), flatShading: true }),
);
const legHeight = height * 0.45;
const torsoHeight = height * 0.33;
const headSize = height * 0.16;
const torso = new Mesh(new BoxGeometry(0.42, torsoHeight, 0.26), skin());
torso.position.y = legHeight + torsoHeight * 0.5;
const head = new Mesh(new BoxGeometry(headSize, headSize, headSize), skin());
head.position.y = legHeight + torsoHeight + headSize * 0.5;
this.leftArm = new Mesh(new BoxGeometry(0.12, torsoHeight * 0.95, 0.14), dark());
this.leftArm.position.set(-0.3, legHeight + torsoHeight * 0.52, 0);
this.rightArm = new Mesh(new BoxGeometry(0.12, torsoHeight * 0.95, 0.14), dark());
this.rightArm.position.set(0.3, legHeight + torsoHeight * 0.52, 0);
this.leftLeg = new Mesh(new BoxGeometry(0.16, legHeight, 0.18), dark());
this.leftLeg.position.set(-0.11, legHeight * 0.5, 0);
this.rightLeg = new Mesh(new BoxGeometry(0.16, legHeight, 0.18), dark());
this.rightLeg.position.set(0.11, legHeight * 0.5, 0);
this.group.add(torso, head, this.leftArm, this.rightArm, this.leftLeg, this.rightLeg);
}
/**
* Procedural walk cycle driven by distance travelled rather than by time, so
* the legs stay in sync when the player is blocked by a wall.
*/
update(distanceMoved: number, dt: number): void {
this.stride += distanceMoved * 3.4;
const moving = distanceMoved > 1e-4;
const amplitude = moving ? 0.7 : 0;
const swing = Math.sin(this.stride) * amplitude;
this.leftLeg.rotation.x = swing;
this.rightLeg.rotation.x = -swing;
this.leftArm.rotation.x = -swing * 0.6;
this.rightArm.rotation.x = swing * 0.6;
// Settle back to neutral when standing still.
if (!moving) {
const settle = 1 - Math.pow(0.0001, dt);
for (const limb of [this.leftLeg, this.rightLeg, this.leftArm, this.rightArm]) {
limb.rotation.x += (0 - limb.rotation.x) * settle;
}
}
}
setTransform(x: number, y: number, z: number, yaw: number): void {
this.group.position.set(x, y, z);
this.group.rotation.y = yaw;
}
dispose(): void {
this.group.traverse((object) => {
if (!(object instanceof Mesh)) return;
object.geometry.dispose();
(object.material as MeshLambertMaterial).dispose();
});
}
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { AmbientLight, DirectionalLight, Light, PointLight, Scene } from "three";
import type { RoomDef } from "@psx/shared";
import { PhysicsWorld } from "../engine/PhysicsWorld.js";
import { GALLERY } from "./rooms/gallery.js";
import { RoomBuilder } from "./RoomBuilder.js";
/**
* RoomBuilder touches no WebGL — geometry and material construction are pure
* data until a renderer compiles them — so the whole room can be built in Node.
*
* The ambient-light assertion exists because it regressed once: `RoomDef`
* carried an `ambientLight` field that nothing ever instantiated, leaving the
* scene lit only by point lights and pure black everywhere else.
*/
/** Gallery plus a flag-gated slab so removeOnFlag behaviour stays covered. */
const ROOM_WITH_SHUTTER: RoomDef = {
...GALLERY,
geometry: [
...GALLERY.geometry,
{
id: "shutter",
position: { x: 0, y: 1.5, z: -4 },
visual: { kind: "box", size: { x: 2, y: 3, z: 0.2 }, color: 0x5c4a3a },
colliders: [{ kind: "box", halfExtents: { x: 1, y: 1.5, z: 0.1 } }],
removeOnFlag: "study-shutter-open",
},
],
};
async function build(flags: string[] = [], room: RoomDef = GALLERY) {
const scene = new Scene();
const physics = new PhysicsWorld();
await physics.init();
const builder = new RoomBuilder(scene, physics);
const built = builder.build(room, new Set(flags));
return { scene, physics, builder, room: built };
}
const lightsOf = <T extends Light>(scene: Scene, kind: new (...args: never[]) => T): T[] => {
const found: T[] = [];
scene.traverse((object) => {
if (object instanceof kind) found.push(object);
});
return found;
};
describe("RoomBuilder lighting", () => {
it("instantiates the room's ambient light", async () => {
const { scene, physics } = await build();
const ambient = lightsOf(scene, AmbientLight);
expect(ambient).toHaveLength(1);
expect(ambient[0]!.intensity).toBeGreaterThan(0);
expect(ambient[0]!.color.getHex()).toBe(GALLERY.ambientLight.color);
physics.dispose();
});
it("instantiates every light declared on the room", async () => {
const { scene, physics } = await build();
const expectedPoints = GALLERY.lights.filter((l) => l.kind === "point").length;
const expectedDirectional = GALLERY.lights.filter((l) => l.kind === "directional").length;
expect(lightsOf(scene, PointLight)).toHaveLength(expectedPoints);
expect(lightsOf(scene, DirectionalLight)).toHaveLength(expectedDirectional);
physics.dispose();
});
it("gives point lights enough intensity to reach across the room", async () => {
const { scene, physics } = await build();
// RoomBuilder floors point intensity and uses decay 1 (game-style falloff).
for (const light of lightsOf(scene, PointLight)) {
expect(light.intensity).toBeGreaterThanOrEqual(8);
expect(light.decay).toBe(1);
}
physics.dispose();
});
});
describe("RoomBuilder flag-gated geometry", () => {
it("builds the shutter when its flag is unset", async () => {
const { scene, physics } = await build([], ROOM_WITH_SHUTTER);
expect(scene.getObjectByName("shutter")).toBeDefined();
expect(physics.get("geo:shutter")).toBeDefined();
physics.dispose();
});
it("omits the shutter, mesh and collider, when the flag is already set", async () => {
const { scene, physics } = await build(["study-shutter-open"], ROOM_WITH_SHUTTER);
expect(scene.getObjectByName("shutter")).toBeUndefined();
expect(physics.get("geo:shutter")).toBeUndefined();
physics.dispose();
});
it("removes both mesh and collider when the flag fires during play", async () => {
const { scene, physics, builder } = await build([], ROOM_WITH_SHUTTER);
builder.removeGeometry("shutter");
expect(scene.getObjectByName("shutter")).toBeUndefined();
expect(physics.get("geo:shutter")).toBeUndefined();
physics.dispose();
});
});
describe("campaign rooms", () => {
it("registers nine linked precinct rooms", async () => {
const { ROOM_CATALOGUE } = await import("./rooms/index.js");
expect(ROOM_CATALOGUE.size).toBe(9);
expect(ROOM_CATALOGUE.has("manor-foyer")).toBe(true);
expect(ROOM_CATALOGUE.has("manor-cellar")).toBe(true);
expect(ROOM_CATALOGUE.has("precinct-office")).toBe(true);
expect(ROOM_CATALOGUE.has("precinct-interview")).toBe(true);
expect(ROOM_CATALOGUE.has("precinct-lounge")).toBe(true);
});
});
+222
View File
@@ -0,0 +1,222 @@
import {
AmbientLight,
BoxGeometry,
Color,
DirectionalLight,
Fog,
Group,
Mesh,
MeshLambertMaterial,
PlaneGeometry,
PointLight,
Scene,
Vector2,
Vector3,
} from "three";
import { ConvexGeometry } from "three/examples/jsm/geometries/ConvexGeometry.js";
import type { InteractableDef, RoomDef, StaticGeometryDef } from "@psx/shared";
import type { PhysicsWorld } from "../engine/PhysicsWorld.js";
import { applyPSXMaterial } from "../post/PSXMaterial.js";
import { INTERACTABLE_PREFIX } from "../systems/InteractionSystem.js";
/**
* Turns a declarative `RoomDef` into three.js objects plus box3d bodies.
*
* Visual and collider are authored separately rather than derived from each
* other. That is the PSX-correct arrangement — collision was always coarser
* than the art — and it keeps the mover's ray probes cheap.
*
* Materials are MeshLambert, not MeshStandard: the era had no PBR, and
* per-vertex diffuse lighting is closer to what the hardware actually did.
*/
const STATIC_PREFIX = "geo:";
export interface BuiltRoom {
group: Group;
dispose(): void;
}
export class RoomBuilder {
private readonly meshesByGeometryId = new Map<string, Mesh>();
private readonly interactableMeshes = new Map<string, Mesh>();
private group = new Group();
private readonly snapResolution = new Vector2(320, 240);
constructor(
private readonly scene: Scene,
private readonly physics: PhysicsWorld,
) {}
setSnapResolution(width: number, height: number): void {
this.snapResolution.set(width, height);
}
build(room: RoomDef, activeFlags: ReadonlySet<string>): BuiltRoom {
this.group = new Group();
this.group.name = room.id;
this.scene.fog = new Fog(room.fog.color, room.fog.near, room.fog.far);
this.scene.background = new Color(room.fog.color);
this.buildLights(room);
for (const def of room.geometry) {
if (def.removeOnFlag && activeFlags.has(def.removeOnFlag)) continue;
this.buildStatic(def);
}
for (const def of room.interactables) {
this.buildInteractableVisual(def);
}
this.scene.add(this.group);
return {
group: this.group,
dispose: () => this.dispose(),
};
}
private buildLights(room: RoomDef): void {
// Base fill so unlit corners stay readable. Authored intensities are
// "game units" (RE-style), not candela — we scale them into three's
// physical light model below.
const ambient = new AmbientLight(
room.ambientLight.color,
// Floor so a mistyped 0.1 ambient cannot black out a room again.
Math.max(0.35, room.ambientLight.intensity),
);
ambient.name = "ambient";
this.group.add(ambient);
for (const [index, light] of room.lights.entries()) {
if (light.kind === "point") {
// decay 1 is closer to classic console falloff than physical inverse-
// square (decay 2), which snuffs out small rooms unless intensities
// are cranked into the hundreds.
const point = new PointLight(
light.color,
Math.max(8, light.intensity),
light.distance ?? 0,
1,
);
point.position.set(light.position.x, light.position.y, light.position.z);
point.name = `light-${index}`;
this.group.add(point);
} else {
const directional = new DirectionalLight(
light.color,
Math.max(0.35, light.intensity),
);
directional.position.set(light.position.x, light.position.y, light.position.z);
directional.name = `light-${index}`;
this.group.add(directional);
}
}
}
private buildStatic(def: StaticGeometryDef): void {
const mesh = this.buildVisual(def);
if (mesh) {
mesh.name = def.id;
mesh.position.set(def.position.x, def.position.y, def.position.z);
if (def.yaw) mesh.rotation.y = def.yaw;
this.group.add(mesh);
this.meshesByGeometryId.set(def.id, mesh);
}
if (def.colliders.length > 0) {
this.physics.createStatic({
id: STATIC_PREFIX + def.id,
position: def.position,
colliders: def.colliders,
material: def.material,
});
}
}
private buildVisual(def: StaticGeometryDef): Mesh | null {
const { visual } = def;
switch (visual.kind) {
case "box":
return new Mesh(
new BoxGeometry(visual.size.x, visual.size.y, visual.size.z),
this.makeMaterial(visual.color),
);
case "plane": {
const mesh = new Mesh(
new PlaneGeometry(visual.size[0], visual.size[1]),
this.makeMaterial(visual.color),
);
mesh.rotation.x = -Math.PI / 2;
return mesh;
}
case "hull": {
const points = visual.points.map((p) => new Vector3(p.x, p.y, p.z));
return new Mesh(new ConvexGeometry(points), this.makeMaterial(visual.color));
}
case "none":
return null;
}
}
private buildInteractableVisual(def: InteractableDef): void {
if (!def.visual || def.visual.kind === "none") return;
const mesh = new Mesh(
new BoxGeometry(def.visual.size.x, def.visual.size.y, def.visual.size.z),
this.makeMaterial(def.visual.color, true),
);
mesh.name = INTERACTABLE_PREFIX + def.id;
mesh.position.set(def.position.x, def.position.y, def.position.z);
this.group.add(mesh);
this.interactableMeshes.set(def.id, mesh);
}
private makeMaterial(color: number, emissive = false): MeshLambertMaterial {
const material = new MeshLambertMaterial({
color,
flatShading: true,
...(emissive ? { emissive: new Color(color).multiplyScalar(0.35) } : {}),
});
return applyPSXMaterial(material, { snapResolution: this.snapResolution });
}
/** Despawn a flag-gated piece of geometry, mesh and collider together. */
removeGeometry(geometryId: string): void {
const mesh = this.meshesByGeometryId.get(geometryId);
if (mesh) {
this.group.remove(mesh);
mesh.geometry.dispose();
(mesh.material as MeshLambertMaterial).dispose();
this.meshesByGeometryId.delete(geometryId);
}
this.physics.remove(STATIC_PREFIX + geometryId);
}
removeInteractableVisual(interactableId: string): void {
const mesh = this.interactableMeshes.get(interactableId);
if (!mesh) return;
this.group.remove(mesh);
mesh.geometry.dispose();
(mesh.material as MeshLambertMaterial).dispose();
this.interactableMeshes.delete(interactableId);
}
getInteractableMesh(interactableId: string): Mesh | undefined {
return this.interactableMeshes.get(interactableId);
}
private dispose(): void {
this.group.traverse((object) => {
if (!(object instanceof Mesh)) return;
object.geometry.dispose();
const material = object.material;
if (Array.isArray(material)) material.forEach((m) => m.dispose());
else material.dispose();
});
this.scene.remove(this.group);
this.meshesByGeometryId.clear();
this.interactableMeshes.clear();
}
}
+151
View File
@@ -0,0 +1,151 @@
import type { Vec3 } from "@psx/shared";
import type { CharacterState } from "./CharacterModel.js";
import civilianFUrl from "../../../assets/characters/civilian-f.glb?url";
import civilianMUrl from "../../../assets/characters/civilian-m.glb?url";
import groundskeeperUrl from "../../../assets/characters/groundskeeper.glb?url";
import officerUrl from "../../../assets/characters/officer.glb?url";
import oliviaUrl from "../../../assets/characters/olivia.glb?url";
import researcherUrl from "../../../assets/characters/researcher.glb?url";
import survivorUrl from "../../../assets/characters/survivor.glb?url";
/**
* Modular cast — each GLB is a recipe of body + parts from the character
* creator (`pnpm cast` / `tools/src/creator/presets.ts`).
*
* Campaign tone: precinct investigation inside a PSX survival-horror shell.
* You walk the building, question people, gather evidence, and open locked wings.
*/
export interface CharacterDef {
id: string;
name: string;
url: string;
/** Default world height the GLB was exported at. */
height: number;
}
export const CHARACTER_CATALOGUE: ReadonlyMap<string, CharacterDef> = new Map(
[
{ id: "survivor", name: "Detective", url: survivorUrl, height: 1.72 },
{ id: "olivia", name: "Det. Reyes", url: oliviaUrl, height: 1.63 },
{ id: "researcher", name: "Dr. Hale", url: researcherUrl, height: 1.66 },
{ id: "officer", name: "Sgt. Marrow", url: officerUrl, height: 1.82 },
{ id: "groundskeeper", name: "Ellis", url: groundskeeperUrl, height: 1.75 },
{ id: "civilian-f", name: "Nora Voss", url: civilianFUrl, height: 1.64 },
{ id: "civilian-m", name: "Garvey Kane", url: civilianMUrl, height: 1.76 },
].map((entry) => [entry.id, entry]),
);
export const PLAYER_CHARACTER_ID = "survivor";
/** Ambient cast placements — living NPCs (and crime-scene bodies) per room. */
export interface AmbientPlacement {
characterId: string;
roomId: string;
position: Vec3;
yaw: number;
/** Defaults to idle — witnesses stand and wait to be questioned. */
pose?: CharacterState;
/** Optional examine / interview prompt wired as an interactable nearby. */
examine?: { id: string; label: string; text: string };
}
/**
* One dedicated room per NPC on Level 1 (Ashgrove Precinct).
*
* Lobby ………… Ellis (custodian)
* Detective Office … Det. Reyes (Olivia)
* Interview Room … Nora Voss
* Witness Lounge … Garvey Kane
* Records ………… Dr. Hale
* Evidence Vault … Sgt. Marrow
* Player ………… survivor (Detective)
*/
export const AMBIENT_PLACEMENTS: readonly AmbientPlacement[] = [
{
characterId: "groundskeeper",
roomId: "manor-foyer",
position: { x: 3.2, y: 0, z: -1.8 },
yaw: -Math.PI / 2,
pose: "idle",
examine: {
id: "talk-ellis",
label: "Ellis",
text:
'Ellis wipes grease off a ring of keys. "Night shift only. I mopped the lobby, then the upstairs hall went quiet. No shouting — just the lights flickering once. If you want the cellar, that key left the board years ago."',
},
},
{
characterId: "olivia",
roomId: "precinct-office",
position: { x: -2.4, y: 0, z: -1.6 },
yaw: Math.PI * 0.25,
pose: "idle",
examine: {
id: "talk-olivia",
label: "Det. Reyes",
text:
'Reyes doesn\'t look up from the case board. "Victim was last seen leaving the records wing. Interview Voss and Kane first — their stories don\'t line up. Hale\'s notes are sealed until you turn the corridor valve. I\'ll hold the line here."',
},
},
{
characterId: "civilian-f",
roomId: "precinct-interview",
position: { x: 1.6, y: 0, z: -1.2 },
yaw: Math.PI,
pose: "idle",
examine: {
id: "talk-nora",
label: "Nora Voss",
text:
'"I was in the lounge. Kane said he was getting coffee — he was gone too long. When he came back he smelled like the cellar, not the kitchen. That\'s all I\'m saying without counsel." Her hands shake, but her eyes stay fixed on the two-way glass.',
},
},
{
characterId: "civilian-m",
roomId: "precinct-lounge",
position: { x: -2.2, y: 0, z: 1.4 },
yaw: Math.PI * 0.6,
pose: "idle",
examine: {
id: "talk-garvey",
label: "Garvey Kane",
text:
'Kane straightens his tie. "I never went downstairs. Nora is scared and inventing stairs. I sat here, I drank cold coffee, I heard the shutter in the corridor grind. Ask Marrow — if he ever comes up from evidence."',
},
},
{
characterId: "researcher",
roomId: "manor-study",
position: { x: -3.0, y: 0, z: -2.2 },
yaw: Math.PI * 0.35,
pose: "idle",
examine: {
id: "talk-hale",
label: "Dr. Hale",
text:
'Dr. Hale keeps one glove on. "The sample from the vault reacts to the manor\'s lower air — not poison, not mould. Something that writes itself into the floorplan. The brass key on my desk opens the break-room wing. Don\'t go alone."',
},
},
{
characterId: "officer",
roomId: "manor-cellar",
position: { x: 2.2, y: 0, z: -2.4 },
yaw: Math.PI * 0.85,
pose: "slumped",
examine: {
id: "examine-officer",
label: "Sgt. Marrow",
text:
"Uniform. Badge. Journal still in his hand. The only person who made it into evidence vault alone — and the only one who never left. His last note points back at Kane's coffee story.",
},
},
];
export function placementsForRoom(roomId: string): AmbientPlacement[] {
return AMBIENT_PLACEMENTS.filter((placement) => placement.roomId === roomId);
}
/** Every ambient NPC that must have a dedicated Level-1 room. */
export const LEVEL1_NPC_ROOM_IDS: ReadonlyMap<string, string> = new Map(
AMBIENT_PLACEMENTS.map((p) => [p.characterId, p.roomId]),
);
+8
View File
@@ -0,0 +1,8 @@
/**
* Back-compat export for tests and older imports.
*
* The live campaign starts in the precinct lobby; the squad hall is the former
* single-room demo with the crank puzzle pieces.
*/
export { GALLERY as DEMO_ROOM } from "./rooms/gallery.js";
export { START_ROOM, START_SPAWN, getRoom, ROOM_CATALOGUE } from "./rooms/index.js";
+101
View File
@@ -0,0 +1,101 @@
import type { CombinationDef, ItemDef } from "@psx/shared";
/** Item catalogue for the Ashgrove Precinct Level 1 campaign. */
export const ITEM_DEFS: ItemDef[] = [
{
id: "crank-handle",
name: "Crank Handle",
description: "A heavy iron handle, snapped off at the base. The square socket looks intact.",
category: "key",
isKeyItem: true,
iconColor: 0x8a7f6d,
},
{
id: "broken-shaft",
name: "Broken Shaft",
description: "The other half of something. Threaded at one end.",
category: "key",
isKeyItem: true,
iconColor: 0x6d6a63,
},
{
id: "repaired-crank",
name: "Repaired Crank",
description: "Handle and shaft, reunited. It should open the records shutter.",
category: "key",
isKeyItem: true,
iconColor: 0xb0a284,
},
{
id: "green-herb",
name: "Green Herb",
description: "Bitter, fibrous, and somehow still the best medicine available.",
category: "consumable",
stackable: true,
maxStack: 6,
iconColor: 0x4a7f42,
},
{
id: "handgun-rounds",
name: "Handgun Rounds",
description: "Nine millimetre. Not nearly enough of them.",
category: "ammo",
stackable: true,
maxStack: 30,
iconColor: 0xc9a227,
},
{
id: "torn-photograph",
name: "Torn Photograph",
description:
"Two people on the precinct steps. One of the faces has been scratched away.",
category: "document",
iconColor: 0xd8cdb8,
},
{
id: "brass-key",
name: "Brass Key",
description: "Warm to the touch. The bow is stamped with a tiny kettle — break room.",
category: "key",
isKeyItem: true,
iconColor: 0xc9a227,
},
{
id: "cellar-key",
name: "Vault Key",
description: "Heavy iron. The bit is caked with red clay from the evidence stairs.",
category: "key",
isKeyItem: true,
iconColor: 0x8a8070,
},
{
id: "witness-statement",
name: "Witness Statement",
description:
"Nora Voss, unsigned. 'Kane left for coffee. He returned smelling of the vault.' Blank signature line.",
category: "document",
iconColor: 0xf0e8d8,
},
{
id: "badge-case",
name: "Badge Case",
description:
"Sgt. Marrow's case. The badge is missing; the foam insert holds a scrap of burgundy silk — the same shade as Kane's tie.",
category: "document",
isKeyItem: true,
iconColor: 0x2a2a30,
},
];
/** GDD §6.3 combination recipes. */
export const COMBINATIONS: CombinationDef[] = [
{
inputs: ["crank-handle", "broken-shaft"],
output: { itemId: "repaired-crank", quantity: 1 },
consumes: [true, true],
},
];
export const ITEM_CATALOGUE: ReadonlyMap<string, ItemDef> = new Map(
ITEM_DEFS.map((def) => [def.id, def]),
);
+104
View File
@@ -0,0 +1,104 @@
import type { RoomDef } from "@psx/shared";
import { door, floor, prop, STONE, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
/**
* Evidence vault — Level 1 crime-scene payoff.
*
* Darker fog, Sgt. Marrow's body, and a journal that closes the demo loop.
*/
export const CELLAR: RoomDef = {
id: "manor-cellar",
displayName: "Ashgrove Precinct — Evidence Vault",
fog: { color: 0x050608, near: 2, far: 12 },
ambientLight: { color: 0x202830, intensity: 0.55 },
lights: [
{
kind: "point",
color: 0xff9040,
intensity: 18,
position: { x: 0.5, y: 2, z: 1 },
distance: 10,
},
],
geometry: [
floor("floor", { x: 0, y: 0, z: 0 }, 4, 4),
wall("wall-n", { x: 0, y: WALL_HALF, z: -4.15 }, { x: 4.2, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
wall("wall-s", { x: 0, y: WALL_HALF, z: 4.15 }, { x: 4.2, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
wall("wall-e", { x: 4.15, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 4 }, STONE),
wall("wall-w-left", { x: -4.15, y: WALL_HALF, z: -2 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2 }, STONE),
wall("wall-w-right", { x: -4.15, y: WALL_HALF, z: 2.2 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.8 }, STONE),
prop("barrel-1", { x: 2.5, y: 0.5, z: -2.2 }, { x: 0.7, y: 1, z: 0.7 }, 0x4a3c2e),
prop("barrel-2", { x: 3.1, y: 0.5, z: -1.4 }, { x: 0.65, y: 1, z: 0.65 }, 0x3e3428),
prop("crate-stack", { x: -2.5, y: 0.5, z: -2.5 }, { x: 1.2, y: 1, z: 1 }, 0x55504a),
prop("shelf", { x: 3.2, y: 1, z: 2 }, { x: 0.4, y: 2, z: 2 }, 0x3a3228),
],
cameraZones: [
{
id: "cellar",
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 4, y: 2, z: 4 } },
camera: {
position: { x: -2.8, y: 2.8, z: 3.4 },
lookAt: { x: 1, y: 0.8, z: -1 },
fov: 58,
trackPlayer: true,
trackRadius: 1.5,
},
blend: { mode: "blend", duration: 0.5 },
priority: 0,
},
],
interactables: [
{
id: "officer-journal",
label: "Officer's Journal",
position: { x: 1.2, y: 0.4, z: -1.5 },
halfExtents: { x: 1.2, y: 1.1, z: 1.2 },
action: {
kind: "examine",
text:
'"Day 4. The precinct is eating the map. Squad hall to corridor still holds. Vault does not. Kane was down here — clay on his shoes. If you find this — leave the brass key where it is and burn the rest." The rest of the page is wet.',
},
repeatable: true,
visual: { kind: "box", size: { x: 0.28, y: 0.04, z: 0.35 }, color: 0x5a4030 },
},
{
id: "cellar-herb",
label: "Green Herb",
position: { x: -2.5, y: 0.3, z: 2.5 },
halfExtents: { x: 1, y: 1, z: 1 },
action: { kind: "pickup", itemId: "green-herb", quantity: 1 },
visual: { kind: "box", size: { x: 0.22, y: 0.28, z: 0.22 }, color: 0x4a7f42 },
},
{
id: "bloody-badge",
label: "Badge Case",
position: { x: 0.5, y: 0.3, z: -2.0 },
halfExtents: { x: 1.0, y: 1.0, z: 1.0 },
action: { kind: "pickup", itemId: "badge-case" },
visual: { kind: "box", size: { x: 0.18, y: 0.04, z: 0.28 }, color: 0x2a2a30 },
},
{
id: "examine-officer",
label: "Sgt. Marrow",
position: { x: 2.2, y: 0.7, z: -2.4 },
halfExtents: { x: 1.2, y: 1.1, z: 1.2 },
action: {
kind: "examine",
text:
"Uniform. Badge. Journal still in his hand. The only person who made it into evidence vault alone — and the only one who never left. His last note points back at Kane's coffee story.",
},
repeatable: true,
visual: { kind: "none" },
},
door({
id: "door-cellar-to-kitchen",
label: "Stairs Up",
position: { x: -4, y: 1.1, z: 0 },
targetRoomId: "manor-kitchen",
spawnPointId: "from-cellar",
}),
],
spawnPoints: [
{ id: "from-kitchen", position: { x: -2.5, y: 0, z: 0 }, yaw: -Math.PI / 2 },
],
};
+102
View File
@@ -0,0 +1,102 @@
import type { RoomDef } from "@psx/shared";
import { door, floor, STONE, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
/**
* Narrow corridor — valve puzzle opens the study.
*
* The north shutter is flag-gated geometry. Until `study-shutter-open` is set,
* examining it explains the puzzle; after the valve turns, the shutter
* despawns and the study door becomes active.
*/
export const CORRIDOR: RoomDef = {
id: "manor-corridor",
displayName: "Ashgrove Precinct — Corridor",
fog: { color: 0x080a0e, near: 2.5, far: 14 },
ambientLight: { color: 0x303848, intensity: 0.85 },
lights: [
{
kind: "point",
color: 0xa8c0ff,
intensity: 22,
position: { x: 0, y: 2.4, z: 0 },
distance: 12,
},
],
geometry: [
floor("floor", { x: 0, y: 0, z: 0 }, 1.6, 5),
wall("wall-w", { x: -1.75, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 5 }, STONE),
wall("wall-e", { x: 1.75, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 5 }, STONE),
wall("wall-s-left", { x: -1.05, y: WALL_HALF, z: 5.15 }, { x: 0.55, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-s-right", { x: 1.05, y: WALL_HALF, z: 5.15 }, { x: 0.55, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-n-left", { x: -1.05, y: WALL_HALF, z: -5.15 }, { x: 0.55, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-n-right", { x: 1.05, y: WALL_HALF, z: -5.15 }, { x: 0.55, y: WALL_HALF, z: WALL_THICKNESS }),
{
id: "study-shutter",
position: { x: 0, y: WALL_HALF, z: -5 },
visual: { kind: "box", size: { x: 1.4, y: 3, z: 0.2 }, color: 0x5c4a3a },
colliders: [{ kind: "box", halfExtents: { x: 0.7, y: WALL_HALF, z: 0.1 } }],
removeOnFlag: "study-shutter-open",
},
],
cameraZones: [
{
id: "corridor",
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 1.6, y: 2, z: 5 } },
camera: {
position: { x: 1.45, y: 2.9, z: 3.8 },
lookAt: { x: 0, y: 1, z: -2 },
fov: 62,
},
blend: { mode: "cut" },
priority: 0,
},
],
interactables: [
{
id: "valve",
label: "Valve",
position: { x: 1.1, y: 1.1, z: -3.2 },
halfExtents: { x: 1.2, y: 1.3, z: 1.2 },
action: {
kind: "useItem",
acceptsItemId: "repaired-crank",
onUseFlag: "study-shutter-open",
text: "A valve stem with a square socket. Your fingers find no purchase — it needs a crank.",
},
visual: { kind: "box", size: { x: 0.3, y: 0.3, z: 0.15 }, color: 0x7a3b2e },
},
door({
id: "door-corridor-to-gallery",
label: "Squad Hall",
position: { x: 0, y: 1.1, z: 5 },
targetRoomId: "manor-gallery",
spawnPointId: "from-corridor",
}),
{
id: "study-shutter-examine",
label: "Steel Shutter",
position: { x: 0, y: 1.1, z: -4.6 },
halfExtents: { x: 1.2, y: 1.4, z: 1 },
action: {
kind: "examine",
text: "A steel shutter seals Records. Pipes along the wall lead to a valve — same hardware the night shift used as a lock.",
},
requiresFlags: ["!study-shutter-open"],
repeatable: true,
visual: { kind: "none" },
},
door({
id: "door-corridor-to-study",
label: "Records",
position: { x: 0, y: 1.1, z: -5 },
targetRoomId: "manor-study",
spawnPointId: "from-corridor",
requiresFlags: ["study-shutter-open"],
visual: false,
}),
],
spawnPoints: [
{ id: "from-gallery", position: { x: 0, y: 0, z: 3.5 }, yaw: Math.PI },
{ id: "from-study", position: { x: 0, y: 0, z: -3.5 }, yaw: 0 },
],
};
+118
View File
@@ -0,0 +1,118 @@
import type { RoomDef } from "@psx/shared";
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
/**
* Precinct lobby — campaign start.
*
* Reception desk, Ellis the night custodian, and doors into the squad hall
* and witness lounge. Atmosphere first; the case opens one door at a time.
*/
export const FOYER: RoomDef = {
id: "manor-foyer",
displayName: "Ashgrove Precinct — Lobby",
fog: { color: 0x0c0e12, near: 3, far: 18 },
ambientLight: { color: 0x3a4050, intensity: 0.95 },
lights: [
{
kind: "point",
color: 0xffd9a0,
intensity: 28,
position: { x: 0, y: 2.5, z: 0 },
distance: 14,
},
{
kind: "directional",
color: 0x304050,
intensity: 0.4,
position: { x: 2, y: 6, z: 4 },
},
],
geometry: [
floor("floor", { x: 0, y: 0, z: 0 }, 4.5, 4),
wall("wall-s", { x: 0, y: WALL_HALF, z: 4.15 }, { x: 4.7, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-n-left", { x: -2.7, y: WALL_HALF, z: -4.15 }, { x: 1.6, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-n-right", { x: 2.7, y: WALL_HALF, z: -4.15 }, { x: 1.6, y: WALL_HALF, z: WALL_THICKNESS }),
// East wall split for lounge door at z≈0
wall("wall-e-north", { x: 4.65, y: WALL_HALF, z: -2.3 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.7 }, STONE),
wall("wall-e-south", { x: 4.65, y: WALL_HALF, z: 2.3 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.7 }, STONE),
wall("wall-w", { x: -4.65, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 4 }, STONE),
prop("console", { x: -2.8, y: 0.45, z: 1.5 }, { x: 1.2, y: 0.9, z: 0.5 }, TIMBER),
prop("bench", { x: 2.8, y: 0.3, z: 2.4 }, { x: 1.4, y: 0.6, z: 0.5 }, 0x3a3530),
prop("umbrella-stand", { x: -3.4, y: 0.4, z: -2.6 }, { x: 0.35, y: 0.8, z: 0.35 }, 0x3a3530),
],
cameraZones: [
{
id: "foyer",
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 4.5, y: 2, z: 4 } },
camera: {
position: { x: 3.6, y: 3.2, z: 3.6 },
lookAt: { x: -0.5, y: 1, z: -1 },
fov: 55,
trackPlayer: true,
trackRadius: 1.8,
},
blend: { mode: "cut" },
priority: 0,
},
],
interactables: [
{
id: "foyer-plaque",
label: "Brass Plaque",
position: { x: -2.8, y: 1.2, z: 1.5 },
halfExtents: { x: 1.1, y: 1.1, z: 1 },
action: {
kind: "examine",
text:
'"Ashgrove Precinct — Est. 1891. Those who enter leave something of themselves behind." Someone scratched last night\'s date into the brass under the motto.',
},
repeatable: true,
visual: { kind: "box", size: { x: 0.4, y: 0.25, z: 0.04 }, color: 0xb0a060 },
},
{
id: "duty-log",
label: "Duty Log",
position: { x: -2.8, y: 1.0, z: 1.1 },
halfExtents: { x: 1.0, y: 1.0, z: 0.9 },
action: {
kind: "examine",
text:
"Night board: REYES — office. MARROW — evidence vault. HALE — records (sealed). CIVILIANS: Voss / Kane — hold for interview. ELLIS — on grounds.",
},
repeatable: true,
visual: { kind: "box", size: { x: 0.35, y: 0.03, z: 0.28 }, color: 0xd8d0c0 },
},
{
id: "talk-ellis",
label: "Ellis",
position: { x: 3.2, y: 0.7, z: -1.8 },
halfExtents: { x: 1.2, y: 1.1, z: 1.2 },
action: {
kind: "examine",
text:
'Ellis wipes grease off a ring of keys. "Night shift only. I mopped the lobby, then the upstairs hall went quiet. No shouting — just the lights flickering once. If you want the cellar, that key left the board years ago."',
},
repeatable: true,
visual: { kind: "none" },
},
door({
id: "door-foyer-to-gallery",
label: "Squad Hall",
position: { x: 0, y: 1.1, z: -4 },
targetRoomId: "manor-gallery",
spawnPointId: "from-foyer",
}),
door({
id: "door-foyer-to-lounge",
label: "Witness Lounge",
position: { x: 4.5, y: 1.1, z: 0 },
targetRoomId: "precinct-lounge",
spawnPointId: "from-foyer",
}),
],
spawnPoints: [
{ id: "start", position: { x: 0, y: 0, z: 2.5 }, yaw: Math.PI },
{ id: "from-gallery", position: { x: 0, y: 0, z: -2.5 }, yaw: 0 },
{ id: "from-lounge", position: { x: 2.8, y: 0, z: 0 }, yaw: Math.PI / 2 },
],
};
+180
View File
@@ -0,0 +1,180 @@
import type { RoomDef, Vec3 } from "@psx/shared";
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
/**
* Squad hall — hub of Level 1.
*
* Routes to corridor / kitchen / detective office / interview room.
* Crank puzzle pieces still live here (records shutter).
*/
const RAMP_POINTS: Vec3[] = [
{ x: -1.4, y: 0, z: 1 },
{ x: 1.4, y: 0, z: 1 },
{ x: -1.4, y: 0, z: -1 },
{ x: 1.4, y: 0, z: -1 },
{ x: -1.4, y: 0.9, z: -1 },
{ x: 1.4, y: 0.9, z: -1 },
];
export const GALLERY: RoomDef = {
id: "manor-gallery",
displayName: "Ashgrove Precinct — Squad Hall",
fog: { color: 0x0a0c10, near: 4, far: 24 },
ambientLight: { color: 0x3a4050, intensity: 1 },
lights: [
{
kind: "point",
color: 0xffd9a0,
intensity: 32,
position: { x: 0, y: 2.6, z: 1 },
distance: 16,
},
{
kind: "directional",
color: 0x404a60,
intensity: 0.5,
position: { x: 4, y: 8, z: 6 },
},
],
geometry: [
floor("floor", { x: 0, y: 0, z: 0 }, 6, 5),
// South: lobby door gap at x=0
wall("wall-s-left", { x: -3.5, y: WALL_HALF, z: 5.15 }, { x: 2.5, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-s-right", { x: 3.5, y: WALL_HALF, z: 5.15 }, { x: 2.5, y: WALL_HALF, z: WALL_THICKNESS }),
// East: office door gap at z=0
wall("wall-e-north", { x: 6.15, y: WALL_HALF, z: -2.8 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2.2 }),
wall("wall-e-south", { x: 6.15, y: WALL_HALF, z: 2.8 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2.2 }),
// West: interview door gap at z=0
wall("wall-w-north", { x: -6.15, y: WALL_HALF, z: -2.8 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2.2 }, STONE),
wall("wall-w-south", { x: -6.15, y: WALL_HALF, z: 2.8 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2.2 }, STONE),
// North: corridor gap at x=0, kitchen gap at x=-3.3
wall("wall-n-far-left", { x: -5.1, y: WALL_HALF, z: -5.15 }, { x: 0.9, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-n-between", { x: -1.65, y: WALL_HALF, z: -5.15 }, { x: 0.85, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-n-right", { x: 3.5, y: WALL_HALF, z: -5.15 }, { x: 2.5, y: WALL_HALF, z: WALL_THICKNESS }),
{
id: "low-step",
position: { x: -3, y: 0.15, z: 2 },
visual: { kind: "box", size: { x: 2, y: 0.3, z: 2 }, color: TIMBER },
colliders: [{ kind: "box", halfExtents: { x: 1, y: 0.15, z: 1 } }],
},
prop("crate", { x: 3, y: 0.4, z: 1.5 }, { x: 0.8, y: 0.8, z: 0.8 }, TIMBER),
prop("bullpen-desk", { x: -3.5, y: 0.4, z: -1.5 }, { x: 1.6, y: 0.8, z: 0.9 }, TIMBER),
{
id: "ramp",
position: { x: 4.5, y: 0, z: -2 },
visual: { kind: "hull", points: RAMP_POINTS, color: 0x55504a },
colliders: [{ kind: "hull", points: RAMP_POINTS }],
material: { friction: 0.9 },
},
{
id: "ledge",
position: { x: 4.5, y: 0.45, z: -3.6 },
visual: { kind: "box", size: { x: 2.8, y: 0.9, z: 1.2 }, color: 0x55504a },
colliders: [{ kind: "box", halfExtents: { x: 1.4, y: 0.45, z: 0.6 } }],
},
],
cameraZones: [
{
id: "gallery",
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 6, y: 2, z: 5 } },
camera: {
position: { x: 5.2, y: 3.4, z: 4.2 },
lookAt: { x: -0.5, y: 1, z: -1 },
fov: 55,
trackPlayer: true,
trackRadius: 2.2,
},
blend: { mode: "cut" },
priority: 0,
},
],
interactables: [
{
id: "portrait",
label: "Precinct Portrait",
position: { x: -0.5, y: 1.4, z: 4.6 },
halfExtents: { x: 1.1, y: 1.4, z: 1.2 },
action: {
kind: "examine",
text:
"A water-damaged group photo of the night shift. One face has been scratched out — the same grey as the wall behind it.",
},
repeatable: true,
visual: { kind: "box", size: { x: 0.8, y: 1.0, z: 0.1 }, color: 0x2e2a26 },
},
{
id: "evidence-board",
label: "Hall Board",
position: { x: -3.5, y: 1.2, z: -1.5 },
halfExtents: { x: 1.2, y: 1.2, z: 1.1 },
action: {
kind: "examine",
text:
"OPEN ROOMS: Lobby · Lounge · Interview · Detective Office · Corridor. SEALED: Records (valve) · Break Room (brass key) · Vault (cellar key). Question the living. Read the dead.",
},
repeatable: true,
visual: { kind: "box", size: { x: 1.2, y: 0.9, z: 0.06 }, color: 0x3a3830 },
},
{
id: "crank-handle-pickup",
label: "Crank Handle",
position: { x: 3, y: 0.95, z: 1.5 },
halfExtents: { x: 1.1, y: 1.1, z: 1.1 },
action: { kind: "pickup", itemId: "crank-handle" },
visual: { kind: "box", size: { x: 0.35, y: 0.1, z: 0.1 }, color: 0x8a7f6d },
},
{
id: "broken-shaft-pickup",
label: "Broken Shaft",
position: { x: 4.5, y: 1.05, z: -3.6 },
halfExtents: { x: 1.2, y: 1.1, z: 1 },
action: { kind: "pickup", itemId: "broken-shaft" },
visual: { kind: "box", size: { x: 0.1, y: 0.1, z: 0.5 }, color: 0x6d6a63 },
},
door({
id: "door-gallery-to-foyer",
label: "Lobby",
position: { x: 0, y: 1.1, z: 5 },
targetRoomId: "manor-foyer",
spawnPointId: "from-gallery",
}),
door({
id: "door-gallery-to-corridor",
label: "Corridor",
position: { x: 0, y: 1.1, z: -5 },
targetRoomId: "manor-corridor",
spawnPointId: "from-gallery",
}),
door({
id: "door-gallery-to-kitchen",
label: "Break Room",
position: { x: -3.3, y: 1.1, z: -5 },
targetRoomId: "manor-kitchen",
spawnPointId: "from-gallery",
requiresItemId: "brass-key",
}),
door({
id: "door-gallery-to-office",
label: "Detective Office",
position: { x: 6, y: 1.1, z: 0 },
targetRoomId: "precinct-office",
spawnPointId: "from-gallery",
}),
door({
id: "door-gallery-to-interview",
label: "Interview Room",
position: { x: -6, y: 1.1, z: 0 },
targetRoomId: "precinct-interview",
spawnPointId: "from-gallery",
}),
],
spawnPoints: [
{ id: "from-foyer", position: { x: 0, y: 0, z: 3.5 }, yaw: Math.PI },
{ id: "from-corridor", position: { x: 0, y: 0, z: -3.5 }, yaw: 0 },
{ id: "from-kitchen", position: { x: -3.3, y: 0, z: -3.5 }, yaw: 0 },
{ id: "from-office", position: { x: 4.2, y: 0, z: 0 }, yaw: Math.PI / 2 },
{ id: "from-interview", position: { x: -4.2, y: 0, z: 0 }, yaw: -Math.PI / 2 },
{ id: "start", position: { x: 0, y: 0, z: 3.2 }, yaw: Math.PI },
],
};
+115
View File
@@ -0,0 +1,115 @@
import type { InteractableDef, StaticGeometryDef, Vec3 } from "@psx/shared";
/** Shared materials for Ashgrove Precinct. */
export const STONE = 0x4a4f57;
export const PLASTER = 0x6b6357;
export const TIMBER = 0x4a3c2e;
export const FLOOR_COLOR = 0x3b3f45;
export const DOOR_COLOR = 0x5c4a3a;
export const METAL = 0x6a6e74;
export const WALL_HALF = 1.5;
export const WALL_THICKNESS = 0.15;
export const FLOOR_HALF = 0.15;
/** Wall slab: one box visual, one box collider. */
export function wall(
id: string,
center: Vec3,
halfExtents: Vec3,
color = PLASTER,
): StaticGeometryDef {
return {
id,
position: center,
visual: {
kind: "box",
size: { x: halfExtents.x * 2, y: halfExtents.y * 2, z: halfExtents.z * 2 },
color,
},
colliders: [{ kind: "box", halfExtents }],
material: { friction: 0.7 },
};
}
export function floor(id: string, center: Vec3, halfX: number, halfZ: number): StaticGeometryDef {
return wall(
id,
{ x: center.x, y: -FLOOR_HALF, z: center.z },
{ x: halfX, y: FLOOR_HALF, z: halfZ },
FLOOR_COLOR,
);
}
/** Box prop with matching collider. */
export function prop(
id: string,
center: Vec3,
size: Vec3,
color: number,
): StaticGeometryDef {
return {
id,
position: center,
visual: { kind: "box", size, color },
colliders: [
{
kind: "box",
halfExtents: { x: size.x / 2, y: size.y / 2, z: size.z / 2 },
},
],
};
}
/** Door interactable — walk up and press E to transition rooms. */
export function door(options: {
id: string;
label: string;
position: Vec3;
targetRoomId: string;
spawnPointId: string;
requiresItemId?: string;
requiresFlags?: string[];
/** Visual slab; omit for an invisible trigger (e.g. open archway). */
visual?: boolean;
yawFacing?: number;
}): InteractableDef {
return {
id: options.id,
label: options.label,
position: options.position,
halfExtents: { x: 1.1, y: 1.4, z: 1.1 },
action: {
kind: "door",
targetRoomId: options.targetRoomId,
spawnPointId: options.spawnPointId,
...(options.requiresItemId ? { requiresItemId: options.requiresItemId } : {}),
},
...(options.requiresFlags ? { requiresFlags: options.requiresFlags } : {}),
repeatable: true,
visual:
options.visual === false
? { kind: "none" }
: { kind: "box", size: { x: 1.2, y: 2.2, z: 0.12 }, color: DOOR_COLOR },
};
}
/** Sealed passage shown only while a flag is *not* set. */
export function sealedPassage(options: {
id: string;
label: string;
position: Vec3;
text: string;
closedFlag: string;
}): InteractableDef {
return {
id: options.id,
label: options.label,
position: options.position,
halfExtents: { x: 1.2, y: 1.4, z: 1.1 },
action: { kind: "examine", text: options.text },
requiresFlags: [`!${options.closedFlag}`],
repeatable: true,
visual: { kind: "box", size: { x: 1.4, y: 2.4, z: 0.18 }, color: 0x5c4a3a },
};
}
+44
View File
@@ -0,0 +1,44 @@
import type { RoomDef } from "@psx/shared";
import { CELLAR } from "./cellar.js";
import { CORRIDOR } from "./corridor.js";
import { FOYER } from "./foyer.js";
import { GALLERY } from "./gallery.js";
import { INTERVIEW } from "./interview.js";
import { KITCHEN } from "./kitchen.js";
import { LOUNGE } from "./lounge.js";
import { OFFICE } from "./office.js";
import { STUDY } from "./study.js";
/**
* Ashgrove Precinct — Level 1 building (all cast NPCs).
*
* Lobby (Ellis) ⇄ Lounge (Kane)
* ↓
* Squad Hall ⇄ Office (Reyes) ⇄ Interview (Voss)
* ↓ ↓ (brass key)
* Corridor → Records (Hale) Break Room → Vault (Marrow)
*/
export const ROOM_CATALOGUE: ReadonlyMap<string, RoomDef> = new Map(
[FOYER, GALLERY, CORRIDOR, STUDY, KITCHEN, CELLAR, OFFICE, INTERVIEW, LOUNGE].map(
(room) => [room.id, room],
),
);
export const START_ROOM = FOYER;
export const START_SPAWN = "start";
export function getRoom(id: string): RoomDef | undefined {
return ROOM_CATALOGUE.get(id);
}
export {
FOYER,
GALLERY,
CORRIDOR,
STUDY,
KITCHEN,
CELLAR,
OFFICE,
INTERVIEW,
LOUNGE,
};
+99
View File
@@ -0,0 +1,99 @@
import type { RoomDef } from "@psx/shared";
import { door, floor, prop, STONE, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
const METAL_TABLE = 0x5a6068;
/**
* Interview room — Nora Voss (civilian-f).
*
* Two-way glass, metal table, classic interrogation staging. Free access from
* the squad hall so the player can start gathering testimony immediately.
*/
export const INTERVIEW: RoomDef = {
id: "precinct-interview",
displayName: "Ashgrove Precinct — Interview Room",
fog: { color: 0x0a0c10, near: 2.5, far: 12 },
ambientLight: { color: 0x384048, intensity: 0.9 },
lights: [
{
kind: "point",
color: 0xc8d0e0,
intensity: 24,
position: { x: 0, y: 2.5, z: 0 },
distance: 11,
},
],
geometry: [
floor("floor", { x: 0, y: 0, z: 0 }, 3.2, 3.2),
wall("wall-n", { x: 0, y: WALL_HALF, z: -3.35 }, { x: 3.4, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
wall("wall-s-left", { x: -1.9, y: WALL_HALF, z: 3.35 }, { x: 1.1, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-s-right", { x: 1.9, y: WALL_HALF, z: 3.35 }, { x: 1.1, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-e", { x: 3.35, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.2 }, STONE),
wall("wall-w", { x: -3.35, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.2 }, STONE),
prop("table", { x: 0, y: 0.4, z: -0.4 }, { x: 1.6, y: 0.8, z: 0.9 }, METAL_TABLE),
prop("chair-suspect", { x: 0, y: 0.35, z: -1.5 }, { x: 0.45, y: 0.7, z: 0.45 }, 0x3a3a40),
prop("chair-cop", { x: 0, y: 0.35, z: 0.7 }, { x: 0.45, y: 0.7, z: 0.45 }, 0x3a3a40),
prop("mirror-slab", { x: -3.15, y: 1.3, z: 0 }, { x: 0.08, y: 1.4, z: 2.2 }, 0x6a7888),
],
cameraZones: [
{
id: "interview",
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 3.2, y: 2, z: 3.2 } },
camera: {
position: { x: 2.6, y: 2.8, z: 2.8 },
lookAt: { x: 0, y: 1.0, z: -0.6 },
fov: 58,
trackPlayer: true,
trackRadius: 1.2,
},
blend: { mode: "cut" },
priority: 0,
},
],
interactables: [
{
id: "two-way-glass",
label: "Two-Way Glass",
position: { x: -3.0, y: 1.3, z: 0 },
halfExtents: { x: 1.1, y: 1.3, z: 1.4 },
action: {
kind: "examine",
text:
"Your reflection sits where the observer should. Someone watched this room last night — a coffee ring on the sill, still sticky.",
},
repeatable: true,
visual: { kind: "none" },
},
{
id: "statement-pad",
label: "Statement Pad",
position: { x: 0, y: 0.95, z: -0.4 },
halfExtents: { x: 1.1, y: 1.0, z: 1.0 },
action: { kind: "pickup", itemId: "witness-statement" },
visual: { kind: "box", size: { x: 0.28, y: 0.02, z: 0.36 }, color: 0xf0e8d8 },
},
{
id: "talk-nora",
label: "Nora Voss",
position: { x: 1.6, y: 0.7, z: -1.2 },
halfExtents: { x: 1.3, y: 1.2, z: 1.3 },
action: {
kind: "examine",
text:
'"I was in the lounge. Kane said he was getting coffee — he was gone too long. When he came back he smelled like the cellar, not the kitchen. That\'s all I\'m saying without counsel." Her hands shake, but her eyes stay fixed on the two-way glass.',
},
repeatable: true,
visual: { kind: "none" },
},
door({
id: "door-interview-to-gallery",
label: "Squad Hall",
position: { x: 0, y: 1.1, z: 3.2 },
targetRoomId: "manor-gallery",
spawnPointId: "from-interview",
}),
],
spawnPoints: [
{ id: "from-gallery", position: { x: 0, y: 0, z: 2.0 }, yaw: Math.PI },
],
};
+100
View File
@@ -0,0 +1,100 @@
import type { RoomDef } from "@psx/shared";
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
/**
* Break room — opened with the brass key from Records.
*
* Cellar key and ammo live here; door to the evidence vault is locked until
* that key is held.
*/
export const KITCHEN: RoomDef = {
id: "manor-kitchen",
displayName: "Ashgrove Precinct — Break Room",
fog: { color: 0x0e100c, near: 3, far: 16 },
ambientLight: { color: 0x404838, intensity: 0.9 },
lights: [
{
kind: "point",
color: 0xffb070,
intensity: 26,
position: { x: 0, y: 2.4, z: 0 },
distance: 12,
},
],
geometry: [
floor("floor", { x: 0, y: 0, z: 0 }, 4, 3.5),
wall("wall-n", { x: 0, y: WALL_HALF, z: -3.65 }, { x: 4.2, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
wall("wall-s-left", { x: -2.4, y: WALL_HALF, z: 3.65 }, { x: 1.4, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-s-right", { x: 2.4, y: WALL_HALF, z: 3.65 }, { x: 1.4, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-e", { x: 4.15, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.5 }),
wall("wall-w-left", { x: -4.15, y: WALL_HALF, z: 1.5 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2 }),
wall("wall-w-right", { x: -4.15, y: WALL_HALF, z: -1.8 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.5 }),
prop("counter", { x: 2.5, y: 0.45, z: -1.5 }, { x: 2.2, y: 0.9, z: 0.7 }, TIMBER),
prop("stove", { x: -2.2, y: 0.4, z: -2 }, { x: 1, y: 0.8, z: 0.8 }, 0x3a3a40),
prop("table", { x: 0, y: 0.4, z: 1 }, { x: 1.4, y: 0.8, z: 0.9 }, 0x5a4a38),
],
cameraZones: [
{
id: "kitchen",
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 4, y: 2, z: 3.5 } },
camera: {
position: { x: 3.2, y: 3.1, z: 3 },
lookAt: { x: -0.5, y: 1, z: -1 },
fov: 58,
trackPlayer: true,
trackRadius: 1.6,
},
blend: { mode: "cut" },
priority: 0,
},
],
interactables: [
{
id: "cellar-key-pickup",
label: "Cellar Key",
position: { x: 2.5, y: 1, z: -1.5 },
halfExtents: { x: 1.1, y: 1.1, z: 1 },
action: { kind: "pickup", itemId: "cellar-key" },
visual: { kind: "box", size: { x: 0.12, y: 0.04, z: 0.3 }, color: 0x8a8070 },
},
{
id: "ammo-pickup",
label: "Handgun Rounds",
position: { x: -2.2, y: 0.95, z: -2 },
halfExtents: { x: 1, y: 1.1, z: 1 },
action: { kind: "pickup", itemId: "handgun-rounds", quantity: 12 },
visual: { kind: "box", size: { x: 0.2, y: 0.12, z: 0.15 }, color: 0xc9a227 },
},
{
id: "kitchen-sink",
label: "Sink",
position: { x: 2.5, y: 1, z: -1.2 },
halfExtents: { x: 1, y: 1, z: 0.8 },
action: {
kind: "examine",
text: "Rust-stained porcelain. The tap still drips, one beat every few seconds. Red clay rings the drain — same clay as the vault stairs.",
},
repeatable: true,
visual: { kind: "none" },
},
door({
id: "door-kitchen-to-gallery",
label: "Squad Hall",
position: { x: 0, y: 1.1, z: 3.5 },
targetRoomId: "manor-gallery",
spawnPointId: "from-kitchen",
}),
door({
id: "door-kitchen-to-cellar",
label: "Evidence Vault",
position: { x: -4, y: 1.1, z: 0 },
targetRoomId: "manor-cellar",
spawnPointId: "from-kitchen",
requiresItemId: "cellar-key",
}),
],
spawnPoints: [
{ id: "from-gallery", position: { x: 0, y: 0, z: 2 }, yaw: Math.PI },
{ id: "from-cellar", position: { x: -2.5, y: 0, z: 0 }, yaw: Math.PI / 2 },
],
};
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
AMBIENT_PLACEMENTS,
CHARACTER_CATALOGUE,
LEVEL1_NPC_ROOM_IDS,
PLAYER_CHARACTER_ID,
} from "../characters.js";
import { ROOM_CATALOGUE } from "./index.js";
/**
* Level 1 contract: every non-player cast member has a dedicated room inside
* the precinct building, and every placement points at a real catalogue room.
*/
describe("Ashgrove Precinct Level 1", () => {
it("catalogues a room for every ambient NPC", () => {
const ambientIds = [...CHARACTER_CATALOGUE.keys()].filter(
(id) => id !== PLAYER_CHARACTER_ID,
);
expect(ambientIds.length).toBeGreaterThanOrEqual(6);
for (const id of ambientIds) {
const roomId = LEVEL1_NPC_ROOM_IDS.get(id);
expect(roomId, `${id} needs a Level-1 room`).toBeDefined();
expect(ROOM_CATALOGUE.has(roomId!), `${id} room ${roomId} missing`).toBe(true);
}
});
it("places each ambient NPC in a unique room", () => {
const rooms = AMBIENT_PLACEMENTS.map((p) => p.roomId);
expect(new Set(rooms).size).toBe(rooms.length);
});
it("wires door targets only to known rooms with valid spawns", () => {
for (const room of ROOM_CATALOGUE.values()) {
for (const interactable of room.interactables) {
if (interactable.action.kind !== "door") continue;
const { targetRoomId, spawnPointId } = interactable.action;
expect(ROOM_CATALOGUE.has(targetRoomId), `${room.id}${targetRoomId}`).toBe(true);
const dest = ROOM_CATALOGUE.get(targetRoomId)!;
const hasSpawn = dest.spawnPoints.some((point) => point.id === spawnPointId);
expect(
hasSpawn,
`${room.id} door ${interactable.id} spawn ${spawnPointId} missing on ${targetRoomId}`,
).toBe(true);
}
}
});
it("gives every ambient placement a matching examine interactable", () => {
for (const placement of AMBIENT_PLACEMENTS) {
expect(placement.examine).toBeDefined();
const room = ROOM_CATALOGUE.get(placement.roomId);
expect(room).toBeDefined();
const talk = room!.interactables.find((entry) => entry.id === placement.examine!.id);
expect(talk, `${placement.characterId} examine ${placement.examine!.id}`).toBeDefined();
expect(talk!.action.kind).toBe("examine");
}
});
});
+108
View File
@@ -0,0 +1,108 @@
import type { RoomDef } from "@psx/shared";
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
/**
* Witness lounge — Garvey Kane (civilian-m).
*
* Soft chairs, cold coffee, the alibi stage. Accessed from the lobby so the
* player meets a witness before the squad hall opens the full case board.
*/
export const LOUNGE: RoomDef = {
id: "precinct-lounge",
displayName: "Ashgrove Precinct — Witness Lounge",
fog: { color: 0x0e1014, near: 3, far: 14 },
ambientLight: { color: 0x404848, intensity: 0.95 },
lights: [
{
kind: "point",
color: 0xffc090,
intensity: 22,
position: { x: 0, y: 2.3, z: 0.5 },
distance: 12,
},
{
kind: "directional",
color: 0x304050,
intensity: 0.35,
position: { x: -2, y: 5, z: 3 },
},
],
geometry: [
floor("floor", { x: 0, y: 0, z: 0 }, 3.8, 3.5),
wall("wall-n", { x: 0, y: WALL_HALF, z: -3.65 }, { x: 4.0, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
wall("wall-s", { x: 0, y: WALL_HALF, z: 3.65 }, { x: 4.0, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-e-left", { x: 3.95, y: WALL_HALF, z: -2.0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.5 }),
wall("wall-e-right", { x: 3.95, y: WALL_HALF, z: 2.0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.5 }),
wall("wall-w", { x: -3.95, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.5 }, STONE),
prop("sofa", { x: -2.0, y: 0.35, z: 1.6 }, { x: 2.0, y: 0.7, z: 0.8 }, 0x4a3c48),
prop("coffee-table", { x: -1.6, y: 0.25, z: 0.2 }, { x: 1.2, y: 0.5, z: 0.7 }, TIMBER),
prop("sideboard", { x: 2.6, y: 0.5, z: -2.2 }, { x: 1.4, y: 1.0, z: 0.5 }, 0x3e3830),
prop("plant", { x: 2.8, y: 0.5, z: 2.2 }, { x: 0.4, y: 1.0, z: 0.4 }, 0x3a5a38),
],
cameraZones: [
{
id: "lounge",
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 3.8, y: 2, z: 3.5 } },
camera: {
position: { x: 3.2, y: 2.9, z: 2.8 },
lookAt: { x: -1.0, y: 1.0, z: 0.2 },
fov: 56,
trackPlayer: true,
trackRadius: 1.5,
},
blend: { mode: "blend", duration: 0.4 },
priority: 0,
},
],
interactables: [
{
id: "cold-coffee",
label: "Coffee Cup",
position: { x: -1.6, y: 0.6, z: 0.2 },
halfExtents: { x: 1.0, y: 1.0, z: 1.0 },
action: {
kind: "examine",
text:
"Two cups. One lipstick-smudged, empty. One black, half full, cold — and a smear of red clay on the saucer that matches the vault stairs.",
},
repeatable: true,
visual: { kind: "box", size: { x: 0.12, y: 0.14, z: 0.12 }, color: 0xd8d0c4 },
},
{
id: "magazine-stack",
label: "Magazines",
position: { x: 2.6, y: 1.1, z: -2.2 },
halfExtents: { x: 1.1, y: 1.1, z: 1.0 },
action: {
kind: "examine",
text:
"Last month's city paper. Ashgrove Station renovation delayed. Someone circled the cellar blueprint in ballpoint.",
},
repeatable: true,
visual: { kind: "box", size: { x: 0.35, y: 0.04, z: 0.28 }, color: 0xc0a878 },
},
{
id: "talk-garvey",
label: "Garvey Kane",
position: { x: -2.2, y: 0.7, z: 1.4 },
halfExtents: { x: 1.3, y: 1.2, z: 1.3 },
action: {
kind: "examine",
text:
'Kane straightens his tie. "I never went downstairs. Nora is scared and inventing stairs. I sat here, I drank cold coffee, I heard the shutter in the corridor grind. Ask Marrow — if he ever comes up from evidence."',
},
repeatable: true,
visual: { kind: "none" },
},
door({
id: "door-lounge-to-foyer",
label: "Lobby",
position: { x: 3.8, y: 1.1, z: 0 },
targetRoomId: "manor-foyer",
spawnPointId: "from-lounge",
}),
],
spawnPoints: [
{ id: "from-foyer", position: { x: 2.4, y: 0, z: 0 }, yaw: Math.PI / 2 },
],
};
+109
View File
@@ -0,0 +1,109 @@
import type { RoomDef } from "@psx/shared";
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
/**
* Detective office — Det. Reyes (Olivia).
*
* Case board and partner briefing. Open from the squad hall; sets the
* investigation tone before the player fans out to witnesses.
*/
export const OFFICE: RoomDef = {
id: "precinct-office",
displayName: "Ashgrove Precinct — Detective Office",
fog: { color: 0x0c1016, near: 3, far: 16 },
ambientLight: { color: 0x3a4250, intensity: 1 },
lights: [
{
kind: "point",
color: 0xffd0a0,
intensity: 28,
position: { x: -1.2, y: 2.4, z: -0.5 },
distance: 12,
},
{
kind: "point",
color: 0x7080a0,
intensity: 10,
position: { x: 2, y: 2.2, z: 2 },
distance: 9,
},
],
geometry: [
floor("floor", { x: 0, y: 0, z: 0 }, 4, 3.5),
wall("wall-n", { x: 0, y: WALL_HALF, z: -3.65 }, { x: 4.2, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
wall("wall-s-left", { x: -2.4, y: WALL_HALF, z: 3.65 }, { x: 1.4, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-s-right", { x: 2.4, y: WALL_HALF, z: 3.65 }, { x: 1.4, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-e", { x: 4.15, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.5 }, STONE),
wall("wall-w", { x: -4.15, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.5 }, STONE),
prop("desk", { x: -2.2, y: 0.4, z: -1.2 }, { x: 1.8, y: 0.8, z: 1.0 }, TIMBER),
prop("case-board", { x: 3.4, y: 1.2, z: -1.5 }, { x: 0.12, y: 1.8, z: 2.4 }, 0x2a2824),
prop("filing", { x: 3.2, y: 0.7, z: 2 }, { x: 0.7, y: 1.4, z: 0.9 }, 0x4a4e54),
prop("chair", { x: -2.2, y: 0.35, z: 0.2 }, { x: 0.5, y: 0.7, z: 0.5 }, 0x3a3530),
],
cameraZones: [
{
id: "office",
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 4, y: 2, z: 3.5 } },
camera: {
position: { x: 3.0, y: 3.0, z: 3.0 },
lookAt: { x: -1.2, y: 1.1, z: -1 },
fov: 56,
trackPlayer: true,
trackRadius: 1.6,
},
blend: { mode: "cut" },
priority: 0,
},
],
interactables: [
{
id: "case-board",
label: "Case Board",
position: { x: 3.2, y: 1.3, z: -1.5 },
halfExtents: { x: 1.2, y: 1.4, z: 1.4 },
action: {
kind: "examine",
text:
"ASHGROVE — NIGHT SHIFT. Red string pins Lobby → Records → Vault. Three living witnesses. One body in evidence. Kane's timeline is marked with a question; Voss's with a star.",
},
repeatable: true,
visual: { kind: "none" },
},
{
id: "partner-notes",
label: "Partner Notes",
position: { x: -2.2, y: 0.95, z: -1.2 },
halfExtents: { x: 1.2, y: 1.1, z: 1.1 },
action: {
kind: "examine",
text:
"Reyes' handwriting: \"Question everyone. Touch nothing in the vault until Marrow's journal is read. The crank pieces still open the records shutter — old building, old puzzles.\"",
},
repeatable: true,
visual: { kind: "box", size: { x: 0.32, y: 0.02, z: 0.26 }, color: 0xe8dcc8 },
},
{
id: "talk-olivia",
label: "Det. Reyes",
position: { x: -2.4, y: 0.7, z: -1.6 },
halfExtents: { x: 1.3, y: 1.2, z: 1.3 },
action: {
kind: "examine",
text:
'Reyes doesn\'t look up from the case board. "Victim was last seen leaving the records wing. Interview Voss and Kane first — their stories don\'t line up. Hale\'s notes are sealed until you turn the corridor valve. I\'ll hold the line here."',
},
repeatable: true,
visual: { kind: "none" },
},
door({
id: "door-office-to-gallery",
label: "Squad Hall",
position: { x: 0, y: 1.1, z: 3.5 },
targetRoomId: "manor-gallery",
spawnPointId: "from-office",
}),
],
spawnPoints: [
{ id: "from-gallery", position: { x: 0, y: 0, z: 2.2 }, yaw: Math.PI },
],
};
+117
View File
@@ -0,0 +1,117 @@
import type { RoomDef } from "@psx/shared";
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
/**
* Records office — payoff room after the valve puzzle.
*
* Dr. Hale waits here with the brass key to the break-room wing.
*/
export const STUDY: RoomDef = {
id: "manor-study",
displayName: "Ashgrove Precinct — Records",
fog: { color: 0x0c1014, near: 3.5, far: 18 },
ambientLight: { color: 0x3a4050, intensity: 1 },
lights: [
{
kind: "point",
color: 0xffc98a,
intensity: 30,
position: { x: -1, y: 2.5, z: -1 },
distance: 14,
},
{
kind: "point",
color: 0x8090b0,
intensity: 12,
position: { x: 2, y: 2.2, z: 2 },
distance: 10,
},
],
geometry: [
floor("floor", { x: 0, y: 0, z: 0 }, 4.5, 4),
wall("wall-s-left", { x: -2.7, y: WALL_HALF, z: 4.15 }, { x: 1.5, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-s-right", { x: 2.7, y: WALL_HALF, z: 4.15 }, { x: 1.5, y: WALL_HALF, z: WALL_THICKNESS }),
wall("wall-n", { x: 0, y: WALL_HALF, z: -4.15 }, { x: 4.65, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
wall("wall-w", { x: -4.65, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 4 }, STONE),
wall("wall-e", { x: 4.65, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 4 }, STONE),
prop("desk", { x: -2.5, y: 0.4, z: 0 }, { x: 1.6, y: 0.8, z: 0.9 }, TIMBER),
prop("bookshelf", { x: 3.5, y: 1.1, z: -2 }, { x: 0.5, y: 2.2, z: 2.4 }, 0x3a3228),
prop("chair", { x: -2.5, y: 0.35, z: 1.2 }, { x: 0.5, y: 0.7, z: 0.5 }, 0x4a4034),
],
cameraZones: [
{
id: "study",
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 4.5, y: 2, z: 4 } },
camera: {
position: { x: 3.6, y: 3.4, z: 3.2 },
lookAt: { x: -1.5, y: 1, z: -1 },
fov: 55,
trackPlayer: true,
trackRadius: 2.2,
},
blend: { mode: "blend", duration: 0.45 },
priority: 0,
},
],
interactables: [
{
id: "photograph-pickup",
label: "Torn Photograph",
position: { x: -2.5, y: 0.95, z: 0 },
halfExtents: { x: 1.3, y: 1.1, z: 1.1 },
action: { kind: "pickup", itemId: "torn-photograph" },
visual: { kind: "box", size: { x: 0.3, y: 0.02, z: 0.24 }, color: 0xd8cdb8 },
},
{
id: "herb-pickup",
label: "Green Herb",
position: { x: 2.5, y: 0.3, z: -2.8 },
halfExtents: { x: 1, y: 1.1, z: 1 },
action: { kind: "pickup", itemId: "green-herb", quantity: 2 },
visual: { kind: "box", size: { x: 0.25, y: 0.3, z: 0.25 }, color: 0x4a7f42 },
},
{
id: "brass-key-pickup",
label: "Brass Key",
position: { x: 3.2, y: 0.5, z: -2 },
halfExtents: { x: 1, y: 1.1, z: 1 },
action: { kind: "pickup", itemId: "brass-key" },
visual: { kind: "box", size: { x: 0.15, y: 0.05, z: 0.35 }, color: 0xc9a227 },
},
{
id: "study-notes",
label: "Research Notes",
position: { x: -2.5, y: 0.95, z: -0.4 },
halfExtents: { x: 1.1, y: 1, z: 1 },
action: {
kind: "examine",
text: '"Subject still responds to the vault air. Do not open evidence alone. Kane\'s clay sample matches the stair risers." The handwriting trails into a smear.',
},
repeatable: true,
visual: { kind: "box", size: { x: 0.35, y: 0.02, z: 0.28 }, color: 0xe8dcc8 },
},
{
id: "talk-hale",
label: "Dr. Hale",
position: { x: -3.0, y: 0.7, z: -2.2 },
halfExtents: { x: 1.2, y: 1.1, z: 1.2 },
action: {
kind: "examine",
text:
'Dr. Hale keeps one glove on. "The sample from the vault reacts to the manor\'s lower air — not poison, not mould. Something that writes itself into the floorplan. The brass key on my desk opens the break-room wing. Don\'t go alone."',
},
repeatable: true,
visual: { kind: "none" },
},
door({
id: "door-study-to-corridor",
label: "Corridor",
position: { x: 0, y: 1.1, z: 4 },
targetRoomId: "manor-corridor",
spawnPointId: "from-study",
}),
],
spawnPoints: [
{ id: "from-corridor", position: { x: 0, y: 0, z: 2.5 }, yaw: Math.PI },
],
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
/**
* box3d-wasm ships two builds and picks one at runtime. The threaded "deluxe"
* build needs SharedArrayBuffer, which browsers only expose on a cross-origin
* isolated page — hence the COOP/COEP headers below. Without them we silently
* fall back to the single-threaded build, which still works but leaves the
* multithreaded solver on the table.
*/
const crossOriginIsolation = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
};
export default defineConfig({
plugins: [solid()],
server: {
headers: crossOriginIsolation,
// Generated characters live in the workspace-root `assets/` directory, which
// is outside the client package. One source of truth beats copying GLBs
// into `public/` on every harness run.
fs: { allow: [".."] },
},
preview: { headers: crossOriginIsolation },
// The threaded build's pthread worker uses top-level await, which cannot be
// expressed in Vite's default `iife` worker format.
worker: { format: "es" },
// Emscripten glue resolves its .wasm via `new URL(..., import.meta.url)`.
// Pre-bundling rewrites that URL and breaks the fetch, so keep it external.
optimizeDeps: { exclude: ["box3d-wasm"] },
build: { target: "es2022" },
});
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
/**
* Engine tests run in plain Node, not jsdom.
*
* This config deliberately omits vite-plugin-solid: it switches the test
* environment to jsdom for component testing, and the systems under test here
* (physics, mover, sensors) touch no DOM. box3d-wasm is isomorphic, so these
* exercise the same wasm the browser loads.
*/
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
testTimeout: 30_000,
},
});