310 lines
15 KiB
Markdown
310 lines
15 KiB
Markdown
# MRP3GI — Specification & Execution Plan v1.0
|
||
|
||
**For the implementing agent:** this document is the contract. Work the
|
||
milestones in order; each has acceptance criteria you can verify yourself
|
||
(headless where possible). When this spec and convenience disagree, the
|
||
spec wins. When the spec is silent, follow the Architecture Invariants.
|
||
|
||
---
|
||
|
||
## 1. What this is
|
||
|
||
MRP3GI renders classic Sierra-style adventure games in **3D** (three.js,
|
||
rigged GLB characters, real camera) while the entire game — rooms, verbs,
|
||
hotspots, dialogue, flags, score, timers, death, saves — runs inside
|
||
**`mrpci-core`**, the existing Rust SCI-generation engine, compiled to
|
||
WASM. This is the Grim Fandango model: 3D presentation over a 2D brain.
|
||
|
||
Why this shape:
|
||
|
||
- `mrpci-core` is already headless, deterministic (fixed 30Hz ticks, seeded
|
||
RNG, no wall clock), and driven entirely by a JSON Command/Event bus. Its
|
||
native-only deps (`tiny_http`) are already fenced behind
|
||
`cfg(not(target_arch = "wasm32"))`. It was built for this.
|
||
- Every MRPCI game (starting with the Neon Precinct demo) becomes a 3D game
|
||
for free, and every future MRP3GI game is *also* playable in the 2D
|
||
MRPCI GUI, testable over MCP, and replayable in CI.
|
||
- The sim's coordinate space is 2D (x 0..320, y 0..190, y = depth). Actors
|
||
walk on a ground plane. That is exactly what a 3D adventure needs: the
|
||
renderer maps sim (x, y) → world (X, Z) and stands a model there.
|
||
|
||
## 2. Architecture invariants (do not violate)
|
||
|
||
1. **No game rules in TypeScript.** If it changes game state, it is a
|
||
`Command` into the core. If the player learns something, it arrived as
|
||
an `Event`. The TS layer may keep *presentation* state only (camera,
|
||
animation phase, UI open/closed).
|
||
2. **The bridge surface is JSON strings + byte buffers.** No fine-grained
|
||
wasm-bindgen types for game objects — the wire format is the same one
|
||
the JSONL/HTTP/MCP surfaces already speak (see `../mrpci/docs/CONTROL.md`).
|
||
3. **Determinism is a feature.** The golden-replay test (M1) must pass on
|
||
every commit: the WASM core, fed the canonical playthrough script,
|
||
produces byte-identical event JSON to the native binary.
|
||
4. **`mrpci-core` is consumed, not forked.** Path dependency `../mrpci`.
|
||
If the core needs a change (it will — see §4.1), make it in the mrpci
|
||
repo, keep it native-compatible, run mrpci's own tests, commit there.
|
||
5. **60fps presentation, 30Hz sim.** The renderer interpolates actor
|
||
positions between sim ticks; it never runs the sim faster to look
|
||
smoother.
|
||
|
||
## 3. Repo layout
|
||
|
||
```
|
||
mrp3gi/
|
||
SPEC.md # this file
|
||
README.md
|
||
package.json # vite + typescript + three + vitest
|
||
vite.config.ts
|
||
tsconfig.json # strict: true
|
||
bridge/ # Rust: the WASM story core
|
||
Cargo.toml # crate mrp3gi-bridge; dep mrpci-core = { path = "../../mrpci/mrpci-core" }
|
||
src/lib.rs # wasm-bindgen exports (§4)
|
||
src/
|
||
main.ts # boot: load bundle, start loop
|
||
bus.ts # typed Command/Event wrappers over the bridge
|
||
world3d.ts # scene graph: set, ground, lights, camera rig
|
||
mapping.ts # sim-space ↔ world-space (§5)
|
||
actors.ts # GLB registry, AnimationMixer state machines (§7)
|
||
sets.ts # backdrop + control-screen extrusion (§6)
|
||
picking.ts # raycast → sim coords → VerbAt/MoveTo
|
||
ui.ts # HTML overlay: verb bar, dialogue, inventory, log
|
||
audio.ts # WebAudio sink for cue/music events (§8)
|
||
interp.ts # per-actor position/facing interpolation
|
||
public/
|
||
models/ # GLBs (robot.glb first; from the MeshGod/Mixamo pipeline)
|
||
games/neon-precinct.bundle.json # generated (§4.2)
|
||
tests/
|
||
golden.test.ts # determinism replay vs recorded native events
|
||
mapping.test.ts # coordinate round-trips
|
||
scripts/
|
||
make-bundle.mjs # game folder → bundle JSON (calls native headless)
|
||
record-golden.mjs # regenerates tests/golden.events.jsonl from native
|
||
```
|
||
|
||
## 4. The WASM bridge
|
||
|
||
### 4.1 Prerequisite changes in `../mrpci` (small, do these first)
|
||
|
||
1. **`Event` needs `Deserialize`** (tests compare parsed events) — add
|
||
`Deserialize` to the derive on `Event` and its payload structs, or keep
|
||
comparison as raw strings (acceptable; then skip this).
|
||
2. **Bundle must carry everything a filesystem-less build needs.**
|
||
`WorldBundle` already carries manifest + rooms + scripts. Add
|
||
`sprites: HashMap<String, (u32 w, u32 h, Vec<u8> rgba)>` (serde with
|
||
base64 or plain array) populated by `World::to_bundle()`, and make
|
||
`World::from_memory`/`apply_bundle` accept them. Background `pics/`
|
||
PNGs may be skipped in v1 — Neon Precinct is fully procedural.
|
||
3. **Expose a screens accessor** if not public already: the bridge needs
|
||
`visual/priority/control/hotspot` slices plus `render_indices()` and
|
||
`effective_palette()` (both exist).
|
||
4. Confirm `rhai` builds for `wasm32-unknown-unknown` with the existing
|
||
`["sync", "no_time"]` features (it does; `no_time` exists precisely for
|
||
wasm). `image` crate is PNG-only and wasm-safe.
|
||
|
||
### 4.2 Bundle format
|
||
|
||
`scripts/make-bundle.mjs` runs the native `mrpci-headless` (build it from
|
||
`../mrpci`) with a new tiny flag to add there: `--export-bundle out.json`
|
||
(serializes `world.to_bundle()`, including sprites per 4.1.2). The bundle
|
||
is committed under `public/games/` so the web app needs no Rust at runtime.
|
||
|
||
### 4.3 `bridge/src/lib.rs` exports (complete list)
|
||
|
||
```rust
|
||
#[wasm_bindgen]
|
||
pub struct Engine { gs: GameState }
|
||
|
||
#[wasm_bindgen]
|
||
impl Engine {
|
||
/// Boot from a bundle JSON string. Never panics: bad JSON → Err(String).
|
||
#[wasm_bindgen(constructor)]
|
||
pub fn new(bundle_json: &str) -> Result<Engine, JsError>;
|
||
|
||
/// One Command JSON in → JSON array of Events out.
|
||
pub fn apply(&mut self, command_json: &str) -> String;
|
||
|
||
/// Wall-clock driver: accumulates dt (seconds) into fixed 30Hz cycles.
|
||
pub fn tick(&mut self, dt: f32) -> String; // events JSON
|
||
|
||
pub fn snapshot(&self) -> String; // StateSnapshot JSON
|
||
|
||
// --- presentation feeds (no JSON; raw buffers, zero-copy where easy) ---
|
||
pub fn render_indices(&self) -> Vec<u8>; // 320*190 palette indices (scene+weather)
|
||
pub fn effective_palette(&self) -> Vec<u8>; // 256*4 RGBA, cycle LUT applied
|
||
pub fn control_screen(&self) -> Vec<u8>; // walls/water (set extrusion + debug)
|
||
pub fn priority_screen(&self) -> Vec<u8>;
|
||
pub fn hotspot_screen(&self) -> Vec<u8>;
|
||
|
||
/// Actor transforms for the renderer, cheaper than full snapshots:
|
||
/// JSON [{name, x, y, dir, scale, walking, visible, talking}] — ego first.
|
||
pub fn actors(&self) -> String;
|
||
|
||
// --- audio content (bytes are WAV; browser decodes once, caches) ---
|
||
pub fn cue_wav(&self, name: &str) -> Vec<u8>;
|
||
pub fn music_wav(&self, mood: u8) -> Vec<u8>;
|
||
}
|
||
```
|
||
|
||
Notes:
|
||
- `actors()` needs a small core addition or can be assembled from
|
||
`snapshot()` + ego fields; prefer a dedicated method (cheap, per-frame).
|
||
Include NPCs and the ego with their current `dir` and `walking` flags —
|
||
the animation state machine keys off these.
|
||
- Build with `wasm-pack build bridge --target web --out-dir ../src/wasm`.
|
||
Commit nothing from `src/wasm` (gitignore); `npm run bridge` regenerates.
|
||
|
||
## 5. Coordinate mapping (`mapping.ts`)
|
||
|
||
- `PIC_W = 320`, `PIC_H = 190`, `UNITS_PER_PIXEL = 1/32`.
|
||
- Sim `(x, y)` → world `(X, 0, Z)`: `X = (x - 160) * UPP`,
|
||
`Z = (y - 190) * UPP` (so the room front edge is Z = 0, deeper rooms go
|
||
negative Z; +Y is up). Inverse for picking; round-trip test required.
|
||
- Character height: sim actors are ~36px tall ⇒ target GLB display height
|
||
`36 * UPP * heightScale(actor)`; the room `ScaleTable` is **ignored for
|
||
size** in 3D (real perspective does that job) but `scale()` still feeds
|
||
walk-speed in the core — leave the core alone.
|
||
- Camera rig per room (presentation data, §6.3): default is a SCI-style
|
||
fixed camera at `(0, 2.2, 1.8)` looking at `(0, 0.5, -2.2)`, vertical
|
||
FOV 50°. All values overridable per room.
|
||
|
||
## 6. Sets (three phases, shipped in this order)
|
||
|
||
### 6.1 Backdrop mode (M2)
|
||
The room's own 256-color frame becomes the set:
|
||
- Ground plane: `PIC_W*UPP × PIC_H*UPP`, textured with the
|
||
`render_indices` + palette composite (build an offscreen canvas texture;
|
||
`NearestFilter`, `SRGBColorSpace`). Rebuild the texture only when a
|
||
`room_changed` event arrives or every N frames while palette cycles run
|
||
(cheap: 60KB).
|
||
- Backdrop plane: same frame, standing at the room's far edge, so the
|
||
camera sees painted walls behind the 3D actors. Crude and charming; it
|
||
proves the whole pipe.
|
||
|
||
### 6.2 Extruded blockout (M4)
|
||
Generate set geometry from the invisible screens:
|
||
- March the **control screen**: cells with `CTL_BLOCK` become merged boxes
|
||
(greedy meshing, 4px grid resolution), height 1.4 world units, textured
|
||
by sampling the visual screen at the wall's base row. Water cells get a
|
||
translucent blue plane at y=0.02.
|
||
- The visual frame still textures the ground; walls now occlude actors in
|
||
real 3D — the priority screen's job, inherited by the depth buffer.
|
||
- Hotspot debug view (`?debug=hotspots`): tinted transparent prisms.
|
||
|
||
### 6.3 Authored sets + camera (M5)
|
||
`public/games/<game>.3d.json` (presentation-only, never in the core):
|
||
```json
|
||
{
|
||
"rooms": {
|
||
"0": {
|
||
"camera": { "pos": [0,2.2,1.8], "lookAt": [0,0.5,-2.2], "fov": 50 },
|
||
"set": { "glb": "models/sets/neon-row.glb" },
|
||
"lights": [ {"type":"point","pos":[1.2,1.5,-2],"color":"#ff8844","intensity":2} ],
|
||
"actors": { "vend-bot": "models/vendbot.glb" }
|
||
}
|
||
},
|
||
"actors": { "ego": "models/robot.glb", "sergeant": "models/sergeant.glb" }
|
||
}
|
||
```
|
||
Missing entries fall back to 6.2, then 6.1. A GLB set replaces extruded
|
||
geometry but the control screen **still owns collision** — sets are looks.
|
||
|
||
## 7. Actors (`actors.ts`)
|
||
|
||
- Loader: GLTFLoader + DRACO optional. Registry maps actor name → model
|
||
URL via `.3d.json`, falling back to `models/robot.glb`, falling back to
|
||
a capsule + name sprite (never crash on a missing model — placeholder
|
||
and console warn, MRPCI's no-Error-47 rule extends to assets).
|
||
- Clips: models follow the house Mixamo convention — clips named `idle`,
|
||
`walk`, `talk` (extras ignored in v1). State machine per actor:
|
||
`walking → walk`, `in_dialogue with this actor → talk`, else `idle`;
|
||
crossfade 0.15s.
|
||
- Facing: sim `dir` (1..8) → yaw; when walking, face the interpolated
|
||
velocity vector instead (smoother on A* paths); damp yaw at 10 rad/s.
|
||
- Interpolation (`interp.ts`): keep the last two sim positions per actor
|
||
with their tick stamps; render at `renderTime - one tick` with linear
|
||
interpolation. Teleports (place_ego, room change) snap: distance > 24px
|
||
in one tick ⇒ no lerp.
|
||
- Weather: `room_changed` carries the room; read `weather` from snapshot.
|
||
Rain/snow/embers as a THREE.Points system (~300 particles) matching the
|
||
core's look. Purely visual; do not consume core RNG (mirror of the
|
||
particles.rs rule).
|
||
|
||
## 8. UI & audio
|
||
|
||
- **Verb bar** (HTML/CSS overlay, not WebGL): WALK/LOOK/DO/TALK + BAG +
|
||
score + room name. Right-click cycles verbs. Number keys answer
|
||
dialogue. Enter opens the parser line → `{"cmd":"parse"}` — the text
|
||
parser ships in 3D too, because it's free.
|
||
- **Dialogue**: `dialogue_open` events render the classic window as HTML
|
||
(white, red border); choices clickable.
|
||
- **Picking**: raycast the ground plane + extruded walls + actor meshes.
|
||
Actor hit → `verb_at` at that actor's sim coords; ground hit → inverse
|
||
mapping → `move_to`/`verb_at`. Walls hit → the sim coords of the wall
|
||
base pixel (hotspots live on the control/hotspot screens there).
|
||
- **Audio**: on `audio`/`music` events, decode `cue_wav`/`music_wav` bytes
|
||
through WebAudio (decode once, cache buffers; music loops, ~0.13 gain).
|
||
Honor a mute toggle. Files under `<game>/sfx|music/` may override later
|
||
— v1 uses the synth only.
|
||
- **Transcript log**: last 3 lines, bottom overlay, fading.
|
||
|
||
## 9. Milestones & acceptance criteria
|
||
|
||
**M0 — scaffold.** vite+TS strict+three+vitest; `bridge/` compiles with
|
||
wasm-pack; `npm run bridge && npm run dev` shows a lit empty scene and an
|
||
FPS meter. CI script (plain `npm test`) green.
|
||
|
||
**M1 — the brain in the browser.** mrpci changes from §4.1 landed (in
|
||
`../mrpci`, its tests still green). Bundle exporter works; Engine boots
|
||
Neon Precinct in a vitest node environment; **golden test**: running
|
||
`tests/playthrough.jsonl` (copy the canonical one from the mrpci session
|
||
logs or re-record with `scripts/record-golden.mjs`) through the WASM
|
||
Engine produces event JSON byte-identical to `tests/golden.events.jsonl`
|
||
recorded from the native binary. This test is sacred; it runs forever.
|
||
|
||
**M2 — backdrop 3D.** Ground+backdrop textured from the live frame;
|
||
capsule actors move when you click; edge exits and the precinct door
|
||
change rooms (with a fade); verb bar works; you can play start-to-finish
|
||
(win 25/25) with capsules. Weather points visible in rooms 0 and 2.
|
||
|
||
**M3 — rigged robots.** `robot.glb` (produce with the existing
|
||
MeshGod/Mixamo pipeline; idle/walk/talk clips) replaces the ego capsule;
|
||
NPC models or recolored fallbacks for sergeant/vend-bot; animation state
|
||
machine + interpolation + facing damping feel right at 60fps; dialogue UI
|
||
styled; full playthrough again, now looking like a game.
|
||
|
||
**M4 — extruded sets + lighting.** Control-screen greedy meshing; walls
|
||
occlude actors; per-room ambient+key light defaults derived from the
|
||
room's palette average; `?debug=` views for control/hotspot/priority.
|
||
|
||
**M5 — authored polish + ship.** `.3d.json` camera/lights/models per
|
||
room for Neon Precinct; save/restore UI over core SaveGame/RestoreGame
|
||
(slots in the core's save dir don't exist on web — add bridge methods
|
||
`save_json()`/`restore_json(s)` wrapping SaveData to/from localStorage);
|
||
`npm run build` produces a static bundle; deploy per the fleet deploy-map
|
||
(games VPS / partly.party, dockerized like the other web games). Run
|
||
`/ship-check` before exposing.
|
||
|
||
## 10. Testing bar
|
||
|
||
- `golden.test.ts` — determinism (M1, forever).
|
||
- `mapping.test.ts` — sim↔world round-trips, edge pixels.
|
||
- A `smoke.test.ts` that boots Engine, ticks 300, asserts no `error`
|
||
events from scripts and a stable snapshot shape.
|
||
- Visual checks: puppeteer screenshot script (`npm run shot -- --room 2`)
|
||
for eyeballing; not CI-gating in v1.
|
||
|
||
## 11. Out of scope for v1 (do not build yet)
|
||
|
||
Free camera / player-relative controls; physics; navmesh (the control
|
||
screen *is* the navmesh); shadows beyond a cheap blob; mobile controls;
|
||
multiplayer; asset streaming; a TS reimplementation of anything the core
|
||
does.
|
||
|
||
## 12. Open questions (decide during M3, note decisions in this file)
|
||
|
||
- Lip-flap/talk loop timing from transcript length?
|
||
- Palette-cycling neon in 3D: emissive planes sampling the cycled palette
|
||
vs. baked flipbook — try emissive planes first.
|
||
- Whether extruded walls should sample the *backdrop* texture region
|
||
instead of base-row color (probably yes, later).
|