commit 8a96ede9f281aed8328ca454b2d5e2f488a17b84 Author: ryanfitzpatrickio Date: Fri Jul 31 06:32:43 2026 -0500 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..05be6cd --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7eae471 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..efdec05 --- /dev/null +++ b/CONTRIBUTING.md @@ -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`. diff --git a/GDD.md b/GDD.md new file mode 100644 index 0000000..7c59bf0 --- /dev/null +++ b/GDD.md @@ -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; +} + +interface CharacterHarness { + fromPhoto(image: Buffer | string, options?: Img2MeshRequest): Promise<{ + mesh: RigResult; + defaultPose?: AnimationClipResult; + }>; + generateAnimation(from: "pose" | "video" | "procedural", input: any): Promise; +} +``` + +--- + +## 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.* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a8c31e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..266f3b5 --- /dev/null +++ b/README.md @@ -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. diff --git a/assets/characters/civilian-f.albedo.png b/assets/characters/civilian-f.albedo.png new file mode 100644 index 0000000..b4aa041 Binary files /dev/null and b/assets/characters/civilian-f.albedo.png differ diff --git a/assets/characters/civilian-f.glb b/assets/characters/civilian-f.glb new file mode 100644 index 0000000..a70a804 Binary files /dev/null and b/assets/characters/civilian-f.glb differ diff --git a/assets/characters/civilian-f.recipe.json b/assets/characters/civilian-f.recipe.json new file mode 100644 index 0000000..87990af --- /dev/null +++ b/assets/characters/civilian-f.recipe.json @@ -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 +} diff --git a/assets/characters/civilian-f.uv-guide.png b/assets/characters/civilian-f.uv-guide.png new file mode 100644 index 0000000..e773ddb Binary files /dev/null and b/assets/characters/civilian-f.uv-guide.png differ diff --git a/assets/characters/civilian-m.glb b/assets/characters/civilian-m.glb new file mode 100644 index 0000000..506fc0b Binary files /dev/null and b/assets/characters/civilian-m.glb differ diff --git a/assets/characters/civilian-m.recipe.json b/assets/characters/civilian-m.recipe.json new file mode 100644 index 0000000..0d8f61b --- /dev/null +++ b/assets/characters/civilian-m.recipe.json @@ -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 + } +} \ No newline at end of file diff --git a/assets/characters/groundskeeper.albedo.png b/assets/characters/groundskeeper.albedo.png new file mode 100644 index 0000000..c095404 Binary files /dev/null and b/assets/characters/groundskeeper.albedo.png differ diff --git a/assets/characters/groundskeeper.glb b/assets/characters/groundskeeper.glb new file mode 100644 index 0000000..a4df113 Binary files /dev/null and b/assets/characters/groundskeeper.glb differ diff --git a/assets/characters/groundskeeper.recipe.json b/assets/characters/groundskeeper.recipe.json new file mode 100644 index 0000000..a1a642e --- /dev/null +++ b/assets/characters/groundskeeper.recipe.json @@ -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 + } +} \ No newline at end of file diff --git a/assets/characters/groundskeeper.uv-guide.png b/assets/characters/groundskeeper.uv-guide.png new file mode 100644 index 0000000..8e59788 Binary files /dev/null and b/assets/characters/groundskeeper.uv-guide.png differ diff --git a/assets/characters/officer.glb b/assets/characters/officer.glb new file mode 100644 index 0000000..f2fe514 Binary files /dev/null and b/assets/characters/officer.glb differ diff --git a/assets/characters/officer.recipe.json b/assets/characters/officer.recipe.json new file mode 100644 index 0000000..c2812de --- /dev/null +++ b/assets/characters/officer.recipe.json @@ -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 + } +} \ No newline at end of file diff --git a/assets/characters/olivia.albedo.png b/assets/characters/olivia.albedo.png new file mode 100644 index 0000000..f175ecf Binary files /dev/null and b/assets/characters/olivia.albedo.png differ diff --git a/assets/characters/olivia.glb b/assets/characters/olivia.glb new file mode 100644 index 0000000..8d8a7a2 Binary files /dev/null and b/assets/characters/olivia.glb differ diff --git a/assets/characters/olivia.recipe.json b/assets/characters/olivia.recipe.json new file mode 100644 index 0000000..48e71e6 --- /dev/null +++ b/assets/characters/olivia.recipe.json @@ -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" + } +} diff --git a/assets/characters/olivia.uv-guide.png b/assets/characters/olivia.uv-guide.png new file mode 100644 index 0000000..b45e551 Binary files /dev/null and b/assets/characters/olivia.uv-guide.png differ diff --git a/assets/characters/researcher.albedo.png b/assets/characters/researcher.albedo.png new file mode 100644 index 0000000..cd54c6d Binary files /dev/null and b/assets/characters/researcher.albedo.png differ diff --git a/assets/characters/researcher.glb b/assets/characters/researcher.glb new file mode 100644 index 0000000..a1d6690 Binary files /dev/null and b/assets/characters/researcher.glb differ diff --git a/assets/characters/researcher.recipe.json b/assets/characters/researcher.recipe.json new file mode 100644 index 0000000..5954c8b --- /dev/null +++ b/assets/characters/researcher.recipe.json @@ -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 + } +} \ No newline at end of file diff --git a/assets/characters/researcher.uv-guide.png b/assets/characters/researcher.uv-guide.png new file mode 100644 index 0000000..1745ce0 Binary files /dev/null and b/assets/characters/researcher.uv-guide.png differ diff --git a/assets/characters/survivor.albedo.png b/assets/characters/survivor.albedo.png new file mode 100644 index 0000000..f318b00 Binary files /dev/null and b/assets/characters/survivor.albedo.png differ diff --git a/assets/characters/survivor.glb b/assets/characters/survivor.glb new file mode 100644 index 0000000..bbb8e01 Binary files /dev/null and b/assets/characters/survivor.glb differ diff --git a/assets/characters/survivor.recipe.json b/assets/characters/survivor.recipe.json new file mode 100644 index 0000000..47859a6 --- /dev/null +++ b/assets/characters/survivor.recipe.json @@ -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" + } +} diff --git a/assets/characters/survivor.uv-guide.png b/assets/characters/survivor.uv-guide.png new file mode 100644 index 0000000..c5bde84 Binary files /dev/null and b/assets/characters/survivor.uv-guide.png differ diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..35cd617 --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + PSX Adventure Engine + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..466f77c --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/icons.svg b/client/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/client/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/engine/Game.ts b/client/src/engine/Game.ts new file mode 100644 index 0000000..42ec441 --- /dev/null +++ b/client/src/engine/Game.ts @@ -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 { + await this.physics.init({ x: 0, y: DEFAULT_MOVER_CONFIG.gravity, z: 0 }); + + const { clientWidth, clientHeight } = this.canvas; + this.cameraManager = new CameraManager(Math.max(1, clientWidth) / Math.max(1, clientHeight)); + this.pipeline = new PSXPipeline(this.renderer, this.scene, this.cameraManager.camera); + this.roomBuilder = new RoomBuilder(this.scene, this.physics); + this.interactions = new InteractionSystem(this.physics, this.inventory, this.flags); + + this.loadRoom(room, spawnPointId); + + this.cameraManager.onCut = () => this.controller.onCameraCut(); + this.inventory.subscribe(() => { + this.hudDirty = true; + }); + this.flags.subscribe((flag, value) => { + if (value) this.applyFlagToWorld(flag); + }); + + this.detachInput = this.input.attach(window); + this.observeResize(); + this.handleResize(); + + this.loop.start(); + this.pushHud(); + + // Loaded after the loop starts so a missing or malformed GLB costs a + // placeholder avatar rather than the whole game. + void this.loadCharacter(); + } + + private async loadCharacter(): Promise { + const def = CHARACTER_CATALOGUE.get(PLAYER_CHARACTER_ID); + if (!def) return; + + try { + const character = await CharacterModel.load(def.url, { + snapResolution: this.pipeline.snapResolution, + }); + + this.character = character; + this.scene.add(character.group); + // Keep the block figure around as the fallback, just hidden. + this.avatar.group.visible = false; + } catch (cause) { + console.warn("harness character failed to load; using placeholder avatar", cause); + } + } + + private clearAmbientCharacters(): void { + for (const model of this.ambientCharacters) { + this.scene.remove(model.group); + model.dispose(); + } + this.ambientCharacters = []; + } + + /** Spawns harness cast members that belong in this room. */ + private async loadAmbientCharacters(roomId: string): Promise { + const token = ++this.ambientLoadToken; + this.clearAmbientCharacters(); + + const placements = placementsForRoom(roomId); + if (placements.length === 0) return; + + const loaded: CharacterModel[] = []; + await Promise.all( + placements.map(async (placement) => { + const def = CHARACTER_CATALOGUE.get(placement.characterId); + if (!def) return; + try { + const model = await CharacterModel.load(def.url, { + snapResolution: this.pipeline.snapResolution, + // Living NPCs idle; crime-scene bodies use pose: "slumped". + initialState: placement.pose ?? "idle", + }); + model.setTransform( + placement.position.x, + placement.position.y, + placement.position.z, + placement.yaw, + ); + // Pose can lift or drop the mesh; pin the soles to the floor. + model.groundToFloor(); + loaded.push(model); + } catch (cause) { + console.warn(`ambient character ${placement.characterId} failed to load`, cause); + } + }), + ); + + // A faster room transition may have superseded this load. + if (token !== this.ambientLoadToken) { + for (const model of loaded) model.dispose(); + return; + } + + for (const model of loaded) this.scene.add(model.group); + this.ambientCharacters = loaded; + } + + private loadRoom(room: RoomDef, spawnPointId: string): void { + this.room = room; + this.builtRoom?.dispose(); + this.cameraManager.clear(); + this.interactions.clear(); + this.clearAmbientCharacters(); + + if (this.avatar) { + this.scene.remove(this.avatar.group); + this.avatar.dispose(); + } + // The player character is not room-specific, so it survives a + // transition — it is only re-parented, never rebuilt. + if (this.character) this.scene.remove(this.character.group); + // Drops the previous room's statics and sensors *and* the old player + // capsule. Without this a transition leaves orphaned bodies in the world + // that the mover's ray probes would still collide with. + this.physics.clear(); + + const activeFlags = new Set(this.flags.serialise()); + this.builtRoom = this.roomBuilder.build(room, activeFlags); + this.cameraManager.loadZones(this.physics, room.cameraZones); + this.interactions.load(room.interactables); + + const spawn = + room.spawnPoints.find((point) => point.id === spawnPointId) ?? room.spawnPoints[0]; + if (!spawn) throw new Error(`Room ${room.id} has no spawn points`); + + const spawnPosition = new Vector3(spawn.position.x, spawn.position.y, spawn.position.z); + + this.physics.createKinematicCapsule({ + id: PLAYER_BODY_ID, + position: { + x: spawnPosition.x, + y: spawnPosition.y + (DEFAULT_MOVER_CONFIG.cylinderHeight + DEFAULT_MOVER_CONFIG.radius * 2) / 2, + z: spawnPosition.z, + }, + radius: DEFAULT_MOVER_CONFIG.radius, + height: DEFAULT_MOVER_CONFIG.cylinderHeight, + }); + + this.mover = new CharacterMover(this.physics, PLAYER_BODY_ID, spawnPosition); + this.controller = new PlayerController(this.mover, this.input); + this.controller.yaw = spawn.yaw; + + this.avatar = new PlayerAvatar({ totalHeight: this.mover.totalHeight }); + this.avatar.group.visible = this.character === null; + this.scene.add(this.avatar.group); + if (this.character) this.scene.add(this.character.group); + + this.previousPosition.copy(spawnPosition); + this.renderPosition.copy(spawnPosition); + this.previousYaw = spawn.yaw; + + void this.loadAmbientCharacters(room.id); + } + + /** Resolve a door transition into a room from the campaign catalogue. */ + private transitionTo(roomId: string, spawnPointId: string): void { + const next = getRoom(roomId); + if (!next) { + console.warn(`unknown room: ${roomId}`); + this.setMessage("The door won't open."); + return; + } + this.loadRoom(next, spawnPointId); + this.setMessage(next.displayName); + } + + /** + * Enter co-op. Safe to call after `start`; the world is already built, and + * remote players simply begin appearing in it. + */ + attachNet(net: NetSession): void { + this.net = net; + this.remotePlayers = new RemotePlayers(this.scene, this.mover.totalHeight); + + if (net.isHost) { + this.hostAuthority = new HostAuthority(this.physics); + } + + net.onPeerJoinedWorld = (peerId) => { + if (!this.hostAuthority || !this.room) return; + const spawn = this.spawnFor(peerId); + this.hostAuthority.addPlayer(peerId, spawn); + // Bring the newcomer up to date on everything it missed. + net.sendToPeer(peerId, { + t: "sync", + roomId: this.room.id, + flags: this.flags.serialise(), + despawned: this.interactions.serialise(), + players: this.hostAuthority.buildSnapshot(this.hostPose()).players, + }); + }; + + net.onPeerLeftWorld = (peerId) => { + this.hostAuthority?.removePlayer(peerId); + this.remotePlayers?.remove(peerId); + }; + + this.hudDirty = true; + } + + /** Peers spawn on distinct points so nobody materialises inside anyone else. */ + private spawnFor(peerId: PeerId): Vector3 { + const points = this.room?.spawnPoints ?? []; + const point = points[peerId % Math.max(1, points.length)]; + const base = point?.position ?? { x: 0, y: 0, z: 0 }; + // Fan out around the point when peers share one. + const angle = (peerId / 4) * Math.PI * 2; + return new Vector3(base.x + Math.sin(angle) * 0.6, base.y, base.z + Math.cos(angle) * 0.6); + } + + private hostPose() { + const state = this.mover.state; + return { + position: state.position, + yaw: this.controller.yaw, + speed: state.lastMoveDistance * 60, + grounded: state.grounded, + }; + } + + /** Host: apply a validated guest message. */ + handleClientMessage(message: ClientMessage, from: PeerId): void { + if (!this.hostAuthority) return; + + switch (message.t) { + case "input": + this.hostAuthority.applyInput(from, message); + break; + case "interact": + this.handleGuestInteract(from, message.interactableId); + break; + case "combine": { + const text = this.combineItems(message.itemA, message.itemB); + this.net?.sendToPeer(from, { t: "notice", text }); + this.broadcastInventory(); + break; + } + case "chat": + this.net?.broadcastEvent({ t: "chat", from, text: message.text }); + break; + } + } + + /** + * The host re-checks reach itself. Sensors only track the local player, so + * proximity is verified against the guest's authoritative mover position — + * a guest cannot open a door from across the map by naming its id. + */ + private handleGuestInteract(peerId: PeerId, interactableId: string): void { + const player = this.hostAuthority?.getPlayer(peerId); + const def = this.room?.interactables.find((entry) => entry.id === interactableId); + if (!player || !def || !this.net) return; + + if (!withinReach(player.mover.state.position, def)) { + this.net.sendToPeer(peerId, { t: "notice", text: "You're too far away." }); + return; + } + + const result = this.interactions.activateById(interactableId); + if (!result) return; + + if (result.type !== "blocked") this.roomBuilder.removeInteractableVisual(interactableId); + + switch (result.type) { + case "picked-up": + case "flag-set": + case "message": + case "blocked": + this.net.sendToPeer(peerId, { t: "notice", text: result.text }); + break; + case "transition": + break; + } + + // World state is shared, so everyone hears about it, including the actor. + this.net.broadcastEvent({ t: "flags", set: this.flags.serialise(), unset: [] }); + this.net.broadcastEvent({ t: "despawn", interactableIds: this.interactions.serialise() }); + this.broadcastInventory(); + this.hudDirty = true; + } + + /** + * Co-op shares one party inventory rather than giving each peer their own. + * That matches how the genre's co-op modes work, and it keeps a single + * authoritative copy on the host instead of four that can disagree. + */ + private broadcastInventory(): void { + this.net?.broadcastEvent({ + t: "inventory", + peerId: 0, + items: this.inventory.list().map((entry) => ({ + itemId: entry.def.id, + quantity: entry.quantity, + })), + }); + } + + /** Guest: apply validated authoritative state from the host. */ + handleHostMessage(message: HostMessage): void { + switch (message.t) { + case "snapshot": { + const now = performance.now() / 1000; + this.remotePlayers?.ingestSnapshot(message.players, this.net?.localPeerId ?? -1, now); + this.reconcileLocalPlayer(message.players); + break; + } + case "sync": + this.flags.restore(message.flags); + this.interactions.restore(message.despawned); + for (const id of message.despawned) this.roomBuilder.removeInteractableVisual(id); + for (const flag of message.flags) this.applyFlagToWorld(flag); + break; + case "flags": + for (const flag of message.set) { + this.flags.set(flag); + } + break; + case "despawn": + this.interactions.restore(message.interactableIds); + for (const id of message.interactableIds) this.roomBuilder.removeInteractableVisual(id); + break; + case "inventory": + this.mirroredItems = message.items; + this.hudDirty = true; + break; + case "notice": + this.setMessage(message.text); + break; + case "chat": + this.setMessage(`Player ${message.from}: ${message.text}`); + break; + } + } + + /** Guest-side prediction error correction. */ + private reconcileLocalPlayer(players: PlayerSnapshot[]): void { + const localPeerId = this.net?.localPeerId ?? -1; + const mine = players.find((player) => player.peerId === localPeerId); + if (!mine) return; + + const local = this.mover.state.position; + const drift = Math.hypot(local.x - mine.p.x, local.y - mine.p.y, local.z - mine.p.z); + this.lastDrift = drift; + + if (drift > DRIFT_HARD_SNAP) { + this.mover.teleport(new Vector3(mine.p.x, mine.p.y, mine.p.z)); + } else if (drift > DRIFT_SOFT_CORRECT) { + // Ease a fraction of the way each snapshot; repeated corrections converge + // without the visible stutter of a hard set. + local.lerp(new Vector3(mine.p.x, mine.p.y, mine.p.z), 0.25); + } + } + + private fixedUpdate(dt: number, _tick: number): void { + this.playtimeSeconds += dt; + this.previousPosition.copy(this.mover.state.position); + this.previousYaw = this.controller.yaw; + + if (this.input.wasPressed("toggleControls")) { + this.controller.toggleScheme(); + this.hudDirty = true; + } + + this.controller.update(dt, this.cameraManager.getLookDirection()); + + // Step physics after the mover has written its target transform, so sensor + // overlaps are computed against where the player actually ended up. + this.physics.step(dt); + + const sensorEvents = this.physics.drainSensorEvents(); + this.cameraManager.handleSensorEvents(sensorEvents, PLAYER_BODY_ID); + this.interactions.handleSensorEvents(sensorEvents, PLAYER_BODY_ID); + + this.cameraManager.update(dt, this.mover.state.position); + + // Advance the walk cycle on the fixed tick, not per rendered frame, or the + // stride runs at double speed on a 120 Hz display. + this.avatar.update(this.mover.state.lastMoveDistance, dt); + + if (this.character) { + // Edge-trigger on the same Space press the controller used, so the clip + // starts once rather than re-arming its unlock timer every fixed tick. + if (this.input.wasPressed("quickTurn")) { + this.character.playQuickTurn(this.controller.config.quickTurnDuration); + } else { + this.character.setSpeed(this.mover.state.lastMoveDistance * TICK_RATE); + } + this.character.update(dt); + } + + for (const ambient of this.ambientCharacters) ambient.update(dt); + + if (this.input.wasPressed("interact")) this.activateInteraction(); + + if (this.hostAuthority) this.remotePlayers?.syncFromAuthority(this.hostAuthority, dt); + + const promptId = this.interactions.current?.id ?? null; + if (promptId !== this.lastPromptId) { + this.lastPromptId = promptId; + this.hudDirty = true; + } + + if (this.messageTimer > 0) { + this.messageTimer = Math.max(0, this.messageTimer - dt); + if (this.messageTimer === 0) { + this.message = null; + this.hudDirty = true; + } + } + + this.updateNetworking(dt); + + this.input.endTick(); + if (this.hudDirty) this.pushHud(); + } + + private updateNetworking(dt: number): void { + if (!this.net) return; + + if (this.hostAuthority) { + // Host: simulate every guest, then publish at SNAPSHOT_HZ. + this.hostAuthority.update(dt); + if (this.hostAuthority.shouldSnapshot(dt)) { + const { tick, ack, players } = this.hostAuthority.buildSnapshot(this.hostPose()); + this.net.broadcastState({ t: "snapshot", tick, ack, players }); + } + return; + } + + // Guest: publish intent at the same rate the host publishes state. Sending + // at 60 Hz would quadruple upstream traffic for no extra authority. + this.inputSendTimer += dt; + if (this.inputSendTimer < SNAPSHOT_INTERVAL) return; + this.inputSendTimer -= SNAPSHOT_INTERVAL; + + const strafe = this.input.axis("left", "right"); + const advance = this.input.axis("back", "forward"); + const move = + this.controller.scheme === "tank" + ? { x: Math.sin(this.controller.yaw) * advance, z: Math.cos(this.controller.yaw) * advance } + : { x: strafe, z: advance }; + + this.net.sendInput({ + t: "input", + tick: this.guestInputTick++, + move, + run: this.input.isHeld("run"), + yaw: this.controller.yaw, + }); + } + + private activateInteraction(): void { + const target = this.interactions.current; + + // A guest owns no world state: it asks the host, which re-checks reach and + // broadcasts the outcome back through `handleHostMessage`. + if (this.net && !this.hostAuthority) { + if (target) this.net.sendAction({ t: "interact", interactableId: target.id }); + return; + } + + const result = this.interactions.activate(); + if (!result) return; + + switch (result.type) { + case "message": + case "blocked": + this.setMessage(result.text); + break; + case "picked-up": + if (target) this.roomBuilder.removeInteractableVisual(target.id); + this.setMessage(result.text); + break; + case "flag-set": + if (target) this.roomBuilder.removeInteractableVisual(target.id); + this.setMessage(result.text); + break; + case "transition": + this.transitionTo(result.roomId, result.spawnPointId); + break; + } + + // A host acting locally still owes the party an update. + if (this.hostAuthority && this.net) { + this.net.broadcastEvent({ t: "flags", set: this.flags.serialise(), unset: [] }); + this.net.broadcastEvent({ t: "despawn", interactableIds: this.interactions.serialise() }); + this.broadcastInventory(); + } + } + + /** Flag-gated geometry despawns here so saves and live play take one path. */ + private applyFlagToWorld(flag: string): void { + if (!this.room) return; + for (const def of this.room.geometry) { + if (def.removeOnFlag === flag) this.roomBuilder.removeGeometry(def.id); + } + } + + private setMessage(text: string): void { + this.message = text; + this.messageTimer = MESSAGE_DURATION; + this.hudDirty = true; + } + + private render(alpha: number, frameDt: number): void { + // Interpolate between the last two ticks so motion is smooth above 60 Hz. + this.renderPosition.lerpVectors(this.previousPosition, this.mover.state.position, alpha); + const yaw = this.previousYaw + (this.controller.yaw - this.previousYaw) * alpha; + + this.avatar.setTransform(this.renderPosition.x, this.renderPosition.y, this.renderPosition.z, yaw); + this.character?.setTransform( + this.renderPosition.x, + this.renderPosition.y, + this.renderPosition.z, + yaw, + ); + + // Guests interpolate remote peers; the host already placed them from its + // own simulation during fixedUpdate. + if (this.net && !this.hostAuthority) { + this.remotePlayers?.update(performance.now() / 1000, frameDt); + } + + this.pipeline.render(); + + this.debugTimer += frameDt; + if (this.debugTimer >= DEBUG_INTERVAL) { + this.debugTimer = 0; + this.onDebug?.({ + fps: Math.round(this.loop.fps), + activeZone: this.cameraManager.activeZone, + grounded: this.mover.state.grounded, + position: { + x: +this.mover.state.position.x.toFixed(2), + y: +this.mover.state.position.y.toFixed(2), + z: +this.mover.state.position.z.toFixed(2), + }, + threadedPhysics: this.physics.threaded, + awakeBodies: this.physics.awakeBodyCount, + net: this.net + ? { + role: this.hostAuthority ? "host" : "guest", + peers: this.net.connectedPeerCount, + drift: +this.lastDrift.toFixed(2), + } + : null, + }); + } + } + + private pushHud(): void { + this.hudDirty = false; + const current = this.interactions.current; + + this.onHud?.({ + prompt: current ? { label: current.label, verb: promptVerb(current.action.kind) } : null, + message: this.message, + // A guest shows the host's party inventory, since the host holds the + // only authoritative copy. + items: this.mirroredItems + ? this.mirroredItems.flatMap(({ itemId, quantity }) => { + const def = ITEM_CATALOGUE.get(itemId); + return def + ? [ + { + id: def.id, + name: def.name, + description: def.description, + quantity, + isKeyItem: def.isKeyItem ?? false, + color: def.iconColor ?? 0x888888, + }, + ] + : []; + }) + : this.inventory.list().map(({ def, quantity }) => ({ + id: def.id, + name: def.name, + description: def.description, + quantity, + isKeyItem: def.isKeyItem ?? false, + color: def.iconColor ?? 0x888888, + })), + capacity: this.inventory.capacity, + usedSlots: this.inventory.usedSlots, + controlScheme: this.controller.scheme, + }); + } + + /** Called by the inventory UI. Returns a human-readable outcome. */ + combineItems(itemA: string, itemB: string): string { + const result = this.inventory.combine(itemA, itemB); + if (!result) return "Those two don't go together."; + const def = ITEM_CATALOGUE.get(result.itemId); + this.setMessage(`Created ${def?.name ?? result.itemId}.`); + return `Created ${def?.name ?? result.itemId}.`; + } + + setControlScheme(scheme: ControlScheme): void { + if (this.controller.scheme !== scheme) this.controller.toggleScheme(); + this.hudDirty = true; + } + + private observeResize(): void { + this.resizeObserver = new ResizeObserver(() => this.handleResize()); + if (this.canvas.parentElement) this.resizeObserver.observe(this.canvas.parentElement); + } + + private handleResize(): void { + const width = this.canvas.clientWidth || window.innerWidth; + const height = this.canvas.clientHeight || window.innerHeight; + + this.cameraManager.setAspect(width / Math.max(1, height)); + this.pipeline.setSize(width, height); + + // Snap grid follows the virtual framebuffer so vertex wobble stays constant + // in virtual pixels rather than scaling with the window. + const snap = this.pipeline.snapResolution; + this.roomBuilder.setSnapResolution(snap.x, snap.y); + } + + serialise(): SaveData { + const state = this.mover.state; + return { + version: SAVE_FORMAT_VERSION, + campaign: "demo", + player: { + roomId: this.room?.id ?? "unknown", + position: { x: state.position.x, y: state.position.y, z: state.position.z }, + yaw: this.controller.yaw, + health: 100, + inventory: this.inventory.serialise(), + }, + flags: this.flags.serialise(), + consumedInteractables: this.interactions.serialise(), + playtimeSeconds: Math.round(this.playtimeSeconds), + savedAt: new Date().toISOString(), + }; + } + + /** Local-only for Milestone 1; Milestone 2 swaps the transport for Neon. */ + saveToLocalStorage(slot = 0): void { + localStorage.setItem(`psx-save-${slot}`, JSON.stringify(this.serialise())); + this.setMessage("Progress recorded."); + } + + static blankSave(roomId: string): SaveData { + return emptySave("demo", roomId); + } + + dispose(): void { + this.loop.stop(); + this.detachInput?.(); + this.resizeObserver?.disconnect(); + this.net?.close(); + this.hostAuthority?.clear(); + this.remotePlayers?.clear(); + this.builtRoom?.dispose(); + this.character?.dispose(); + this.clearAmbientCharacters(); + this.avatar.dispose(); + this.pipeline.dispose(); + this.physics.dispose(); + this.renderer.dispose(); + } +} + +/** Host-side reach check, mirroring the sensor volume the local player uses. */ +function withinReach(position: Vec3, def: InteractableDef): boolean { + return ( + Math.abs(position.x - def.position.x) <= def.halfExtents.x && + Math.abs(position.y - def.position.y) <= def.halfExtents.y + 1 && + Math.abs(position.z - def.position.z) <= def.halfExtents.z + ); +} + +function promptVerb(kind: string): string { + switch (kind) { + case "pickup": + return "Take"; + case "door": + return "Open"; + case "useItem": + return "Use"; + default: + return "Examine"; + } +} diff --git a/client/src/engine/GameLoop.ts b/client/src/engine/GameLoop.ts new file mode 100644 index 0000000..c2fd3b0 --- /dev/null +++ b/client/src/engine/GameLoop.ts @@ -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; + } +} diff --git a/client/src/engine/PhysicsWorld.ts b/client/src/engine/PhysicsWorld.ts new file mode 100644 index 0000000..24fee9a --- /dev/null +++ b/client/src/engine/PhysicsWorld.ts @@ -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(); + private readonly entitiesById = new Map(); + + /** 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 { + 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(); + } +} diff --git a/client/src/index.tsx b/client/src/index.tsx new file mode 100644 index 0000000..4895afa --- /dev/null +++ b/client/src/index.tsx @@ -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(() => , root); diff --git a/client/src/net/ApiClient.ts b/client/src/net/ApiClient.ts new file mode 100644 index 0000000..a502ee7 --- /dev/null +++ b/client/src/net/ApiClient.ts @@ -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(path: string, init: RequestInit = {}): Promise { + const headers: Record = { + "content-type": "application/json", + ...((init.headers as Record) ?? {}), + }; + 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; + + 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 { + 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 { + 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 { + return this.request("/lobbies", { method: "POST", body: "{}" }); + } + + joinLobby(code: string): Promise { + return this.request(`/lobbies/${code.toUpperCase()}/join`, { method: "POST" }); + } + + getLobby(code: string): Promise { + return this.request(`/lobbies/${code.toUpperCase()}`); + } + + setReady(code: string, isReady: boolean): Promise { + return this.request(`/lobbies/${code.toUpperCase()}/ready`, { + method: "POST", + body: JSON.stringify({ isReady }), + }); + } + + startLobby(code: string): Promise { + return this.request(`/lobbies/${code.toUpperCase()}/start`, { method: "POST" }); + } + + leaveLobby(code: string): Promise { + return this.request(`/lobbies/${code.toUpperCase()}/leave`, { method: "POST" }); + } + + signOut(): void { + this.token = null; + this.user = null; + } +} diff --git a/client/src/net/HostAuthority.test.ts b/client/src/net/HostAuthority.test.ts new file mode 100644 index 0000000..4f330f7 --- /dev/null +++ b/client/src/net/HostAuthority.test.ts @@ -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(); + }); +}); diff --git a/client/src/net/HostAuthority.ts b/client/src/net/HostAuthority.ts new file mode 100644 index 0000000..37ae1fe --- /dev/null +++ b/client/src/net/HostAuthority.ts @@ -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(); + 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): 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; + 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 = {}; + + 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; diff --git a/client/src/net/NetSession.ts b/client/src/net/NetSession.ts new file mode 100644 index 0000000..cc0bb33 --- /dev/null +++ b/client/src/net/NetSession.ts @@ -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(); + private readonly peers = new Map(); + + 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 { + 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(); + } +} diff --git a/client/src/net/PeerLink.ts b/client/src/net/PeerLink.ts new file mode 100644 index 0000000..c02b3a1 --- /dev/null +++ b/client/src/net/PeerLink.ts @@ -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 { + 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 { + 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"); + } +} diff --git a/client/src/net/RemotePlayers.ts b/client/src/net/RemotePlayers.ts new file mode 100644 index 0000000..60bd86e --- /dev/null +++ b/client/src/net/RemotePlayers.ts @@ -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(); + + 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(); + + 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(); + + 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()}`; + } +} diff --git a/client/src/net/SignalingClient.ts b/client/src/net/SignalingClient.ts new file mode 100644 index 0000000..e662a94 --- /dev/null +++ b/client/src/net/SignalingClient.ts @@ -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 { + 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; + } +} diff --git a/client/src/net/SnapshotBuffer.test.ts b/client/src/net/SnapshotBuffer.test.ts new file mode 100644 index 0000000..d3e49f4 --- /dev/null +++ b/client/src/net/SnapshotBuffer.test.ts @@ -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); + }); +}); diff --git a/client/src/net/SnapshotBuffer.ts b/client/src/net/SnapshotBuffer.ts new file mode 100644 index 0000000..1ed3bb7 --- /dev/null +++ b/client/src/net/SnapshotBuffer.ts @@ -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, + }; +} diff --git a/client/src/post/PSXMaterial.ts b/client/src/post/PSXMaterial.ts new file mode 100644 index 0000000..f0443dc --- /dev/null +++ b/client/src/post/PSXMaterial.ts @@ -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( + 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 ", `#include \n${vertexDeclarations}`) + .replace("#include ", `#include \n${vertexBody}`); + + if (affineMapping) { + shader.fragmentShader = shader.fragmentShader + .replace( + "#include ", + "#include \nvarying vec2 vAffineUv;\nvarying float vAffineW;", + ) + .replace( + "#include ", + ` + #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); +} diff --git a/client/src/post/PSXPipeline.ts b/client/src/post/PSXPipeline.ts new file mode 100644 index 0000000..dd9abc3 --- /dev/null +++ b/client/src/post/PSXPipeline.ts @@ -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(); + } +} diff --git a/client/src/post/PSXShader.ts b/client/src/post/PSXShader.ts new file mode 100644 index 0000000..0dcf10e --- /dev/null +++ b/client/src/post/PSXShader.ts @@ -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 ); + } + `, +}; diff --git a/client/src/styles/index.css b/client/src/styles/index.css new file mode 100644 index 0000000..46747d7 --- /dev/null +++ b/client/src/styles/index.css @@ -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; +} diff --git a/client/src/systems/CameraManager.ts b/client/src/systems/CameraManager.ts new file mode 100644 index 0000000..49671d3 --- /dev/null +++ b/client/src/systems/CameraManager.ts @@ -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(); + private readonly occupied = new Map(); + 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(); + } +} diff --git a/client/src/systems/CharacterMover.test.ts b/client/src/systems/CharacterMover.test.ts new file mode 100644 index 0000000..2eab076 --- /dev/null +++ b/client/src/systems/CharacterMover.test.ts @@ -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 { + 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(); + }); +}); diff --git a/client/src/systems/CharacterMover.ts b/client/src/systems/CharacterMover.ts new file mode 100644 index 0000000..c1b5046 --- /dev/null +++ b/client/src/systems/CharacterMover.ts @@ -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 }, + }); + } + } +} diff --git a/client/src/systems/FlagStore.ts b/client/src/systems/FlagStore.ts new file mode 100644 index 0000000..922cc9a --- /dev/null +++ b/client/src/systems/FlagStore.ts @@ -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(); + 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); + } +} diff --git a/client/src/systems/InputManager.ts b/client/src/systems/InputManager.ts new file mode 100644 index 0000000..b49c7fc --- /dev/null +++ b/client/src/systems/InputManager.ts @@ -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 = { + 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 = new Set([ + "forward", + "back", + "left", + "right", + "quickTurn", + "inventory", +]); + +export class InputManager { + private readonly held = new Set(); + private readonly pressedThisTick = new Set(); + private readonly bindings: Record; + private attached = false; + + constructor(bindings: Record = 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(); + } +} diff --git a/client/src/systems/InteractionSystem.ts b/client/src/systems/InteractionSystem.ts new file mode 100644 index 0000000..b60339f --- /dev/null +++ b/client/src/systems/InteractionSystem.ts @@ -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(); + private readonly inReach = new Set(); + private readonly consumed = new Set(); + + 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); + } +} diff --git a/client/src/systems/InventorySystem.ts b/client/src/systems/InventorySystem.ts new file mode 100644 index 0000000..2b48a2d --- /dev/null +++ b/client/src/systems/InventorySystem.ts @@ -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(); + private readonly listeners = new Set<(event: InventoryEvent) => void>(); + + constructor( + private readonly catalogue: ReadonlyMap, + private readonly combinations: readonly CombinationDef[] = [], + capacity = 8, + ) { + this.state = { slots: Array(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); + } + } +} diff --git a/client/src/systems/PlayerController.ts b/client/src/systems/PlayerController.ts new file mode 100644 index 0000000..d7d14ed --- /dev/null +++ b/client/src/systems/PlayerController.ts @@ -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; + } +} diff --git a/client/src/types/box3d-wasm.d.ts b/client/src/types/box3d-wasm.d.ts new file mode 100644 index 0000000..1e2c05d --- /dev/null +++ b/client/src/types/box3d-wasm.d.ts @@ -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; + 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; + 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; + 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; + export default Box3D; +} + +declare module "box3d-wasm/standard" { + export { default } from "box3d-wasm"; +} + +declare module "box3d-wasm/deluxe" { + export { default } from "box3d-wasm"; +} diff --git a/client/src/ui/App.tsx b/client/src/ui/App.tsx new file mode 100644 index 0000000..6d24583 --- /dev/null +++ b/client/src/ui/App.tsx @@ -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(null); + const [hud, setHud] = createSignal(null); + const [debug, setDebug] = createSignal(null); + const [inventoryOpen, setInventoryOpen] = createSignal(false); + const [debugOpen, setDebugOpen] = createSignal(false); + const [lobbyOpen, setLobbyOpen] = createSignal(false); + const [netNotice, setNetNotice] = createSignal(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 => { + 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 ( +
+ + + +
+

Loading physics core…

+
+
+ + + {(message) => ( +
+
+

Failed to start

+

{message()}

+
+
+ )} +
+ + +
+ + +
+ + {hud()?.controlScheme === "tank" ? "TANK" : "CAMERA-RELATIVE"} + + + C switch · TAB inventory · M co-op · ` debug · SHIFT+F5 save + +
+ + + {(notice) =>
{notice()}
} +
+ + + setLobbyOpen(false)} + /> + + + + game?.combineItems(a, b) ?? "Nothing happens."} + onClose={() => setInventoryOpen(false)} + /> + + + + + +
+
+
+ ); +} diff --git a/client/src/ui/DebugPanel.tsx b/client/src/ui/DebugPanel.tsx new file mode 100644 index 0000000..c90995d --- /dev/null +++ b/client/src/ui/DebugPanel.tsx @@ -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 ( + + {(debug) => ( +
+
fps
+
{debug().fps}
+ +
camera zone
+
{debug().activeZone ?? "—"}
+ +
grounded
+
{debug().grounded ? "yes" : "no"}
+ +
position
+
+ {debug().position.x} {debug().position.y} {debug().position.z} +
+ +
awake bodies
+
{debug().awakeBodies}
+ +
physics
+
{debug().threadedPhysics ? "box3d threaded" : "box3d single-thread"}
+ +
session
+
{debug().net ? `${debug().net!.role} · ${debug().net!.peers} peers` : "solo"}
+ + +
drift
+
{debug().net!.drift}m
+
+
+ )} +
+ ); +} diff --git a/client/src/ui/InventoryPanel.tsx b/client/src/ui/InventoryPanel.tsx new file mode 100644 index 0000000..b14c3b6 --- /dev/null +++ b/client/src/ui/InventoryPanel.tsx @@ -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(null); + const [combining, setCombining] = createSignal(null); + const [result, setResult] = createSignal(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 ( +
+
+

Inventory

+ + {props.usedSlots} / {props.capacity} + + +
+ +
+
    + Empty}> + {(item) => ( +
  • + +
  • + )} +
    +
+ + +
+
+ ); +} diff --git a/client/src/ui/LobbyScreen.tsx b/client/src/ui/LobbyScreen.tsx new file mode 100644 index 0000000..199c8cb --- /dev/null +++ b/client/src/ui/LobbyScreen.tsx @@ -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; + onClose(): void; +} + +type Stage = "auth" | "menu" | "lobby"; + +/** Co-op lobby — GDD §8, §9. */ +export function LobbyScreen(props: LobbyScreenProps) { + const [stage, setStage] = createSignal(props.api.isAuthenticated ? "menu" : "auth"); + const [busy, setBusy] = createSignal(false); + const [error, setError] = createSignal(null); + + const [username, setUsername] = createSignal(""); + const [password, setPassword] = createSignal(""); + const [isRegistering, setIsRegistering] = createSignal(false); + const [joinCode, setJoinCode] = createSignal(""); + + const [lobby, setLobby] = createSignal(null); + const [peers, setPeers] = createSignal([]); + const [session, setSession] = createSignal(null); + + /** Wraps an async action with the shared busy/error handling. */ + const run = async (action: () => Promise) => { + 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 ( +
+
+

Co-op

+ +
+ + {(message) =>

{message()}

}
+ + +
{ + event.preventDefault(); + void authenticate(); + }} + > + + +
+ + +
+
+
+ + +
+ + +
+ setJoinCode(event.currentTarget.value.toUpperCase())} + /> + +
+
+
+ + +
+

+ Room code {lobby()?.code} +

+ +
    + Waiting for players…}> + {(peer) => ( +
  • + + {peer.displayName} + + HOST + + + {peer.isReady ? "READY" : "…"} + +
  • + )} +
    +
+ +
+ + + + +
+ +

+ Everyone spawns into the room already running behind this menu. Close it to play. +

+
+
+
+ ); +} diff --git a/client/src/ui/Prompt.tsx b/client/src/ui/Prompt.tsx new file mode 100644 index 0000000..de5bd13 --- /dev/null +++ b/client/src/ui/Prompt.tsx @@ -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 ( + <> + + {(prompt) => ( +
+ E + {prompt().verb} + {prompt().label} +
+ )} +
+ + + {(message) =>
{message()}
} +
+ + ); +} diff --git a/client/src/ui/ui.css b/client/src/ui/ui.css new file mode 100644 index 0000000..ab14691 --- /dev/null +++ b/client/src/ui/ui.css @@ -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; +} diff --git a/client/src/world/CharacterModel.ts b/client/src/world/CharacterModel.ts new file mode 100644 index 0000000..102ed0e --- /dev/null +++ b/client/src/world/CharacterModel.ts @@ -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 = { + 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(); + 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 { + 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(); + }); + } +} diff --git a/client/src/world/PlayerAvatar.ts b/client/src/world/PlayerAvatar.ts new file mode 100644 index 0000000..b88f148 --- /dev/null +++ b/client/src/world/PlayerAvatar.ts @@ -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(); + }); + } +} diff --git a/client/src/world/RoomBuilder.test.ts b/client/src/world/RoomBuilder.test.ts new file mode 100644 index 0000000..6a64e05 --- /dev/null +++ b/client/src/world/RoomBuilder.test.ts @@ -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 = (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); + }); +}); diff --git a/client/src/world/RoomBuilder.ts b/client/src/world/RoomBuilder.ts new file mode 100644 index 0000000..a06f85f --- /dev/null +++ b/client/src/world/RoomBuilder.ts @@ -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(); + private readonly interactableMeshes = new Map(); + 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): 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(); + } +} diff --git a/client/src/world/characters.ts b/client/src/world/characters.ts new file mode 100644 index 0000000..7b2c89d --- /dev/null +++ b/client/src/world/characters.ts @@ -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 = 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 = new Map( + AMBIENT_PLACEMENTS.map((p) => [p.characterId, p.roomId]), +); diff --git a/client/src/world/demoRoom.ts b/client/src/world/demoRoom.ts new file mode 100644 index 0000000..95ef4de --- /dev/null +++ b/client/src/world/demoRoom.ts @@ -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"; diff --git a/client/src/world/items.ts b/client/src/world/items.ts new file mode 100644 index 0000000..edd2151 --- /dev/null +++ b/client/src/world/items.ts @@ -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 = new Map( + ITEM_DEFS.map((def) => [def.id, def]), +); diff --git a/client/src/world/rooms/cellar.ts b/client/src/world/rooms/cellar.ts new file mode 100644 index 0000000..a7e0100 --- /dev/null +++ b/client/src/world/rooms/cellar.ts @@ -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 }, + ], +}; diff --git a/client/src/world/rooms/corridor.ts b/client/src/world/rooms/corridor.ts new file mode 100644 index 0000000..9bce90a --- /dev/null +++ b/client/src/world/rooms/corridor.ts @@ -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 }, + ], +}; diff --git a/client/src/world/rooms/foyer.ts b/client/src/world/rooms/foyer.ts new file mode 100644 index 0000000..3563f23 --- /dev/null +++ b/client/src/world/rooms/foyer.ts @@ -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 }, + ], +}; diff --git a/client/src/world/rooms/gallery.ts b/client/src/world/rooms/gallery.ts new file mode 100644 index 0000000..25ca0d5 --- /dev/null +++ b/client/src/world/rooms/gallery.ts @@ -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 }, + ], +}; diff --git a/client/src/world/rooms/helpers.ts b/client/src/world/rooms/helpers.ts new file mode 100644 index 0000000..c83e298 --- /dev/null +++ b/client/src/world/rooms/helpers.ts @@ -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 }, + }; +} diff --git a/client/src/world/rooms/index.ts b/client/src/world/rooms/index.ts new file mode 100644 index 0000000..1b40697 --- /dev/null +++ b/client/src/world/rooms/index.ts @@ -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 = 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, +}; diff --git a/client/src/world/rooms/interview.ts b/client/src/world/rooms/interview.ts new file mode 100644 index 0000000..9cb88a0 --- /dev/null +++ b/client/src/world/rooms/interview.ts @@ -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 }, + ], +}; diff --git a/client/src/world/rooms/kitchen.ts b/client/src/world/rooms/kitchen.ts new file mode 100644 index 0000000..e8f84ec --- /dev/null +++ b/client/src/world/rooms/kitchen.ts @@ -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 }, + ], +}; diff --git a/client/src/world/rooms/level1.test.ts b/client/src/world/rooms/level1.test.ts new file mode 100644 index 0000000..9f28c1e --- /dev/null +++ b/client/src/world/rooms/level1.test.ts @@ -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"); + } + }); +}); diff --git a/client/src/world/rooms/lounge.ts b/client/src/world/rooms/lounge.ts new file mode 100644 index 0000000..727bdee --- /dev/null +++ b/client/src/world/rooms/lounge.ts @@ -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 }, + ], +}; diff --git a/client/src/world/rooms/office.ts b/client/src/world/rooms/office.ts new file mode 100644 index 0000000..23980f2 --- /dev/null +++ b/client/src/world/rooms/office.ts @@ -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 }, + ], +}; diff --git a/client/src/world/rooms/study.ts b/client/src/world/rooms/study.ts new file mode 100644 index 0000000..1387689 --- /dev/null +++ b/client/src/world/rooms/study.ts @@ -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 }, + ], +}; diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..2ba6277 --- /dev/null +++ b/client/tsconfig.json @@ -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"] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..453a8f8 --- /dev/null +++ b/client/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" }, +}); diff --git a/client/vitest.config.ts b/client/vitest.config.ts new file mode 100644 index 0000000..ef2ea94 --- /dev/null +++ b/client/vitest.config.ts @@ -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, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..e72f507 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..6c14a60 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1774 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + client: + dependencies: + '@psx/shared': + specifier: workspace:* + version: link:../shared + box3d-wasm: + specifier: ^0.2.0 + version: 0.2.0 + solid-js: + specifier: ^1.9.14 + version: 1.9.14 + three: + specifier: ^0.185.1 + version: 0.185.1 + devDependencies: + '@types/three': + specifier: ^0.185.1 + version: 0.185.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.0 + version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1) + vite-plugin-solid: + specifier: ^2.11.13 + version: 2.11.14(solid-js@1.9.14)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + + server: + dependencies: + '@hono/node-server': + specifier: ^1.19.7 + version: 1.19.17(hono@4.12.32) + '@psx/shared': + specifier: workspace:* + version: link:../shared + hono: + specifier: ^4.12.32 + version: 4.12.32 + ws: + specifier: ^8.21.1 + version: 8.21.1 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.13.3 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + + shared: + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + + tools: + dependencies: + '@psx/shared': + specifier: workspace:* + version: link:../shared + jpeg-js: + specifier: ^0.4.4 + version: 0.4.4 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.13.3 + '@types/pngjs': + specifier: ^6.0.5 + version: 6.0.5 + '@types/three': + specifier: ^0.185.1 + version: 0.185.1 + three: + specifier: ^0.185.1 + version: 0.185.1 + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.0 + version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} + + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} + + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.1': + resolution: {integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@rolldown/binding-android-arm64@1.2.1': + resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.1': + resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.1': + resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.1': + resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.1': + resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.1': + resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.1': + resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.1': + resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.2.1': + resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.185.1': + resolution: {integrity: sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + + baseline-browser-mapping@2.11.8: + resolution: {integrity: sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + box3d-wasm@0.2.0: + resolution: {integrity: sha512-cvju1RYCTeChr+CUc2Sh+EsFaypP+6SSlNNSuf4Y3+e4nUFu1O25xQLZTBVUqp1+Wn4Xa2fA4cxrtLzLoWOk4A==} + engines: {node: '>=22'} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.398: + resolution: {integrity: sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + hono@4.12.32: + resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} + engines: {node: '>=16.9.0'} + + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + solid-js@1.9.14: + resolution: {integrity: sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite-plugin-solid@2.11.14: + resolution: {integrity: sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0 + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@dimforge/rapier3d-compat@0.12.0': {} + + '@emnapi/core@2.0.0-alpha.3': + dependencies: + '@emnapi/wasi-threads': 2.0.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@2.0.0-alpha.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@2.0.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@hono/node-server@1.19.17(hono@4.12.32)': + dependencies: + hono: 4.12.32 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.142.0': {} + + '@rolldown/binding-android-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-x64@1.2.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.2.1': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tweenjs/tween.js@23.1.3': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 24.13.3 + + '@types/stats.js@0.17.4': {} + + '@types/three@0.185.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + + '@types/webxr@0.5.24': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.13.3 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + assertion-error@2.0.1: {} + + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.14): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7) + optionalDependencies: + solid-js: 1.9.14 + + baseline-browser-mapping@2.11.8: {} + + box3d-wasm@0.2.0: {} + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.8 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.398 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + caniuse-lite@1.0.30001806: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.398: {} + + entities@6.0.1: {} + + es-module-lexer@2.3.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fflate@0.8.3: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + hono@4.12.32: {} + + html-entities@2.3.3: {} + + is-what@4.1.16: {} + + jpeg-js@0.4.4: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + + meshoptimizer@1.1.1: {} + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + node-releases@2.0.51: {} + + obug@2.1.4: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pngjs@7.0.0: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.1: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.1 + '@rolldown/binding-darwin-arm64': 1.2.1 + '@rolldown/binding-darwin-x64': 1.2.1 + '@rolldown/binding-freebsd-x64': 1.2.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 + '@rolldown/binding-linux-arm64-gnu': 1.2.1 + '@rolldown/binding-linux-arm64-musl': 1.2.1 + '@rolldown/binding-linux-ppc64-gnu': 1.2.1 + '@rolldown/binding-linux-s390x-gnu': 1.2.1 + '@rolldown/binding-linux-x64-gnu': 1.2.1 + '@rolldown/binding-linux-x64-musl': 1.2.1 + '@rolldown/binding-openharmony-arm64': 1.2.1 + '@rolldown/binding-wasm32-wasi': 1.2.1 + '@rolldown/binding-win32-arm64-msvc': 1.2.1 + '@rolldown/binding-win32-x64-msvc': 1.2.1 + + semver@6.3.1: {} + + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + + siginfo@2.0.0: {} + + solid-js@1.9.14: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + solid-refresh@0.6.3(solid-js@1.9.14): + dependencies: + '@babel/generator': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/types': 7.29.7 + solid-js: 1.9.14 + transitivePeerDependencies: + - supports-color + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + three@0.185.1: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tslib@2.8.1: + optional: true + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite-plugin-solid@2.11.14(solid-js@1.9.14)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)): + dependencies: + '@babel/core': 7.29.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.14) + merge-anything: 5.1.7 + solid-js: 1.9.14 + solid-refresh: 0.6.3(solid-js@1.9.14) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1) + vitefu: 1.1.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + transitivePeerDependencies: + - supports-color + + vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + tsx: 4.23.1 + + vitefu@1.1.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)): + optionalDependencies: + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1) + + vitest@4.1.10(@types/node@24.13.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.1: {} + + yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..52c3de2 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - client + - server + - shared + - tools diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..3942eaa --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..6f385d8 --- /dev/null +++ b/server/src/app.ts @@ -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 }; +} diff --git a/server/src/auth.ts b/server/src/auth.ts new file mode 100644 index 0000000..502eb8e --- /dev/null +++ b/server/src/auth.ts @@ -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; + +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 { + 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 { + 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 { + if (!header?.startsWith("Bearer ")) return null; + const verified = verifyToken(header.slice(7)); + return verified ? store.findUserById(verified.userId) : null; +} diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql new file mode 100644 index 0000000..46f12d8 --- /dev/null +++ b/server/src/db/schema.sql @@ -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) +); diff --git a/server/src/db/store.ts b/server/src/db/store.ts new file mode 100644 index 0000000..7bba358 --- /dev/null +++ b/server/src/db/store.ts @@ -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; + createdAt: string; + expiresAt: string; + players: LobbyPlayerRecord[]; +} + +export interface Store { + createUser(input: { + username: string; + email: string; + displayName?: string; + passwordHash: string; + }): Promise; + findUserByUsername(username: string): Promise; + findUserById(id: string): Promise; + touchLogin(id: string): Promise; + + putSave(record: Omit): Promise; + getSave(userId: string, campaign: string, slot: number): Promise; + listSaves(userId: string): Promise; + deleteSave(userId: string, campaign: string, slot: number): Promise; + + createLobby(hostId: string, hostName: string, settings?: Record): Promise; + findLobbyByCode(code: string): Promise; + joinLobby(code: string, userId: string, displayName: string): Promise; + leaveLobby(code: string, userId: string): Promise; + setReady(code: string, userId: string, isReady: boolean): Promise; + setLobbyStatus(code: string, status: LobbyStatus): Promise; +} + +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(); + private readonly usersByUsername = new Map(); + private readonly saves = new Map(); + private readonly lobbies = new Map(); + + // --- users --------------------------------------------------------------- + + async createUser(input: { + username: string; + email: string; + displayName?: string; + passwordHash: string; + }): Promise { + 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 { + const id = this.usersByUsername.get(username.toLowerCase()); + return id ? (this.users.get(id) ?? null) : null; + } + + async findUserById(id: string): Promise { + return this.users.get(id) ?? null; + } + + async touchLogin(id: string): Promise { + const user = this.users.get(id); + if (user) user.lastLogin = new Date().toISOString(); + } + + // --- saves --------------------------------------------------------------- + + async putSave(record: Omit): Promise { + 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 { + return this.saves.get(saveKey(userId, campaign, slot)) ?? null; + } + + async listSaves(userId: string): Promise { + 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 { + 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 = {}, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const lobby = await this.findLobbyByCode(code); + if (!lobby) return null; + lobby.status = status; + return lobby; + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..4d03e36 --- /dev/null +++ b/server/src/index.ts @@ -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 }; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..25866d2 --- /dev/null +++ b/server/src/routes/auth.ts @@ -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 }; +} diff --git a/server/src/routes/lobbies.ts b/server/src/routes/lobbies.ts new file mode 100644 index 0000000..a0e4639 --- /dev/null +++ b/server/src/routes/lobbies.ts @@ -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; + } | 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, + })), + }; +} diff --git a/server/src/routes/saves.ts b/server/src/routes/saves.ts new file mode 100644 index 0000000..61e9cb5 --- /dev/null +++ b/server/src/routes/saves.ts @@ -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; +} diff --git a/server/src/ws/signaling.test.ts b/server/src/ws/signaling.test.ts new file mode 100644 index 0000000..8702a62 --- /dev/null +++ b/server/src/ws/signaling.test.ts @@ -0,0 +1,251 @@ +import { createServer, type Server } from "node:http"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; +import type { SignalMessage } from "@psx/shared"; +import { PROTOCOL_VERSION } from "@psx/shared"; +import { createApp } from "../app.js"; +import { InMemoryStore } from "../db/store.js"; +import { SignalingServer } from "./signaling.js"; + +/** + * End-to-end signalling tests against a real HTTP + WebSocket server. + * + * The impersonation test is the important one: the relay stamps `from` off the + * authenticated socket, so a peer claiming to be someone else must be ignored. + */ + +let server: Server; +let signaling: SignalingServer; +let store: InMemoryStore; +let baseUrl: string; +let wsUrl: string; + +/** Bridges Hono's fetch handler onto a plain Node server so `ws` can attach. */ +function nodeAdapter(app: ReturnType["app"]): Server { + return createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const url = `http://localhost${req.url ?? "/"}`; + const hasBody = chunks.length > 0; + const request = new Request(url, { + method: req.method, + headers: req.headers as Record, + ...(hasBody ? { body: Buffer.concat(chunks) } : {}), + }); + + // Hono's `fetch` is sync-or-async depending on the route. + void Promise.resolve(app.fetch(request)).then(async (response: Response) => { + res.writeHead(response.status, Object.fromEntries(response.headers.entries())); + res.end(Buffer.from(await response.arrayBuffer())); + }); + }); + }); +} + +beforeEach(async () => { + store = new InMemoryStore(); + const { app } = createApp(store); + server = nodeAdapter(app); + signaling = new SignalingServer(server, store); + + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + baseUrl = `http://localhost:${port}`; + wsUrl = `ws://localhost:${port}/ws`; +}); + +afterEach(async () => { + signaling.close(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +async function register(username: string): Promise { + const response = await fetch(`${baseUrl}/auth/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username, email: `${username}@example.com`, password: "hunter2hunter2" }), + }); + expect(response.status).toBe(201); + return ((await response.json()) as { token: string }).token; +} + +/** Opens a socket, sends `hello`, and resolves once `welcome` arrives. */ +async function connect(token: string, lobbyCode: string) { + const socket = new WebSocket(wsUrl); + const inbox: SignalMessage[] = []; + + await new Promise((resolve, reject) => { + socket.on("open", resolve); + socket.on("error", reject); + }); + socket.on("message", (raw) => inbox.push(JSON.parse(raw.toString()) as SignalMessage)); + socket.send(JSON.stringify({ t: "hello", token, lobbyCode, protocol: PROTOCOL_VERSION })); + + const welcome = await waitFor(inbox, (m) => m.t === "welcome" || m.t === "error"); + return { socket, inbox, welcome }; +} + +/** Polls `inbox` until a matching message appears, or times out. */ +async function waitFor( + inbox: SignalMessage[], + predicate: (message: SignalMessage) => boolean, + timeoutMs = 3000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const found = inbox.find(predicate); + if (found) return found; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`timed out; inbox was ${JSON.stringify(inbox)}`); +} + +describe("signalling", () => { + it("rejects a socket with an invalid token", async () => { + const hostToken = await register("host"); + const created = await fetch(`${baseUrl}/lobbies`, { + method: "POST", + headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" }, + body: "{}", + }); + const { code } = (await created.json()) as { code: string }; + + const { welcome, socket } = await connect("not-a-real-token", code); + expect(welcome.t).toBe("error"); + socket.close(); + }); + + it("refuses a peer that has not joined the lobby over REST", async () => { + const hostToken = await register("host"); + const strangerToken = await register("stranger"); + + const created = await fetch(`${baseUrl}/lobbies`, { + method: "POST", + headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" }, + body: "{}", + }); + const { code } = (await created.json()) as { code: string }; + + const { welcome, socket } = await connect(strangerToken, code); + expect(welcome.t).toBe("error"); + socket.close(); + }); + + it("welcomes host as peer 0 and announces a guest joining", async () => { + const hostToken = await register("host"); + const guestToken = await register("guest"); + + const created = await fetch(`${baseUrl}/lobbies`, { + method: "POST", + headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" }, + body: "{}", + }); + const { code } = (await created.json()) as { code: string }; + + await fetch(`${baseUrl}/lobbies/${code}/join`, { + method: "POST", + headers: { authorization: `Bearer ${guestToken}` }, + }); + + const host = await connect(hostToken, code); + expect(host.welcome).toMatchObject({ t: "welcome", peerId: 0, isHost: true }); + + const guest = await connect(guestToken, code); + expect(guest.welcome).toMatchObject({ t: "welcome", peerId: 1, isHost: false }); + + const announced = await waitFor(host.inbox, (m) => m.t === "peer-joined"); + expect(announced).toMatchObject({ t: "peer-joined", peer: { peerId: 1 } }); + + host.socket.close(); + guest.socket.close(); + }); + + it("relays SDP between peers and stamps the true sender", async () => { + const hostToken = await register("host"); + const guestToken = await register("guest"); + + const created = await fetch(`${baseUrl}/lobbies`, { + method: "POST", + headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" }, + body: "{}", + }); + const { code } = (await created.json()) as { code: string }; + await fetch(`${baseUrl}/lobbies/${code}/join`, { + method: "POST", + headers: { authorization: `Bearer ${guestToken}` }, + }); + + const host = await connect(hostToken, code); + const guest = await connect(guestToken, code); + + host.socket.send( + JSON.stringify({ + t: "signal", + // Lie about the sender; the relay must overwrite this with peer 0. + from: 99, + to: 1, + payload: { sdp: { type: "offer", sdp: "v=0 fake" } }, + }), + ); + + const relayed = await waitFor(guest.inbox, (m) => m.t === "signal"); + expect(relayed).toMatchObject({ + t: "signal", + from: 0, + to: 1, + payload: { sdp: { type: "offer", sdp: "v=0 fake" } }, + }); + + host.socket.close(); + guest.socket.close(); + }); + + it("rejects a protocol version mismatch", async () => { + const hostToken = await register("host"); + const created = await fetch(`${baseUrl}/lobbies`, { + method: "POST", + headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" }, + body: "{}", + }); + const { code } = (await created.json()) as { code: string }; + + const socket = new WebSocket(wsUrl); + const inbox: SignalMessage[] = []; + await new Promise((resolve) => socket.on("open", () => resolve())); + socket.on("message", (raw) => inbox.push(JSON.parse(raw.toString()) as SignalMessage)); + socket.send(JSON.stringify({ t: "hello", token: hostToken, lobbyCode: code, protocol: 999 })); + + const error = await waitFor(inbox, (m) => m.t === "error"); + expect(error).toMatchObject({ t: "error" }); + expect((error as { message: string }).message).toContain("protocol mismatch"); + socket.close(); + }); + + it("tells remaining peers when the host disconnects", async () => { + const hostToken = await register("host"); + const guestToken = await register("guest"); + + const created = await fetch(`${baseUrl}/lobbies`, { + method: "POST", + headers: { authorization: `Bearer ${hostToken}`, "content-type": "application/json" }, + body: "{}", + }); + const { code } = (await created.json()) as { code: string }; + await fetch(`${baseUrl}/lobbies/${code}/join`, { + method: "POST", + headers: { authorization: `Bearer ${guestToken}` }, + }); + + const host = await connect(hostToken, code); + const guest = await connect(guestToken, code); + + host.socket.close(); + + const left = await waitFor(guest.inbox, (m) => m.t === "peer-left"); + expect(left).toMatchObject({ t: "peer-left", peerId: 0 }); + + guest.socket.close(); + }); +}); diff --git a/server/src/ws/signaling.ts b/server/src/ws/signaling.ts new file mode 100644 index 0000000..84d1ae1 --- /dev/null +++ b/server/src/ws/signaling.ts @@ -0,0 +1,240 @@ +import type { Server } from "node:http"; +import { WebSocketServer, type WebSocket } from "ws"; +import type { PeerInfo, SignalMessage, SignalPayload } from "@psx/shared"; +import { validateSignalMessage } from "@psx/shared"; +import { verifyToken } from "../auth.js"; +import type { LobbyRecord, Store } from "../db/store.js"; + +/** + * SDP/ICE exchange — GDD §8. + * + * The server is a relay and nothing more. It never sees gameplay: once peers + * have exchanged descriptors they talk over data channels directly, and this + * socket carries only membership changes for the rest of the session. + * + * Two rules make the relay safe to expose: + * 1. `from` on a relayed signal is stamped from the authenticated socket, + * never read from the payload, so peers cannot impersonate one another. + * 2. A socket may only address peers inside the lobby it authenticated into. + */ + +interface Connection { + socket: WebSocket; + peerId: number; + userId: string; + lobbyCode: string; + isHost: boolean; + alive: boolean; +} + +const HEARTBEAT_INTERVAL_MS = 30_000; + +export class SignalingServer { + private readonly wss: WebSocketServer; + /** lobby code → peer id → connection */ + private readonly rooms = new Map>(); + private readonly heartbeat: NodeJS.Timeout; + + constructor( + server: Server, + private readonly store: Store, + path = "/ws", + ) { + this.wss = new WebSocketServer({ server, path }); + this.wss.on("connection", (socket) => this.onConnection(socket)); + + // Half-open TCP connections would otherwise linger as phantom lobby members. + this.heartbeat = setInterval(() => { + for (const room of this.rooms.values()) { + for (const connection of room.values()) { + if (!connection.alive) { + connection.socket.terminate(); + continue; + } + connection.alive = false; + connection.socket.ping(); + } + } + }, HEARTBEAT_INTERVAL_MS); + } + + private onConnection(socket: WebSocket): void { + let connection: Connection | null = null; + + socket.on("pong", () => { + if (connection) connection.alive = true; + }); + + socket.on("message", async (raw) => { + let parsed: unknown; + try { + parsed = JSON.parse(raw.toString()); + } catch { + return send(socket, { t: "error", message: "malformed JSON" }); + } + + const validation = validateSignalMessage(parsed); + if (!validation.ok) { + return send(socket, { t: "error", message: validation.errors.join("; ") }); + } + const message = validation.value; + + // Everything except `hello` requires an authenticated socket. + if (!connection && message.t !== "hello") { + return send(socket, { t: "error", message: "expected hello first" }); + } + + switch (message.t) { + case "hello": { + if (connection) return send(socket, { t: "error", message: "already authenticated" }); + connection = await this.authenticate(socket, message.token, message.lobbyCode); + break; + } + case "signal": + this.relaySignal(connection!, message.to, message.payload); + break; + case "ready": + await this.setReady(connection!, message.isReady); + break; + default: + send(socket, { t: "error", message: `unsupported message: ${message.t}` }); + } + }); + + socket.on("close", () => { + if (connection) void this.disconnect(connection); + }); + } + + private async authenticate( + socket: WebSocket, + token: string, + lobbyCode: string, + ): Promise { + const verified = verifyToken(token); + if (!verified) { + send(socket, { t: "error", message: "invalid or expired token" }); + socket.close(); + return null; + } + + const lobby = await this.store.findLobbyByCode(lobbyCode); + if (!lobby || lobby.status === "closed") { + send(socket, { t: "error", message: "lobby not found or closed" }); + socket.close(); + return null; + } + + // Membership is established over REST (`POST /lobbies/:code/join`) so that + // capacity and duplicate-join rules live in exactly one place. + const member = lobby.players.find((player) => player.userId === verified.userId); + if (!member) { + send(socket, { t: "error", message: "join the lobby before connecting" }); + socket.close(); + return null; + } + + const room = this.rooms.get(lobby.code) ?? new Map(); + this.rooms.set(lobby.code, room); + + // A reconnect from the same peer supersedes the stale socket. + room.get(member.peerId)?.socket.terminate(); + + const connection: Connection = { + socket, + peerId: member.peerId, + userId: verified.userId, + lobbyCode: lobby.code, + isHost: verified.userId === lobby.hostId, + alive: true, + }; + room.set(member.peerId, connection); + + send(socket, { + t: "welcome", + peerId: connection.peerId, + isHost: connection.isHost, + peers: this.peerList(lobby, room), + }); + + this.broadcast( + lobby.code, + { t: "peer-joined", peer: this.peerInfo(lobby, member.peerId) }, + connection.peerId, + ); + + return connection; + } + + private relaySignal(from: Connection, to: number, payload: SignalPayload): void { + const target = this.rooms.get(from.lobbyCode)?.get(to); + if (!target) return; + + send(target.socket, { + t: "signal", + // Stamped from the authenticated socket — never trusted from the payload. + from: from.peerId, + to, + payload, + }); + } + + private async setReady(connection: Connection, isReady: boolean): Promise { + const lobby = await this.store.setReady(connection.lobbyCode, connection.userId, isReady); + if (!lobby) return; + this.broadcast(connection.lobbyCode, { + t: "peer-updated", + peer: this.peerInfo(lobby, connection.peerId), + }); + } + + private async disconnect(connection: Connection): Promise { + const room = this.rooms.get(connection.lobbyCode); + // Guard against a superseded socket removing its replacement. + if (room?.get(connection.peerId)?.socket === connection.socket) { + room.delete(connection.peerId); + } + if (room && room.size === 0) this.rooms.delete(connection.lobbyCode); + + await this.store.leaveLobby(connection.lobbyCode, connection.userId); + this.broadcast(connection.lobbyCode, { t: "peer-left", peerId: connection.peerId }); + + // The authoritative simulation lived in the host's browser, so its departure + // ends the session for everyone rather than silently freezing the world. + if (connection.isHost) { + this.broadcast(connection.lobbyCode, { t: "error", message: "the host left the session" }); + for (const remaining of room?.values() ?? []) remaining.socket.close(); + this.rooms.delete(connection.lobbyCode); + } + } + + private peerInfo(lobby: LobbyRecord, peerId: number): PeerInfo { + const player = lobby.players.find((entry) => entry.peerId === peerId); + return { + peerId, + displayName: player?.displayName ?? `Player ${peerId}`, + isHost: player ? player.userId === lobby.hostId : false, + isReady: player?.isReady ?? false, + }; + } + + private peerList(lobby: LobbyRecord, room: Map): PeerInfo[] { + return [...room.keys()].map((peerId) => this.peerInfo(lobby, peerId)); + } + + private broadcast(lobbyCode: string, message: SignalMessage, exceptPeerId?: number): void { + for (const [peerId, connection] of this.rooms.get(lobbyCode) ?? []) { + if (peerId === exceptPeerId) continue; + send(connection.socket, message); + } + } + + close(): void { + clearInterval(this.heartbeat); + this.wss.close(); + } +} + +function send(socket: WebSocket, message: SignalMessage): void { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message)); +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..7d64dec --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..2237f14 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,22 @@ +{ + "name": "@psx/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./skeleton": "./src/skeleton/canonical.ts", + "./types": "./src/types/index.ts", + "./schemas": "./src/schemas/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.1.10" + } +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..eb43e55 --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from "./types/index.js"; +export * from "./schemas/index.js"; +export * from "./skeleton/canonical.js"; diff --git a/shared/src/schemas/common.ts b/shared/src/schemas/common.ts new file mode 100644 index 0000000..b10b077 --- /dev/null +++ b/shared/src/schemas/common.ts @@ -0,0 +1,28 @@ +/** + * Minimal validation primitives. + * + * `shared` stays dependency-free so the browser bundle and the Hono server can + * both import it without agreeing on a validation library — worth the hand + * written predicates, since this code sits on two trust boundaries (cloud saves + * and peer messages). + */ + +export type ValidationResult = { ok: true; value: T } | { ok: false; errors: string[] }; + +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const isVec3 = (value: unknown): boolean => + isRecord(value) && + Number.isFinite(value["x"]) && + Number.isFinite(value["y"]) && + Number.isFinite(value["z"]); + +export const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((entry) => typeof entry === "string"); + +/** Rejects NaN and Infinity, which JSON.parse will happily produce from input. */ +export const isFiniteNumber = (value: unknown): value is number => Number.isFinite(value); + +export const ok = (value: T): ValidationResult => ({ ok: true, value }); +export const fail = (...errors: string[]): ValidationResult => ({ ok: false, errors }); diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts new file mode 100644 index 0000000..eb3b94d --- /dev/null +++ b/shared/src/schemas/index.ts @@ -0,0 +1,3 @@ +export * from "./common.js"; +export * from "./save.js"; +export * from "./net.js"; diff --git a/shared/src/schemas/net.test.ts b/shared/src/schemas/net.test.ts new file mode 100644 index 0000000..6b2a62d --- /dev/null +++ b/shared/src/schemas/net.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { PROTOCOL_VERSION } from "../types/net.js"; +import { validateClientMessage, validateHostMessage, validateSignalMessage } from "./net.js"; + +/** + * These validators sit on the boundary between the host and an untrusted peer. + * They are expected to *sanitise*, not merely accept or reject, so the tests + * assert on the values that come out rather than only on the ok/fail verdict. + */ + +describe("validateClientMessage", () => { + it("accepts well-formed input", () => { + const result = validateClientMessage({ + t: "input", + tick: 12, + move: { x: 0.5, z: -1 }, + run: true, + yaw: 1.57, + }); + + expect(result.ok).toBe(true); + expect(result.ok && result.value).toMatchObject({ + t: "input", + tick: 12, + move: { x: 0.5, z: -1 }, + run: true, + }); + }); + + it("clamps an out-of-range movement vector instead of rejecting it", () => { + const result = validateClientMessage({ + t: "input", + tick: 1, + move: { x: 1e9, z: -1e9 }, + run: true, + yaw: 0, + }); + + expect(result.ok).toBe(true); + expect(result.ok && result.value).toMatchObject({ move: { x: 1, z: -1 } }); + }); + + it("rejects NaN and Infinity, which survive JSON round trips", () => { + expect( + validateClientMessage({ + t: "input", + tick: 1, + move: { x: Number.NaN, z: 0 }, + run: false, + yaw: 0, + }).ok, + ).toBe(false); + + expect( + validateClientMessage({ + t: "input", + tick: 1, + move: { x: 0, z: 0 }, + run: false, + yaw: Number.POSITIVE_INFINITY, + }).ok, + ).toBe(false); + }); + + it("coerces a non-boolean run flag rather than trusting it", () => { + const result = validateClientMessage({ + t: "input", + tick: 1, + move: { x: 0, z: 0 }, + run: "yes", + yaw: 0, + }); + expect(result.ok && result.value).toMatchObject({ run: false }); + }); + + it("floors a fractional tick", () => { + const result = validateClientMessage({ + t: "input", + tick: 7.9, + move: { x: 0, z: 0 }, + run: false, + yaw: 0, + }); + expect(result.ok && result.value).toMatchObject({ tick: 7 }); + }); + + it("caps chat length so one frame cannot exhaust host memory", () => { + const result = validateClientMessage({ t: "chat", text: "x".repeat(10_000) }); + expect(result.ok).toBe(true); + expect(result.ok && (result.value as { text: string }).text.length).toBe(280); + }); + + it("rejects an over-long interactable id", () => { + expect(validateClientMessage({ t: "interact", interactableId: "x".repeat(500) }).ok).toBe(false); + }); + + it("rejects unknown message types", () => { + expect(validateClientMessage({ t: "shutdown" }).ok).toBe(false); + expect(validateClientMessage(null).ok).toBe(false); + expect(validateClientMessage([]).ok).toBe(false); + }); +}); + +describe("validateSignalMessage", () => { + it("overwrites a claimed sender id", () => { + const result = validateSignalMessage({ + t: "signal", + from: 99, + to: 1, + payload: { sdp: { type: "offer", sdp: "v=0" } }, + }); + + // The relay stamps the true sender; a claimed `from` must not survive. + expect(result.ok && result.value).toMatchObject({ from: -1, to: 1 }); + }); + + it("rejects a protocol version mismatch", () => { + const result = validateSignalMessage({ + t: "hello", + token: "abc", + lobbyCode: "ABCDE", + protocol: PROTOCOL_VERSION + 1, + }); + + expect(result.ok).toBe(false); + expect(!result.ok && result.errors[0]).toContain("protocol mismatch"); + }); + + it("accepts a valid hello", () => { + const result = validateSignalMessage({ + t: "hello", + token: "abc", + lobbyCode: "ABCDE", + protocol: PROTOCOL_VERSION, + }); + expect(result.ok).toBe(true); + }); +}); + +describe("validateHostMessage", () => { + it("rejects a snapshot carrying more players than the lobby allows", () => { + const players = Array.from({ length: 9 }, (_, i) => ({ + peerId: i, + p: { x: 0, y: 0, z: 0 }, + yaw: 0, + spd: 0, + grounded: true, + })); + + expect(validateHostMessage({ t: "snapshot", tick: 1, ack: {}, players }).ok).toBe(false); + }); + + it("rejects a snapshot with a non-finite position", () => { + const result = validateHostMessage({ + t: "snapshot", + tick: 1, + ack: {}, + players: [ + { peerId: 1, p: { x: Number.NaN, y: 0, z: 0 }, yaw: 0, spd: 0, grounded: true }, + ], + }); + expect(result.ok).toBe(false); + }); + + it("accepts a well-formed snapshot", () => { + const result = validateHostMessage({ + t: "snapshot", + tick: 4, + ack: { 1: 3 }, + players: [{ peerId: 1, p: { x: 1, y: 0, z: 2 }, yaw: 0.4, spd: 1.2, grounded: true }], + }); + expect(result.ok).toBe(true); + }); + + it("rejects malformed flag payloads", () => { + expect(validateHostMessage({ t: "flags", set: "everything", unset: [] }).ok).toBe(false); + expect(validateHostMessage({ t: "flags", set: [1, 2], unset: [] }).ok).toBe(false); + }); +}); diff --git a/shared/src/schemas/net.ts b/shared/src/schemas/net.ts new file mode 100644 index 0000000..84b7a46 --- /dev/null +++ b/shared/src/schemas/net.ts @@ -0,0 +1,178 @@ +import type { ClientMessage, HostMessage, SignalMessage, SignalPayload } from "../types/net.js"; +import { MAX_PLAYERS, PROTOCOL_VERSION } from "../types/net.js"; +import { fail, isFiniteNumber, isRecord, isStringArray, ok, type ValidationResult } from "./common.js"; + +/** + * Peer message validation. + * + * Guests are untrusted. Every `ClientMessage` the host acts on passes through + * here first, and the validator *sanitises* rather than merely accepting: + * movement is clamped to the unit square so a peer cannot claim a 1e9 input + * vector and teleport, and identifiers are length-capped so a peer cannot + * exhaust host memory with a single frame. + */ + +/** Nothing legitimate approaches these; they exist to bound hostile input. */ +const MAX_ID_LENGTH = 128; +const MAX_CHAT_LENGTH = 280; + +const clampUnit = (value: number): number => (value < -1 ? -1 : value > 1 ? 1 : value); + +const isSaneId = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= MAX_ID_LENGTH; + +export function validateClientMessage(input: unknown): ValidationResult { + if (!isRecord(input)) return fail("message must be an object"); + + switch (input["t"]) { + case "input": { + const move = input["move"]; + if (!isRecord(move) || !isFiniteNumber(move["x"]) || !isFiniteNumber(move["z"])) { + return fail("input.move must be {x, z} finite numbers"); + } + if (!isFiniteNumber(input["tick"]) || input["tick"] < 0) { + return fail("input.tick must be a non-negative number"); + } + if (!isFiniteNumber(input["yaw"])) return fail("input.yaw must be finite"); + + return ok({ + t: "input", + tick: Math.floor(input["tick"]), + move: { x: clampUnit(move["x"]), z: clampUnit(move["z"]) }, + run: input["run"] === true, + yaw: input["yaw"], + }); + } + + case "interact": + if (!isSaneId(input["interactableId"])) return fail("interact.interactableId invalid"); + return ok({ t: "interact", interactableId: input["interactableId"] }); + + case "combine": + if (!isSaneId(input["itemA"]) || !isSaneId(input["itemB"])) { + return fail("combine requires two item ids"); + } + return ok({ t: "combine", itemA: input["itemA"], itemB: input["itemB"] }); + + case "chat": { + const text = input["text"]; + if (typeof text !== "string") return fail("chat.text must be a string"); + return ok({ t: "chat", text: text.slice(0, MAX_CHAT_LENGTH) }); + } + + default: + return fail(`unknown client message type: ${String(input["t"])}`); + } +} + +/** + * Guests validate host messages too. The host is authoritative over *game* + * state, not over the guest's memory safety, and a compromised host should not + * be able to crash the room. + */ +export function validateHostMessage(input: unknown): ValidationResult { + if (!isRecord(input)) return fail("message must be an object"); + + switch (input["t"]) { + case "snapshot": { + if (!isFiniteNumber(input["tick"])) return fail("snapshot.tick must be finite"); + if (!Array.isArray(input["players"])) return fail("snapshot.players must be an array"); + if (input["players"].length > MAX_PLAYERS) return fail("snapshot.players exceeds MAX_PLAYERS"); + + for (const player of input["players"]) { + if (!isRecord(player) || !isFiniteNumber(player["peerId"])) { + return fail("snapshot.players entry missing peerId"); + } + const position = player["p"]; + if ( + !isRecord(position) || + !isFiniteNumber(position["x"]) || + !isFiniteNumber(position["y"]) || + !isFiniteNumber(position["z"]) + ) { + return fail("snapshot.players entry has an invalid position"); + } + if (!isFiniteNumber(player["yaw"])) return fail("snapshot player yaw must be finite"); + } + return ok(input as unknown as HostMessage); + } + + case "flags": + if (!isStringArray(input["set"]) || !isStringArray(input["unset"])) { + return fail("flags.set and flags.unset must be string arrays"); + } + return ok(input as unknown as HostMessage); + + case "despawn": + if (!isStringArray(input["interactableIds"])) { + return fail("despawn.interactableIds must be a string array"); + } + return ok(input as unknown as HostMessage); + + case "inventory": + if (!isFiniteNumber(input["peerId"]) || !Array.isArray(input["items"])) { + return fail("inventory requires peerId and items[]"); + } + return ok(input as unknown as HostMessage); + + case "notice": + if (typeof input["text"] !== "string") return fail("notice.text must be a string"); + return ok(input as unknown as HostMessage); + + case "chat": + if (!isFiniteNumber(input["from"]) || typeof input["text"] !== "string") { + return fail("chat requires from and text"); + } + return ok({ t: "chat", from: input["from"], text: input["text"].slice(0, MAX_CHAT_LENGTH) }); + + case "sync": + if (typeof input["roomId"] !== "string") return fail("sync.roomId must be a string"); + if (!isStringArray(input["flags"]) || !isStringArray(input["despawned"])) { + return fail("sync.flags and sync.despawned must be string arrays"); + } + return ok(input as unknown as HostMessage); + + default: + return fail(`unknown host message type: ${String(input["t"])}`); + } +} + +export function validateSignalMessage(input: unknown): ValidationResult { + if (!isRecord(input)) return fail("message must be an object"); + + switch (input["t"]) { + case "hello": + if (typeof input["token"] !== "string") return fail("hello.token must be a string"); + if (!isSaneId(input["lobbyCode"])) return fail("hello.lobbyCode invalid"); + if (input["protocol"] !== PROTOCOL_VERSION) { + return fail( + `protocol mismatch: peer speaks ${String(input["protocol"])}, server speaks ${PROTOCOL_VERSION}`, + ); + } + return ok({ + t: "hello", + token: input["token"], + lobbyCode: input["lobbyCode"], + protocol: PROTOCOL_VERSION, + }); + + case "signal": { + if (!isFiniteNumber(input["to"])) return fail("signal.to must be a peer id"); + if (!isRecord(input["payload"])) return fail("signal.payload must be an object"); + // `from` is assigned by the server from the authenticated socket, never + // taken from the sender — otherwise a peer could impersonate another. + return ok({ + t: "signal", + from: -1, + to: input["to"], + payload: input["payload"] as SignalPayload, + }); + } + + case "ready": + return ok({ t: "ready", isReady: input["isReady"] === true }); + + default: + return fail(`unknown signal message type: ${String(input["t"])}`); + } +} diff --git a/shared/src/schemas/save.ts b/shared/src/schemas/save.ts new file mode 100644 index 0000000..add15a6 --- /dev/null +++ b/shared/src/schemas/save.ts @@ -0,0 +1,43 @@ +import type { SaveData } from "../types/save.js"; +import { SAVE_FORMAT_VERSION } from "../types/save.js"; +import { isRecord, isVec3, type ValidationResult } from "./common.js"; + +export function validateSaveData(input: unknown): ValidationResult { + const errors: string[] = []; + + if (!isRecord(input)) return { ok: false, errors: ["save must be an object"] }; + + if (input["version"] !== SAVE_FORMAT_VERSION) { + errors.push( + `unsupported save version ${String(input["version"])}, expected ${SAVE_FORMAT_VERSION}`, + ); + } + if (typeof input["campaign"] !== "string") errors.push("campaign must be a string"); + if (typeof input["playtimeSeconds"] !== "number") errors.push("playtimeSeconds must be a number"); + if (!Array.isArray(input["flags"])) errors.push("flags must be an array"); + if (!Array.isArray(input["consumedInteractables"])) { + errors.push("consumedInteractables must be an array"); + } + + const player = input["player"]; + if (!isRecord(player)) { + errors.push("player must be an object"); + } else { + if (typeof player["roomId"] !== "string") errors.push("player.roomId must be a string"); + if (!isVec3(player["position"])) errors.push("player.position must be a Vec3"); + if (typeof player["yaw"] !== "number") errors.push("player.yaw must be a number"); + if (typeof player["health"] !== "number") errors.push("player.health must be a number"); + const inventory = player["inventory"]; + if ( + !isRecord(inventory) || + !Array.isArray(inventory["slots"]) || + typeof inventory["capacity"] !== "number" + ) { + errors.push("player.inventory must have slots[] and capacity"); + } + } + + return errors.length === 0 + ? { ok: true, value: input as unknown as SaveData } + : { ok: false, errors }; +} diff --git a/shared/src/skeleton/canonical.ts b/shared/src/skeleton/canonical.ts new file mode 100644 index 0000000..5c20fa5 --- /dev/null +++ b/shared/src/skeleton/canonical.ts @@ -0,0 +1,143 @@ +/** + * Canonical skeleton — GDD §10.2. + * + * Mixamo-compatible core set. The harness builds joints under these names and + * binds skin weights to them, so every generated character plays the same + * procedural clips (and any future shared animation library). + */ + +export const CANONICAL_BONES = [ + "Hips", + "Spine", + "Spine1", + "Spine2", + "Neck", + "Head", + "LeftShoulder", + "LeftArm", + "LeftForeArm", + "LeftHand", + "RightShoulder", + "RightArm", + "RightForeArm", + "RightHand", + "LeftUpLeg", + "LeftLeg", + "LeftFoot", + "LeftToeBase", + "RightUpLeg", + "RightLeg", + "RightFoot", + "RightToeBase", +] as const; + +export type CanonicalBone = (typeof CANONICAL_BONES)[number]; + +/** Parent of each bone; `null` marks the skeleton root. */ +export const CANONICAL_HIERARCHY: Record = { + Hips: null, + Spine: "Hips", + Spine1: "Spine", + Spine2: "Spine1", + Neck: "Spine2", + Head: "Neck", + LeftShoulder: "Spine2", + LeftArm: "LeftShoulder", + LeftForeArm: "LeftArm", + LeftHand: "LeftForeArm", + RightShoulder: "Spine2", + RightArm: "RightShoulder", + RightForeArm: "RightArm", + RightHand: "RightForeArm", + LeftUpLeg: "Hips", + LeftLeg: "LeftUpLeg", + LeftFoot: "LeftLeg", + LeftToeBase: "LeftFoot", + RightUpLeg: "Hips", + RightLeg: "RightUpLeg", + RightFoot: "RightLeg", + RightToeBase: "RightFoot", +}; + +/** Bones a rigger must produce for a clip to be playable at all. */ +export const REQUIRED_BONES: readonly CanonicalBone[] = [ + "Hips", + "Spine", + "Head", + "LeftArm", + "RightArm", + "LeftUpLeg", + "LeftLeg", + "LeftFoot", + "RightUpLeg", + "RightLeg", + "RightFoot", +]; + +const CANONICAL_SET: ReadonlySet = new Set(CANONICAL_BONES); + +export function isCanonicalBone(name: string): name is CanonicalBone { + return CANONICAL_SET.has(name); +} + +/** Depth-first walk order, parents always before children. */ +export function canonicalWalkOrder(): CanonicalBone[] { + const out: CanonicalBone[] = []; + const visit = (bone: CanonicalBone) => { + out.push(bone); + for (const child of CANONICAL_BONES) { + if (CANONICAL_HIERARCHY[child] === bone) visit(child); + } + }; + visit("Hips"); + return out; +} + +/** + * Common aliases emitted by third-party riggers, normalised to canonical names. + * Mixamo's `mixamorig:` prefix is stripped before lookup. + */ +export const BONE_ALIASES: Record = { + hip: "Hips", + pelvis: "Hips", + root: "Hips", + spine_01: "Spine", + spine_02: "Spine1", + spine_03: "Spine2", + chest: "Spine2", + upperchest: "Spine2", + head_end: "Head", + clavicle_l: "LeftShoulder", + clavicle_r: "RightShoulder", + upperarm_l: "LeftArm", + upperarm_r: "RightArm", + lowerarm_l: "LeftForeArm", + lowerarm_r: "RightForeArm", + hand_l: "LeftHand", + hand_r: "RightHand", + thigh_l: "LeftUpLeg", + thigh_r: "RightUpLeg", + calf_l: "LeftLeg", + calf_r: "RightLeg", + foot_l: "LeftFoot", + foot_r: "RightFoot", + ball_l: "LeftToeBase", + ball_r: "RightToeBase", +}; + +/** Best-effort mapping of an arbitrary rigger bone name onto the canonical set. */ +export function normaliseBoneName(raw: string): CanonicalBone | null { + const stripped = raw.replace(/^mixamorig[:_]?/i, "").trim(); + if (isCanonicalBone(stripped)) return stripped; + + const key = stripped.toLowerCase().replace(/[\s.-]+/g, "_"); + const alias = BONE_ALIASES[key]; + if (alias) return alias; + + // `LeftArm` vs `leftarm` vs `Left_Arm` + const squashed = key.replace(/_/g, ""); + for (const bone of CANONICAL_BONES) { + if (bone.toLowerCase() === squashed) return bone; + } + return null; +} diff --git a/shared/src/types/collision.ts b/shared/src/types/collision.ts new file mode 100644 index 0000000..9107216 --- /dev/null +++ b/shared/src/types/collision.ts @@ -0,0 +1,47 @@ +import type { Quat, Vec3 } from "./math.js"; + +/** + * Collision filter categories. + * + * box3d-wasm exposes `{categoryBits, maskBits}` filters on shapes and raycasts. + * The character mover raycasts against WORLD|PROP only, so it never snags on + * sensors or on its own capsule. + */ +export const CollisionGroup = { + WORLD: 0x0001, + PLAYER: 0x0002, + ENEMY: 0x0004, + PROP: 0x0008, + TRIGGER: 0x0010, + CAMERA_ZONE: 0x0020, +} as const; + +export type CollisionGroupName = keyof typeof CollisionGroup; + +export interface CollisionFilter { + categoryBits: number; + maskBits: number; +} + +/** Everything the character mover is allowed to stand on or bump into. */ +export const MOVER_SOLID_MASK = CollisionGroup.WORLD | CollisionGroup.PROP; + +/** + * Collider primitives. + * + * Deliberately limited to what box3d-wasm@0.2.0 binds: box, sphere, capsule and + * convex hull. Upstream Box3D has no triangle-mesh shape in this build, so room + * geometry is authored as a compound of these rather than as a trimesh (GDD §7 + * assumes trimesh support that does not exist yet). + */ +export type ColliderDef = + | { kind: "box"; halfExtents: Vec3; offset?: Vec3; rotation?: Quat } + | { kind: "sphere"; radius: number; offset?: Vec3 } + | { kind: "capsule"; radius: number; height: number; offset?: Vec3 } + | { kind: "hull"; points: Vec3[]; offset?: Vec3 }; + +export interface ColliderMaterial { + friction?: number; + restitution?: number; + density?: number; +} diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts new file mode 100644 index 0000000..b8b5a82 --- /dev/null +++ b/shared/src/types/index.ts @@ -0,0 +1,6 @@ +export * from "./math.js"; +export * from "./collision.js"; +export * from "./room.js"; +export * from "./inventory.js"; +export * from "./save.js"; +export * from "./net.js"; diff --git a/shared/src/types/inventory.ts b/shared/src/types/inventory.ts new file mode 100644 index 0000000..36e87b3 --- /dev/null +++ b/shared/src/types/inventory.ts @@ -0,0 +1,39 @@ +/** Inventory + item definitions — GDD §6.3. */ + +export type ItemCategory = "key" | "consumable" | "weapon" | "ammo" | "document"; + +export interface ItemDef { + id: string; + name: string; + description: string; + category: ItemCategory; + /** Key items cannot be discarded and do not consume a normal slot. */ + isKeyItem?: boolean; + stackable?: boolean; + maxStack?: number; + /** Icon colour, standing in for a sprite until the item pipeline lands. */ + iconColor?: number; +} + +export interface ItemStack { + itemId: string; + quantity: number; +} + +/** Recipe for GDD §6.3 item combination. Order-independent. */ +export interface CombinationDef { + inputs: [string, string]; + output: ItemStack; + /** Consume the inputs, or keep them (e.g. a tool used on a component). */ + consumes?: [boolean, boolean]; +} + +export interface InventoryState { + slots: (ItemStack | null)[]; + capacity: number; + /** + * Key items are held outside the slot array — they must not compete for + * capacity, or a full inventory could soft-lock a puzzle. + */ + keyItems: string[]; +} diff --git a/shared/src/types/math.ts b/shared/src/types/math.ts new file mode 100644 index 0000000..296b3e3 --- /dev/null +++ b/shared/src/types/math.ts @@ -0,0 +1,88 @@ +/** + * Plain-object math types. + * + * box3d-wasm passes vectors as `{x,y,z}` and quaternions as `{x,y,z,w}`, which + * is also what three.js `.set()` / `.copy()` accept, so these cross the physics + * boundary with no marshalling. + */ + +export interface Vec3 { + x: number; + y: number; + z: number; +} + +export interface Quat { + x: number; + y: number; + z: number; + w: number; +} + +export interface Transform { + position: Vec3; + rotation: Quat; +} + +export const VEC3_ZERO: Readonly = Object.freeze({ x: 0, y: 0, z: 0 }); +export const VEC3_UP: Readonly = Object.freeze({ x: 0, y: 1, z: 0 }); +export const QUAT_IDENTITY: Readonly = Object.freeze({ x: 0, y: 0, z: 0, w: 1 }); + +export const vec3 = (x = 0, y = 0, z = 0): Vec3 => ({ x, y, z }); + +export const addVec3 = (a: Vec3, b: Vec3): Vec3 => ({ + x: a.x + b.x, + y: a.y + b.y, + z: a.z + b.z, +}); + +export const subVec3 = (a: Vec3, b: Vec3): Vec3 => ({ + x: a.x - b.x, + y: a.y - b.y, + z: a.z - b.z, +}); + +export const scaleVec3 = (v: Vec3, s: number): Vec3 => ({ + x: v.x * s, + y: v.y * s, + z: v.z * s, +}); + +export const dotVec3 = (a: Vec3, b: Vec3): number => a.x * b.x + a.y * b.y + a.z * b.z; + +export const lengthVec3 = (v: Vec3): number => Math.sqrt(dotVec3(v, v)); + +export function normaliseVec3(v: Vec3): Vec3 { + const len = lengthVec3(v); + return len > 1e-8 ? scaleVec3(v, 1 / len) : { x: 0, y: 0, z: 0 }; +} + +/** Yaw-only quaternion — the only rotation a tank-controlled character needs. */ +export function quatFromYaw(yaw: number): Quat { + const half = yaw * 0.5; + return { x: 0, y: Math.sin(half), z: 0, w: Math.cos(half) }; +} + +export function yawFromQuat(q: Quat): number { + return Math.atan2(2 * (q.w * q.y + q.x * q.z), 1 - 2 * (q.y * q.y + q.z * q.z)); +} + +/** Shortest signed angular delta from `a` to `b`, in (-PI, PI]. */ +export function angleDelta(a: number, b: number): number { + let d = (b - a) % (Math.PI * 2); + if (d > Math.PI) d -= Math.PI * 2; + if (d < -Math.PI) d += Math.PI * 2; + return d; +} + +export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; + +export const clamp = (v: number, min: number, max: number): number => + v < min ? min : v > max ? max : v; + +/** + * Frame-rate independent exponential smoothing. + * `smoothing` is the fraction of remaining distance left after one second. + */ +export const damp = (a: number, b: number, smoothing: number, dt: number): number => + lerp(a, b, 1 - Math.pow(smoothing, dt)); diff --git a/shared/src/types/net.ts b/shared/src/types/net.ts new file mode 100644 index 0000000..6f6c8b9 --- /dev/null +++ b/shared/src/types/net.ts @@ -0,0 +1,118 @@ +import type { Vec3 } from "./math.js"; + +/** + * Network protocol — GDD §8. + * + * Two distinct channels: + * + * - **Signalling** (`SignalMessage`) rides the WebSocket to the Hono server. + * It exists only to exchange SDP and ICE candidates and to announce lobby + * membership. No gameplay ever crosses it. + * + * - **Game** (`ClientMessage` / `HostMessage`) rides WebRTC data channels + * directly between peers. Topology is a star centred on the host, not a + * mesh: the host is authoritative, so guest-to-guest links would carry + * nothing anyone is allowed to trust. + * + * Field names are short because these are serialised on every snapshot at + * `SNAPSHOT_HZ`, and JSON key overhead dominates a payload this small. + */ + +export const PROTOCOL_VERSION = 1; + +/** GDD §8: "10–20 Hz (adventure pacing is forgiving)". */ +export const SNAPSHOT_HZ = 15; +export const SNAPSHOT_INTERVAL = 1 / SNAPSHOT_HZ; + +export const MAX_PLAYERS = 4; + +export type PeerId = number; + +export interface PeerInfo { + peerId: PeerId; + displayName: string; + isHost: boolean; + isReady: boolean; +} + +// --- signalling ------------------------------------------------------------- + +/** Opaque WebRTC descriptors. Kept `unknown`-ish so we never re-type the spec. */ +export interface SignalPayload { + sdp?: { type: "offer" | "answer"; sdp: string }; + ice?: { candidate: string; sdpMid: string | null; sdpMLineIndex: number | null }; +} + +export type SignalMessage = + /** client → server, first frame after the socket opens */ + | { t: "hello"; token: string; lobbyCode: string; protocol: number } + /** server → client, accepted */ + | { t: "welcome"; peerId: PeerId; isHost: boolean; peers: PeerInfo[] } + /** server → all, membership changes */ + | { t: "peer-joined"; peer: PeerInfo } + | { t: "peer-left"; peerId: PeerId } + | { t: "peer-updated"; peer: PeerInfo } + /** client → server → target client, relayed verbatim */ + | { t: "signal"; from: PeerId; to: PeerId; payload: SignalPayload } + /** client → server */ + | { t: "ready"; isReady: boolean } + /** server → all, host pressed start */ + | { t: "start"; roomId: string; spawnPointId: string } + | { t: "error"; message: string }; + +// --- guest → host ----------------------------------------------------------- + +/** + * Guests send intent, never position. The host is free to reject any of it, + * which is the whole point of host-authoritative play (GDD §8). + */ +export type ClientMessage = + | { + t: "input"; + /** Guest's local tick, echoed back in snapshots so it can reconcile. */ + tick: number; + /** Desired movement on the XZ plane, each component in -1..1. */ + move: { x: number; z: number }; + run: boolean; + /** Facing the guest believes it has; the host treats it as advisory. */ + yaw: number; + } + | { t: "interact"; interactableId: string } + | { t: "combine"; itemA: string; itemB: string } + | { t: "chat"; text: string }; + +// --- host → guests ---------------------------------------------------------- + +export interface PlayerSnapshot { + peerId: PeerId; + p: Vec3; + yaw: number; + /** Horizontal speed, so remote avatars animate without a second message. */ + spd: number; + grounded: boolean; +} + +export type HostMessage = + | { + t: "snapshot"; + tick: number; + /** Last input tick the host consumed, per peer, for reconciliation. */ + ack: Record; + players: PlayerSnapshot[]; + } + /** Authoritative world state. Sent on change, not per tick. */ + | { t: "flags"; set: string[]; unset: string[] } + | { t: "despawn"; interactableIds: string[] } + | { t: "inventory"; peerId: PeerId; items: Array<{ itemId: string; quantity: number }> } + | { t: "notice"; text: string; to?: PeerId } + | { t: "chat"; from: PeerId; text: string } + /** Full state for a peer that just joined mid-session. */ + | { + t: "sync"; + roomId: string; + flags: string[]; + despawned: string[]; + players: PlayerSnapshot[]; + }; + +export type GameMessage = ClientMessage | HostMessage; diff --git a/shared/src/types/room.ts b/shared/src/types/room.ts new file mode 100644 index 0000000..9530296 --- /dev/null +++ b/shared/src/types/room.ts @@ -0,0 +1,91 @@ +import type { ColliderDef, ColliderMaterial } from "./collision.js"; +import type { Vec3 } from "./math.js"; + +/** A static piece of level geometry: one visual mesh + its collider compound. */ +export interface StaticGeometryDef { + id: string; + /** Which builder in `client/src/world/geometry.ts` renders this piece. */ + visual: + | { kind: "box"; size: Vec3; color: number; textureId?: string } + | { kind: "plane"; size: [number, number]; color: number; textureId?: string } + /** Rendered via ConvexGeometry so the mesh matches a hull collider exactly. */ + | { kind: "hull"; points: Vec3[]; color: number } + | { kind: "none" }; + position: Vec3; + /** Yaw in radians. Rooms are authored axis-aligned plus yaw; that is enough for the genre. */ + yaw?: number; + colliders: ColliderDef[]; + material?: ColliderMaterial; + /** Despawned once this flag is set — barred doors, collapsed debris, shutters. */ + removeOnFlag?: string; +} + +/** + * A fixed camera zone — GDD §6.1. + * + * The trigger is a box sensor. While the player is inside, the camera snaps to + * `camera` and looks at `lookAt` (or tracks the player if `trackPlayer` is set). + * Overlapping zones resolve by highest `priority`. + */ +export interface CameraZoneDef { + id: string; + trigger: { center: Vec3; halfExtents: Vec3 }; + camera: { + position: Vec3; + lookAt: Vec3; + fov?: number; + /** Follow the player with a damped look-at instead of a fixed target. */ + trackPlayer?: boolean; + /** How far the camera may pan while tracking, in world units. */ + trackRadius?: number; + }; + /** Hard cut (classic RE) or a timed blend (Silent Hill style). Seconds. */ + blend?: { mode: "cut" } | { mode: "blend"; duration: number }; + priority?: number; +} + +export type InteractionKind = + | { kind: "examine"; text: string } + | { kind: "pickup"; itemId: string; quantity?: number } + | { kind: "door"; targetRoomId: string; spawnPointId: string; requiresItemId?: string } + | { kind: "useItem"; acceptsItemId: string; onUseFlag: string; text: string }; + +/** Something the player can walk up to and press Interact on. */ +export interface InteractableDef { + id: string; + label: string; + position: Vec3; + /** Sensor half-extents defining the "you can reach this" volume. */ + halfExtents: Vec3; + action: InteractionKind; + /** Only active while every listed flag is set (or unset, when prefixed with `!`). */ + requiresFlags?: string[]; + /** Set to false once consumed; persisted in the save. */ + repeatable?: boolean; + visual?: { kind: "box"; size: Vec3; color: number } | { kind: "none" }; +} + +export interface SpawnPointDef { + id: string; + position: Vec3; + yaw: number; +} + +export interface RoomDef { + id: string; + displayName: string; + /** Linear fog, the primary atmosphere tool (GDD §14). */ + fog: { color: number; near: number; far: number }; + ambientLight: { color: number; intensity: number }; + lights: Array<{ + kind: "point" | "directional"; + color: number; + intensity: number; + position: Vec3; + distance?: number; + }>; + geometry: StaticGeometryDef[]; + cameraZones: CameraZoneDef[]; + interactables: InteractableDef[]; + spawnPoints: SpawnPointDef[]; +} diff --git a/shared/src/types/save.ts b/shared/src/types/save.ts new file mode 100644 index 0000000..0e4697b --- /dev/null +++ b/shared/src/types/save.ts @@ -0,0 +1,46 @@ +import type { InventoryState } from "./inventory.js"; +import type { Vec3 } from "./math.js"; + +/** + * Save payload — serialised straight into the `saves.data` JSONB column + * (GDD §9). Versioned so migrations stay possible once campaigns ship. + */ +export const SAVE_FORMAT_VERSION = 1; + +export interface PlayerSaveState { + roomId: string; + position: Vec3; + yaw: number; + health: number; + inventory: InventoryState; +} + +export interface SaveData { + version: number; + campaign: string; + player: PlayerSaveState; + /** Puzzle/door/interaction flags. Presence in the set means "set". */ + flags: string[]; + /** Interactables already consumed (non-repeatable ones that fired). */ + consumedInteractables: string[]; + playtimeSeconds: number; + savedAt: string; +} + +export function emptySave(campaign: string, spawnRoomId: string): SaveData { + return { + version: SAVE_FORMAT_VERSION, + campaign, + player: { + roomId: spawnRoomId, + position: { x: 0, y: 0, z: 0 }, + yaw: 0, + health: 100, + inventory: { slots: Array(8).fill(null), capacity: 8, keyItems: [] }, + }, + flags: [], + consumedInteractables: [], + playtimeSeconds: 0, + savedAt: new Date().toISOString(), + }; +} diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..f5712f7 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/tools/fixtures/groundskeeper-synthetic.png b/tools/fixtures/groundskeeper-synthetic.png new file mode 100644 index 0000000..cb24dd6 Binary files /dev/null and b/tools/fixtures/groundskeeper-synthetic.png differ diff --git a/tools/fixtures/officer-synthetic.png b/tools/fixtures/officer-synthetic.png new file mode 100644 index 0000000..0d1d233 Binary files /dev/null and b/tools/fixtures/officer-synthetic.png differ diff --git a/tools/fixtures/researcher-synthetic.png b/tools/fixtures/researcher-synthetic.png new file mode 100644 index 0000000..5fd6de0 Binary files /dev/null and b/tools/fixtures/researcher-synthetic.png differ diff --git a/tools/fixtures/survivor-reference.jpg b/tools/fixtures/survivor-reference.jpg new file mode 100644 index 0000000..6d00346 Binary files /dev/null and b/tools/fixtures/survivor-reference.jpg differ diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 0000000..7ae09c9 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,31 @@ +{ + "name": "@psx/tools", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "harness": "tsx src/cli.ts", + "cast": "tsx src/creator/build.ts", + "creator": "tsx src/creator/build.ts", + "creator:ui": "vite --config web/vite.config.ts", + "creator:ui:build": "vite build --config web/vite.config.ts", + "test": "vitest run", + "preview": "tsx src/preview/cli.ts" + }, + "dependencies": { + "@psx/shared": "workspace:*", + "jpeg-js": "^0.4.4", + "pngjs": "^7.0.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/pngjs": "^6.0.5", + "@types/three": "^0.185.1", + "three": "^0.185.1", + "tsx": "^4.23.1", + "typescript": "^5.9.3", + "vite": "^8.2.0", + "vitest": "^4.1.10" + } +} diff --git a/tools/src/anim/procedural.ts b/tools/src/anim/procedural.ts new file mode 100644 index 0000000..0620203 --- /dev/null +++ b/tools/src/anim/procedural.ts @@ -0,0 +1,262 @@ +import type { CanonicalBone } from "@psx/shared"; + +/** + * Procedural animation clips authored against the canonical skeleton. + * + * Because every channel targets a canonical bone *name* rather than a joint + * index, a clip generated for one character plays on any other character the + * harness produces (GDD §10.2). Nothing here depends on the figure's + * proportions — only on its topology. + * + * Rotation-only, and deliberately so: root translation would fight the + * runtime's `CharacterMover`, which owns the character's position. The mover + * drives where the body goes; these clips only say what the limbs do. + * + * ## Axis convention + * + * The harness skeleton uses **identity rest rotations** and local translations + * only (Y-up, Z-forward, right-handed — same as the runtime). Under that setup: + * + * - A limb hanging down the −Y axis swings **backward (−Z)** under **+X** pitch. + * - Forward swing (toward +Z, the facing direction) is therefore **−X**. + * - Knee flex (shin folds back toward −Z when the thigh is vertical) is **+X**. + * - Elbow flex (hand comes forward toward +Z when the upper arm hangs) is **−X**. + * - Spine lean toward +Z (forward) is **+X** (children sit along +Y). + */ + +export interface Keyframe { + time: number; + /** Euler XYZ in radians, converted to quaternions at export. */ + rotation: [number, number, number]; +} + +export interface AnimationChannel { + bone: CanonicalBone; + keyframes: Keyframe[]; +} + +export interface AnimationClip { + name: string; + duration: number; + loop: boolean; + channels: AnimationChannel[]; +} + +/** Samples per cycle. Low on purpose — PSX animation was chunky. */ +const SAMPLES = 8; + +/** + * Builds a channel by sampling a function over one cycle. + * The final key repeats the first so looping playback has no seam. + */ +function cycle( + bone: CanonicalBone, + duration: number, + fn: (phase: number) => [number, number, number], +): AnimationChannel { + const keyframes: Keyframe[] = []; + for (let i = 0; i <= SAMPLES; i++) { + const phase = i / SAMPLES; + keyframes.push({ time: phase * duration, rotation: fn(phase % 1) }); + } + return { bone, keyframes }; +} + +const wave = (phase: number, offset = 0): number => Math.sin((phase + offset) * Math.PI * 2); + +/** Neutral pose — every bone at rest. Used as the bind/reset clip. */ +export function buildIdlePose(): AnimationClip { + return { + name: "TPose", + duration: 0.001, + loop: false, + channels: [{ bone: "Hips", keyframes: [{ time: 0, rotation: [0, 0, 0] }] }], + }; +} + +/** + * Breathing idle. Survival horror characters stand tense, so the motion is + * small and slow — big idle sway reads as comedic. + */ +export function buildIdleClip(duration = 4): AnimationClip { + return { + name: "Idle", + duration, + loop: true, + channels: [ + // Spine children sit along +Y: +X leans forward (+Z). Tiny breath. + cycle("Spine", duration, (p) => [wave(p) * 0.022, 0, 0]), + cycle("Spine1", duration, (p) => [wave(p, 0.08) * 0.018, 0, 0]), + cycle("Neck", duration, (p) => [wave(p, 0.15) * -0.03, wave(p, 0.3) * 0.04, 0]), + // Soft arm hang: slight −X is forward; elbows rest slightly flexed (−X). + cycle("LeftArm", duration, (p) => [wave(p, 0.1) * -0.03, 0, 0.05]), + cycle("RightArm", duration, (p) => [wave(p, 0.1) * -0.03, 0, -0.05]), + cycle("LeftForeArm", duration, (p) => [-0.2 + wave(p, 0.2) * -0.02, 0, 0]), + cycle("RightForeArm", duration, (p) => [-0.2 + wave(p, 0.2) * -0.02, 0, 0]), + ], + }; +} + +/** + * Walk cycle. Arms swing in opposition to the legs — the single detail that + * makes a walk read as a walk rather than a shuffle. + */ +export function buildWalkClip(duration = 1, stride = 0.55): AnimationClip { + return { + name: "Walk", + duration, + loop: true, + channels: [ + // Counter-rotation through the torso, small enough not to look like a strut. + cycle("Hips", duration, (p) => [0, wave(p) * 0.06, 0]), + // Slight forward lean (+X with +Y spine). + cycle("Spine", duration, (p) => [0.05, wave(p) * -0.05, 0]), + cycle("Spine2", duration, (p) => [0.02, wave(p) * -0.07, 0]), + + // Hips: −X is forward (+Z). Opposite legs are half a cycle apart. + cycle("LeftUpLeg", duration, (p) => [-wave(p) * stride, 0, 0]), + cycle("RightUpLeg", duration, (p) => [-wave(p, 0.5) * stride, 0, 0]), + // Knees only flex one way: +X folds the shin back (foot toward −Z when vertical). + // Peak flex just after the hip starts the swing (phase offset 0.25 / 0.75). + cycle("LeftLeg", duration, (p) => [Math.max(0, wave(p, 0.25)) * stride * 1.15, 0, 0]), + cycle("RightLeg", duration, (p) => [Math.max(0, wave(p, 0.75)) * stride * 1.15, 0, 0]), + // Plantar flex follows the stride so the sole doesn't skate. + cycle("LeftFoot", duration, (p) => [-wave(p, 0.15) * 0.22, 0, 0]), + cycle("RightFoot", duration, (p) => [-wave(p, 0.65) * 0.22, 0, 0]), + + // Arms opposite the legs (0.5 phase), −X forward. + cycle("LeftArm", duration, (p) => [-wave(p, 0.5) * stride * 0.55, 0, 0.08]), + cycle("RightArm", duration, (p) => [-wave(p) * stride * 0.55, 0, -0.08]), + // Elbows: −X flexes the hand forward. Extra bend on the forward swing. + cycle("LeftForeArm", duration, (p) => [-0.22 - Math.max(0, wave(p, 0.5)) * 0.32, 0, 0]), + cycle("RightForeArm", duration, (p) => [-0.22 - Math.max(0, wave(p)) * 0.32, 0, 0]), + ], + }; +} + +/** Run cycle: longer stride, deeper forward lean, faster loop. */ +export function buildRunClip(duration = 0.62): AnimationClip { + const walk = buildWalkClip(duration, 0.95); + return { + ...walk, + name: "Run", + channels: walk.channels.map((channel) => + channel.bone === "Spine" + ? { + ...channel, + keyframes: channel.keyframes.map((key) => ({ + ...key, + // Deeper forward lean for the run. + rotation: [0.18, key.rotation[1], key.rotation[2]] as [number, number, number], + })), + } + : channel, + ), + }; +} + +/** + * Quick-turn: the 180° spin from GDD §6.2. Body-only — the runtime rotates the + * character's yaw itself, so this adds the lean and arm swing that sell it. + */ +export function buildQuickTurnClip(duration = 0.22): AnimationClip { + const arc = (phase: number): number => Math.sin(phase * Math.PI); + const keys = (fn: (phase: number) => [number, number, number]): Keyframe[] => + Array.from({ length: 5 }, (_, i) => { + const phase = i / 4; + return { time: phase * duration, rotation: fn(phase) }; + }); + + return { + name: "QuickTurn", + duration, + loop: false, + channels: [ + { bone: "Spine", keyframes: keys((p) => [0, arc(p) * -0.4, 0]) }, + { bone: "Spine2", keyframes: keys((p) => [0, arc(p) * -0.3, 0]) }, + // −X is forward; arms brace into the spin. + { bone: "LeftArm", keyframes: keys((p) => [arc(p) * -0.5, 0, 0.25]) }, + { bone: "RightArm", keyframes: keys((p) => [arc(p) * 0.35, 0, -0.25]) }, + { bone: "LeftUpLeg", keyframes: keys((p) => [arc(p) * -0.35, 0, 0]) }, + { bone: "RightUpLeg", keyframes: keys((p) => [arc(p) * 0.35, 0, 0]) }, + ], + }; +} + +/** + * Corpse / ambient body pose — not a locomotion clip. + * + * Weight settles into the hips, spine folds, head hangs, arms go limp. Used by + * room NPCs so they don't stand in the breathing Idle like living party members. + */ +export function buildSlumpedClip(): AnimationClip { + const hold = (bone: CanonicalBone, rotation: [number, number, number]): AnimationChannel => ({ + bone, + keyframes: [ + { time: 0, rotation }, + { time: 0.001, rotation }, + ], + }); + + return { + name: "Slumped", + duration: 0.001, + loop: false, + channels: [ + // Collapse into a sit-lean against a wall: pelvis tucks, spine curls forward. + hold("Hips", [0.12, 0.08, 0.04]), + hold("Spine", [0.45, 0.18, 0.1]), + hold("Spine1", [0.35, 0.12, 0.06]), + hold("Spine2", [0.2, 0.08, 0.04]), + hold("Neck", [0.55, 0.25, 0.12]), + hold("Head", [0.4, 0.2, 0.08]), + // Arms hang heavy — −X brings hands forward/down rather than locking behind. + hold("LeftShoulder", [0.1, 0, 0.15]), + hold("RightShoulder", [0.1, 0, -0.15]), + hold("LeftArm", [0.35, 0.1, 0.55]), + hold("RightArm", [0.55, -0.15, -0.65]), + hold("LeftForeArm", [-0.85, 0.1, 0.05]), + hold("RightForeArm", [-0.7, -0.1, -0.05]), + hold("LeftHand", [0.1, 0, 0.15]), + hold("RightHand", [0.15, 0, -0.2]), + // Legs splay loosely; +X on the knee folds the shin back. + hold("LeftUpLeg", [-0.35, 0.2, 0.12]), + hold("RightUpLeg", [-0.25, -0.25, -0.15]), + hold("LeftLeg", [0.85, 0, 0]), + hold("RightLeg", [1.05, 0, 0]), + hold("LeftFoot", [-0.2, 0.05, 0]), + hold("RightFoot", [-0.15, -0.05, 0]), + ], + }; +} + +export function buildDefaultClips(): AnimationClip[] { + return [ + buildIdleClip(), + buildWalkClip(), + buildRunClip(), + buildQuickTurnClip(), + buildSlumpedClip(), + ]; +} + +/** Euler XYZ to quaternion, matching three.js's default order and glTF XYZW. */ +export function eulerToQuaternion( + x: number, + y: number, + z: number, +): [number, number, number, number] { + const cx = Math.cos(x / 2); + const sx = Math.sin(x / 2); + const cy = Math.cos(y / 2); + const sy = Math.sin(y / 2); + const cz = Math.cos(z / 2); + const sz = Math.sin(z / 2); + + return [ + sx * cy * cz + cx * sy * sz, + cx * sy * cz - sx * cy * sz, + cx * cy * sz + sx * sy * cz, + cx * cy * cz - sx * sy * sz, + ]; +} diff --git a/tools/src/cast/buildCast.ts b/tools/src/cast/buildCast.ts new file mode 100644 index 0000000..0c1d24f --- /dev/null +++ b/tools/src/cast/buildCast.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node +/** + * Build every campaign character via the modular creator. + * + * pnpm cast + * + * Recipes live in `creator/presets.ts` — swap hair / clothes / accessories + * there rather than re-running photo analysis. + */ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const result = spawnSync(process.execPath, ["--import", "tsx", join(here, "../creator/build.ts")], { + stdio: "inherit", + cwd: join(here, "../../.."), +}); +process.exit(result.status ?? 1); diff --git a/tools/src/cast/specs.ts b/tools/src/cast/specs.ts new file mode 100644 index 0000000..e6a0177 --- /dev/null +++ b/tools/src/cast/specs.ts @@ -0,0 +1,117 @@ +import type { FigureSpec } from "../image/testFixtures.js"; +import { DEFAULT_FIGURE } from "../image/testFixtures.js"; + +/** + * Cast definitions for the Ashgrove Precinct campaign. + * + * Each entry is a synthetic, measurable figure used when no Grok reference is + * available. Proportions and palettes are distinct so the code-drawn meshes + * read as different people at 240p. + */ + +export interface CastMember { + id: string; + name: string; + height: number; + /** Role in the campaign — player, ambient body, or both. */ + role: "player" | "ambient" | "both"; + description: string; + figure: FigureSpec; +} + +export const CAST: CastMember[] = [ + { + id: "survivor", + name: "Survivor", + height: 1.72, + role: "player", + description: "Lone survivor in a canvas jacket — the default player body.", + figure: { + ...DEFAULT_FIGURE, + colors: { + hair: { r: 42, g: 38, b: 34 }, + skin: { r: 168, g: 128, b: 102 }, + torso: { r: 86, g: 92, b: 60 }, + arm: { r: 74, g: 80, b: 52 }, + leg: { r: 48, g: 50, b: 58 }, + foot: { r: 92, g: 66, b: 44 }, + }, + backpack: { + fromY: 0.2, + toY: 0.48, + width: 0.28, + color: { r: 62, g: 56, b: 44 }, + }, + }, + }, + { + id: "researcher", + name: "Dr. Hale", + height: 1.68, + role: "ambient", + description: "Manor researcher in a pale lab coat — found in the study.", + figure: { + ...DEFAULT_FIGURE, + neckY: 0.13, + shoulderY: 0.19, + crotchY: 0.54, + torsoWidth: 0.24, + armReach: 0.36, + colors: { + hair: { r: 210, g: 200, b: 185 }, + skin: { r: 210, g: 178, b: 152 }, + torso: { r: 220, g: 218, b: 210 }, + arm: { r: 210, g: 208, b: 200 }, + leg: { r: 55, g: 58, b: 72 }, + foot: { r: 40, g: 40, b: 48 }, + }, + }, + }, + { + id: "officer", + name: "Sgt. Marrow", + height: 1.82, + role: "ambient", + description: "Uniformed officer — slumped in the cellar.", + figure: { + ...DEFAULT_FIGURE, + neckY: 0.12, + shoulderY: 0.18, + crotchY: 0.52, + torsoWidth: 0.3, + headWidth: 0.095, + legWidth: 0.1, + armWidth: 0.085, + armReach: 0.4, + colors: { + hair: { r: 28, g: 28, b: 30 }, + skin: { r: 145, g: 110, b: 88 }, + torso: { r: 40, g: 52, b: 48 }, + arm: { r: 36, g: 46, b: 42 }, + leg: { r: 32, g: 36, b: 40 }, + foot: { r: 28, g: 28, b: 28 }, + }, + }, + }, + { + id: "groundskeeper", + name: "Ellis", + height: 1.75, + role: "ambient", + description: "Groundskeeper in a stained work coat — foyer and kitchen.", + figure: { + ...DEFAULT_FIGURE, + crotchY: 0.56, + torsoWidth: 0.28, + legWidth: 0.095, + colors: { + hair: { r: 90, g: 70, b: 50 }, + skin: { r: 175, g: 130, b: 100 }, + torso: { r: 120, g: 78, b: 48 }, + arm: { r: 100, g: 70, b: 48 }, + leg: { r: 70, g: 72, b: 78 }, + foot: { r: 55, g: 48, b: 40 }, + }, + }, + }, +]; diff --git a/tools/src/cli.ts b/tools/src/cli.ts new file mode 100644 index 0000000..80a2b35 --- /dev/null +++ b/tools/src/cli.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import { chdir } from "node:process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { GrokBridge } from "./grok/GrokBridge.js"; +import { defaultOutputPath, formatReport, runPipeline } from "./pipeline.js"; + +/** + * `psx-harness` — photo to rigged, animated GLB. + * + * pnpm harness --describe "a lone survivor in a canvas jacket" --name Survivor + * pnpm harness --image tools/fixtures/survivor-reference.jpg --name Survivor + * + * Paths are resolved from the monorepo root so the same flags work whether + * you invoke via the root script, `pnpm --filter @psx/tools harness`, or + * `tsx tools/src/cli.ts` from the repo root. + */ + +/** `tools/src/cli.ts` → monorepo root is two levels up. */ +const WORKSPACE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +interface Args { + describe?: string; + image?: string; + name?: string; + out?: string; + height?: number; + help?: boolean; +} + +function parseArgs(argv: string[]): Args { + const args: Args = {}; + + for (let i = 0; i < argv.length; i++) { + const token = argv[i]!; + // pnpm/npm insert a bare `--` between the script name and user args. + if (token === "--") continue; + const next = () => argv[++i]; + + switch (token) { + case "--describe": + case "-d": + args.describe = next(); + break; + case "--image": + case "-i": + args.image = next(); + break; + case "--name": + case "-n": + args.name = next(); + break; + case "--out": + case "-o": + args.out = next(); + break; + case "--height": + args.height = Number(next()); + break; + case "--help": + case "-h": + args.help = true; + break; + default: + if (token.startsWith("-")) throw new Error(`unknown flag: ${token}`); + } + } + return args; +} + +const USAGE = ` +psx-harness — generate a rigged, animated PSX character from a reference image + +Usage: + psx-harness --describe "" [options] + psx-harness --image [options] + +Options: + -d, --describe Generate the reference through the Grok CLI (image_gen) + -i, --image Use an existing reference image (PNG or JPEG) + -n, --name Character name [default: Character] + -o, --out Output .glb path [default: assets/characters/.glb] + --height Target world height [default: 1.7] + -h, --help Show this message + +The reference should be a full-body front view on a plain background. Generation +is cached by prompt, so re-running the same description costs nothing. +`.trim(); + +async function main(): Promise { + // Always run relative to the monorepo root so default outputs land in + // `assets/characters/` and documented fixture paths resolve. + if (existsSync(resolve(WORKSPACE_ROOT, "pnpm-workspace.yaml"))) { + chdir(WORKSPACE_ROOT); + } + + const args = parseArgs(process.argv.slice(2)); + + if (args.help || (!args.describe && !args.image)) { + console.log(USAGE); + process.exit(args.help ? 0 : 1); + } + + const name = args.name ?? "Character"; + const outputPath = args.out ?? defaultOutputPath(name); + + if (args.describe && !args.image) { + const bridge = new GrokBridge(); + if (!(await bridge.isAvailable())) { + console.error( + "Grok CLI not found on PATH. Install it, set GROK_BIN, or pass --image with an existing reference.", + ); + process.exit(1); + } + console.log("Generating reference image (this takes about a minute)…"); + } + + const result = await runPipeline({ + ...(args.image ? { imagePath: args.image } : {}), + ...(args.describe ? { describe: args.describe } : {}), + name, + outputPath, + ...(args.height ? { height: args.height } : {}), + }); + + console.log(`\n${name}\n${formatReport(result)}\n`); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/tools/src/creator/assemble.test.ts b/tools/src/creator/assemble.test.ts new file mode 100644 index 0000000..5d306f9 --- /dev/null +++ b/tools/src/creator/assemble.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { AnimationClip as ThreeClip, Bone, SkinnedMesh } from "three"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { assembleCharacter } from "./assemble.js"; +import { CHARACTER_PRESETS } from "./presets.js"; +import { listParts } from "./parts.js"; + +function parseGlb(glb: Uint8Array) { + const loader = new GLTFLoader(); + const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer; + return new Promise((resolve, reject) => { + loader.parse(buffer, "", resolve, reject); + }); +} + +describe("character creator", () => { + it("exposes hair / upper / lower / feet / accessory parts", () => { + expect(listParts("hair").length).toBeGreaterThan(2); + expect(listParts("upper").length).toBeGreaterThan(3); + expect(listParts("lower").length).toBeGreaterThan(1); + expect(listParts("feet").length).toBeGreaterThan(1); + expect(listParts("accessory").length).toBeGreaterThan(1); + }); + + it("assembles male and female presets into loadable skinned GLBs", async () => { + const male = assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "survivor")!); + const female = assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "researcher")!); + + expect(male.stats.triangles).toBeGreaterThan(400); + expect(female.stats.triangles).toBeGreaterThan(400); + expect(male.recipe.body).toBe("male"); + expect(female.recipe.body).toBe("female"); + + for (const result of [male, female]) { + const gltf = await parseGlb(result.glb); + let skinned: SkinnedMesh | null = null; + gltf.scene.traverse((object) => { + if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh; + }); + expect(skinned).not.toBeNull(); + expect(skinned!.skeleton.bones.length).toBe(22); + expect(gltf.animations.some((clip: ThreeClip) => clip.name === "Walk")).toBe(true); + expect(gltf.animations.some((clip: ThreeClip) => clip.name === "Slumped")).toBe(true); + + const bones = new Map(); + gltf.scene.traverse((object) => { + if ((object as Bone).isBone) bones.set(object.name, object as Bone); + }); + expect(bones.get("LeftForeArm")?.parent?.name).toBe("LeftArm"); + } + }); + + it("rejects body-locked parts on the wrong sex", () => { + expect(() => + assembleCharacter({ + id: "bad", + name: "Bad", + body: "male", + height: 1.7, + palette: CHARACTER_PRESETS[0]!.palette, + parts: { lower: "lower.skirt" }, + }), + ).toThrow(/female/); + }); + + it("mass/muscle/fat sliders change torso girth", () => { + const base = CHARACTER_PRESETS.find((p) => p.id === "survivor")!; + const bare = { + hair: "hair.none", + upper: "upper.none", + lower: "lower.none", + feet: "feet.none", + accessory: "accessory.none", + } as const; + const lean = assembleCharacter({ + ...base, + id: "lean", + bodyStyle: { mass: -1, muscle: -0.5, fat: 0 }, + parts: { ...bare }, + }); + const heavy = assembleCharacter({ + ...base, + id: "heavy", + bodyStyle: { mass: 1, muscle: 1, fat: 1 }, + parts: { ...bare }, + }); + + // A-pose arms dominate mid-height X. Shoulder girdle (just below neck) + // is where mass/muscle/fat expand the torso loft most clearly. + const girdleHalfW = (positions: number[], height: number) => { + const y0 = height * 0.78; + const y1 = height * 0.86; + let m = 0; + for (let i = 0; i < positions.length; i += 3) { + const y = positions[i + 1]!; + if (y < y0 || y > y1) continue; + m = Math.max(m, Math.abs(positions[i]!)); + } + return m; + }; + + const leanW = girdleHalfW(lean.mesh.positions, base.height); + const heavyW = girdleHalfW(heavy.mesh.positions, base.height); + expect(heavyW).toBeGreaterThan(leanW * 1.35); + }); +}); diff --git a/tools/src/creator/assemble.ts b/tools/src/creator/assemble.ts new file mode 100644 index 0000000..b0c544d --- /dev/null +++ b/tools/src/creator/assemble.ts @@ -0,0 +1,114 @@ +import { buildDefaultClips } from "../anim/procedural.js"; +import { exportGlb, meshStatistics } from "../export/glb.js"; +import { appendMesh, computeFlatNormals, createMesh, triangleCount, vertexCount } from "../mesh/MeshBuilder.js"; +import { buildSkeleton } from "../rig/skeleton.js"; +import { computeInverseBindMatrices, computeSkinWeights } from "../rig/skin.js"; +import { buildBaseBody } from "./body.js"; +import { normalizeBodyStyle } from "./bodyStyle.js"; +import { normalizeHeadStyle } from "./headStyle.js"; +import { applyCoverage, type CoverageBand } from "./coverage.js"; +import { PART_CATALOGUE } from "./parts.js"; +import { measurementsFor } from "./proportions.js"; +import type { AssembledCharacter, CharacterRecipe, PartContext, PartSlot } from "./types.js"; + +const SLOT_ORDER: PartSlot[] = ["lower", "upper", "feet", "hair", "accessory"]; + +/** + * Assemble a character from a modular recipe. + * + * base body (male|female) + equipped parts → single skinned mesh → GLB + */ +export function assembleCharacter(recipe: CharacterRecipe): AssembledCharacter { + const measurements = measurementsFor(recipe.body); + const skeleton = buildSkeleton(measurements, { height: recipe.height }); + const bodyStyle = normalizeBodyStyle(recipe.bodyStyle); + const headStyle = normalizeHeadStyle(recipe.headStyle); + + // Resolve the outfit first: the body is built knowing what will cover it, so + // the skin underneath can be deleted rather than fought with padding. + const ctx: PartContext = { + skeleton, + body: recipe.body, + palette: recipe.palette, + bodyStyle, + headStyle, + }; + const worn: { def: (typeof PART_CATALOGUE) extends ReadonlyMap ? D : never; id: string }[] = []; + const coverage: CoverageBand[] = []; + + for (const slot of SLOT_ORDER) { + const partId = recipe.parts[slot]; + if (!partId || partId.endsWith(".none")) continue; + + const def = PART_CATALOGUE.get(partId); + if (!def) throw new Error(`Unknown part "${partId}" on recipe "${recipe.id}"`); + if (def.slot !== slot) throw new Error(`Part "${partId}" is slot ${def.slot}, expected ${slot}`); + if (def.body && def.body !== recipe.body) { + throw new Error(`Part "${partId}" is only valid on ${def.body} bodies`); + } + worn.push({ def, id: partId }); + if (def.coverage) coverage.push(...def.coverage); + } + + const mesh = createMesh(); + const skin = buildBaseBody(skeleton, recipe.body, recipe.palette, bodyStyle, headStyle); + const hidden = applyCoverage(skin, coverage, { recessDepth: skeleton.height * 0.009 }); + appendMesh(mesh, hidden.mesh); + + for (const { def, id } of worn) { + appendMesh(mesh, def.build({ + ...ctx, + ...(recipe.partColors?.[id] ? { color: recipe.partColors[id] } : {}), + })); + } + + computeFlatNormals(mesh); + computeSkinWeights(mesh, skeleton); + const inverseBindMatrices = computeInverseBindMatrices(skeleton); + const clips = buildDefaultClips(); + const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { + name: recipe.name, + clips, + generator: "psx-adventure-engine character creator", + }); + + const meshStats = meshStatistics(mesh); + + return { + recipe, + skeleton, + mesh, + glb, + stats: { + vertices: meshStats.vertices, + triangles: meshStats.triangles, + bones: skeleton.joints.length, + clips: clips.length, + skinHidden: hidden.dropped, + skinRecessed: hidden.recessed, + }, + }; +} + +export function formatAssemblyReport(result: AssembledCharacter): string { + const { recipe, stats } = result; + const parts = Object.entries(recipe.parts) + .filter(([, id]) => id && !id.endsWith(".none")) + .map(([slot, id]) => `${slot}=${id}`) + .join(", "); + + const style = normalizeBodyStyle(recipe.bodyStyle); + const face = normalizeHeadStyle(recipe.headStyle); + return [ + ` body ${recipe.body} @ ${recipe.height.toFixed(2)}m`, + ` physique mass=${style.mass.toFixed(2)} muscle=${style.muscle.toFixed(2)} fat=${style.fat.toFixed(2)}`, + ` face length=${face.length.toFixed(2)} jaw=${face.jaw.toFixed(2)} brow=${face.brow.toFixed(2)}`, + ` parts ${parts || "(bare)"}`, + ` mesh ${stats.vertices} vertices, ${stats.triangles} triangles`, + ` skin ${stats.skinHidden} triangles hidden under clothing, ${stats.skinRecessed} recessed`, + ` rig ${stats.bones} bones, ${stats.clips} clips`, + ].join("\n"); +} + +// Re-export for tests / diagnostics. +export { vertexCount, triangleCount }; diff --git a/tools/src/creator/body.ts b/tools/src/creator/body.ts new file mode 100644 index 0000000..ea93a79 --- /dev/null +++ b/tools/src/creator/body.ts @@ -0,0 +1,573 @@ +import type { CanonicalBone } from "@psx/shared"; +import type { Rgb } from "../image/Raster.js"; +import { + add, + computeFlatNormals, + createMesh, + lerp, + loft, + normalise, + orientedBox, + scale, + sub, + vertexCount, + type LoftKey, + type MeshData, + type Vec3Tuple, +} from "../mesh/MeshBuilder.js"; +import { jointWorld, type Skeleton } from "../rig/skeleton.js"; +import type { BodyStyle } from "./bodyStyle.js"; +import type { HeadStyle } from "./headStyle.js"; +import { PART, tagLoft, tagRange, type BodyPart } from "./coverage.js"; +import { bodyMetrics, torsoPlan, type BodyMetrics } from "./metrics.js"; +import type { BodyType, CreatorPalette } from "./types.js"; + +/** + * Base body mesh — skin only. Every clothed character derives from this. + * + * Shape comes entirely from {@link bodyMetrics}; this file decides only where + * the rings go and how many. The stylisation is in the ring counts, not in the + * proportions — a PSX figure that is honestly proportioned and coarsely + * tessellated reads as an adult at 240p, where a lanky one reads as a + * marionette. + * + * Note that `computeFlatNormals` is a misnomer: it averages face normals into + * shared vertices, so every loft here is smooth-shaded and the faceting you see + * is the silhouette, not the shading. The face relies on that — eyes and mouth + * are painted, so the head is kept as clean unbroken surface for the texture and + * only the nose, which changes the silhouette, stays as geometry. + * + * Structure, root to tip: + * torso crotch → glutes → hip shelf → waist → ribs → chest → girdle + * neck C7 → jaw, buried at both ends + * head jaw → chin → cheek → brow → cranium → crown, plus nose and ears + * arms deltoid socket buried in the girdle → upper → fore → palm → thumb + * legs hip socket buried in the pelvis → thigh → knee → calf → ankle + * feet heel → instep → ball → toe, lofted along the foot axis + * + * Every piece is tagged with its region and loft parameter (see + * `coverage.ts`), so a garment can claim the skin it covers and the assembler + * can delete it. The loft parameters quoted in the comments below are the same + * numbers garments cite in their coverage bands — move a key here and the + * matching band moves with it. + */ + +/** Torso reads round at 8 sides; limbs stay cheap. */ +const TORSO_SIDES = 8; +const ARM_SIDES = 6; +const LEG_SIDES = 6; +const HEAD_SIDES = 8; +const FOOT_SIDES = 5; + +/** Stable ring frames: rx across the body, rz front-to-back. */ +const ACROSS: Vec3Tuple = [1, 0, 0]; + +export interface HeadLandmarks { + head: Vec3Tuple; + neck: Vec3Tuple; + crown: Vec3Tuple; + brow: Vec3Tuple; + browFwd: Vec3Tuple; + occiput: Vec3Tuple; + headW: number; + /** Half-depth of the skull, front to back. */ + headD: number; + height: number; +} + +/** + * Skull reference points for hair and headgear. + * + * Body style is ignored — a hat doesn't care how heavy its wearer is — but head + * style is not: the sliders reshape the skull these anchors sit on, so hair + * built off a neutral head would clip through a long or square one. + */ +export function headLandmarks( + skeleton: Skeleton, + body: BodyType, + headStyle: HeadStyle | null = null, +): HeadLandmarks { + const m = bodyMetrics(skeleton, body, null, headStyle); + const head = jointWorld(skeleton, "Head"); + const neck = jointWorld(skeleton, "Neck"); + const headW = m.headHalfW; + + const crown: Vec3Tuple = [0, m.crownY, m.headZ + headW * 0.05]; + const brow: Vec3Tuple = [0, m.browY, m.headZ + m.headHalfD * 0.5]; + const browFwd: Vec3Tuple = [0, m.browY - headW * 0.04, m.headZ + m.headHalfD * 0.92]; + const occiput: Vec3Tuple = [ + 0, + m.browY - headW * 0.12, + m.headZ - m.headHalfD * 0.78, + ]; + + return { + head, + neck, + crown, + brow, + browFwd, + occiput, + headW, + headD: m.headHalfD, + height: skeleton.height, + }; +} + +export function buildBaseBody( + skeleton: Skeleton, + body: BodyType, + palette: CreatorPalette, + bodyStyle: BodyStyle | null = null, + headStyle: HeadStyle | null = null, +): MeshData { + const mesh = createMesh(); + const m = bodyMetrics(skeleton, body, bodyStyle, headStyle); + const skin = palette.skin; + + buildTorso(mesh, m, skin); + buildChestForms(mesh, m, skin); + buildNeck(mesh, m, skin); + buildHead(mesh, m, skin); + buildArms(mesh, m, skin); + buildLegs(mesh, m, skin); + + computeFlatNormals(mesh); + return mesh; +} + +// --- torso ----------------------------------------------------------------- + +function buildTorso(mesh: MeshData, m: BodyMetrics, skin: Rgb): void { + const h = m.height; + const hips = m.at("Hips"); + + // Ring plan lives in metrics.ts so torso garments loft on the same one. + tagLoft( + mesh, + loft(mesh, torsoPlan(m), skin, { + sides: TORSO_SIDES, + rings: 13, + caps: true, + startRight: ACROSS, + }), + PART.TORSO, + ); + + // Groin bridge — fills between the leg roots so the crotch reads solid from + // the front and under a skirt hem. + const legRootX = Math.abs(m.at("LeftUpLeg")[0]); + const bridgeFrom = vertexCount(mesh); + loft( + mesh, + [ + { t: 0, c: [0, m.hipY - h * 0.02, hips[2] + h * 0.012], rx: legRootX * 0.8, rz: m.hip.rz * 0.5 }, + { + t: 0.6, + c: [0, m.crotchY + h * 0.012, hips[2] + h * 0.014], + rx: legRootX * 0.62, + rz: m.hip.rz * 0.42, + }, + { t: 1, c: [0, m.crotchY - h * 0.008, hips[2] + h * 0.008], rx: legRootX * 0.4, rz: m.hip.rz * 0.3 }, + ], + skin, + { sides: 5, rings: 4, caps: true, startRight: ACROSS }, + ); + // The bridge lives at the crotch, so it hides with the pelvis. + tagRange(mesh, bridgeFrom, PART.TORSO, 0.02); +} + +/** Breasts (female) or a flat pectoral shelf (male), on the front of the ribcage. */ +function buildChestForms(mesh: MeshData, m: BodyMetrics, skin: Rgb): void { + if (m.female) { + const { r, x, y, z, out } = m.bust; + if (r <= 0) return; + for (const side of [-1, 1] as const) { + const from = vertexCount(mesh); + loft( + mesh, + [ + // Rooted inside the ribcage so the seam never separates. + { t: 0, c: [side * x, y + r * 0.15, z - r * 0.5], rx: r * 0.92, rz: r * 0.92 }, + { t: 0.55, c: [side * x, y, z + out * 0.45], rx: r, rz: r }, + { t: 1, c: [side * x * 0.98, y - r * 0.22, z + out], rx: r * 0.42, rz: r * 0.42 }, + ], + skin, + { sides: 5, rings: 3, caps: true, startRight: ACROSS }, + ); + // Chest height on the torso loft — hides under any upper garment. + tagRange(mesh, from, PART.TORSO, 0.7); + } + return; + } + + const { rx, rz, x, y, z } = m.pec; + // Flat chest at rest — a pec pad at neutral muscle just adds a seam to + // shade wrong. It grows out of the chest wall only when the slider asks. + if (rz < m.height * 0.004) return; + for (const side of [-1, 1] as const) { + const from = vertexCount(mesh); + loft( + mesh, + [ + { t: 0, c: [side * x, y + rx * 0.42, z - rz * 1.6], rx: rx * 0.86, rz: rz * 0.7 }, + { t: 1, c: [side * x, y - rx * 0.5, z - rz * 0.2], rx, rz }, + ], + skin, + { sides: 5, rings: 3, caps: true, startRight: ACROSS }, + ); + tagRange(mesh, from, PART.TORSO, 0.7); + } +} + +// --- neck and head --------------------------------------------------------- + +function buildNeck(mesh: MeshData, m: BodyMetrics, skin: Rgb): void { + const h = m.height; + const neck = m.at("Neck"); + const neckLoft = loft( + mesh, + [ + // Buried in the trapezius — uncapped, or the cap pokes through the collar. + { t: 0, c: [0, m.collarY - h * 0.035, neck[2]], rx: m.neck.rx * 1.35, rz: m.neck.rz * 1.3 }, + { t: 0.4, c: [0, m.collarY + h * 0.008, neck[2] + h * 0.002], rx: m.neck.rx, rz: m.neck.rz }, + // Just under the jaw, tilted slightly forward with the cervical curve. + { t: 1, c: [0, m.neckTopY, neck[2] + h * 0.007], rx: m.neck.rx * 0.92, rz: m.neck.rz * 0.94 }, + ], + skin, + { sides: 6, rings: 4, capStart: false, capEnd: true, startRight: ACROSS }, + ); + tagLoft(mesh, neckLoft, PART.NECK); +} + +/** + * Head loft, chin (t=0) to crown (t=1). + * + * Shape is entirely `m.cranium` — the sex face base times the three head + * sliders. The ring plan below decides only *where* the rings sit; how wide the + * jaw is against the cheekbones, whether the forehead stands up or slopes back, + * and how far the brow juts are all read off the metrics, the same way the + * torso reads its widths. Every hard-coded fraction still here is one no face + * varies by. + */ +function buildHead(mesh: MeshData, m: BodyMetrics, skin: Rgb): void { + const hw = m.headHalfW; + const hd = m.headHalfD; + const z = m.headZ; + const cr = m.cranium; + const mix = (a: number, b: number, t: number) => a + (b - a) * t; + + // Skull mass sits behind the face plane; the jaw is narrower and set back. + const keys: LoftKey[] = [ + // Under the jaw, inside the neck. + { t: 0.0, c: [0, m.chinY - hw * 0.16, z + hd * 0.04], rx: hw * cr.chinW * 0.8, rz: hd * cr.chinD * 0.58 }, + // Chin — narrow and thrust forward. This is what stops the head reading as + // an egg: the lower face must be a wedge, not a hemisphere. + { t: 0.12, c: [0, m.chinY, z + hd * cr.chinOut], rx: hw * cr.chinW, rz: hd * cr.chinD }, + // Jawline, halfway to the jaw angle. + // + // The centre sits at 0.12, not the 0.18 it used to: `rz` here is nearly the + // jaw's, so a centre that far forward pushed this ring's front surface past + // both the jaw above it and the chin below. Reading down the face the front + // went 0.96 → 1.00 → 0.92, and that non-monotonic step is a shelf across the + // lower face, not a jawline. Keep the profile falling: 0.96 → 0.94 → 0.92. + { + t: 0.24, + c: [0, m.chinY + (m.jawY - m.chinY) * 0.6, z + hd * 0.12], + rx: hw * mix(cr.chinW, cr.jawW, 0.62), + rz: hd * mix(cr.chinD, cr.jawD, 0.71), + }, + // Jaw angle / masseter — widest point of the lower face. + { t: 0.38, c: [0, m.jawY, z + hd * 0.06], rx: hw * cr.jawW, rz: hd * cr.jawD }, + // Cheekbone — the head's widest ring. + { t: 0.55, c: [0, m.mouthY + (m.browY - m.mouthY) * 0.5, z + hd * 0.06], rx: hw * cr.cheekW, rz: hd * 0.98 }, + // Brow ridge, carried by the ring itself rather than a bar glued on top. + // A separate slab here shades as a hard step across exactly the band the + // texture paints eyes into; rolled into the loft it stays one continuous + // surface and the slider still reads in profile. + { + t: 0.68, + c: [0, m.browY, z + hd * (0.02 + 0.012 * cr.browOut)], + rx: hw * cr.cheekW * 0.97, + // Spans ~0.96·hd soft to ~1.09·hd heavy, so the brow sits behind the + // cheekbone at one end and a centimetre proud of it at the other. That is + // the whole slider now, and it stays a swelling of the skull rather than + // a step the texture has to paint around. + rz: hd * (0.96 + 0.065 * cr.browOut), + }, + // Upper cranium. On a heavy brow this drifts back over the occiput; on a + // soft one the forehead stands up instead. + { + t: 0.83, + c: [0, m.browY + (m.crownY - m.browY) * 0.52, z - hd * 0.08 * cr.foreheadSlope], + rx: hw * 0.92, + rz: hd * 0.94, + }, + // Crown. + { t: 0.95, c: [0, m.crownY - hw * 0.1, z - hd * 0.12 * cr.foreheadSlope], rx: hw * 0.56, rz: hd * 0.54 }, + { t: 1.0, c: [0, m.crownY, z - hd * 0.12 * cr.foreheadSlope], rx: hw * 0.18, rz: hd * 0.18 }, + ]; + // 13 rings, not 10: the chin juts forward of the jaw on purpose, and at 10 + // the hermite sweep resolves that turn as a hard crease across the lower face + // — a shading break on a smooth head rather than a jawline. + tagLoft(mesh, loft(mesh, keys, skin, { sides: HEAD_SIDES, rings: 13, caps: true, startRight: ACROSS }), PART.HEAD); + + const featuresFrom = vertexCount(mesh); + + // Nose — the one feature that stays geometry, because it is the only one that + // changes the silhouette. Eyes and mouth are painted, so the face is left as + // clean surface for them; a modelled brow or lip at this scale only fights + // the texture and shades as a crease across it. + // + // Anchored to the face plane rather than a fixed fraction of the skull. The + // old placement put the tip at ~0.96·hd while the surface at nose height sits + // near 1.0·hd, so the wedge was buried and read as a flat plate. + // Lofted rather than boxed: a box cannot taper, so it reads as a slab stuck + // to the face from the front however well it sits in profile. Four sides and + // three rings is 20 triangles for a wedge that narrows to the tip. + const noseY = m.mouthY + (m.browY - m.mouthY) * 0.5; + const faceZ = z + hd * 0.97; + const proj = hw * 0.2 * cr.noseOut; + const noseW = hw * cr.noseW; + loft( + mesh, + [ + // Root sits behind the face plane, so the bridge never opens a gap. + { + t: 0, + c: [0, m.browY - hw * 0.04 * cr.noseLen, faceZ - hw * 0.12], + rx: noseW * 0.07, + rz: hw * 0.05, + }, + { + t: 0.6, + c: [0, noseY, faceZ + proj * 0.5], + rx: noseW * 0.1, + rz: hw * 0.09, + }, + // Tip — the widest ring, and the only part clear of the face. + { + t: 1, + c: [0, noseY - hw * 0.22 * cr.noseLen, faceZ + proj], + rx: noseW * 0.12, + rz: hw * 0.1, + }, + ], + skin, + { sides: 4, rings: 3, caps: true, startRight: ACROSS }, + ); + + // Ears, flat against the skull just behind the cheekbone. + for (const side of [-1, 1] as const) { + orientedBox( + mesh, + [side * hw * 0.88, m.browY - hw * 0.34, z - hd * 0.08], + hw * 0.07, + hw * 0.26 * cr.earSize, + hw * 0.14 * cr.earSize, + [side, 0, 0.25], + skin, + ); + } + // Face features sit at brow height; nothing in the catalogue hides them, and + // nothing should — an open-fronted hood over a hidden face reads decapitated. + tagRange(mesh, featuresFrom, PART.HEAD, 0.66); +} + +// --- arms ------------------------------------------------------------------ + +function buildArms(mesh: MeshData, m: BodyMetrics, skin: Rgb): void { + const h = m.height; + + for (const prefix of ["Left", "Right"] as const) { + const side = prefix === "Left" ? 1 : -1; + const shoulder = m.at(`${prefix}Shoulder` as CanonicalBone); + const arm = m.at(`${prefix}Arm` as CanonicalBone); + const foreArm = m.at(`${prefix}ForeArm` as CanonicalBone); + const hand = m.at(`${prefix}Hand` as CanonicalBone); + + const down = normalise(sub(hand, arm)); + const wristOut = normalise(sub(hand, foreArm)); + const handEnd = add(hand, scale(wristOut, m.handLength)); + + const keys: LoftKey[] = [ + // Inside the girdle — no cap, so no disk floating in the chest. + { + t: 0.0, + c: [side * m.clavX, shoulder[1] - h * 0.012, shoulder[2]], + rx: m.deltoidR * 0.94, + rz: m.deltoidR, + }, + // Deltoid cap. This, not the trunk, sets the shoulder silhouette — but it + // must sit *under* the trapezius line, or it reads as an epaulette. + { + t: 0.1, + c: [side * m.socketX, arm[1] + h * 0.002, arm[2]], + rx: m.deltoidR, + rz: m.deltoidR * 1.04, + }, + // Deltoid insertion — the arm proper takes over. + { + t: 0.22, + c: add(arm, scale(down, h * 0.045)), + rx: m.upperArm.rx * 1.06, + rz: m.upperArm.rz * 1.06, + }, + { t: 0.4, c: lerp(arm, foreArm, 0.62), rx: m.upperArm.rx * 0.92, rz: m.upperArm.rz * 0.92 }, + // Elbow. + { t: 0.52, c: foreArm, rx: m.elbow.rx, rz: m.elbow.rz }, + // Forearm belly, just below the elbow. + { t: 0.62, c: lerp(foreArm, hand, 0.2), rx: m.foreArm.rx, rz: m.foreArm.rz }, + { t: 0.8, c: lerp(foreArm, hand, 0.72), rx: m.wrist.rx * 1.18, rz: m.wrist.rz * 1.12 }, + // Wrist — the section turns flat here and stays flat through the palm. + { t: 0.86, c: hand, rx: m.wrist.rx, rz: m.wrist.rz }, + { t: 0.93, c: lerp(hand, handEnd, 0.45), rx: m.palm.rx, rz: m.palm.rz }, + // Knuckles, then the fingers close as one paddle. + { t: 0.97, c: lerp(hand, handEnd, 0.78), rx: m.palm.rx * 0.94, rz: m.palm.rz * 0.94 }, + { t: 1.0, c: handEnd, rx: m.palm.rx * 0.7, rz: m.palm.rz * 0.62 }, + ]; + // startRight along X puts rx across the body and rz front-to-back, so the + // palm faces the thigh at any A-pose angle. + tagLoft( + mesh, + loft(mesh, keys, skin, { + sides: ARM_SIDES, + rings: 11, + capStart: false, + capEnd: true, + startRight: ACROSS, + }), + side > 0 ? PART.ARM_L : PART.ARM_R, + ); + + // Thumb — a wedge off the front-inboard corner of the palm. + const thumbFrom = vertexCount(mesh); + const thumbAt = lerp(hand, handEnd, 0.32); + orientedBox( + mesh, + add(thumbAt, [side * -m.palm.rx * 0.5, 0, m.palm.rz * 0.75]), + m.thumbR * 0.62, + m.thumbR * 1.5, + m.thumbR * 0.8, + [side * -0.25, -0.55, 0.8], + skin, + ); + tagRange(mesh, thumbFrom, side > 0 ? PART.ARM_L : PART.ARM_R, 0.93); + } +} + +// --- legs and feet --------------------------------------------------------- + +function buildLegs(mesh: MeshData, m: BodyMetrics, skin: Rgb): void { + const h = m.height; + const hips = m.at("Hips"); + + for (const prefix of ["Left", "Right"] as const) { + const side = prefix === "Left" ? 1 : -1; + const upLeg = m.at(`${prefix}UpLeg` as CanonicalBone); + const leg = m.at(`${prefix}Leg` as CanonicalBone); + const foot = m.at(`${prefix}Foot` as CanonicalBone); + const legX = Math.abs(upLeg[0]); + + const keys: LoftKey[] = [ + // Buried inside the pelvis bowl — uncapped, so the hip owns the join. + { + t: 0.0, + c: [side * legX * 0.6, m.hipY + h * 0.012, hips[2] + m.buttZ * 0.5], + rx: m.hipSocket.rx, + rz: m.hipSocket.rz, + }, + // Upper thigh, still wide where it meets the glute. + { + t: 0.1, + c: [side * legX * 0.95, m.crotchY + h * 0.01, upLeg[2] - h * 0.004], + rx: m.thigh.rx * 1.1, + rz: m.thigh.rz * 1.12, + }, + { t: 0.24, c: [side * legX, upLeg[1] - h * 0.03, upLeg[2]], rx: m.thigh.rx, rz: m.thigh.rz }, + // Above the knee the thigh narrows to the condyles. + { t: 0.42, c: lerp(upLeg, leg, 0.78), rx: m.thigh.rx * 0.78, rz: m.thigh.rz * 0.8 }, + { t: 0.52, c: leg, rx: m.knee.rx, rz: m.knee.rz }, + // Calf belly — behind the shin axis, and high on the leg. + { + t: 0.65, + c: add(lerp(leg, foot, 0.26), [0, 0, m.calfZ]), + rx: m.calf.rx, + rz: m.calf.rz, + }, + { t: 0.82, c: add(lerp(leg, foot, 0.62), [0, 0, m.calfZ * 0.4]), rx: m.calf.rx * 0.72, rz: m.calf.rz * 0.7 }, + // Ankle. + { t: 1.0, c: [foot[0], foot[1], foot[2]], rx: m.ankle.rx, rz: m.ankle.rz }, + ]; + tagLoft( + mesh, + loft(mesh, keys, skin, { + sides: LEG_SIDES, + rings: 10, + capStart: false, + capEnd: false, + startRight: ACROSS, + }), + side > 0 ? PART.LEG_L : PART.LEG_R, + ); + + buildFoot(mesh, m, skin, foot, side > 0 ? PART.FOOT_L : PART.FOOT_R); + } +} + +/** + * Foot lofted along its own axis (heel → toe) rather than dangling off the + * shin, so the ring plane is vertical and `rz` is the foot's height above the + * floor. Every section is sized to touch y=0 exactly where it should: heel and + * ball on the ground, arch lifted. + */ +function buildFoot( + mesh: MeshData, + m: BodyMetrics, + skin: Rgb, + ankle: Vec3Tuple, + part: BodyPart, +): void { + const x = ankle[0]; + const z = ankle[2]; + const hw = m.footHalfW; + const toeZ = z + m.footLength - m.heelBack; + const heelZ = z - m.heelBack; + + const keys: LoftKey[] = [ + // Back of the heel, rounded off the floor. + { t: 0.0, c: [x, m.ankleHeight * 0.62, heelZ], rx: hw * 0.62, rz: m.ankleHeight * 0.62 }, + // Heel pad — on the ground. + { t: 0.16, c: [x, m.ankleHeight * 0.52, heelZ + m.footLength * 0.1], rx: hw * 0.78, rz: m.ankleHeight * 0.52 }, + // Under the ankle: tallest section, arch lifted off the floor. + { t: 0.36, c: [x, m.ankleHeight * 0.62, z + m.footLength * 0.06], rx: hw * 0.84, rz: m.ankleHeight * 0.56 }, + // Midfoot / instep. + { t: 0.58, c: [x, m.ankleHeight * 0.44, z + m.footLength * 0.28], rx: hw * 0.92, rz: m.ankleHeight * 0.44 }, + // Ball of the foot — widest, back on the ground. + { t: 0.82, c: [x, m.ankleHeight * 0.34, z + m.footLength * 0.5], rx: hw, rz: m.ankleHeight * 0.34 }, + // Toes. + { t: 1.0, c: [x, m.ankleHeight * 0.24, toeZ], rx: hw * 0.82, rz: m.ankleHeight * 0.24 }, + ]; + tagLoft(mesh, loft(mesh, keys, skin, { sides: FOOT_SIDES, rings: 7, caps: true, startRight: ACROSS }), part); + + // Ankle collar — bridges the shin ring into the top of the foot. + const collarFrom = vertexCount(mesh); + loft( + mesh, + [ + { t: 0, c: [x, ankle[1] + m.height * 0.012, z], rx: m.ankle.rx, rz: m.ankle.rz }, + { t: 1, c: [x, m.ankleHeight * 0.66, z + m.footLength * 0.04], rx: hw * 0.8, rz: m.ankleHeight * 0.6 }, + ], + skin, + { sides: FOOT_SIDES, rings: 3, caps: true, startRight: ACROSS }, + ); + tagRange(mesh, collarFrom, part, 0.35); +} + +export const SKIN = { + fair: { r: 220, g: 185, b: 160 } satisfies Rgb, + light: { r: 200, g: 160, b: 130 } satisfies Rgb, + medium: { r: 168, g: 128, b: 100 } satisfies Rgb, + tan: { r: 145, g: 105, b: 78 } satisfies Rgb, + deep: { r: 90, g: 62, b: 48 } satisfies Rgb, +}; diff --git a/tools/src/creator/bodyStyle.test.ts b/tools/src/creator/bodyStyle.test.ts new file mode 100644 index 0000000..dca6ed0 --- /dev/null +++ b/tools/src/creator/bodyStyle.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + BODY_STYLE_DEFAULTS, + normalizeBodyStyle, + physiqueFor, + physiqueFromBodyStyle, +} from "./bodyStyle.js"; + +describe("bodyStyle", () => { + it("normalizes missing / packed values", () => { + expect(normalizeBodyStyle(null)).toEqual(BODY_STYLE_DEFAULTS); + expect(normalizeBodyStyle({ m: 0.5, u: -0.25, f: 0.1 })).toEqual({ + mass: 0.5, + muscle: -0.25, + fat: 0.1, + }); + expect(normalizeBodyStyle({ mass: 2, muscle: -3, fat: -1 })).toEqual({ + mass: 1, + muscle: -1, + fat: 0, + }); + }); + + it("maps extremes to bulkier / leaner physiques", () => { + const lean = physiqueFromBodyStyle({ mass: -1, muscle: 0, fat: 0 }); + const heavy = physiqueFromBodyStyle({ mass: 1, muscle: 1, fat: 1 }); + expect(heavy.bulk).toBeGreaterThan(lean.bulk); + expect(heavy.shoulderF).toBeGreaterThan(lean.shoulderF); + expect(heavy.waistF).toBeGreaterThan(lean.waistF); + }); + + it("stacks sex base under slider multipliers", () => { + const male = physiqueFor("male", { mass: 0, muscle: 0, fat: 0 }); + const female = physiqueFor("female", { mass: 0, muscle: 0, fat: 0 }); + expect(male.bulk).toBeGreaterThan(female.bulk); + expect(male.shoulderF).toBeGreaterThan(female.shoulderF); + }); +}); diff --git a/tools/src/creator/bodyStyle.ts b/tools/src/creator/bodyStyle.ts new file mode 100644 index 0000000..18a6d14 --- /dev/null +++ b/tools/src/creator/bodyStyle.ts @@ -0,0 +1,178 @@ +/** + * Body style — three appearance sliders from ludus (dreamfall-style morphs). + * + * mass −1 lean … +1 heavy + * muscle −1 soft … +1 muscular + * fat 0 base … +1 fat + * + * Maps onto loft physique scalars used by the body + clothing builders. + */ + +import type { BodyType } from "./types.js"; + +export interface BodyStyle { + /** −1 lean … +1 heavy */ + mass: number; + /** −1 soft … +1 muscular */ + muscle: number; + /** 0 base … +1 fat */ + fat: number; +} + +export const BODY_STYLE_DEFAULTS: BodyStyle = Object.freeze({ + mass: 0, + muscle: 0, + fat: 0, +}); + +/** Slider metadata for the creator UI. */ +export const BODY_STYLE_SLIDERS = Object.freeze([ + { + id: "mass" as const, + label: "Mass", + min: -1, + max: 1, + step: 0.01, + left: "Lean", + right: "Heavy", + }, + { + id: "muscle" as const, + label: "Muscle", + min: -1, + max: 1, + step: 0.01, + left: "Soft", + right: "Muscular", + }, + { + id: "fat" as const, + label: "Fat", + min: 0, + max: 1, + step: 0.01, + left: "Base", + right: "Heavy", + }, +]); + +function clamp(v: number, lo: number, hi: number): number { + if (!Number.isFinite(v)) return lo; + return Math.max(lo, Math.min(hi, v)); +} + +/** Normalize a partial body bag. Missing keys become neutral. */ +export function normalizeBodyStyle(raw: unknown): BodyStyle { + if (!raw || typeof raw !== "object") { + return { ...BODY_STYLE_DEFAULTS }; + } + const o = raw as Record; + // Accept packed wire form { m, u, f } as well as full names. + const mass = o.mass ?? o.m; + const muscle = o.muscle ?? o.u; + const fat = o.fat ?? o.f; + return { + mass: clamp(Number(mass ?? 0), -1, 1), + muscle: clamp(Number(muscle ?? 0), -1, 1), + fat: clamp(Number(fat ?? 0), 0, 1), + }; +} + +export function isDefaultBodyStyle(style: BodyStyle): boolean { + const s = normalizeBodyStyle(style); + return s.mass === 0 && s.muscle === 0 && s.fat === 0; +} + +/** + * Ease unit strength so mid-slider stays mild and the last third punches + * harder into caricature (leaner / bulkier / more cut / fatter). + */ +function easeExtreme(t: number): number { + const a = clamp(t, 0, 1); + return a * 0.35 + a * a * 0.25 + a * a * a * 0.4; +} + +function shapedSigned(v: number): number { + if (v === 0) return 0; + return Math.sign(v) * easeExtreme(Math.abs(v)); +} + +/** Multipliers applied to loft half-widths (≈1 at neutral). */ +export interface Physique { + bulk: number; + waistF: number; + shoulderF: number; + chestF: number; + armF: number; + legF: number; + headF: number; + style: BodyStyle; +} + +/** + * Map mass/muscle/fat onto loft physique scalars (ludus physiqueFromBodyStyle). + * Deterministic — no rng jitter so the creator UI is stable. + */ +export function physiqueFromBodyStyle(style: BodyStyle | null | undefined): Physique { + const s = normalizeBodyStyle(style); + const mass = shapedSigned(s.mass); + const muscle = shapedSigned(s.muscle); + const fat = easeExtreme(s.fat); + + // Peak gains at shaped = ±1 — readable under clothing at 240p. + const bulkBase = 1 + mass * 0.32 + fat * 0.26 + muscle * 0.1; + const waistBase = 1 + fat * 0.48 + mass * 0.2 - muscle * 0.18; + const shoulderBase = 1 + muscle * 0.42 + mass * 0.16 + fat * 0.1; + const chestBase = 1 + muscle * 0.28 + mass * 0.14 + fat * 0.18; + const armBase = 1 + muscle * 0.48 + mass * 0.14 + fat * 0.12; + const legBase = 1 + fat * 0.36 + mass * 0.2 + muscle * 0.16; + const headBase = 1 + mass * 0.06 + fat * 0.05; + + return { + bulk: bulkBase, + waistF: Math.max(0.52, waistBase), + shoulderF: Math.max(0.62, shoulderBase), + chestF: Math.max(0.65, chestBase), + armF: Math.max(0.58, armBase), + legF: Math.max(0.6, legBase), + headF: Math.max(0.85, headBase), + style: s, + }; +} + +/** + * Sex base (per sex) × slider multipliers. + * + * These sit near 1.0 on purpose. Breadth differences between the male and + * female bases live in the width tables in `proportions.ts`, which + * `bodyMetrics` reads directly; anything but ~1 here would discount those a + * second time — which is exactly what the earlier 0.78–0.86 "slender" bases + * did, taking a third off every limb. What is left here is the girth + * difference breadth alone doesn't capture: limbs and neck. + */ +export function physiqueFor( + body: BodyType, + style: BodyStyle | null | undefined = null, +): Physique { + const female = body === "female"; + const base = { + bulk: female ? 0.97 : 1.0, + waistF: female ? 0.98 : 1.0, + shoulderF: female ? 0.96 : 1.0, + chestF: female ? 0.98 : 1.0, + armF: female ? 0.9 : 1.0, + legF: female ? 0.95 : 1.0, + headF: female ? 1.01 : 1.0, + }; + const slider = physiqueFromBodyStyle(style); + return { + bulk: base.bulk * slider.bulk, + waistF: base.waistF * slider.waistF, + shoulderF: base.shoulderF * slider.shoulderF, + chestF: base.chestF * slider.chestF, + armF: base.armF * slider.armF, + legF: base.legF * slider.legF, + headF: base.headF * slider.headF, + style: slider.style, + }; +} diff --git a/tools/src/creator/build.ts b/tools/src/creator/build.ts new file mode 100644 index 0000000..5b62a8a --- /dev/null +++ b/tools/src/creator/build.ts @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/** + * Build modular characters from recipes. + * + * pnpm cast # all presets → assets/characters/ + * pnpm creator -- --list # list parts + presets + * pnpm creator -- --id survivor # one recipe + * pnpm creator -- --id survivor --texture # UV + Grok Imagine albedo + * pnpm creator -- --id survivor --texture --force # repaint, replacing the cache + * pnpm creator -- --id survivor --texture --notes "charcoal business suit" + * pnpm creator -- --id survivor --albedo painted.png # import, no Grok + * pnpm creator -- --id survivor --uv-guide # UV guide only (no Grok) + */ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { textureCharacter } from "../texture/textureCharacter.js"; +import { assembleCharacter, formatAssemblyReport } from "./assemble.js"; +import { listParts, PART_DEFINITIONS } from "./parts.js"; +import { CHARACTER_PRESETS, presetById } from "./presets.js"; + +const ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url))); +const OUT_DIR = join(ROOT, "assets", "characters"); + +function parseArgs(argv: string[]) { + const args = { + list: false, + id: undefined as string | undefined, + help: false, + texture: false, + uvGuide: false, + force: false, + albedo: undefined as string | undefined, + notes: undefined as string | undefined, + }; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]!; + if (token === "--") continue; + if (token === "--list" || token === "-l") args.list = true; + else if (token === "--id" || token === "-i") args.id = argv[++i]; + else if (token === "--texture" || token === "-t") args.texture = true; + else if (token === "--uv-guide") args.uvGuide = true; + else if (token === "--force" || token === "-f") args.force = true; + else if (token === "--albedo") args.albedo = argv[++i]; + else if (token === "--notes" || token === "-n") args.notes = argv[++i]; + else if (token === "--help" || token === "-h") args.help = true; + } + return args; +} + +function printList(): void { + console.log("Bodies: male | female\n"); + console.log("Slots & parts:"); + for (const slot of ["hair", "upper", "lower", "feet", "accessory"] as const) { + const parts = listParts(slot); + console.log(` ${slot}`); + for (const part of parts) { + const body = part.body ? ` [${part.body}]` : ""; + console.log(` ${part.id.padEnd(22)} ${part.name}${body}`); + } + } + console.log("\nPresets:"); + for (const preset of CHARACTER_PRESETS) { + console.log(` ${preset.id.padEnd(16)} ${preset.name} (${preset.body})`); + } +} + +async function buildOne( + id: string, + opts: { + texture?: boolean; + uvGuide?: boolean; + force?: boolean; + albedo?: string; + notes?: string; + } = {}, +): Promise { + const preset = presetById(id); + if (!preset) { + console.error(`Unknown preset "${id}". Use --list to see options.`); + process.exit(1); + } + // --notes overrides the preset's wardrobe direction for this run, so prompt + // wording can be iterated on without editing presets.ts + const recipe = opts.notes ? { ...preset, styleNotes: opts.notes } : preset; + + await mkdir(OUT_DIR, { recursive: true }); + + if (opts.texture || opts.uvGuide || opts.albedo) { + // Relative to the shell's cwd, which under `pnpm creator` is tools/, not + // the repo root — so say where we looked rather than leaking a bare ENOENT. + const albedoPath = opts.albedo ? resolve(opts.albedo) : undefined; + const importAlbedo = albedoPath + ? new Uint8Array( + await readFile(albedoPath).catch(() => { + throw new Error(`Cannot read --albedo image at ${albedoPath}`); + }), + ) + : undefined; + const label = importAlbedo + ? "texture, imported" + : opts.uvGuide + ? "uv-guide" + : opts.force + ? "texture, forced" + : "texture"; + console.log(`\n→ ${recipe.name} (${recipe.id}) [${label}]`); + const tex = await textureCharacter(recipe, { + outDir: OUT_DIR, + guideOnly: Boolean(opts.uvGuide && !opts.texture && !importAlbedo), + force: Boolean(opts.force), + ...(importAlbedo ? { importAlbedo } : {}), + }); + const outPath = join(OUT_DIR, `${recipe.id}.glb`); + await writeFile(outPath, tex.glb); + await writeFile( + join(OUT_DIR, `${recipe.id}.recipe.json`), + JSON.stringify({ ...recipe, stats: tex.assembled.stats, textured: tex.textured }, null, 2), + ); + console.log(formatAssemblyReport(tex.assembled)); + console.log(` uv-guide ${tex.guidePath}`); + if (tex.albedoPath) { + const how = tex.imported ? " (imported)" : tex.cached ? " (cached)" : opts.force ? " (regenerated)" : ""; + console.log(` albedo ${tex.albedoPath}${how}`); + } + if (tex.costUsd > 0) console.log(` cost $${tex.costUsd.toFixed(3)}`); + console.log(` output ${outPath} (${(tex.glb.byteLength / 1024).toFixed(1)} KB)${tex.textured ? " textured" : ""}`); + return; + } + + const result = assembleCharacter(recipe); + const outPath = join(OUT_DIR, `${recipe.id}.glb`); + await writeFile(outPath, result.glb); + + await writeFile( + join(OUT_DIR, `${recipe.id}.recipe.json`), + JSON.stringify( + { + ...recipe, + stats: result.stats, + }, + null, + 2, + ), + ); + + console.log(`\n→ ${recipe.name} (${recipe.id})`); + console.log(formatAssemblyReport(result)); + console.log(` output ${outPath} (${(result.glb.byteLength / 1024).toFixed(1)} KB)`); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + console.log(`Usage: + pnpm creator -- --list + pnpm creator -- --id survivor + pnpm creator -- --id survivor --uv-guide # bake UV template PNG only + pnpm creator -- --id survivor --texture # UV + Grok Imagine albedo → textured GLB + pnpm creator -- --id survivor --texture --force # ignore the cached albedo and repaint it + pnpm creator -- --id survivor --texture --notes "charcoal three-piece business suit" + pnpm creator -- --id survivor --albedo painted.png # import an albedo, no Grok involved + pnpm cast # build every preset (vertex colour) +`); + return; + } + + if (args.list) { + printList(); + return; + } + + if (args.id) { + await buildOne(args.id, { + texture: args.texture, + uvGuide: args.uvGuide, + force: args.force, + ...(args.albedo ? { albedo: args.albedo } : {}), + ...(args.notes ? { notes: args.notes } : {}), + }); + return; + } + + // Default: all presets (vertex colour — texturing is opt-in per id). + await mkdir(OUT_DIR, { recursive: true }); + console.log(`Building ${CHARACTER_PRESETS.length} characters → ${OUT_DIR}\n`); + for (const recipe of CHARACTER_PRESETS) { + await buildOne(recipe.id); + } + console.log(`\nDone. ${PART_DEFINITIONS.length} parts available in the catalog.`); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/tools/src/creator/coverage.test.ts b/tools/src/creator/coverage.test.ts new file mode 100644 index 0000000..ed42c94 --- /dev/null +++ b/tools/src/creator/coverage.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { assembleCharacter } from "./assemble.js"; +import { buildBaseBody } from "./body.js"; +import { applyCoverage, PART } from "./coverage.js"; +import { CHARACTER_PRESETS } from "./presets.js"; +import { measurementsFor } from "./proportions.js"; +import { buildSkeleton } from "../rig/skeleton.js"; +import { vertexCount } from "../mesh/MeshBuilder.js"; + +const palette = CHARACTER_PRESETS[0]!.palette; + +function bareMale() { + const skeleton = buildSkeleton(measurementsFor("male"), { height: 1.78 }); + return { skeleton, mesh: buildBaseBody(skeleton, "male", palette, null) }; +} + +describe("body region tags", () => { + it("tags every base body vertex with a region", () => { + const { mesh } = bareMale(); + const n = vertexCount(mesh); + expect(mesh.parts.length).toBe(n); + const untagged = mesh.parts.filter((p) => p === PART.NONE).length; + expect(untagged).toBe(0); + // Both arms, both legs, both feet, plus head / neck / torso. + expect(new Set(mesh.parts).size).toBe(9); + }); + + it("runs t from 0 to 1 along each region", () => { + const { mesh } = bareMale(); + for (const part of [PART.TORSO, PART.ARM_L, PART.LEG_R, PART.HEAD]) { + const ts = mesh.ts.filter((_, i) => mesh.parts[i] === part); + expect(Math.min(...ts)).toBeLessThanOrEqual(0.05); + expect(Math.max(...ts)).toBeGreaterThanOrEqual(0.95); + } + }); +}); + +describe("hiding skin under clothing", () => { + it("leaves the body untouched when nothing is worn", () => { + const { mesh } = bareMale(); + const result = applyCoverage(mesh, []); + expect(result.mesh).toBe(mesh); + expect(result.dropped).toBe(0); + }); + + it("drops enclosed triangles and keeps the rest", () => { + const { mesh } = bareMale(); + const before = mesh.indices.length / 3; + const result = applyCoverage(mesh, [{ part: PART.TORSO, tMin: -0.01, tMax: 0.97 }]); + + expect(result.dropped).toBeGreaterThan(0); + expect(result.kept).toBeGreaterThan(0); + expect(result.kept + result.recessed + result.dropped).toBe(before); + expect(result.mesh.indices.length / 3).toBe(result.kept + result.recessed); + + // Nothing outside the claimed region may be dropped: the head still has to + // be a closed surface after a shirt hides the chest. + const heads = result.mesh.parts.filter((p) => p === PART.HEAD).length; + const headsBefore = mesh.parts.filter((p) => p === PART.HEAD).length; + expect(heads).toBe(headsBefore); + }); + + it("recesses the rim inward rather than dropping it", () => { + const { mesh } = bareMale(); + // A band ending mid-torso: the skin around t=0.5 is the collar of the hole. + const result = applyCoverage(mesh, [{ part: PART.TORSO, tMin: -0.01, tMax: 0.5 }], { + recessDepth: 0.02, + }); + expect(result.recessed).toBeGreaterThan(0); + + // Recessed skin must sit inside the original surface, never outside it. + const radius = (m: typeof mesh, i: number) => + Math.hypot(m.positions[i * 3]!, m.positions[i * 3 + 2]!); + let moved = 0; + for (let i = 0; i < vertexCount(result.mesh); i++) { + if (result.mesh.parts[i] !== PART.TORSO) continue; + const t = result.mesh.ts[i]!; + if (t < 0.35 || t > 0.5) continue; + const original = mesh.ts.findIndex( + (v, j) => mesh.parts[j] === PART.TORSO && Math.abs(v - t) < 1e-6, + ); + if (original < 0) continue; + if (radius(result.mesh, i) < radius(mesh, original) - 1e-4) moved++; + } + expect(moved).toBeGreaterThan(0); + }); + + it("hides several hundred triangles on a fully dressed preset", () => { + const dressed = assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "survivor")!); + expect(dressed.stats.skinHidden).toBeGreaterThan(200); + // The recess exists so openings read as skin, so it must not be empty + // either — a figure with every rim dropped shows hollow cuffs. + expect(dressed.stats.skinRecessed).toBeGreaterThan(0); + }); +}); diff --git a/tools/src/creator/coverage.ts b/tools/src/creator/coverage.ts new file mode 100644 index 0000000..8fc434d --- /dev/null +++ b/tools/src/creator/coverage.ts @@ -0,0 +1,282 @@ +import { + createMesh, + padTags, + vertexCount, + type LoftResult, + type MeshData, +} from "../mesh/MeshBuilder.js"; + +/** + * Hiding skin under clothing. + * + * Ported from Ludus `equipment/hideMasks.js` + `recessField.js`, which in turn + * came from Dreamfall's `bodyHideUnderOutfit`. The idea: don't try to make + * every garment clear the body by padding — padding fails wherever the two + * lofts sample their rings at different heights, and it costs triangles nobody + * ever sees. Instead the garment *claims* the skin it covers, and that skin is + * deleted. The outer mesh then owns the form outright. + * + * Three classes, in Ludus's precedence order: + * + * DROP triangle fully enclosed by a garment — removed from the body + * RECESS triangle covered but within a rim of an opening (collar, cuff, + * hem) — kept, but pushed inward so it reads as skin *inside* the + * opening rather than z-fighting the cloth + * KEEP nothing covers it + * + * Limbs drop rather than recess: a recess deep enough to read on a torso + * punches straight through the far side of a forearm. + * + * Coverage is a claim that nothing shows through. Only closed shells belong in + * a coverage list — an open or banded piece that claims skin leaves you looking + * at the inside of the character. + */ + +export const PART = { + NONE: 0, + HEAD: 1, + NECK: 2, + TORSO: 3, + ARM_L: 4, + ARM_R: 5, + LEG_L: 6, + LEG_R: 7, + FOOT_L: 8, + FOOT_R: 9, +} as const; + +export type BodyPart = (typeof PART)[keyof typeof PART]; + +/** Limbs drop outright; only the torso and head recess. */ +const LIMB_PARTS = new Set([ + PART.ARM_L, + PART.ARM_R, + PART.LEG_L, + PART.LEG_R, + PART.FOOT_L, + PART.FOOT_R, +]); + +/** A band of one body region's loft, `tMin`..`tMax` in loft parameter. */ +export interface CoverageBand { + part: BodyPart; + tMin: number; + tMax: number; +} + +export const CLASS = { KEEP: 0, RECESS: 1, DROP: 2 } as const; + +/** Tag a loft's vertices: `t` runs 0 at the first ring to 1 at the last. */ +export function tagLoft(mesh: MeshData, result: LoftResult, part: BodyPart): void { + const { firstVertex, rings, sides } = result; + if (rings < 1 || sides < 1) return; + padTags(mesh, firstVertex); + const span = Math.max(1, rings - 1); + for (let ring = 0; ring < rings; ring++) { + const t = ring / span; + for (let s = 0; s < sides; s++) { + const v = firstVertex + ring * sides + s; + mesh.parts[v] = part; + mesh.ts[v] = t; + } + } +} + +/** Tag everything added since `from` with one region and a fixed parameter. */ +export function tagRange(mesh: MeshData, from: number, part: BodyPart, t: number): void { + padTags(mesh, from); + for (let v = from; v < vertexCount(mesh); v++) { + mesh.parts[v] = part; + mesh.ts[v] = t; + } +} + +export interface CoverageOptions { + /** + * How close to the edge of coverage a vertex must be to recess instead of + * drop, in loft parameter. Roughly one ring on the torso. + */ + rimBand?: number; + /** How far inward recessed skin is pushed, in metres. */ + recessDepth?: number; + /** Graph-distance rings over which the recess ramps up from the seam. */ + recessRings?: number; +} + +export interface CoverageResult { + mesh: MeshData; + kept: number; + recessed: number; + dropped: number; +} + +/** + * Remove the skin a garment set covers, and recess what sits inside its + * openings. Returns a fresh mesh; the input is untouched. + */ +export function applyCoverage( + body: MeshData, + bands: CoverageBand[], + options: CoverageOptions = {}, +): CoverageResult { + const triangles = body.indices.length / 3; + if (bands.length === 0) { + return { mesh: body, kept: triangles, recessed: 0, dropped: 0 }; + } + + const rimBand = options.rimBand ?? 0.09; + const recessDepth = options.recessDepth ?? 0.014; + const recessRings = options.recessRings ?? 2; + + const n = vertexCount(body); + padTags(body, n); + + const covers = (part: number, t: number): boolean => { + for (const b of bands) if (part === b.part && t >= b.tMin && t <= b.tMax) return true; + return false; + }; + + const covered = new Uint8Array(n); + const nearRim = new Uint8Array(n); + const isLimb = new Uint8Array(n); + for (let i = 0; i < n; i++) { + const part = body.parts[i]!; + const t = body.ts[i]!; + isLimb[i] = LIMB_PARTS.has(part) ? 1 : 0; + if (part === PART.NONE || !covers(part, t)) continue; + covered[i] = 1; + // Just inside an opening: coverage would lapse a rim's width away. + if (!covers(part, t - rimBand) || !covers(part, t + rimBand)) nearRim[i] = 1; + } + + const classes = new Uint8Array(triangles); + for (let t = 0; t < triangles; t++) { + const a = body.indices[t * 3]!; + const b = body.indices[t * 3 + 1]!; + const c = body.indices[t * 3 + 2]!; + const cov = covered[a]! + covered[b]! + covered[c]!; + if (cov === 0) { + classes[t] = CLASS.KEEP; + continue; + } + const rim = nearRim[a]! || nearRim[b]! || nearRim[c]!; + const limb = isLimb[a]! || isLimb[b]! || isLimb[c]!; + if (cov === 3 && !rim) classes[t] = CLASS.DROP; + else if (limb && !rim) classes[t] = CLASS.DROP; + else classes[t] = CLASS.RECESS; + } + + // Vertices touched by kept geometry pin the recess to depth 0, so the + // recessed sheet stays welded to the visible body instead of floating inside + // the collar as a detached shell. + const onKept = new Uint8Array(n); + const inRecess = new Uint8Array(n); + for (let t = 0; t < triangles; t++) { + const target = classes[t] === CLASS.KEEP ? onKept : classes[t] === CLASS.RECESS ? inRecess : null; + if (!target) continue; + target[body.indices[t * 3]!] = 1; + target[body.indices[t * 3 + 1]!] = 1; + target[body.indices[t * 3 + 2]!] = 1; + } + + const depth = recessField(body, inRecess, onKept, recessDepth, recessRings); + + const out = createMesh(); + const remap = new Int32Array(n).fill(-1); + const emit = (v: number): number => { + const existing = remap[v]!; + if (existing >= 0) return existing; + const index = vertexCount(out); + const d = depth[v]!; + out.positions.push( + body.positions[v * 3]! - body.normals[v * 3]! * d, + body.positions[v * 3 + 1]! - body.normals[v * 3 + 1]! * d, + body.positions[v * 3 + 2]! - body.normals[v * 3 + 2]! * d, + ); + out.normals.push(body.normals[v * 3]!, body.normals[v * 3 + 1]!, body.normals[v * 3 + 2]!); + out.colors.push( + body.colors[v * 4]!, + body.colors[v * 4 + 1]!, + body.colors[v * 4 + 2]!, + body.colors[v * 4 + 3]!, + ); + if (body.uvs.length > 0) out.uvs.push(body.uvs[v * 2] ?? 0, body.uvs[v * 2 + 1] ?? 0); + out.parts.push(body.parts[v]!); + out.ts.push(body.ts[v]!); + remap[v] = index; + return index; + }; + + let kept = 0; + let recessed = 0; + for (let t = 0; t < triangles; t++) { + if (classes[t] === CLASS.DROP) continue; + if (classes[t] === CLASS.KEEP) kept++; + else recessed++; + out.indices.push( + emit(body.indices[t * 3]!), + emit(body.indices[t * 3 + 1]!), + emit(body.indices[t * 3 + 2]!), + ); + } + + return { mesh: out, kept, recessed, dropped: triangles - kept - recessed }; +} + +/** + * Per-vertex recess depth: 0 on any vertex shared with kept surface, ramping to + * full depth over `rings` steps of graph distance. A constant offset instead of + * a ramp gives either a hard step at the collar or a detached sheet. + * + * Graph distance over mesh edges, not geodesic — at this triangle count the + * difference is invisible. + */ +function recessField( + mesh: MeshData, + inRecess: Uint8Array, + boundary: Uint8Array, + depth: number, + rings: number, +): Float32Array { + const n = vertexCount(mesh); + const field = new Float32Array(n); + if (depth <= 0) return field; + + const neighbours: number[][] = Array.from({ length: n }, () => []); + for (let t = 0; t < mesh.indices.length; t += 3) { + const a = mesh.indices[t]!; + const b = mesh.indices[t + 1]!; + const c = mesh.indices[t + 2]!; + neighbours[a]!.push(b, c); + neighbours[b]!.push(a, c); + neighbours[c]!.push(a, b); + } + + const dist = new Int32Array(n).fill(-1); + const queue: number[] = []; + for (let i = 0; i < n; i++) { + if (boundary[i]) { + dist[i] = 0; + queue.push(i); + } + } + for (let head = 0; head < queue.length; head++) { + const v = queue[head]!; + const d = dist[v]!; + if (d >= rings) continue; + for (const nb of neighbours[v]!) { + if (dist[nb] !== -1) continue; + dist[nb] = d + 1; + queue.push(nb); + } + } + + for (let i = 0; i < n; i++) { + if (!inRecess[i]) continue; + // Unreached vertices sit deep inside the covered area: full depth. + const d = dist[i]!; + const u = d === -1 ? 1 : Math.min(1, d / rings); + field[i] = depth * (u * u * (3 - 2 * u)); + } + return field; +} diff --git a/tools/src/creator/headStyle.test.ts b/tools/src/creator/headStyle.test.ts new file mode 100644 index 0000000..2309105 --- /dev/null +++ b/tools/src/creator/headStyle.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + craniumFor, + HEAD_STYLE_DEFAULTS, + isDefaultHeadStyle, + normalizeHeadStyle, +} from "./headStyle.js"; + +describe("headStyle", () => { + it("normalizes missing / packed values", () => { + expect(normalizeHeadStyle(null)).toEqual(HEAD_STYLE_DEFAULTS); + expect(normalizeHeadStyle({ l: 0.5, j: -0.25, b: 0.1 })).toEqual({ + length: 0.5, + jaw: -0.25, + brow: 0.1, + }); + expect(normalizeHeadStyle({ length: 2, jaw: -3, brow: 9 })).toEqual({ + length: 1, + jaw: -1, + brow: 1, + }); + expect(isDefaultHeadStyle(normalizeHeadStyle({}))).toBe(true); + }); + + it("gives the sexes different faces at neutral sliders", () => { + const male = craniumFor("male"); + const female = craniumFor("female"); + + // The taper is the tell: female cheekbones carry more of the width while + // the jaw and chin narrow under them. + expect(female.jawW).toBeLessThan(male.jawW); + expect(female.chinW).toBeLessThan(male.chinW); + expect(female.cheekW).toBeGreaterThan(male.cheekW); + // Softer profile: little brow ridge, a more vertical forehead, smaller nose. + expect(female.browOut).toBeLessThan(male.browOut); + expect(female.foreheadSlope).toBeLessThan(male.foreheadSlope); + expect(female.noseLen).toBeLessThan(male.noseLen); + }); + + it("trades width against length so a long face doesn't just grow", () => { + const round = craniumFor("male", { length: -1, jaw: 0, brow: 0 }); + const long = craniumFor("male", { length: 1, jaw: 0, brow: 0 }); + + expect(long.faceLengthF).toBeGreaterThan(round.faceLengthF); + expect(long.widthF).toBeLessThan(round.widthF); + }); + + it("squares the lower face on the jaw slider", () => { + const tapered = craniumFor("male", { length: 0, jaw: -1, brow: 0 }); + const square = craniumFor("male", { length: 0, jaw: 1, brow: 0 }); + + expect(square.jawW).toBeGreaterThan(tapered.jawW); + expect(square.chinW).toBeGreaterThan(tapered.chinW); + }); + + it("flattens the brow ridge to nothing at the soft end", () => { + const soft = craniumFor("male", { length: 0, jaw: 0, brow: -1 }); + const heavy = craniumFor("male", { length: 0, jaw: 0, brow: 1 }); + + expect(heavy.browOut).toBeGreaterThan(soft.browOut); + expect(heavy.noseOut).toBeGreaterThan(soft.noseOut); + // Drives the depth of the brow *ring* in the head loft, so it has to stay + // non-negative — a negative would invert the ring through the skull. + expect(soft.browOut).toBeLessThan(0.15); + expect(soft.browOut).toBeGreaterThanOrEqual(0); + }); + + it("keeps the sliders stacked on the sex base, not replacing it", () => { + const male = craniumFor("male", { length: 0, jaw: 1, brow: 0 }); + const female = craniumFor("female", { length: 0, jaw: 1, brow: 0 }); + // A square-jawed woman still has a narrower jaw than a square-jawed man. + expect(female.jawW).toBeLessThan(male.jawW); + }); +}); diff --git a/tools/src/creator/headStyle.ts b/tools/src/creator/headStyle.ts new file mode 100644 index 0000000..dba0ec3 --- /dev/null +++ b/tools/src/creator/headStyle.ts @@ -0,0 +1,239 @@ +/** + * Head style — three appearance sliders, the face's answer to `bodyStyle.ts`. + * + * length −1 round/short … +1 long/narrow + * jaw −1 tapered … +1 square/heavy + * brow −1 soft/flat … +1 heavy brow, strong nose + * + * Three axes are enough because a PSX head has three things a viewer can read + * at 240p: the skull's proportion, the shape of the lower face, and the + * profile. Eye colour and freckles are the texture's job; these move geometry. + * + * The sliders sit on top of a per-sex face base — see {@link craniumFor}. That + * base is where the male and female heads actually differ, so a neutral + * character of each sex already reads as a different person rather than the + * same skull scaled by a couple of percent. + */ + +import type { BodyType } from "./types.js"; + +export interface HeadStyle { + /** −1 round & short … +1 long & narrow */ + length: number; + /** −1 tapered chin … +1 square, heavy jaw */ + jaw: number; + /** −1 smooth profile … +1 heavy brow and nose */ + brow: number; +} + +export const HEAD_STYLE_DEFAULTS: HeadStyle = Object.freeze({ + length: 0, + jaw: 0, + brow: 0, +}); + +/** Slider metadata for the creator UI. */ +export const HEAD_STYLE_SLIDERS = Object.freeze([ + { + id: "length" as const, + label: "Face length", + min: -1, + max: 1, + step: 0.01, + left: "Round", + right: "Long", + }, + { + id: "jaw" as const, + label: "Jaw", + min: -1, + max: 1, + step: 0.01, + left: "Tapered", + right: "Square", + }, + { + id: "brow" as const, + label: "Brow", + min: -1, + max: 1, + step: 0.01, + left: "Soft", + right: "Heavy", + }, +]); + +function clamp(v: number, lo: number, hi: number): number { + if (!Number.isFinite(v)) return 0; + return Math.max(lo, Math.min(hi, v)); +} + +/** Normalize a partial head bag. Missing keys become neutral. */ +export function normalizeHeadStyle(raw: unknown): HeadStyle { + if (!raw || typeof raw !== "object") { + return { ...HEAD_STYLE_DEFAULTS }; + } + const o = raw as Record; + // Accept packed wire form { l, j, b } as well as full names. + const length = o.length ?? o.l; + const jaw = o.jaw ?? o.j; + const brow = o.brow ?? o.b; + return { + length: clamp(Number(length ?? 0), -1, 1), + jaw: clamp(Number(jaw ?? 0), -1, 1), + brow: clamp(Number(brow ?? 0), -1, 1), + }; +} + +export function isDefaultHeadStyle(style: HeadStyle): boolean { + const s = normalizeHeadStyle(style); + return s.length === 0 && s.jaw === 0 && s.brow === 0; +} + +/** + * Ease unit strength so mid-slider stays mild and the last third pushes into + * caricature. Same curve as the body sliders, so the two panels feel alike. + */ +function easeExtreme(t: number): number { + const a = clamp(t, 0, 1); + return a * 0.35 + a * a * 0.25 + a * a * a * 0.4; +} + +function shapedSigned(v: number): number { + if (v === 0) return 0; + return Math.sign(v) * easeExtreme(Math.abs(v)); +} + +/** + * Skull shape scalars. Widths are fractions of the head's half-width, so + * `jawW: 0.92` is a jaw ring 92% as wide as the cheekbones; the rest are + * multipliers on the base head dimensions (≈1 on the male base). + */ +export interface Cranium { + /** Multipliers on head half-width / half-depth / chin-to-crown height. */ + widthF: number; + depthF: number; + faceLengthF: number; + + /** Chin ring: breadth, depth, and how far it juts forward. */ + chinW: number; + chinD: number; + chinOut: number; + + /** Jaw-angle ring — the widest point of the lower face. */ + jawW: number; + jawD: number; + + /** Cheekbone breadth, the head's widest ring. */ + cheekW: number; + + /** Brow ridge size and projection. Near 0 = no ridge; the bar is dropped. */ + browOut: number; + + /** 0 = vertical forehead … 1+ = cranium drifting back over the occiput. */ + foreheadSlope: number; + + /** Nose length (down), projection (forward) and breadth (across). */ + noseLen: number; + noseOut: number; + noseW: number; + + /** Ear scale. */ + earSize: number; + + style: HeadStyle; +} + +/** + * The sex face bases. + * + * Male numbers reproduce the head `body.ts` drew before the sliders existed, so + * a default male is unchanged. The female base is the actual edit: cheekbones + * carry the width while the jaw and chin narrow under them (the tapered read), + * the brow ridge nearly disappears, the forehead stands up rather than sloping + * back, and the nose comes down in all three axes. + */ +function faceBase(body: BodyType): Omit { + if (body === "female") { + return { + widthF: 0.985, + depthF: 0.965, + faceLengthF: 0.955, + chinW: 0.42, + chinD: 0.58, + chinOut: 0.28, + jawW: 0.83, + jawD: 0.86, + cheekW: 1.03, + browOut: 0.4, + foreheadSlope: 0.55, + noseLen: 0.86, + noseOut: 0.84, + noseW: 0.85, + earSize: 0.92, + }; + } + return { + widthF: 1, + depthF: 1, + faceLengthF: 1, + chinW: 0.5, + chinD: 0.62, + chinOut: 0.3, + jawW: 0.92, + jawD: 0.9, + cheekW: 1, + browOut: 1, + foreheadSlope: 1, + noseLen: 1, + noseOut: 1, + noseW: 1, + earSize: 1, + }; +} + +/** + * Sex face base × slider multipliers. + * + * Peak gains are tuned to stay inside a believable adult skull: the head never + * changes size much (that is the body's `headF`), it changes *proportion*. + */ +export function craniumFor( + body: BodyType, + style: HeadStyle | null | undefined = null, +): Cranium { + const s = normalizeHeadStyle(style); + const len = shapedSigned(s.length); + const jaw = shapedSigned(s.jaw); + const brow = shapedSigned(s.brow); + const base = faceBase(body); + + return { + // A long face is also a narrow one — otherwise the head just grows. + widthF: base.widthF * (1 - len * 0.075), + depthF: base.depthF * (1 + len * 0.045), + faceLengthF: base.faceLengthF * (1 + len * 0.17), + + chinW: base.chinW * (1 + jaw * 0.44), + chinD: base.chinD * (1 + jaw * 0.14), + chinOut: base.chinOut * (1 + jaw * 0.18), + + jawW: base.jawW * (1 + jaw * 0.19), + jawD: base.jawD * (1 + jaw * 0.12), + + // Cheeks widen slightly with the jaw and narrow with a long face, so the + // taper from cheekbone to chin stays legible at both ends of both sliders. + cheekW: base.cheekW * (1 + jaw * 0.04) * (1 - len * 0.04), + + browOut: Math.max(0, base.browOut * (1 + brow * 0.95)), + foreheadSlope: Math.max(0, base.foreheadSlope * (1 + brow * 0.45)), + + noseLen: base.noseLen * (1 + brow * 0.24), + noseOut: base.noseOut * (1 + brow * 0.34), + noseW: base.noseW * (1 + brow * 0.16), + + earSize: base.earSize, + + style: s, + }; +} diff --git a/tools/src/creator/metrics.ts b/tools/src/creator/metrics.ts new file mode 100644 index 0000000..69c8810 --- /dev/null +++ b/tools/src/creator/metrics.ts @@ -0,0 +1,436 @@ +import type { CanonicalBone } from "@psx/shared"; +import { jointWorld, type Skeleton } from "../rig/skeleton.js"; +import type { LoftKey, Vec3Tuple } from "../mesh/MeshBuilder.js"; +import { physiqueFor, type BodyStyle, type Physique } from "./bodyStyle.js"; +import { craniumFor, type Cranium, type HeadStyle } from "./headStyle.js"; +import { measurementsFor } from "./proportions.js"; +import type { BodyType } from "./types.js"; + +/** + * Every dimension of a figure, in metres, derived once. + * + * Before this existed, `body.ts` and `parts.ts` each carried their own magic + * half-widths scaled off `height / 1.72`, and clothing fitted the body only + * because both files had been nudged in tandem. Sizes now come from one place: + * skin builds at the metrics, garments build at `padded(metrics, thickness)`. + * A jacket is the body plus 12 mm of cloth, which is what a jacket is. + * + * Widths come from {@link measurementsFor} (fractions of height); depths and + * the offsets a front-view photo cannot show — how far the glutes sit aft of + * the hip axis, how far the calf sits behind the shin — are anatomical + * constants applied here. + */ + +/** Elliptical cross-section: half-width across the ring, half-depth through it. */ +export interface Section { + rx: number; + rz: number; +} + +const sec = (rx: number, rz: number): Section => ({ rx, rz }); + +export interface BodyMetrics { + height: number; + female: boolean; + physique: Physique; + + /** Joint lookup, so callers don't re-import the skeleton helpers. */ + at: (bone: CanonicalBone) => Vec3Tuple; + + // --- torso levels (world Y) --- + crotchY: number; + gluteY: number; + hipY: number; + waistY: number; + ribY: number; + chestY: number; + girdleY: number; + collarY: number; + neckTopY: number; + + // --- torso sections --- + crotch: Section; + glute: Section; + hip: Section; + waist: Section; + rib: Section; + chest: Section; + girdle: Section; + collar: Section; + neck: Section; + + /** Z offsets from the spine line: glutes aft, belly and sternum forward. */ + buttZ: number; + bellyZ: number; + chestZ: number; + + /** Breast form (female). `out` is projection past the chest wall. */ + bust: { r: number; x: number; y: number; z: number; out: number }; + /** Pectoral shelf (male) — a flat pad, not a muscle. */ + pec: { rx: number; rz: number; x: number; y: number; z: number }; + + // --- arm --- + clavX: number; + socketX: number; + deltoidR: number; + upperArm: Section; + elbow: Section; + foreArm: Section; + wrist: Section; + /** Palm: rx is thickness (knuckle to palm), rz is breadth. */ + palm: Section; + handLength: number; + thumbR: number; + + // --- leg --- + hipSocket: Section; + thigh: Section; + knee: Section; + calf: Section; + ankle: Section; + /** Calf mass sits behind the shin axis. */ + calfZ: number; + + // --- foot --- + footHalfW: number; + footLength: number; + heelBack: number; + ankleHeight: number; + + // --- head --- + headHalfW: number; + headHalfD: number; + chinY: number; + jawY: number; + mouthY: number; + browY: number; + crownY: number; + headZ: number; + /** + * Face shape scalars — sex base × head sliders. The dimensions above already + * have the size terms baked in; this carries the *proportions* the head loft + * needs (how wide the jaw is against the cheekbones, how far the brow juts). + */ + cranium: Cranium; +} + +export function bodyMetrics( + skeleton: Skeleton, + body: BodyType, + bodyStyle: BodyStyle | null = null, + headStyle: HeadStyle | null = null, +): BodyMetrics { + const h = skeleton.height; + const female = body === "female"; + const phy = physiqueFor(body, bodyStyle); + const cranium = craniumFor(body, headStyle); + const { bulk, waistF, shoulderF, chestF, armF, legF, headF } = phy; + const w = measurementsFor(body).widths; + const at = (bone: CanonicalBone): Vec3Tuple => jointWorld(skeleton, bone); + + // Half-breadths in metres before physique. + const halfShoulder = (w.shoulder / 2) * h; + const halfChest = (w.chest / 2) * h; + const halfWaist = (w.waist / 2) * h; + const halfHip = (w.hip / 2) * h; + const halfHead = (w.head / 2) * h; + const halfNeck = (w.neck / 2) * h; + + const hips = at("Hips"); + const spine = at("Spine"); + const spine1 = at("Spine1"); + const spine2 = at("Spine2"); + const neckJoint = at("Neck"); + const headJoint = at("Head"); + const shoulderJoint = at("LeftArm"); + + // Levels. The skeleton already carries the landmark heights; these name the + // ones the loft needs and that no bone sits on. + const crotchY = hips[1] - h * (female ? 0.033 : 0.031); + const gluteY = hips[1] - h * 0.012; + const hipY = hips[1] + h * 0.012; + const waistY = spine[1] + (spine1[1] - spine[1]) * 0.35; + const ribY = spine1[1] + (spine2[1] - spine1[1]) * 0.45; + const chestY = spine2[1] - h * 0.005; + // The Neck bone is C7 — the base of the neck, level with the shoulders — so + // the collar ring sits *above* the girdle and the trapezius slopes between. + const girdleY = shoulderJoint[1]; + const collarY = neckJoint[1] + h * 0.008; + + // Head size and proportion, needed here because the neck ends inside the jaw. + const headHalfW = halfHead * headF * cranium.widthF; + const chinY = h - h * 0.132 * headF * cranium.faceLengthF; + // Root ring of the head loft — under the jaw, buried in the neck. + const headBottomY = chinY - headHalfW * 0.16; + + // The neck stops just inside that ring. Pinning it to the Head joint instead + // (as this did before the face sliders) leaves it fixed while a round face + // lifts the chin: past about −0.5 on the length slider the head floats off + // and the neck's end cap shows as a disk under a detached jaw. + const neckTopY = headBottomY + h * 0.011; + + // Trunk at shoulder level stops short of the acromion; the deltoids carry + // the silhouette out the rest of the way. + const girdleRx = halfShoulder * 0.78 * bulk * shoulderF; + const deltoidR = h * 0.031 * Math.sqrt(bulk) * (0.72 + 0.34 * armF); + + const chestRx = halfChest * bulk * chestF; + const waistRx = halfWaist * bulk * waistF; + const hipRx = halfHip * bulk * (female ? 1 : 0.98); + + return { + height: h, + female, + physique: phy, + at, + + crotchY, + gluteY, + hipY, + waistY, + ribY, + chestY, + girdleY, + collarY, + neckTopY, + + // The crotch ring is the saddle between the thighs, not a hip section. + crotch: sec(hipRx * 0.4, hipRx * 0.5), + glute: sec(hipRx * 0.94, hipRx * (female ? 0.86 : 0.8)), + hip: sec(hipRx, hipRx * 0.78), + waist: sec(waistRx, waistRx * 0.76), + rib: sec(chestRx * 0.93, chestRx * 0.74), + chest: sec(chestRx, chestRx * (female ? 0.76 : 0.79)), + girdle: sec(girdleRx, girdleRx * 0.62), + // Tight to the neck: this is the torso's top ring, and its end cap is only + // hidden if the neck loft is wider than it at that height. + collar: sec(halfNeck * 1.12 * bulk, halfNeck * 1.24 * bulk), + neck: sec(halfNeck * bulk, halfNeck * 1.14 * bulk), + + buttZ: -h * (female ? 0.02 : 0.016), + bellyZ: h * (0.004 + phy.style.fat * 0.016), + chestZ: h * 0.008, + + bust: { + r: h * (female ? 0.032 : 0) * (0.88 + 0.24 * chestF), + x: halfChest * 0.46, + y: chestY + h * 0.006, + z: spine2[2] + chestRx * 0.6, + out: h * 0.026, + }, + // Only emerges past the chest wall on a muscular slider; at rest it stays + // buried and the chest keeps its flat PSX plane. + pec: { + rx: chestRx * 0.56, + rz: h * 0.03 * Math.max(0, phy.style.muscle), + x: chestRx * 0.42, + y: chestY + h * 0.016, + z: spine2[2] + chestRx * 0.66, + }, + + clavX: halfShoulder * 0.3, + socketX: Math.abs(shoulderJoint[0]), + deltoidR, + upperArm: sec((w.upperArm / 2) * h * armF * bulk, (w.upperArm / 2) * h * armF * bulk * 0.94), + elbow: sec((w.forearm / 2) * h * armF * bulk * 1.02, (w.forearm / 2) * h * armF * bulk * 0.98), + foreArm: sec((w.forearm / 2) * h * armF * bulk, (w.forearm / 2) * h * armF * bulk * 0.9), + wrist: sec(h * 0.016 * armF, h * 0.02 * armF), + palm: sec(h * 0.011 * armF, h * 0.026 * armF), + handLength: h * 0.095, + thumbR: h * 0.011, + + hipSocket: sec(hipRx * 0.58, hipRx * 0.6), + thigh: sec((w.thigh / 2) * h * legF * bulk, (w.thigh / 2) * h * legF * bulk * 0.92), + knee: sec(h * 0.031 * legF, h * 0.033 * legF), + calf: sec((w.calf / 2) * h * legF * bulk, (w.calf / 2) * h * legF * bulk * 1.08), + ankle: sec(h * 0.021 * legF, h * 0.026 * legF), + calfZ: -h * 0.009, + + footHalfW: (w.foot / 2) * h, + footLength: h * 0.152, + heelBack: h * 0.038, + ankleHeight: h * 0.045, + + // Size comes from the body (`headF`), proportion from the face sliders. + // Levels are measured down from the crown, so a longer face drops the chin + // rather than lifting the skull off the neck. + headHalfW, + headHalfD: halfHead * headF * cranium.depthF * 1.26, + chinY, + jawY: h - h * 0.105 * headF * cranium.faceLengthF, + mouthY: h - h * 0.098 * headF * cranium.faceLengthF, + browY: h - h * 0.056 * headF * cranium.faceLengthF, + crownY: h, + headZ: headJoint[2], + cranium, + }; +} + +/** + * The torso ring plan, crotch (t=0) to collar (t=1). + * + * Shared by the skin and by every torso garment. A garment that invents its own + * key list will sooner or later skip a section the body has — the labcoat that + * went hem → hip skipped the glutes, and the trousers underneath poked through + * the back of it. Take the body's plan, grow it by cloth, cut it at the hem. + * + * The `t` values here are the coverage parameters garments cite in + * `parts.ts`: waist 0.4, chest 0.7, girdle 0.87. + */ +export function torsoPlan(m: BodyMetrics): LoftKey[] { + const h = m.height; + const hips = m.at("Hips"); + const spine = m.at("Spine"); + const spine1 = m.at("Spine1"); + const spine2 = m.at("Spine2"); + const neck = m.at("Neck"); + + return [ + // Saddle between the thighs. Narrow and tucked forward so the crotch closes + // instead of showing daylight between two leg tubes. + { t: 0.0, c: [0, m.crotchY, hips[2] + h * 0.008], rx: m.crotch.rx, rz: m.crotch.rz }, + // Glutes — the widest part of the figure from behind, set aft of the axis. + { t: 0.08, c: [0, m.gluteY, hips[2] + m.buttZ], rx: m.glute.rx, rz: m.glute.rz }, + // Hip shelf / iliac crest. + { t: 0.17, c: [0, m.hipY, hips[2] + m.buttZ * 0.4], rx: m.hip.rx, rz: m.hip.rz }, + // Lower abdomen. + { + t: 0.28, + c: [0, (m.hipY + m.waistY) * 0.5, spine[2] + m.bellyZ], + rx: (m.hip.rx + m.waist.rx) * 0.5, + rz: (m.hip.rz + m.waist.rz) * 0.52, + }, + // Waist — narrowest, and on the female base the whole silhouette turns here. + { t: 0.4, c: [0, m.waistY, spine[2] + m.bellyZ * 0.8], rx: m.waist.rx, rz: m.waist.rz }, + // Lower ribs. + { t: 0.55, c: [0, m.ribY, spine1[2] + m.chestZ * 0.7], rx: m.rib.rx, rz: m.rib.rz }, + // Chest at nipple line. + { t: 0.7, c: [0, m.chestY, spine2[2] + m.chestZ], rx: m.chest.rx, rz: m.chest.rz }, + // Clavicle shelf: flattens front-to-back before the shoulders. + { + t: 0.83, + c: [0, m.chestY + (m.girdleY - m.chestY) * 0.72, spine2[2] + m.chestZ * 0.6], + rx: (m.chest.rx + m.girdle.rx) * 0.5, + rz: (m.chest.rz + m.girdle.rz) * 0.5, + }, + // Shoulder girdle — trunk only; the deltoids extend past this. + { t: 0.87, c: [0, m.girdleY, neck[2] + h * 0.004], rx: m.girdle.rx, rz: m.girdle.rz }, + // Trapezius. Two keys close together, or the slope from acromion to neck + // reads as one hard step — a square coat hanger instead of a shoulder. + { + t: 0.94, + c: [0, m.girdleY + (m.collarY - m.girdleY) * 0.45, neck[2] + h * 0.005], + rx: m.girdle.rx * 0.7, + rz: m.girdle.rz * 0.82, + }, + { t: 1.0, c: [0, m.collarY, neck[2] + h * 0.006], rx: m.collar.rx, rz: m.collar.rz }, + ]; +} + +/** + * Cut a plan off below `hemY`, replacing everything under it with one ring + * interpolated at that height. `t` is renormalised so the result still runs + * 0..1 — coverage bands are quoted against the *body's* plan, so they are read + * before this is applied. + */ +export function cutBelow(keys: LoftKey[], hemY: number): LoftKey[] { + const above = keys.filter((k) => k.c[1] > hemY); + if (above.length === keys.length || above.length < 2) return keys; + + const first = above[0]!; + const below = keys[keys.length - above.length - 1]!; + const span = first.c[1] - below.c[1]; + const f = span > 1e-6 ? (hemY - below.c[1]) / span : 0; + const mix = (a: number, b: number) => a + (b - a) * f; + const hem: LoftKey = { + t: 0, + c: [0, hemY, mix(below.c[2], first.c[2])], + rx: mix(below.rx, first.rx), + rz: mix(below.rz, first.rz), + }; + + const t0 = first.t; + return [ + hem, + ...above.map((k) => ({ ...k, t: (k.t - t0) / (1 - t0) })), + ]; +} + +/** + * Replace the bottom of a plan with an open, flared hem. + * + * A coat is not a body: taking the torso plan all the way down inherits the + * crotch saddle, and the coat tapers to a point between the legs instead of + * hanging as a tube. Cut at the hips, then drop one wide ring to the hem. + */ +export function withHem( + keys: LoftKey[], + hemY: number, + hem: Section, + hemZ: number, + share = 0.2, +): LoftKey[] { + const above = keys.filter((k) => k.c[1] > hemY); + if (above.length < 2) return keys; + const t0 = above[0]!.t; + const rescale = (t: number) => share + ((t - t0) / (1 - t0)) * (1 - share); + return [ + { t: 0, c: [0, hemY, hemZ], rx: hem.rx, rz: hem.rz }, + ...above.map((k) => ({ ...k, t: rescale(k.t) })), + ]; +} + +/** As {@link cutBelow}, from the other end: keep everything under `waistY`. */ +export function cutAbove(keys: LoftKey[], waistY: number): LoftKey[] { + const below = keys.filter((k) => k.c[1] < waistY); + if (below.length === keys.length || below.length < 2) return keys; + + const last = below[below.length - 1]!; + const above = keys[below.length]!; + const span = above.c[1] - last.c[1]; + const f = span > 1e-6 ? (waistY - last.c[1]) / span : 0; + const mix = (a: number, b: number) => a + (b - a) * f; + const band: LoftKey = { + t: 1, + c: [0, waistY, mix(last.c[2], above.c[2])], + rx: mix(last.rx, above.rx), + rz: mix(last.rz, above.rz), + }; + + const t1 = last.t + (above.t - last.t) * f; + return [...below.map((k) => ({ ...k, t: k.t / t1 })), band]; +} + +/** + * The same figure, grown by `pad` metres of cloth. + * + * Garments loft on these so they clear the skin by a known thickness instead of + * a percentage — a 12 mm jacket is 12 mm at every scale, and a heavy coat over + * a heavy body still clears it. + */ +export function padded(metrics: BodyMetrics, pad: number): BodyMetrics { + const grow = (s: Section): Section => sec(s.rx + pad, s.rz + pad); + return { + ...metrics, + crotch: grow(metrics.crotch), + glute: grow(metrics.glute), + hip: grow(metrics.hip), + waist: grow(metrics.waist), + rib: grow(metrics.rib), + chest: grow(metrics.chest), + girdle: grow(metrics.girdle), + collar: grow(metrics.collar), + neck: grow(metrics.neck), + deltoidR: metrics.deltoidR + pad, + upperArm: grow(metrics.upperArm), + elbow: grow(metrics.elbow), + foreArm: grow(metrics.foreArm), + wrist: grow(metrics.wrist), + hipSocket: grow(metrics.hipSocket), + thigh: grow(metrics.thigh), + knee: grow(metrics.knee), + calf: grow(metrics.calf), + ankle: grow(metrics.ankle), + footHalfW: metrics.footHalfW + pad, + }; +} diff --git a/tools/src/creator/parts.ts b/tools/src/creator/parts.ts new file mode 100644 index 0000000..5cbb8f7 --- /dev/null +++ b/tools/src/creator/parts.ts @@ -0,0 +1,850 @@ +import type { CanonicalBone } from "@psx/shared"; +import type { Rgb } from "../image/Raster.js"; +import { + add, + computeFlatNormals, + createMesh, + cross, + domeShell, + dot, + lerp, + loft, + normalise, + orientedBox, + scale, + sub, + tube, + tubePath, + type DomeShellOptions, + type LoftKey, + type MeshData, + type Vec3Tuple, +} from "../mesh/MeshBuilder.js"; +import { jointWorld, type Skeleton } from "../rig/skeleton.js"; +import { headLandmarks } from "./body.js"; +import { PART, type CoverageBand } from "./coverage.js"; +import { bodyMetrics, cutAbove, cutBelow, padded, torsoPlan, withHem } from "./metrics.js"; +import type { PartContext, PartDefinition } from "./types.js"; + +/** + * Outfit part builders. Each returns a mesh in bind pose on the shared + * skeleton; the assembler merges them with the base body and re-skins once. + * + * Clothing is lofted like the body (ludus keyframes) — slightly larger, 4–6 + * sides for a PS1 / FF8 angular read. + */ + +const TORSO_SIDES = 8; +const LIMB_SIDES = 6; + +/** Ring frames pinned across the body, matching the skin loft. */ +const ACROSS: Vec3Tuple = [1, 0, 0]; + +const at = (skeleton: Skeleton, bone: CanonicalBone): Vec3Tuple => jointWorld(skeleton, bone); +const finish = (mesh: MeshData): MeshData => { + computeFlatNormals(mesh); + return mesh; +}; + +// --- hair ------------------------------------------------------------------ + +function hairLandmarks(ctx: PartContext) { + const { skeleton, body, headStyle } = ctx; + const hl = headLandmarks(skeleton, body, headStyle); + const { head, neck, crown, brow, browFwd, occiput, headW, headD, height: h } = hl; + const pad = 1.14; + const skullAxis = normalise(sub(crown, head)); + const face: Vec3Tuple = [0, 0, 1]; + const hairFrom = add( + lerp(head, crown, 0.5), + add(scale(skullAxis, headW * 0.04), scale(face, headW * 0.03)), + ); + const hairTo = add(crown, scale(skullAxis, headW * 0.12)); + const halfW = headW * pad; + // Depth tracks the real skull rather than breadth: the face sliders trade one + // against the other, so a long head is narrower *and* deeper, and a cap sized + // off width alone would let the occiput through the back of the hair. + const halfD = headD * pad * 0.778; + return { + h, + head, + neck, + headHalf: headW, + crown, + brow, + browFwd, + occiput, + hairFrom, + hairTo, + halfW, + halfD, + centreZ: (brow[2] + occiput[2]) * 0.5, + hairlineY: hairFrom[1], + crownY: hairTo[1], + browFwdZ: browFwd[2], + capHeight: Math.hypot(hairTo[0] - hairFrom[0], hairTo[1] - hairFrom[1], hairTo[2] - hairFrom[2]), + }; +} + +function baseCapOptions(style: "short" | "long" | "bun" | "undercut" | "messy"): DomeShellOptions { + const faceDir: Vec3Tuple = [0, 0, 1]; + const faceCut = { height: 0.62, halfWidth: 0.62, depth: 0.12 }; + const common = { + sections: 4, + sides: 8, + faceDir, + faceCut, + frontScale: 0.9, + frontScaleTop: 0.68, + backScale: 1.12, + topScale: 0.82, + }; + if (style === "long") return { ...common, backScale: 1.2, topScale: 0.84, faceCut: { ...faceCut, halfWidth: 0.58 } }; + if (style === "bun") return { ...common, topScale: 0.78 }; + if (style === "undercut") return { ...common, faceCut: { ...faceCut, height: 0.68 }, topScale: 0.8 }; + return common; +} + +type BackScalpLength = "crop" | "nape" | "shoulder"; + +function capFrame(lm: ReturnType, cap: DomeShellOptions) { + const from = lm.hairFrom; + const to = lm.hairTo; + const axisN = normalise(sub(to, from)); + const faceWanted = cap.faceDir ?? ([0, 0, 1] as Vec3Tuple); + let face = sub(faceWanted, scale(axisN, dot(faceWanted, axisN))); + if (Math.hypot(face[0], face[1], face[2]) < 1e-5) { + const alt: Vec3Tuple = Math.abs(axisN[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]; + face = sub(alt, scale(axisN, dot(alt, axisN))); + } + face = normalise(face); + const side = normalise(cross(axisN, face)); + const hw = lm.halfW; + const hd = lm.halfD; + const backScale = cap.backScale ?? 1.12; + const ringPoint = (cos: number, sin: number): Vec3Tuple => { + const depthScale = sin >= 0 ? (cap.frontScale ?? 0.9) : backScale; + return add(from, add(scale(side, -cos * hw), scale(face, sin * hd * depthScale))); + }; + const inv = Math.SQRT1_2; + return { + axisN, + face, + side, + ringCentre: from, + backCentre: ringPoint(0, -1), + backLeft: ringPoint(inv, -inv), + backRight: ringPoint(-inv, -inv), + hw, + hd, + backScale, + }; +} + +function addBackScalp( + mesh: MeshData, + ctx: PartContext, + lm: ReturnType, + color: Rgb, + length: BackScalpLength, + cap: DomeShellOptions, +): void { + const { headHalf: skinW, neck } = lm; + const spine2 = at(ctx.skeleton, "Spine2"); + const spine1 = at(ctx.skeleton, "Spine1"); + const frame = capFrame(lm, cap); + const { axisN, face, backCentre, backLeft, backRight } = frame; + const seamHalfW = + 0.5 * + Math.hypot(backLeft[0] - backRight[0], backLeft[1] - backRight[1], backLeft[2] - backRight[2]); + const depthRatio = length === "crop" ? 0.4 : length === "nape" ? 0.42 : 0.45; + const tubeHalfD = seamHalfW * depthRatio; + const sinkIn = scale(face, tubeHalfD * 0.92); + const seamOverlap = scale(axisN, skinW * 0.04); + const backTop = add(add(backCentre, seamOverlap), sinkIn); + const seamLeft = add(add(backLeft, seamOverlap), sinkIn); + const seamRight = add(add(backRight, seamOverlap), sinkIn); + const backMid = add(backTop, add(scale(axisN, -skinW * 0.55), scale(face, skinW * 0.05))); + + let end: Vec3Tuple; + let widths: number[]; + if (length === "crop") { + end = add(backMid, add(scale(axisN, -skinW * 0.4), scale(face, skinW * 0.03))); + widths = [seamHalfW, seamHalfW * 0.92, seamHalfW * 0.72]; + } else if (length === "nape") { + end = add([0, neck[1] - skinW * 0.1, neck[2] - skinW * 0.12] as Vec3Tuple, sinkIn); + widths = [seamHalfW, seamHalfW * 0.88, seamHalfW * 0.58]; + } else { + const shoulderY = Math.min(spine2[1], spine1[1] + (spine2[1] - spine1[1]) * 0.35); + end = add( + [0, shoulderY - skinW * 0.05, spine2[2] - skinW * 0.22] as Vec3Tuple, + scale(face, tubeHalfD * 0.5), + ); + widths = [seamHalfW, seamHalfW * 0.9, seamHalfW * 0.65, seamHalfW * 0.48]; + } + + if (length === "shoulder") { + const nape = add( + [backTop[0], neck[1] - skinW * 0.05, neck[2] - skinW * 0.16] as Vec3Tuple, + scale(face, tubeHalfD * 0.7), + ); + tubePath(mesh, [backTop, backMid, nape, end], widths, depthRatio, color, { + sides: 6, + sectionsPerSegment: 2, + }); + for (const [s, corner] of [[-1, seamLeft], [1, seamRight]] as const) { + tubePath( + mesh, + [ + corner, + [s * seamHalfW * 0.9, nape[1], nape[2] + skinW * 0.04], + [s * seamHalfW * 0.65, end[1] + skinW * 0.05, end[2] + skinW * 0.05], + ], + [skinW * 0.24, skinW * 0.22, skinW * 0.15], + 0.7, + color, + { sides: 5, sectionsPerSegment: 2 }, + ); + } + } else { + tubePath(mesh, [backTop, backMid, end], widths, depthRatio, color, { + sides: 6, + sectionsPerSegment: 2, + }); + } + orientedBox( + mesh, + backTop, + seamHalfW * 0.95, + skinW * 0.08, + tubeHalfD * 0.9, + normalise(add(scale(face, -1), scale(axisN, -0.12))), + color, + ); +} + +function addSideburns( + mesh: MeshData, + lm: ReturnType, + color: Rgb, + lengthScale: number, + thicknessScale = 1, +): void { + const drop = lm.headHalf * (0.55 + lengthScale * 0.45); + const yTop = lm.hairlineY - lm.headHalf * 0.02; + const yBot = yTop - drop; + const z = lm.centreZ - lm.headHalf * 0.05; + const x = lm.halfW * 0.9; + const tw = lm.headHalf * 0.16 * thicknessScale; + const td = lm.headHalf * 0.2 * thicknessScale; + for (const side of [-1, 1] as const) { + tube( + mesh, + [side * x, yTop, z], + [side * x * 0.95, yBot, z - lm.headHalf * 0.05], + tw, + tw * 0.85, + td / Math.max(tw, 1e-6), + color, + { sides: 5, sections: 1, caps: true }, + ); + } +} + +function hairCap(ctx: PartContext, style: "short" | "long" | "bun" | "undercut" | "messy"): MeshData { + const mesh = createMesh(); + const color = ctx.color ?? ctx.palette.hair; + const lm = hairLandmarks(ctx); + const cap = baseCapOptions(style); + + domeShell(mesh, lm.hairFrom, lm.hairTo, lm.halfW, lm.halfD, color, cap); + + const cutTop = lerp(lm.hairFrom, lm.hairTo, 0.55); + orientedBox( + mesh, + add(cutTop, [0, lm.headHalf * 0.02, lm.headHalf * 0.05]), + lm.halfW * 0.55, + lm.headHalf * 0.06, + lm.headHalf * 0.08, + [0, 0.35, 1], + color, + ); + for (const s of [-1, 1] as const) { + orientedBox( + mesh, + add(lm.hairFrom, [s * lm.halfW * 0.55, lm.headHalf * 0.04, lm.headHalf * 0.08]), + lm.headHalf * 0.14, + lm.headHalf * 0.16, + lm.headHalf * 0.1, + [s * 0.15, 0.2, 1], + color, + ); + } + + const backLength: BackScalpLength = + style === "long" ? "shoulder" : style === "messy" || style === "bun" ? "nape" : "crop"; + addBackScalp(mesh, ctx, lm, color, backLength, cap); + + if (style === "short") addSideburns(mesh, lm, color, 0.35, 0.9); + if (style === "long") addSideburns(mesh, lm, color, 0.55, 1.05); + if (style === "messy") { + addSideburns(mesh, lm, color, 0.45, 1.0); + for (const t of [ + { c: add(lm.crown, [0, lm.headHalf * 0.06, lm.headHalf * 0.04]), hr: lm.headHalf * 0.38, hu: lm.headHalf * 0.2, hf: lm.headHalf * 0.28, dir: [0, 1, 0.2] as Vec3Tuple }, + { c: add(lm.crown, [-lm.halfW * 0.35, -lm.headHalf * 0.02, -lm.headHalf * 0.08]), hr: lm.headHalf * 0.28, hu: lm.headHalf * 0.2, hf: lm.headHalf * 0.22, dir: [-0.4, 0.9, -0.1] as Vec3Tuple }, + { c: add(lm.crown, [lm.halfW * 0.32, 0, -lm.headHalf * 0.12]), hr: lm.headHalf * 0.26, hu: lm.headHalf * 0.22, hf: lm.headHalf * 0.2, dir: [0.35, 1, -0.2] as Vec3Tuple }, + ]) { + orientedBox(mesh, t.c, t.hr, t.hu, t.hf, t.dir, color); + } + } + if (style === "bun") { + const bunCentre: Vec3Tuple = [0, lm.crown[1] - lm.headHalf * 0.02, lm.occiput[2] - lm.headHalf * 0.1]; + tube( + mesh, + [0, lm.crown[1] - lm.headHalf * 0.1, lm.centreZ - lm.halfD * 0.2], + bunCentre, + lm.headHalf * 0.22, + lm.headHalf * 0.32, + 1, + color, + { sides: 6, sections: 1 }, + ); + orientedBox(mesh, bunCentre, lm.headHalf * 0.42, lm.headHalf * 0.35, lm.headHalf * 0.38, [0, 0.4, -1], color); + } + if (style === "undercut") { + orientedBox( + mesh, + add(lm.crown, [0, -lm.headHalf * 0.02, lm.headHalf * 0.02]), + lm.halfW * 0.5, + lm.headHalf * 0.12, + lm.headHalf * 0.26, + [0, 0.55, 0.3], + color, + ); + } + + return finish(mesh); +} + +// --- upper body clothing --------------------------------------------------- + +function upperGarment( + ctx: PartContext, + kind: "shirt" | "jacket" | "coat" | "labcoat" | "uniform" | "workcoat", +): MeshData { + const mesh = createMesh(); + const { skeleton, palette, body, bodyStyle } = ctx; + const h = skeleton.height; + const color = ctx.color ?? palette.primary; + const trim = palette.secondary; + const long = kind === "coat" || kind === "labcoat"; + const heavy = kind === "jacket" || kind === "workcoat" || kind === "uniform"; + + // Cloth thickness in metres, not a percentage: a jacket clears the skin by + // the same 2 cm on a heavy body as on a lean one. + const cloth = h * (long ? 0.014 : heavy ? 0.011 : 0.007); + const skin = bodyMetrics(skeleton, body, bodyStyle); + const m = padded(skin, cloth); + + // Body's ring plan, grown by cloth and cut at the hem — a garment that + // invents its own key list eventually skips a section the body has. + const plan = torsoPlan(m); + const torsoKeys = long + ? withHem( + plan, + m.crotchY - h * 0.075, + // Wide enough to hang clear of a flared skirt underneath. + { rx: m.hip.rx * 1.28, rz: m.hip.rz * 1.22 }, + m.at("Hips")[2] + m.buttZ * 0.3, + ) + : cutBelow(plan, m.waistY - h * 0.05); + + // Ring count matters more here than on the skin: the garment only clears the + // body if its chords sit outside the body's, so the shoulder slope needs + // rings on both sides of the trapezius key, not a single chord across it. + loft(mesh, torsoKeys, color, { sides: TORSO_SIDES, rings: 17, caps: true, startRight: ACROSS }); + + // Sleeves — the skin arm loft, stopped at the cuff. + for (const prefix of ["Left", "Right"] as const) { + const side = prefix === "Left" ? 1 : -1; + const shoulder = m.at(`${prefix}Shoulder` as CanonicalBone); + const arm = m.at(`${prefix}Arm` as CanonicalBone); + const foreArm = m.at(`${prefix}ForeArm` as CanonicalBone); + const hand = m.at(`${prefix}Hand` as CanonicalBone); + const sleeveEnd = kind === "shirt" ? lerp(foreArm, hand, 0.55) : lerp(foreArm, hand, 0.92); + const down = normalise(sub(hand, arm)); + + const sleeveKeys: LoftKey[] = [ + { + t: 0.0, + c: [side * m.clavX, shoulder[1] - h * 0.012, shoulder[2]], + rx: m.deltoidR * 0.94, + rz: m.deltoidR, + }, + { + // Same t as the skin's deltoid key so both lofts peak on a ring. + t: 0.1, + c: [side * m.socketX, arm[1] + h * 0.002, arm[2]], + rx: m.deltoidR, + rz: m.deltoidR * 1.04, + }, + { + t: 0.22, + c: add(arm, scale(down, h * 0.045)), + rx: m.upperArm.rx * 1.06, + rz: m.upperArm.rz * 1.06, + }, + { t: 0.45, c: lerp(arm, foreArm, 0.62), rx: m.upperArm.rx * 0.94, rz: m.upperArm.rz * 0.94 }, + { t: 0.6, c: foreArm, rx: m.elbow.rx, rz: m.elbow.rz }, + { t: 0.8, c: lerp(foreArm, sleeveEnd, 0.6), rx: m.foreArm.rx * 0.95, rz: m.foreArm.rz * 0.95 }, + { t: 1.0, c: sleeveEnd, rx: m.wrist.rx * 1.3, rz: m.wrist.rz * 1.2 }, + ]; + loft(mesh, sleeveKeys, color, { + sides: LIMB_SIDES, + rings: 11, + capStart: false, + capEnd: true, + startRight: ACROSS, + }); + // Cuff accent + loft( + mesh, + [ + { t: 0, c: lerp(sleeveEnd, foreArm, 0.1), rx: m.wrist.rx * 1.36, rz: m.wrist.rz * 1.26 }, + { t: 1, c: sleeveEnd, rx: m.wrist.rx * 1.38, rz: m.wrist.rz * 1.28 }, + ], + trim, + { sides: LIMB_SIDES, rings: 2, caps: true, startRight: ACROSS }, + ); + } + + // Low stand collar (not a brick tower) + if (kind !== "shirt") { + const neck = m.at("Neck"); + loft( + mesh, + [ + { t: 0, c: [0, m.collarY, neck[2] + h * 0.005], rx: m.neck.rx * 1.2, rz: m.neck.rz * 1.18 }, + { + t: 1, + c: [0, m.collarY + h * 0.03, neck[2] + h * 0.008], + rx: m.neck.rx * 1.26, + rz: m.neck.rz * 1.22, + }, + ], + trim, + { sides: 6, rings: 2, caps: true, startRight: ACROSS }, + ); + } + + return finish(mesh); +} + +function lowerGarment(ctx: PartContext, kind: "pants" | "cargo" | "skirt"): MeshData { + const mesh = createMesh(); + const { skeleton, palette, body, bodyStyle } = ctx; + const h = skeleton.height; + const color = ctx.color ?? palette.secondary; + const cloth = h * (kind === "cargo" ? 0.011 : 0.008); + const m = padded(bodyMetrics(skeleton, body, bodyStyle), cloth); + const hips = at(skeleton, "Hips"); + const spine = at(skeleton, "Spine"); + const waistbandY = m.waistY + h * 0.01; + + if (kind === "skirt") { + // Full A-line: waistband → hip shelf → past the crotch → knee hem. It must + // wrap the whole pelvis, or the uncapped leg roots show as open tubes. + const kneeY = at(skeleton, "LeftLeg")[1] + h * 0.02; + loft( + mesh, + [ + { t: 0.0, c: [0, waistbandY, spine[2] + m.bellyZ], rx: m.waist.rx, rz: m.waist.rz }, + { + t: 0.24, + c: [0, m.hipY, hips[2] + m.buttZ * 0.4], + rx: m.hip.rx * 1.04, + rz: m.hip.rz * 1.06, + }, + // Below the crotch — the hem is already clear of the leg roots here. + { + t: 0.52, + c: [0, m.crotchY - h * 0.05, hips[2]], + rx: m.hip.rx * 1.16, + rz: m.hip.rz * 1.12, + }, + { t: 1.0, c: [0, kneeY, hips[2]], rx: m.hip.rx * 1.34, rz: m.hip.rz * 1.24 }, + ], + color, + { sides: TORSO_SIDES, rings: 8, caps: true, startRight: ACROSS }, + ); + // Waist sash + loft( + mesh, + [ + { t: 0, c: [0, waistbandY - h * 0.012, spine[2] + m.bellyZ], rx: m.waist.rx * 1.03, rz: m.waist.rz * 1.03 }, + { t: 1, c: [0, waistbandY + h * 0.018, spine[2] + m.bellyZ], rx: m.waist.rx * 0.99, rz: m.waist.rz * 0.99 }, + ], + palette.primary, + { sides: TORSO_SIDES, rings: 3, caps: true, startRight: ACROSS }, + ); + return finish(mesh); + } + + // Pants: a hip yoke on the body's own ring plan, cut at the waistband, then + // two legs whose roots stay buried inside it. + loft( + mesh, + cutAbove(torsoPlan(m), waistbandY), + color, + // Rings no denser than the layer above: an inner layer that samples a + // curve more finely than an outer one bulges through it at the glutes. + { sides: TORSO_SIDES, rings: 6, caps: true, startRight: ACROSS }, + ); + + for (const prefix of ["Left", "Right"] as const) { + const side = prefix === "Left" ? 1 : -1; + const upLeg = at(skeleton, `${prefix}UpLeg` as CanonicalBone); + const leg = at(skeleton, `${prefix}Leg` as CanonicalBone); + const foot = at(skeleton, `${prefix}Foot` as CanonicalBone); + const legX = Math.abs(upLeg[0]); + const hemY = lerp(leg, foot, 0.9); + + const legKeys: LoftKey[] = [ + // Buried in the yoke. + { + t: 0.0, + c: [side * legX * 0.6, m.hipY + h * 0.012, hips[2] + m.buttZ * 0.5], + rx: m.hipSocket.rx, + rz: m.hipSocket.rz, + }, + { + t: 0.1, + c: [side * legX * 0.95, m.crotchY + h * 0.01, upLeg[2] - h * 0.004], + rx: m.thigh.rx * 1.1, + rz: m.thigh.rz * 1.12, + }, + { t: 0.26, c: [side * legX, upLeg[1] - h * 0.03, upLeg[2]], rx: m.thigh.rx, rz: m.thigh.rz }, + { t: 0.45, c: lerp(upLeg, leg, 0.78), rx: m.thigh.rx * 0.84, rz: m.thigh.rz * 0.86 }, + { t: 0.56, c: leg, rx: m.knee.rx * 1.12, rz: m.knee.rz * 1.12 }, + { t: 0.72, c: add(lerp(leg, foot, 0.26), [0, 0, m.calfZ]), rx: m.calf.rx, rz: m.calf.rz }, + // Hem breaks over the boot rather than shrink-wrapping the ankle. + { t: 1.0, c: hemY, rx: m.calf.rx * 0.86, rz: m.calf.rz * 0.86 }, + ]; + loft(mesh, legKeys, color, { + sides: LIMB_SIDES, + rings: 11, + capStart: false, + capEnd: true, + startRight: ACROSS, + }); + } + + return finish(mesh); +} + +function footwear(ctx: PartContext, kind: "boots" | "shoes"): MeshData { + const mesh = createMesh(); + const { skeleton, palette, body, bodyStyle } = ctx; + const h = skeleton.height; + const color = ctx.color ?? palette.leather; + const sole = palette.secondary; + const m = padded(bodyMetrics(skeleton, body, bodyStyle), h * 0.008); + const soleLift = h * 0.012; + + for (const prefix of ["Left", "Right"] as const) { + const ankleJoint = at(skeleton, `${prefix}Foot` as CanonicalBone); + const leg = at(skeleton, `${prefix}Leg` as CanonicalBone); + const x = ankleJoint[0]; + const z = ankleJoint[2]; + const hw = m.footHalfW; + const heelZ = z - m.heelBack; + const toeZ = z + m.footLength - m.heelBack + h * 0.006; + + // Shaft: from the cuff down over the ankle, on the skin leg sections. + const top = kind === "boots" ? lerp(leg, ankleJoint, 0.55) : lerp(leg, ankleJoint, 0.86); + loft( + mesh, + [ + { t: 0, c: top, rx: m.calf.rx * 0.9, rz: m.calf.rz * 0.9 }, + { t: 0.6, c: [x, ankleJoint[1] + h * 0.01, z], rx: m.ankle.rx * 1.15, rz: m.ankle.rz * 1.1 }, + { t: 1, c: [x, m.ankleHeight * 0.7, z + h * 0.004], rx: hw * 0.86, rz: m.ankleHeight * 0.66 }, + ], + color, + { sides: LIMB_SIDES, rings: kind === "boots" ? 5 : 4, capStart: true, capEnd: false, startRight: ACROSS }, + ); + + // Shell over the foot — the skin foot's ring plan, lifted onto a sole. + loft( + mesh, + [ + { t: 0.0, c: [x, soleLift + m.ankleHeight * 0.62, heelZ], rx: hw * 0.66, rz: m.ankleHeight * 0.62 }, + { t: 0.18, c: [x, soleLift + m.ankleHeight * 0.56, heelZ + m.footLength * 0.1], rx: hw * 0.82, rz: m.ankleHeight * 0.56 }, + { t: 0.4, c: [x, soleLift + m.ankleHeight * 0.66, z + m.footLength * 0.06], rx: hw * 0.9, rz: m.ankleHeight * 0.6 }, + { t: 0.62, c: [x, soleLift + m.ankleHeight * 0.5, z + m.footLength * 0.28], rx: hw * 0.97, rz: m.ankleHeight * 0.48 }, + { t: 0.85, c: [x, soleLift + m.ankleHeight * 0.4, z + m.footLength * 0.5], rx: hw * 1.02, rz: m.ankleHeight * 0.4 }, + { t: 1.0, c: [x, soleLift + m.ankleHeight * 0.32, toeZ], rx: hw * 0.88, rz: m.ankleHeight * 0.32 }, + ], + color, + { sides: LIMB_SIDES, rings: 6, caps: true, startRight: ACROSS }, + ); + + // Sole slab, sitting on the floor. Spans heel to toe exactly: run it off + // its own length and it juts out behind the heel, where the shin has a + // claim on the weights and a stride shears it into a blade. + orientedBox( + mesh, + [x, soleLift * 0.5, (heelZ + toeZ) * 0.5], + hw * 1.02, + soleLift * 0.5, + (toeZ - heelZ) * 0.5, + [0, 0, 1], + sole, + ); + } + return finish(mesh); +} + +function accessory(ctx: PartContext, kind: "backpack" | "belt" | "glasses" | "satchel"): MeshData { + const mesh = createMesh(); + const { skeleton, palette } = ctx; + const h = skeleton.height; + const s = h / 1.72; + const color = ctx.color ?? palette.accent; + + if (kind === "backpack") { + const spine2 = at(skeleton, "Spine2"); + const spine1 = at(skeleton, "Spine1"); + const centre = lerp(spine1, spine2, 0.5); + loft( + mesh, + [ + { + t: 0, + c: [centre[0], centre[1] - h * 0.06, centre[2] - h * 0.065], + rx: 0.065 * s, + rz: 0.05 * s, + }, + { + t: 0.5, + c: [centre[0], centre[1], centre[2] - h * 0.07], + rx: 0.072 * s, + rz: 0.055 * s, + }, + { + t: 1, + c: [centre[0], centre[1] + h * 0.08, centre[2] - h * 0.06], + rx: 0.068 * s, + rz: 0.052 * s, + }, + ], + color, + { sides: 5, rings: 4, caps: true }, + ); + for (const side of [-1, 1]) { + loft( + mesh, + [ + { + t: 0, + c: [side * h * 0.07, spine2[1] + h * 0.01, spine2[2]], + rx: 0.008 * s, + rz: 0.008 * s, + }, + { + t: 1, + c: [side * h * 0.07, spine1[1] - h * 0.01, spine1[2] - h * 0.04], + rx: 0.008 * s, + rz: 0.008 * s, + }, + ], + palette.leather, + { sides: 4, rings: 2, caps: true }, + ); + } + } + if (kind === "belt") { + // Rides on the hip shelf, over whatever the lower slot put there. + const m = padded(bodyMetrics(skeleton, ctx.body, ctx.bodyStyle), h * 0.014); + const beltY = m.hipY - h * 0.005; + loft( + mesh, + [ + { t: 0, c: [0, beltY, m.at("Hips")[2] + m.buttZ * 0.4], rx: m.hip.rx, rz: m.hip.rz }, + { + t: 1, + c: [0, beltY + h * 0.026, m.at("Hips")[2] + m.buttZ * 0.35], + rx: m.hip.rx * 0.98, + rz: m.hip.rz * 0.98, + }, + ], + color, + { sides: TORSO_SIDES, rings: 2, caps: true, startRight: ACROSS }, + ); + const buckleZ = m.at("Hips")[2] + m.hip.rz + h * 0.004; + loft( + mesh, + [ + { t: 0, c: [0, beltY + h * 0.004, buckleZ], rx: h * 0.018, rz: h * 0.008 }, + { t: 1, c: [0, beltY + h * 0.022, buckleZ], rx: h * 0.016, rz: h * 0.008 }, + ], + palette.metal, + { sides: 4, rings: 2, caps: true, startRight: ACROSS }, + ); + } + if (kind === "glasses") { + const hl = headLandmarks(ctx.skeleton, ctx.body, ctx.headStyle); + const y = hl.brow[1] - hl.headW * 0.12; + const z = hl.browFwd[2] + hl.headW * 0.05; + const eyeX = hl.headW * 0.32; + for (const side of [-1, 1]) { + orientedBox( + mesh, + [side * eyeX, y, z], + hl.headW * 0.2, + hl.headW * 0.14, + hl.headW * 0.07, + [0, 0, 1], + palette.metal, + ); + } + tube( + mesh, + [-eyeX * 0.55, y, z], + [eyeX * 0.55, y, z], + h * 0.004, + h * 0.004, + 1, + palette.metal, + { sides: 4, sections: 1 }, + ); + } + if (kind === "satchel") { + const hip = at(skeleton, "Hips"); + loft( + mesh, + [ + { + t: 0, + c: [h * 0.12, hip[1] - h * 0.02, 0], + rx: 0.04 * s, + rz: 0.025 * s, + }, + { + t: 1, + c: [h * 0.12, hip[1] - h * 0.09, 0], + rx: 0.045 * s, + rz: 0.028 * s, + }, + ], + color, + { sides: 5, rings: 3, caps: true }, + ); + loft( + mesh, + [ + { t: 0, c: [0, hip[1] + h * 0.12, 0], rx: 0.007 * s, rz: 0.007 * s }, + { t: 1, c: [h * 0.12, hip[1], 0], rx: 0.007 * s, rz: 0.007 * s }, + ], + palette.leather, + { sides: 4, rings: 2, caps: true }, + ); + } + return finish(mesh); +} + +// --- coverage --------------------------------------------------------------- + +/** + * Skin each closed garment encloses, in body loft parameters. + * + * The numbers are read off the key lists in `body.ts` — the torso loft runs + * crotch 0 → waist 0.4 → chest 0.7 → collar 1, arms deltoid 0.1 → elbow 0.52 → + * wrist 0.86 → fingertip 1, legs hip 0 → knee 0.52 → ankle 1. Bands stop a + * little short of each opening so a rim of skin recesses inside the cuff or + * collar instead of ending on a hard cloth edge. + * + * Hair claims nothing: the caps are open-fronted, and hiding the scalp under + * one shows straight through the face cut. + */ +const bothArms = (tMin: number, tMax: number): CoverageBand[] => [ + { part: PART.ARM_L, tMin, tMax }, + { part: PART.ARM_R, tMin, tMax }, +]; +const bothLegs = (tMin: number, tMax: number): CoverageBand[] => [ + { part: PART.LEG_L, tMin, tMax }, + { part: PART.LEG_R, tMin, tMax }, +]; +const bothFeet: CoverageBand[] = [ + { part: PART.FOOT_L, tMin: -0.01, tMax: 1.01 }, + { part: PART.FOOT_R, tMin: -0.01, tMax: 1.01 }, +]; + +/** Waist-hem tops: torso from below the hem up to the collar, sleeve to cuff. */ +const TORSO_TO_COLLAR = (hemT: number): CoverageBand[] => [ + { part: PART.TORSO, tMin: hemT, tMax: 0.97 }, +]; +const SHIRT_COVER: CoverageBand[] = [...TORSO_TO_COLLAR(0.3), ...bothArms(-0.01, 0.62)]; +const JACKET_COVER: CoverageBand[] = [...TORSO_TO_COLLAR(0.28), ...bothArms(-0.01, 0.76)]; +// Hem falls past the crotch, so the whole trunk goes. +const COAT_COVER: CoverageBand[] = [ + { part: PART.TORSO, tMin: -0.01, tMax: 0.97 }, + ...bothArms(-0.01, 0.76), + ...bothLegs(-0.01, 0.3), +]; +const PANTS_COVER: CoverageBand[] = [ + { part: PART.TORSO, tMin: -0.01, tMax: 0.38 }, + ...bothLegs(-0.01, 0.9), +]; +const SKIRT_COVER: CoverageBand[] = [ + { part: PART.TORSO, tMin: -0.01, tMax: 0.38 }, + ...bothLegs(-0.01, 0.24), +]; +const BOOTS_COVER: CoverageBand[] = [...bothFeet, ...bothLegs(0.86, 1.01)]; +const SHOES_COVER: CoverageBand[] = [...bothFeet, ...bothLegs(0.97, 1.01)]; + +// --- catalog registration -------------------------------------------------- + +export const PART_DEFINITIONS: PartDefinition[] = [ + // Hair + { id: "hair.none", slot: "hair", name: "None", build: () => createMesh() }, + { id: "hair.short", slot: "hair", name: "Short", build: (c) => hairCap(c, "short") }, + { id: "hair.long", slot: "hair", name: "Long", build: (c) => hairCap(c, "long") }, + { id: "hair.bun", slot: "hair", name: "Bun", build: (c) => hairCap(c, "bun") }, + { id: "hair.undercut", slot: "hair", name: "Undercut", build: (c) => hairCap(c, "undercut") }, + { id: "hair.messy", slot: "hair", name: "Messy", build: (c) => hairCap(c, "messy") }, + + // Upper + { id: "upper.none", slot: "upper", name: "None", build: () => createMesh() }, + { id: "upper.shirt", slot: "upper", name: "Shirt", build: (c) => upperGarment(c, "shirt"), coverage: SHIRT_COVER }, + { id: "upper.jacket", slot: "upper", name: "Jacket", build: (c) => upperGarment(c, "jacket"), coverage: JACKET_COVER }, + { id: "upper.coat", slot: "upper", name: "Coat", build: (c) => upperGarment(c, "coat"), coverage: COAT_COVER }, + { id: "upper.labcoat", slot: "upper", name: "Lab Coat", build: (c) => upperGarment(c, "labcoat"), coverage: COAT_COVER }, + { id: "upper.uniform", slot: "upper", name: "Uniform", build: (c) => upperGarment(c, "uniform"), coverage: JACKET_COVER }, + { id: "upper.workcoat", slot: "upper", name: "Work Coat", build: (c) => upperGarment(c, "workcoat"), coverage: JACKET_COVER }, + + // Lower + { id: "lower.none", slot: "lower", name: "None", build: () => createMesh() }, + { id: "lower.pants", slot: "lower", name: "Pants", build: (c) => lowerGarment(c, "pants"), coverage: PANTS_COVER }, + { id: "lower.cargo", slot: "lower", name: "Cargo Pants", build: (c) => lowerGarment(c, "cargo"), coverage: PANTS_COVER }, + { id: "lower.skirt", slot: "lower", name: "Skirt", body: "female", build: (c) => lowerGarment(c, "skirt"), coverage: SKIRT_COVER }, + + // Feet + { id: "feet.none", slot: "feet", name: "None", build: () => createMesh() }, + { id: "feet.boots", slot: "feet", name: "Boots", build: (c) => footwear(c, "boots"), coverage: BOOTS_COVER }, + { id: "feet.shoes", slot: "feet", name: "Shoes", build: (c) => footwear(c, "shoes"), coverage: SHOES_COVER }, + + // Accessories + { id: "accessory.none", slot: "accessory", name: "None", build: () => createMesh() }, + { id: "accessory.backpack", slot: "accessory", name: "Backpack", build: (c) => accessory(c, "backpack") }, + { id: "accessory.belt", slot: "accessory", name: "Belt", build: (c) => accessory(c, "belt") }, + { id: "accessory.glasses", slot: "accessory", name: "Glasses", build: (c) => accessory(c, "glasses") }, + { id: "accessory.satchel", slot: "accessory", name: "Satchel", build: (c) => accessory(c, "satchel") }, +]; + +export const PART_CATALOGUE: ReadonlyMap = new Map( + PART_DEFINITIONS.map((part) => [part.id, part]), +); + +export function listParts(slot?: PartDefinition["slot"]): PartDefinition[] { + return PART_DEFINITIONS.filter((part) => (slot ? part.slot === slot : true) && part.id !== `${part.slot}.none`); +} diff --git a/tools/src/creator/presets.ts b/tools/src/creator/presets.ts new file mode 100644 index 0000000..ec52b11 --- /dev/null +++ b/tools/src/creator/presets.ts @@ -0,0 +1,197 @@ +import type { Rgb } from "../image/Raster.js"; +import { SKIN } from "./body.js"; +import type { CharacterRecipe, CreatorPalette } from "./types.js"; + +const rgb = (r: number, g: number, b: number): Rgb => ({ r, g, b }); + +function palette(partial: Partial & Pick): CreatorPalette { + return { + primary: rgb(70, 75, 55), + secondary: rgb(45, 48, 52), + accent: rgb(120, 100, 60), + metal: rgb(140, 140, 145), + leather: rgb(70, 50, 35), + ...partial, + }; +} + +/** + * Game cast as modular outfits. Add a recipe, run `pnpm cast`, get a GLB. + */ +export const CHARACTER_PRESETS: CharacterRecipe[] = [ + { + id: "survivor", + name: "Survivor", + body: "male", + height: 1.72, + // Wiry survivor — lean, a bit of muscle. + bodyStyle: { mass: -0.25, muscle: 0.2, fat: 0.05 }, + // Gaunt and long-featured — the face the player looks at most. + headStyle: { length: 0.45, jaw: 0.15, brow: 0.3 }, + styleNotes: + "olive drab field jacket over a grey tee, dirt-worn canvas cargo trousers, " + + "scuffed brown leather boots, dried mud at the hems, stubble", + + palette: palette({ + skin: SKIN.medium, + hair: rgb(40, 36, 32), + primary: rgb(78, 82, 52), + secondary: rgb(48, 50, 55), + accent: rgb(90, 70, 40), + leather: rgb(85, 60, 40), + }), + parts: { + hair: "hair.short", + upper: "upper.jacket", + lower: "lower.cargo", + feet: "feet.boots", + accessory: "accessory.backpack", + }, + }, + { + id: "researcher", + name: "Dr. Hale", + body: "female", + height: 1.66, + bodyStyle: { mass: -0.15, muscle: -0.1, fat: 0.05 }, + // Fine-boned, neutral length — reads academic next to the others. + 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: palette({ + skin: SKIN.fair, + hair: rgb(200, 190, 175), + primary: rgb(230, 228, 220), + secondary: rgb(50, 52, 65), + accent: rgb(80, 90, 110), + leather: rgb(60, 50, 45), + }), + parts: { + hair: "hair.bun", + upper: "upper.labcoat", + lower: "lower.pants", + feet: "feet.shoes", + accessory: "accessory.glasses", + }, + partColors: { + "upper.labcoat": rgb(235, 233, 225), + }, + }, + { + id: "officer", + name: "Sgt. Marrow", + body: "male", + height: 1.82, + // Built cop — heavier mass + muscle. + bodyStyle: { mass: 0.35, muscle: 0.45, fat: 0.1 }, + // Slab of a head: short, square, heavy brow. + 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: palette({ + skin: SKIN.tan, + hair: rgb(28, 28, 30), + primary: rgb(38, 50, 46), + secondary: rgb(32, 36, 40), + accent: rgb(160, 140, 50), + leather: rgb(35, 30, 28), + }), + parts: { + hair: "hair.undercut", + upper: "upper.uniform", + lower: "lower.pants", + feet: "feet.boots", + accessory: "accessory.belt", + }, + }, + { + id: "groundskeeper", + name: "Ellis", + body: "male", + height: 1.75, + // Soft bulk from outdoor work / beer. + bodyStyle: { mass: 0.2, muscle: 0.1, fat: 0.35 }, + // Round and jowly to match the soft bulk. + 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: palette({ + skin: SKIN.tan, + hair: rgb(90, 70, 50), + primary: rgb(120, 78, 48), + secondary: rgb(70, 72, 78), + accent: rgb(100, 80, 50), + leather: rgb(55, 45, 35), + }), + parts: { + hair: "hair.messy", + upper: "upper.workcoat", + lower: "lower.cargo", + feet: "feet.boots", + accessory: "accessory.satchel", + }, + }, + // Extra starter outfits — ready for new NPCs / player variants. + { + 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: palette({ + skin: SKIN.light, + hair: rgb(55, 35, 28), + primary: rgb(90, 70, 95), + secondary: rgb(50, 48, 55), + leather: rgb(80, 55, 45), + }), + parts: { + hair: "hair.long", + upper: "upper.coat", + lower: "lower.skirt", + feet: "feet.shoes", + accessory: "accessory.none", + }, + }, + { + 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: palette({ + skin: SKIN.light, + hair: rgb(70, 60, 50), + primary: rgb(95, 90, 85), + secondary: rgb(55, 55, 60), + leather: rgb(65, 48, 38), + }), + parts: { + hair: "hair.short", + upper: "upper.shirt", + lower: "lower.pants", + feet: "feet.shoes", + accessory: "accessory.belt", + }, + }, +]; + +export function presetById(id: string): CharacterRecipe | undefined { + return CHARACTER_PRESETS.find((preset) => preset.id === id); +} diff --git a/tools/src/creator/proportions.ts b/tools/src/creator/proportions.ts new file mode 100644 index 0000000..840fb49 --- /dev/null +++ b/tools/src/creator/proportions.ts @@ -0,0 +1,104 @@ +import type { Measurements } from "../image/analyze.js"; +import type { BodyType } from "./types.js"; + +/** + * Authored body proportions for the two base meshes. + * + * These are the single source of truth for figure shape: `buildSkeleton` reads + * the landmarks, `bodyMetrics` reads the widths, and every garment sizes itself + * off the metrics. Change a number here and the skin, the clothes and the rig + * all move together. + * + * Values are fractions of total height — landmarks measured **down from the + * crown**, widths as **full breadth** (so `hip: 0.19` on a 1.78 m figure is a + * 34 cm hip). They sit close to standard adult anthropometry (a 7.5-head + * figure) rather than the FF8 lanky read the first pass used: the art + * direction in `tools/fixtures/survivor-reference.jpg` is grounded + * survival-horror, where a stick figure reads as a child at 240p. + * + * The stylisation lives elsewhere — low ring counts, hard facets, flat colour. + * The proportions themselves stay honest so silhouettes read at distance and + * animation retargeting behaves. + */ + +function landmark(y: number) { + return { y, source: "derived" as const }; +} + +/** Adult male — 7.5 heads, moderate shoulder V, real limb girth. */ +export const MALE_MEASUREMENTS: Measurements = { + pixelHeight: 1000, + pixelWidth: 440, + landmarks: { + // Neck base sits at the trapezius line, not under the jaw — the old 0.12 + // gave a swan neck that made the head look shrunken. + neck: landmark(0.175), + shoulder: landmark(0.182), + chest: landmark(0.26), + waist: landmark(0.37), + hip: landmark(0.48), + crotch: landmark(0.52), + knee: landmark(0.715), + ankle: landmark(0.955), + }, + widths: { + head: 0.087, + neck: 0.062, + // Biacromial (bone). The deltoids push the silhouette out to ~0.26. + shoulder: 0.245, + chest: 0.174, + waist: 0.155, + hip: 0.191, + thigh: 0.098, + calf: 0.068, + foot: 0.055, + upperArm: 0.058, + forearm: 0.05, + }, + // Half the arm span: centreline to fingertip. Vitruvian — span ≈ height. + armReach: 0.51, + stance: 0.048, + aspect: 2.4, +}; + +/** Adult female — narrower shoulders, higher and narrower waist, wider hips. */ +export const FEMALE_MEASUREMENTS: Measurements = { + pixelHeight: 1000, + pixelWidth: 415, + landmarks: { + neck: landmark(0.172), + shoulder: landmark(0.18), + chest: landmark(0.265), + waist: landmark(0.355), + hip: landmark(0.485), + crotch: landmark(0.525), + knee: landmark(0.715), + ankle: landmark(0.955), + }, + widths: { + head: 0.084, + neck: 0.055, + shoulder: 0.215, + chest: 0.163, + waist: 0.136, + hip: 0.202, + thigh: 0.1, + calf: 0.062, + foot: 0.05, + upperArm: 0.05, + forearm: 0.043, + }, + armReach: 0.49, + stance: 0.044, + aspect: 2.45, +}; + +export function measurementsFor(body: BodyType): Measurements { + return body === "female" ? FEMALE_MEASUREMENTS : MALE_MEASUREMENTS; +} + +/** Default heights when a recipe doesn't set one. */ +export const DEFAULT_HEIGHT: Record = { + male: 1.78, + female: 1.66, +}; diff --git a/tools/src/creator/types.ts b/tools/src/creator/types.ts new file mode 100644 index 0000000..c8cb042 --- /dev/null +++ b/tools/src/creator/types.ts @@ -0,0 +1,119 @@ +import type { Rgb } from "../image/Raster.js"; +import type { CoverageBand } from "./coverage.js"; +import type { MeshData } from "../mesh/MeshBuilder.js"; +import type { Skeleton } from "../rig/skeleton.js"; +import type { BodyStyle } from "./bodyStyle.js"; +import type { HeadStyle } from "./headStyle.js"; + +/** + * Modular character creator — PSX-budget outfit assembly. + * + * Two base bodies (male / female) share the canonical skeleton. Clothing, + * hair and accessories are separate low-poly parts drawn in code, then merged, + * skinned and exported as one GLB. Swap parts to mint every cast member + * without re-running photo analysis. + */ + +export type BodyType = "male" | "female"; + +export type { BodyStyle, HeadStyle }; + +/** Equipment slots. Body is always present; the rest are optional overlays. */ +export type PartSlot = "hair" | "upper" | "lower" | "feet" | "accessory"; + +export type PartId = string; + +export interface CreatorPalette { + skin: Rgb; + hair: Rgb; + /** Fallback dyes when a part doesn't specify its own colour. */ + primary: Rgb; + secondary: Rgb; + accent: Rgb; + metal: Rgb; + leather: Rgb; +} + +/** Context handed to every part builder. */ +export interface PartContext { + skeleton: Skeleton; + body: BodyType; + palette: CreatorPalette; + /** Mass / muscle / fat sliders (ludus-style). */ + bodyStyle: BodyStyle; + /** Face length / jaw / brow sliders. Hair and headgear fit off these. */ + headStyle: HeadStyle; + /** Optional per-part colour override. */ + color?: Rgb; +} + +export type PartBuilder = (ctx: PartContext) => MeshData; + +export interface PartDefinition { + id: PartId; + slot: PartSlot; + name: string; + /** If set, only valid on this body type. */ + body?: BodyType; + build: PartBuilder; + /** + * Skin this piece encloses, as bands of the body's loft parameters. The + * assembler deletes that skin so the garment owns the form outright. + * + * Only claim what a *closed* shell covers. Claiming skin under an open or + * banded piece shows the inside of the character through the gaps. + */ + coverage?: CoverageBand[]; +} + +/** A complete character recipe. */ +export interface CharacterRecipe { + id: string; + name: string; + body: BodyType; + height: number; + palette: CreatorPalette; + /** + * Physique sliders (ludus mass / muscle / fat). + * Omitted = average build. + */ + bodyStyle?: BodyStyle; + /** + * Face sliders (length / jaw / brow) on top of the sex face base. + * Omitted = the neutral face for this body type. + */ + headStyle?: HeadStyle; + /** + * Free-text art direction for the texture pass — wardrobe, material, wear. + * e.g. "charcoal three-piece business suit, white shirt, burgundy tie, + * polished black oxfords". + * + * Steers the Grok albedo only; it cannot change the silhouette, which is + * fixed by the equipped parts. Ignored when building vertex-colour GLBs. + * + * Part of the texture prompt, so editing it invalidates the cached albedo on + * its own — no need to force a regenerate after changing it. + */ + styleNotes?: string; + /** Part ids keyed by slot. Missing slots = bare for that region. */ + parts: Partial>; + /** Optional colour overrides keyed by part id. */ + partColors?: Record; +} + +export interface AssembledCharacter { + recipe: CharacterRecipe; + skeleton: Skeleton; + mesh: MeshData; + glb: Uint8Array; + stats: { + vertices: number; + triangles: number; + bones: number; + clips: number; + /** Body triangles deleted because a garment enclosed them. */ + skinHidden: number; + /** Body triangles kept but pushed inside a collar / cuff opening. */ + skinRecessed: number; + }; +} diff --git a/tools/src/export/glb.test.ts b/tools/src/export/glb.test.ts new file mode 100644 index 0000000..b13d998 --- /dev/null +++ b/tools/src/export/glb.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; +import { AnimationClip as ThreeClip, Bone, SkinnedMesh } from "three"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { buildDefaultClips } from "../anim/procedural.js"; +import { analyzeReference } from "../image/analyze.js"; +import { denoise, segmentForeground } from "../image/segment.js"; +import { DEFAULT_FIGURE, renderFigure } from "../image/testFixtures.js"; +import { buildHumanoidMesh } from "../mesh/humanoid.js"; +import { triangleCount, vertexCount } from "../mesh/MeshBuilder.js"; +import { buildSkeleton } from "../rig/skeleton.js"; +import { computeInverseBindMatrices, computeSkinWeights, skinStatistics } from "../rig/skin.js"; +import { exportGlb } from "./glb.js"; + +/** + * The exporter is hand-rolled, so "it produced bytes" proves nothing. These + * tests feed the output back through three's own `GLTFLoader` — the exact + * parser the runtime uses — and assert on the reconstructed scene graph. + */ + +function buildCharacter() { + const raster = renderFigure(DEFAULT_FIGURE); + const mask = denoise(segmentForeground(raster)); + const analysis = analyzeReference(raster, mask); + + const skeleton = buildSkeleton(analysis.measurements, { height: 1.7 }); + const mesh = buildHumanoidMesh(skeleton, analysis.measurements, analysis.palette); + computeSkinWeights(mesh, skeleton); + + const inverseBindMatrices = computeInverseBindMatrices(skeleton); + const clips = buildDefaultClips(); + const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { name: "TestCharacter", clips }); + + return { glb, mesh, skeleton, clips, analysis }; +} + +/** Parses GLB bytes with the real loader. */ +function parseGlb(glb: Uint8Array) { + const loader = new GLTFLoader(); + const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer; + + return new Promise((resolve, reject) => { + loader.parse(buffer, "", resolve, reject); + }); +} + +describe("GLB container", () => { + it("writes a valid header", () => { + const { glb } = buildCharacter(); + const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength); + + expect(view.getUint32(0, true)).toBe(0x46546c67); // "glTF" + expect(view.getUint32(4, true)).toBe(2); + expect(view.getUint32(8, true)).toBe(glb.byteLength); + }); + + it("keeps every chunk 4-byte aligned", () => { + const { glb } = buildCharacter(); + const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength); + + let offset = 12; + while (offset < glb.byteLength) { + const chunkLength = view.getUint32(offset, true); + expect(chunkLength % 4).toBe(0); + offset += 8 + chunkLength; + } + // Chunks must tile the file exactly. + expect(offset).toBe(glb.byteLength); + }); +}); + +describe("GLTFLoader round trip", () => { + it("parses without error", async () => { + const { glb } = buildCharacter(); + const gltf = await parseGlb(glb); + expect(gltf.scene).toBeDefined(); + }); + + it("reconstructs a skinned mesh with the expected geometry", async () => { + const { glb, mesh } = buildCharacter(); + const gltf = await parseGlb(glb); + + let skinned: SkinnedMesh | null = null; + gltf.scene.traverse((object) => { + if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh; + }); + + expect(skinned).not.toBeNull(); + const geometry = skinned!.geometry; + + expect(geometry.getAttribute("position").count).toBe(vertexCount(mesh)); + expect(geometry.index!.count / 3).toBe(triangleCount(mesh)); + + // Vertex colours carry the palette; without them the character is white. + expect(geometry.getAttribute("color")).toBeDefined(); + expect(geometry.getAttribute("normal")).toBeDefined(); + expect(geometry.getAttribute("skinIndex")).toBeDefined(); + expect(geometry.getAttribute("skinWeight")).toBeDefined(); + }); + + it("binds all 22 canonical bones", async () => { + const { glb, skeleton } = buildCharacter(); + const gltf = await parseGlb(glb); + + let skinned: SkinnedMesh | null = null; + gltf.scene.traverse((object) => { + if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh; + }); + + expect(skinned!.skeleton.bones).toHaveLength(skeleton.joints.length); + + const names = skinned!.skeleton.bones.map((bone: Bone) => bone.name); + expect(names).toContain("Hips"); + expect(names).toContain("Head"); + expect(names).toContain("LeftHand"); + expect(names).toContain("RightToeBase"); + }); + + it("preserves the bone hierarchy", async () => { + const { glb } = buildCharacter(); + const gltf = await parseGlb(glb); + + const bones = new Map(); + gltf.scene.traverse((object) => { + if ((object as Bone).isBone) bones.set(object.name, object as Bone); + }); + + // A flat list of bones would still load but would not animate correctly. + expect(bones.get("LeftForeArm")?.parent?.name).toBe("LeftArm"); + expect(bones.get("LeftLeg")?.parent?.name).toBe("LeftUpLeg"); + expect(bones.get("Spine")?.parent?.name).toBe("Hips"); + }); + + it("carries every animation clip with named bone tracks", async () => { + const { glb, clips } = buildCharacter(); + const gltf = await parseGlb(glb); + + expect(gltf.animations).toHaveLength(clips.length); + + const walk = gltf.animations.find((clip: ThreeClip) => clip.name === "Walk"); + expect(walk).toBeDefined(); + expect(walk!.duration).toBeGreaterThan(0); + expect(walk!.tracks.length).toBeGreaterThan(0); + + // Tracks must target bone names, which is what makes clips portable + // between characters produced by the harness. + const targets = walk!.tracks.map((track) => track.name.split(".")[0]); + expect(targets).toContain("LeftUpLeg"); + expect(targets).toContain("RightUpLeg"); + }); + + it("keeps the character upright and roughly the requested height", async () => { + const { glb } = buildCharacter(); + const gltf = await parseGlb(glb); + + let skinned: SkinnedMesh | null = null; + gltf.scene.traverse((object) => { + if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh; + }); + + skinned!.geometry.computeBoundingBox(); + const bbox = skinned!.geometry.boundingBox!; + + // Feet near the origin, crown near the target height. + expect(bbox.min.y).toBeGreaterThan(-0.05); + expect(bbox.max.y).toBeGreaterThan(1.5); + expect(bbox.max.y).toBeLessThan(1.85); + // Taller than wide — a figure, not a puddle. + expect(bbox.max.y - bbox.min.y).toBeGreaterThan(bbox.max.x - bbox.min.x); + }); +}); + +describe("skin weights", () => { + it("normalises every vertex to exactly one", () => { + const { mesh } = buildCharacter(); + const stats = skinStatistics(mesh); + expect(stats.unweighted).toBe(0); + expect(stats.maxInfluences).toBeLessThanOrEqual(4); + }); + + it("assigns hand vertices to the hand bone, not the torso", () => { + const { mesh, skeleton } = buildCharacter(); + const handIndex = skeleton.index.get("LeftHand")!; + const foreArmIndex = skeleton.index.get("LeftForeArm")!; + const handWorld = skeleton.joints[handIndex]!.world; + + // Find the vertex nearest the left hand joint. + let nearest = -1; + let nearestDistance = Infinity; + for (let v = 0; v < vertexCount(mesh); v++) { + const distance = Math.hypot( + mesh.positions[v * 3]! - handWorld[0], + mesh.positions[v * 3 + 1]! - handWorld[1], + mesh.positions[v * 3 + 2]! - handWorld[2], + ); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = v; + } + } + + const influences = [0, 1, 2, 3].map((i) => ({ + joint: mesh.joints[nearest * 4 + i]!, + weight: mesh.weights[nearest * 4 + i]!, + })); + const dominant = influences.reduce((best, entry) => (entry.weight > best.weight ? entry : best)); + + // Distance-to-joint skinning would let the spine claim this vertex; the + // segment-based version must not. + expect([handIndex, foreArmIndex]).toContain(dominant.joint); + }); + + it("keeps left-side vertices off right-side bones", () => { + const { mesh, skeleton } = buildCharacter(); + const rightBones = new Set( + skeleton.joints + .map((joint, index) => ({ joint, index })) + .filter(({ joint }) => joint.name.startsWith("Right")) + .map(({ index }) => index), + ); + + let violations = 0; + for (let v = 0; v < vertexCount(mesh); v++) { + const x = mesh.positions[v * 3]!; + if (x < 0.15) continue; // clearly on the left side + + for (let i = 0; i < 4; i++) { + if (rightBones.has(mesh.joints[v * 4 + i]!) && mesh.weights[v * 4 + i]! > 0.2) { + violations++; + } + } + } + expect(violations).toBe(0); + }); +}); diff --git a/tools/src/export/glb.ts b/tools/src/export/glb.ts new file mode 100644 index 0000000..ef65b4a --- /dev/null +++ b/tools/src/export/glb.ts @@ -0,0 +1,418 @@ +import { eulerToQuaternion, type AnimationClip } from "../anim/procedural.js"; +import { bounds, vertexCount, type MeshData } from "../mesh/MeshBuilder.js"; +import type { Skeleton } from "../rig/skeleton.js"; + +/** + * glTF 2.0 / GLB writer. + * + * Hand-rolled rather than using three's `GLTFExporter`, which expects a browser + * (Blob, FileReader) and a live scene graph. The pipeline is a Node process + * holding plain arrays, so emitting the container directly is both simpler and + * dependency-free — GLB is a JSON chunk and a binary chunk with 4-byte + * alignment, and that is the whole format. + */ + +const GLB_MAGIC = 0x46546c67; // "glTF" +const GLB_VERSION = 2; +const CHUNK_JSON = 0x4e4f534a; +const CHUNK_BIN = 0x004e4942; + +const COMPONENT_FLOAT = 5126; +const COMPONENT_UNSIGNED_SHORT = 5123; +const COMPONENT_UNSIGNED_INT = 5125; + +const TARGET_ARRAY_BUFFER = 34962; +const TARGET_ELEMENT_ARRAY_BUFFER = 34963; + +interface Accessor { + bufferView: number; + componentType: number; + count: number; + type: string; + min?: number[]; + max?: number[]; + normalized?: boolean; +} + +interface BufferView { + buffer: 0; + byteOffset: number; + byteLength: number; + target?: number; + byteStride?: number; +} + +/** Accumulates binary data with the 4-byte alignment glTF requires. */ +class BinaryWriter { + private readonly chunks: Uint8Array[] = []; + private length = 0; + + readonly bufferViews: BufferView[] = []; + readonly accessors: Accessor[] = []; + + private align(): void { + const padding = (4 - (this.length % 4)) % 4; + if (padding === 0) return; + this.chunks.push(new Uint8Array(padding)); + this.length += padding; + } + + private addView(bytes: Uint8Array, target?: number): number { + this.align(); + const byteOffset = this.length; + this.chunks.push(bytes); + this.length += bytes.byteLength; + + this.bufferViews.push({ + buffer: 0, + byteOffset, + byteLength: bytes.byteLength, + ...(target !== undefined ? { target } : {}), + }); + return this.bufferViews.length - 1; + } + + addFloats(values: ArrayLike, type: string, target?: number, withBounds = false): number { + const array = Float32Array.from(values as number[]); + const view = this.addView(new Uint8Array(array.buffer, array.byteOffset, array.byteLength), target); + const components = COMPONENTS_PER_TYPE[type]!; + + const accessor: Accessor = { + bufferView: view, + componentType: COMPONENT_FLOAT, + count: array.length / components, + type, + }; + + if (withBounds) { + const min = new Array(components).fill(Infinity); + const max = new Array(components).fill(-Infinity); + for (let i = 0; i < array.length; i += components) { + for (let c = 0; c < components; c++) { + const value = array[i + c]!; + if (value < min[c]!) min[c] = value; + if (value > max[c]!) max[c] = value; + } + } + accessor.min = min; + accessor.max = max; + } + + this.accessors.push(accessor); + return this.accessors.length - 1; + } + + addIndices(values: number[]): number { + // 16-bit where it fits: a PSX-budget mesh almost always does, and it halves + // the index buffer. + const needs32 = values.some((value) => value > 65535); + const array = needs32 ? Uint32Array.from(values) : Uint16Array.from(values); + const view = this.addView( + new Uint8Array(array.buffer, array.byteOffset, array.byteLength), + TARGET_ELEMENT_ARRAY_BUFFER, + ); + + this.accessors.push({ + bufferView: view, + componentType: needs32 ? COMPONENT_UNSIGNED_INT : COMPONENT_UNSIGNED_SHORT, + count: values.length, + type: "SCALAR", + }); + return this.accessors.length - 1; + } + + addJoints(values: number[]): number { + const array = Uint16Array.from(values); + const view = this.addView( + new Uint8Array(array.buffer, array.byteOffset, array.byteLength), + TARGET_ARRAY_BUFFER, + ); + + this.accessors.push({ + bufferView: view, + componentType: COMPONENT_UNSIGNED_SHORT, + count: values.length / 4, + type: "VEC4", + }); + return this.accessors.length - 1; + } + + /** Raw bytes (e.g. embedded PNG) — no accessor, just a bufferView. */ + addRaw(bytes: Uint8Array): number { + return this.addView(bytes); + } + + finish(): Uint8Array { + this.align(); + const out = new Uint8Array(this.length); + let offset = 0; + for (const chunk of this.chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; + } +} + +const COMPONENTS_PER_TYPE: Record = { + SCALAR: 1, + VEC2: 2, + VEC3: 3, + VEC4: 4, + MAT4: 16, +}; + +export interface ExportOptions { + name?: string; + clips?: AnimationClip[]; + /** Written into the asset's generator string for provenance. */ + generator?: string; + /** + * Optional image bytes for baseColorTexture (PNG or JPEG). + * Requires mesh.uvs to be populated (TEXCOORD_0). + */ + baseColorImage?: Uint8Array; + /** MIME type for baseColorImage. Default: sniff from magic bytes, else image/png. */ + baseColorMimeType?: string; +} + +export function exportGlb( + mesh: MeshData, + skeleton: Skeleton, + inverseBindMatrices: Float32Array, + options: ExportOptions = {}, +): Uint8Array { + const writer = new BinaryWriter(); + const name = options.name ?? "Character"; + const clips = options.clips ?? []; + const hasUvs = mesh.uvs.length >= (mesh.positions.length / 3) * 2 && mesh.uvs.length > 0; + const imageBytes = options.baseColorImage; + const hasTexture = Boolean(imageBytes && hasUvs); + const imageMime = + options.baseColorMimeType ?? + (imageBytes ? sniffImageMime(imageBytes) : "image/png"); + + // --- mesh attributes ----------------------------------------------------- + const positionAccessor = writer.addFloats(mesh.positions, "VEC3", TARGET_ARRAY_BUFFER, true); + const normalAccessor = writer.addFloats(mesh.normals, "VEC3", TARGET_ARRAY_BUFFER); + const colorAccessor = writer.addFloats(mesh.colors, "VEC4", TARGET_ARRAY_BUFFER); + const uvAccessor = hasUvs + ? writer.addFloats(mesh.uvs, "VEC2", TARGET_ARRAY_BUFFER) + : undefined; + const jointsAccessor = writer.addJoints(mesh.joints); + const weightsAccessor = writer.addFloats(mesh.weights, "VEC4", TARGET_ARRAY_BUFFER); + const indexAccessor = writer.addIndices(mesh.indices); + const ibmAccessor = writer.addFloats(inverseBindMatrices, "MAT4"); + + let imageBufferView: number | undefined; + if (hasTexture && imageBytes) { + imageBufferView = writer.addRaw(imageBytes); + } + + // --- node graph ---------------------------------------------------------- + // Node 0 is the skinned mesh; joints follow, so joint N is node N+1. + const JOINT_NODE_OFFSET = 1; + + const nodes: Array> = [ + { name, mesh: 0, skin: 0 }, + ]; + + for (const joint of skeleton.joints) { + const children = skeleton.joints + .map((other, index) => ({ other, index })) + .filter(({ other }) => other.parent === joint.name) + .map(({ index }) => index + JOINT_NODE_OFFSET); + + nodes.push({ + name: joint.name, + translation: joint.local, + ...(children.length > 0 ? { children } : {}), + }); + } + + const rootJointIndex = skeleton.joints.findIndex((joint) => joint.parent === null); + + // --- animations ---------------------------------------------------------- + const animations = clips.map((clip) => buildAnimation(clip, skeleton, writer, JOINT_NODE_OFFSET)); + + const attributes: Record = { + POSITION: positionAccessor, + NORMAL: normalAccessor, + COLOR_0: colorAccessor, + JOINTS_0: jointsAccessor, + WEIGHTS_0: weightsAccessor, + }; + if (uvAccessor !== undefined) attributes["TEXCOORD_0"] = uvAccessor; + + const material: Record = { + name: hasTexture ? "PSXTextured" : "PSXVertexColor", + pbrMetallicRoughness: { + baseColorFactor: [1, 1, 1, 1], + metallicFactor: 0, + roughnessFactor: 1, + ...(hasTexture ? { baseColorTexture: { index: 0 } } : {}), + }, + doubleSided: false, + }; + + const gltf: Record = { + asset: { + version: "2.0", + generator: options.generator ?? "psx-adventure-engine harness", + }, + scene: 0, + scenes: [{ nodes: [0, rootJointIndex + JOINT_NODE_OFFSET] }], + nodes, + meshes: [ + { + name: `${name}Mesh`, + primitives: [ + { + attributes, + indices: indexAccessor, + material: 0, + }, + ], + }, + ], + skins: [ + { + name: `${name}Skin`, + inverseBindMatrices: ibmAccessor, + skeleton: rootJointIndex + JOINT_NODE_OFFSET, + joints: skeleton.joints.map((_, index) => index + JOINT_NODE_OFFSET), + }, + ], + materials: [material], + ...(hasTexture + ? { + textures: [{ source: 0, sampler: 0 }], + images: [{ bufferView: imageBufferView, mimeType: imageMime }], + // Nearest sampling — PSX chunky texels. + samplers: [ + { + magFilter: 9728, // NEAREST + minFilter: 9728, // NEAREST + wrapS: 33071, // CLAMP_TO_EDGE + wrapT: 33071, + }, + ], + } + : {}), + ...(animations.length > 0 ? { animations } : {}), + accessors: writer.accessors, + bufferViews: writer.bufferViews, + buffers: [{ byteLength: 0 }], + }; + + const binary = writer.finish(); + (gltf["buffers"] as Array<{ byteLength: number }>)[0]!.byteLength = binary.byteLength; + + return packGlb(gltf, binary); +} + +function buildAnimation( + clip: AnimationClip, + skeleton: Skeleton, + writer: BinaryWriter, + jointNodeOffset: number, +): Record { + const samplers: Array> = []; + const channels: Array> = []; + + for (const channel of clip.channels) { + const jointIndex = skeleton.index.get(channel.bone); + // A clip may reference bones a given rig does not have; skip rather than + // fail, so clips stay portable across characters (GDD §10.2). + if (jointIndex === undefined) continue; + + const times = channel.keyframes.map((key) => key.time); + // Ensure consecutive quats take the *short* arc (dot ≥ 0). LINEAR + // interpolation of XYZW otherwise spins the long way around. + const rotations: number[] = []; + let prev: [number, number, number, number] | null = null; + for (const key of channel.keyframes) { + let q = eulerToQuaternion(key.rotation[0], key.rotation[1], key.rotation[2]) as [ + number, + number, + number, + number, + ]; + if (prev && prev[0] * q[0] + prev[1] * q[1] + prev[2] * q[2] + prev[3] * q[3] < 0) { + q = [-q[0], -q[1], -q[2], -q[3]]; + } + rotations.push(q[0], q[1], q[2], q[3]); + prev = q; + } + + // Rest rotations are identity, so a clip's quaternion is the final value. + const input = writer.addFloats(times, "SCALAR", undefined, true); + const output = writer.addFloats(rotations, "VEC4"); + + samplers.push({ input, output, interpolation: "LINEAR" }); + channels.push({ + sampler: samplers.length - 1, + target: { node: jointIndex + jointNodeOffset, path: "rotation" }, + }); + } + + return { name: clip.name, samplers, channels }; +} + +function packGlb(gltf: unknown, binary: Uint8Array): Uint8Array { + const jsonText = JSON.stringify(gltf); + const jsonBytes = new TextEncoder().encode(jsonText); + + // Both chunks pad to 4 bytes: JSON with spaces, BIN with zeros, per spec. + const jsonPadding = (4 - (jsonBytes.byteLength % 4)) % 4; + const binPadding = (4 - (binary.byteLength % 4)) % 4; + + const jsonChunkLength = jsonBytes.byteLength + jsonPadding; + const binChunkLength = binary.byteLength + binPadding; + const totalLength = 12 + 8 + jsonChunkLength + 8 + binChunkLength; + + const out = new Uint8Array(totalLength); + const view = new DataView(out.buffer); + let offset = 0; + + view.setUint32(offset, GLB_MAGIC, true); + view.setUint32(offset + 4, GLB_VERSION, true); + view.setUint32(offset + 8, totalLength, true); + offset += 12; + + view.setUint32(offset, jsonChunkLength, true); + view.setUint32(offset + 4, CHUNK_JSON, true); + offset += 8; + out.set(jsonBytes, offset); + // 0x20 is a space; JSON chunks pad with spaces so the text stays valid. + out.fill(0x20, offset + jsonBytes.byteLength, offset + jsonChunkLength); + offset += jsonChunkLength; + + view.setUint32(offset, binChunkLength, true); + view.setUint32(offset + 4, CHUNK_BIN, true); + offset += 8; + out.set(binary, offset); + + return out; +} + +/** Convenience for the pipeline report. */ +export function meshStatistics(mesh: MeshData): { vertices: number; triangles: number } { + const { min, max } = bounds(mesh); + void min; + void max; + return { vertices: vertexCount(mesh), triangles: mesh.indices.length / 3 }; +} + +function sniffImageMime(bytes: Uint8Array): string { + if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) { + return "image/png"; + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return "image/jpeg"; + } + if (bytes.length >= 12 && bytes[0] === 0x52 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42) { + return "image/webp"; + } + return "image/png"; +} diff --git a/tools/src/grok/GrokBridge.test.ts b/tools/src/grok/GrokBridge.test.ts new file mode 100644 index 0000000..ce33503 --- /dev/null +++ b/tools/src/grok/GrokBridge.test.ts @@ -0,0 +1,112 @@ +import { chmod, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { GrokBridge } from "./GrokBridge.js"; + +/** + * Cache behaviour, driven by a stub standing in for the Grok CLI. + * + * Generation costs real money, so the rule these tests pin down is: pay once + * per prompt, and pay again only when explicitly asked to — and when you do, + * the new image replaces the old one rather than piling up beside it. + */ + +/** A fake `grok` that writes a numbered image into `--cwd` and logs the call. */ +const STUB = `#!/usr/bin/env node +const { appendFileSync, readFileSync, writeFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const argv = process.argv.slice(2); +if (argv.includes("--version")) { + process.stdout.write("grok-stub 0.0.0\\n"); + process.exit(0); +} + +const log = process.env.STUB_LOG; +let calls = 0; +try { + calls = readFileSync(log, "utf8").split("\\n").filter(Boolean).length; +} catch {} +calls += 1; +appendFileSync(log, "call\\n"); + +const cwd = argv[argv.indexOf("--cwd") + 1]; +writeFileSync(join(cwd, \`gen-\${calls}.png\`), \`png-\${calls}\`); +process.stdout.write(JSON.stringify({ text: "done", total_cost_usd: 0.05 })); +`; + +const dirs: string[] = []; + +async function makeBridge() { + const dir = await mkdtemp(join(tmpdir(), "grok-bridge-")); + dirs.push(dir); + const stubPath = join(dir, "grok-stub.cjs"); + await writeFile(stubPath, STUB); + await chmod(stubPath, 0o755); + + const log = join(dir, "calls.log"); + process.env["STUB_LOG"] = log; + + const outputDir = join(dir, "cache"); + const bridge = new GrokBridge({ command: stubPath, outputDir, timeoutMs: 20_000 }); + const callCount = async () => + (await readFile(log, "utf8").catch(() => "")).split("\n").filter(Boolean).length; + return { bridge, outputDir, callCount }; +} + +afterEach(() => { + delete process.env["STUB_LOG"]; +}); + +describe("GrokBridge cache", () => { + it("pays once per prompt, then serves the cached image", async () => { + const { bridge, callCount } = await makeBridge(); + const request = { prompt: "a survivor atlas" }; + + const first = await bridge.generate(request); + expect(first.cached).toBe(false); + expect(await readFile(first.path, "utf8")).toBe("png-1"); + + const second = await bridge.generate(request); + expect(second.cached).toBe(true); + expect(second.costUsd).toBe(0); + expect(second.path).toBe(first.path); + // The CLI was never invoked a second time. + expect(await callCount()).toBe(1); + }); + + it("force repaints and replaces the cache entry in place", async () => { + const { bridge, outputDir, callCount } = await makeBridge(); + const request = { prompt: "a survivor atlas" }; + + await bridge.generate(request); + const forced = await bridge.generate({ ...request, force: true }); + + expect(forced.cached).toBe(false); + expect(await readFile(forced.path, "utf8")).toBe("png-2"); + expect(await callCount()).toBe(2); + + // One slot, one image — the stale file is gone rather than shadowing. + const slots = await readdir(outputDir); + expect(slots).toHaveLength(1); + const images = await readdir(join(outputDir, slots[0]!)); + expect(images).toEqual(["gen-2.png"]); + + // And the *unforced* path now serves the repainted image, which is the + // whole point: a forced run must land in the slot it is replacing. + const after = await bridge.generate(request); + expect(after.cached).toBe(true); + expect(await readFile(after.path, "utf8")).toBe("png-2"); + expect(await callCount()).toBe(2); + }); + + it("keeps different prompts in different slots", async () => { + const { bridge, outputDir } = await makeBridge(); + + await bridge.generate({ prompt: "one" }); + await bridge.generate({ prompt: "two", force: true }); + + expect(await readdir(outputDir)).toHaveLength(2); + }); +}); diff --git a/tools/src/grok/GrokBridge.ts b/tools/src/grok/GrokBridge.ts new file mode 100644 index 0000000..656438e --- /dev/null +++ b/tools/src/grok/GrokBridge.ts @@ -0,0 +1,291 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { access, mkdir, readdir, rm, stat } from "node:fs/promises"; +import { constants } from "node:fs"; +import { join, resolve } from "node:path"; + +/** + * Bridge to the Grok Build CLI for reference-image generation. + * + * `/imagine` is not a CLI subcommand — it is a bundled *skill* that documents + * two agent tools, `image_gen` and `image_edit`. Neither is reachable from the + * shell directly, so the bridge drives a headless single-turn session + * (`grok -p … --output-format json`) and lets the agent make the tool call, + * then recovers the file it wrote. + * + * Two consequences shape the design: + * + * 1. The agent decides where the file lands. We give it a dedicated working + * directory and diff the contents rather than trusting the path it reports + * in prose, which is free-form and occasionally wrapped or annotated. + * 2. Generation costs real money and takes ~60-90s, so results are cached by + * prompt hash. Re-running the pipeline on the same prompt is free. + */ + +export interface GrokBridgeOptions { + /** Executable name or absolute path. */ + command?: string; + /** Where generated images are written and cached. */ + outputDir?: string; + timeoutMs?: number; + model?: string; +} + +export interface ImageRequest { + prompt: string; + aspectRatio?: "1:1" | "16:9" | "9:16" | "4:3" | "3:4" | "auto"; + /** Existing image(s) to edit. Present means `image_edit` rather than `image_gen`. */ + sourceImages?: string[]; + /** + * Re-run even if this prompt is already cached, and replace the cache entry + * with the new image. Costs money every time — see {@link GrokBridge.generate}. + * + * Deliberately absent from {@link hashRequest}: a forced run has to land in + * the *same* cache slot it is replacing, or the stale image would keep + * winning on the next unforced call. + */ + force?: boolean; +} + +export interface ImageResult { + path: string; + prompt: string; + cached: boolean; + costUsd: number; + elapsedMs: number; +} + +export class GrokUnavailableError extends Error { + constructor(command: string) { + super( + `Grok CLI not found (looked for "${command}"). Install it, or pass an explicit image ` + + `with --image to skip generation.`, + ); + this.name = "GrokUnavailableError"; + } +} + +export class GrokGenerationError extends Error { + constructor( + message: string, + readonly stderr: string, + ) { + super(message); + this.name = "GrokGenerationError"; + } +} + +interface GrokJsonResponse { + text?: string; + stopReason?: string; + total_cost_usd?: number; + sessionId?: string; +} + +const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]); + +export class GrokBridge { + private readonly command: string; + private readonly outputDir: string; + private readonly timeoutMs: number; + private readonly model: string | undefined; + + constructor(options: GrokBridgeOptions = {}) { + this.command = options.command ?? process.env["GROK_BIN"] ?? "grok"; + this.outputDir = resolve(options.outputDir ?? "assets/references"); + this.timeoutMs = options.timeoutMs ?? 5 * 60_000; + this.model = options.model; + } + + /** True when the CLI is on PATH and responds. */ + async isAvailable(): Promise { + try { + await this.exec(["--version"], 20_000); + return true; + } catch { + return false; + } + } + + /** + * Generate (or edit) a reference image. + * + * The prompt is wrapped in an instruction that pins the agent to a single + * tool call and a predictable output location — left to its own devices it + * may narrate, ask questions, or save somewhere else. + * + * `request.force` skips the cache and overwrites it. Worth reaching for + * because the cache key is the prompt, not the mesh: edit a character's face + * or physique without renaming it and the prompt is unchanged, so an unforced + * run happily returns the texture painted for the *old* body. + */ + async generate(request: ImageRequest): Promise { + const started = Date.now(); + const key = hashRequest(request); + const workDir = join(this.outputDir, key); + + if (request.force) { + // Clear the slot first, so the fresh image is unambiguously the one that + // `findCached` picks up next time (it takes the first name in sort order, + // which a leftover file could otherwise win). + await this.clearCache(workDir); + } else { + const cachedPath = await this.findCached(workDir); + if (cachedPath) { + return { path: cachedPath, prompt: request.prompt, cached: true, costUsd: 0, elapsedMs: 0 }; + } + } + + await mkdir(workDir, { recursive: true }); + const before = await listImages(workDir); + + const args = ["-p", this.buildInstruction(request), "--output-format", "json", "--always-approve", "--cwd", workDir]; + if (this.model) args.push("--model", this.model); + + const { stdout, stderr } = await this.exec(args, this.timeoutMs); + + let response: GrokJsonResponse = {}; + try { + response = JSON.parse(stdout) as GrokJsonResponse; + } catch { + throw new GrokGenerationError("Grok did not return parseable JSON", stderr || stdout.slice(0, 500)); + } + + // Prefer a file that actually appeared over the path quoted in prose. + const after = await listImages(workDir); + const created = after.filter((file) => !before.includes(file)); + const path = created[0] ?? extractPath(response.text ?? ""); + + if (!path) { + throw new GrokGenerationError( + `Grok produced no image. It said: ${(response.text ?? "").slice(0, 300)}`, + stderr, + ); + } + + return { + path: created[0] ? join(workDir, created[0]) : path, + prompt: request.prompt, + cached: false, + costUsd: response.total_cost_usd ?? 0, + elapsedMs: Date.now() - started, + }; + } + + private buildInstruction(request: ImageRequest): string { + const aspect = request.aspectRatio ?? "3:4"; + + if (request.sourceImages?.length) { + return [ + `Call image_edit exactly once with these source images: ${request.sourceImages.join(", ")}.`, + `Transformation: ${request.prompt}`, + `Save the resulting image into the current working directory.`, + `Do not ask questions. Reply with only the absolute file path on the final line.`, + ].join("\n"); + } + + return [ + `Call image_gen exactly once with aspect_ratio ${aspect}.`, + `Prompt: ${request.prompt}`, + `Save the resulting image into the current working directory.`, + `Do not ask questions and do not generate more than one image.`, + `Reply with only the absolute file path on the final line.`, + ].join("\n"); + } + + private async findCached(workDir: string): Promise { + try { + const images = await listImages(workDir); + return images[0] ? join(workDir, images[0]) : null; + } catch { + return null; + } + } + + /** + * Drop the images in one cache slot. + * + * Only image files, and only inside the hashed directory — never the slot + * itself or anything else the agent left behind. `workDir` is built from a + * hex digest, so it cannot escape `outputDir`. + */ + private async clearCache(workDir: string): Promise { + for (const name of await listImages(workDir)) { + await rm(join(workDir, name), { force: true }); + } + } + + private exec(args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolvePromise, reject) => { + let child: ReturnType; + try { + child = spawn(this.command, args, { stdio: ["ignore", "pipe", "pipe"] }); + } catch { + reject(new GrokUnavailableError(this.command)); + return; + } + + let stdout = ""; + let stderr = ""; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + reject(new GrokGenerationError(`Grok timed out after ${timeoutMs}ms`, stderr)); + }, timeoutMs); + + child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + + child.on("error", (error: NodeJS.ErrnoException) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error.code === "ENOENT" ? new GrokUnavailableError(this.command) : error); + }); + + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code !== 0) { + reject(new GrokGenerationError(`Grok exited with code ${code}`, stderr)); + return; + } + resolvePromise({ stdout, stderr }); + }); + }); + } +} + +function hashRequest(request: ImageRequest): string { + return createHash("sha256") + .update(JSON.stringify([request.prompt, request.aspectRatio, request.sourceImages])) + .digest("hex") + .slice(0, 16); +} + +async function listImages(dir: string): Promise { + const entries = await readdir(dir).catch(() => [] as string[]); + const images: string[] = []; + for (const entry of entries) { + const dot = entry.lastIndexOf("."); + if (dot === -1 || !IMAGE_EXTENSIONS.has(entry.slice(dot).toLowerCase())) continue; + const info = await stat(join(dir, entry)).catch(() => null); + if (info?.isFile()) images.push(entry); + } + return images.sort(); +} + +/** Last absolute-looking path in the agent's prose, as a fallback. */ +function extractPath(text: string): string | null { + const matches = text.match(/\/[^\s"'`]+\.(?:jpg|jpeg|png|webp)/gi); + return matches?.[matches.length - 1] ?? null; +} + +/** Confirms a path exists and is readable before the pipeline commits to it. */ +export async function ensureReadable(path: string): Promise { + await access(path, constants.R_OK); +} diff --git a/tools/src/grok/prompts.ts b/tools/src/grok/prompts.ts new file mode 100644 index 0000000..12ab477 --- /dev/null +++ b/tools/src/grok/prompts.ts @@ -0,0 +1,61 @@ +import type { ImageRequest } from "./GrokBridge.js"; + +/** + * Reference-sheet prompt templates. + * + * The analysis stage measures a silhouette, so the prompt is engineered for + * *measurability* rather than beauty: a plain background it can flood-fill + * away, flat lighting so shadows do not read as body mass, limbs held clear of + * the torso so the row-width profile can separate them, and the whole figure + * inside frame so no landmark is cropped. + */ + +export interface CharacterPromptOptions { + /** Free-form description: "a lone survivor in a heavy canvas jacket". */ + description: string; + /** Held clear of the body so arms and torso separate in the silhouette. */ + pose?: "a-pose" | "t-pose"; +} + +const MEASURABILITY_CLAUSE = + "Flat, even, shadowless lighting. Plain uniform neutral grey background with nothing else in frame. " + + "The entire body is visible from the top of the head to the soles of the boots, with clear space " + + "above and below. Straight-on front view, camera at chest height, no perspective foreshortening. " + + "No text, no labels, no props on the ground, no cast shadow."; + +export function characterReferencePrompt(options: CharacterPromptOptions): ImageRequest { + const pose = + options.pose === "t-pose" + ? "standing in a symmetrical T-pose with both arms straight out horizontally at shoulder height, " + + "legs straight and slightly apart, palms down" + : "standing in a symmetrical A-pose with both arms held down and away from the body at about " + + "45 degrees, clear of the torso, legs straight and slightly apart"; + + return { + prompt: `Full-body character reference sheet. ${options.description}, ${pose}. ${MEASURABILITY_CLAUSE} Low-poly PSX-era survival horror video game character art, muted desaturated palette, strong readable silhouette.`, + aspectRatio: "3:4", + }; +} + +/** + * Variation of an existing character, for a cast that shares proportions. + * Uses `image_edit` so the silhouette stays measurable and consistent. + */ +export function characterVariantPrompt( + sourceImage: string, + change: string, +): ImageRequest { + return { + prompt: `${change}. Keep the exact same pose, framing, body proportions, camera angle, flat lighting and plain grey background.`, + sourceImages: [sourceImage], + aspectRatio: "3:4", + }; +} + +/** Prop/item reference — GDD §10.1's secondary pipeline. */ +export function propReferencePrompt(description: string): ImageRequest { + return { + prompt: `A single ${description}, isolated product shot, centred in frame, straight-on side view. ${MEASURABILITY_CLAUSE} Low-poly PSX-era survival horror game asset, muted palette.`, + aspectRatio: "1:1", + }; +} diff --git a/tools/src/image/Raster.ts b/tools/src/image/Raster.ts new file mode 100644 index 0000000..f2be6bf --- /dev/null +++ b/tools/src/image/Raster.ts @@ -0,0 +1,62 @@ +import { readFile } from "node:fs/promises"; +import { extname } from "node:path"; +import jpeg from "jpeg-js"; +import { PNG } from "pngjs"; + +/** Decoded RGBA image, row-major, 4 bytes per pixel. */ +export interface Raster { + width: number; + height: number; + data: Uint8Array; +} + +export interface Rgb { + r: number; + g: number; + b: number; +} + +export async function loadRaster(path: string): Promise { + const bytes = await readFile(path); + const extension = extname(path).toLowerCase(); + + if (extension === ".png") { + const png = PNG.sync.read(bytes); + return { width: png.width, height: png.height, data: new Uint8Array(png.data) }; + } + + if (extension === ".jpg" || extension === ".jpeg") { + // `useTArray` keeps the result a Uint8Array rather than a Node Buffer. + const decoded = jpeg.decode(bytes, { useTArray: true }); + return { width: decoded.width, height: decoded.height, data: new Uint8Array(decoded.data) }; + } + + throw new Error(`Unsupported image format: ${extension || path}. Use PNG or JPEG.`); +} + +export const pixelIndex = (raster: Raster, x: number, y: number): number => + (y * raster.width + x) * 4; + +export function pixelAt(raster: Raster, x: number, y: number): Rgb { + const i = pixelIndex(raster, x, y); + return { r: raster.data[i]!, g: raster.data[i + 1]!, b: raster.data[i + 2]! }; +} + +/** Perceptual-ish distance. Cheap, and good enough to separate a flat backdrop. */ +export function colorDistance(a: Rgb, b: Rgb): number { + const dr = a.r - b.r; + const dg = a.g - b.g; + const db = a.b - b.b; + // Green weighted highest, matching luminance sensitivity. + return Math.sqrt(dr * dr * 0.3 + dg * dg * 0.59 + db * db * 0.11); +} + +export const luminance = (color: Rgb): number => + (color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722) / 255; + +/** Debug output: write a mask or raster to PNG so a human can eyeball it. */ +export function rasterToPng(raster: Raster): Buffer { + const png = new PNG({ width: raster.width, height: raster.height }); + png.data = Buffer.from(raster.data); + return PNG.sync.write(png); +} diff --git a/tools/src/image/analyze.test.ts b/tools/src/image/analyze.test.ts new file mode 100644 index 0000000..7d2126a --- /dev/null +++ b/tools/src/image/analyze.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { analyzeReference } from "./analyze.js"; +import { colorDistance } from "./Raster.js"; +import { denoise, segmentForeground } from "./segment.js"; +import { DEFAULT_FIGURE, PACKED_FIGURE, renderFigure } from "./testFixtures.js"; + +/** + * Ground truth is known here, so tolerances are tight where the analyzer + * genuinely measures and loose where it admits to deriving. + */ + +function analyze(spec = DEFAULT_FIGURE) { + const raster = renderFigure(spec); + const mask = denoise(segmentForeground(raster)); + return { ...analyzeReference(raster, mask), mask }; +} + +/** Landmarks are normalised to the *body*, the fixture to the *image*. */ +function toBodySpace(spec: typeof DEFAULT_FIGURE, imageFraction: number): number { + return (imageFraction - spec.headTop) / (spec.soleY - spec.headTop); +} + +describe("segmentation", () => { + it("separates the figure from a flat backdrop", () => { + const { mask } = analyze(); + expect(mask.coverage).toBeGreaterThan(0.05); + expect(mask.coverage).toBeLessThan(0.6); + }); + + it("keeps a backdrop-coloured patch enclosed by the body", () => { + // A grey badge in the middle of the torso, the exact colour of the + // backdrop. A global colour threshold would punch a hole through the + // character; a flood fill from the border cannot reach it. + const spec = DEFAULT_FIGURE; + const raster = renderFigure(spec); + + const midX = Math.round(spec.width / 2); + const torsoY = Math.round(((spec.shoulderY + spec.crotchY) / 2) * spec.height); + for (let y = torsoY - 12; y <= torsoY + 12; y++) { + for (let x = midX - 12; x <= midX + 12; x++) { + const i = (y * spec.width + x) * 4; + raster.data[i] = spec.backdrop.r; + raster.data[i + 1] = spec.backdrop.g; + raster.data[i + 2] = spec.backdrop.b; + } + } + + const mask = denoise(segmentForeground(raster)); + expect(mask.data[torsoY * mask.width + midX]).toBe(1); + }); +}); + +describe("analyzeReference landmarks", () => { + it("measures the neck near the head/body junction", () => { + const { measurements } = analyze(); + expect(measurements.landmarks.neck.y).toBeCloseTo( + toBodySpace(DEFAULT_FIGURE, DEFAULT_FIGURE.neckY), + 1, + ); + }); + + it("measures the crotch where the legs separate", () => { + const { measurements } = analyze(); + expect(measurements.landmarks.crotch.source).toBe("measured"); + expect(measurements.landmarks.crotch.y).toBeCloseTo( + toBodySpace(DEFAULT_FIGURE, DEFAULT_FIGURE.crotchY), + 1, + ); + }); + + it("measures the ankle above the boot flare", () => { + const { measurements } = analyze(); + expect(measurements.landmarks.ankle.y).toBeGreaterThan(0.85); + expect(measurements.landmarks.ankle.y).toBeLessThan(0.98); + }); + + it("orders every landmark head to toe", () => { + const { landmarks } = analyze().measurements; + const order = [ + landmarks.neck.y, + landmarks.shoulder.y, + landmarks.chest.y, + landmarks.waist.y, + landmarks.hip.y, + landmarks.crotch.y, + landmarks.knee.y, + landmarks.ankle.y, + ]; + for (let i = 1; i < order.length; i++) { + expect(order[i]).toBeGreaterThanOrEqual(order[i - 1]!); + } + }); +}); + +describe("analyzeReference robustness", () => { + it("rejects an implausible shoulder caused by worn gear", () => { + // The pack merges with the collar, so the width test fires under the jaw. + // The analyzer must notice and fall back rather than rig arms to the chin. + const { measurements, notes } = analyze(PACKED_FIGURE); + + expect(measurements.landmarks.shoulder.source).toBe("derived"); + expect(notes.join(" ")).toContain("plausible band"); + // Still lands somewhere a shoulder could actually be. + expect(measurements.landmarks.shoulder.y).toBeGreaterThan(0.12); + expect(measurements.landmarks.shoulder.y).toBeLessThan(0.3); + }); + + it("keeps the torso width free of the arms", () => { + const { measurements } = analyze(); + // Torso is 0.26 of image height; arms add ~0.15 per side if wrongly merged. + const expected = DEFAULT_FIGURE.torsoWidth / (DEFAULT_FIGURE.soleY - DEFAULT_FIGURE.headTop); + expect(measurements.widths.chest).toBeLessThan(expected * 1.4); + }); + + it("reports a thigh wider than a calf", () => { + const { measurements } = analyze(); + expect(measurements.widths.thigh).toBeGreaterThan(0); + expect(measurements.widths.calf).toBeGreaterThan(0); + }); + + it("survives a figure whose legs never separate", () => { + const { measurements, notes } = analyze({ ...DEFAULT_FIGURE, legGap: 0 }); + expect(measurements.landmarks.crotch.y).toBeGreaterThan(0.3); + expect(measurements.landmarks.crotch.y).toBeLessThan(0.75); + if (measurements.landmarks.crotch.source === "derived") { + expect(notes.join(" ")).toContain("crotch"); + } + }); +}); + +describe("palette sampling", () => { + it("recovers the colour of each body region", () => { + const { palette } = analyze(); + const { colors } = DEFAULT_FIGURE; + + // Generous tolerance: the sampler insets from edges and takes a median. + expect(colorDistance(palette.hair, colors.hair)).toBeLessThan(40); + expect(colorDistance(palette.skin, colors.skin)).toBeLessThan(40); + expect(colorDistance(palette.torsoUpper, colors.torso)).toBeLessThan(40); + expect(colorDistance(palette.leg, colors.leg)).toBeLessThan(40); + expect(colorDistance(palette.foot, colors.foot)).toBeLessThan(40); + }); + + it("distinguishes the arms from the torso", () => { + const { palette } = analyze(); + expect(colorDistance(palette.arm, DEFAULT_FIGURE.colors.arm)).toBeLessThan(45); + }); + + it("never returns the backdrop colour for a body region", () => { + const { palette } = analyze(); + for (const [region, color] of Object.entries(palette)) { + expect( + colorDistance(color, DEFAULT_FIGURE.backdrop), + `${region} sampled the backdrop`, + ).toBeGreaterThan(8); + } + }); +}); + +describe("analyzeReference on a real generated reference", () => { + // The image Grok's image_gen actually produced: a survivor in a bulky jacket + // wearing a high pack. Synthetic fixtures cannot reproduce JPEG ringing, + // painted shading, or gear that genuinely merges with the collar. + const fixture = new URL("../../fixtures/survivor-reference.jpg", import.meta.url).pathname; + + it("produces an anatomically ordered figure", async () => { + const { loadRaster } = await import("./Raster.js"); + const raster = await loadRaster(fixture); + const mask = denoise(segmentForeground(raster)); + const { measurements, palette } = analyzeReference(raster, mask); + const { landmarks, widths } = measurements; + + expect(landmarks.neck.y).toBeGreaterThan(0.08); + expect(landmarks.neck.y).toBeLessThan(0.2); + // Must not be dragged up to the arm-separation line by the loose jacket. + expect(landmarks.crotch.y).toBeGreaterThan(0.5); + expect(landmarks.crotch.y).toBeLessThan(0.62); + expect(landmarks.ankle.y).toBeGreaterThan(0.85); + + // Torso tapers downward, and never includes the arms. + expect(widths.chest).toBeGreaterThan(widths.hip); + expect(widths.chest).toBeLessThan(0.35); + expect(widths.thigh).toBeGreaterThan(widths.calf); + + // The jacket is olive and the trousers are dark; they must not be confused. + expect(palette.torsoUpper.g).toBeGreaterThan(palette.leg.g); + }, 30_000); +}); diff --git a/tools/src/image/analyze.ts b/tools/src/image/analyze.ts new file mode 100644 index 0000000..04633de --- /dev/null +++ b/tools/src/image/analyze.ts @@ -0,0 +1,538 @@ +import { luminance, pixelAt, type Raster, type Rgb } from "./Raster.js"; +import { isForeground, type Mask } from "./segment.js"; + +/** + * Turns a reference image into measurements the mesh builder can draw from. + * + * The core trick is **run structure per scanline** rather than raw width. For a + * front-facing figure with arms held clear of the body, a horizontal scan + * yields a predictable pattern: + * + * head/neck 1 run + * shoulders+arms 3 runs [left arm | torso | right arm] + * below the arms 1 run (torso only) + * below the crotch 2 runs [left leg | right leg] + * + * The transitions between those counts are the landmarks, and they are far more + * robust than thresholding widths: they survive baggy clothing, backpack + * straps, and the figure being off-centre. + * + * Anything that cannot be measured falls back to a documented anthropometric + * ratio, and `MeasurementSource` records which is which so the caller can tell + * a measurement from an assumption. + */ + +export type MeasurementSource = "measured" | "derived"; + +export interface Landmark { + /** Normalised 0 (top of head) to 1 (soles). */ + y: number; + source: MeasurementSource; +} + +export interface Measurements { + /** Pixel bounds used, for debugging overlays. */ + pixelHeight: number; + pixelWidth: number; + + landmarks: { + neck: Landmark; + shoulder: Landmark; + chest: Landmark; + waist: Landmark; + hip: Landmark; + crotch: Landmark; + knee: Landmark; + ankle: Landmark; + }; + + /** All widths normalised against total body height. */ + widths: { + head: number; + neck: number; + shoulder: number; + chest: number; + waist: number; + hip: number; + thigh: number; + calf: number; + foot: number; + upperArm: number; + forearm: number; + }; + + /** Half the full arm span, normalised to body height. */ + armReach: number; + /** Horizontal offset of each foot centre from the body midline. */ + stance: number; + /** Body height in pixels over width, for sanity checks. */ + aspect: number; +} + +export interface Palette { + hair: Rgb; + skin: Rgb; + torsoUpper: Rgb; + torsoLower: Rgb; + arm: Rgb; + hand: Rgb; + leg: Rgb; + foot: Rgb; +} + +export interface ReferenceAnalysis { + measurements: Measurements; + palette: Palette; + /** Warnings where a landmark could not be measured and was derived. */ + notes: string[]; +} + +interface Run { + start: number; + end: number; +} + +/** Runs shorter than this fraction of body width are speckle, not anatomy. */ +const MIN_RUN_FRACTION = 0.012; + +function runsForRow(mask: Mask, y: number, minRunWidth: number): Run[] { + const runs: Run[] = []; + let start = -1; + + for (let x = 0; x <= mask.width; x++) { + const solid = x < mask.width && isForeground(mask, x, y); + if (solid && start === -1) start = x; + else if (!solid && start !== -1) { + if (x - start >= minRunWidth) runs.push({ start, end: x - 1 }); + start = -1; + } + } + return runs; +} + +const runWidth = (run: Run): number => run.end - run.start + 1; + +/** The run containing the body midline, or the widest as a fallback. */ +function centralRun(runs: Run[], midX: number): Run | null { + if (runs.length === 0) return null; + const containing = runs.find((run) => midX >= run.start && midX <= run.end); + if (containing) return containing; + return runs.reduce((widest, run) => (runWidth(run) > runWidth(widest) ? run : widest)); +} + +export function analyzeReference(raster: Raster, mask: Mask): ReferenceAnalysis { + const notes: string[] = []; + const { minY, maxY, minX, maxX } = mask.bounds; + + const pixelHeight = maxY - minY + 1; + const pixelWidth = maxX - minX + 1; + const minRunWidth = Math.max(2, Math.floor(pixelWidth * MIN_RUN_FRACTION)); + + // Midline from the centre of mass, not the bounding box: an asymmetric pose + // (a backpack, one arm lower) would otherwise skew the box. + const midX = centreOfMassX(mask); + + const rows: Array<{ y: number; runs: Run[]; central: Run | null }> = []; + for (let y = minY; y <= maxY; y++) { + const runs = runsForRow(mask, y, minRunWidth); + rows.push({ y, runs, central: centralRun(runs, midX) }); + } + + const norm = (y: number): number => (y - minY) / Math.max(1, pixelHeight - 1); + const at = (t: number) => rows[Math.min(rows.length - 1, Math.max(0, Math.round(t * (rows.length - 1))))]!; + + // --- neck: narrowest central run in the upper third -------------------- + let neckIndex = -1; + let neckWidth = Number.POSITIVE_INFINITY; + for (let i = Math.floor(rows.length * 0.06); i < Math.floor(rows.length * 0.3); i++) { + const central = rows[i]?.central; + if (!central) continue; + if (runWidth(central) < neckWidth) { + neckWidth = runWidth(central); + neckIndex = i; + } + } + if (neckIndex === -1) { + neckIndex = Math.floor(rows.length * 0.13); + neckWidth = runWidth(at(0.13).central ?? { start: 0, end: 10 }); + notes.push("neck not measurable; assumed at 13% of height"); + } + + // --- shoulder: where the torso itself widens past the neck ------------- + // + // Deliberately driven by the *central* run rather than by a 3-run split. + // Gear that sticks out sideways — a backpack, a rifle, a raised collar — + // produces side runs above the true shoulder line and fools a run-count test + // into placing the shoulder on top of the neck. + // Shoulder breadth runs roughly 2.2-2.7x neck breadth on an adult, so the + // yoke announces itself as an abrupt multiple of the neck — not as a gentle + // widening, which a collar or scarf also produces. + // + // The run-count guard matters as much as the width test: gear that projects + // sideways (a backpack, a raised hood) fragments the scanline into four or + // more runs while the central one is still narrow. Those rows are rejected + // outright rather than mistaken for the shoulder line. + const torsoSearchEnd = Math.floor(rows.length * 0.45); + + const shoulderIndex = firstSustained( + rows, + neckIndex + 1, + torsoSearchEnd, + (row) => + row.runs.length <= 3 && row.central !== null && runWidth(row.central) >= neckWidth * 2, + Math.max(2, Math.floor(rows.length * 0.02)), + ); + // Anthropometric plausibility gate. + // + // Head height (crown to chin) is the one vertical measurement that is + // reliable here, and adult shoulder height sits about 1.35-2.1 head heights + // below the crown. A "shoulder" outside that band is not a shoulder — on a + // figure wearing a high pack the gear merges with the collar and the width + // test fires just under the jaw. Rather than trust it, fall back to 1.6 head + // heights and say so. + const headHeightRows = Math.max(1, neckIndex); + const shoulderMin = Math.round(headHeightRows * 1.35); + const shoulderMax = Math.round(headHeightRows * 2.1); + + let shoulderSource: MeasurementSource = "measured"; + let resolvedShoulder = shoulderIndex; + + if (resolvedShoulder === -1) { + resolvedShoulder = Math.round(headHeightRows * 1.6); + shoulderSource = "derived"; + notes.push("shoulder not measurable; derived as 1.6 head heights below the crown"); + } else if (resolvedShoulder < shoulderMin || resolvedShoulder > shoulderMax) { + notes.push( + `shoulder measured at ${(norm(rows[resolvedShoulder]!.y)).toFixed(3)} of height, outside the ` + + `plausible band — worn gear likely merged with the collar; derived from head height instead`, + ); + resolvedShoulder = Math.round(headHeightRows * 1.6); + shoulderSource = "derived"; + } + resolvedShoulder = Math.min(rows.length - 1, Math.max(0, resolvedShoulder)); + + // --- crotch: where one run becomes two, and *stays* two ---------------- + // + // A long coat hem, a belt, or a dangling strap all notch the silhouette + // briefly. Real legs stay separated all the way to the floor, so the split + // must persist across most of the remaining height to count. + // Exactly two runs, not "two or more": with the arms still clear of the body + // a scanline reads [arm | torso | arm] — three runs — and a `>= 2` test would + // call the point where the *arms* separate the crotch. Two runs and only two + // means the arms have ended and what remains is a pair of legs. + const legSearchStart = Math.floor(rows.length * 0.4); + let crotchSource: MeasurementSource = "measured"; + let crotchIndex = firstSustained( + rows, + legSearchStart, + Math.floor(rows.length * 0.75), + (row) => row.runs.length === 2, + Math.max(4, Math.floor(rows.length * 0.12)), + ); + if (crotchIndex === -1) { + crotchIndex = Math.floor(rows.length * 0.53); + crotchSource = "derived"; + notes.push("legs never cleanly separate; crotch assumed at 53% of height"); + } + + // --- ankle: narrowest leg span between crotch and the boot flare ------- + const footSearchStart = crotchIndex + Math.floor((rows.length - crotchIndex) * 0.55); + let ankleIndex = -1; + let ankleWidth = Number.POSITIVE_INFINITY; + for (let i = footSearchStart; i < rows.length - Math.floor(rows.length * 0.02); i++) { + const total = rows[i]!.runs.reduce((sum, run) => sum + runWidth(run), 0); + if (total > 0 && total < ankleWidth) { + ankleWidth = total; + ankleIndex = i; + } + } + if (ankleIndex === -1) { + ankleIndex = Math.floor(rows.length * 0.94); + notes.push("ankle not measurable; assumed at 94% of height"); + } + + // Knee sits midway between crotch and ankle on a standing figure. There is no + // reliable silhouette cue for it through trousers, so this one is always + // derived rather than pretending to measure it. + const kneeIndex = Math.round((crotchIndex + ankleIndex) / 2); + + const chestIndex = Math.round(resolvedShoulder + (crotchIndex - resolvedShoulder) * 0.25); + const waistIndex = Math.round(resolvedShoulder + (crotchIndex - resolvedShoulder) * 0.68); + const hipIndex = Math.round(resolvedShoulder + (crotchIndex - resolvedShoulder) * 0.88); + + // --- widths ------------------------------------------------------------ + const centralWidthAt = (index: number): number => { + const central = rows[Math.min(rows.length - 1, Math.max(0, index))]?.central; + return central ? runWidth(central) : 0; + }; + + const headWidth = maxRunWidthBetween(rows, 0, neckIndex); + + /** + * Torso width is only observable where the arms are clear of the body. On + * rows where they overlap, the central run is torso *plus* both arms and + * over-reports by 50% or more, so those rows are not used at all — the + * reference width comes from the band where the silhouette actually + * separates, and the unmeasurable rows are scaled from it. + */ + const separatedWidths: number[] = []; + for (let i = resolvedShoulder; i < crotchIndex; i++) { + const row = rows[i]!; + if (row.runs.length >= 3 && row.central) separatedWidths.push(runWidth(row.central)); + } + const torsoReference = + separatedWidths.length > 0 ? median(separatedWidths) : centralWidthAt(waistIndex); + + const torsoWidthAt = (index: number, fallbackRatio: number): number => { + const row = rows[Math.min(rows.length - 1, Math.max(0, index))]; + return row && row.runs.length >= 3 && row.central + ? runWidth(row.central) + : torsoReference * fallbackRatio; + }; + + // Shoulder breadth tracks chest breadth closely on a clothed figure. + const shoulderWidth = torsoWidthAt(resolvedShoulder, 1); + + // Arm thickness from the side runs where they are cleanly separated. + const armRuns = rows + .slice(resolvedShoulder, crotchIndex) + .filter((row) => row.runs.length >= 3) + .flatMap((row) => [row.runs[0]!, row.runs[row.runs.length - 1]!]); + const upperArmWidth = + armRuns.length > 0 + ? median(armRuns.slice(0, Math.max(1, armRuns.length >> 1)).map(runWidth)) + : headWidth * 0.42; + const forearmWidth = + armRuns.length > 0 + ? median(armRuns.slice(Math.max(1, armRuns.length >> 1)).map(runWidth)) + : upperArmWidth * 0.8; + + const legRunsAt = (index: number): Run[] => rows[index]?.runs ?? []; + + /** + * Width of a *single* leg near `index`. Only rows where the legs are actually + * split are usable — a merged row measures both legs plus the gap, and a row + * still under a coat hem measures the coat. + */ + const singleLegWidth = (index: number, searchSpan: number): number => { + const widths: number[] = []; + for (let i = index; i < Math.min(rows.length, index + searchSpan); i++) { + const runs = legRunsAt(i); + if (runs.length >= 2) widths.push(median(runs.map(runWidth))); + } + return widths.length > 0 ? median(widths) : 0; + }; + + const thighSpan = Math.max(3, Math.floor((kneeIndex - crotchIndex) * 0.5)); + const thighWidth = + singleLegWidth(Math.round(crotchIndex + (kneeIndex - crotchIndex) * 0.3), thighSpan) || + headWidth * 0.55; + const calfWidth = singleLegWidth(kneeIndex, thighSpan) || thighWidth * 0.78; + + // The boot is widest at its sole, so take the maximum across the bottom band + // rather than a sample a few rows from the very bottom, which catches only + // the toe tip and reports a foot narrower than the ankle. + let footWidth = 0; + for (let i = Math.max(ankleIndex, rows.length - Math.floor(rows.length * 0.06)); i < rows.length; i++) { + const runs = legRunsAt(i); + if (runs.length === 0) continue; + footWidth = Math.max(footWidth, Math.max(...runs.map(runWidth))); + } + if (footWidth === 0) footWidth = calfWidth * 1.3; + + // Arm reach: the widest point anywhere above the crotch. + let reachPixels = 0; + for (let i = 0; i < crotchIndex; i++) { + const row = rows[i]!; + if (row.runs.length === 0) continue; + const span = row.runs[row.runs.length - 1]!.end - row.runs[0]!.start + 1; + if (span > reachPixels) reachPixels = span; + } + + // Stance: horizontal offset of the feet from the midline. + const footRuns = legRunsAt(rows.length - 3); + const stancePixels = + footRuns.length >= 2 + ? Math.abs((footRuns[0]!.start + runWidth(footRuns[0]!) / 2) - midX) + : pixelWidth * 0.08; + + const measurements: Measurements = { + pixelHeight, + pixelWidth, + landmarks: { + neck: { y: norm(rows[neckIndex]!.y), source: "measured" }, + shoulder: { y: norm(rows[resolvedShoulder]!.y), source: shoulderSource }, + chest: { y: norm(rows[chestIndex]!.y), source: "derived" }, + waist: { y: norm(rows[waistIndex]!.y), source: "derived" }, + hip: { y: norm(rows[hipIndex]!.y), source: "derived" }, + crotch: { y: norm(rows[crotchIndex]!.y), source: crotchSource }, + knee: { y: norm(rows[kneeIndex]!.y), source: "derived" }, + ankle: { y: norm(rows[ankleIndex]!.y), source: "measured" }, + }, + widths: { + head: headWidth / pixelHeight, + neck: neckWidth / pixelHeight, + shoulder: shoulderWidth / pixelHeight, + chest: torsoWidthAt(chestIndex, 1) / pixelHeight, + waist: torsoWidthAt(waistIndex, 0.92) / pixelHeight, + hip: torsoWidthAt(hipIndex, 0.95) / pixelHeight, + thigh: thighWidth / pixelHeight, + calf: calfWidth / pixelHeight, + foot: footWidth / pixelHeight, + upperArm: upperArmWidth / pixelHeight, + forearm: forearmWidth / pixelHeight, + }, + armReach: reachPixels / 2 / pixelHeight, + stance: stancePixels / pixelHeight, + aspect: pixelHeight / pixelWidth, + }; + + const palette = samplePalette(raster, mask, rows, midX, { + neckIndex, + resolvedShoulder, + chestIndex, + waistIndex, + crotchIndex, + kneeIndex, + ankleIndex, + }); + + return { measurements, palette, notes }; +} + +interface PaletteIndices { + neckIndex: number; + resolvedShoulder: number; + chestIndex: number; + waistIndex: number; + crotchIndex: number; + kneeIndex: number; + ankleIndex: number; +} + +/** + * Median colour per body region. + * + * Median rather than mean: a mean blends a dark jacket and a light shirt into a + * muddy average that appears nowhere in the reference, whereas the median lands + * on whichever actually dominates the region. + */ +function samplePalette( + raster: Raster, + mask: Mask, + rows: Array<{ y: number; runs: Run[]; central: Run | null }>, + midX: number, + indices: PaletteIndices, +): Palette { + const sampleCentral = (fromIndex: number, toIndex: number): Rgb => { + const samples: Rgb[] = []; + const step = Math.max(1, Math.floor((toIndex - fromIndex) / 24)); + + for (let i = fromIndex; i < toIndex; i += step) { + const row = rows[i]; + if (!row?.central) continue; + // Inset from the silhouette edge to dodge outlines and JPEG ringing. + const inset = Math.max(1, Math.floor(runWidth(row.central) * 0.25)); + for (let x = row.central.start + inset; x <= row.central.end - inset; x += 3) { + if (isForeground(mask, x, row.y)) samples.push(pixelAt(raster, x, row.y)); + } + } + return medianColor(samples); + }; + + const sampleSideRuns = (fromIndex: number, toIndex: number): Rgb => { + const samples: Rgb[] = []; + for (let i = fromIndex; i < toIndex; i++) { + const row = rows[i]; + if (!row || row.runs.length < 3) continue; + for (const run of [row.runs[0]!, row.runs[row.runs.length - 1]!]) { + const centre = Math.round((run.start + run.end) / 2); + if (isForeground(mask, centre, row.y)) samples.push(pixelAt(raster, centre, row.y)); + } + } + return samples.length > 0 ? medianColor(samples) : sampleCentral(fromIndex, toIndex); + }; + + const headTop = 0; + const headEnd = indices.neckIndex; + const hairEnd = headTop + Math.round((headEnd - headTop) * 0.34); + + // The face is the lighter half of the head region; hair the darker cap. + const hair = sampleCentral(headTop, Math.max(headTop + 1, hairEnd)); + const skin = sampleCentral(hairEnd, Math.max(hairEnd + 1, headEnd)); + + const armStart = indices.resolvedShoulder; + const armSplit = Math.round(armStart + (indices.crotchIndex - armStart) * 0.55); + + return { + hair, + skin, + torsoUpper: sampleCentral(indices.resolvedShoulder, indices.waistIndex), + torsoLower: sampleCentral(indices.waistIndex, indices.crotchIndex), + arm: sampleSideRuns(armStart, armSplit), + hand: sampleSideRuns(armSplit, indices.crotchIndex), + leg: sampleCentral(indices.crotchIndex, indices.ankleIndex), + foot: sampleCentral(indices.ankleIndex, rows.length - 1), + }; +} + +function centreOfMassX(mask: Mask): number { + let sum = 0; + let count = 0; + for (let y = mask.bounds.minY; y <= mask.bounds.maxY; y++) { + for (let x = mask.bounds.minX; x <= mask.bounds.maxX; x++) { + if (mask.data[y * mask.width + x] !== 1) continue; + sum += x; + count++; + } + } + return count > 0 ? sum / count : mask.width / 2; +} + +/** + * First index in `[from, to)` where `predicate` holds and keeps holding for + * `sustain` consecutive rows. Transient silhouette features — a strap, a hem, + * a stray highlight — satisfy a predicate for a row or two; anatomy does not. + */ +function firstSustained( + rows: Array<{ runs: Run[]; central: Run | null }>, + from: number, + to: number, + predicate: (row: { runs: Run[]; central: Run | null }) => boolean, + sustain: number, +): number { + for (let i = Math.max(0, from); i < Math.min(rows.length, to); i++) { + if (!predicate(rows[i]!)) continue; + + let held = 0; + while (held < sustain && i + held < rows.length && predicate(rows[i + held]!)) held++; + if (held >= sustain) return i; + } + return -1; +} + +function maxRunWidthBetween( + rows: Array<{ central: Run | null }>, + fromIndex: number, + toIndex: number, +): number { + let widest = 0; + for (let i = fromIndex; i < toIndex; i++) { + const central = rows[i]?.central; + if (central) widest = Math.max(widest, runWidth(central)); + } + return widest; +} + +function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + return sorted[sorted.length >> 1]!; +} + +function medianColor(samples: Rgb[]): Rgb { + if (samples.length === 0) return { r: 128, g: 128, b: 128 }; + // Median by luminance keeps a real pixel rather than inventing a channel mix. + const sorted = [...samples].sort((a, b) => luminance(a) - luminance(b)); + return sorted[sorted.length >> 1]!; +} diff --git a/tools/src/image/segment.ts b/tools/src/image/segment.ts new file mode 100644 index 0000000..5ae22b6 --- /dev/null +++ b/tools/src/image/segment.ts @@ -0,0 +1,179 @@ +import { colorDistance, pixelAt, type Raster, type Rgb } from "./Raster.js"; + +/** + * Foreground extraction. + * + * The reference prompt asks for a plain uniform backdrop, which makes this + * tractable without a segmentation model: sample the corners for the backdrop + * colour, then flood-fill inward from the border. Flood-fill rather than a + * global colour threshold matters — a global test would also erase any part of + * the *character* that happens to match the backdrop (grey clothing against + * grey), whereas a fill only removes background actually connected to the edge. + */ + +export interface Mask { + width: number; + height: number; + /** 1 = foreground, 0 = background. */ + data: Uint8Array; + /** Tight bounds of the foreground. */ + bounds: { minX: number; minY: number; maxX: number; maxY: number }; + coverage: number; +} + +export interface SegmentOptions { + /** Colour distance under which a pixel counts as backdrop. */ + tolerance?: number; + /** Fraction of the shorter side sampled at each corner. */ + cornerSampleRatio?: number; +} + +const DEFAULT_TOLERANCE = 42; + +/** Median of the four corner patches, so one odd corner cannot skew it. */ +export function estimateBackdrop(raster: Raster, sampleRatio = 0.06): Rgb { + const size = Math.max(2, Math.floor(Math.min(raster.width, raster.height) * sampleRatio)); + const reds: number[] = []; + const greens: number[] = []; + const blues: number[] = []; + + const corners: Array<[number, number]> = [ + [0, 0], + [raster.width - size, 0], + [0, raster.height - size], + [raster.width - size, raster.height - size], + ]; + + for (const [originX, originY] of corners) { + for (let y = originY; y < originY + size; y++) { + for (let x = originX; x < originX + size; x++) { + const pixel = pixelAt(raster, x, y); + reds.push(pixel.r); + greens.push(pixel.g); + blues.push(pixel.b); + } + } + } + + return { r: median(reds), g: median(greens), b: median(blues) }; +} + +export function segmentForeground(raster: Raster, options: SegmentOptions = {}): Mask { + const tolerance = options.tolerance ?? DEFAULT_TOLERANCE; + const backdrop = estimateBackdrop(raster, options.cornerSampleRatio); + + const { width, height } = raster; + const total = width * height; + + // Start as all-foreground; the fill carves the background away. + const data = new Uint8Array(total).fill(1); + + // Iterative stack rather than recursion — a 1024x1365 image overflows the + // call stack immediately with a recursive fill. + const stack: number[] = []; + const pushIfBackdrop = (x: number, y: number) => { + if (x < 0 || y < 0 || x >= width || y >= height) return; + const index = y * width + x; + if (data[index] === 0) return; + if (colorDistance(pixelAt(raster, x, y), backdrop) > tolerance) return; + data[index] = 0; + stack.push(index); + }; + + for (let x = 0; x < width; x++) { + pushIfBackdrop(x, 0); + pushIfBackdrop(x, height - 1); + } + for (let y = 0; y < height; y++) { + pushIfBackdrop(0, y); + pushIfBackdrop(width - 1, y); + } + + while (stack.length > 0) { + const index = stack.pop()!; + const x = index % width; + const y = (index - x) / width; + pushIfBackdrop(x + 1, y); + pushIfBackdrop(x - 1, y); + pushIfBackdrop(x, y + 1); + pushIfBackdrop(x, y - 1); + } + + const mask: Mask = { + width, + height, + data, + bounds: { minX: width, minY: height, maxX: -1, maxY: -1 }, + coverage: 0, + }; + + let filled = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (data[y * width + x] !== 1) continue; + filled++; + if (x < mask.bounds.minX) mask.bounds.minX = x; + if (x > mask.bounds.maxX) mask.bounds.maxX = x; + if (y < mask.bounds.minY) mask.bounds.minY = y; + if (y > mask.bounds.maxY) mask.bounds.maxY = y; + } + } + + mask.coverage = filled / total; + if (mask.bounds.maxX < 0) { + throw new Error("Segmentation found no foreground — is the background plain and uniform?"); + } + return mask; +} + +/** + * Removes salt-and-pepper speckle left by JPEG ringing near the silhouette + * edge, which otherwise adds spurious width to the row profile. + */ +export function denoise(mask: Mask, minNeighbours = 5): Mask { + const { width, height, data } = mask; + const out = new Uint8Array(data); + + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + let neighbours = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + neighbours += data[(y + dy) * width + (x + dx)]!; + } + } + const index = y * width + x; + if (data[index] === 1 && neighbours < minNeighbours - 2) out[index] = 0; + else if (data[index] === 0 && neighbours > minNeighbours + 1) out[index] = 1; + } + } + + return { ...mask, data: out }; +} + +export const isForeground = (mask: Mask, x: number, y: number): boolean => + x >= 0 && y >= 0 && x < mask.width && y < mask.height && mask.data[y * mask.width + x] === 1; + +/** Foreground pixel count and horizontal extent for one scanline. */ +export function rowProfile(mask: Mask, y: number): { count: number; minX: number; maxX: number } { + let count = 0; + let minX = mask.width; + let maxX = -1; + + for (let x = 0; x < mask.width; x++) { + if (mask.data[y * mask.width + x] !== 1) continue; + count++; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + } + return { count, minX, maxX }; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 === 0 + ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2) + : sorted[mid]!; +} diff --git a/tools/src/image/testFixtures.ts b/tools/src/image/testFixtures.ts new file mode 100644 index 0000000..4a65fec --- /dev/null +++ b/tools/src/image/testFixtures.ts @@ -0,0 +1,177 @@ +import type { Raster, Rgb } from "./Raster.js"; + +/** + * Synthetic reference figures for testing the analyzer. + * + * Deterministic and with known ground truth, so a landmark assertion means + * something precise. Testing only against a real generated image would prove + * the analyzer works on *that* image; these prove it works against stated + * proportions, and let us build adversarial cases — a backpack that merges with + * the collar, legs that never separate — on demand. + */ + +export interface FigureSpec { + width: number; + height: number; + backdrop: Rgb; + /** All values are fractions of image height, measured from the crown. */ + headTop: number; + neckY: number; + shoulderY: number; + crotchY: number; + ankleY: number; + soleY: number; + headWidth: number; + neckWidth: number; + torsoWidth: number; + legWidth: number; + legGap: number; + footWidth: number; + armWidth: number; + /** Arms separate from the torso below this height. */ + armSeparationY: number; + armReach: number; + /** Ankle width as a fraction of thigh width. */ + ankleTaper: number; + colors: { + hair: Rgb; + skin: Rgb; + torso: Rgb; + arm: Rgb; + leg: Rgb; + foot: Rgb; + }; + /** Optional pack that projects sideways beside the neck, merging with it. */ + backpack?: { fromY: number; toY: number; width: number; color: Rgb }; +} + +export const DEFAULT_FIGURE: FigureSpec = { + width: 512, + height: 768, + backdrop: { r: 128, g: 128, b: 128 }, + headTop: 0.02, + neckY: 0.14, + shoulderY: 0.2, + crotchY: 0.55, + ankleY: 0.93, + soleY: 0.98, + headWidth: 0.1, + neckWidth: 0.05, + torsoWidth: 0.26, + legWidth: 0.09, + legGap: 0.035, + footWidth: 0.11, + armWidth: 0.075, + armSeparationY: 0.3, + armReach: 0.38, + ankleTaper: 0.62, + colors: { + hair: { r: 40, g: 34, b: 30 }, + skin: { r: 198, g: 156, b: 130 }, + torso: { r: 86, g: 92, b: 60 }, + arm: { r: 74, g: 80, b: 52 }, + leg: { r: 48, g: 50, b: 58 }, + foot: { r: 92, g: 66, b: 44 }, + }, +}; + +export function renderFigure(spec: FigureSpec = DEFAULT_FIGURE): Raster { + const { width, height } = spec; + const data = new Uint8Array(width * height * 4); + + const put = (x: number, y: number, color: Rgb) => { + if (x < 0 || y < 0 || x >= width || y >= height) return; + const i = (y * width + x) * 4; + data[i] = color.r; + data[i + 1] = color.g; + data[i + 2] = color.b; + data[i + 3] = 255; + }; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) put(x, y, spec.backdrop); + } + + const midX = Math.round(width / 2); + const py = (t: number) => Math.round(t * height); + const px = (t: number) => Math.round(t * height); // widths are height-relative + + // Half-widths must be integers: a fractional loop bound produces fractional + // array indices, which typed arrays silently discard, and the shape simply + // never gets drawn. + const bar = (fromY: number, toY: number, halfWidth: number, color: Rgb, centre = midX) => { + const half = Math.round(halfWidth); + for (let y = py(fromY); y < py(toY); y++) { + for (let x = centre - half; x <= centre + half; x++) put(x, y, color); + } + }; + + // Head: a hair cap over a face, so the palette sampler has two bands to find. + const headHalf = Math.round(px(spec.headWidth) / 2); + const hairEnd = spec.headTop + (spec.neckY - spec.headTop) * 0.34; + bar(spec.headTop, hairEnd, headHalf, spec.colors.hair); + bar(hairEnd, spec.neckY, headHalf, spec.colors.skin); + + bar(spec.neckY, spec.shoulderY, Math.round(px(spec.neckWidth) / 2), spec.colors.skin); + + if (spec.backpack) { + bar( + spec.backpack.fromY, + spec.backpack.toY, + Math.round(px(spec.backpack.width) / 2), + spec.backpack.color, + ); + } + + bar(spec.shoulderY, spec.crotchY, Math.round(px(spec.torsoWidth) / 2), spec.colors.torso); + + // Arms: angled outward, leaving the torso at `armSeparationY`. + const armHalf = Math.round(px(spec.armWidth) / 2); + const torsoHalf = Math.round(px(spec.torsoWidth) / 2); + const reachPx = px(spec.armReach); + const armStartY = py(spec.shoulderY); + const armEndY = py(spec.crotchY); + + for (let y = armStartY; y < armEndY; y++) { + const t = (y - armStartY) / Math.max(1, armEndY - armStartY); + const offset = Math.round(torsoHalf * 0.6 + (reachPx - armHalf - torsoHalf * 0.6) * t); + for (const side of [-1, 1]) { + const centre = midX + side * offset; + for (let x = centre - armHalf; x <= centre + armHalf; x++) put(x, y, spec.colors.arm); + } + } + + // Legs and boots. + const legHalf = Math.round(px(spec.legWidth) / 2); + const gapHalf = Math.round(px(spec.legGap) / 2); + const footHalf = Math.round(px(spec.footWidth) / 2); + const ankleHalf = Math.round(legHalf * spec.ankleTaper); + + for (const side of [-1, 1]) { + const centre = midX + side * (gapHalf + legHalf); + + // Legs taper from thigh to ankle, so the ankle is a genuine local minimum + // in the width profile rather than an arbitrary row of a constant column. + const fromY = py(spec.crotchY); + const toY = py(spec.ankleY); + for (let y = fromY; y < toY; y++) { + const t = (y - fromY) / Math.max(1, toY - fromY); + const half = Math.round(legHalf + (ankleHalf - legHalf) * t); + for (let x = centre - half; x <= centre + half; x++) put(x, y, spec.colors.leg); + } + bar(spec.ankleY, spec.soleY, footHalf, spec.colors.foot, centre); + } + + return { width, height, data }; +} + +/** The adversarial case: a high pack that merges with the collar in silhouette. */ +export const PACKED_FIGURE: FigureSpec = { + ...DEFAULT_FIGURE, + backpack: { + fromY: 0.155, + toY: 0.45, + width: 0.3, + color: { r: 70, g: 64, b: 50 }, + }, +}; diff --git a/tools/src/img2mesh/mockProvider.ts b/tools/src/img2mesh/mockProvider.ts new file mode 100644 index 0000000..aa74576 --- /dev/null +++ b/tools/src/img2mesh/mockProvider.ts @@ -0,0 +1,27 @@ +import type { Img2MeshRequest, Img2MeshResult, MeshProviderAdapter } from "../types.js"; + +/** + * Stub MeshProviderAdapter for interface tests. + * + * Characters are produced by the code-drawn pipeline (`runPipeline` / + * `pnpm harness`), not by an external mesh API. This mock remains so anything + * still typed against `MeshProviderAdapter` has a configured stand-in. + */ +export class MockMeshProvider implements MeshProviderAdapter { + readonly name = "mock" as const; + + constructor(private readonly placeholderGlbPath = "assets/placeholder/character.glb") {} + + isConfigured(): boolean { + return true; + } + + async generate(request: Img2MeshRequest): Promise { + return { + glbPath: this.placeholderGlbPath, + provider: "mock", + triangleCount: request.targetTriangles ?? 900, + generationSeconds: 0, + }; + } +} diff --git a/tools/src/index.ts b/tools/src/index.ts new file mode 100644 index 0000000..03c8315 --- /dev/null +++ b/tools/src/index.ts @@ -0,0 +1,18 @@ +export * from "./types.js"; +export { GrokBridge } from "./grok/GrokBridge.js"; +export { characterReferencePrompt } from "./grok/prompts.js"; +export { runPipeline, formatReport, defaultOutputPath } from "./pipeline.js"; +export type { PipelineOptions, PipelineResult } from "./pipeline.js"; +export { analyzeReference } from "./image/analyze.js"; +export { loadRaster } from "./image/Raster.js"; +export { buildSkeleton } from "./rig/skeleton.js"; +export { buildHumanoidMesh } from "./mesh/humanoid.js"; +export { exportGlb } from "./export/glb.js"; +export { buildDefaultClips } from "./anim/procedural.js"; +/** Modular character creator — preferred path for game cast. */ +export { assembleCharacter, formatAssemblyReport } from "./creator/assemble.js"; +export { CHARACTER_PRESETS, presetById } from "./creator/presets.js"; +export { PART_DEFINITIONS, listParts } from "./creator/parts.js"; +export type { CharacterRecipe, BodyType, PartSlot, CreatorPalette } from "./creator/types.js"; +/** @deprecated Prefer the modular creator (`assembleCharacter`). Kept for interface tests. */ +export { MockMeshProvider } from "./img2mesh/mockProvider.js"; diff --git a/tools/src/mesh/MeshBuilder.test.ts b/tools/src/mesh/MeshBuilder.test.ts new file mode 100644 index 0000000..333a543 --- /dev/null +++ b/tools/src/mesh/MeshBuilder.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { + box, + computeFlatNormals, + createMesh, + domeShell, + loft, + tube, + vertexCount, + type MeshData, + type Vec3Tuple, +} from "./MeshBuilder.js"; + +const COLOR = { r: 180, g: 140, b: 100 }; + +/** Mean of triangle normals dotted with (centroid − meshCentre). Outward > 0. */ +function outwardScore(mesh: MeshData): number { + computeFlatNormals(mesh); + + let cx = 0; + let cy = 0; + let cz = 0; + const n = vertexCount(mesh); + for (let i = 0; i < n; i++) { + cx += mesh.positions[i * 3]!; + cy += mesh.positions[i * 3 + 1]!; + cz += mesh.positions[i * 3 + 2]!; + } + cx /= n; + cy /= n; + cz /= n; + + let score = 0; + let faces = 0; + for (let i = 0; i < mesh.indices.length; i += 3) { + const a = mesh.indices[i]! * 3; + const b = mesh.indices[i + 1]! * 3; + const c = mesh.indices[i + 2]! * 3; + + const px = (mesh.positions[a]! + mesh.positions[b]! + mesh.positions[c]!) / 3; + const py = (mesh.positions[a + 1]! + mesh.positions[b + 1]! + mesh.positions[c + 1]!) / 3; + const pz = (mesh.positions[a + 2]! + mesh.positions[b + 2]! + mesh.positions[c + 2]!) / 3; + + // Use the geometric normal of the triangle (not the averaged vertex normal) + // so the score measures winding, not smoothing. + const abx = mesh.positions[b]! - mesh.positions[a]!; + const aby = mesh.positions[b + 1]! - mesh.positions[a + 1]!; + const abz = mesh.positions[b + 2]! - mesh.positions[a + 2]!; + const acx = mesh.positions[c]! - mesh.positions[a]!; + const acy = mesh.positions[c + 1]! - mesh.positions[a + 1]!; + const acz = mesh.positions[c + 2]! - mesh.positions[a + 2]!; + let nx = aby * acz - abz * acy; + let ny = abz * acx - abx * acz; + let nz = abx * acy - aby * acx; + const len = Math.hypot(nx, ny, nz) || 1; + nx /= len; + ny /= len; + nz /= len; + + score += nx * (px - cx) + ny * (py - cy) + nz * (pz - cz); + faces++; + } + + return faces > 0 ? score / faces : 0; +} + +describe("mesh winding", () => { + it("emits outward-facing fronts on a vertical tube", () => { + const mesh = createMesh(); + const from: Vec3Tuple = [0, 0, 0]; + const to: Vec3Tuple = [0, 1, 0]; + tube(mesh, from, to, 0.2, 0.15, 0.8, COLOR, { sides: 6, sections: 2 }); + expect(outwardScore(mesh)).toBeGreaterThan(0.05); + }); + + it("emits outward-facing fronts on a diagonal tube (A-pose arm case)", () => { + const mesh = createMesh(); + tube(mesh, [0, 1, 0], [0.4, 0.4, 0.1], 0.08, 0.06, 0.9, COLOR, { sides: 6, sections: 3 }); + expect(outwardScore(mesh)).toBeGreaterThan(0.05); + }); + + it("emits outward-facing fronts on a keyframed loft (ludus torso path)", () => { + const mesh = createMesh(); + loft( + mesh, + [ + { t: 0, c: [0, 0.9, 0], rx: 0.14, rz: 0.1 }, + { t: 0.4, c: [0, 1.1, 0.01], rx: 0.11, rz: 0.08 }, + { t: 0.75, c: [0, 1.3, 0.015], rx: 0.16, rz: 0.1 }, + { t: 1, c: [0, 1.45, 0.02], rx: 0.06, rz: 0.055 }, + ], + COLOR, + { sides: 6, rings: 8 }, + ); + expect(outwardScore(mesh)).toBeGreaterThan(0.05); + }); + + it("emits outward-facing fronts on an axis-aligned box", () => { + const mesh = createMesh(); + box(mesh, [0, 0.5, 0], [0.2, 0.3, 0.15], COLOR); + expect(outwardScore(mesh)).toBeGreaterThan(0.05); + }); + + it("emits outward-facing fronts on a hair dome shell", () => { + const mesh = createMesh(); + // Angled skull axis (hairline → crown), face +Z — same as the head mesh. + const from: Vec3Tuple = [0, 1.52, 0.02]; + const to: Vec3Tuple = [0, 1.66, 0.01]; + domeShell(mesh, from, to, 0.1, 0.1, COLOR, { + sections: 4, + sides: 8, + frontScale: 0.92, + frontScaleTop: 0.7, + backScale: 1.1, + topScale: 0.82, + faceDir: [0, 0, 1], + }); + expect(outwardScore(mesh)).toBeGreaterThan(0.04); + + // Crown fan specifically: average normal near the apex should point along +axis. + const axis: Vec3Tuple = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; + const al = Math.hypot(axis[0], axis[1], axis[2]) || 1; + const axisN: Vec3Tuple = [axis[0] / al, axis[1] / al, axis[2] / al]; + const apex: Vec3Tuple = [to[0] + axisN[0] * 0.01, to[1] + axisN[1] * 0.01, to[2] + axisN[2] * 0.01]; + let score = 0; + let faces = 0; + for (let i = 0; i < mesh.indices.length; i += 3) { + const ia = mesh.indices[i]! * 3; + const ib = mesh.indices[i + 1]! * 3; + const ic = mesh.indices[i + 2]! * 3; + const px = (mesh.positions[ia]! + mesh.positions[ib]! + mesh.positions[ic]!) / 3; + const py = (mesh.positions[ia + 1]! + mesh.positions[ib + 1]! + mesh.positions[ic + 1]!) / 3; + const pz = (mesh.positions[ia + 2]! + mesh.positions[ib + 2]! + mesh.positions[ic + 2]!) / 3; + if (Math.hypot(px - apex[0], py - apex[1], pz - apex[2]) > 0.08) continue; + const abx = mesh.positions[ib]! - mesh.positions[ia]!; + const aby = mesh.positions[ib + 1]! - mesh.positions[ia + 1]!; + const abz = mesh.positions[ib + 2]! - mesh.positions[ia + 2]!; + const acx = mesh.positions[ic]! - mesh.positions[ia]!; + const acy = mesh.positions[ic + 1]! - mesh.positions[ia + 1]!; + const acz = mesh.positions[ic + 2]! - mesh.positions[ia + 2]!; + let nx = aby * acz - abz * acy; + let ny = abz * acx - abx * acz; + let nz = abx * acy - aby * acx; + const len = Math.hypot(nx, ny, nz) || 1; + score += (nx / len) * axisN[0] + (ny / len) * axisN[1] + (nz / len) * axisN[2]; + faces++; + } + expect(faces).toBeGreaterThan(0); + expect(score / faces).toBeGreaterThan(0.2); + }); +}); diff --git a/tools/src/mesh/MeshBuilder.ts b/tools/src/mesh/MeshBuilder.ts new file mode 100644 index 0000000..61b4b06 --- /dev/null +++ b/tools/src/mesh/MeshBuilder.ts @@ -0,0 +1,809 @@ +import type { Rgb } from "../image/Raster.js"; + +/** + * Minimal indexed triangle mesh with vertex colours. + * + * Vertex colours rather than a texture atlas: the PSX drew plenty of untextured + * gouraud geometry, and the palette we extract from the reference is per-region + * anyway. Faceted normals keep the Silent Hill / early-3D look without textures. + */ + +export interface MeshData { + positions: number[]; + normals: number[]; + colors: number[]; + /** Optional TEXCOORD_0 (u,v per vertex). Empty until unwrap runs. */ + uvs: number[]; + indices: number[]; + /** One entry per vertex, filled in by the skinning pass. */ + joints: number[]; + weights: number[]; + /** + * Body region id per vertex, and where along that region's loft the vertex + * sits (0 at the root ring, 1 at the tip). Written by the tagging helpers in + * `creator/coverage.ts`; garments use them to claim the skin they cover. + * Build-time only — the GLB exporter ignores both. + */ + parts: number[]; + ts: number[]; +} + +export const createMesh = (): MeshData => ({ + positions: [], + normals: [], + colors: [], + uvs: [], + indices: [], + joints: [], + weights: [], + parts: [], + ts: [], +}); + +/** Append `source` geometry into `target`, remapping indices. Skin weights cleared. */ +export function appendMesh(target: MeshData, source: MeshData): void { + const base = vertexCount(target); + target.positions.push(...source.positions); + target.normals.push(...source.normals); + target.colors.push(...source.colors); + if (source.uvs.length > 0) { + // Keep UV streams aligned when present; leave empty if neither side has them. + if (target.uvs.length === 0 && base > 0) { + for (let i = 0; i < base; i++) target.uvs.push(0, 0); + } + target.uvs.push(...source.uvs); + } else if (target.uvs.length > 0) { + const n = vertexCount(source); + for (let i = 0; i < n; i++) target.uvs.push(0, 0); + } + // Region tags are sparse — pad both sides to full length so vertex i in the + // merged mesh still names vertex i's region. + padTags(target, base); + padTags(source, vertexCount(source)); + target.parts.push(...source.parts); + target.ts.push(...source.ts); + + for (const index of source.indices) target.indices.push(index + base); + // Weights are recomputed for the combined mesh after assembly. + target.joints = []; + target.weights = []; +} + +/** Fill any untagged tail with the "no region" sentinel. */ +export function padTags(mesh: MeshData, count: number): void { + while (mesh.parts.length < count) mesh.parts.push(0); + while (mesh.ts.length < count) mesh.ts.push(0); +} + +export type Vec3Tuple = [number, number, number]; + +export const vertexCount = (mesh: MeshData): number => mesh.positions.length / 3; +export const triangleCount = (mesh: MeshData): number => mesh.indices.length / 3; + +function pushVertex(mesh: MeshData, position: Vec3Tuple, color: Rgb): number { + const index = vertexCount(mesh); + mesh.positions.push(position[0], position[1], position[2]); + mesh.normals.push(0, 0, 0); + // glTF COLOR_0 is linear float; the reference is sRGB bytes. + mesh.colors.push(srgbToLinear(color.r), srgbToLinear(color.g), srgbToLinear(color.b), 1); + return index; +} + +const srgbToLinear = (channel: number): number => { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +}; + +function pushTriangle(mesh: MeshData, a: number, b: number, c: number): void { + mesh.indices.push(a, b, c); +} + +function pushQuad(mesh: MeshData, a: number, b: number, c: number, d: number): void { + pushTriangle(mesh, a, b, c); + pushTriangle(mesh, a, c, d); +} + +export function add(a: Vec3Tuple, b: Vec3Tuple): Vec3Tuple { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +export function sub(a: Vec3Tuple, b: Vec3Tuple): Vec3Tuple { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +export function scale(v: Vec3Tuple, s: number): Vec3Tuple { + return [v[0] * s, v[1] * s, v[2] * s]; +} + +export function lerp(a: Vec3Tuple, b: Vec3Tuple, t: number): Vec3Tuple { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; +} + +export function length(v: Vec3Tuple): number { + return Math.hypot(v[0], v[1], v[2]); +} + +export function normalise(v: Vec3Tuple): Vec3Tuple { + const len = length(v); + return len > 1e-8 ? [v[0] / len, v[1] / len, v[2] / len] : [0, 1, 0]; +} + +export function cross(a: Vec3Tuple, b: Vec3Tuple): Vec3Tuple { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +export function dot(a: Vec3Tuple, b: Vec3Tuple): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +/** + * Local frame with `axis` as the primary direction (limb length). + * + * Pass `hintRight` from the previous ring to **parallel-transport** the frame + * along a bent limb. Without that, each ring picks world-up independently and + * the right vector can spin the long way around the bone (classic tube twist — + * sleeves looking wound 2–3 turns along an A-pose arm). + */ +export function basisFromAxis( + axis: Vec3Tuple, + hintRight?: Vec3Tuple, +): { forward: Vec3Tuple; right: Vec3Tuple; up: Vec3Tuple } { + const forward = normalise(axis); + + if (hintRight) { + // Project previous right onto the plane ⊥ forward, then take the short flip. + let right = sub(hintRight, scale(forward, dot(hintRight, forward))); + if (length(right) > 1e-6) { + right = normalise(right); + if (dot(right, hintRight) < 0) right = scale(right, -1); + const up = normalise(cross(forward, right)); + return { forward, right, up }; + } + } + + const worldUp: Vec3Tuple = Math.abs(forward[1]) > 0.9 ? [0, 0, 1] : [0, 1, 0]; + const right = normalise(cross(worldUp, forward)); + const up = normalise(cross(forward, right)); + return { forward, right, up }; +} + +interface Ring { + indices: number[]; + /** Frame right vector — fed into the next ring for parallel transport. */ + right: Vec3Tuple; +} + +/** + * Builds a cross-section ring in a plane perpendicular to `axis`. + * + * `sides` 4 = classic PSX box limb; 6 is the Silent Hill sweet spot (reads + * round at 240p without burning the triangle budget). + */ +function orientedRing( + mesh: MeshData, + centre: Vec3Tuple, + axis: Vec3Tuple, + halfWidth: number, + halfDepth: number, + color: Rgb, + sides: number, + hintRight?: Vec3Tuple, +): Ring { + const { right, up } = basisFromAxis(axis, hintRight); + const indices: number[] = []; + + for (let i = 0; i < sides; i++) { + const angle = (i / sides) * Math.PI * 2 + Math.PI / sides; + // Elliptical profile: width across the body, depth front-to-back. + const rx = Math.cos(angle) * halfWidth; + const ry = Math.sin(angle) * halfDepth; + const position = add(centre, add(scale(right, rx), scale(up, ry))); + indices.push(pushVertex(mesh, position, color)); + } + + return { indices, right }; +} + +/** + * Skins the side wall between two rings. + * + * Rings are emitted CCW when looking *along* the extrusion axis (from `lower` + * toward `upper`). For three.js / glTF (CCW front faces) the outward side of + * the tube is then the winding lower[i] → lower[next] → upper[next] → upper[i]. + * The previous order (lower → upper → upper_next → lower_next) produced + * inward-facing fronts, so the runtime only showed the mesh interior. + */ +function bridgeRings(mesh: MeshData, lower: Ring, upper: Ring): void { + const n = lower.indices.length; + for (let i = 0; i < n; i++) { + const next = (i + 1) % n; + pushQuad( + mesh, + lower.indices[i]!, + lower.indices[next]!, + upper.indices[next]!, + upper.indices[i]!, + ); + } +} + +function capRing(mesh: MeshData, ring: Ring, axis: Vec3Tuple, outward: boolean): void { + // Fan from the first vertex. Fine for convex rings of 4–8 sides. + const [first, ...rest] = ring.indices; + if (first === undefined || rest.length < 2) return; + + // Ring is CCW looking along +axis. The +axis end cap (outward) keeps that + // order; the −axis end cap reverses it so the normal points out of the tube. + for (let i = 0; i < rest.length - 1; i++) { + if (outward) pushTriangle(mesh, first, rest[i]!, rest[i + 1]!); + else pushTriangle(mesh, first, rest[i + 1]!, rest[i]!); + } + void axis; +} + +export interface TubeOptions { + /** Cross-section sides. 4 = blocky, 6 = SH1-ish. */ + sides?: number; + /** Intermediate rings for skinning bend. */ + sections?: number; + /** Cap the ends. */ + caps?: boolean; + /** + * Optional per-section width/depth scale curve, t in 0..1. + * Used for muscle/joint bulges (elbow, knee, calf). + */ + profile?: (t: number) => { width: number; depth: number }; +} + +/** + * A tapered limb between two points. Rings sit perpendicular to the bone so + * diagonal arms stay round instead of becoming stair-stepped world boxes. + */ +export function tube( + mesh: MeshData, + from: Vec3Tuple, + to: Vec3Tuple, + fromHalfWidth: number, + toHalfWidth: number, + depthRatio: number, + color: Rgb, + options: TubeOptions | number = {}, +): void { + // Back-compat: older call sites passed `sections` as a bare number. + const opts: TubeOptions = typeof options === "number" ? { sections: options } : options; + const sections = Math.max(1, opts.sections ?? 3); + const sides = Math.max(4, opts.sides ?? 6); + const caps = opts.caps ?? true; + const axis = sub(to, from); + if (length(axis) < 1e-6) return; + + const rings: Ring[] = []; + let prevRight: Vec3Tuple | undefined; + + for (let s = 0; s <= sections; s++) { + const t = s / sections; + const centre = lerp(from, to, t); + const baseWidth = fromHalfWidth + (toHalfWidth - fromHalfWidth) * t; + const profile = opts.profile?.(t) ?? { width: 1, depth: 1 }; + const halfWidth = baseWidth * profile.width; + const halfDepth = baseWidth * depthRatio * profile.depth; + const ring = orientedRing(mesh, centre, axis, halfWidth, halfDepth, color, sides, prevRight); + prevRight = ring.right; + rings.push(ring); + } + + for (let s = 0; s < sections; s++) bridgeRings(mesh, rings[s]!, rings[s + 1]!); + + if (caps) { + capRing(mesh, rings[0]!, axis, false); + capRing(mesh, rings[rings.length - 1]!, axis, true); + } +} + +/** + * Multi-segment path (e.g. full arm shoulder→elbow→wrist) with continuous + * skinning rings so joints don't show a hard seam. + */ +export function tubePath( + mesh: MeshData, + points: Vec3Tuple[], + halfWidths: number[], + depthRatio: number, + color: Rgb, + options: { sides?: number; sectionsPerSegment?: number; caps?: boolean } = {}, +): void { + if (points.length < 2) return; + const sides = options.sides ?? 6; + const sectionsPerSegment = options.sectionsPerSegment ?? 2; + const caps = options.caps ?? true; + + const rings: Ring[] = []; + const axes: Vec3Tuple[] = []; + // Parallel-transport the ring frame along the whole path so elbows don't + // introduce a multi-turn twist (right·prev would otherwise jump when the + // bone axis crosses the world-up singularity). + let prevRight: Vec3Tuple | undefined; + + for (let segment = 0; segment < points.length - 1; segment++) { + const from = points[segment]!; + const to = points[segment + 1]!; + const fromW = halfWidths[segment] ?? halfWidths[halfWidths.length - 1]!; + const toW = halfWidths[segment + 1] ?? fromW; + const axis = sub(to, from); + if (length(axis) < 1e-6) continue; + + const start = segment === 0 ? 0 : 1; // skip duplicate ring at shared joints + for (let s = start; s <= sectionsPerSegment; s++) { + const t = s / sectionsPerSegment; + const centre = lerp(from, to, t); + // Slight bulge mid-segment so elbows/knees don't look like broomsticks. + const bulge = 1 + 0.06 * Math.sin(t * Math.PI); + const halfWidth = (fromW + (toW - fromW) * t) * bulge; + const ring = orientedRing( + mesh, + centre, + axis, + halfWidth, + halfWidth * depthRatio, + color, + sides, + prevRight, + ); + prevRight = ring.right; + rings.push(ring); + axes.push(axis); + } + } + + for (let s = 0; s < rings.length - 1; s++) bridgeRings(mesh, rings[s]!, rings[s + 1]!); + if (caps && rings.length > 0) { + capRing(mesh, rings[0]!, axes[0] ?? [0, 1, 0], false); + capRing(mesh, rings[rings.length - 1]!, axes[axes.length - 1] ?? [0, 1, 0], true); + } +} + +/** + * Keyframed cross-section for {@link loft} — same idea as ludus `loftPart`. + * `rx` is half-width on the ring right axis, `rz` half-depth on ring up. + */ +export interface LoftKey { + /** 0..1 path parameter (keys must be sorted ascending). */ + t: number; + c: Vec3Tuple; + rx: number; + rz: number; +} + +export interface LoftOptions { + /** Cross-section sides. 4 = PSX diamond limb, 6 = angular torso. */ + sides?: number; + /** Sampled rings along the key path (defaults to keys.length). */ + rings?: number; + /** Cap both ends (default true). Overridden by capStart / capEnd. */ + caps?: boolean; + /** Cap the first ring (limb root). Default = caps. */ + capStart?: boolean; + /** Cap the last ring (limb tip). Default = caps. */ + capEnd?: boolean; + /** + * Seed for the first ring's right axis (`rx` runs along it; `rz` along + * forward × right). Without it the first ring picks world-up, which flips + * 90° as a limb crosses ~26° from vertical — fine for round sections, wrong + * for flat ones like a palm or a foot. + */ + startRight?: Vec3Tuple; +} + +const hermite01 = (t: number): number => t * t * (3 - 2 * t); + +/** + * Loft an angular tube through keyframed rings (ludus gladiator style). + * + * Centres + independent rx/rz give real anatomical silhouettes; low `sides` + * (4–6) keeps the PS1 / FF8 faceted look. Ring frames are parallel-transported + * along the path so bent limbs don't twist. + */ +/** Where a loft's vertices landed, so callers can tag them by region. */ +export interface LoftResult { + firstVertex: number; + rings: number; + sides: number; +} + +export function loft( + mesh: MeshData, + keys: LoftKey[], + color: Rgb, + options: LoftOptions = {}, +): LoftResult { + const firstVertex = vertexCount(mesh); + if (keys.length < 2) return { firstVertex, rings: 0, sides: 0 }; + const sides = Math.max(4, options.sides ?? 6); + const ringCount = Math.max(2, options.rings ?? keys.length); + const caps = options.caps ?? true; + const capStart = options.capStart ?? caps; + const capEnd = options.capEnd ?? caps; + + // Sample smooth centres / radii along the key curve. + type Sample = { c: Vec3Tuple; rx: number; rz: number }; + const samples: Sample[] = []; + for (let i = 0; i < ringCount; i++) { + const t = i / (ringCount - 1); + let k = 0; + while (k < keys.length - 2 && (keys[k + 1]?.t ?? 1) < t) k++; + const a = keys[k]!; + const b = keys[k + 1]!; + const span = Math.max(1e-6, b.t - a.t); + const ft = hermite01(Math.min(1, Math.max(0, (t - a.t) / span))); + samples.push({ + c: lerp(a.c, b.c, ft), + rx: a.rx + (b.rx - a.rx) * ft, + rz: a.rz + (b.rz - a.rz) * ft, + }); + } + + const rings: Ring[] = []; + const axes: Vec3Tuple[] = []; + let prevRight: Vec3Tuple | undefined = options.startRight; + + for (let i = 0; i < ringCount; i++) { + const prev = samples[Math.max(0, i - 1)]!; + const next = samples[Math.min(ringCount - 1, i + 1)]!; + const axis = sub(next.c, prev.c); + if (length(axis) < 1e-8) { + // Degenerate — fall back to vertical. + const fallback: Vec3Tuple = [0, 1, 0]; + const ring = orientedRing( + mesh, + samples[i]!.c, + fallback, + samples[i]!.rx, + samples[i]!.rz, + color, + sides, + prevRight, + ); + prevRight = ring.right; + rings.push(ring); + axes.push(fallback); + continue; + } + const ring = orientedRing( + mesh, + samples[i]!.c, + axis, + samples[i]!.rx, + samples[i]!.rz, + color, + sides, + prevRight, + ); + prevRight = ring.right; + rings.push(ring); + axes.push(axis); + } + + for (let s = 0; s < rings.length - 1; s++) bridgeRings(mesh, rings[s]!, rings[s + 1]!); + if (rings.length > 0) { + if (capStart) capRing(mesh, rings[0]!, axes[0] ?? [0, 1, 0], false); + if (capEnd) capRing(mesh, rings[rings.length - 1]!, axes[axes.length - 1] ?? [0, 1, 0], true); + } + + // Caps reuse ring vertices, so the vertex block is exactly rings x sides. + return { firstVertex, rings: ringCount, sides }; +} + +/** Rectangular face opening on lower rings — clears forehead / eyes. */ +export interface DomeFaceCut { + /** + * How far up the cap (0 at hairline → 1 at crown) the cut extends. + * Default 0.62 — open through the eye band, closed over the upper scalp. + */ + height?: number; + /** + * Half-width of the cut as a fraction of the ring half-width (0–1). + * Default 0.62 — temples still wrap; centre front is open. + */ + halfWidth?: number; + /** + * Face-depth of the cut plane as a fraction of halfDepth. + * Default 0.14 — flat “window” pulled back off the face. + */ + depth?: number; +} + +export interface DomeShellOptions { + /** Number of rings along the skull axis (not counting the apex). */ + sections?: number; + /** Ring sides. 6–8 reads as a helmet at PSX resolution. */ + sides?: number; + /** + * How far the face half of each ring extends relative to halfDepth + * at the hairline. ~0.85–1.0 covers forehead scalp. + */ + frontScale?: number; + /** Front scale at the crown end of the cap. */ + frontScaleTop?: number; + /** How far the occiput half extends relative to halfDepth. */ + backScale?: number; + /** Radial scale at the crown ring (keep high so the top isn't bald). */ + topScale?: number; + /** World-space face direction (default +Z). */ + faceDir?: Vec3Tuple; + /** + * Rectangular cut on the face side of lower rings so forehead and eyes + * stay clear. Temples and crown remain covered. + */ + faceCut?: DomeFaceCut | true; + /** Close the crown end with an apex fan. */ + apex?: boolean; +} + +/** + * Skull-cap shell extruded along an arbitrary axis (hairline → crown). + * + * Rings lie in planes perpendicular to `from→to`, so the cap follows the + * angled top of the head instead of stacking as a vertical chimney. Face/back + * asymmetry uses the projection of `faceDir` into each ring plane. + */ +export function domeShell( + mesh: MeshData, + from: Vec3Tuple, + to: Vec3Tuple, + halfWidth: number, + halfDepth: number, + color: Rgb, + options: DomeShellOptions = {}, +): void { + const axis = sub(to, from); + const axisLen = length(axis); + if (axisLen < 1e-6) return; + + const sections = Math.max(2, options.sections ?? 4); + const sides = Math.max(6, options.sides ?? 8); + const frontScaleBottom = options.frontScale ?? 0.88; + const frontScaleTop = options.frontScaleTop ?? frontScaleBottom * 0.72; + const backScale = options.backScale ?? 1.12; + const topScale = options.topScale ?? 0.82; + const withApex = options.apex ?? true; + + const faceCutOpt = options.faceCut === true ? {} : options.faceCut; + const cutHeight = faceCutOpt?.height ?? (faceCutOpt ? 0.62 : 0); + const cutHalfWidthFrac = faceCutOpt?.halfWidth ?? 0.62; + const cutDepthFrac = faceCutOpt?.depth ?? 0.14; + const useFaceCut = faceCutOpt != null && cutHeight > 0; + + const axisN = normalise(axis); + const faceWanted = options.faceDir ?? ([0, 0, 1] as Vec3Tuple); + // Face vector in the ring plane (perp to skull axis). + let face = sub(faceWanted, scale(axisN, dot(faceWanted, axisN))); + if (length(face) < 1e-5) { + // Axis parallel to faceDir — pick a stable fallback. + const alt: Vec3Tuple = Math.abs(axisN[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]; + face = sub(alt, scale(axisN, dot(alt, axisN))); + } + face = normalise(face); + // side × face should align with axis for a right-handed frame. + // side = axis × face → looking along axis, face is "up" of the ring, side is "right". + const side = normalise(cross(axisN, face)); + + const rings: Ring[] = []; + + for (let s = 0; s <= sections; s++) { + const t = s / sections; + const radial = 1 - (1 - topScale) * (t * t * 0.85); + const bulge = 1 + 0.05 * Math.sin(t * Math.PI); + const centre = lerp(from, to, t); + const hw = halfWidth * radial * bulge; + const hd = halfDepth * radial * bulge; + const frontScale = frontScaleBottom + (frontScaleTop - frontScaleBottom) * t; + + // Soft falloff of the cut as we approach its top edge (keeps crown solid). + const cutBlend = + useFaceCut && t < cutHeight ? 1 - smoothstep(cutHeight * 0.72, cutHeight, t) : 0; + const cutW = hw * cutHalfWidthFrac; + const cutD = hd * cutDepthFrac; + + const indices: number[] = []; + for (let i = 0; i < sides; i++) { + // CCW looking along axis: match tube orientedRing convention + // (cos,sin) → (−side, +face) so bridgeRings faces outward. + const angle = (i / sides) * Math.PI * 2 + Math.PI / sides; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const depthScale = sin >= 0 ? frontScale : backScale; + let sideAmt = -cos * hw; + let faceAmt = sin * hd * depthScale; + + // Rectangular face window: pull centre-front verts back off the face so + // forehead + eyes read clear; temples (|side| > cutW) still wrap forward. + if (cutBlend > 0 && sin > 0.02) { + const sideAbs = Math.abs(sideAmt); + if (sideAbs <= cutW) { + // Hard rectangle in the middle; slight ease at the vertical edges. + const edge = sideAbs / Math.max(cutW, 1e-6); + const rect = edge < 0.85 ? 1 : 1 - (edge - 0.85) / 0.15; + const pull = cutBlend * rect; + faceAmt = faceAmt * (1 - pull) + cutD * pull; + } + } + + const offset = add(scale(side, sideAmt), scale(face, faceAmt)); + indices.push(pushVertex(mesh, add(centre, offset), color)); + } + rings.push({ indices, right: side }); + } + + for (let s = 0; s < rings.length - 1; s++) { + bridgeRings(mesh, rings[s]!, rings[s + 1]!); + } + + if (withApex) { + const top = rings[rings.length - 1]!; + // Slight past `to` along the skull axis. + const apex = pushVertex(mesh, add(to, scale(axisN, axisLen * 0.06)), color); + const n = top.indices.length; + for (let i = 0; i < n; i++) { + const next = (i + 1) % n; + // Ring is CCW looking along +axis (from→to). Apex is on the +axis end: + // keep ring order so the fan normal points outward along +axisN. + pushTriangle(mesh, apex, top.indices[i]!, top.indices[next]!); + } + } +} + +function smoothstep(edge0: number, edge1: number, x: number): number { + const t = Math.max(0, Math.min(1, (x - edge0) / Math.max(edge1 - edge0, 1e-6))); + return t * t * (3 - 2 * t); +} + +/** Axis-aligned box, still useful for props and simple feet. */ +export function box( + mesh: MeshData, + centre: Vec3Tuple, + half: Vec3Tuple, + color: Rgb, +): void { + const axis: Vec3Tuple = [0, 1, 0]; + const lower = orientedRing( + mesh, + [centre[0], centre[1] - half[1], centre[2]], + axis, + half[0], + half[2], + color, + 4, + ); + const upper = orientedRing( + mesh, + [centre[0], centre[1] + half[1], centre[2]], + axis, + half[0], + half[2], + color, + 4, + ); + bridgeRings(mesh, lower, upper); + capRing(mesh, lower, axis, false); + capRing(mesh, upper, axis, true); +} + +/** + * Oriented box — centre, half-extents in local right/up/forward, and a forward + * axis. Used for hands and boots that must point along the limb. + * + * Each face is wound CCW when viewed from outside so front-face culling keeps + * the exterior visible. + */ +export function orientedBox( + mesh: MeshData, + centre: Vec3Tuple, + halfRight: number, + halfUp: number, + halfForward: number, + forwardAxis: Vec3Tuple, + color: Rgb, +): void { + const { forward, right, up } = basisFromAxis(forwardAxis); + + // Index map: bit0 = +right, bit1 = +up, bit2 = +forward + const corner = (r: number, u: number, f: number): Vec3Tuple => + add( + centre, + add(scale(right, r * halfRight), add(scale(up, u * halfUp), scale(forward, f * halfForward))), + ); + + const idx = [ + pushVertex(mesh, corner(-1, -1, -1), color), // 0 + pushVertex(mesh, corner(1, -1, -1), color), // 1 + pushVertex(mesh, corner(1, 1, -1), color), // 2 + pushVertex(mesh, corner(-1, 1, -1), color), // 3 + pushVertex(mesh, corner(-1, -1, 1), color), // 4 + pushVertex(mesh, corner(1, -1, 1), color), // 5 + pushVertex(mesh, corner(1, 1, 1), color), // 6 + pushVertex(mesh, corner(-1, 1, 1), color), // 7 + ]; + + // −forward (looking toward −F): CCW is 0→3→2→1 + pushQuad(mesh, idx[0]!, idx[3]!, idx[2]!, idx[1]!); + // +forward + pushQuad(mesh, idx[4]!, idx[5]!, idx[6]!, idx[7]!); + // −up + pushQuad(mesh, idx[0]!, idx[1]!, idx[5]!, idx[4]!); + // +up + pushQuad(mesh, idx[3]!, idx[7]!, idx[6]!, idx[2]!); + // −right + pushQuad(mesh, idx[0]!, idx[4]!, idx[7]!, idx[3]!); + // +right + pushQuad(mesh, idx[1]!, idx[2]!, idx[6]!, idx[5]!); +} + +/** + * Flat normals, computed per triangle and accumulated per shared vertex then + * re-normalised. Shared vertices on a flat face stay faceted enough for the + * era; adjacent coplanar quads don't get a hard crease through the middle. + */ +export function computeFlatNormals(mesh: MeshData): void { + const positions = mesh.positions; + const normals = new Array(positions.length).fill(0); + + for (let i = 0; i < mesh.indices.length; i += 3) { + const a = mesh.indices[i]! * 3; + const b = mesh.indices[i + 1]! * 3; + const c = mesh.indices[i + 2]! * 3; + + const abx = positions[b]! - positions[a]!; + const aby = positions[b + 1]! - positions[a + 1]!; + const abz = positions[b + 2]! - positions[a + 2]!; + const acx = positions[c]! - positions[a]!; + const acy = positions[c + 1]! - positions[a + 1]!; + const acz = positions[c + 2]! - positions[a + 2]!; + + const nx = aby * acz - abz * acy; + const ny = abz * acx - abx * acz; + const nz = abx * acy - aby * acx; + + for (const vertex of [a, b, c]) { + normals[vertex] = normals[vertex]! + nx; + normals[vertex + 1] = normals[vertex + 1]! + ny; + normals[vertex + 2] = normals[vertex + 2]! + nz; + } + } + + for (let i = 0; i < normals.length; i += 3) { + const x = normals[i]!; + const y = normals[i + 1]!; + const z = normals[i + 2]!; + const len = Math.hypot(x, y, z); + if (len > 1e-8) { + normals[i] = x / len; + normals[i + 1] = y / len; + normals[i + 2] = z / len; + } else { + normals[i] = 0; + normals[i + 1] = 1; + normals[i + 2] = 0; + } + } + + mesh.normals = normals; +} + +/** Axis-aligned bounds, needed for the GLB accessor min/max. */ +export function bounds(mesh: MeshData): { min: Vec3Tuple; max: Vec3Tuple } { + const min: Vec3Tuple = [Infinity, Infinity, Infinity]; + const max: Vec3Tuple = [-Infinity, -Infinity, -Infinity]; + + for (let i = 0; i < mesh.positions.length; i += 3) { + for (let axis = 0; axis < 3; axis++) { + const value = mesh.positions[i + axis]!; + if (value < min[axis]!) min[axis] = value; + if (value > max[axis]!) max[axis] = value; + } + } + return { min, max }; +} diff --git a/tools/src/mesh/humanoid.ts b/tools/src/mesh/humanoid.ts new file mode 100644 index 0000000..683583e --- /dev/null +++ b/tools/src/mesh/humanoid.ts @@ -0,0 +1,231 @@ +import type { CanonicalBone } from "@psx/shared"; +import type { Measurements, Palette } from "../image/analyze.js"; +import { jointWorld, type Skeleton } from "../rig/skeleton.js"; +import { + add, + computeFlatNormals, + createMesh, + lerp, + normalise, + orientedBox, + scale, + sub, + tube, + tubePath, + type MeshData, + type Vec3Tuple, +} from "./MeshBuilder.js"; + +/** + * Silent Hill–style low-poly humanoid from measured proportions. + * + * Design targets (SH1 / early RE, not Roblox): + * - ~600–1200 triangles, faceted, vertex-coloured + * - Hex cross-sections so limbs read round at 240p + * - Rings oriented along bones (A-pose arms stay clean) + * - Soft torso taper (shoulder → chest → waist → hip) + * - Slightly oversized head, short neck, defined shoulder mass + * - Feet point forward with a heel; hands are flat paddles + * + * Geometry is still drawn between canonical joints so skin weights and clips + * stay portable across every character the harness produces. + */ + +export interface HumanoidOptions { + /** Cross-section sides for limbs (6 ≈ SH1). */ + sides?: number; + /** Body depth as a fraction of width. Front view cannot measure this. */ + depthRatio?: number; +} + +export function buildHumanoidMesh( + skeleton: Skeleton, + measurements: Measurements, + palette: Palette, + options: HumanoidOptions = {}, +): MeshData { + const mesh = createMesh(); + const sides = options.sides ?? 6; + const depthRatio = options.depthRatio ?? 0.58; + const { height } = skeleton; + const across = (fraction: number): number => fraction * height; + const at = (bone: CanonicalBone): Vec3Tuple => jointWorld(skeleton, bone); + const half = (widthFraction: number): number => across(widthFraction) / 2; + + const hips = at("Hips"); + const spine = at("Spine"); + const spine1 = at("Spine1"); + const spine2 = at("Spine2"); + const neck = at("Neck"); + const head = at("Head"); + + const hipW = half(measurements.widths.hip); + const waistW = half(measurements.widths.waist) * 0.92; + const chestW = half(measurements.widths.chest); + const shoulderW = half(measurements.widths.shoulder); + const neckW = half(measurements.widths.neck); + const headW = half(measurements.widths.head); + + // --- pelvis / torso ------------------------------------------------------ + // Wider hips, pinched waist, full chest, soft shoulder shelf — the SH1 + // jacket silhouette. Slight forward lean on the upper chest for depth. + const pelvisTop = lerp(hips, spine, 0.35); + tube(mesh, hips, pelvisTop, hipW * 1.05, hipW * 0.98, depthRatio + 0.08, palette.torsoLower, { + sides, + sections: 2, + }); + + // Drop the hip joint centre slightly so the pelvis has volume under the belt. + const belly = lerp(spine, spine1, 0.35); + tube(mesh, pelvisTop, belly, hipW * 0.95, waistW, depthRatio + 0.05, palette.torsoLower, { + sides, + sections: 3, + profile: (t) => ({ width: 1 - 0.04 * Math.sin(t * Math.PI), depth: 1 }), + }); + + const rib = lerp(spine1, spine2, 0.55); + const ribPoint: Vec3Tuple = [rib[0], rib[1], rib[2] + height * 0.012]; + tube(mesh, belly, ribPoint, waistW, chestW * 0.98, depthRatio + 0.1, palette.torsoUpper, { + sides, + sections: 3, + }); + + // Shoulder yoke — wider than the ribcage, flatter in depth (coat shoulders). + const yoke: Vec3Tuple = [spine2[0], spine2[1] + height * 0.01, spine2[2] + height * 0.008]; + tube(mesh, ribPoint, yoke, chestW * 0.95, shoulderW * 0.92, depthRatio * 0.85, palette.torsoUpper, { + sides, + sections: 2, + }); + + // Collar / neck base. + tube(mesh, yoke, neck, neckW * 1.55, neckW * 1.15, 0.85, palette.torsoUpper, { + sides: 6, + sections: 1, + }); + + // --- neck + head --------------------------------------------------------- + // Short neck, then an egg-shaped head (taller than wide, slightly deeper face). + tube(mesh, neck, head, neckW, neckW * 0.95, 0.9, palette.skin, { sides: 6, sections: 1 }); + + const crownY = height; + const chin = head; + const crown: Vec3Tuple = [0, crownY, head[2] * 0.3]; + const brow = lerp(chin, crown, 0.45); + const browFwd: Vec3Tuple = [brow[0], brow[1], brow[2] + headW * 0.15]; + + // Face (skin) — slightly forward of the skull centreline. + tube(mesh, chin, browFwd, headW * 0.72, headW * 0.88, 0.95, palette.skin, { + sides: 6, + sections: 2, + profile: (t) => ({ width: 0.9 + 0.15 * Math.sin(t * Math.PI), depth: 1 }), + }); + + // Cranial mass + hair cap sitting on top. + const skull: Vec3Tuple = [0, (brow[1] + crownY) * 0.5, head[2] * 0.2]; + const hairTop: Vec3Tuple = [0, crownY, head[2] * 0.15]; + tube(mesh, browFwd, skull, headW * 0.9, headW * 0.95, 1.05, palette.skin, { + sides: 6, + sections: 1, + caps: false, + }); + tube(mesh, skull, hairTop, headW * 0.98, headW * 0.7, 1.05, palette.hair, { + sides: 6, + sections: 2, + profile: (t) => ({ width: 1 + 0.08 * (1 - t), depth: 1 + 0.05 * (1 - t) }), + }); + + // --- shoulders + arms ---------------------------------------------------- + for (const prefix of ["Left", "Right"] as const) { + const side = prefix === "Left" ? 1 : -1; + const shoulder = at(`${prefix}Shoulder` as CanonicalBone); + const arm = at(`${prefix}Arm` as CanonicalBone); + const foreArm = at(`${prefix}ForeArm` as CanonicalBone); + const hand = at(`${prefix}Hand` as CanonicalBone); + + const upperHalf = half(measurements.widths.upperArm) * 0.95; + const foreHalf = half(measurements.widths.forearm) * 0.95; + + // Deltoid mass bridging torso to upper arm (the SH "coat shoulder"). + const deltoid = lerp(shoulder, arm, 0.35); + tube(mesh, shoulder, deltoid, upperHalf * 1.25, upperHalf * 1.1, 1.05, palette.torsoUpper, { + sides, + sections: 2, + }); + + // Continuous arm path so the elbow bends without a hard mesh break. + tubePath( + mesh, + [deltoid, arm, foreArm, hand], + [upperHalf * 1.05, upperHalf, foreHalf * 1.05, foreHalf * 0.88], + 0.92, + palette.arm, + { sides, sectionsPerSegment: 2 }, + ); + + // Hand paddle past the wrist, flattened. + const handDir = normalise(sub(hand, foreArm)); + const handCentre = add(hand, scale(handDir, across(0.028))); + orientedBox( + mesh, + handCentre, + foreHalf * 0.85, + foreHalf * 0.35, + across(0.03), + handDir, + palette.hand, + ); + void side; + } + + // --- legs + feet --------------------------------------------------------- + for (const prefix of ["Left", "Right"] as const) { + const upLeg = at(`${prefix}UpLeg` as CanonicalBone); + const leg = at(`${prefix}Leg` as CanonicalBone); + const foot = at(`${prefix}Foot` as CanonicalBone); + const toe = at(`${prefix}ToeBase` as CanonicalBone); + + const thighHalf = half(measurements.widths.thigh) * 1.02; + const calfHalf = half(measurements.widths.calf); + + // Hip join: slight flair so legs don't spike out of a flat pelvis. + const hipJoin = lerp(hips, upLeg, 0.55); + tube(mesh, hipJoin, upLeg, hipW * 0.42, thighHalf * 1.05, depthRatio + 0.15, palette.leg, { + sides, + sections: 1, + }); + + tubePath( + mesh, + [upLeg, leg, foot], + [thighHalf, thighHalf * 0.92, calfHalf * 0.9], + depthRatio + 0.18, + palette.leg, + { sides, sectionsPerSegment: 3 }, + ); + + // Boot: sole sits on y=0 so the character is grounded (not floating). + const footHalfW = half(measurements.widths.foot) * 0.95; + const footAxis = normalise(sub(toe, foot)); + // If toe is almost under ankle, force forward +Z so boots still read. + const forward: Vec3Tuple = + Math.hypot(footAxis[0], footAxis[2]) < 0.2 ? [0, 0, 1] : [footAxis[0], 0, footAxis[2]]; + const bootTop = Math.max(across(0.045), foot[1] * 0.55); + const bootCentre: Vec3Tuple = [ + foot[0], + bootTop * 0.5, + foot[2] + across(0.025), + ]; + orientedBox( + mesh, + bootCentre, + footHalfW, + bootTop * 0.5, + across(0.055), + normalise(forward), + palette.foot, + ); + } + + computeFlatNormals(mesh); + return mesh; +} diff --git a/tools/src/pipeline.ts b/tools/src/pipeline.ts new file mode 100644 index 0000000..4443f61 --- /dev/null +++ b/tools/src/pipeline.ts @@ -0,0 +1,176 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { analyzeReference, type ReferenceAnalysis } from "./image/analyze.js"; +import { loadRaster } from "./image/Raster.js"; +import { denoise, segmentForeground } from "./image/segment.js"; +import { buildHumanoidMesh } from "./mesh/humanoid.js"; +import { buildSkeleton, type Skeleton } from "./rig/skeleton.js"; +import { computeInverseBindMatrices, computeSkinWeights, skinStatistics } from "./rig/skin.js"; +import { buildDefaultClips, type AnimationClip } from "./anim/procedural.js"; +import { exportGlb, meshStatistics } from "./export/glb.js"; +import { GrokBridge, type ImageResult } from "./grok/GrokBridge.js"; +import { characterReferencePrompt } from "./grok/prompts.js"; +import type { MeshData } from "./mesh/MeshBuilder.js"; + +/** + * Photo to rigged, animated GLB — Milestone 3 end to end. + * + * reference image → silhouette → measurements + palette + * → mesh drawn in code + * → canonical skeleton + automatic skin weights + * → procedural clips + * → GLB + * + * The reference can be generated through the Grok bridge or supplied as a file; + * everything downstream is deterministic, so the same image always yields the + * same character. + */ + +export interface PipelineOptions { + /** Existing reference image. Takes precedence over `describe`. */ + imagePath?: string; + /** Text description, generated into a reference via the Grok bridge. */ + describe?: string; + name?: string; + outputPath: string; + /** Target world height in metres. */ + height?: number; + /** Also write the analysis as JSON beside the GLB. */ + writeReport?: boolean; + clips?: AnimationClip[]; + grok?: GrokBridge; +} + +export interface PipelineResult { + glbPath: string; + reportPath: string | null; + image: { path: string; generated: boolean; costUsd: number }; + analysis: ReferenceAnalysis; + skeleton: Skeleton; + mesh: MeshData; + stats: { + vertices: number; + triangles: number; + bones: number; + clips: number; + maxInfluences: number; + averageInfluences: number; + unweightedVertices: number; + glbBytes: number; + }; +} + +export async function runPipeline(options: PipelineOptions): Promise { + const { imagePath, generated, costUsd } = await resolveReference(options); + + // --- analyse ------------------------------------------------------------- + const raster = await loadRaster(imagePath); + const mask = denoise(segmentForeground(raster)); + const analysis = analyzeReference(raster, mask); + + // --- rig and mesh -------------------------------------------------------- + // The skeleton is built first: the mesh is drawn *between* its joints, so the + // two can never disagree about where a knee is. + const skeleton = buildSkeleton(analysis.measurements, { height: options.height ?? 1.7 }); + const mesh = buildHumanoidMesh(skeleton, analysis.measurements, analysis.palette); + + computeSkinWeights(mesh, skeleton); + const inverseBindMatrices = computeInverseBindMatrices(skeleton); + + // --- animate and export -------------------------------------------------- + const clips = options.clips ?? buildDefaultClips(); + const name = options.name ?? "Character"; + const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { name, clips }); + + await mkdir(dirname(options.outputPath), { recursive: true }); + await writeFile(options.outputPath, glb); + + let reportPath: string | null = null; + if (options.writeReport !== false) { + reportPath = options.outputPath.replace(/\.glb$/i, "") + ".analysis.json"; + await writeFile( + reportPath, + JSON.stringify( + { + name, + sourceImage: imagePath, + generated, + measurements: analysis.measurements, + palette: analysis.palette, + notes: analysis.notes, + }, + null, + 2, + ), + ); + } + + const meshStats = meshStatistics(mesh); + const skinStats = skinStatistics(mesh); + + return { + glbPath: options.outputPath, + reportPath, + image: { path: imagePath, generated, costUsd }, + analysis, + skeleton, + mesh, + stats: { + vertices: meshStats.vertices, + triangles: meshStats.triangles, + bones: skeleton.joints.length, + clips: clips.length, + maxInfluences: skinStats.maxInfluences, + averageInfluences: skinStats.averageInfluences, + unweightedVertices: skinStats.unweighted, + glbBytes: glb.byteLength, + }, + }; +} + +async function resolveReference( + options: PipelineOptions, +): Promise<{ imagePath: string; generated: boolean; costUsd: number }> { + if (options.imagePath) { + return { imagePath: options.imagePath, generated: false, costUsd: 0 }; + } + + if (!options.describe) { + throw new Error("provide either imagePath or describe"); + } + + const bridge = options.grok ?? new GrokBridge(); + const request = characterReferencePrompt({ description: options.describe }); + const result: ImageResult = await bridge.generate(request); + + return { imagePath: result.path, generated: !result.cached, costUsd: result.costUsd }; +} + +/** Human-readable summary for the CLI. */ +export function formatReport(result: PipelineResult): string { + const { stats, analysis } = result; + const lines: string[] = []; + + lines.push(` source ${result.image.path}${result.image.generated ? " (generated)" : ""}`); + lines.push(` output ${result.glbPath} (${(stats.glbBytes / 1024).toFixed(1)} KB)`); + lines.push(` mesh ${stats.vertices} vertices, ${stats.triangles} triangles`); + lines.push(` rig ${stats.bones} canonical bones, ${stats.clips} clips`); + lines.push( + ` skinning max ${stats.maxInfluences} influences, ` + + `avg ${stats.averageInfluences.toFixed(2)}, ${stats.unweightedVertices} unweighted`, + ); + + const { landmarks } = analysis.measurements; + const derived = Object.entries(landmarks) + .filter(([, landmark]) => landmark.source === "derived") + .map(([name]) => name); + lines.push(` measured ${Object.keys(landmarks).length - derived.length}/${Object.keys(landmarks).length} landmarks`); + if (derived.length > 0) lines.push(` derived ${derived.join(", ")}`); + for (const note of analysis.notes) lines.push(` note ${note}`); + + return lines.join("\n"); +} + +/** Default output location for a named character. */ +export const defaultOutputPath = (name: string): string => + join("assets", "characters", `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.glb`); diff --git a/tools/src/preview/cli.ts b/tools/src/preview/cli.ts new file mode 100644 index 0000000..ff76f3f --- /dev/null +++ b/tools/src/preview/cli.ts @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * Offscreen mesh review. + * + * pnpm preview # bare male + female base bodies + * pnpm preview -- --wire # with topology overlay + * pnpm preview -- --clay # flat clay instead of skin palette + * pnpm preview -- --id survivor # an assembled preset + * pnpm preview -- --body female --style mass=0.6,muscle=-0.2,fat=0.3 + * pnpm preview -- --body male --face length=-0.4,jaw=0.8,brow=0.6 + * pnpm preview -- --backfaces # magenta = inverted winding + */ +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { rasterToPng, type Raster } from "../image/Raster.js"; +import { computeFlatNormals } from "../mesh/MeshBuilder.js"; +import { buildSkeleton } from "../rig/skeleton.js"; +import { assembleCharacter } from "../creator/assemble.js"; +import { buildBaseBody } from "../creator/body.js"; +import { normalizeBodyStyle, type BodyStyle } from "../creator/bodyStyle.js"; +import { normalizeHeadStyle, type HeadStyle } from "../creator/headStyle.js"; +import { DEFAULT_HEIGHT, measurementsFor } from "../creator/proportions.js"; +import { presetById } from "../creator/presets.js"; +import type { BodyType, CreatorPalette } from "../creator/types.js"; +import { buildDefaultClips } from "../anim/procedural.js"; +import { poseMesh } from "./pose.js"; +import { renderSheet, stackRasters, type RenderOptions } from "./render.js"; + +const ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url))); +const OUT_DIR = join(ROOT, "assets", "generated", "preview"); + +const NEUTRAL_PALETTE: CreatorPalette = { + skin: { r: 200, g: 160, b: 130 }, + hair: { r: 60, g: 45, b: 38 }, + primary: { r: 90, g: 96, b: 110 }, + secondary: { r: 60, g: 64, b: 74 }, + accent: { r: 150, g: 60, b: 50 }, + metal: { r: 150, g: 152, b: 158 }, + leather: { r: 80, g: 58, b: 42 }, +}; + +const VIEWS = [ + { yaw: 0 }, + { yaw: 35 }, + { yaw: 90 }, + { yaw: 180 }, +]; + +interface Args { + id?: string; + body?: BodyType; + style: Partial; + face: Partial; + wire: boolean; + clay: boolean; + backfaces: boolean; + size: number; + out?: string; + clip?: string; + /** Phases through the clip to render, 0..1. */ + phases: number[]; + help: boolean; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { style: {}, face: {}, wire: false, clay: false, backfaces: false, size: 420, phases: [], help: false }; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]!; + if (token === "--") continue; + if (token === "--id" || token === "-i") args.id = argv[++i]; + else if (token === "--body" || token === "-b") args.body = argv[++i] as BodyType; + else if (token === "--wire" || token === "-w") args.wire = true; + else if (token === "--clay") args.clay = true; + else if (token === "--backfaces") args.backfaces = true; + else if (token === "--size" || token === "-s") args.size = Number(argv[++i]); + else if (token === "--out" || token === "-o") args.out = argv[++i]; + else if (token === "--style" || token === "--face") { + const bag = token === "--face" ? args.face : args.style; + for (const pair of (argv[++i] ?? "").split(",")) { + const [key, value] = pair.split("="); + if (key && value !== undefined) { + (bag as Record)[key.trim()] = Number(value); + } + } + } else if (token === "--clip" || token === "-c") args.clip = argv[++i]; + else if (token === "--phase" || token === "-p") { + args.phases = (argv[++i] ?? "").split(",").map(Number).filter(Number.isFinite); + } else if (token === "--help" || token === "-h") args.help = true; + } + return args; +} + +function baseBody(body: BodyType, style: BodyStyle, face: HeadStyle) { + const skeleton = buildSkeleton(measurementsFor(body), { height: DEFAULT_HEIGHT[body] }); + const mesh = buildBaseBody(skeleton, body, NEUTRAL_PALETTE, style, face); + computeFlatNormals(mesh); + return mesh; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + [ + "pnpm preview bare male + female base bodies", + "pnpm preview -- --wire topology overlay", + "pnpm preview -- --clay flat clay shading", + "pnpm preview -- --backfaces magenta = inverted winding", + "pnpm preview -- --id survivor assembled preset", + "pnpm preview -- --body female --style mass=0.5,fat=0.3", + "pnpm preview -- --body male --face length=-0.4,jaw=0.8,brow=0.6", + "pnpm preview -- --id survivor --clip Walk four phases of a clip", + "pnpm preview -- --id survivor --clip Walk --phase 0.25,0.5", + "pnpm preview -- --size 520 --out /tmp/base.png", + ].join("\n"), + ); + return; + } + + const options: RenderOptions = { + size: args.size, + wireframe: args.wire, + showBackfaces: args.backfaces, + ...(args.clay ? { clay: { r: 186, g: 182, b: 176 } } : {}), + }; + + const rows: Raster[] = []; + let label: string; + + if (args.id) { + const recipe = presetById(args.id); + if (!recipe) throw new Error(`Unknown preset "${args.id}"`); + const assembled = assembleCharacter(recipe); + + if (args.clip) { + // Deformation, not silhouette: one view, several moments in the cycle. + const clip = buildDefaultClips().find( + (c) => c.name.toLowerCase() === args.clip!.toLowerCase(), + ); + if (!clip) { + throw new Error( + `Unknown clip "${args.clip}". Have: ${buildDefaultClips().map((c) => c.name).join(", ")}`, + ); + } + const phases = args.phases.length > 0 ? args.phases : [0, 0.25, 0.5, 0.75]; + for (const yaw of [90, 35]) { + rows.push( + renderSheet( + phases.map((phase) => ({ + mesh: poseMesh(assembled.mesh, assembled.skeleton, clip, phase * clip.duration), + yaw, + })), + options, + ), + ); + } + label = `${recipe.id}-${clip.name.toLowerCase()}`; + } else { + rows.push(renderSheet(VIEWS.map((v) => ({ mesh: assembled.mesh, ...v })), options)); + label = recipe.id; + } + } else { + const style = normalizeBodyStyle(args.style); + const face = normalizeHeadStyle(args.face); + const bodies: BodyType[] = args.body ? [args.body] : ["male", "female"]; + for (const body of bodies) { + const mesh = baseBody(body, style, face); + console.log( + `${body.padEnd(7)} ${mesh.positions.length / 3} vertices, ${mesh.indices.length / 3} triangles`, + ); + rows.push(renderSheet(VIEWS.map((v) => ({ mesh, ...v })), options)); + } + label = args.body ? `base-${args.body}` : "base-body"; + } + + const sheet = stackRasters(rows); + const outPath = args.out ?? join(OUT_DIR, `${label}.png`); + await mkdir(resolve(outPath, ".."), { recursive: true }); + await writeFile(outPath, rasterToPng(sheet)); + console.log(`${outPath} ${sheet.width}x${sheet.height}`); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/tools/src/preview/pose.ts b/tools/src/preview/pose.ts new file mode 100644 index 0000000..dc882da --- /dev/null +++ b/tools/src/preview/pose.ts @@ -0,0 +1,160 @@ +import { CANONICAL_HIERARCHY, type CanonicalBone } from "@psx/shared"; +import type { AnimationClip } from "../anim/procedural.js"; +import { computeFlatNormals, vertexCount, type MeshData } from "../mesh/MeshBuilder.js"; +import type { Skeleton } from "../rig/skeleton.js"; + +/** + * Linear blend skinning on the CPU, so a clip can be inspected offscreen. + * + * Skin weights are only wrong in ways you can see once the skeleton moves — + * a heel split 50/50 between shin and foot looks perfect in bind pose and + * shears into a wedge the moment the ankle pitches. Rendering a posed mesh is + * the only way to catch that without launching the game. + * + * Matches the runtime's convention: rest rotations are identity, rest + * translations are the joint's local offset, and the inverse bind matrix is a + * translation by the negated rest world position. + */ + +/** Column-major 4x4, matching glTF. */ +type Mat4 = Float32Array; + +function identity(): Mat4 { + const m = new Float32Array(16); + m[0] = 1; + m[5] = 1; + m[10] = 1; + m[15] = 1; + return m; +} + +function multiply(a: Mat4, b: Mat4): Mat4 { + const out = new Float32Array(16); + for (let col = 0; col < 4; col++) { + for (let row = 0; row < 4; row++) { + let sum = 0; + for (let k = 0; k < 4; k++) sum += a[k * 4 + row]! * b[col * 4 + k]!; + out[col * 4 + row] = sum; + } + } + return out; +} + +/** Translation composed with an Euler XYZ rotation — the clips' channel form. */ +function trs(translation: readonly number[], euler: readonly [number, number, number]): Mat4 { + const [x, y, z] = euler; + const cx = Math.cos(x); + const sx = Math.sin(x); + const cy = Math.cos(y); + const sy = Math.sin(y); + const cz = Math.cos(z); + const sz = Math.sin(z); + + // R = Rz * Ry * Rx, matching eulerToQuaternion in anim/procedural.ts. + const m = identity(); + m[0] = cy * cz; + m[1] = cy * sz; + m[2] = -sy; + m[4] = sx * sy * cz - cx * sz; + m[5] = sx * sy * sz + cx * cz; + m[6] = sx * cy; + m[8] = cx * sy * cz + sx * sz; + m[9] = cx * sy * sz - sx * cz; + m[10] = cx * cy; + m[12] = translation[0]!; + m[13] = translation[1]!; + m[14] = translation[2]!; + return m; +} + +/** Sample a channel's keyframes, lerping the Euler triples. */ +function sampleEuler(clip: AnimationClip, bone: CanonicalBone, time: number): [number, number, number] { + const channel = clip.channels.find((c) => c.bone === bone); + if (!channel || channel.keyframes.length === 0) return [0, 0, 0]; + const keys = channel.keyframes; + + if (time <= keys[0]!.time) return keys[0]!.rotation; + const last = keys[keys.length - 1]!; + if (time >= last.time) return last.rotation; + + for (let i = 0; i < keys.length - 1; i++) { + const a = keys[i]!; + const b = keys[i + 1]!; + if (time > b.time) continue; + const span = b.time - a.time; + const f = span > 1e-6 ? (time - a.time) / span : 0; + return [ + a.rotation[0] + (b.rotation[0] - a.rotation[0]) * f, + a.rotation[1] + (b.rotation[1] - a.rotation[1]) * f, + a.rotation[2] + (b.rotation[2] - a.rotation[2]) * f, + ]; + } + return last.rotation; +} + +/** Skinning matrices, one per joint, in the skeleton's own joint order. */ +export function poseMatrices(skeleton: Skeleton, clip: AnimationClip, time: number): Mat4[] { + const world: Mat4[] = []; + const skin: Mat4[] = []; + + for (const [i, joint] of skeleton.joints.entries()) { + const local = trs(joint.local, sampleEuler(clip, joint.name, time)); + const parentName = CANONICAL_HIERARCHY[joint.name]; + const parentIndex = parentName === null ? -1 : (skeleton.index.get(parentName) ?? -1); + world[i] = parentIndex >= 0 ? multiply(world[parentIndex]!, local) : local; + + // Inverse bind is a pure translation by the negated rest world position. + const inverseBind = identity(); + inverseBind[12] = -joint.world[0]; + inverseBind[13] = -joint.world[1]; + inverseBind[14] = -joint.world[2]; + skin[i] = multiply(world[i]!, inverseBind); + } + return skin; +} + +/** A copy of `mesh` deformed into the clip's pose at `time`. */ +export function poseMesh( + mesh: MeshData, + skeleton: Skeleton, + clip: AnimationClip, + time: number, +): MeshData { + const skin = poseMatrices(skeleton, clip, time); + const count = vertexCount(mesh); + const posed: MeshData = { ...mesh, positions: new Array(count * 3).fill(0) }; + + for (let v = 0; v < count; v++) { + const x = mesh.positions[v * 3]!; + const y = mesh.positions[v * 3 + 1]!; + const z = mesh.positions[v * 3 + 2]!; + let ox = 0; + let oy = 0; + let oz = 0; + let total = 0; + + for (let i = 0; i < 4; i++) { + const weight = mesh.weights[v * 4 + i] ?? 0; + if (weight <= 0) continue; + const m = skin[mesh.joints[v * 4 + i]!]; + if (!m) continue; + total += weight; + ox += weight * (m[0]! * x + m[4]! * y + m[8]! * z + m[12]!); + oy += weight * (m[1]! * x + m[5]! * y + m[9]! * z + m[13]!); + oz += weight * (m[2]! * x + m[6]! * y + m[10]! * z + m[14]!); + } + + if (total <= 0) { + ox = x; + oy = y; + oz = z; + } + posed.positions[v * 3] = ox; + posed.positions[v * 3 + 1] = oy; + posed.positions[v * 3 + 2] = oz; + } + + posed.normals = []; + computeFlatNormals(posed); + return posed; +} diff --git a/tools/src/preview/render.ts b/tools/src/preview/render.ts new file mode 100644 index 0000000..70d4d82 --- /dev/null +++ b/tools/src/preview/render.ts @@ -0,0 +1,397 @@ +import type { Raster, Rgb } from "../image/Raster.js"; +import { bounds, type MeshData, type Vec3Tuple } from "../mesh/MeshBuilder.js"; + +/** + * Software rasteriser for mesh review. + * + * The creator emits GLB, which means the only way to judge a silhouette used to + * be opening the web viewer. That is a bad loop for authoring a *base* mesh, + * where every change is a judgement call about shape. This renders the same + * `MeshData` the exporter sees, offscreen, to a PNG contact sheet: front / + * three-quarter / side, with head-unit rules for proportion checks. + * + * Orthographic on purpose — perspective foreshortening lies about proportion, + * and the game's fixed cameras are near-ortho at range anyway. + */ + +export interface RenderOptions { + /** Tile size in pixels. */ + size?: number; + /** Camera yaw in degrees. 0 = front (looking down −Z), 90 = the figure's left. */ + yaw?: number; + /** Camera pitch in degrees. Positive looks down on the figure. */ + pitch?: number; + /** Background fill. */ + background?: Rgb; + /** Override every vertex colour with flat clay — shape reads without palette noise. */ + clay?: Rgb | null; + /** Draw triangle edges over the shading. */ + wireframe?: boolean; + /** Paint back-facing triangles magenta instead of culling (winding check). */ + showBackfaces?: boolean; + /** Horizontal rules every head-height (figure height / 7.5) plus a centre line. */ + guides?: boolean; + /** Vertical fraction of the tile the figure fills. */ + fill?: number; +} + +const BACKFACE: Rgb = { r: 255, g: 0, b: 190 }; +const GUIDE: Rgb = { r: 62, g: 70, b: 84 }; +const CENTRE_GUIDE: Rgb = { r: 82, g: 92, b: 110 }; +const WIRE: Rgb = { r: 20, g: 22, b: 28 }; +const DEFAULT_BG: Rgb = { r: 30, g: 33, b: 40 }; + +/** Key / fill / rim in view space — a three-point setup so the far side isn't black. */ +const LIGHTS: { dir: Vec3Tuple; intensity: number }[] = [ + { dir: [-0.45, 0.55, 0.7], intensity: 0.85 }, + { dir: [0.7, 0.1, 0.35], intensity: 0.3 }, + { dir: [0.1, -0.3, -0.8], intensity: 0.22 }, +]; +const AMBIENT = 0.22; + +const linearToSrgb = (c: number): number => { + const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + return Math.max(0, Math.min(255, Math.round(v * 255))); +}; + +const norm = (v: Vec3Tuple): Vec3Tuple => { + const l = Math.hypot(v[0], v[1], v[2]) || 1; + return [v[0] / l, v[1] / l, v[2] / l]; +}; + +const NORMALISED_LIGHTS = LIGHTS.map((l) => ({ dir: norm(l.dir), intensity: l.intensity })); + +function rotate(p: Vec3Tuple, yaw: number, pitch: number): Vec3Tuple { + const cy = Math.cos(yaw); + const sy = Math.sin(yaw); + const x = p[0] * cy + p[2] * sy; + const z = -p[0] * sy + p[2] * cy; + const cp = Math.cos(pitch); + const sp = Math.sin(pitch); + return [x, p[1] * cp - z * sp, p[1] * sp + z * cp]; +} + +export interface View { + raster: Raster; + /** Pixels per world metre — callers overlay their own annotations with it. */ + scale: number; +} + +export function renderMesh(mesh: MeshData, options: RenderOptions = {}): View { + const size = options.size ?? 420; + const yaw = ((options.yaw ?? 0) * Math.PI) / 180; + const pitch = ((options.pitch ?? 0) * Math.PI) / 180; + const bg = options.background ?? DEFAULT_BG; + const clay = options.clay ?? null; + const fill = options.fill ?? 0.88; + + const width = size; + const height = size; + const data = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i++) { + data[i * 4] = bg.r; + data[i * 4 + 1] = bg.g; + data[i * 4 + 2] = bg.b; + data[i * 4 + 3] = 255; + } + const depth = new Float32Array(width * height).fill(-Infinity); + + const { min, max } = bounds(mesh); + const modelHeight = Math.max(1e-6, max[1] - min[1]); + // Fit on height alone so male / female / clothed tiles stay directly comparable. + const scale = (height * fill) / modelHeight; + const centreY = (min[1] + max[1]) / 2; + const originX = width / 2; + const originY = height / 2; + + const project = (p: Vec3Tuple): { x: number; y: number; z: number } => { + const r = rotate(p, yaw, pitch); + return { + x: originX + r[0] * scale, + y: originY - (r[1] - centreY) * scale, + z: r[2], + }; + }; + + if (options.guides !== false) { + drawGuides(data, width, height, min, max, centreY, scale, originX, originY); + } + + const vertexCount = mesh.positions.length / 3; + const screen = new Float32Array(vertexCount * 3); + const viewNormal = new Float32Array(vertexCount * 3); + for (let v = 0; v < vertexCount; v++) { + const p = project([ + mesh.positions[v * 3]!, + mesh.positions[v * 3 + 1]!, + mesh.positions[v * 3 + 2]!, + ]); + screen[v * 3] = p.x; + screen[v * 3 + 1] = p.y; + screen[v * 3 + 2] = p.z; + const n = rotate( + [mesh.normals[v * 3] ?? 0, mesh.normals[v * 3 + 1] ?? 1, mesh.normals[v * 3 + 2] ?? 0], + yaw, + pitch, + ); + viewNormal[v * 3] = n[0]; + viewNormal[v * 3 + 1] = n[1]; + viewNormal[v * 3 + 2] = n[2]; + } + + const edges: [number, number][] = []; + + for (let t = 0; t < mesh.indices.length; t += 3) { + const ia = mesh.indices[t]!; + const ib = mesh.indices[t + 1]!; + const ic = mesh.indices[t + 2]!; + + const ax = screen[ia * 3]!; + const ay = screen[ia * 3 + 1]!; + const az = screen[ia * 3 + 2]!; + const bx = screen[ib * 3]!; + const by = screen[ib * 3 + 1]!; + const bz = screen[ib * 3 + 2]!; + const cx = screen[ic * 3]!; + const cy = screen[ic * 3 + 1]!; + const cz = screen[ic * 3 + 2]!; + + // Screen y is flipped, so a CCW (front-facing) triangle has negative area. + const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + if (area === 0) continue; + const back = area > 0; + if (back && !options.showBackfaces) continue; + + const minX = Math.max(0, Math.floor(Math.min(ax, bx, cx))); + const maxX = Math.min(width - 1, Math.ceil(Math.max(ax, bx, cx))); + const minY = Math.max(0, Math.floor(Math.min(ay, by, cy))); + const maxY = Math.min(height - 1, Math.ceil(Math.max(ay, by, cy))); + if (minX > maxX || minY > maxY) continue; + + const inv = 1 / area; + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + const px = x + 0.5; + const py = y + 0.5; + let w0 = ((bx - ax) * (py - ay) - (by - ay) * (px - ax)) * inv; + let w1 = ((cx - bx) * (py - by) - (cy - by) * (px - bx)) * inv; + let w2 = 1 - w0 - w1; + if (w0 < 0 || w1 < 0 || w2 < 0) continue; + // w0 is opposite c, w1 opposite a, w2 opposite b. + const la = w1; + const lb = w2; + const lc = w0; + + const z = la * az + lb * bz + lc * cz; + const at = y * width + x; + if (z <= depth[at]!) continue; + depth[at] = z; + + let shade: Rgb; + if (back) { + shade = BACKFACE; + } else { + const nx = la * viewNormal[ia * 3]! + lb * viewNormal[ib * 3]! + lc * viewNormal[ic * 3]!; + const ny = + la * viewNormal[ia * 3 + 1]! + + lb * viewNormal[ib * 3 + 1]! + + lc * viewNormal[ic * 3 + 1]!; + const nz = + la * viewNormal[ia * 3 + 2]! + + lb * viewNormal[ib * 3 + 2]! + + lc * viewNormal[ic * 3 + 2]!; + const n = norm([nx, ny, nz]); + + let lit = AMBIENT; + for (const light of NORMALISED_LIGHTS) { + const d = n[0] * light.dir[0] + n[1] * light.dir[1] + n[2] * light.dir[2]; + // Half-lambert: PSX-era vertex lighting never went fully black either. + lit += Math.max(0, d * 0.5 + 0.5) * light.intensity; + } + + if (clay) { + shade = { + r: linearToSrgb(srgbToLinear(clay.r) * lit), + g: linearToSrgb(srgbToLinear(clay.g) * lit), + b: linearToSrgb(srgbToLinear(clay.b) * lit), + }; + } else { + const cr = la * mesh.colors[ia * 4]! + lb * mesh.colors[ib * 4]! + lc * mesh.colors[ic * 4]!; + const cg = + la * mesh.colors[ia * 4 + 1]! + + lb * mesh.colors[ib * 4 + 1]! + + lc * mesh.colors[ic * 4 + 1]!; + const cb = + la * mesh.colors[ia * 4 + 2]! + + lb * mesh.colors[ib * 4 + 2]! + + lc * mesh.colors[ic * 4 + 2]!; + shade = { + r: linearToSrgb(cr * lit), + g: linearToSrgb(cg * lit), + b: linearToSrgb(cb * lit), + }; + } + } + + data[at * 4] = shade.r; + data[at * 4 + 1] = shade.g; + data[at * 4 + 2] = shade.b; + } + } + + if (options.wireframe && !back) { + edges.push([ia, ib], [ib, ic], [ic, ia]); + } + } + + if (options.wireframe) { + for (const [a, b] of edges) { + drawLine( + data, + depth, + width, + height, + screen[a * 3]!, + screen[a * 3 + 1]!, + screen[a * 3 + 2]!, + screen[b * 3]!, + screen[b * 3 + 1]!, + screen[b * 3 + 2]!, + WIRE, + ); + } + } + + return { raster: { width, height, data }, scale }; +} + +const srgbToLinear = (channel: number): number => { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +}; + +/** Head-unit rules + centre line, drawn under the figure. */ +function drawGuides( + data: Uint8Array, + width: number, + height: number, + min: Vec3Tuple, + max: Vec3Tuple, + centreY: number, + scale: number, + originX: number, + originY: number, +): void { + const figureHeight = max[1] - min[1]; + const headUnit = figureHeight / 7.5; + const put = (x: number, y: number, c: Rgb) => { + if (x < 0 || y < 0 || x >= width || y >= height) return; + const i = (y * width + x) * 4; + data[i] = c.r; + data[i + 1] = c.g; + data[i + 2] = c.b; + }; + + for (let u = 0; u <= 7.5; u += 1) { + const worldY = max[1] - u * headUnit; + const y = Math.round(originY - (worldY - centreY) * scale); + for (let x = 0; x < width; x++) put(x, y, GUIDE); + } + for (let y = 0; y < height; y++) put(Math.round(originX), y, CENTRE_GUIDE); +} + +/** Depth-tested line for the wireframe overlay. */ +function drawLine( + data: Uint8Array, + depth: Float32Array, + width: number, + height: number, + x0: number, + y0: number, + z0: number, + x1: number, + y1: number, + z1: number, + color: Rgb, +): void { + const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)))); + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(x0 + (x1 - x0) * t); + const y = Math.round(y0 + (y1 - y0) * t); + if (x < 0 || y < 0 || x >= width || y >= height) continue; + const at = y * width + x; + const z = z0 + (z1 - z0) * t; + // Small bias so edges win against their own faces but stay hidden behind others. + if (z < depth[at]! - 1e-4) continue; + data[at * 4] = color.r; + data[at * 4 + 1] = color.g; + data[at * 4 + 2] = color.b; + } +} + +export interface SheetTile { + mesh: MeshData; + yaw?: number; + pitch?: number; +} + +/** Horizontal contact sheet — one tile per view, shared scale. */ +export function renderSheet(tiles: SheetTile[], options: RenderOptions = {}): Raster { + const size = options.size ?? 420; + const views = tiles.map((tile) => + renderMesh(tile.mesh, { + ...options, + ...(tile.yaw === undefined ? {} : { yaw: tile.yaw }), + ...(tile.pitch === undefined ? {} : { pitch: tile.pitch }), + }), + ); + + const width = size * views.length; + const data = new Uint8Array(width * size * 4); + for (let i = 0; i < views.length; i++) { + const src = views[i]!.raster; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const from = (y * size + x) * 4; + const to = (y * width + i * size + x) * 4; + data[to] = src.data[from]!; + data[to + 1] = src.data[from + 1]!; + data[to + 2] = src.data[from + 2]!; + data[to + 3] = 255; + } + } + } + // Tile separators. + for (let i = 1; i < views.length; i++) { + for (let y = 0; y < size; y++) { + const to = (y * width + i * size) * 4; + data[to] = 12; + data[to + 1] = 13; + data[to + 2] = 16; + } + } + return { width, height: size, data }; +} + +/** Stack contact sheets vertically (e.g. a male row above a female row). */ +export function stackRasters(rows: Raster[]): Raster { + const width = Math.max(...rows.map((r) => r.width)); + const height = rows.reduce((sum, r) => sum + r.height, 0); + const data = new Uint8Array(width * height * 4); + let offsetY = 0; + for (const row of rows) { + for (let y = 0; y < row.height; y++) { + for (let x = 0; x < row.width; x++) { + const from = (y * row.width + x) * 4; + const to = ((offsetY + y) * width + x) * 4; + data[to] = row.data[from]!; + data[to + 1] = row.data[from + 1]!; + data[to + 2] = row.data[from + 2]!; + data[to + 3] = 255; + } + } + offsetY += row.height; + } + return { width, height, data }; +} diff --git a/tools/src/rig/skeleton.ts b/tools/src/rig/skeleton.ts new file mode 100644 index 0000000..5996f3f --- /dev/null +++ b/tools/src/rig/skeleton.ts @@ -0,0 +1,185 @@ +import { CANONICAL_BONES, CANONICAL_HIERARCHY, type CanonicalBone } from "@psx/shared"; +import type { Measurements } from "../image/analyze.js"; + +/** + * Builds a canonical skeleton scaled to a measured figure. + * + * Bone *positions* come from the reference wherever the analyzer measured + * something; the ones a front-view silhouette cannot show — how far forward the + * shoulder joint sits inside the deltoid, the depth of the hip socket — come + * from fixed anatomical ratios. Those are the same for every character, so + * clips authored against the canonical skeleton stay portable (GDD §10.2). + * + * Coordinates are metres, Y up, Z forward, origin between the feet — matching + * the runtime's world space, so an exported GLB drops straight in. + */ + +export interface Joint { + name: CanonicalBone; + parent: CanonicalBone | null; + /** Rest position in world space. */ + world: [number, number, number]; + /** Rest position relative to the parent joint; what the GLB node stores. */ + local: [number, number, number]; +} + +export interface Skeleton { + joints: Joint[]; + index: Map; + /** Total height in metres, feet to crown. */ + height: number; +} + +export interface SkeletonOptions { + /** Target world height in metres. PSX protagonists sit around 1.7. */ + height?: number; +} + +/** Depth of the body, as a fraction of height. Not observable from the front. */ +const BODY_DEPTH = 0.06; + +export function buildSkeleton( + measurements: Measurements, + options: SkeletonOptions = {}, +): Skeleton { + const height = options.height ?? 1.7; + const { landmarks, widths } = measurements; + + // The analyzer measures downward from the crown; the skeleton is built upward + // from the floor, so every landmark is flipped here once. + const up = (fromCrown: number): number => (1 - fromCrown) * height; + const across = (fraction: number): number => fraction * height; + + const shoulderY = up(landmarks.shoulder.y); + const neckY = up(landmarks.neck.y); + const hipY = up(landmarks.hip.y); + const crotchY = up(landmarks.crotch.y); + const kneeY = up(landmarks.knee.y); + const ankleY = up(landmarks.ankle.y); + const chestY = up(landmarks.chest.y); + const waistY = up(landmarks.waist.y); + + // Hips sit slightly above the crotch: the joint is inside the pelvis, not at + // the gap between the legs. + const hipsRootY = crotchY + (hipY - crotchY) * 0.4 + height * 0.015; + + const halfHip = across(widths.hip) / 2; + const halfShoulder = across(widths.shoulder) / 2; + // Slightly wider stance than pure silhouette so legs clear the coat hem. + const legX = Math.max(halfHip * 0.48, across(Math.max(measurements.stance, 0.045))); + + // Deltoid sits inboard of the silhouette edge. + const shoulderJointX = halfShoulder * 0.82; + // Shoulder joint to fingertip, split on standard segment ratios + // (humerus 42% / radius 33% / hand 25%). The hand length is left to the mesh: + // the Hand joint is the wrist. + const armLength = Math.max(across(measurements.armReach) - shoulderJointX, height * 0.32); + const upperArmLength = armLength * 0.423; + const forearmLength = armLength * 0.332; + const handLength = armLength * 0.245; + + // Soft A-pose (~22° from the body) — SH1 rest pose, not a hard T or 45° V. + const armAngle = (22 * Math.PI) / 180; + const armDrop = Math.cos(armAngle); + const armOut = Math.sin(armAngle); + + // Mild chest forward offset so the torso has a front, not a flat slab. + const chestZ = across(BODY_DEPTH) * 0.35; + + const world = new Map(); + const set = (bone: CanonicalBone, x: number, y: number, z: number) => + world.set(bone, [x, y, z]); + + set("Hips", 0, hipsRootY, 0); + set("Spine", 0, hipsRootY + (waistY - hipsRootY) * 0.5, chestZ * 0.15); + set("Spine1", 0, waistY + (chestY - waistY) * 0.45, chestZ * 0.55); + set("Spine2", 0, chestY + (shoulderY - chestY) * 0.15, chestZ * 0.75); + set("Neck", 0, neckY - height * 0.01, chestZ * 0.4); + set("Head", 0, neckY + height * 0.035, chestZ * 0.35); + + for (const side of [-1, 1] as const) { + const prefix = side < 0 ? "Right" : "Left"; + + // Clavicle sits near the neck; shoulder joint out at the deltoid. + set( + `${prefix}Shoulder` as CanonicalBone, + side * halfShoulder * 0.28, + shoulderY + height * 0.005, + chestZ * 0.5, + ); + set( + `${prefix}Arm` as CanonicalBone, + side * shoulderJointX, + shoulderY - height * 0.01, + chestZ * 0.25, + ); + set( + `${prefix}ForeArm` as CanonicalBone, + side * (shoulderJointX + upperArmLength * armOut), + shoulderY - upperArmLength * armDrop, + chestZ * 0.1, + ); + set( + `${prefix}Hand` as CanonicalBone, + side * (shoulderJointX + (upperArmLength + forearmLength) * armOut), + shoulderY - (upperArmLength + forearmLength) * armDrop, + chestZ * 0.05, + ); + + set(`${prefix}UpLeg` as CanonicalBone, side * legX, hipsRootY - height * 0.01, 0); + set(`${prefix}Leg` as CanonicalBone, side * legX, kneeY, 0); + set(`${prefix}Foot` as CanonicalBone, side * legX, ankleY, 0); + set( + `${prefix}ToeBase` as CanonicalBone, + side * legX, + across(0.02), + across(BODY_DEPTH) * 2.1, + ); + } + + void handLength; + + const joints: Joint[] = []; + const index = new Map(); + + // Parents must precede children so local offsets can be resolved in one pass. + for (const bone of orderedBones()) { + const position = world.get(bone); + if (!position) throw new Error(`skeleton is missing bone ${bone}`); + + const parent = CANONICAL_HIERARCHY[bone]; + const parentPosition = parent ? world.get(parent) : null; + const local: [number, number, number] = parentPosition + ? [ + position[0] - parentPosition[0], + position[1] - parentPosition[1], + position[2] - parentPosition[2], + ] + : [...position]; + + index.set(bone, joints.length); + joints.push({ name: bone, parent, world: position, local }); + } + + return { joints, index, height }; +} + +/** Depth-first order, parents before children. */ +function orderedBones(): CanonicalBone[] { + const out: CanonicalBone[] = []; + const visit = (bone: CanonicalBone) => { + out.push(bone); + for (const child of CANONICAL_BONES) { + if (CANONICAL_HIERARCHY[child] === bone) visit(child); + } + }; + visit("Hips"); + return out; +} + +/** Rest-pose world position of a bone. */ +export function jointWorld(skeleton: Skeleton, bone: CanonicalBone): [number, number, number] { + const joint = skeleton.joints[skeleton.index.get(bone)!]; + if (!joint) throw new Error(`unknown bone ${bone}`); + return joint.world; +} diff --git a/tools/src/rig/skin.test.ts b/tools/src/rig/skin.test.ts new file mode 100644 index 0000000..6326e95 --- /dev/null +++ b/tools/src/rig/skin.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { assembleCharacter } from "../creator/assemble.js"; +import { CHARACTER_PRESETS } from "../creator/presets.js"; +import { vertexCount } from "../mesh/MeshBuilder.js"; + +/** + * Weight defects are invisible in bind pose — they only show once a clip runs. + * These assert the two that produced visible tearing: the far leg claiming a + * share of this one (dragging heels across during a stride) and the shin + * claiming half of every heel vertex (shearing the boot as the ankle pitches). + */ + +const survivor = () => assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "survivor")!); + +describe("skin weights", () => { + it("keeps every vertex fully weighted", () => { + const { mesh } = survivor(); + for (let v = 0; v < vertexCount(mesh); v++) { + let sum = 0; + for (let i = 0; i < 4; i++) sum += mesh.weights[v * 4 + i]!; + expect(sum).toBeCloseTo(1, 5); + } + }); + + it("never lets one leg influence geometry on the other side", () => { + const { mesh, skeleton } = survivor(); + const name = (i: number) => skeleton.joints[i]!.name; + let offenders = 0; + + for (let v = 0; v < vertexCount(mesh); v++) { + const x = mesh.positions[v * 3]!; + // Only judge geometry clearly lateralised — the pelvis straddles both. + if (Math.abs(x) < skeleton.height * 0.03) continue; + const wantSide = x > 0 ? "Left" : "Right"; + for (let i = 0; i < 4; i++) { + const weight = mesh.weights[v * 4 + i]!; + if (weight < 0.02) continue; + const bone = name(mesh.joints[v * 4 + i]!); + if (!bone.startsWith("Left") && !bone.startsWith("Right")) continue; + if (!bone.startsWith(wantSide)) offenders++; + } + } + expect(offenders).toBe(0); + }); + + it("gives the heel to the foot, not the shin", () => { + const { mesh, skeleton } = survivor(); + const h = skeleton.height; + const name = (i: number) => skeleton.joints[i]!.name; + let heels = 0; + + for (let v = 0; v < vertexCount(mesh); v++) { + const y = mesh.positions[v * 3 + 1]!; + const z = mesh.positions[v * 3 + 2]!; + // Behind the ankle and below it: heel and the back of the sole. + if (y > h * 0.05 || z > -h * 0.02) continue; + heels++; + + let foot = 0; + let shin = 0; + for (let i = 0; i < 4; i++) { + const weight = mesh.weights[v * 4 + i]!; + const bone = name(mesh.joints[v * 4 + i]!); + if (bone.endsWith("Foot") || bone.endsWith("ToeBase")) foot += weight; + else if (bone.endsWith("Leg") && !bone.endsWith("UpLeg")) shin += weight; + } + expect(foot).toBeGreaterThan(shin); + } + expect(heels).toBeGreaterThan(10); + }); +}); diff --git a/tools/src/rig/skin.ts b/tools/src/rig/skin.ts new file mode 100644 index 0000000..97a9967 --- /dev/null +++ b/tools/src/rig/skin.ts @@ -0,0 +1,299 @@ +import { CANONICAL_HIERARCHY, type CanonicalBone } from "@psx/shared"; +import type { MeshData, Vec3Tuple } from "../mesh/MeshBuilder.js"; +import { vertexCount } from "../mesh/MeshBuilder.js"; +import type { Skeleton } from "./skeleton.js"; + +/** + * Automatic skin weights — the auto-rig half of GDD §10. + * + * Weights come from distance to a bone's *segment* (parent joint to child + * joint), not to the joint point. Distance-to-point is the obvious approach and + * it is wrong: every vertex along a long bone would be pulled toward whichever + * end it happened to sit nearer, so a forearm would tear in half at its middle. + * Distance to the segment is uniform along the bone's length, which is what + * "belongs to this bone" actually means. + * + * glTF allows four influences per vertex, which is also more than a PSX-era + * character needs — most vertices end up with one or two. + */ + +export interface SkinOptions { + /** + * Falloff exponent. Higher gives crisper, more rigid joints; lower gives + * softer, more rubbery bending. 4 reads as "articulated puppet", which is + * exactly the era's look. + */ + falloff?: number; + /** Influences below this fraction of the strongest are dropped. */ + cullRatio?: number; +} + +const MAX_INFLUENCES = 4; + +/** Bones that should never claim a vertex — they have no geometry of their own. */ +const NON_DEFORMING: ReadonlySet = new Set([]); + +/** + * Extra distance charged per metre a vertex sits on the far side of the body + * from a left/right bone. + * + * Without it the opposite leg still clears the cull ratio at a 17 cm stance — + * bind pose looks fine, and then the walk cycle drags heel vertices toward the + * other foot as the legs separate. Scaling with the offset rather than + * rejecting outright keeps midline geometry (pelvis, groin) smooth. + */ +const MIRROR_PENALTY = 2; + +interface SegmentShape { + /** Extend the scoring segment backward past its own joint, in bone lengths. */ + headExtend?: number; + /** Pull the scoring segment short of the child joint, in bone lengths. */ + tailTrim?: number; +} + +/** + * Where a bone's *scoring* segment differs from the bone itself. + * + * Distance-to-segment cannot separate two bones that meet at a joint: anything + * near that joint is equidistant from both, and splits 50/50. Mid-limb that is + * harmless. At a joint where the geometry continues past it in a direction only + * the child owns, it tears — the heel sits behind and below the ankle, so half + * of every heel vertex was driven by the shin and the boot sheared into a + * wedge as the ankle pitched. + * + * The fix is to say so explicitly, per bone, rather than to invent a rule. + */ +const SEGMENT_SHAPE: Partial> = { + // The foot's mass extends back to the heel, ~45% of the ankle→toe length + // behind the ankle. Claim it, and keep the shin off it. + LeftFoot: { headExtend: 0.55 }, + RightFoot: { headExtend: 0.55 }, + LeftLeg: { tailTrim: 0.12 }, + RightLeg: { tailTrim: 0.12 }, + // Same story, milder: the palm continues past the wrist. + LeftForeArm: { tailTrim: 0.1 }, + RightForeArm: { tailTrim: 0.1 }, +}; + +/** +1 for a left-side bone, −1 for a right-side one, 0 for the spine. */ +function boneSide(name: CanonicalBone): number { + if (name.startsWith("Left")) return 1; + if (name.startsWith("Right")) return -1; + return 0; +} + +interface BoneSegment { + jointIndex: number; + name: CanonicalBone; + head: Vec3Tuple; + tail: Vec3Tuple; + /** +1 left, −1 right, 0 central. */ + side: number; +} + +/** + * A bone's influence region runs from its own joint to its child's. Leaf bones + * (hands, toes, head) have no child, so they get a stub extending along the + * direction they inherit from their parent. + */ +function buildSegments(skeleton: Skeleton): BoneSegment[] { + const segments: BoneSegment[] = []; + + for (const [jointIndex, joint] of skeleton.joints.entries()) { + if (NON_DEFORMING.has(joint.name)) continue; + + const children = skeleton.joints.filter((other) => CANONICAL_HIERARCHY[other.name] === joint.name); + + if (children.length > 0) { + // Average the children so a fork (Hips → both legs, Spine2 → both + // shoulders) points down the body's axis rather than at one branch. + const tail: Vec3Tuple = [0, 0, 0]; + for (const child of children) { + tail[0] += child.world[0] / children.length; + tail[1] += child.world[1] / children.length; + tail[2] += child.world[2] / children.length; + } + segments.push(shape(jointIndex, joint.name, joint.world, tail)); + continue; + } + + const parent = joint.parent ? skeleton.joints[skeleton.index.get(joint.parent)!] : null; + const direction: Vec3Tuple = parent + ? [ + joint.world[0] - parent.world[0], + joint.world[1] - parent.world[1], + joint.world[2] - parent.world[2], + ] + : [0, 1, 0]; + + const length = Math.hypot(...direction) || 1; + const stub = skeleton.height * 0.05; + segments.push( + shape(jointIndex, joint.name, joint.world, [ + joint.world[0] + (direction[0] / length) * stub, + joint.world[1] + (direction[1] / length) * stub, + joint.world[2] + (direction[2] / length) * stub, + ]), + ); + } + + return segments; +} + +/** Apply this bone's {@link SEGMENT_SHAPE} entry to its raw head/tail. */ +function shape( + jointIndex: number, + name: CanonicalBone, + head: Vec3Tuple, + tail: Vec3Tuple, +): BoneSegment { + const adjust = SEGMENT_SHAPE[name]; + const side = boneSide(name); + if (!adjust) return { jointIndex, name, head, tail, side }; + + const dx = tail[0] - head[0]; + const dy = tail[1] - head[1]; + const dz = tail[2] - head[2]; + const headExtend = adjust.headExtend ?? 0; + const tailTrim = adjust.tailTrim ?? 0; + + return { + jointIndex, + name, + side, + head: [head[0] - dx * headExtend, head[1] - dy * headExtend, head[2] - dz * headExtend], + tail: [tail[0] - dx * tailTrim, tail[1] - dy * tailTrim, tail[2] - dz * tailTrim], + }; +} + +/** Shortest distance from a point to a finite segment. */ +function distanceToSegment(point: Vec3Tuple, head: Vec3Tuple, tail: Vec3Tuple): number { + const dx = tail[0] - head[0]; + const dy = tail[1] - head[1]; + const dz = tail[2] - head[2]; + const lengthSq = dx * dx + dy * dy + dz * dz; + + if (lengthSq < 1e-12) return Math.hypot(point[0] - head[0], point[1] - head[1], point[2] - head[2]); + + let t = + ((point[0] - head[0]) * dx + (point[1] - head[1]) * dy + (point[2] - head[2]) * dz) / lengthSq; + t = t < 0 ? 0 : t > 1 ? 1 : t; + + return Math.hypot( + point[0] - (head[0] + dx * t), + point[1] - (head[1] + dy * t), + point[2] - (head[2] + dz * t), + ); +} + +export function computeSkinWeights( + mesh: MeshData, + skeleton: Skeleton, + options: SkinOptions = {}, +): void { + const falloff = options.falloff ?? 4; + const cullRatio = options.cullRatio ?? 0.08; + const segments = buildSegments(skeleton); + + // Keeps the inverse-distance weight finite for a vertex sitting exactly on a + // bone, and sets the scale at which "close" stops meaning anything. + const epsilon = skeleton.height * 0.004; + + const count = vertexCount(mesh); + mesh.joints = new Array(count * 4).fill(0); + mesh.weights = new Array(count * 4).fill(0); + + const scored: Array<{ jointIndex: number; weight: number }> = []; + + for (let v = 0; v < count; v++) { + const point: Vec3Tuple = [ + mesh.positions[v * 3]!, + mesh.positions[v * 3 + 1]!, + mesh.positions[v * 3 + 2]!, + ]; + + scored.length = 0; + let strongest = 0; + + for (const segment of segments) { + // Charge for being on the wrong side of the body, so the far leg cannot + // claim a share of this one and drag it across during a stride. + const wrongSide = segment.side === 0 ? 0 : Math.max(0, -segment.side * point[0]); + const distance = + distanceToSegment(point, segment.head, segment.tail) + wrongSide * MIRROR_PENALTY + epsilon; + const weight = 1 / Math.pow(distance, falloff); + if (weight > strongest) strongest = weight; + scored.push({ jointIndex: segment.jointIndex, weight }); + } + + // Keep the four strongest, discarding anything negligible next to the best. + const kept = scored + .filter((entry) => entry.weight >= strongest * cullRatio) + .sort((a, b) => b.weight - a.weight) + .slice(0, MAX_INFLUENCES); + + const total = kept.reduce((sum, entry) => sum + entry.weight, 0) || 1; + + for (let i = 0; i < MAX_INFLUENCES; i++) { + const entry = kept[i]; + mesh.joints[v * 4 + i] = entry?.jointIndex ?? 0; + mesh.weights[v * 4 + i] = entry ? entry.weight / total : 0; + } + } +} + +/** + * Inverse bind matrices: the transform taking a vertex from model space into + * each joint's local space at rest. Rest poses here are translation-only, so + * the inverse is a translation by the negated world position — no general + * matrix inversion required. + */ +export function computeInverseBindMatrices(skeleton: Skeleton): Float32Array { + const matrices = new Float32Array(skeleton.joints.length * 16); + + for (const [i, joint] of skeleton.joints.entries()) { + const offset = i * 16; + // Column-major identity. + matrices[offset] = 1; + matrices[offset + 5] = 1; + matrices[offset + 10] = 1; + matrices[offset + 15] = 1; + // Translation occupies the last column in column-major order. + matrices[offset + 12] = -joint.world[0]; + matrices[offset + 13] = -joint.world[1]; + matrices[offset + 14] = -joint.world[2]; + } + + return matrices; +} + +/** Diagnostics for the pipeline report and for tests. */ +export function skinStatistics(mesh: MeshData): { + maxInfluences: number; + averageInfluences: number; + unweighted: number; +} { + const count = vertexCount(mesh); + let maxInfluences = 0; + let totalInfluences = 0; + let unweighted = 0; + + for (let v = 0; v < count; v++) { + let influences = 0; + let sum = 0; + for (let i = 0; i < 4; i++) { + const weight = mesh.weights[v * 4 + i]!; + if (weight > 0) influences++; + sum += weight; + } + if (sum < 0.999) unweighted++; + totalInfluences += influences; + maxInfluences = Math.max(maxInfluences, influences); + } + + return { + maxInfluences, + averageInfluences: count > 0 ? totalInfluences / count : 0, + unweighted, + }; +} diff --git a/tools/src/texture/bakeUvGuide.ts b/tools/src/texture/bakeUvGuide.ts new file mode 100644 index 0000000..8da2a8a --- /dev/null +++ b/tools/src/texture/bakeUvGuide.ts @@ -0,0 +1,185 @@ +import { PNG } from "pngjs"; +import { vertexCount, type MeshData } from "../mesh/MeshBuilder.js"; + +export interface UvGuideOptions { + size?: number; + /** Draw wireframe edges over the filled islands. */ + wireframe?: boolean; +} + +/** + * Rasterise the mesh's UVs into a guide sheet for Imagine: + * - fill each triangle with its vertex colour (linear→approx sRGB) + * - optional white wireframe so islands stay legible + * - skip degenerate / zero-area UV tris (chart borders) + * - dark background outside islands + */ +export function bakeUvGuide(mesh: MeshData, options: UvGuideOptions = {}): Buffer { + if (mesh.uvs.length < vertexCount(mesh) * 2) { + throw new Error("Mesh has no UVs — run unwrapCharacterAtlas first"); + } + + const size = options.size ?? 512; + const wireframe = options.wireframe ?? true; + const png = new PNG({ width: size, height: size }); + const data = png.data; + + // Dark charcoal background. + for (let i = 0; i < size * size; i++) { + data[i * 4] = 22; + data[i * 4 + 1] = 24; + data[i * 4 + 2] = 26; + data[i * 4 + 3] = 255; + } + + const toPx = (u: number, v: number): [number, number] => { + // Match glTF: (0,0) = upper-left of the image, V increases downward. + // Our unwrap puts the head at low V and feet at high V, so the guide + // sheet reads head-at-top the same way the mesh samples the albedo. + const x = Math.min(size - 1, Math.max(0, Math.floor(u * (size - 1) + 0.5))); + const y = Math.min(size - 1, Math.max(0, Math.floor(v * (size - 1) + 0.5))); + return [x, y]; + }; + + const linToByte = (c: number) => { + // Rough linear→sRGB so skin/cloth read closer to the vertex palette. + const s = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + return Math.max(0, Math.min(255, Math.round(s * 255))); + }; + + // Painter's algorithm: draw back-facing / deeper tris first so the front + // silhouette of each island wins in the guide (cleaner for Imagine). + type Tri = { ia: number; ib: number; ic: number; depth: number }; + const tris: Tri[] = []; + for (let t = 0; t < mesh.indices.length; t += 3) { + const ia = mesh.indices[t]!; + const ib = mesh.indices[t + 1]!; + const ic = mesh.indices[t + 2]!; + const ua = mesh.uvs[ia * 2]!; + const va = mesh.uvs[ia * 2 + 1]!; + const ub = mesh.uvs[ib * 2]!; + const vb = mesh.uvs[ib * 2 + 1]!; + const uc = mesh.uvs[ic * 2]!; + const vc = mesh.uvs[ic * 2 + 1]!; + if (Math.max(ua, ub, uc) - Math.min(ua, ub, uc) > 0.4) continue; + if (Math.max(va, vb, vc) - Math.min(va, vb, vc) > 0.4) continue; + // Depth = average model Z (front = larger Z draws later). + const depth = + (mesh.positions[ia * 3 + 2]! + mesh.positions[ib * 3 + 2]! + mesh.positions[ic * 3 + 2]!) / 3; + tris.push({ ia, ib, ic, depth }); + } + tris.sort((a, b) => a.depth - b.depth); + + for (const tri of tris) { + const { ia, ib, ic } = tri; + const [ax, ay] = toPx(mesh.uvs[ia * 2]!, mesh.uvs[ia * 2 + 1]!); + const [bx, by] = toPx(mesh.uvs[ib * 2]!, mesh.uvs[ib * 2 + 1]!); + const [cx, cy] = toPx(mesh.uvs[ic * 2]!, mesh.uvs[ic * 2 + 1]!); + + const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay); + if (Math.abs(area) < 0.5) continue; + + const r = linToByte((mesh.colors[ia * 4]! + mesh.colors[ib * 4]! + mesh.colors[ic * 4]!) / 3); + const g = linToByte( + (mesh.colors[ia * 4 + 1]! + mesh.colors[ib * 4 + 1]! + mesh.colors[ic * 4 + 1]!) / 3, + ); + const b = linToByte( + (mesh.colors[ia * 4 + 2]! + mesh.colors[ib * 4 + 2]! + mesh.colors[ic * 4 + 2]!) / 3, + ); + + fillTriangle(data, size, ax, ay, bx, by, cx, cy, r, g, b); + if (wireframe) { + drawLine(data, size, ax, ay, bx, by, 235, 235, 230); + drawLine(data, size, bx, by, cx, cy, 235, 235, 230); + drawLine(data, size, cx, cy, ax, ay, 235, 235, 230); + } + } + + return PNG.sync.write(png); +} + +function setPx( + data: Buffer, + size: number, + x: number, + y: number, + r: number, + g: number, + b: number, +): void { + if (x < 0 || y < 0 || x >= size || y >= size) return; + const i = (y * size + x) * 4; + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = 255; +} + +/** Barycentric fill. */ +function fillTriangle( + data: Buffer, + size: number, + ax: number, + ay: number, + bx: number, + by: number, + cx: number, + cy: number, + r: number, + g: number, + b: number, +): void { + const minX = Math.max(0, Math.min(ax, bx, cx)); + const maxX = Math.min(size - 1, Math.max(ax, bx, cx)); + const minY = Math.max(0, Math.min(ay, by, cy)); + const maxY = Math.min(size - 1, Math.max(ay, by, cy)); + const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay); + if (Math.abs(area) < 1e-6) return; + + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + const w0 = (bx - ax) * (y - ay) - (by - ay) * (x - ax); + const w1 = (cx - bx) * (y - by) - (cy - by) * (x - bx); + const w2 = (ax - cx) * (y - cy) - (ay - cy) * (x - cx); + if ( + (w0 >= 0 && w1 >= 0 && w2 >= 0 && area > 0) || + (w0 <= 0 && w1 <= 0 && w2 <= 0 && area < 0) + ) { + setPx(data, size, x, y, r, g, b); + } + } + } +} + +function drawLine( + data: Buffer, + size: number, + x0: number, + y0: number, + x1: number, + y1: number, + r: number, + g: number, + b: number, +): void { + let dx = Math.abs(x1 - x0); + let dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + let x = x0; + let y = y0; + for (;;) { + setPx(data, size, x, y, r, g, b); + if (x === x1 && y === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x += sx; + } + if (e2 < dx) { + err += dx; + y += sy; + } + } +} diff --git a/tools/src/texture/cli.ts b/tools/src/texture/cli.ts new file mode 100644 index 0000000..8eb416b --- /dev/null +++ b/tools/src/texture/cli.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * Stdin/stdout CLI for the creator UI texture API. + * + * echo '{"mode":"guide","recipe":{...}}' | tsx src/texture/cli.ts + * echo '{"mode":"texture","force":true,"recipe":{...}}' | tsx src/texture/cli.ts + * echo '{"mode":"texture","albedoBase64":"...","recipe":{...}}' | tsx src/texture/cli.ts + * + * Prints one JSON line: { ok, glbBase64, guidePath, albedoPath, ... } + */ +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { CharacterRecipe } from "../creator/types.js"; +import { textureCharacter } from "./textureCharacter.js"; + +const ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url))); +const OUT_DIR = join(ROOT, "assets", "characters"); + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + +async function main(): Promise { + const raw = await readStdin(); + const payload = JSON.parse(raw) as { + recipe?: CharacterRecipe; + mode?: "guide" | "texture"; + force?: boolean; + /** Import this albedo instead of generating one (PNG/JPEG, base64). */ + albedoBase64?: string; + }; + if (!payload.recipe?.id || !payload.recipe.name || !payload.recipe.body) { + console.log(JSON.stringify({ ok: false, error: "Expected { recipe, mode }" })); + process.exit(1); + } + + const imported = payload.albedoBase64 + ? new Uint8Array(Buffer.from(payload.albedoBase64, "base64")) + : undefined; + // An imported albedo is a texture run by definition — there is nothing to ask + // Grok for, but the caller still wants a textured GLB back. + const mode = payload.mode === "texture" || imported ? "texture" : "guide"; + const result = await textureCharacter(payload.recipe, { + outDir: OUT_DIR, + guideOnly: mode === "guide", + force: payload.force === true, + ...(imported ? { importAlbedo: imported } : {}), + }); + + const { writeFile, mkdir } = await import("node:fs/promises"); + await mkdir(OUT_DIR, { recursive: true }); + const glbPath = join(OUT_DIR, `${payload.recipe.id}.glb`); + await writeFile(glbPath, result.glb); + await writeFile( + join(OUT_DIR, `${payload.recipe.id}.recipe.json`), + `${JSON.stringify({ ...payload.recipe, stats: result.assembled.stats, textured: result.textured }, null, 2)}\n`, + ); + + console.log( + JSON.stringify({ + ok: true, + mode, + forced: payload.force === true, + textured: result.textured, + imported: result.imported, + cached: result.cached, + costUsd: result.costUsd, + guidePath: result.guidePath, + albedoPath: result.albedoPath, + glbPath, + glbBase64: Buffer.from(result.glb).toString("base64"), + guideUrl: `/assets-characters/${payload.recipe.id}.uv-guide.png?t=${Date.now()}`, + albedoUrl: result.albedoPath + ? `/assets-characters/${payload.recipe.id}.albedo.png?t=${Date.now()}` + : null, + bytes: result.glb.byteLength, + }), + ); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.log(JSON.stringify({ ok: false, error: message })); + process.exit(1); +}); + +void dirname; diff --git a/tools/src/texture/textureCharacter.ts b/tools/src/texture/textureCharacter.ts new file mode 100644 index 0000000..d3d80f8 --- /dev/null +++ b/tools/src/texture/textureCharacter.ts @@ -0,0 +1,239 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { buildDefaultClips } from "../anim/procedural.js"; +import { assembleCharacter } from "../creator/assemble.js"; +import { PART_CATALOGUE } from "../creator/parts.js"; +import type { AssembledCharacter, CharacterRecipe } from "../creator/types.js"; +import { exportGlb } from "../export/glb.js"; +import type { Rgb } from "../image/Raster.js"; +import { GrokBridge, GrokUnavailableError } from "../grok/GrokBridge.js"; +import { computeInverseBindMatrices, computeSkinWeights } from "../rig/skin.js"; +import { bakeUvGuide } from "./bakeUvGuide.js"; +import { unwrapCharacterAtlas } from "./unwrap.js"; + +export interface TextureCharacterOptions { + /** Output directory for guide / albedo / glb sidecars. */ + outDir: string; + /** UV guide resolution. */ + guideSize?: number; + /** Skip Grok and only write the UV guide (offline / CI). */ + guideOnly?: boolean; + /** + * Repaint even if this character's prompt is already cached, replacing the + * cached image. Costs a fresh generation every time. + */ + force?: boolean; + /** + * Use these image bytes as the albedo instead of generating one. + * + * The escape hatch from the Grok bridge: bake the UV guide, paint it in + * anything — Photoshop, Aseprite, some other model — and hand the result + * back. Grok is never invoked, so this path needs no CLI, no auth and no + * money, and it is the supported way to use a different image generator. + * + * PNG or JPEG; JPEG is re-encoded so the GLB's mime type stays honest. + */ + importAlbedo?: Uint8Array; + /** Optional override for Grok bridge. */ + grok?: GrokBridge; +} + +export interface TextureCharacterResult { + assembled: AssembledCharacter; + guidePath: string; + albedoPath: string | null; + glb: Uint8Array; + textured: boolean; + cached: boolean; + /** The albedo came from {@link TextureCharacterOptions.importAlbedo}. */ + imported: boolean; + costUsd: number; +} + +/** + * Full path: assemble → cylindrical UV unwrap → bake guide → Grok Imagine + * edit → re-export GLB with baseColorTexture. + */ +export async function textureCharacter( + recipe: CharacterRecipe, + options: TextureCharacterOptions, +): Promise { + const outDir = resolve(options.outDir); + await mkdir(outDir, { recursive: true }); + + const assembled = assembleCharacter(recipe); + const { mesh, skeleton } = assembled; + + // Unwrap may duplicate verts for the front|back fold — re-skin after. + unwrapCharacterAtlas(mesh, skeleton); + computeSkinWeights(mesh, skeleton); + const inverseBindMatrices = computeInverseBindMatrices(skeleton); + const clips = buildDefaultClips(); + + const guidePng = bakeUvGuide(mesh, { size: options.guideSize ?? 512, wireframe: true }); + const guidePath = join(outDir, `${recipe.id}.uv-guide.png`); + await writeFile(guidePath, guidePng); + + if (options.guideOnly) { + // White vertex colours so untextured path still works; no map. + const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { + name: recipe.name, + clips, + generator: "psx-adventure-engine character creator (uv-guide)", + }); + return { + assembled: { ...assembled, mesh, glb }, + guidePath, + albedoPath: null, + glb, + textured: false, + cached: false, + imported: false, + costUsd: 0, + }; + } + + // Either the caller brings its own albedo, or Grok paints one. + let rawAlbedo: Buffer; + let cached = false; + let costUsd = 0; + const imported = Boolean(options.importAlbedo?.length); + + if (options.importAlbedo?.length) { + rawAlbedo = Buffer.from(options.importAlbedo); + } else { + const grok = options.grok ?? new GrokBridge({ outputDir: join(outDir, ".grok-texture-cache") }); + const available = await grok.isAvailable(); + if (!available) { + throw new GrokUnavailableError("grok"); + } + + const prompt = texturePrompt(recipe); + const result = await grok.generate({ + prompt, + aspectRatio: "1:1", + sourceImages: [guidePath], + ...(options.force ? { force: true } : {}), + }); + + rawAlbedo = await readFile(result.path); + cached = result.cached; + costUsd = result.costUsd; + } + + // Imagine (or the user's paint tool) may hand back JPEG; re-encode to PNG so + // the GLB mime type stays honest. + const albedoPng = await ensurePng(rawAlbedo); + const albedoPath = join(outDir, `${recipe.id}.albedo.png`); + await writeFile(albedoPath, albedoPng); + + // Texture carries colour — neutralise vertex colours so they don't tint the map. + for (let i = 0; i < mesh.colors.length; i += 4) { + mesh.colors[i] = 1; + mesh.colors[i + 1] = 1; + mesh.colors[i + 2] = 1; + mesh.colors[i + 3] = 1; + } + + const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { + name: recipe.name, + clips, + generator: "psx-adventure-engine character creator (textured)", + baseColorImage: new Uint8Array(albedoPng), + baseColorMimeType: "image/png", + }); + + return { + assembled: { ...assembled, mesh, glb }, + guidePath, + albedoPath, + glb, + textured: true, + cached, + imported, + costUsd, + }; +} + +function hex({ r, g, b }: Rgb): string { + const c = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0"); + return `#${c(r)}${c(g)}${c(b)}`; +} + +/** "a, b and c" — reads better to the model than a bare comma list. */ +function sentenceList(items: string[]): string { + if (items.length <= 1) return items[0] ?? ""; + return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; +} + +/** + * Prompt for the albedo pass. + * + * Three things it has to get right, in order: that this is an atlas and not a + * portrait, what the figure is actually wearing, and what the wearer wants it + * to look like. The middle one is derived from the equipped parts rather than + * written down — an earlier version named "jacket, cargo pants and boots" + * literally, which is the survivor's kit, so every other character was being + * asked for someone else's outfit. + * + * `recipe.styleNotes` is the steering wheel and deliberately outranks the + * palette: asking for a charcoal suit should win over an olive `primary`. + */ +export function texturePrompt(recipe: CharacterRecipe): string { + const worn = Object.entries(recipe.parts) + .filter(([, id]) => id && !String(id).endsWith(".none")) + .map(([slot, id]) => { + const key = String(id); + const def = PART_CATALOGUE.get(key); + // Hair styles are named bare ("Short", "Bun"), which reads as a garment + // in a list of clothes unless it is spelled out. + const name = `${(def?.name ?? key).toLowerCase()}${slot === "hair" ? " hair" : ""}`; + const override = recipe.partColors?.[key]; + return override ? `${name} (${hex(override)})` : name; + }); + + const garments = worn.length ? sentenceList(worn) : "bare skin"; + const raw = recipe.styleNotes?.trim(); + // Terminate it, or the next sentence runs straight on from the direction. + const notes = raw ? (/[.!?]$/.test(raw) ? raw : `${raw}.`) : undefined; + + return [ + "CRITICAL: This is a 3D model UV texture atlas / unwrap sheet, NOT a character portrait or turnaround.", + "The white wireframe lines mark mesh islands. Paint albedo colours INTO those islands only.", + "Do NOT redraw the character as a front-view illustration. Do NOT invent new silhouette shapes.", + "Keep the dark charcoal background pure black/empty outside the islands.", + "Preserve island shapes exactly — only recolour the filled regions.", + "Style: PSX / early PlayStation survival-horror game texture — flat, limited muted palette, soft dirt, no photoreal skin pores, no text.", + `Character kit: ${recipe.name}, ${recipe.body} body, wearing ${garments}.`, + `Base palette — skin ${hex(recipe.palette.skin)}, hair ${hex(recipe.palette.hair)}, primary ${hex(recipe.palette.primary)}, secondary ${hex(recipe.palette.secondary)}, accent ${hex(recipe.palette.accent)}, metal ${hex(recipe.palette.metal)}, leather ${hex(recipe.palette.leather)}.`, + // Placed after the palette so the model reads it as the overriding note. + ...(notes + ? [ + `WARDROBE DIRECTION (authoritative — follow this over the base palette where they disagree): ${notes}`, + "Keep the garment cut implied by the mesh islands; change only the colour, material and detailing to match that direction.", + ] + : []), + "Atlas layout: LEFT half = full-body FRONT (head at top, feet at bottom); RIGHT half = full-body BACK (mirrored, same height).", + `The face and the ${garments} should read as a clear standing figure on each half.`, + "Paint face features on the upper centre of the LEFT half. Output a square texture atlas.", + ].join(" "); +} + +/** Re-encode JPEG/WebP outputs from Imagine as PNG for GLB embedding. */ +async function ensurePng(bytes: Buffer): Promise { + const isPng = bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47; + if (isPng) return bytes; + + const isJpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8; + if (isJpeg) { + const jpeg = await import("jpeg-js"); + const { PNG } = await import("pngjs"); + const decoded = jpeg.decode(bytes, { useTArray: true, maxMemoryUsageInMB: 256 }); + const png = new PNG({ width: decoded.width, height: decoded.height }); + png.data = Buffer.from(decoded.data); + return PNG.sync.write(png); + } + + // Unknown — pass through and let mime sniff handle it. + return bytes; +} diff --git a/tools/src/texture/texturePrompt.test.ts b/tools/src/texture/texturePrompt.test.ts new file mode 100644 index 0000000..8ea964f --- /dev/null +++ b/tools/src/texture/texturePrompt.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { presetById } from "../creator/presets.js"; +import type { CharacterRecipe } from "../creator/types.js"; +import { texturePrompt } from "./textureCharacter.js"; + +/** + * The prompt is the whole steering surface for the albedo pass, and it is + * cheap to get subtly wrong in ways only a $0.05 generation would reveal. + */ + +const base: CharacterRecipe = { + id: "suit", + name: "Broker", + body: "male", + height: 1.8, + palette: { + skin: { r: 200, g: 160, b: 130 }, + hair: { r: 40, g: 36, b: 32 }, + primary: { r: 60, g: 62, b: 68 }, + secondary: { r: 30, g: 32, b: 36 }, + accent: { r: 120, g: 30, b: 40 }, + metal: { r: 150, g: 152, b: 158 }, + leather: { r: 40, g: 30, b: 26 }, + }, + parts: { + hair: "hair.short", + upper: "upper.shirt", + lower: "lower.pants", + feet: "feet.shoes", + accessory: "accessory.none", + }, +}; + +describe("texturePrompt", () => { + it("names the garments actually equipped, not a hard-coded outfit", () => { + const prompt = texturePrompt(base); + + expect(prompt).toContain("wearing short hair, shirt, pants and shoes"); + // The old prompt named the survivor's kit for every character. + expect(prompt).not.toContain("cargo pants"); + expect(prompt).not.toContain("backpack"); + // Slots that are ".none" are not garments. + expect(prompt).not.toContain("none"); + }); + + it("uses readable part names rather than raw ids", () => { + const prompt = texturePrompt({ + ...base, + parts: { upper: "upper.labcoat", lower: "lower.cargo" }, + }); + + expect(prompt).toContain("lab coat"); + expect(prompt).toContain("cargo pants"); + expect(prompt).not.toContain("upper.labcoat"); + }); + + it("carries the palette so the texture matches the model's colours", () => { + const prompt = texturePrompt(base); + expect(prompt).toContain("skin #c8a082"); + expect(prompt).toContain("accent #781e28"); + }); + + it("quotes per-part colour overrides next to the garment", () => { + const prompt = texturePrompt({ + ...base, + partColors: { "upper.shirt": { r: 255, g: 255, b: 255 } }, + }); + expect(prompt).toContain("shirt (#ffffff)"); + }); + + it("adds wardrobe direction only when set, and marks it authoritative", () => { + expect(texturePrompt(base)).not.toContain("WARDROBE DIRECTION"); + + const steered = texturePrompt({ + ...base, + styleNotes: "charcoal three-piece business suit, burgundy tie", + }); + expect(steered).toContain("WARDROBE DIRECTION"); + expect(steered).toContain("charcoal three-piece business suit, burgundy tie."); + expect(steered).toContain("authoritative"); + // Direction must land after the palette, or it reads as the thing being + // overridden rather than the thing overriding. + expect(steered.indexOf("WARDROBE DIRECTION")).toBeGreaterThan(steered.indexOf("Base palette")); + }); + + it("treats blank notes as absent", () => { + expect(texturePrompt({ ...base, styleNotes: " " })).not.toContain("WARDROBE DIRECTION"); + }); + + it("changes with the notes, which is what busts the albedo cache", () => { + const a = texturePrompt({ ...base, styleNotes: "navy suit" }); + const b = texturePrompt({ ...base, styleNotes: "charcoal suit" }); + expect(a).not.toBe(b); + }); + + it("survives a character wearing nothing", () => { + const prompt = texturePrompt({ ...base, parts: {} }); + expect(prompt).toContain("bare skin"); + }); + + it("describes the shipped business-suit preset as a suit", () => { + const prompt = texturePrompt(presetById("civilian-m")!); + expect(prompt).toContain("business suit"); + expect(prompt).toContain("WARDROBE DIRECTION"); + }); +}); diff --git a/tools/src/texture/unwrap.test.ts b/tools/src/texture/unwrap.test.ts new file mode 100644 index 0000000..35a4541 --- /dev/null +++ b/tools/src/texture/unwrap.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { buildDefaultClips } from "../anim/procedural.js"; +import { assembleCharacter } from "../creator/assemble.js"; +import { CHARACTER_PRESETS } from "../creator/presets.js"; +import { exportGlb } from "../export/glb.js"; +import { vertexCount } from "../mesh/MeshBuilder.js"; +import { computeInverseBindMatrices, computeSkinWeights } from "../rig/skin.js"; +import { bakeUvGuide } from "./bakeUvGuide.js"; +import { PART } from "../creator/coverage.js"; +import type { MeshData } from "../mesh/MeshBuilder.js"; +import { unwrapCharacterAtlas } from "./unwrap.js"; + +/** + * Texel density per triangle: UV area over world area, so 1.0 after + * normalising to the median is "average sized in the atlas". + */ +function headDensities(mesh: MeshData): number[] { + const P = (i: number) => + [mesh.positions[i * 3]!, mesh.positions[i * 3 + 1]!, mesh.positions[i * 3 + 2]!] as const; + const UV = (i: number) => [mesh.uvs[i * 2]!, mesh.uvs[i * 2 + 1]!] as const; + + const out: number[] = []; + for (let t = 0; t < mesh.indices.length; t += 3) { + const ia = mesh.indices[t]!; + const ib = mesh.indices[t + 1]!; + const ic = mesh.indices[t + 2]!; + if ((mesh.parts[ia] ?? 0) !== PART.HEAD) continue; + + const [ax, ay, az] = P(ia); + const [bx, by, bz] = P(ib); + const [cx, cy, cz] = P(ic); + const ux = bx - ax; + const uy = by - ay; + const uz = bz - az; + const vx = cx - ax; + const vy = cy - ay; + const vz = cz - az; + const world = + 0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx); + if (world < 1e-9) continue; + + const [au, av] = UV(ia); + const [bu, bv] = UV(ib); + const [cu, cv] = UV(ic); + const uv = Math.abs((bu - au) * (cv - av) - (bv - av) * (cu - au)) * 0.5; + out.push(uv / world); + } + return out; +} + +describe("UV unwrap + guide", () => { + it("assigns UVs in 0..1 for every vertex via multi-island atlas", () => { + const result = assembleCharacter(CHARACTER_PRESETS[0]!); + unwrapCharacterAtlas(result.mesh, result.skeleton); + computeSkinWeights(result.mesh, result.skeleton); + const n = vertexCount(result.mesh); + expect(result.mesh.uvs.length).toBe(n * 2); + for (let i = 0; i < result.mesh.uvs.length; i++) { + const v = result.mesh.uvs[i]!; + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + } + }); + + it("bakes a non-empty PNG guide with front and back body halves", () => { + const result = assembleCharacter(CHARACTER_PRESETS[0]!); + unwrapCharacterAtlas(result.mesh, result.skeleton); + const png = bakeUvGuide(result.mesh, { size: 256 }); + expect(png.byteLength).toBeGreaterThan(500); + expect(png[0]).toBe(0x89); + expect(png[1]).toBe(0x50); + + // Front half (u < 0.5) and back half (u > 0.5) should both be used. + let front = 0; + let back = 0; + for (let i = 0; i < result.mesh.uvs.length; i += 2) { + if (result.mesh.uvs[i]! < 0.5) front++; + else back++; + } + expect(front).toBeGreaterThan(50); + expect(back).toBeGreaterThan(50); + }); + + it("keeps texel density even around the head", () => { + // A flat X projection starves the sides of every round form: on a loft ring + // `x = c + R·cos(a)`, so density `dx/da` collapses to zero exactly at the + // left and right silhouette. That is what smeared the side of the head — + // 18% of its surface was running under a quarter of median density. + // Placing `u` by angle instead holds it near uniform. + const result = assembleCharacter(CHARACTER_PRESETS[0]!); + unwrapCharacterAtlas(result.mesh, result.skeleton); + + const densities = headDensities(result.mesh).sort((a, b) => a - b); + expect(densities.length).toBeGreaterThan(50); + const median = densities[Math.floor(densities.length / 2)]!; + expect(median).toBeGreaterThan(0); + + const starved = densities.filter((d) => d / median < 0.25).length; + // Some residue is unavoidable in a front|back atlas: the ears and the + // nose's side strips are surfaces perpendicular to X, so they have no + // extent to project however `u` is chosen. They are small. + expect(starved / densities.length).toBeLessThan(0.15); + + const p10 = densities[Math.floor(densities.length * 0.1)]!; + expect(p10 / median).toBeGreaterThan(0.15); + }); + + it("exports a GLB with TEXCOORD_0 and embedded PNG image chunk", () => { + const result = assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "survivor")!); + unwrapCharacterAtlas(result.mesh, result.skeleton); + computeSkinWeights(result.mesh, result.skeleton); + const ibm = computeInverseBindMatrices(result.skeleton); + const guide = bakeUvGuide(result.mesh, { size: 128 }); + + const glb = exportGlb(result.mesh, result.skeleton, ibm, { + name: "TexTest", + clips: buildDefaultClips(), + baseColorImage: new Uint8Array(guide), + baseColorMimeType: "image/png", + }); + + const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength); + expect(view.getUint32(0, true)).toBe(0x46546c67); + const jsonLen = view.getUint32(12, true); + const jsonBytes = glb.subarray(20, 20 + jsonLen); + const jsonText = new TextDecoder().decode(jsonBytes).replace(/\0/g, "").trim(); + const gltf = JSON.parse(jsonText) as { + meshes: Array<{ primitives: Array<{ attributes: Record }> }>; + images?: Array<{ mimeType: string }>; + textures?: unknown[]; + materials: Array<{ pbrMetallicRoughness?: { baseColorTexture?: { index: number } } }>; + }; + + expect(gltf.meshes[0]!.primitives[0]!.attributes["TEXCOORD_0"]).toBeDefined(); + expect(gltf.images?.[0]?.mimeType).toBe("image/png"); + expect(gltf.textures?.length).toBe(1); + expect(gltf.materials[0]!.pbrMetallicRoughness?.baseColorTexture?.index).toBe(0); + }); +}); diff --git a/tools/src/texture/unwrap.ts b/tools/src/texture/unwrap.ts new file mode 100644 index 0000000..f5e19a0 --- /dev/null +++ b/tools/src/texture/unwrap.ts @@ -0,0 +1,346 @@ +import { bounds, vertexCount, type MeshData } from "../mesh/MeshBuilder.js"; +import type { Skeleton } from "../rig/skeleton.js"; + +/** + * Front | back orthographic body sheet with **triangle-consistent** side + * assignment (no UV tris spanning the fold). + * + * ┌────────────┬────────────┐ + * │ FRONT │ BACK │ + * │ head → feet│ head → feet│ same metric scale + shared height + * └────────────┴────────────┘ + * + * Vertices used by both front and back faces are duplicated so each half + * gets a clean silhouette. That stops the horizontal "rubber band" edges + * that appeared when one corner of a tri went left and another went right. + * + * `u` is not raw X, though — see {@link sheetX}. A straight orthographic + * projection starves the sides of every round form of texels, which is what + * smeared the side of the head. + */ + +const MARGIN = 0.04; +const GAP = 0.03; + +/** Ring buckets per body region. Comfortably above the highest ring count. */ +const RING_BINS = 32; + +/** + * The ellipse one loft ring traces, so a vertex on it can be placed by angle. + * + * A ring's vertices sit at `x = cx + rx·cos(a)`, `z = cz + rz·sin(a)`. Project + * that flat down X and texel density is `dx/da = −rx·sin(a)`, which falls to + * **zero** at `a = 0, π` — exactly the left and right silhouette. Those + * triangles collapse to slivers and a few texels get stretched across the whole + * side of the form. Measured on a male head: 18% of its surface under a quarter + * of median density, the worst of it at 4%. + * + * Recovering `a` and spacing `u` by it instead holds density constant around + * the ring. Centre and radii are per ring rather than per region, so the chin + * and the cheekbone each get their own width, and offset regions like the arms + * work without assuming they straddle x = 0. + * + * Rings come from bucketing the loft parameter the body tags onto every vertex. + * That beats bucketing by height: feet are lofted heel-to-toe, so their rings + * are vertical planes. + * + * Untagged geometry — every garment — is left alone. Extending the fix there + * means tagging clothing the way `coverage.ts` tags skin. + */ +interface RingFrame { + /** Centre and half-extent of the ring, across (x) and through (z). */ + cx: number; + rx: number; + cz: number; + rz: number; +} + +/** Per-ring frames, indexed by original vertex. `null` = untagged, leave alone. */ +function ringFrames(mesh: MeshData, n0: number): (RingFrame | null)[] { + const frames: (RingFrame | null)[] = new Array(n0).fill(null); + + const groups = new Map(); + for (let i = 0; i < n0; i++) { + const part = mesh.parts[i] ?? 0; + if (part === 0) continue; + let group = groups.get(part); + if (!group) { + group = []; + groups.set(part, group); + } + group.push(i); + } + + const xLo = new Float64Array(RING_BINS); + const xHi = new Float64Array(RING_BINS); + const zLo = new Float64Array(RING_BINS); + const zHi = new Float64Array(RING_BINS); + + for (const indices of groups.values()) { + xLo.fill(Number.POSITIVE_INFINITY); + xHi.fill(Number.NEGATIVE_INFINITY); + zLo.fill(Number.POSITIVE_INFINITY); + zHi.fill(Number.NEGATIVE_INFINITY); + + const binOf = (i: number) => { + const t = mesh.ts[i] ?? 0; + return Math.min(RING_BINS - 1, Math.max(0, Math.floor(t * RING_BINS))); + }; + + for (const i of indices) { + const bin = binOf(i); + const x = mesh.positions[i * 3]!; + const z = mesh.positions[i * 3 + 2]!; + if (x < xLo[bin]!) xLo[bin] = x; + if (x > xHi[bin]!) xHi[bin] = x; + if (z < zLo[bin]!) zLo[bin] = z; + if (z > zHi[bin]!) zHi[bin] = z; + } + + for (const i of indices) { + const bin = binOf(i); + const rx = (xHi[bin]! - xLo[bin]!) * 0.5; + const rz = (zHi[bin]! - zLo[bin]!) * 0.5; + // A ring with no width or no depth has no angle to measure. + if (!(rx > 1e-6) || !(rz > 1e-6)) continue; + frames[i] = { + cx: (xLo[bin]! + xHi[bin]!) * 0.5, + rx, + cz: (zLo[bin]! + zHi[bin]!) * 0.5, + rz, + }; + } + } + + return frames; +} + +/** + * Where this vertex sits along its sheet, in units of the ring's half-width. + * + * `±1` is the silhouette, `0` is dead centre of the sheet. Values outside + * `[-1, 1]` are legitimate and are the point of the whole exercise — see below. + */ +function sheetX(frame: RingFrame, x: number, z: number, back: boolean): number { + const xRel = (x - frame.cx) / frame.rx; + const zRel = (z - frame.cz) / frame.rz; + + // Is this vertex actually *on* the ring the frame describes? + // + // Rings are found by bucketing the loft parameter, and the nose and ears are + // tagged with a single constant one, so they land in a skull ring's bucket and + // inherit a frame they were never part of. An ear sits at the frame's x + // extreme but nowhere near its z centre, and the angle that falls out of that + // flings it clear of the head — it showed up in the atlas as a bar wider than + // the figure, right where a texturing model would read a hat brim. + // + // A vertex genuinely on the ellipse has a normalised radius near 1: exactly + // 1.08 for an eight-sided ring, at most about 1.12 for a six-sided one, since + // `rx`/`rz` are the sampled extents rather than the true semi-axes. Anything + // outside that band is not on this ring, so it keeps its flat projection. + const r = Math.hypot(xRel, zRel); + if (r < 0.85 || r > 1.2) return x; + + // Normalise the ellipse to a circle, then take the angle: 0 = the +x + // silhouette, π/2 = facing the camera, π = the −x silhouette. + let a = Math.atan2(zRel, xRel); + if (back) a = -a; + + // `a` came back on (−π, π]. A vertex on the far side of the seam from its own + // sheet has to be read on the *near* branch, or it lands on the wrong edge. + if (a < -Math.PI / 2) a += 2 * Math.PI; + + // A sheet legitimately overruns its silhouette by half a column — 22.5° at + // eight sides — to give the straddling quad some width. Past that is noise. + const OVERSHOOT = Math.PI / 4; + a = Math.max(-OVERSHOOT, Math.min(Math.PI + OVERSHOOT, a)); + + // Linear in angle, so texel density is constant around the ring. + return frame.cx + frame.rx * (1 - (2 * a) / Math.PI); +} + +export function unwrapCharacterAtlas(mesh: MeshData, skeleton: Skeleton): void { + const n0 = vertexCount(mesh); + if (n0 === 0) return; + + // Before any duplication: the region/ring tags only cover the original + // vertices, and `duplicateVertex` does not carry them across. + const frames = ringFrames(mesh, n0); + + // --- classify each triangle as front or back ----------------------------- + const triSide: ("front" | "back")[] = []; + for (let t = 0; t < mesh.indices.length; t += 3) { + const ia = mesh.indices[t]!; + const ib = mesh.indices[t + 1]!; + const ic = mesh.indices[t + 2]!; + const nz = + (mesh.normals[ia * 3 + 2]! + mesh.normals[ib * 3 + 2]! + mesh.normals[ic * 3 + 2]!) / 3; + triSide.push(nz >= 0 ? "front" : "back"); + } + + // Which sides need each original vertex? + const needFront = new Uint8Array(n0); + const needBack = new Uint8Array(n0); + for (let t = 0; t < triSide.length; t++) { + const ia = mesh.indices[t * 3]!; + const ib = mesh.indices[t * 3 + 1]!; + const ic = mesh.indices[t * 3 + 2]!; + if (triSide[t] === "front") { + needFront[ia] = 1; + needFront[ib] = 1; + needFront[ic] = 1; + } else { + needBack[ia] = 1; + needBack[ib] = 1; + needBack[ic] = 1; + } + } + + // Map original vert → back-side duplicate (if it also needs a front UV). + const backDup = new Int32Array(n0).fill(-1); + for (let i = 0; i < n0; i++) { + if (needFront[i] && needBack[i]) { + backDup[i] = duplicateVertex(mesh, i); + } else if (!needFront[i] && needBack[i]) { + // Only used on the back — keep original index, mark as back-only. + backDup[i] = i; + } + } + + // Rewrite indices: back tris use the back duplicate when present. + for (let t = 0; t < triSide.length; t++) { + if (triSide[t] !== "back") continue; + for (let k = 0; k < 3; k++) { + const src = mesh.indices[t * 3 + k]!; + const mapped = backDup[src] ?? -1; + if (mapped >= 0) mesh.indices[t * 3 + k] = mapped; + } + } + + const n = vertexCount(mesh); + // side[i] = 0 front, 1 back + const side = new Uint8Array(n); + for (let i = 0; i < n0; i++) { + if (needFront[i] && needBack[i]) { + side[i] = 0; // original stays front + side[backDup[i]!] = 1; + } else if (needBack[i]) { + side[i] = 1; + } else { + side[i] = 0; + } + } + // Any brand-new verts only from dup are already set; leftover defaults front. + + // A duplicate carries its source's ring frame; positions were copied verbatim. + const origOf = new Int32Array(n); + for (let i = 0; i < n; i++) origOf[i] = i; + for (let i = 0; i < n0; i++) { + const dup = backDup[i]!; + if (dup >= 0 && dup !== i) origOf[dup] = i; + } + + // --- place every vertex along its sheet ---------------------------------- + // Has to happen before the bounds are taken: a column straddling the + // silhouette is deliberately pushed outside `±rx`, so the sheet is a little + // wider than the figure's outline and the packing must allow for it. + const xUv = new Float64Array(n); + for (let i = 0; i < n; i++) { + const frame = frames[origOf[i]!] ?? null; + const x = mesh.positions[i * 3]!; + xUv[i] = frame + ? sheetX(frame, x, mesh.positions[i * 3 + 2]!, side[i] === 1) + : x; + } + + const { min, max } = bounds(mesh); + const padM = Math.max(skeleton.height * 0.015, 0.008); + let xLo = Number.POSITIVE_INFINITY; + let xHi = Number.NEGATIVE_INFINITY; + for (let i = 0; i < n; i++) { + if (xUv[i]! < xLo) xLo = xUv[i]!; + if (xUv[i]! > xHi) xHi = xUv[i]!; + } + const minX = xLo - padM; + const maxX = xHi + padM; + const minY = min[1]! - padM * 0.35; + const maxY = max[1]! + padM * 0.35; + const width = Math.max(maxX - minX, 1e-4); + const height = Math.max(maxY - minY, 1e-4); + + // --- pack with uniform scale --------------------------------------------- + const halfW = 0.5 - MARGIN - GAP * 0.5; + const fullH = 1 - MARGIN * 2; + const scale = Math.min(halfW / width, fullH / height); + const contentW = width * scale; + const contentH = height * scale; + + const frontU0 = MARGIN + (halfW - contentW) * 0.5; + const backU0 = 0.5 + GAP * 0.5 + (halfW - contentW) * 0.5; + const v0 = MARGIN + (fullH - contentH) * 0.5; + + mesh.uvs = new Array(n * 2); + for (let i = 0; i < n; i++) { + const x = xUv[i]!; + const y = mesh.positions[i * 3 + 1]!; + const tx = (x - minX) / width; + // ty: 0 = feet, 1 = head in model space. + const ty = (y - minY) / height; + + let u: number; + if (side[i]) { + u = backU0 + (1 - tx) * contentW; // mirrored back + } else { + u = frontU0 + tx * contentW; + } + // glTF UV origin is the **upper-left** of the texture image, with V + // increasing downward. Put the head at low V (top of the sheet) and feet + // at high V (bottom) so albedo maps aren't upside-down on the mesh. + const v = v0 + (1 - ty) * contentH; + mesh.uvs[i * 2] = clamp01(u); + mesh.uvs[i * 2 + 1] = clamp01(v); + } +} + +/** @deprecated */ +export function unwrapCylindrical(mesh: MeshData, _margin?: number): void { + const { min, max } = bounds(mesh); + const height = Math.max(max[1]! - min[1]!, 1); + unwrapCharacterAtlas(mesh, { height, joints: [], index: new Map() } as unknown as Skeleton); +} + +function duplicateVertex(mesh: MeshData, index: number): number { + const ni = vertexCount(mesh); + const i = index; + mesh.positions.push( + mesh.positions[i * 3]!, + mesh.positions[i * 3 + 1]!, + mesh.positions[i * 3 + 2]!, + ); + mesh.normals.push(mesh.normals[i * 3]!, mesh.normals[i * 3 + 1]!, mesh.normals[i * 3 + 2]!); + mesh.colors.push( + mesh.colors[i * 4]!, + mesh.colors[i * 4 + 1]!, + mesh.colors[i * 4 + 2]!, + mesh.colors[i * 4 + 3]!, + ); + if (mesh.joints.length >= (i + 1) * 4) { + mesh.joints.push( + mesh.joints[i * 4]!, + mesh.joints[i * 4 + 1]!, + mesh.joints[i * 4 + 2]!, + mesh.joints[i * 4 + 3]!, + ); + mesh.weights.push( + mesh.weights[i * 4]!, + mesh.weights[i * 4 + 1]!, + mesh.weights[i * 4 + 2]!, + mesh.weights[i * 4 + 3]!, + ); + } + return ni; +} + +function clamp01(x: number): number { + return Math.max(0, Math.min(1, x)); +} diff --git a/tools/src/types.ts b/tools/src/types.ts new file mode 100644 index 0000000..5aea9e5 --- /dev/null +++ b/tools/src/types.ts @@ -0,0 +1,69 @@ +import type { CanonicalBone, ColliderDef } from "@psx/shared"; + +/** + * Content harness interfaces — GDD §10.4, Appendix C. + * + * The shipped Milestone 3 path is *code-drawn*, not a third-party mesh API: + * reference image → silhouette → measurements → tubes on the canonical + * skeleton → automatic skin weights → procedural clips → GLB. See + * `pipeline.ts` / `pnpm harness`. + * + * These types remain for the broader GDD contract (props, colliders, future + * alternate generators). Prefer `runPipeline` for characters. + */ + +/** How the mesh was produced. `"code"` is the default harness path. */ +export type MeshProvider = "code" | "meshy" | "triposr" | "img2threejs" | "mock"; +export type MeshStyle = "realistic" | "lowpoly" | "psx"; + +export interface Img2MeshRequest { + image: Buffer | string; + style?: MeshStyle; + provider?: MeshProvider; + /** Target triangle budget after PSX decimation. */ + targetTriangles?: number; +} + +export interface Img2MeshResult { + glbPath: string; + provider: MeshProvider; + triangleCount: number; + generationSeconds: number; +} + +export interface RigResult { + glbPath: string; + /** Source rigger bone name → canonical bone name. */ + boneMap: Record; + /** Canonical bones the rigger could not produce. */ + missingBones: CanonicalBone[]; + /** Auto-generated collision, limited to shapes box3d-wasm actually binds. */ + colliders: ColliderDef[]; +} + +export interface AnimationClipResult { + name: string; + glbPath: string; + durationSeconds: number; + frameRate: number; + /** Bones the clip actually animates; the rest fall back to bind pose. */ + animatedBones: CanonicalBone[]; +} + +export type AnimationSource = "pose" | "video" | "procedural"; + +export interface CharacterHarness { + fromPhoto( + image: Buffer | string, + options?: Img2MeshRequest, + ): Promise<{ mesh: RigResult; defaultPose?: AnimationClipResult }>; + + generateAnimation(from: AnimationSource, input: unknown): Promise; +} + +/** Optional external mesh-provider plug-in (not used by the default pipeline). */ +export interface MeshProviderAdapter { + readonly name: MeshProvider; + isConfigured(): boolean; + generate(request: Img2MeshRequest): Promise; +} diff --git a/tools/tsconfig.json b/tools/tsconfig.json new file mode 100644 index 0000000..7d64dec --- /dev/null +++ b/tools/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/tools/web/index.html b/tools/web/index.html new file mode 100644 index 0000000..5282241 --- /dev/null +++ b/tools/web/index.html @@ -0,0 +1,213 @@ + + + + + + PSX Character Creator + + + +
+ + +
+ +
+
Assembling…
+
+ + +
+
+
+ + +
+ + + diff --git a/tools/web/src/environment.ts b/tools/web/src/environment.ts new file mode 100644 index 0000000..e434219 --- /dev/null +++ b/tools/web/src/environment.ts @@ -0,0 +1,267 @@ +import { + BackSide, + BoxGeometry, + CircleGeometry, + Color, + ConeGeometry, + CylinderGeometry, + FogExp2, + Group, + Mesh, + MeshLambertMaterial, + PlaneGeometry, + Scene, + type Material, +} from "three"; + +/** Muted outdoor palette — fog-choked manor grounds, not neon. */ +const PALETTE = { + skyTop: 0x6a7a72, + skyHorizon: 0xa8a890, + fog: 0x8a9080, + grass: 0x3d4a32, + grassDark: 0x2a3424, + dirt: 0x5c4a32, + dirtLight: 0x6e5a3e, + path: 0x6a5c48, + bark: 0x3a2e22, + foliage: 0x2f3d28, + foliageDark: 0x243020, + rock: 0x5a5850, + rockDark: 0x3e3c36, + trunk: 0x2c241c, +}; + +function lambert(color: number, opts: { flat?: boolean; side?: typeof BackSide } = {}): MeshLambertMaterial { + return new MeshLambertMaterial({ + color, + flatShading: opts.flat ?? true, + ...(opts.side !== undefined ? { side: opts.side } : {}), + }); +} + +function addMesh( + parent: Group, + geometry: ConstructorParameters[0], + material: Material, + x: number, + y: number, + z: number, + sx = 1, + sy = 1, + sz = 1, + rotY = 0, +): Mesh { + const mesh = new Mesh(geometry, material); + mesh.position.set(x, y, z); + mesh.scale.set(sx, sy, sz); + mesh.rotation.y = rotY; + mesh.receiveShadow = false; + mesh.castShadow = false; + parent.add(mesh); + return mesh; +} + +/** Low-poly pine: trunk + stacked cones. */ +function makeTree(foliage: MeshLambertMaterial, bark: MeshLambertMaterial, scale = 1): Group { + const g = new Group(); + const trunk = new Mesh(new CylinderGeometry(0.08, 0.12, 0.7, 5), bark); + trunk.position.y = 0.35; + g.add(trunk); + + const tiers = [ + { y: 0.85, r: 0.55, h: 0.7 }, + { y: 1.25, r: 0.42, h: 0.6 }, + { y: 1.55, r: 0.28, h: 0.5 }, + ]; + for (const tier of tiers) { + const cone = new Mesh(new ConeGeometry(tier.r, tier.h, 6), foliage); + cone.position.y = tier.y; + g.add(cone); + } + g.scale.setScalar(scale); + return g; +} + +/** Dead / bare tree — just trunk and a few branch boxes. */ +function makeDeadTree(bark: MeshLambertMaterial, scale = 1): Group { + const g = new Group(); + const trunk = new Mesh(new CylinderGeometry(0.06, 0.1, 1.4, 5), bark); + trunk.position.y = 0.7; + g.add(trunk); + + const arm = new Mesh(new BoxGeometry(0.7, 0.07, 0.07), bark); + arm.position.set(0.2, 1.15, 0); + arm.rotation.z = -0.5; + g.add(arm); + + const arm2 = new Mesh(new BoxGeometry(0.45, 0.06, 0.06), bark); + arm2.position.set(-0.15, 1.35, 0.05); + arm2.rotation.z = 0.65; + g.add(arm2); + + g.scale.setScalar(scale); + return g; +} + +function makeRock(rock: MeshLambertMaterial, rockDark: MeshLambertMaterial, scale = 1): Group { + const g = new Group(); + const a = new Mesh(new BoxGeometry(0.5, 0.28, 0.4), rock); + a.position.y = 0.12; + a.rotation.y = 0.3; + g.add(a); + const b = new Mesh(new BoxGeometry(0.28, 0.18, 0.32), rockDark); + b.position.set(0.18, 0.08, 0.1); + g.add(b); + g.scale.setScalar(scale); + return g; +} + +/** + * Builds a fixed outdoor set for the character creator: + * grass clearing, dirt path, pines, rocks, fog sky. + * Keeps triangle counts low so the preview stays snappy. + */ +export function buildEnvironment(scene: Scene): Group { + const root = new Group(); + root.name = "environment"; + + // --- atmosphere ---------------------------------------------------------- + scene.background = new Color(PALETTE.skyHorizon); + scene.fog = new FogExp2(PALETTE.fog, 0.085); + + // Gradient sky dome (inside of a sphere-ish cone stack of rings via large sphere). + const skyGeo = new ConeGeometry(18, 14, 8, 1, true); + // Cone points up; flip so we stand inside a bowl of sky. + const sky = new Mesh(skyGeo, lambert(PALETTE.skyTop, { side: BackSide, flat: true })); + sky.position.y = 5; + sky.rotation.x = Math.PI; + // Paint horizon band with a second inverted cone / ring. + root.add(sky); + + const horizon = new Mesh( + new CylinderGeometry(17, 17, 4, 10, 1, true), + lambert(PALETTE.skyHorizon, { side: BackSide, flat: true }), + ); + horizon.position.y = 1.5; + root.add(horizon); + + // --- ground -------------------------------------------------------------- + const grassMat = lambert(PALETTE.grass); + const grassDarkMat = lambert(PALETTE.grassDark); + const dirtMat = lambert(PALETTE.dirt); + const pathMat = lambert(PALETTE.path); + + // Large grass disc (circle reads softer than a huge square at the fog edge). + const ground = new Mesh(new CircleGeometry(14, 12), grassMat); + ground.rotation.x = -Math.PI / 2; + ground.position.y = 0; + root.add(ground); + + // Darker grass patches. + for (const [x, z, s] of [ + [-2.2, -1.8, 1.4], + [2.8, 1.2, 1.1], + [-1.0, 3.0, 1.3], + [3.5, -2.5, 0.9], + ] as const) { + const patch = new Mesh(new CircleGeometry(s, 6), grassDarkMat); + patch.rotation.x = -Math.PI / 2; + patch.position.set(x, 0.005, z); + root.add(patch); + } + + // Dirt clearing under the character. + const clearing = new Mesh(new CircleGeometry(1.35, 8), dirtMat); + clearing.rotation.x = -Math.PI / 2; + clearing.position.y = 0.01; + root.add(clearing); + + // Path leading toward camera / back. + const path = new Mesh(new PlaneGeometry(1.1, 8), pathMat); + path.rotation.x = -Math.PI / 2; + path.position.set(0, 0.012, 3.2); + root.add(path); + + const pathBack = new Mesh(new PlaneGeometry(0.9, 5), pathMat); + pathBack.rotation.x = -Math.PI / 2; + pathBack.position.set(0.4, 0.012, -3.5); + pathBack.rotation.z = 0.15; + root.add(pathBack); + + // --- props --------------------------------------------------------------- + const bark = lambert(PALETTE.bark); + const foliage = lambert(PALETTE.foliage); + const foliageDark = lambert(PALETTE.foliageDark); + const rock = lambert(PALETTE.rock); + const rockDark = lambert(PALETTE.rockDark); + const trunk = lambert(PALETTE.trunk); + + const trees: Array<[number, number, number, number]> = [ + [-3.2, -2.4, 1.15, 0.4], + [-4.5, 1.2, 1.35, 1.1], + [3.8, -1.6, 1.05, -0.3], + [4.2, 2.0, 1.4, 0.8], + [-2.8, 3.6, 0.95, 0.2], + [2.5, 4.0, 1.2, -0.6], + [-5.5, -0.5, 1.5, 0], + [5.0, -3.2, 1.1, 1.4], + ]; + trees.forEach(([x, z, s, rot], i) => { + const tree = makeTree(i % 2 === 0 ? foliage : foliageDark, bark, s); + tree.position.set(x, 0, z); + tree.rotation.y = rot; + root.add(tree); + }); + + // A couple of bare trees for RE / SH silhouette variety. + for (const [x, z, s, rot] of [ + [-4.0, 3.8, 1.1, 0.5], + [4.8, 0.5, 0.95, -0.8], + ] as const) { + const dead = makeDeadTree(trunk, s); + dead.position.set(x, 0, z); + dead.rotation.y = rot; + root.add(dead); + } + + for (const [x, z, s, rot] of [ + [1.6, -1.1, 0.7, 0.2], + [-1.4, -1.4, 0.9, 1.0], + [2.2, 1.8, 0.55, -0.4], + [-2.5, 0.8, 0.65, 0.7], + [0.8, 2.4, 0.45, 0.1], + ] as const) { + const r = makeRock(rock, rockDark, s); + r.position.set(x, 0, z); + r.rotation.y = rot; + root.add(r); + } + + // Low hedge / bush clumps (cones). + for (const [x, z, s] of [ + [-1.8, 2.2, 0.45], + [1.9, -2.0, 0.38], + [-3.5, -3.0, 0.5], + ] as const) { + const bush = new Mesh(new ConeGeometry(0.45 * s * 2, 0.5 * s * 2, 5), foliageDark); + bush.position.set(x, 0.2 * s * 2, z); + root.add(bush); + } + + // Distant low hills (flattened boxes at fog edge). + const hillMat = lambert(PALETTE.grassDark); + for (const [x, z, sx, sy, sz] of [ + [-8, -6, 5, 1.2, 3], + [7, -7, 4, 0.9, 3.5], + [-6, 8, 6, 1.4, 3], + [9, 5, 4, 1.1, 4], + ] as const) { + addMesh(root, new BoxGeometry(1, 1, 1), hillMat, x, sy * 0.35, z, sx, sy, sz); + } + + scene.add(root); + return root; +} + +export { PALETTE as ENV_PALETTE }; diff --git a/tools/web/src/main.ts b/tools/web/src/main.ts new file mode 100644 index 0000000..fcc5c6e --- /dev/null +++ b/tools/web/src/main.ts @@ -0,0 +1,769 @@ +import { assembleCharacter } from "../../src/creator/assemble"; +import { SKIN } from "../../src/creator/body"; +import { + BODY_STYLE_DEFAULTS, + normalizeBodyStyle, + type BodyStyle, +} from "../../src/creator/bodyStyle"; +import { + HEAD_STYLE_DEFAULTS, + normalizeHeadStyle, + type HeadStyle, +} from "../../src/creator/headStyle"; +import { PART_DEFINITIONS } from "../../src/creator/parts"; +import { CHARACTER_PRESETS } from "../../src/creator/presets"; +import type { + BodyType, + CharacterRecipe, + CreatorPalette, + PartSlot, +} from "../../src/creator/types"; +import type { Rgb } from "../../src/image/Raster"; +import { CharacterPreview } from "./preview"; + +const SLOTS: PartSlot[] = ["hair", "upper", "lower", "feet", "accessory"]; +const PALETTE_KEYS: (keyof CreatorPalette)[] = [ + "skin", + "hair", + "primary", + "secondary", + "accent", + "metal", + "leather", +]; + +const SKIN_OPTIONS: { id: string; name: string; color: Rgb }[] = [ + { id: "fair", name: "Fair", color: SKIN.fair }, + { id: "light", name: "Light", color: SKIN.light }, + { id: "medium", name: "Medium", color: SKIN.medium }, + { id: "tan", name: "Tan", color: SKIN.tan }, + { id: "deep", name: "Deep", color: SKIN.deep }, +]; + +interface CreatorState { + id: string; + name: string; + body: BodyType; + height: number; + bodyStyle: BodyStyle; + headStyle: HeadStyle; + styleNotes: string; + palette: CreatorPalette; + parts: Partial>; +} + +function clonePalette(palette: CreatorPalette): CreatorPalette { + return { + skin: { ...palette.skin }, + hair: { ...palette.hair }, + primary: { ...palette.primary }, + secondary: { ...palette.secondary }, + accent: { ...palette.accent }, + metal: { ...palette.metal }, + leather: { ...palette.leather }, + }; +} + +function cloneBodyStyle(style?: BodyStyle | null): BodyStyle { + return normalizeBodyStyle(style ?? BODY_STYLE_DEFAULTS); +} + +function cloneHeadStyle(style?: HeadStyle | null): HeadStyle { + return normalizeHeadStyle(style ?? HEAD_STYLE_DEFAULTS); +} + +function fromPreset(preset: CharacterRecipe): CreatorState { + return { + id: preset.id, + name: preset.name, + body: preset.body, + height: preset.height, + bodyStyle: cloneBodyStyle(preset.bodyStyle), + headStyle: cloneHeadStyle(preset.headStyle), + styleNotes: preset.styleNotes ?? "", + palette: clonePalette(preset.palette), + parts: { ...preset.parts }, + }; +} + +function defaultState(): CreatorState { + const preset = CHARACTER_PRESETS[0]!; + return fromPreset(preset); +} + +function rgbToHex({ r, g, b }: Rgb): string { + const h = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0"); + return `#${h(r)}${h(g)}${h(b)}`; +} + +function hexToRgb(hex: string): Rgb { + const raw = hex.replace("#", ""); + const full = raw.length === 3 ? raw.split("").map((c) => c + c).join("") : raw; + const n = Number.parseInt(full, 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; +} + +function nearestSkinId(skin: Rgb): string { + let best = SKIN_OPTIONS[0]!; + let bestDist = Infinity; + for (const option of SKIN_OPTIONS) { + const d = + (option.color.r - skin.r) ** 2 + + (option.color.g - skin.g) ** 2 + + (option.color.b - skin.b) ** 2; + if (d < bestDist) { + bestDist = d; + best = option; + } + } + return best.id; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) || "character"; +} + +function partOptions(slot: PartSlot, body: BodyType) { + return PART_DEFINITIONS.filter((part) => part.slot === slot && (!part.body || part.body === body)); +} + +function buildRecipe(state: CreatorState): CharacterRecipe { + const parts: CharacterRecipe["parts"] = {}; + for (const slot of SLOTS) { + const id = state.parts[slot]; + if (id) parts[slot] = id; + } + const bodyStyle = cloneBodyStyle(state.bodyStyle); + const headStyle = cloneHeadStyle(state.headStyle); + const styleNotes = state.styleNotes.trim(); + return { + id: state.id || slugify(state.name), + name: state.name || "Character", + body: state.body, + height: state.height, + bodyStyle, + headStyle, + ...(styleNotes ? { styleNotes } : {}), + palette: clonePalette(state.palette), + parts, + }; +} + +function formatStyle(n: number): string { + return (Math.round(n * 100) / 100).toFixed(2); +} + +function recipeJson(recipe: CharacterRecipe, stats?: { vertices: number; triangles: number; bones: number; clips: number }): string { + return JSON.stringify( + { + ...recipe, + ...(stats ? { stats } : {}), + }, + null, + 2, + ); +} + +function downloadBlob(filename: string, blob: Blob): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +// --------------------------------------------------------------------------- +// UI +// --------------------------------------------------------------------------- + +const state = defaultState(); +let latestGlb: Uint8Array | null = null; +let latestRecipe: CharacterRecipe | null = null; +let rebuildTimer = 0; +let rebuildToken = 0; + +const el = { + preset: document.querySelector("#preset")!, + name: document.querySelector("#name")!, + id: document.querySelector("#id")!, + body: document.querySelector("#body")!, + height: document.querySelector("#height")!, + heightLabel: document.querySelector("#height-label")!, + mass: document.querySelector("#mass")!, + massLabel: document.querySelector("#mass-label")!, + muscle: document.querySelector("#muscle")!, + muscleLabel: document.querySelector("#muscle-label")!, + fat: document.querySelector("#fat")!, + fatLabel: document.querySelector("#fat-label")!, + bodyStyleReset: document.querySelector("#body-style-reset")!, + faceLength: document.querySelector("#face-length")!, + faceLengthLabel: document.querySelector("#face-length-label")!, + faceJaw: document.querySelector("#face-jaw")!, + faceJawLabel: document.querySelector("#face-jaw-label")!, + faceBrow: document.querySelector("#face-brow")!, + faceBrowLabel: document.querySelector("#face-brow-label")!, + headStyleReset: document.querySelector("#head-style-reset")!, + skinSwatches: document.querySelector("#skin-swatches")!, + parts: document.querySelector("#parts")!, + palette: document.querySelector("#palette")!, + stats: document.querySelector("#stats")!, + recipeJson: document.querySelector("#recipe-json")!, + status: document.querySelector("#status")!, + clip: document.querySelector("#clip")!, + autoRotate: document.querySelector("#auto-rotate")!, + downloadGlb: document.querySelector("#download-glb")!, + downloadRecipe: document.querySelector("#download-recipe")!, + copyRecipe: document.querySelector("#copy-recipe")!, + saveAssets: document.querySelector("#save-assets")!, + uvGuide: document.querySelector("#uv-guide")!, + textureGrok: document.querySelector("#texture-grok")!, + textureRegen: document.querySelector("#texture-regen")!, + albedoImport: document.querySelector("#albedo-import")!, + albedoFile: document.querySelector("#albedo-file")!, + styleNotes: document.querySelector("#style-notes")!, + texStatus: document.querySelector("#tex-status")!, + texPreviews: document.querySelector("#tex-previews")!, + uvGuideImg: document.querySelector("#uv-guide-img")!, + uvGuideLink: document.querySelector("#uv-guide-link")!, + albedoCard: document.querySelector("#albedo-card")!, + albedoImg: document.querySelector("#albedo-img")!, + albedoLink: document.querySelector("#albedo-link")!, + viewport: document.querySelector("#viewport")!, +}; + +const preview = new CharacterPreview(el.viewport); +preview.setAutoRotate(el.autoRotate.checked); + +function setStatus(message: string, kind: "ok" | "err" | "" = ""): void { + el.status.textContent = message; + el.status.classList.remove("is-ok", "is-err"); + if (kind === "ok") el.status.classList.add("is-ok"); + if (kind === "err") el.status.classList.add("is-err"); +} + +function setTexStatus(message: string, kind: "ok" | "err" | "" = ""): void { + el.texStatus.textContent = message; + el.texStatus.classList.remove("is-ok", "is-err"); + if (kind === "ok") el.texStatus.classList.add("is-ok"); + if (kind === "err") el.texStatus.classList.add("is-err"); +} + +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} + +function setTextureBusy(busy: boolean): void { + el.uvGuide.disabled = busy; + el.textureGrok.disabled = busy; + el.textureRegen.disabled = busy; + el.albedoImport.disabled = busy; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +interface TextureRun { + mode: "guide" | "texture"; + force?: boolean; + /** Apply this image as the albedo instead of calling Grok. */ + albedo?: File; +} + +async function runTexture({ mode, force = false, albedo }: TextureRun): Promise { + const recipe = buildRecipe(state); + setTextureBusy(true); + setTexStatus( + albedo + ? `Applying ${albedo.name}…` + : mode === "guide" + ? "Baking UV guide…" + : force + ? "Repainting with Grok Imagine, ignoring the cache… (can take ~1 min)" + : "Texturing with Grok Imagine… (can take ~1 min)", + ); + + try { + const albedoBase64 = albedo + ? bytesToBase64(new Uint8Array(await albedo.arrayBuffer())) + : undefined; + const response = await fetch("/api/texture", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ recipe, mode, force, albedoBase64 }), + }); + const body = (await response.json()) as { + ok?: boolean; + error?: string; + glbBase64?: string; + guideUrl?: string; + albedoUrl?: string | null; + textured?: boolean; + imported?: boolean; + cached?: boolean; + costUsd?: number; + bytes?: number; + }; + if (!response.ok || !body.ok || !body.glbBase64) { + throw new Error(body.error ?? `HTTP ${response.status}`); + } + + latestGlb = base64ToBytes(body.glbBase64); + latestRecipe = recipe; + + if (body.guideUrl) { + el.texPreviews.hidden = false; + el.uvGuideImg.src = body.guideUrl; + el.uvGuideLink.href = body.guideUrl; + } + if (body.albedoUrl) { + el.albedoCard.hidden = false; + el.albedoImg.src = body.albedoUrl; + el.albedoLink.href = body.albedoUrl; + } else { + el.albedoCard.hidden = true; + } + + await preview.setGlb(latestGlb, el.clip.value); + + const bits = [ + mode === "guide" + ? "UV guide ready" + : !body.textured + ? "Done" + : body.imported + ? "Albedo imported" + : force + ? "Repainted — cache replaced" + : "Textured GLB ready", + body.bytes ? `${(body.bytes / 1024).toFixed(0)} KB` : "", + body.cached ? "cached" : "", + body.costUsd && body.costUsd > 0 ? `$${body.costUsd.toFixed(3)}` : "", + ].filter(Boolean); + setTexStatus(bits.join(" · "), "ok"); + el.stats.textContent = [ + body.textured + ? body.imported + ? "Textured (imported albedo)" + : "Textured (Grok albedo)" + : "UV guide baked (vertex colour GLB)", + body.bytes ? `${(body.bytes / 1024).toFixed(1)} KB` : "", + recipe.body + " @ " + recipe.height.toFixed(2) + " m", + ] + .filter(Boolean) + .join("\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setTexStatus(message, "err"); + } finally { + setTextureBusy(false); + } +} + +function syncBodyStyleControls(): void { + el.mass.value = String(state.bodyStyle.mass); + el.muscle.value = String(state.bodyStyle.muscle); + el.fat.value = String(state.bodyStyle.fat); + el.massLabel.textContent = formatStyle(state.bodyStyle.mass); + el.muscleLabel.textContent = formatStyle(state.bodyStyle.muscle); + el.fatLabel.textContent = formatStyle(state.bodyStyle.fat); +} + +function syncHeadStyleControls(): void { + el.faceLength.value = String(state.headStyle.length); + el.faceJaw.value = String(state.headStyle.jaw); + el.faceBrow.value = String(state.headStyle.brow); + el.faceLengthLabel.textContent = formatStyle(state.headStyle.length); + el.faceJawLabel.textContent = formatStyle(state.headStyle.jaw); + el.faceBrowLabel.textContent = formatStyle(state.headStyle.brow); +} + +function syncFormFromState(): void { + el.name.value = state.name; + el.id.value = state.id; + el.height.value = String(state.height); + el.heightLabel.textContent = `${state.height.toFixed(2)} m`; + syncBodyStyleControls(); + syncHeadStyleControls(); + el.styleNotes.value = state.styleNotes; + + for (const btn of el.body.querySelectorAll(".seg__btn")) { + btn.classList.toggle("is-active", btn.dataset.body === state.body); + } + + const skinId = nearestSkinId(state.palette.skin); + for (const swatch of el.skinSwatches.querySelectorAll(".swatch")) { + swatch.classList.toggle("is-active", swatch.dataset.skin === skinId); + } + + for (const key of PALETTE_KEYS) { + const input = el.palette.querySelector(`input[data-key="${key}"]`); + if (input) input.value = rgbToHex(state.palette[key]); + } + + renderParts(); +} + +function renderPresets(): void { + el.preset.innerHTML = ""; + const blank = document.createElement("option"); + blank.value = ""; + blank.textContent = "— custom —"; + el.preset.append(blank); + + for (const preset of CHARACTER_PRESETS) { + const option = document.createElement("option"); + option.value = preset.id; + option.textContent = `${preset.name} (${preset.id})`; + el.preset.append(option); + } + el.preset.value = state.id; +} + +function renderSkinSwatches(): void { + el.skinSwatches.innerHTML = ""; + for (const option of SKIN_OPTIONS) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "swatch"; + btn.title = option.name; + btn.dataset.skin = option.id; + btn.style.background = rgbToHex(option.color); + btn.addEventListener("click", () => { + state.palette.skin = { ...option.color }; + const skinInput = el.palette.querySelector(`input[data-key="skin"]`); + if (skinInput) skinInput.value = rgbToHex(option.color); + for (const s of el.skinSwatches.querySelectorAll(".swatch")) { + s.classList.toggle("is-active", s === btn); + } + scheduleRebuild(); + }); + el.skinSwatches.append(btn); + } +} + +function renderPalette(): void { + el.palette.innerHTML = ""; + for (const key of PALETTE_KEYS) { + const label = document.createElement("label"); + label.className = "color-field"; + const span = document.createElement("span"); + span.textContent = key; + const input = document.createElement("input"); + input.type = "color"; + input.dataset.key = key; + input.value = rgbToHex(state.palette[key]); + input.addEventListener("input", () => { + state.palette[key] = hexToRgb(input.value); + if (key === "skin") { + const skinId = nearestSkinId(state.palette.skin); + for (const s of el.skinSwatches.querySelectorAll(".swatch")) { + s.classList.toggle("is-active", s.dataset.skin === skinId); + } + } + scheduleRebuild(); + }); + label.append(span, input); + el.palette.append(label); + } +} + +function renderParts(): void { + el.parts.innerHTML = ""; + for (const slot of SLOTS) { + const wrap = document.createElement("div"); + wrap.className = "part-slot"; + + const label = document.createElement("span"); + label.className = "part-slot__label"; + label.textContent = slot; + wrap.append(label); + + const row = document.createElement("div"); + row.className = "chip-row"; + + const options = partOptions(slot, state.body); + const current = state.parts[slot] ?? `${slot}.none`; + + // If current part is invalid for body (e.g. skirt on male), clear it. + if (!options.some((p) => p.id === current)) { + state.parts[slot] = options[0]?.id ?? `${slot}.none`; + } + + for (const part of options) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "chip"; + chip.textContent = part.name; + chip.dataset.part = part.id; + if (part.id === (state.parts[slot] ?? `${slot}.none`)) chip.classList.add("is-active"); + chip.addEventListener("click", () => { + state.parts[slot] = part.id; + for (const c of row.querySelectorAll(".chip")) c.classList.remove("is-active"); + chip.classList.add("is-active"); + scheduleRebuild(); + }); + row.append(chip); + } + + wrap.append(row); + el.parts.append(wrap); + } +} + +function scheduleRebuild(): void { + window.clearTimeout(rebuildTimer); + rebuildTimer = window.setTimeout(() => { + void rebuild(); + }, 80); +} + +async function rebuild(): Promise { + const token = ++rebuildToken; + const recipe = buildRecipe(state); + el.recipeJson.textContent = recipeJson(recipe); + el.stats.textContent = "Assembling…"; + + try { + const t0 = performance.now(); + const result = assembleCharacter(recipe); + if (token !== rebuildToken) return; + + latestGlb = result.glb; + latestRecipe = result.recipe; + const ms = performance.now() - t0; + + el.recipeJson.textContent = recipeJson(result.recipe, result.stats); + const bs = result.recipe.bodyStyle ?? BODY_STYLE_DEFAULTS; + const hs = result.recipe.headStyle ?? HEAD_STYLE_DEFAULTS; + el.stats.textContent = [ + `${result.stats.vertices} verts · ${result.stats.triangles} tris · ${result.stats.bones} bones`, + `${(result.glb.byteLength / 1024).toFixed(1)} KB · ${ms.toFixed(0)} ms`, + `${result.recipe.body} @ ${result.recipe.height.toFixed(2)} m`, + `mass ${formatStyle(bs.mass)} · muscle ${formatStyle(bs.muscle)} · fat ${formatStyle(bs.fat)}`, + `face ${formatStyle(hs.length)} · jaw ${formatStyle(hs.jaw)} · brow ${formatStyle(hs.brow)}`, + ].join("\n"); + + await preview.setGlb(result.glb, el.clip.value); + if (token !== rebuildToken) return; + setStatus(""); + } catch (error) { + if (token !== rebuildToken) return; + const message = error instanceof Error ? error.message : String(error); + el.stats.textContent = "Assemble failed"; + setStatus(message, "err"); + console.error(error); + } +} + +// Events ------------------------------------------------------------------- + +el.preset.addEventListener("change", () => { + const preset = CHARACTER_PRESETS.find((p) => p.id === el.preset.value); + if (!preset) return; + Object.assign(state, fromPreset(preset)); + syncFormFromState(); + scheduleRebuild(); +}); + +el.name.addEventListener("input", () => { + state.name = el.name.value; + // Keep id in sync while it still looks auto-derived. + if (!el.id.dataset.manual) { + state.id = slugify(state.name); + el.id.value = state.id; + } + el.preset.value = CHARACTER_PRESETS.some((p) => p.id === state.id) ? state.id : ""; + scheduleRebuild(); +}); + +el.id.addEventListener("input", () => { + el.id.dataset.manual = "1"; + state.id = slugify(el.id.value) || el.id.value; + el.preset.value = CHARACTER_PRESETS.some((p) => p.id === state.id) ? state.id : ""; + scheduleRebuild(); +}); + +el.body.addEventListener("click", (event) => { + const btn = (event.target as HTMLElement).closest("[data-body]"); + if (!btn?.dataset.body) return; + state.body = btn.dataset.body as BodyType; + for (const b of el.body.querySelectorAll(".seg__btn")) b.classList.remove("is-active"); + btn.classList.add("is-active"); + renderParts(); + scheduleRebuild(); +}); + +el.height.addEventListener("input", () => { + state.height = Number(el.height.value); + el.heightLabel.textContent = `${state.height.toFixed(2)} m`; + scheduleRebuild(); +}); + +function bindStyleSlider( + input: HTMLInputElement, + label: HTMLElement, + key: keyof BodyStyle, +): void { + input.addEventListener("input", () => { + state.bodyStyle[key] = Number(input.value); + label.textContent = formatStyle(state.bodyStyle[key]); + scheduleRebuild(); + }); +} + +bindStyleSlider(el.mass, el.massLabel, "mass"); +bindStyleSlider(el.muscle, el.muscleLabel, "muscle"); +bindStyleSlider(el.fat, el.fatLabel, "fat"); + +function bindFaceSlider( + input: HTMLInputElement, + label: HTMLElement, + key: keyof HeadStyle, +): void { + input.addEventListener("input", () => { + state.headStyle[key] = Number(input.value); + label.textContent = formatStyle(state.headStyle[key]); + scheduleRebuild(); + }); +} + +bindFaceSlider(el.faceLength, el.faceLengthLabel, "length"); +bindFaceSlider(el.faceJaw, el.faceJawLabel, "jaw"); +bindFaceSlider(el.faceBrow, el.faceBrowLabel, "brow"); + +el.bodyStyleReset.addEventListener("click", () => { + state.bodyStyle = cloneBodyStyle(BODY_STYLE_DEFAULTS); + syncBodyStyleControls(); + scheduleRebuild(); +}); + +el.headStyleReset.addEventListener("click", () => { + state.headStyle = cloneHeadStyle(HEAD_STYLE_DEFAULTS); + syncHeadStyleControls(); + scheduleRebuild(); +}); + +el.clip.addEventListener("change", () => { + preview.play(el.clip.value); +}); + +el.autoRotate.addEventListener("change", () => { + preview.setAutoRotate(el.autoRotate.checked); +}); + +el.downloadGlb.addEventListener("click", () => { + if (!latestGlb || !latestRecipe) { + setStatus("Nothing to download yet", "err"); + return; + } + const bytes = new Uint8Array(latestGlb); + downloadBlob( + `${latestRecipe.id}.glb`, + new Blob([bytes], { type: "model/gltf-binary" }), + ); + setStatus(`Downloaded ${latestRecipe.id}.glb`, "ok"); +}); + +el.downloadRecipe.addEventListener("click", () => { + if (!latestRecipe) { + setStatus("Nothing to download yet", "err"); + return; + } + const text = recipeJson(latestRecipe); + downloadBlob(`${latestRecipe.id}.recipe.json`, new Blob([text], { type: "application/json" })); + setStatus(`Downloaded ${latestRecipe.id}.recipe.json`, "ok"); +}); + +el.copyRecipe.addEventListener("click", async () => { + if (!latestRecipe) { + setStatus("Nothing to copy yet", "err"); + return; + } + try { + await navigator.clipboard.writeText(recipeJson(latestRecipe)); + setStatus("Recipe JSON copied", "ok"); + } catch { + setStatus("Clipboard blocked — select the JSON panel instead", "err"); + } +}); + +el.saveAssets.addEventListener("click", async () => { + if (!latestGlb || !latestRecipe) { + setStatus("Nothing to save yet", "err"); + return; + } + + setStatus("Saving to assets/characters/…"); + try { + const response = await fetch("/api/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: latestRecipe.id, + recipe: { + ...latestRecipe, + }, + glbBase64: bytesToBase64(latestGlb), + }), + }); + const body = (await response.json()) as { ok?: boolean; error?: string; bytes?: number; glb?: string }; + if (!response.ok || !body.ok) { + throw new Error(body.error ?? `HTTP ${response.status}`); + } + setStatus(`Saved ${latestRecipe.id}.glb (${((body.bytes ?? 0) / 1024).toFixed(1)} KB)`, "ok"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setStatus(`Save failed: ${message}`, "err"); + } +}); + +el.styleNotes.addEventListener("input", () => { + state.styleNotes = el.styleNotes.value; + // Doesn't touch geometry, but keeps the recipe JSON panel truthful. + scheduleRebuild(); +}); + +el.uvGuide.addEventListener("click", () => { + void runTexture({ mode: "guide" }); +}); + +el.textureGrok.addEventListener("click", () => { + void runTexture({ mode: "texture" }); +}); + +el.textureRegen.addEventListener("click", () => { + void runTexture({ mode: "texture", force: true }); +}); + +el.albedoImport.addEventListener("click", () => { + el.albedoFile.click(); +}); + +el.albedoFile.addEventListener("change", () => { + const file = el.albedoFile.files?.[0]; + // Reset first, so picking the same file twice still fires a change event. + el.albedoFile.value = ""; + if (file) void runTexture({ mode: "texture", albedo: file }); +}); + +// Boot --------------------------------------------------------------------- + +renderPresets(); +renderSkinSwatches(); +renderPalette(); +syncFormFromState(); +void rebuild(); diff --git a/tools/web/src/preview.ts b/tools/web/src/preview.ts new file mode 100644 index 0000000..33fc0bd --- /dev/null +++ b/tools/web/src/preview.ts @@ -0,0 +1,284 @@ +import { + AmbientLight, + AnimationMixer, + Color, + DirectionalLight, + Group, + HemisphereLight, + LoopOnce, + LoopRepeat, + Mesh, + MeshBasicMaterial, + MeshLambertMaterial, + NearestFilter, + OrthographicCamera, + PerspectiveCamera, + PlaneGeometry, + RGBAFormat, + Scene, + SkinnedMesh, + SRGBColorSpace, + WebGLRenderTarget, + WebGLRenderer, + type AnimationAction, + type AnimationClip, + type Material, +} from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { buildEnvironment, ENV_PALETTE } from "./environment"; + +/** + * Three.js viewport for the character creator. + * Outdoor PSX-style set, fog, sun lighting, and a low-res nearest upscale. + */ +export class CharacterPreview { + private readonly renderer: WebGLRenderer; + private readonly scene = new Scene(); + private readonly camera: PerspectiveCamera; + private readonly controls: OrbitControls; + private readonly loader = new GLTFLoader(); + private readonly root = new Group(); + private readonly clock = { last: performance.now() }; + + /** Offscreen low-res buffer → nearest-neighbor blit (GTE-ish look). */ + private readonly rt: WebGLRenderTarget; + private readonly blitScene = new Scene(); + private readonly blitCam = new OrthographicCamera(-1, 1, 1, -1, 0, 1); + private readonly blitMat: MeshBasicMaterial; + private viewW = 1; + private viewH = 1; + + private mixer: AnimationMixer | null = null; + private actions = new Map(); + private character: Group | null = null; + private env: Group | null = null; + private raf = 0; + private autoRotate = true; + private disposed = false; + private loadGen = 0; + + constructor(private readonly canvas: HTMLCanvasElement) { + this.renderer = new WebGLRenderer({ + canvas, + antialias: false, + alpha: false, + powerPreference: "high-performance", + }); + this.renderer.setClearColor(new Color(ENV_PALETTE.fog), 1); + this.renderer.setPixelRatio(1); + this.renderer.outputColorSpace = SRGBColorSpace; + + this.camera = new PerspectiveCamera(38, 1, 0.08, 28); + this.camera.position.set(1.55, 1.25, 2.65); + + this.controls = new OrbitControls(this.camera, canvas); + this.controls.target.set(0, 0.85, 0); + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.08; + this.controls.minDistance = 1.0; + this.controls.maxDistance = 7; + this.controls.maxPolarAngle = Math.PI * 0.48; + this.controls.minPolarAngle = 0.15; + + this.scene.add(this.root); + this.env = buildEnvironment(this.scene); + this.setupLighting(); + + // Low-res render target; size updated on resize. + this.rt = new WebGLRenderTarget(320, 240, { + format: RGBAFormat, + minFilter: NearestFilter, + magFilter: NearestFilter, + depthBuffer: true, + stencilBuffer: false, + }); + this.rt.texture.generateMipmaps = false; + + this.blitMat = new MeshBasicMaterial({ + map: this.rt.texture, + depthTest: false, + depthWrite: false, + }); + const blitQuad = new Mesh(new PlaneGeometry(2, 2), this.blitMat); + this.blitScene.add(blitQuad); + + this.onResize(); + window.addEventListener("resize", this.onResize); + this.tick(); + } + + private setupLighting(): void { + // Cool overcast sky / warm dirt bounce — PSX adventure outdoor. + const hemi = new HemisphereLight(0xb0b8a0, 0x3a3220, 0.55); + hemi.position.set(0, 4, 0); + + // Late-afternoon key (sun). + const sun = new DirectionalLight(0xffe2b0, 1.35); + sun.position.set(4.5, 7.5, 3.2); + + // Cool sky fill from the opposite side. + const fill = new DirectionalLight(0x8898a8, 0.4); + fill.position.set(-3.5, 2.5, -2.5); + + // Soft ambient so flats don't crush to black. + const ambient = new AmbientLight(0x4a5040, 0.38); + + // Weak rim so silhouettes read against fog. + const rim = new DirectionalLight(0xc8d0c0, 0.25); + rim.position.set(-1.5, 3.0, -4.0); + + this.scene.add(hemi, sun, fill, ambient, rim); + } + + setAutoRotate(enabled: boolean): void { + this.autoRotate = enabled; + this.controls.autoRotate = enabled; + this.controls.autoRotateSpeed = 1.15; + } + + async setGlb(glb: Uint8Array, clipName = "Idle"): Promise { + const gen = ++this.loadGen; + const gltf = await this.parseGlb(glb); + if (this.disposed || gen !== this.loadGen) return; + + if (this.character) { + this.root.remove(this.character); + this.disposeObject(this.character); + this.character = null; + } + this.mixer = null; + this.actions.clear(); + + const scene = gltf.scene as Group; + scene.traverse((object) => { + const mesh = object as Mesh; + if (!mesh.isMesh) return; + + // Keep Grok albedo map when present; otherwise vertex colours. + const prior = mesh.material as Material & { map?: import("three").Texture | null }; + const loadedMap = !Array.isArray(prior) && prior.map ? prior.map : null; + + mesh.material = new MeshLambertMaterial({ + vertexColors: !loadedMap, + flatShading: true, + fog: true, + ...(loadedMap ? { map: loadedMap } : {}), + }); + if (loadedMap) { + loadedMap.magFilter = NearestFilter; + loadedMap.minFilter = NearestFilter; + loadedMap.needsUpdate = true; + } + + if ((mesh as SkinnedMesh).isSkinnedMesh) { + mesh.frustumCulled = false; + } + }); + + this.root.add(scene); + this.character = scene; + + this.mixer = new AnimationMixer(scene); + for (const clip of gltf.animations) { + 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(clipName); + this.mixer.update(0); + } + + play(clipName: string): void { + if (!this.mixer) return; + const next = this.actions.get(clipName); + if (!next) return; + + for (const [name, action] of this.actions) { + if (name === clipName) continue; + if (action.isRunning()) action.fadeOut(0.15); + } + + next.reset().fadeIn(0.15).play(); + } + + dispose(): void { + this.disposed = true; + cancelAnimationFrame(this.raf); + window.removeEventListener("resize", this.onResize); + this.controls.dispose(); + if (this.character) this.disposeObject(this.character); + if (this.env) this.disposeObject(this.env); + this.rt.dispose(); + this.blitMat.dispose(); + this.renderer.dispose(); + } + + private parseGlb(glb: Uint8Array): Promise<{ scene: Group; animations: AnimationClip[] }> { + const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer; + return new Promise((resolve, reject) => { + this.loader.parse(buffer, "", (gltf) => resolve(gltf as { scene: Group; animations: AnimationClip[] }), reject); + }); + } + + private disposeObject(root: Group): void { + root.traverse((object) => { + const mesh = object as Mesh; + if (mesh.isMesh) { + mesh.geometry?.dispose(); + const material = mesh.material; + if (Array.isArray(material)) material.forEach((m) => m.dispose()); + else material?.dispose(); + } + }); + } + + private readonly onResize = (): void => { + const parent = this.canvas.parentElement; + if (!parent) return; + const width = parent.clientWidth; + const height = parent.clientHeight; + if (width === 0 || height === 0) return; + + this.viewW = width; + this.viewH = height; + + // Internal PSX-ish resolution (~320px wide), keep aspect. + const internalW = 320; + const internalH = Math.max(180, Math.round(internalW * (height / width))); + this.rt.setSize(internalW, internalH); + + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + + // Full canvas for the upscale blit. + this.renderer.setSize(width, height, false); + }; + + private readonly tick = (): void => { + if (this.disposed) return; + this.raf = requestAnimationFrame(this.tick); + + const now = performance.now(); + const dt = Math.min(0.05, (now - this.clock.last) / 1000); + this.clock.last = now; + + this.mixer?.update(dt); + this.controls.autoRotate = this.autoRotate; + this.controls.update(); + + // Pass 1: scene → low-res RT + this.renderer.setRenderTarget(this.rt); + this.renderer.setClearColor(new Color(ENV_PALETTE.fog), 1); + this.renderer.clear(); + this.renderer.render(this.scene, this.camera); + + // Pass 2: nearest upscale to canvas (chunky pixels). + this.renderer.setRenderTarget(null); + this.renderer.clear(); + this.renderer.render(this.blitScene, this.blitCam); + }; +} diff --git a/tools/web/src/style.css b/tools/web/src/style.css new file mode 100644 index 0000000..dd8a442 --- /dev/null +++ b/tools/web/src/style.css @@ -0,0 +1,542 @@ +:root { + --ink: #c8d0c0; + --ink-dim: #7d8878; + --ink-bright: #eef2e6; + --bg: #05060a; + --panel: #0a0e0c; + --panel-elevated: #101612; + --border: #47513f; + --border-soft: #2a3228; + --accent: #c9a227; + --accent-dim: #8a7020; + --danger: #a33b2e; + --ok: #6a9a5a; + --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 { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + overflow: hidden; +} + +#app { + display: grid; + grid-template-columns: minmax(280px, 340px) 1fr minmax(240px, 300px); + height: 100vh; + min-height: 0; +} + +/* --- panels --------------------------------------------------------------- */ + +.panel { + display: flex; + flex-direction: column; + min-height: 0; + background: var(--panel); + border-right: 2px solid var(--border); + overflow: hidden; +} + +.panel--right { + border-right: none; + border-left: 2px solid var(--border); +} + +.panel__header { + padding: 1rem 1rem 0.75rem; + border-bottom: 1px solid var(--border-soft); + flex-shrink: 0; +} + +.panel__header h1, +.panel__header h2 { + margin: 0; + font-size: 0.95rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-bright); +} + +.panel__sub { + margin: 0.35rem 0 0; + font-size: 0.7rem; + color: var(--ink-dim); + letter-spacing: 0.06em; +} + +.panel--left { + overflow-y: auto; + overflow-x: hidden; +} + +.section { + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--border-soft); +} + +.section h2 { + margin: 0 0 0.65rem; + font-size: 0.72rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--accent); +} + +.section--actions { + padding-bottom: 1.25rem; +} + +.hint { + margin: 0 0 0.65rem; + font-size: 0.68rem; + line-height: 1.4; + color: var(--ink-dim); + letter-spacing: 0.03em; +} + +.tex-previews { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.45rem; + margin-top: 0.65rem; +} + +.tex-card { + margin: 0; + padding: 0.35rem; + background: var(--panel-elevated); + border: 1px solid var(--border-soft); +} + +.tex-card figcaption { + margin-bottom: 0.3rem; + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-dim); +} + +.tex-card img { + display: block; + width: 100%; + aspect-ratio: 1; + object-fit: contain; + background: #111; + image-rendering: pixelated; +} + +.tex-card a { + display: block; + text-decoration: none; +} + +.btn:disabled { + opacity: 0.45; + cursor: wait; +} + +/* --- form controls -------------------------------------------------------- */ + +.field { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-bottom: 0.65rem; + font-size: 0.75rem; + color: var(--ink-dim); +} + +.field span em { + font-style: normal; + color: var(--ink-bright); + float: right; +} + +.body-style { + display: flex; + flex-direction: column; + gap: 0.15rem; + margin: 0 0 0.75rem; + padding: 0.55rem 0.6rem 0.45rem; + background: var(--panel-elevated); + border: 1px solid var(--border-soft); +} + +.field--slider { + margin-bottom: 0.4rem; +} + +.slider-ends { + display: flex; + justify-content: space-between; + margin: 0 0 0.1rem; + font-size: 0.6rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-dim); + opacity: 0.75; +} + +.slider-ends i { + font-style: normal; +} + +.btn--ghost { + background: transparent; + border: 1px solid var(--border-soft); + color: var(--ink-dim); +} + +.btn--ghost:hover { + border-color: var(--border); + color: var(--ink); +} + +.btn--small { + align-self: flex-start; + padding: 0.25rem 0.55rem; + font-size: 0.65rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.field.grow { + flex: 1; + min-width: 0; +} + +.row { + display: flex; + gap: 0.5rem; +} + +input[type="text"], +select { + width: 100%; + padding: 0.4rem 0.5rem; + background: var(--panel-elevated); + border: 1px solid var(--border); + color: var(--ink-bright); + font: inherit; + font-size: 0.8rem; + outline: none; +} + +input[type="text"]:focus, +select:focus { + border-color: var(--accent); +} + +input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +.seg { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.seg__btn { + padding: 0.45rem 0.5rem; + background: var(--panel-elevated); + border: 1px solid var(--border); + color: var(--ink-dim); + font: inherit; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; +} + +.seg__btn:hover { + color: var(--ink-bright); + border-color: var(--ink-dim); +} + +.seg__btn.is-active { + color: var(--bg); + background: var(--accent); + border-color: var(--accent); +} + +.part-slot { + margin-bottom: 0.75rem; +} + +.part-slot__label { + display: block; + margin-bottom: 0.3rem; + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-dim); +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.chip { + padding: 0.28rem 0.45rem; + background: var(--panel-elevated); + border: 1px solid var(--border-soft); + color: var(--ink); + font: inherit; + font-size: 0.68rem; + letter-spacing: 0.04em; + cursor: pointer; +} + +.chip:hover { + border-color: var(--border); + color: var(--ink-bright); +} + +.chip.is-active { + border-color: var(--accent); + color: var(--accent); + background: rgba(201, 162, 39, 0.1); +} + +.chip:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.palette { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.color-field { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.7rem; + color: var(--ink-dim); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.color-field input[type="color"] { + width: 2rem; + height: 1.6rem; + padding: 0; + border: 1px solid var(--border); + background: transparent; + cursor: pointer; +} + +.swatches { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.swatch { + width: 1.55rem; + height: 1.55rem; + border: 2px solid var(--border-soft); + cursor: pointer; + padding: 0; +} + +.swatch.is-active { + border-color: var(--accent); + outline: 1px solid var(--accent); +} + +/* --- actions -------------------------------------------------------------- */ + +.actions { + display: grid; + gap: 0.4rem; +} + +.btn { + padding: 0.5rem 0.65rem; + background: var(--panel-elevated); + border: 1px solid var(--border); + color: var(--ink-bright); + font: inherit; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; +} + +.btn:hover { + border-color: var(--ink-dim); +} + +.btn:active { + transform: translateY(1px); +} + +.btn--primary { + background: var(--accent); + border-color: var(--accent); + color: var(--bg); + font-weight: bold; +} + +.btn--primary:hover { + background: #d4b03a; + border-color: #d4b03a; +} + +.btn--accent { + border-color: var(--accent-dim); + color: var(--accent); +} + +.status { + min-height: 1.2rem; + margin: 0.55rem 0 0; + font-size: 0.68rem; + color: var(--ink-dim); + letter-spacing: 0.04em; +} + +.status.is-ok { + color: var(--ok); +} + +.status.is-err { + color: var(--danger); +} + +/* --- stage ---------------------------------------------------------------- */ + +.stage { + position: relative; + min-width: 0; + min-height: 0; + /* Fallback while WebGL boots — matches outdoor fog palette. */ + background: linear-gradient(180deg, #6a7a72 0%, #a8a890 42%, #3d4a32 100%); +} + +#viewport { + display: block; + width: 100%; + height: 100%; + touch-action: none; +} + +.stage__hud { + position: absolute; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + padding: 0.85rem 1rem; + pointer-events: none; + background: linear-gradient(transparent, rgba(5, 6, 10, 0.85)); +} + +.stage__stats { + font-size: 0.7rem; + color: var(--ink-dim); + letter-spacing: 0.06em; + white-space: pre-line; + line-height: 1.45; +} + +.stage__controls { + display: flex; + gap: 0.85rem; + align-items: center; + pointer-events: auto; +} + +.inline { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.7rem; + color: var(--ink-dim); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.inline select { + width: auto; + min-width: 7rem; +} + +.checkbox input { + accent-color: var(--accent); +} + +/* --- recipe json ---------------------------------------------------------- */ + +.panel--right .panel__header { + flex-shrink: 0; +} + +.code { + flex: 1; + margin: 0; + padding: 0.85rem 1rem; + overflow: auto; + font-size: 0.68rem; + line-height: 1.45; + color: var(--ink); + white-space: pre-wrap; + word-break: break-word; +} + +/* --- responsive ----------------------------------------------------------- */ + +@media (max-width: 1100px) { + #app { + grid-template-columns: minmax(260px, 300px) 1fr; + } + + .panel--right { + display: none; + } +} + +@media (max-width: 760px) { + #app { + grid-template-columns: 1fr; + grid-template-rows: 45vh 1fr; + } + + .panel--left { + order: 2; + border-right: none; + border-top: 2px solid var(--border); + } + + .stage { + order: 1; + } +} diff --git a/tools/web/tsconfig.json b/tools/web/tsconfig.json new file mode 100644 index 0000000..f44685a --- /dev/null +++ b/tools/web/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@creator/*": ["../src/*"] + } + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/tools/web/vite.config.ts b/tools/web/vite.config.ts new file mode 100644 index 0000000..fa994cf --- /dev/null +++ b/tools/web/vite.config.ts @@ -0,0 +1,190 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { defineConfig, type Plugin } from "vite"; + +const root = fileURLToPath(new URL(".", import.meta.url)); +const toolsSrc = resolve(root, "../src"); +const toolsDir = resolve(root, ".."); +const workspaceRoot = resolve(root, "../.."); +const assetsDir = resolve(workspaceRoot, "assets/characters"); +const textureCli = resolve(toolsSrc, "texture/cli.ts"); + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolveBody, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolveBody(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); +} + +function runTextureCli(payload: unknown): Promise> { + return new Promise((resolvePromise, reject) => { + const child = spawn( + process.execPath, + ["--import", "tsx", textureCli], + { + cwd: toolsDir, + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (c: Buffer) => (stdout += c.toString())); + child.stderr.on("data", (c: Buffer) => (stderr += c.toString())); + child.on("error", reject); + child.on("close", (code) => { + const line = stdout.trim().split("\n").filter(Boolean).pop() ?? ""; + try { + const json = JSON.parse(line) as Record; + if (code !== 0 && !json["ok"]) { + reject(new Error(String(json["error"] ?? (stderr || `texture cli exited ${code}`)))); + return; + } + resolvePromise(json); + } catch { + reject(new Error(stderr || stdout || `texture cli exited ${code}`)); + } + }); + + child.stdin.write(JSON.stringify(payload)); + child.stdin.end(); + }); +} + +/** Dev-only APIs: save GLB, UV guide / Grok texture, serve character assets. */ +function creatorApiPlugin(): Plugin { + return { + name: "psx-creator-api", + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + // GET /assets-characters/ + if (req.method === "GET" && req.url?.startsWith("/assets-characters/")) { + const name = decodeURIComponent(req.url.slice("/assets-characters/".length).split("?")[0] ?? ""); + if (!name || name.includes("..") || name.includes("/")) { + res.statusCode = 400; + res.end("bad path"); + return; + } + try { + const bytes = await readFile(resolve(assetsDir, name)); + const ext = extname(name).toLowerCase(); + const mime = + ext === ".png" + ? "image/png" + : ext === ".jpg" || ext === ".jpeg" + ? "image/jpeg" + : ext === ".glb" + ? "model/gltf-binary" + : "application/octet-stream"; + res.statusCode = 200; + res.setHeader("Content-Type", mime); + res.setHeader("Cache-Control", "no-store"); + res.end(bytes); + } catch { + res.statusCode = 404; + res.end("not found"); + } + return; + } + + if (req.method !== "POST") { + next(); + return; + } + + try { + if (req.url === "/api/save") { + const raw = await readBody(req); + const payload = JSON.parse(raw.toString("utf8")) as { + id?: string; + recipe?: unknown; + glbBase64?: string; + }; + const id = payload.id?.replace(/[^a-z0-9_-]/gi, "") ?? ""; + if (!id || !payload.recipe || !payload.glbBase64) { + sendJson(res, 400, { error: "Expected { id, recipe, glbBase64 }" }); + return; + } + await mkdir(assetsDir, { recursive: true }); + const glbPath = resolve(assetsDir, `${id}.glb`); + const recipePath = resolve(assetsDir, `${id}.recipe.json`); + const glb = Buffer.from(payload.glbBase64, "base64"); + await writeFile(glbPath, glb); + await writeFile(recipePath, `${JSON.stringify(payload.recipe, null, 2)}\n`); + sendJson(res, 200, { ok: true, glb: glbPath, recipe: recipePath, bytes: glb.byteLength }); + return; + } + + if (req.url === "/api/texture") { + const raw = await readBody(req); + const payload = JSON.parse(raw.toString("utf8")) as { + recipe?: unknown; + mode?: "guide" | "texture"; + force?: boolean; + albedoBase64?: string; + }; + if (!payload.recipe) { + sendJson(res, 400, { error: "Expected { recipe, mode }" }); + return; + } + const result = await runTextureCli({ + recipe: payload.recipe, + mode: payload.mode === "texture" ? "texture" : "guide", + force: payload.force === true, + ...(payload.albedoBase64 ? { albedoBase64: payload.albedoBase64 } : {}), + }); + sendJson(res, result["ok"] ? 200 : 500, result); + return; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendJson(res, 500, { error: message }); + return; + } + + next(); + }); + }, + }; +} + +export default defineConfig({ + root, + plugins: [creatorApiPlugin()], + resolve: { + alias: { + "@creator": toolsSrc, + }, + }, + server: { + port: 5174, + open: false, + fs: { + allow: [workspaceRoot, dirname(toolsSrc)], + }, + }, + build: { + outDir: resolve(root, "dist"), + emptyOutDir: true, + target: "es2022", + }, + optimizeDeps: { + include: [ + "three", + "three/examples/jsm/loaders/GLTFLoader.js", + "three/examples/jsm/controls/OrbitControls.js", + ], + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..c838b3b --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": false, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + } +}