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.
@@ -0,0 +1,9 @@
|
||||
# Optional server overrides (copy to server/.env or export in shell)
|
||||
# PORT=8787
|
||||
# PSX_SESSION_SECRET=replace-with-a-long-random-string
|
||||
|
||||
# Optional client (defaults to local server)
|
||||
# VITE_API_URL=http://localhost:8787
|
||||
|
||||
# Optional tools — path to the Grok CLI if not on PATH
|
||||
# GROK_BIN=grok
|
||||
@@ -0,0 +1,50 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build output
|
||||
dist
|
||||
dist-ssr
|
||||
.turbo
|
||||
*.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Environment / secrets (never commit)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor / OS
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*~
|
||||
|
||||
# Local agent / IDE scratch
|
||||
.claude
|
||||
.grok
|
||||
.cursor
|
||||
|
||||
# Generated / cache (rebuild with tools)
|
||||
assets/generated
|
||||
assets/characters/.grok-texture-cache
|
||||
**/.grok-texture-cache/
|
||||
*.analysis.json
|
||||
|
||||
# Temp scratch
|
||||
*.tmp.ts
|
||||
*.tmp.js
|
||||
/tmp/
|
||||
@@ -0,0 +1,40 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for looking at the project. A few conventions keep the monorepo tidy.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Responsibility |
|
||||
| --- | --- |
|
||||
| `client` | Game runtime (must stay free of Node-only APIs) |
|
||||
| `server` | Auth, lobbies, signalling |
|
||||
| `shared` | Types and schemas used by both ends |
|
||||
| `tools` | Character pipeline (Node only) |
|
||||
|
||||
Put new gameplay types in `shared` before wiring them into client/server.
|
||||
|
||||
## Style
|
||||
|
||||
- TypeScript strict; no `any` without a comment why.
|
||||
- Prefer small declarative room / item / cast data over one-off engine forks.
|
||||
- Keep PSX constraints intentional: low poly, fixed cams, fog, nearest textures.
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. One concern per PR when possible.
|
||||
2. `pnpm typecheck && pnpm test` green.
|
||||
3. Update `README.md` / `GDD.md` when behaviour or demo layout changes.
|
||||
|
||||
## Character assets
|
||||
|
||||
Prefer `pnpm cast` / the creator UI over hand-editing GLBs. Ship
|
||||
`assets/characters/{id}.glb` plus `.recipe.json` (and albedo if textured).
|
||||
Do not commit `.grok-texture-cache` or `*.analysis.json`.
|
||||
@@ -0,0 +1,481 @@
|
||||
# PSX Adventure Engine
|
||||
**Reference Project / Game Design Document**
|
||||
Version 0.1 — July 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
A modern, browser-first **reference engine and project template** for creating authentic PSX-era third-person survival-horror and action-adventure games in the style of:
|
||||
|
||||
- Resident Evil 1 & 2
|
||||
- Parasite Eve
|
||||
- Dino Crisis
|
||||
- Silent Hill (classic)
|
||||
|
||||
The engine prioritizes:
|
||||
|
||||
- Fixed or limited cameras
|
||||
- Tank or camera-relative controls
|
||||
- Inventory, item combination, and puzzle gameplay
|
||||
- Atmospheric low-poly aesthetics
|
||||
- True peer-to-peer multiplayer
|
||||
- Rapid content creation via photo-to-character pipelines
|
||||
|
||||
It is designed as a clean, fully-typed foundation that teams can fork and extend into complete games (or other genres later).
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Pillars
|
||||
|
||||
1. **Authentic PSX Feel**
|
||||
Low-poly geometry, affine texture mapping, dithering, color quantization, fixed cameras, optional tank controls, atmospheric fog and limited draw distance.
|
||||
|
||||
2. **Content Velocity**
|
||||
Powerful img2mesh + common-skeleton harness so characters and items can be generated from reference photographs and immediately animated.
|
||||
|
||||
3. **Multiplayer-Native**
|
||||
Host-authoritative P2P WebRTC for 2–4 player co-op out of the box.
|
||||
|
||||
4. **Cloud Persistence**
|
||||
Neon-backed accounts, cloud saves, lobby system, and shared campaign progress.
|
||||
|
||||
5. **Clean Architecture**
|
||||
Strict separation between runtime engine, backend services, and content-creation harnesses.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals & Non-Goals
|
||||
|
||||
### Goals
|
||||
- Provide a production-quality reference "engine of sorts" for the target genre.
|
||||
- Support both classic fixed-camera and freer camera styles.
|
||||
- Deliver true browser P2P multiplayer with host-authoritative simulation.
|
||||
- Enable rapid character and item creation from reference photos.
|
||||
- Keep everything TypeScript-first and highly modular.
|
||||
|
||||
### Non-Goals
|
||||
- Competing with Unity or Unreal as a general-purpose engine.
|
||||
- High-fidelity modern PBR graphics or complex soft-body physics.
|
||||
- Dedicated-server authoritative multiplayer (we stay pure P2P + lightweight signaling).
|
||||
- Mobile-first design (desktop + modern browsers are the primary target).
|
||||
|
||||
---
|
||||
|
||||
## 4. Technical Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|--------------------|-------------------------------------------------|
|
||||
| Runtime | Three.js (vanilla or React Three Fiber) + TypeScript + Vite |
|
||||
| Physics | Box3D (via `box3d-wasm`) |
|
||||
| Networking | PeerJS (or simple-peer) + custom WebSocket signaling |
|
||||
| Backend | Hono + Neon Postgres |
|
||||
| Asset Pipeline | Node-based tools + optional local web UI |
|
||||
| Build / Monorepo | pnpm / Turborepo or simple Vite workspaces |
|
||||
|
||||
---
|
||||
|
||||
## 5. Monorepo Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── client/ # Three.js game runtime
|
||||
│ ├── src/
|
||||
│ │ ├── engine/ # core loop, scene manager
|
||||
│ │ ├── systems/ # Camera, Player, Inventory, Interaction, Multiplayer…
|
||||
│ │ ├── post/ # PSX visual pipeline
|
||||
│ │ └── ...
|
||||
│ └── public/
|
||||
│
|
||||
├── server/ # Hono API + WebSocket signaling
|
||||
│ ├── routes/ # auth, saves, lobbies
|
||||
│ ├── ws/ # SDP/ICE exchange
|
||||
│ └── db/ # Neon + Drizzle/Prisma
|
||||
│
|
||||
├── shared/ # Shared types & definitions
|
||||
│ ├── skeleton/ # canonical skeleton
|
||||
│ ├── types/
|
||||
│ └── schemas/
|
||||
│
|
||||
├── tools/ # Content creation harness
|
||||
│ ├── src/
|
||||
│ │ ├── img2mesh/
|
||||
│ │ ├── rig/
|
||||
│ │ ├── anim/
|
||||
│ │ ├── items/
|
||||
│ │ ├── postprocess/
|
||||
│ │ └── export/
|
||||
│ └── web/ # optional local UI
|
||||
│
|
||||
└── assets/ # Generated GLBs + animation clips
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Core Runtime Systems
|
||||
|
||||
### 6.1 CameraManager
|
||||
- Fixed camera zones using trigger volumes (classic RE style).
|
||||
- Smooth or hard cuts between cameras.
|
||||
- Optional free / follow / rail cameras for Silent Hill style.
|
||||
- Camera-relative movement so "up" always feels correct after a cut.
|
||||
|
||||
### 6.2 PlayerController
|
||||
- Classic tank controls (toggleable).
|
||||
- Modern camera-relative movement.
|
||||
- Quick-turn, aim, interact, item use.
|
||||
- Uses Box3D character mover under the hood.
|
||||
|
||||
### 6.3 InventorySystem
|
||||
- Classic RE-style list or RE4-style grid.
|
||||
- Item examination, combination, and key-item usage.
|
||||
- Fully serializable to JSON for cloud saves.
|
||||
|
||||
### 6.4 Interaction & Puzzle Framework
|
||||
- Examine, pick up, use item on object, door/key systems.
|
||||
- Trigger volumes and state machines for puzzles.
|
||||
- Sensors in Box3D for detection.
|
||||
|
||||
### 6.5 Combat & AI (baseline)
|
||||
- Simple melee and limited-ammo ranged combat.
|
||||
- Basic enemy behaviors suitable for the genre.
|
||||
- Host simulates all AI and damage.
|
||||
|
||||
### 6.6 Multiplayer Layer
|
||||
See section 8.
|
||||
|
||||
---
|
||||
|
||||
## 7. Physics — Box3D
|
||||
|
||||
Box3D (Erin Catto, 2026) is the primary physics engine.
|
||||
|
||||
**Usage guidelines:**
|
||||
- Player → Capsule + character mover
|
||||
- Rooms / static geometry → Triangle meshes or static compounds
|
||||
- Triggers (cameras, items, doors, puzzles) → Sensors
|
||||
- Dynamic props / enemies → Capsules or convex hulls
|
||||
- Determinism is leveraged for host-authoritative multiplayer
|
||||
|
||||
Integration via `box3d-wasm`. Colliders and sensors are auto-generated or defined in asset metadata.
|
||||
|
||||
---
|
||||
|
||||
## 8. Multiplayer Design
|
||||
|
||||
- **Transport**: PeerJS (or simple-peer) data channels after WebSocket signaling.
|
||||
- **Model**: Host-authoritative.
|
||||
- Host (usually lobby creator) owns world simulation, AI, inventory truth, door/puzzle states.
|
||||
- Clients send high-level actions / inputs.
|
||||
- Host validates and broadcasts state or deltas.
|
||||
- Update rate: 10–20 Hz (adventure pacing is forgiving).
|
||||
- Interpolation + simple prediction on clients.
|
||||
- STUN (Google public) by default; TURN optional for reliability.
|
||||
- Shared campaign progress is written to Neon on checkpoints or session end.
|
||||
|
||||
---
|
||||
|
||||
## 9. Backend & Persistence (Neon)
|
||||
|
||||
### Core Tables
|
||||
|
||||
```sql
|
||||
users (
|
||||
id UUID PRIMARY KEY,
|
||||
username TEXT UNIQUE,
|
||||
email TEXT UNIQUE,
|
||||
display_name TEXT,
|
||||
created_at, last_login
|
||||
)
|
||||
|
||||
saves (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID REFERENCES users,
|
||||
slot INT,
|
||||
campaign TEXT,
|
||||
title TEXT,
|
||||
data JSONB NOT NULL, -- inventory, flags, positions, puzzle states…
|
||||
playtime_seconds INT,
|
||||
updated_at,
|
||||
UNIQUE(user_id, slot, campaign)
|
||||
)
|
||||
|
||||
lobbies (
|
||||
id UUID PRIMARY KEY,
|
||||
code TEXT UNIQUE, -- short room code
|
||||
host_id UUID,
|
||||
status TEXT, -- waiting / starting / in_game / closed
|
||||
max_players INT DEFAULT 4,
|
||||
settings JSONB,
|
||||
created_at, expires_at
|
||||
)
|
||||
|
||||
lobby_players (
|
||||
lobby_id, user_id,
|
||||
peer_id INT,
|
||||
is_ready BOOLEAN,
|
||||
PRIMARY KEY (lobby_id, user_id)
|
||||
)
|
||||
```
|
||||
|
||||
JSONB is used heavily for flexible save data and lobby settings.
|
||||
|
||||
---
|
||||
|
||||
## 10. Asset Pipeline & Harness
|
||||
|
||||
### 10.1 Prioritization
|
||||
|
||||
1. **Primary (Characters) — shipped path**
|
||||
Photo (Grok `/imagine` or file) → silhouette measure → code-drawn low-poly mesh on the
|
||||
canonical skeleton → automatic skin weights → procedural clips → GLB.
|
||||
Geometry is constructed in code from measured proportions; we do **not** use Meshy
|
||||
or other third-party mesh APIs for the default harness.
|
||||
|
||||
2. **Secondary (Props / Stylized)**
|
||||
Same measurement → code-drawn approach, simplified for non-humanoids.
|
||||
|
||||
### 10.2 Canonical Skeleton
|
||||
|
||||
Mixamo-compatible core set:
|
||||
|
||||
```
|
||||
Hips
|
||||
Spine → Spine1 → Spine2 → Neck → Head
|
||||
LeftShoulder → LeftArm → LeftForeArm → LeftHand
|
||||
RightShoulder → RightArm → RightForeArm → RightHand
|
||||
LeftUpLeg → LeftLeg → LeftFoot → LeftToeBase
|
||||
RightUpLeg → RightLeg → RightFoot → RightToeBase
|
||||
```
|
||||
|
||||
Defined in `shared/skeleton/canonical.ts`. All auto-riggers map into these names.
|
||||
|
||||
### 10.3 Tools Folder Structure
|
||||
|
||||
```
|
||||
tools/
|
||||
├── src/
|
||||
│ ├── grok/ # Grok CLI bridge (image_gen / image_edit)
|
||||
│ ├── image/ # decode, segment, measure reference photos
|
||||
│ ├── mesh/ # code-drawn humanoid from measurements
|
||||
│ ├── rig/ # canonical skeleton + automatic skin weights
|
||||
│ ├── anim/ # procedural Idle / Walk / Run / QuickTurn
|
||||
│ ├── export/ # hand-rolled GLB writer
|
||||
│ ├── pipeline.ts # end-to-end photo → GLB
|
||||
│ └── cli.ts # `pnpm harness`
|
||||
└── fixtures/ # reference images for tests
|
||||
```
|
||||
|
||||
### 10.4 Key Interfaces (TypeScript)
|
||||
|
||||
```ts
|
||||
interface Img2MeshRequest {
|
||||
image: Buffer | string;
|
||||
style?: "realistic" | "lowpoly" | "psx";
|
||||
provider?: "meshy" | "triposr" | "img2threejs";
|
||||
}
|
||||
|
||||
interface RigResult {
|
||||
glbPath: string;
|
||||
boneMap: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CharacterHarness {
|
||||
fromPhoto(image: Buffer | string, options?: Img2MeshRequest): Promise<{
|
||||
mesh: RigResult;
|
||||
defaultPose?: AnimationClipResult;
|
||||
}>;
|
||||
generateAnimation(from: "pose" | "video" | "procedural", input: any): Promise<AnimationClipResult>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. PSX Visual Pipeline
|
||||
|
||||
- Vertex snapping
|
||||
- Affine texture mapping
|
||||
- Ordered dithering
|
||||
- Color depth reduction
|
||||
- Low-resolution render target + nearest-neighbor upscale
|
||||
- Optional CRT / scanlines / fog
|
||||
- Implemented via material `onBeforeCompile` patches + EffectComposer passes
|
||||
|
||||
---
|
||||
|
||||
## 12. Demo Content (First Playable)
|
||||
|
||||
**Ashgrove Precinct — Level 1**: a procedural crime-investigation loop inside a
|
||||
classic fixed-camera survival-horror building. Traverse rooms, question living
|
||||
NPCs, gather evidence, and open locked wings with inventory puzzles.
|
||||
|
||||
- 9 connected rooms with fixed cameras (lobby, squad hall, office, interview,
|
||||
lounge, corridor, records, break room, evidence vault)
|
||||
- One dedicated room per ambient cast member + player Detective body
|
||||
- Question / examine dialogue on every NPC (Reyes, Ellis, Voss, Kane, Hale, Marrow)
|
||||
- Inventory + key items + crank/valve + brass/vault key progression
|
||||
- Evidence pickups (statement, badge case, photograph, journal)
|
||||
- 2-player co-op lobby
|
||||
- Cloud save / load
|
||||
- Harness-driven cast GLBs with Idle / Walk / Slumped poses
|
||||
|
||||
---
|
||||
|
||||
## 13. Roadmap / Milestones
|
||||
|
||||
**Milestone 1 – Core Runtime**
|
||||
- CameraManager + PlayerController (tank + camera-relative)
|
||||
- Basic room system + Box3D integration
|
||||
- Inventory + Interaction
|
||||
|
||||
**Milestone 2 – Multiplayer**
|
||||
- PeerJS lobby + host-authoritative movement & interactions
|
||||
- Neon auth + cloud saves
|
||||
|
||||
**Milestone 3 – Asset Harness**
|
||||
- Grok CLI bridge for reference generation (`image_gen`)
|
||||
- Silhouette analysis → measurements + palette
|
||||
- Code-drawn mesh on canonical skeleton + automatic skin weights
|
||||
- Procedural locomotion clips + GLB export into the runtime
|
||||
|
||||
**Milestone 4 – First Playable Demo**
|
||||
- Complete small campaign
|
||||
- Full 2-player co-op
|
||||
- Documentation & example workflows
|
||||
|
||||
**Later**
|
||||
- Expanded animation library
|
||||
- More advanced AI & combat
|
||||
- Additional camera modes
|
||||
- Modding / user-generated content hooks
|
||||
|
||||
---
|
||||
|
||||
## 14. Style Guide (PSX Aesthetic Rules)
|
||||
|
||||
- Prefer low polygon counts and limited texture resolution.
|
||||
- Embrace affine distortion and vertex jitter where it serves atmosphere.
|
||||
- Use strong silhouettes and readable shapes over fine detail.
|
||||
- Fog, limited draw distance, and carefully placed lights are primary tools for tension.
|
||||
- UI should feel period-appropriate (simple, slightly chunky, limited color palette).
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Canonical Skeleton Definition
|
||||
|
||||
Full TypeScript definition lives in `shared/skeleton/canonical.ts` (see section 10.2).
|
||||
|
||||
## Appendix B — Neon Schema
|
||||
|
||||
Full SQL is maintained in `server/db/schema.sql` (see section 9).
|
||||
|
||||
## Appendix C — Key Interfaces
|
||||
|
||||
See section 10.4 and the `tools/src/**/types.ts` files.
|
||||
|
||||
---
|
||||
|
||||
## Appendix D — Implementation Addendum (Milestone 1, July 2026)
|
||||
|
||||
Recorded as systems were implemented, per the note below that this is a living
|
||||
document. Nothing here changes the design intent; it records where the intent
|
||||
met the current state of the dependencies.
|
||||
|
||||
### D.1 Box3D capability gaps
|
||||
|
||||
`box3d-wasm@0.2.0` (published 2026-07-02) binds less of upstream Box3D than
|
||||
§6.2 and §7 assume. Verified by enumerating the embind prototypes on the built
|
||||
wasm module:
|
||||
|
||||
| Assumed in this document | Actual | Resolution |
|
||||
| --- | --- | --- |
|
||||
| Box3D character mover (§6.2, §7) | Not bound | Hand-rolled collide-and-slide capsule mover, `client/src/systems/CharacterMover.ts` |
|
||||
| Triangle-mesh room collision (§7) | No trimesh shape; only box, sphere, capsule, hull | Rooms are static compounds of boxes and convex hulls. `ColliderDef` in `shared` is deliberately narrowed to the four supported primitives so the asset pipeline cannot emit collision the runtime can't build |
|
||||
| Capsule sweeps for the mover | No shape cast or overlap query; `castRayClosest` and `shape.rayCast` only | A fan of ray probes (three heights × three lateral offsets) approximates the sweep |
|
||||
|
||||
Confirmed working and load-bearing: raycasts with filters, sensor begin/end
|
||||
events keyed by `userData`, kinematic bodies via `setTargetTransform`, convex
|
||||
hulls, contact and body events.
|
||||
|
||||
The package ships no TypeScript declarations. `client/src/types/box3d-wasm.d.ts`
|
||||
declares the API as observed at runtime rather than as documented — the README
|
||||
describes a slightly larger surface than is actually bound.
|
||||
|
||||
### D.2 Determinism claim in §7
|
||||
|
||||
§7 states determinism is leveraged for host-authoritative multiplayer. These are
|
||||
independent properties: host-authoritative simulation requires only that one
|
||||
peer owns the state and broadcasts it, which is what §8 actually describes.
|
||||
Determinism is a requirement of *lockstep* networking, which this project is not
|
||||
using. Milestone 2 should not be designed around a determinism guarantee that a
|
||||
0.2.0 wasm build has not demonstrated.
|
||||
|
||||
### D.3 Userdata tag collisions
|
||||
|
||||
box3d-wasm keeps separate auto-increment counters for body and shape
|
||||
`userData` tags, so an auto-assigned body tag and shape tag can be numerically
|
||||
equal. Since sensor and contact events reference *shape* tags, `PhysicsWorld`
|
||||
assigns every tag from a single counter of its own rather than relying on the
|
||||
auto-assigned values.
|
||||
|
||||
### D.4 Vite worker format
|
||||
|
||||
The threaded ("deluxe") physics build spawns emscripten pthread workers whose
|
||||
entry uses top-level await. Vite bundles workers as `iife` by default, which
|
||||
cannot express that, and the production build fails. `worker: { format: "es" }`
|
||||
in `client/vite.config.ts` is required, not optional.
|
||||
|
||||
### D.5 Affine texture mapping technique
|
||||
|
||||
GLSL ES 3.00 has no `noperspective` interpolation qualifier, so §11's affine
|
||||
mapping cannot be requested directly. It is instead produced arithmetically:
|
||||
the vertex shader emits `uv * w` and `w` as varyings and the fragment shader
|
||||
divides them. The interpolator's own `1/w` factors cancel, leaving
|
||||
screen-linear UVs. The patch is applied only to materials that carry a `map`,
|
||||
since without one three.js may not declare the `uv` attribute at all.
|
||||
|
||||
### D.6 WebRTC transport: neither PeerJS nor simple-peer
|
||||
|
||||
§4 and §8 name "PeerJS (or simple-peer) + custom WebSocket signaling". Those
|
||||
two halves are in tension, and neither library fits:
|
||||
|
||||
- **simple-peer** depends on `buffer` and `readable-stream`, Node polyfills that
|
||||
are a recurring source of Vite build breakage.
|
||||
- **PeerJS** is built around its own broker protocol. Using it would *replace*
|
||||
the custom WebSocket signalling §4 calls for, not complement it.
|
||||
|
||||
What both libraries would have provided is a data channel following an SDP
|
||||
exchange, which is a thin wrapper over `RTCPeerConnection`. `client/src/net/
|
||||
PeerLink.ts` is that wrapper, with no dependencies, and it matches §8's
|
||||
description exactly: data channels established after WebSocket signalling.
|
||||
|
||||
Each link carries **two** channels rather than one, because gameplay traffic has
|
||||
two delivery profiles: `state` (unreliable, unordered) for position snapshots,
|
||||
which are worthless once superseded, and `event` (reliable, ordered) for flags,
|
||||
despawns and inventory, where a single drop desynchronises the world.
|
||||
|
||||
### D.7 Co-op inventory is shared, not per-player
|
||||
|
||||
§6.3 and §8 do not say whether each peer carries their own inventory. In this
|
||||
implementation the party shares one, held by the host. It matches how the
|
||||
genre's co-op modes work, and it keeps a single authoritative copy rather than
|
||||
four that can disagree. Guests display a read-only mirror pushed by the host.
|
||||
|
||||
### D.8 Host reach checks for remote interactions
|
||||
|
||||
Interaction reach is detected with Box3D sensors, but sensors only ever track
|
||||
the *local* player's capsule. When a guest asks to interact, the host therefore
|
||||
re-derives proximity from that guest's authoritative mover position before
|
||||
running the interaction (`Game.handleGuestInteract`). Without this a guest could
|
||||
open any door in the room by naming its id.
|
||||
|
||||
### D.9 Key items and inventory capacity
|
||||
|
||||
§6.3 does not say whether key items consume inventory slots. They do not, in
|
||||
this implementation: a full inventory would otherwise be able to soft-lock a
|
||||
puzzle. They are held outside the slot array and serialised separately in
|
||||
`InventoryState.keyItems`.
|
||||
|
||||
---
|
||||
|
||||
*This document is the living design and technical specification for the PSX Adventure Engine reference project. It will be updated as systems are implemented and lessons are learned.*
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 PSX Adventure Engine contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,156 @@
|
||||
# PSX Adventure Engine
|
||||
|
||||
Browser-first reference engine and project template for **PSX-era third-person
|
||||
adventure** — fixed cameras, inventory puzzles, low-poly atmosphere, and true
|
||||
peer-to-peer co-op.
|
||||
|
||||
Inspired by classic survival-horror and investigation games of the late ’90s
|
||||
(not affiliated with any of those franchises).
|
||||
|
||||
| Layer | Stack |
|
||||
| --- | --- |
|
||||
| Client | Three.js · Solid · Vite · Box3D (`box3d-wasm`) |
|
||||
| Server | Hono · WebSocket signalling · optional Neon |
|
||||
| Shared | Strict TypeScript types & save schemas |
|
||||
| Tools | Code-drawn character creator · procedural clips · optional Grok textures |
|
||||
|
||||
**Status:** Milestone 3 — playable multi-room demo, co-op, character harness.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
**Requirements:** Node 20+, [pnpm](https://pnpm.io) 10+
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # client → http://localhost:5173
|
||||
pnpm dev:server # API / signalling → http://localhost:8787
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Copy [`.env.example`](./.env.example) if you need custom ports or a session
|
||||
secret. Defaults work for local solo play without any env file.
|
||||
|
||||
---
|
||||
|
||||
## Controls
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `W` `A` `S` `D` / arrows | Move (tank: `A`/`D` turn, `W`/`S` walk) |
|
||||
| `Shift` | Run |
|
||||
| `E` | Interact / talk / examine |
|
||||
| `Space` | Quick turn (180°) |
|
||||
| `C` | Toggle tank ↔ camera-relative |
|
||||
| `Tab` | Inventory |
|
||||
| `M` | Co-op lobby |
|
||||
| `` ` `` | Debug overlay |
|
||||
| `Shift`+`F5` | Save to local storage |
|
||||
|
||||
---
|
||||
|
||||
## Demo campaign — Ashgrove Precinct (Level 1)
|
||||
|
||||
Investigation loop in a fixed-camera building: walk the precinct, **question
|
||||
NPCs**, gather evidence, and open locked wings with inventory puzzles.
|
||||
|
||||
```
|
||||
Lobby (Ellis) ── Witness Lounge (Kane)
|
||||
│
|
||||
Squad Hall ── Detective Office (Det. Reyes)
|
||||
│ ── Interview Room (Nora Voss)
|
||||
│
|
||||
Corridor ── Records (Dr. Hale) [repaired crank → valve]
|
||||
│
|
||||
Break Room ── Evidence Vault (Marrow) [brass key → vault key]
|
||||
```
|
||||
|
||||
**Progression**
|
||||
|
||||
1. Talk to people in Lobby, Lounge, Office, Interview
|
||||
2. Squad Hall — crank handle + broken shaft → combine into repaired crank
|
||||
3. Corridor valve → Records (Hale, brass key, notes)
|
||||
4. Break Room (brass key) → vault key
|
||||
5. Evidence Vault — journal, badge case, case payoff
|
||||
|
||||
Cast GLBs live in `assets/characters/` and are rebuilt with `pnpm cast`.
|
||||
|
||||
See [`GDD.md`](./GDD.md) for full design, networking model, and implementation notes.
|
||||
|
||||
---
|
||||
|
||||
## Character creator
|
||||
|
||||
Two base bodies (**male** / **female**) on a Mixamo-compatible canonical skeleton,
|
||||
plus swappable hair / clothing / footwear. Recipes in
|
||||
`tools/src/creator/presets.ts` export Idle / Walk / Run / QuickTurn / **Slumped**
|
||||
clips.
|
||||
|
||||
```bash
|
||||
pnpm creator -- --list
|
||||
pnpm cast # all presets → assets/characters/
|
||||
pnpm creator -- --id survivor
|
||||
pnpm creator -- --id survivor --uv-guide
|
||||
pnpm creator -- --id survivor --texture # needs Grok CLI for albedo paint
|
||||
pnpm creator:ui # http://localhost:5174
|
||||
```
|
||||
|
||||
Optional photo path (silhouette measure → code-drawn mesh):
|
||||
|
||||
```bash
|
||||
pnpm harness -- --image tools/fixtures/survivor-reference.jpg --name Survivor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Co-op
|
||||
|
||||
```bash
|
||||
pnpm dev:server # terminal 1
|
||||
pnpm dev # terminal 2
|
||||
```
|
||||
|
||||
Press `M`, create an account, host, share the five-character room code. Host is
|
||||
authoritative; guests send movement intent and reconcile 15 Hz snapshots.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
client/ Runtime — engine, systems, PSX post, rooms, Solid UI
|
||||
server/ Hono API + WebSocket signalling (in-memory store by default)
|
||||
shared/ Types, skeleton, save / net schemas
|
||||
tools/ Character harness + local creator UI
|
||||
assets/ Shipped cast GLBs (+ recipes); generated/ is gitignored
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes
|
||||
|
||||
A few design-doc features are unavailable in `box3d-wasm@0.2.0`:
|
||||
|
||||
| Design doc | Reality | Workaround |
|
||||
| --- | --- | --- |
|
||||
| Character mover | Not bound | Capsule collide-and-slide via ray probes |
|
||||
| Triangle-mesh rooms | No trimesh | Box + convex-hull compounds |
|
||||
| Capsule sweeps | No shape cast | Multi-height `castRayClosest` fan |
|
||||
|
||||
The client sets COOP/COEP headers so the threaded physics build can load; the
|
||||
debug overlay reports which build you got.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
||||
Not affiliated with Capcom, Konami, Square, or any television franchise. The
|
||||
demo setting and cast are original fiction.
|
||||
|
After Width: | Height: | Size: 412 KiB |
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"id": "civilian-f",
|
||||
"name": "Civilian",
|
||||
"body": "female",
|
||||
"height": 1.64,
|
||||
"bodyStyle": {
|
||||
"mass": 0,
|
||||
"muscle": -0.05,
|
||||
"fat": 0.1
|
||||
},
|
||||
"headStyle": {
|
||||
"length": -0.15,
|
||||
"jaw": 0.1,
|
||||
"brow": 0.15
|
||||
},
|
||||
"styleNotes": "muted plum wool overcoat, dark knee-length skirt, black tights, simple low-heeled shoes, everyday and unremarkable",
|
||||
"palette": {
|
||||
"skin": {
|
||||
"r": 200,
|
||||
"g": 160,
|
||||
"b": 130
|
||||
},
|
||||
"hair": {
|
||||
"r": 55,
|
||||
"g": 35,
|
||||
"b": 28
|
||||
},
|
||||
"primary": {
|
||||
"r": 90,
|
||||
"g": 70,
|
||||
"b": 95
|
||||
},
|
||||
"secondary": {
|
||||
"r": 50,
|
||||
"g": 48,
|
||||
"b": 55
|
||||
},
|
||||
"accent": {
|
||||
"r": 120,
|
||||
"g": 100,
|
||||
"b": 60
|
||||
},
|
||||
"metal": {
|
||||
"r": 140,
|
||||
"g": 140,
|
||||
"b": 145
|
||||
},
|
||||
"leather": {
|
||||
"r": 80,
|
||||
"g": 55,
|
||||
"b": 45
|
||||
}
|
||||
},
|
||||
"parts": {
|
||||
"hair": "hair.long",
|
||||
"upper": "upper.coat",
|
||||
"lower": "lower.skirt",
|
||||
"feet": "feet.shoes",
|
||||
"accessory": "accessory.none"
|
||||
},
|
||||
"stats": {
|
||||
"vertices": 1189,
|
||||
"triangles": 2112,
|
||||
"bones": 22,
|
||||
"clips": 5,
|
||||
"skinHidden": 448,
|
||||
"skinRecessed": 310
|
||||
},
|
||||
"textured": true
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"id": "civilian-m",
|
||||
"name": "Townsman",
|
||||
"body": "male",
|
||||
"height": 1.76,
|
||||
"bodyStyle": {
|
||||
"mass": 0.05,
|
||||
"muscle": 0,
|
||||
"fat": 0.15
|
||||
},
|
||||
"headStyle": {
|
||||
"length": 0.1,
|
||||
"jaw": -0.2,
|
||||
"brow": -0.35
|
||||
},
|
||||
"styleNotes": "charcoal two-piece business suit, white dress shirt, burgundy tie, polished black oxfords, slightly rumpled after a long day",
|
||||
"palette": {
|
||||
"primary": {
|
||||
"r": 95,
|
||||
"g": 90,
|
||||
"b": 85
|
||||
},
|
||||
"secondary": {
|
||||
"r": 55,
|
||||
"g": 55,
|
||||
"b": 60
|
||||
},
|
||||
"accent": {
|
||||
"r": 120,
|
||||
"g": 100,
|
||||
"b": 60
|
||||
},
|
||||
"metal": {
|
||||
"r": 140,
|
||||
"g": 140,
|
||||
"b": 145
|
||||
},
|
||||
"leather": {
|
||||
"r": 65,
|
||||
"g": 48,
|
||||
"b": 38
|
||||
},
|
||||
"skin": {
|
||||
"r": 200,
|
||||
"g": 160,
|
||||
"b": 130
|
||||
},
|
||||
"hair": {
|
||||
"r": 70,
|
||||
"g": 60,
|
||||
"b": 50
|
||||
}
|
||||
},
|
||||
"parts": {
|
||||
"hair": "hair.short",
|
||||
"upper": "upper.shirt",
|
||||
"lower": "lower.pants",
|
||||
"feet": "feet.shoes",
|
||||
"accessory": "accessory.belt"
|
||||
},
|
||||
"stats": {
|
||||
"vertices": 1171,
|
||||
"triangles": 2044,
|
||||
"bones": 22,
|
||||
"clips": 5,
|
||||
"skinHidden": 540,
|
||||
"skinRecessed": 262
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 539 KiB |
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"id": "groundskeeper",
|
||||
"name": "Ellis",
|
||||
"body": "male",
|
||||
"height": 1.75,
|
||||
"bodyStyle": {
|
||||
"mass": 0.2,
|
||||
"muscle": 0.1,
|
||||
"fat": 0.35
|
||||
},
|
||||
"headStyle": {
|
||||
"length": -0.5,
|
||||
"jaw": 0.3,
|
||||
"brow": 0.1
|
||||
},
|
||||
"styleNotes": "faded rust-brown work coat, heavy tan canvas trousers, oil stains at the knees and cuffs, worn steel-toe boots",
|
||||
"palette": {
|
||||
"primary": {
|
||||
"r": 120,
|
||||
"g": 78,
|
||||
"b": 48
|
||||
},
|
||||
"secondary": {
|
||||
"r": 70,
|
||||
"g": 72,
|
||||
"b": 78
|
||||
},
|
||||
"accent": {
|
||||
"r": 100,
|
||||
"g": 80,
|
||||
"b": 50
|
||||
},
|
||||
"metal": {
|
||||
"r": 140,
|
||||
"g": 140,
|
||||
"b": 145
|
||||
},
|
||||
"leather": {
|
||||
"r": 55,
|
||||
"g": 45,
|
||||
"b": 35
|
||||
},
|
||||
"skin": {
|
||||
"r": 145,
|
||||
"g": 105,
|
||||
"b": 78
|
||||
},
|
||||
"hair": {
|
||||
"r": 90,
|
||||
"g": 70,
|
||||
"b": 50
|
||||
}
|
||||
},
|
||||
"parts": {
|
||||
"hair": "hair.messy",
|
||||
"upper": "upper.workcoat",
|
||||
"lower": "lower.cargo",
|
||||
"feet": "feet.boots",
|
||||
"accessory": "accessory.satchel"
|
||||
},
|
||||
"stats": {
|
||||
"vertices": 1206,
|
||||
"triangles": 2098,
|
||||
"bones": 22,
|
||||
"clips": 5,
|
||||
"skinHidden": 564,
|
||||
"skinRecessed": 262
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"id": "officer",
|
||||
"name": "Sgt. Marrow",
|
||||
"body": "male",
|
||||
"height": 1.82,
|
||||
"bodyStyle": {
|
||||
"mass": 0.35,
|
||||
"muscle": 0.45,
|
||||
"fat": 0.1
|
||||
},
|
||||
"headStyle": {
|
||||
"length": -0.35,
|
||||
"jaw": 0.8,
|
||||
"brow": 0.65
|
||||
},
|
||||
"styleNotes": "dark slate police uniform, brass buttons and a gold shoulder flash, black duty belt, polished black boots, crisp and pressed",
|
||||
"palette": {
|
||||
"primary": {
|
||||
"r": 38,
|
||||
"g": 50,
|
||||
"b": 46
|
||||
},
|
||||
"secondary": {
|
||||
"r": 32,
|
||||
"g": 36,
|
||||
"b": 40
|
||||
},
|
||||
"accent": {
|
||||
"r": 160,
|
||||
"g": 140,
|
||||
"b": 50
|
||||
},
|
||||
"metal": {
|
||||
"r": 140,
|
||||
"g": 140,
|
||||
"b": 145
|
||||
},
|
||||
"leather": {
|
||||
"r": 35,
|
||||
"g": 30,
|
||||
"b": 28
|
||||
},
|
||||
"skin": {
|
||||
"r": 145,
|
||||
"g": 105,
|
||||
"b": 78
|
||||
},
|
||||
"hair": {
|
||||
"r": 28,
|
||||
"g": 28,
|
||||
"b": 30
|
||||
}
|
||||
},
|
||||
"parts": {
|
||||
"hair": "hair.undercut",
|
||||
"upper": "upper.uniform",
|
||||
"lower": "lower.pants",
|
||||
"feet": "feet.boots",
|
||||
"accessory": "accessory.belt"
|
||||
},
|
||||
"stats": {
|
||||
"vertices": 1171,
|
||||
"triangles": 2044,
|
||||
"bones": 22,
|
||||
"clips": 5,
|
||||
"skinHidden": 616,
|
||||
"skinRecessed": 262
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 402 KiB |
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"id": "olivia",
|
||||
"name": "Det. Reyes",
|
||||
"body": "female",
|
||||
"height": 1.63,
|
||||
"bodyStyle": {
|
||||
"mass": 0.21,
|
||||
"muscle": 0.35,
|
||||
"fat": 0.05
|
||||
},
|
||||
"headStyle": {
|
||||
"length": -0.16,
|
||||
"jaw": 0.06,
|
||||
"brow": 0.53
|
||||
},
|
||||
"styleNotes": "homicide detective in a muted jacket, dark pants, and boots — ashgrove precinct night shift",
|
||||
"palette": {
|
||||
"skin": {
|
||||
"r": 200,
|
||||
"g": 160,
|
||||
"b": 130
|
||||
},
|
||||
"hair": {
|
||||
"r": 40,
|
||||
"g": 36,
|
||||
"b": 32
|
||||
},
|
||||
"primary": {
|
||||
"r": 78,
|
||||
"g": 82,
|
||||
"b": 52
|
||||
},
|
||||
"secondary": {
|
||||
"r": 48,
|
||||
"g": 50,
|
||||
"b": 55
|
||||
},
|
||||
"accent": {
|
||||
"r": 90,
|
||||
"g": 70,
|
||||
"b": 40
|
||||
},
|
||||
"metal": {
|
||||
"r": 140,
|
||||
"g": 140,
|
||||
"b": 145
|
||||
},
|
||||
"leather": {
|
||||
"r": 85,
|
||||
"g": 60,
|
||||
"b": 40
|
||||
}
|
||||
},
|
||||
"parts": {
|
||||
"hair": "hair.long",
|
||||
"upper": "upper.jacket",
|
||||
"lower": "lower.cargo",
|
||||
"feet": "feet.boots",
|
||||
"accessory": "accessory.none"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 784 KiB |
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"id": "researcher",
|
||||
"name": "Dr. Hale",
|
||||
"body": "female",
|
||||
"height": 1.66,
|
||||
"bodyStyle": {
|
||||
"mass": -0.15,
|
||||
"muscle": -0.1,
|
||||
"fat": 0.05
|
||||
},
|
||||
"headStyle": {
|
||||
"length": 0.2,
|
||||
"jaw": -0.35,
|
||||
"brow": -0.2
|
||||
},
|
||||
"styleNotes": "clean off-white lab coat over a navy blouse and slim charcoal trousers, plain black flats, wire-rimmed glasses, no weathering",
|
||||
"palette": {
|
||||
"primary": {
|
||||
"r": 230,
|
||||
"g": 228,
|
||||
"b": 220
|
||||
},
|
||||
"secondary": {
|
||||
"r": 50,
|
||||
"g": 52,
|
||||
"b": 65
|
||||
},
|
||||
"accent": {
|
||||
"r": 80,
|
||||
"g": 90,
|
||||
"b": 110
|
||||
},
|
||||
"metal": {
|
||||
"r": 140,
|
||||
"g": 140,
|
||||
"b": 145
|
||||
},
|
||||
"leather": {
|
||||
"r": 60,
|
||||
"g": 50,
|
||||
"b": 45
|
||||
},
|
||||
"skin": {
|
||||
"r": 220,
|
||||
"g": 185,
|
||||
"b": 160
|
||||
},
|
||||
"hair": {
|
||||
"r": 200,
|
||||
"g": 190,
|
||||
"b": 175
|
||||
}
|
||||
},
|
||||
"parts": {
|
||||
"hair": "hair.bun",
|
||||
"upper": "upper.labcoat",
|
||||
"lower": "lower.pants",
|
||||
"feet": "feet.shoes",
|
||||
"accessory": "accessory.glasses"
|
||||
},
|
||||
"partColors": {
|
||||
"upper.labcoat": {
|
||||
"r": 235,
|
||||
"g": 233,
|
||||
"b": 225
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"vertices": 1171,
|
||||
"triangles": 2036,
|
||||
"bones": 22,
|
||||
"clips": 5,
|
||||
"skinHidden": 616,
|
||||
"skinRecessed": 262
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 418 KiB |
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"id": "survivor",
|
||||
"name": "Survivor",
|
||||
"body": "male",
|
||||
"height": 1.72,
|
||||
"bodyStyle": {
|
||||
"mass": -0.25,
|
||||
"muscle": 0.2,
|
||||
"fat": 0.05
|
||||
},
|
||||
"headStyle": {
|
||||
"length": -0.65,
|
||||
"jaw": 0.68,
|
||||
"brow": 0.65
|
||||
},
|
||||
"styleNotes": "business suit and tie",
|
||||
"palette": {
|
||||
"skin": {
|
||||
"r": 220,
|
||||
"g": 185,
|
||||
"b": 160
|
||||
},
|
||||
"hair": {
|
||||
"r": 40,
|
||||
"g": 36,
|
||||
"b": 32
|
||||
},
|
||||
"primary": {
|
||||
"r": 78,
|
||||
"g": 82,
|
||||
"b": 52
|
||||
},
|
||||
"secondary": {
|
||||
"r": 48,
|
||||
"g": 50,
|
||||
"b": 55
|
||||
},
|
||||
"accent": {
|
||||
"r": 90,
|
||||
"g": 70,
|
||||
"b": 40
|
||||
},
|
||||
"metal": {
|
||||
"r": 140,
|
||||
"g": 140,
|
||||
"b": 145
|
||||
},
|
||||
"leather": {
|
||||
"r": 85,
|
||||
"g": 60,
|
||||
"b": 40
|
||||
}
|
||||
},
|
||||
"parts": {
|
||||
"hair": "hair.long",
|
||||
"upper": "upper.jacket",
|
||||
"lower": "lower.pants",
|
||||
"feet": "feet.boots",
|
||||
"accessory": "accessory.none"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 38 KiB |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
@@ -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";
|
||||
@@ -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]),
|
||||
);
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "psxbase",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "PSX Adventure Engine — browser reference engine for PSX-era third-person adventure (fixed cameras, inventory, P2P co-op)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @psx/client dev",
|
||||
"dev:server": "pnpm --filter @psx/server dev",
|
||||
"build": "pnpm -r build",
|
||||
"test": "pnpm -r test",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"preview": "pnpm --filter @psx/tools preview",
|
||||
"harness": "pnpm --filter @psx/tools harness",
|
||||
"cast": "pnpm --filter @psx/tools cast",
|
||||
"creator": "pnpm --filter @psx/tools creator",
|
||||
"creator:ui": "pnpm --filter @psx/tools creator:ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
packages:
|
||||
- client
|
||||
- server
|
||||
- shared
|
||||
- tools
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@psx/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json --noEmit false --outDir dist",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"@psx/shared": "workspace:*",
|
||||
"hono": "^4.12.32",
|
||||
"ws": "^8.21.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { InMemoryStore, type Store } from "./db/store.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { lobbyRoutes } from "./routes/lobbies.js";
|
||||
import { savesRoutes } from "./routes/saves.js";
|
||||
|
||||
/**
|
||||
* Route wiring, separated from the bootstrap so tests can mount the app
|
||||
* against a fresh store without opening a port.
|
||||
*/
|
||||
export function createApp(store: Store = new InMemoryStore()) {
|
||||
const app = new Hono();
|
||||
|
||||
// The client page is cross-origin isolated (for wasm threads), which does not
|
||||
// restrict its own outbound fetches, but in dev it runs on a different port.
|
||||
app.use("/*", cors({ origin: (origin) => origin ?? "*", credentials: true }));
|
||||
|
||||
app.get("/health", (context) =>
|
||||
context.json({ ok: true, storage: "in-memory", version: "0.1.0" }),
|
||||
);
|
||||
|
||||
app.route("/auth", authRoutes(store));
|
||||
app.route("/lobbies", lobbyRoutes(store));
|
||||
app.route("/saves", savesRoutes(store));
|
||||
|
||||
return { app, store };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createHmac, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import type { Store, UserRecord } from "./db/store.js";
|
||||
|
||||
/**
|
||||
* Password hashing and session tokens.
|
||||
*
|
||||
* Real scrypt and real HMAC even though the store is in-memory: getting these
|
||||
* wrong is the kind of thing that survives into production once a Neon
|
||||
* connection string appears, and there is no cost to doing it properly now.
|
||||
*/
|
||||
|
||||
const scryptAsync = promisify(scrypt) as (
|
||||
password: string,
|
||||
salt: string,
|
||||
keylen: number,
|
||||
) => Promise<Buffer>;
|
||||
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Dev-only fallback secret. Regenerated per process, so restarting invalidates
|
||||
* every outstanding token rather than signing with a known constant.
|
||||
*/
|
||||
const SECRET = process.env["PSX_SESSION_SECRET"] ?? randomBytes(32).toString("hex");
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const derived = await scryptAsync(password, salt, SCRYPT_KEYLEN);
|
||||
return `${salt}:${derived.toString("hex")}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [salt, expected] = stored.split(":");
|
||||
if (!salt || !expected) return false;
|
||||
|
||||
const derived = await scryptAsync(password, salt, SCRYPT_KEYLEN);
|
||||
const expectedBuffer = Buffer.from(expected, "hex");
|
||||
// Length check first: timingSafeEqual throws on mismatched lengths.
|
||||
if (expectedBuffer.length !== derived.length) return false;
|
||||
return timingSafeEqual(derived, expectedBuffer);
|
||||
}
|
||||
|
||||
interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
jti: string;
|
||||
}
|
||||
|
||||
const sign = (data: string): string =>
|
||||
createHmac("sha256", SECRET).update(data).digest("base64url");
|
||||
|
||||
/** Compact signed token. Not a full JWT — there is no need for the header. */
|
||||
export function issueToken(userId: string): string {
|
||||
const payload: TokenPayload = {
|
||||
sub: userId,
|
||||
exp: Date.now() + TOKEN_TTL_MS,
|
||||
jti: randomUUID(),
|
||||
};
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `${body}.${sign(body)}`;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): { userId: string } | null {
|
||||
const [body, signature] = token.split(".");
|
||||
if (!body || !signature) return null;
|
||||
|
||||
const expected = sign(body);
|
||||
const givenBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (givenBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(givenBuffer, expectedBuffer)) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString()) as TokenPayload;
|
||||
if (typeof payload.sub !== "string" || typeof payload.exp !== "number") return null;
|
||||
if (payload.exp < Date.now()) return null;
|
||||
return { userId: payload.sub };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the caller from an `Authorization: Bearer …` header. */
|
||||
export async function userFromAuthHeader(
|
||||
store: Store,
|
||||
header: string | undefined,
|
||||
): Promise<UserRecord | null> {
|
||||
if (!header?.startsWith("Bearer ")) return null;
|
||||
const verified = verifyToken(header.slice(7));
|
||||
return verified ? store.findUserById(verified.userId) : null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
-- PSX Adventure Engine — Neon schema (GDD §9, Appendix B).
|
||||
-- JSONB is used heavily so save shape and lobby settings can evolve without
|
||||
-- a migration for every gameplay addition.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saves (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
slot INT NOT NULL,
|
||||
campaign TEXT NOT NULL,
|
||||
title TEXT,
|
||||
data JSONB NOT NULL,
|
||||
playtime_seconds INT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, slot, campaign)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS saves_user_idx ON saves (user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lobbies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
host_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'waiting'
|
||||
CHECK (status IN ('waiting', 'starting', 'in_game', 'closed')),
|
||||
max_players INT NOT NULL DEFAULT 4,
|
||||
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '4 hours'
|
||||
);
|
||||
|
||||
-- Lobby codes are looked up on every join attempt; keep it cheap.
|
||||
CREATE INDEX IF NOT EXISTS lobbies_open_idx
|
||||
ON lobbies (code) WHERE status <> 'closed';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lobby_players (
|
||||
lobby_id UUID NOT NULL REFERENCES lobbies(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
peer_id INT NOT NULL,
|
||||
is_ready BOOLEAN NOT NULL DEFAULT false,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (lobby_id, user_id)
|
||||
);
|
||||
@@ -0,0 +1,268 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { SaveData } from "@psx/shared";
|
||||
import { MAX_PLAYERS } from "@psx/shared";
|
||||
|
||||
/**
|
||||
* Persistence port.
|
||||
*
|
||||
* Milestone 2 still has no Neon credentials, so the in-memory implementation
|
||||
* below is what runs. The interface mirrors `schema.sql` closely enough that a
|
||||
* Neon-backed implementation is a matter of writing queries rather than
|
||||
* reshaping call sites.
|
||||
*/
|
||||
|
||||
export interface UserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
passwordHash: string;
|
||||
createdAt: string;
|
||||
lastLogin: string | null;
|
||||
}
|
||||
|
||||
export interface SaveRecord {
|
||||
userId: string;
|
||||
slot: number;
|
||||
campaign: string;
|
||||
title: string | null;
|
||||
data: SaveData;
|
||||
playtimeSeconds: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type LobbyStatus = "waiting" | "starting" | "in_game" | "closed";
|
||||
|
||||
export interface LobbyPlayerRecord {
|
||||
userId: string;
|
||||
peerId: number;
|
||||
displayName: string;
|
||||
isReady: boolean;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface LobbyRecord {
|
||||
id: string;
|
||||
code: string;
|
||||
hostId: string;
|
||||
status: LobbyStatus;
|
||||
maxPlayers: number;
|
||||
settings: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
players: LobbyPlayerRecord[];
|
||||
}
|
||||
|
||||
export interface Store {
|
||||
createUser(input: {
|
||||
username: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
passwordHash: string;
|
||||
}): Promise<UserRecord>;
|
||||
findUserByUsername(username: string): Promise<UserRecord | null>;
|
||||
findUserById(id: string): Promise<UserRecord | null>;
|
||||
touchLogin(id: string): Promise<void>;
|
||||
|
||||
putSave(record: Omit<SaveRecord, "updatedAt">): Promise<SaveRecord>;
|
||||
getSave(userId: string, campaign: string, slot: number): Promise<SaveRecord | null>;
|
||||
listSaves(userId: string): Promise<SaveRecord[]>;
|
||||
deleteSave(userId: string, campaign: string, slot: number): Promise<boolean>;
|
||||
|
||||
createLobby(hostId: string, hostName: string, settings?: Record<string, unknown>): Promise<LobbyRecord>;
|
||||
findLobbyByCode(code: string): Promise<LobbyRecord | null>;
|
||||
joinLobby(code: string, userId: string, displayName: string): Promise<LobbyRecord | { error: string }>;
|
||||
leaveLobby(code: string, userId: string): Promise<LobbyRecord | null>;
|
||||
setReady(code: string, userId: string, isReady: boolean): Promise<LobbyRecord | null>;
|
||||
setLobbyStatus(code: string, status: LobbyStatus): Promise<LobbyRecord | null>;
|
||||
}
|
||||
|
||||
const saveKey = (userId: string, campaign: string, slot: number) =>
|
||||
`${userId}:${campaign}:${slot}`;
|
||||
|
||||
/**
|
||||
* Lobby codes are read aloud over voice chat, so the alphabet omits characters
|
||||
* that sound or look alike: no O/0, no I/1, no S/5.
|
||||
*/
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRTUVWXY2346789";
|
||||
const CODE_LENGTH = 5;
|
||||
|
||||
export class InMemoryStore implements Store {
|
||||
private readonly users = new Map<string, UserRecord>();
|
||||
private readonly usersByUsername = new Map<string, string>();
|
||||
private readonly saves = new Map<string, SaveRecord>();
|
||||
private readonly lobbies = new Map<string, LobbyRecord>();
|
||||
|
||||
// --- users ---------------------------------------------------------------
|
||||
|
||||
async createUser(input: {
|
||||
username: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
passwordHash: string;
|
||||
}): Promise<UserRecord> {
|
||||
const key = input.username.toLowerCase();
|
||||
if (this.usersByUsername.has(key)) throw new Error("username already taken");
|
||||
|
||||
const user: UserRecord = {
|
||||
id: randomUUID(),
|
||||
username: input.username,
|
||||
email: input.email,
|
||||
displayName: input.displayName ?? input.username,
|
||||
passwordHash: input.passwordHash,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastLogin: null,
|
||||
};
|
||||
this.users.set(user.id, user);
|
||||
this.usersByUsername.set(key, user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
async findUserByUsername(username: string): Promise<UserRecord | null> {
|
||||
const id = this.usersByUsername.get(username.toLowerCase());
|
||||
return id ? (this.users.get(id) ?? null) : null;
|
||||
}
|
||||
|
||||
async findUserById(id: string): Promise<UserRecord | null> {
|
||||
return this.users.get(id) ?? null;
|
||||
}
|
||||
|
||||
async touchLogin(id: string): Promise<void> {
|
||||
const user = this.users.get(id);
|
||||
if (user) user.lastLogin = new Date().toISOString();
|
||||
}
|
||||
|
||||
// --- saves ---------------------------------------------------------------
|
||||
|
||||
async putSave(record: Omit<SaveRecord, "updatedAt">): Promise<SaveRecord> {
|
||||
const stored: SaveRecord = { ...record, updatedAt: new Date().toISOString() };
|
||||
this.saves.set(saveKey(record.userId, record.campaign, record.slot), stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
async getSave(userId: string, campaign: string, slot: number): Promise<SaveRecord | null> {
|
||||
return this.saves.get(saveKey(userId, campaign, slot)) ?? null;
|
||||
}
|
||||
|
||||
async listSaves(userId: string): Promise<SaveRecord[]> {
|
||||
return [...this.saves.values()]
|
||||
.filter((record) => record.userId === userId)
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
async deleteSave(userId: string, campaign: string, slot: number): Promise<boolean> {
|
||||
return this.saves.delete(saveKey(userId, campaign, slot));
|
||||
}
|
||||
|
||||
// --- lobbies -------------------------------------------------------------
|
||||
|
||||
private generateCode(): string {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
let code = "";
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
|
||||
}
|
||||
if (!this.lobbies.has(code)) return code;
|
||||
}
|
||||
throw new Error("could not allocate a free lobby code");
|
||||
}
|
||||
|
||||
async createLobby(
|
||||
hostId: string,
|
||||
hostName: string,
|
||||
settings: Record<string, unknown> = {},
|
||||
): Promise<LobbyRecord> {
|
||||
const now = Date.now();
|
||||
const lobby: LobbyRecord = {
|
||||
id: randomUUID(),
|
||||
code: this.generateCode(),
|
||||
hostId,
|
||||
status: "waiting",
|
||||
maxPlayers: MAX_PLAYERS,
|
||||
settings,
|
||||
createdAt: new Date(now).toISOString(),
|
||||
expiresAt: new Date(now + 4 * 60 * 60 * 1000).toISOString(),
|
||||
// The host is peer 0 by construction, which is what makes "peer 0 is
|
||||
// authoritative" a safe assumption everywhere else in the stack.
|
||||
players: [
|
||||
{
|
||||
userId: hostId,
|
||||
peerId: 0,
|
||||
displayName: hostName,
|
||||
isReady: true,
|
||||
joinedAt: new Date(now).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
this.lobbies.set(lobby.code, lobby);
|
||||
return lobby;
|
||||
}
|
||||
|
||||
async findLobbyByCode(code: string): Promise<LobbyRecord | null> {
|
||||
const lobby = this.lobbies.get(code.toUpperCase());
|
||||
if (!lobby) return null;
|
||||
if (Date.parse(lobby.expiresAt) < Date.now()) {
|
||||
lobby.status = "closed";
|
||||
}
|
||||
return lobby;
|
||||
}
|
||||
|
||||
async joinLobby(
|
||||
code: string,
|
||||
userId: string,
|
||||
displayName: string,
|
||||
): Promise<LobbyRecord | { error: string }> {
|
||||
const lobby = await this.findLobbyByCode(code);
|
||||
if (!lobby) return { error: "lobby not found" };
|
||||
if (lobby.status === "closed") return { error: "lobby is closed" };
|
||||
|
||||
const existing = lobby.players.find((player) => player.userId === userId);
|
||||
if (existing) return lobby; // rejoin is idempotent
|
||||
|
||||
if (lobby.players.length >= lobby.maxPlayers) return { error: "lobby is full" };
|
||||
|
||||
// Reuse the lowest free peer id so a 4-player lobby always spans 0..3 even
|
||||
// after churn — the client indexes spawn points by peer id.
|
||||
const taken = new Set(lobby.players.map((player) => player.peerId));
|
||||
let peerId = 0;
|
||||
while (taken.has(peerId)) peerId++;
|
||||
|
||||
lobby.players.push({
|
||||
userId,
|
||||
peerId,
|
||||
displayName,
|
||||
isReady: false,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
return lobby;
|
||||
}
|
||||
|
||||
async leaveLobby(code: string, userId: string): Promise<LobbyRecord | null> {
|
||||
const lobby = await this.findLobbyByCode(code);
|
||||
if (!lobby) return null;
|
||||
|
||||
lobby.players = lobby.players.filter((player) => player.userId !== userId);
|
||||
|
||||
// Losing the host ends the session: the authoritative simulation lived in
|
||||
// that browser, so there is nothing for the remaining peers to continue.
|
||||
if (lobby.players.length === 0 || userId === lobby.hostId) {
|
||||
lobby.status = "closed";
|
||||
}
|
||||
return lobby;
|
||||
}
|
||||
|
||||
async setReady(code: string, userId: string, isReady: boolean): Promise<LobbyRecord | null> {
|
||||
const lobby = await this.findLobbyByCode(code);
|
||||
if (!lobby) return null;
|
||||
const player = lobby.players.find((entry) => entry.userId === userId);
|
||||
if (player) player.isReady = isReady;
|
||||
return lobby;
|
||||
}
|
||||
|
||||
async setLobbyStatus(code: string, status: LobbyStatus): Promise<LobbyRecord | null> {
|
||||
const lobby = await this.findLobbyByCode(code);
|
||||
if (!lobby) return null;
|
||||
lobby.status = status;
|
||||
return lobby;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Server } from "node:http";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createApp } from "./app.js";
|
||||
import { SignalingServer } from "./ws/signaling.js";
|
||||
|
||||
/**
|
||||
* Hono API + WebSocket signalling — GDD §4, §8, §9.
|
||||
*
|
||||
* Storage is in-memory until Neon credentials exist; `db/schema.sql` is already
|
||||
* the target shape and `Store` is the seam.
|
||||
*/
|
||||
const { app, store } = createApp();
|
||||
const port = Number(process.env["PORT"] ?? 8787);
|
||||
|
||||
const server = serve({ fetch: app.fetch, port }, (info) => {
|
||||
console.log(`psx server listening on http://localhost:${info.port}`);
|
||||
console.log(`signalling on ws://localhost:${info.port}/ws`);
|
||||
});
|
||||
|
||||
// `serve` returns the underlying Node http server, which `ws` attaches to.
|
||||
const signaling = new SignalingServer(server as unknown as Server, store);
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
signaling.close();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
|
||||
export { app, store };
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Hono } from "hono";
|
||||
import { hashPassword, issueToken, userFromAuthHeader, verifyPassword } from "../auth.js";
|
||||
import type { Store } from "../db/store.js";
|
||||
|
||||
/** Accounts and sessions — GDD §9. */
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{3,24}$/;
|
||||
|
||||
export function authRoutes(store: Store) {
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/register", async (context) => {
|
||||
const body = (await context.req.json().catch(() => null)) as {
|
||||
username?: unknown;
|
||||
email?: unknown;
|
||||
password?: unknown;
|
||||
displayName?: unknown;
|
||||
} | null;
|
||||
|
||||
const { username, email, password, displayName } = body ?? {};
|
||||
|
||||
if (typeof username !== "string" || !USERNAME_PATTERN.test(username)) {
|
||||
return context.json({ error: "username must be 3-24 chars, alphanumeric/_/-" }, 400);
|
||||
}
|
||||
if (typeof email !== "string" || !email.includes("@")) {
|
||||
return context.json({ error: "a valid email is required" }, 400);
|
||||
}
|
||||
if (typeof password !== "string" || password.length < MIN_PASSWORD_LENGTH) {
|
||||
return context.json({ error: `password must be at least ${MIN_PASSWORD_LENGTH} chars` }, 400);
|
||||
}
|
||||
|
||||
if (await store.findUserByUsername(username)) {
|
||||
return context.json({ error: "username already taken" }, 409);
|
||||
}
|
||||
|
||||
const user = await store.createUser({
|
||||
username,
|
||||
email,
|
||||
displayName: typeof displayName === "string" ? displayName : username,
|
||||
passwordHash: await hashPassword(password),
|
||||
});
|
||||
|
||||
return context.json(
|
||||
{ token: issueToken(user.id), user: publicUser(user) },
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/login", async (context) => {
|
||||
const body = (await context.req.json().catch(() => null)) as {
|
||||
username?: unknown;
|
||||
password?: unknown;
|
||||
} | null;
|
||||
|
||||
const { username, password } = body ?? {};
|
||||
if (typeof username !== "string" || typeof password !== "string") {
|
||||
return context.json({ error: "username and password are required" }, 400);
|
||||
}
|
||||
|
||||
const user = await store.findUserByUsername(username);
|
||||
// Same response whether the user is missing or the password is wrong, so
|
||||
// this endpoint cannot be used to enumerate accounts.
|
||||
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
||||
return context.json({ error: "invalid credentials" }, 401);
|
||||
}
|
||||
|
||||
await store.touchLogin(user.id);
|
||||
return context.json({ token: issueToken(user.id), user: publicUser(user) });
|
||||
});
|
||||
|
||||
app.get("/me", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
return context.json({ user: publicUser(user) });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Never let `passwordHash` reach a response body. */
|
||||
function publicUser(user: { id: string; username: string; displayName: string | null }) {
|
||||
return { id: user.id, username: user.username, displayName: user.displayName };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Hono } from "hono";
|
||||
import { userFromAuthHeader } from "../auth.js";
|
||||
import type { LobbyRecord, Store } from "../db/store.js";
|
||||
|
||||
/** Lobby lifecycle — GDD §9. Signalling itself lives in `src/ws/`. */
|
||||
|
||||
export function lobbyRoutes(store: Store) {
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const body = (await context.req.json().catch(() => null)) as {
|
||||
settings?: Record<string, unknown>;
|
||||
} | null;
|
||||
|
||||
const lobby = await store.createLobby(
|
||||
user.id,
|
||||
user.displayName ?? user.username,
|
||||
body?.settings ?? {},
|
||||
);
|
||||
return context.json(publicLobby(lobby), 201);
|
||||
});
|
||||
|
||||
app.get("/:code", async (context) => {
|
||||
const lobby = await store.findLobbyByCode(context.req.param("code"));
|
||||
if (!lobby) return context.json({ error: "lobby not found" }, 404);
|
||||
return context.json(publicLobby(lobby));
|
||||
});
|
||||
|
||||
app.post("/:code/join", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const result = await store.joinLobby(
|
||||
context.req.param("code"),
|
||||
user.id,
|
||||
user.displayName ?? user.username,
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
const status = result.error === "lobby not found" ? 404 : 409;
|
||||
return context.json({ error: result.error }, status);
|
||||
}
|
||||
return context.json(publicLobby(result));
|
||||
});
|
||||
|
||||
app.post("/:code/ready", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const body = (await context.req.json().catch(() => null)) as { isReady?: unknown } | null;
|
||||
const lobby = await store.setReady(
|
||||
context.req.param("code"),
|
||||
user.id,
|
||||
body?.isReady === true,
|
||||
);
|
||||
if (!lobby) return context.json({ error: "lobby not found" }, 404);
|
||||
return context.json(publicLobby(lobby));
|
||||
});
|
||||
|
||||
app.post("/:code/leave", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const lobby = await store.leaveLobby(context.req.param("code"), user.id);
|
||||
if (!lobby) return context.json({ error: "lobby not found" }, 404);
|
||||
return context.json(publicLobby(lobby));
|
||||
});
|
||||
|
||||
app.post("/:code/start", async (context) => {
|
||||
const user = await userFromAuthHeader(store, context.req.header("authorization"));
|
||||
if (!user) return context.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const lobby = await store.findLobbyByCode(context.req.param("code"));
|
||||
if (!lobby) return context.json({ error: "lobby not found" }, 404);
|
||||
if (lobby.hostId !== user.id) return context.json({ error: "only the host can start" }, 403);
|
||||
|
||||
const notReady = lobby.players.filter((player) => !player.isReady);
|
||||
if (notReady.length > 0) {
|
||||
return context.json(
|
||||
{ error: "not everyone is ready", waitingOn: notReady.map((p) => p.displayName) },
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const started = await store.setLobbyStatus(lobby.code, "in_game");
|
||||
return context.json(publicLobby(started!));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Strips internal user ids; peers address each other by peer id only. */
|
||||
function publicLobby(lobby: LobbyRecord) {
|
||||
return {
|
||||
code: lobby.code,
|
||||
status: lobby.status,
|
||||
maxPlayers: lobby.maxPlayers,
|
||||
settings: lobby.settings,
|
||||
createdAt: lobby.createdAt,
|
||||
expiresAt: lobby.expiresAt,
|
||||
players: lobby.players.map((player) => ({
|
||||
peerId: player.peerId,
|
||||
displayName: player.displayName,
|
||||
isReady: player.isReady,
|
||||
isHost: player.userId === lobby.hostId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Hono } from "hono";
|
||||
import { validateSaveData } from "@psx/shared";
|
||||
import type { Store } from "../db/store.js";
|
||||
|
||||
/**
|
||||
* Cloud save endpoints (GDD §9).
|
||||
*
|
||||
* Save payloads are validated with the shared schema before they are stored —
|
||||
* the client is not trusted, and in co-op the host writes on behalf of the
|
||||
* party, which widens the blast radius of a malformed payload.
|
||||
*/
|
||||
export function savesRoutes(store: Store) {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:campaign/:slot", async (context) => {
|
||||
const userId = context.req.header("x-user-id");
|
||||
if (!userId) return context.json({ error: "missing x-user-id" }, 401);
|
||||
|
||||
const slot = Number(context.req.param("slot"));
|
||||
if (!Number.isInteger(slot)) return context.json({ error: "slot must be an integer" }, 400);
|
||||
|
||||
const record = await store.getSave(userId, context.req.param("campaign"), slot);
|
||||
return record ? context.json(record) : context.json({ error: "not found" }, 404);
|
||||
});
|
||||
|
||||
app.put("/:campaign/:slot", async (context) => {
|
||||
const userId = context.req.header("x-user-id");
|
||||
if (!userId) return context.json({ error: "missing x-user-id" }, 401);
|
||||
|
||||
const slot = Number(context.req.param("slot"));
|
||||
if (!Number.isInteger(slot)) return context.json({ error: "slot must be an integer" }, 400);
|
||||
|
||||
const body = (await context.req.json().catch(() => null)) as { data?: unknown; title?: string } | null;
|
||||
const validation = validateSaveData(body?.data);
|
||||
if (!validation.ok) return context.json({ error: "invalid save", details: validation.errors }, 422);
|
||||
|
||||
const record = await store.putSave({
|
||||
userId,
|
||||
slot,
|
||||
campaign: context.req.param("campaign"),
|
||||
title: body?.title ?? null,
|
||||
data: validation.value,
|
||||
playtimeSeconds: validation.value.playtimeSeconds,
|
||||
});
|
||||
return context.json(record, 200);
|
||||
});
|
||||
|
||||
app.get("/", async (context) => {
|
||||
const userId = context.req.header("x-user-id");
|
||||
if (!userId) return context.json({ error: "missing x-user-id" }, 401);
|
||||
return context.json(await store.listSaves(userId));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||