Initial public release of PSX Adventure Engine
Browser reference stack for PSX-era third-person adventure: fixed cameras, inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular character harness. Ships the Ashgrove Precinct Level 1 investigation demo with a full cast and nine linked rooms.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import type { RoomDef } from "@psx/shared";
|
||||
import { door, floor, prop, STONE, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* Evidence vault — Level 1 crime-scene payoff.
|
||||
*
|
||||
* Darker fog, Sgt. Marrow's body, and a journal that closes the demo loop.
|
||||
*/
|
||||
export const CELLAR: RoomDef = {
|
||||
id: "manor-cellar",
|
||||
displayName: "Ashgrove Precinct — Evidence Vault",
|
||||
fog: { color: 0x050608, near: 2, far: 12 },
|
||||
ambientLight: { color: 0x202830, intensity: 0.55 },
|
||||
lights: [
|
||||
{
|
||||
kind: "point",
|
||||
color: 0xff9040,
|
||||
intensity: 18,
|
||||
position: { x: 0.5, y: 2, z: 1 },
|
||||
distance: 10,
|
||||
},
|
||||
],
|
||||
geometry: [
|
||||
floor("floor", { x: 0, y: 0, z: 0 }, 4, 4),
|
||||
wall("wall-n", { x: 0, y: WALL_HALF, z: -4.15 }, { x: 4.2, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
|
||||
wall("wall-s", { x: 0, y: WALL_HALF, z: 4.15 }, { x: 4.2, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
|
||||
wall("wall-e", { x: 4.15, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 4 }, STONE),
|
||||
wall("wall-w-left", { x: -4.15, y: WALL_HALF, z: -2 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2 }, STONE),
|
||||
wall("wall-w-right", { x: -4.15, y: WALL_HALF, z: 2.2 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.8 }, STONE),
|
||||
prop("barrel-1", { x: 2.5, y: 0.5, z: -2.2 }, { x: 0.7, y: 1, z: 0.7 }, 0x4a3c2e),
|
||||
prop("barrel-2", { x: 3.1, y: 0.5, z: -1.4 }, { x: 0.65, y: 1, z: 0.65 }, 0x3e3428),
|
||||
prop("crate-stack", { x: -2.5, y: 0.5, z: -2.5 }, { x: 1.2, y: 1, z: 1 }, 0x55504a),
|
||||
prop("shelf", { x: 3.2, y: 1, z: 2 }, { x: 0.4, y: 2, z: 2 }, 0x3a3228),
|
||||
],
|
||||
cameraZones: [
|
||||
{
|
||||
id: "cellar",
|
||||
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 4, y: 2, z: 4 } },
|
||||
camera: {
|
||||
position: { x: -2.8, y: 2.8, z: 3.4 },
|
||||
lookAt: { x: 1, y: 0.8, z: -1 },
|
||||
fov: 58,
|
||||
trackPlayer: true,
|
||||
trackRadius: 1.5,
|
||||
},
|
||||
blend: { mode: "blend", duration: 0.5 },
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
interactables: [
|
||||
{
|
||||
id: "officer-journal",
|
||||
label: "Officer's Journal",
|
||||
position: { x: 1.2, y: 0.4, z: -1.5 },
|
||||
halfExtents: { x: 1.2, y: 1.1, z: 1.2 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
'"Day 4. The precinct is eating the map. Squad hall to corridor still holds. Vault does not. Kane was down here — clay on his shoes. If you find this — leave the brass key where it is and burn the rest." The rest of the page is wet.',
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "box", size: { x: 0.28, y: 0.04, z: 0.35 }, color: 0x5a4030 },
|
||||
},
|
||||
{
|
||||
id: "cellar-herb",
|
||||
label: "Green Herb",
|
||||
position: { x: -2.5, y: 0.3, z: 2.5 },
|
||||
halfExtents: { x: 1, y: 1, z: 1 },
|
||||
action: { kind: "pickup", itemId: "green-herb", quantity: 1 },
|
||||
visual: { kind: "box", size: { x: 0.22, y: 0.28, z: 0.22 }, color: 0x4a7f42 },
|
||||
},
|
||||
{
|
||||
id: "bloody-badge",
|
||||
label: "Badge Case",
|
||||
position: { x: 0.5, y: 0.3, z: -2.0 },
|
||||
halfExtents: { x: 1.0, y: 1.0, z: 1.0 },
|
||||
action: { kind: "pickup", itemId: "badge-case" },
|
||||
visual: { kind: "box", size: { x: 0.18, y: 0.04, z: 0.28 }, color: 0x2a2a30 },
|
||||
},
|
||||
{
|
||||
id: "examine-officer",
|
||||
label: "Sgt. Marrow",
|
||||
position: { x: 2.2, y: 0.7, z: -2.4 },
|
||||
halfExtents: { x: 1.2, y: 1.1, z: 1.2 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
"Uniform. Badge. Journal still in his hand. The only person who made it into evidence vault alone — and the only one who never left. His last note points back at Kane's coffee story.",
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "none" },
|
||||
},
|
||||
door({
|
||||
id: "door-cellar-to-kitchen",
|
||||
label: "Stairs Up",
|
||||
position: { x: -4, y: 1.1, z: 0 },
|
||||
targetRoomId: "manor-kitchen",
|
||||
spawnPointId: "from-cellar",
|
||||
}),
|
||||
],
|
||||
spawnPoints: [
|
||||
{ id: "from-kitchen", position: { x: -2.5, y: 0, z: 0 }, yaw: -Math.PI / 2 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RoomDef } from "@psx/shared";
|
||||
import { door, floor, STONE, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* Narrow corridor — valve puzzle opens the study.
|
||||
*
|
||||
* The north shutter is flag-gated geometry. Until `study-shutter-open` is set,
|
||||
* examining it explains the puzzle; after the valve turns, the shutter
|
||||
* despawns and the study door becomes active.
|
||||
*/
|
||||
export const CORRIDOR: RoomDef = {
|
||||
id: "manor-corridor",
|
||||
displayName: "Ashgrove Precinct — Corridor",
|
||||
fog: { color: 0x080a0e, near: 2.5, far: 14 },
|
||||
ambientLight: { color: 0x303848, intensity: 0.85 },
|
||||
lights: [
|
||||
{
|
||||
kind: "point",
|
||||
color: 0xa8c0ff,
|
||||
intensity: 22,
|
||||
position: { x: 0, y: 2.4, z: 0 },
|
||||
distance: 12,
|
||||
},
|
||||
],
|
||||
geometry: [
|
||||
floor("floor", { x: 0, y: 0, z: 0 }, 1.6, 5),
|
||||
wall("wall-w", { x: -1.75, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 5 }, STONE),
|
||||
wall("wall-e", { x: 1.75, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 5 }, STONE),
|
||||
wall("wall-s-left", { x: -1.05, y: WALL_HALF, z: 5.15 }, { x: 0.55, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-s-right", { x: 1.05, y: WALL_HALF, z: 5.15 }, { x: 0.55, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-n-left", { x: -1.05, y: WALL_HALF, z: -5.15 }, { x: 0.55, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-n-right", { x: 1.05, y: WALL_HALF, z: -5.15 }, { x: 0.55, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
{
|
||||
id: "study-shutter",
|
||||
position: { x: 0, y: WALL_HALF, z: -5 },
|
||||
visual: { kind: "box", size: { x: 1.4, y: 3, z: 0.2 }, color: 0x5c4a3a },
|
||||
colliders: [{ kind: "box", halfExtents: { x: 0.7, y: WALL_HALF, z: 0.1 } }],
|
||||
removeOnFlag: "study-shutter-open",
|
||||
},
|
||||
],
|
||||
cameraZones: [
|
||||
{
|
||||
id: "corridor",
|
||||
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 1.6, y: 2, z: 5 } },
|
||||
camera: {
|
||||
position: { x: 1.45, y: 2.9, z: 3.8 },
|
||||
lookAt: { x: 0, y: 1, z: -2 },
|
||||
fov: 62,
|
||||
},
|
||||
blend: { mode: "cut" },
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
interactables: [
|
||||
{
|
||||
id: "valve",
|
||||
label: "Valve",
|
||||
position: { x: 1.1, y: 1.1, z: -3.2 },
|
||||
halfExtents: { x: 1.2, y: 1.3, z: 1.2 },
|
||||
action: {
|
||||
kind: "useItem",
|
||||
acceptsItemId: "repaired-crank",
|
||||
onUseFlag: "study-shutter-open",
|
||||
text: "A valve stem with a square socket. Your fingers find no purchase — it needs a crank.",
|
||||
},
|
||||
visual: { kind: "box", size: { x: 0.3, y: 0.3, z: 0.15 }, color: 0x7a3b2e },
|
||||
},
|
||||
door({
|
||||
id: "door-corridor-to-gallery",
|
||||
label: "Squad Hall",
|
||||
position: { x: 0, y: 1.1, z: 5 },
|
||||
targetRoomId: "manor-gallery",
|
||||
spawnPointId: "from-corridor",
|
||||
}),
|
||||
{
|
||||
id: "study-shutter-examine",
|
||||
label: "Steel Shutter",
|
||||
position: { x: 0, y: 1.1, z: -4.6 },
|
||||
halfExtents: { x: 1.2, y: 1.4, z: 1 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text: "A steel shutter seals Records. Pipes along the wall lead to a valve — same hardware the night shift used as a lock.",
|
||||
},
|
||||
requiresFlags: ["!study-shutter-open"],
|
||||
repeatable: true,
|
||||
visual: { kind: "none" },
|
||||
},
|
||||
door({
|
||||
id: "door-corridor-to-study",
|
||||
label: "Records",
|
||||
position: { x: 0, y: 1.1, z: -5 },
|
||||
targetRoomId: "manor-study",
|
||||
spawnPointId: "from-corridor",
|
||||
requiresFlags: ["study-shutter-open"],
|
||||
visual: false,
|
||||
}),
|
||||
],
|
||||
spawnPoints: [
|
||||
{ id: "from-gallery", position: { x: 0, y: 0, z: 3.5 }, yaw: Math.PI },
|
||||
{ id: "from-study", position: { x: 0, y: 0, z: -3.5 }, yaw: 0 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { RoomDef } from "@psx/shared";
|
||||
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* Precinct lobby — campaign start.
|
||||
*
|
||||
* Reception desk, Ellis the night custodian, and doors into the squad hall
|
||||
* and witness lounge. Atmosphere first; the case opens one door at a time.
|
||||
*/
|
||||
export const FOYER: RoomDef = {
|
||||
id: "manor-foyer",
|
||||
displayName: "Ashgrove Precinct — Lobby",
|
||||
fog: { color: 0x0c0e12, near: 3, far: 18 },
|
||||
ambientLight: { color: 0x3a4050, intensity: 0.95 },
|
||||
lights: [
|
||||
{
|
||||
kind: "point",
|
||||
color: 0xffd9a0,
|
||||
intensity: 28,
|
||||
position: { x: 0, y: 2.5, z: 0 },
|
||||
distance: 14,
|
||||
},
|
||||
{
|
||||
kind: "directional",
|
||||
color: 0x304050,
|
||||
intensity: 0.4,
|
||||
position: { x: 2, y: 6, z: 4 },
|
||||
},
|
||||
],
|
||||
geometry: [
|
||||
floor("floor", { x: 0, y: 0, z: 0 }, 4.5, 4),
|
||||
wall("wall-s", { x: 0, y: WALL_HALF, z: 4.15 }, { x: 4.7, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-n-left", { x: -2.7, y: WALL_HALF, z: -4.15 }, { x: 1.6, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-n-right", { x: 2.7, y: WALL_HALF, z: -4.15 }, { x: 1.6, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
// East wall split for lounge door at z≈0
|
||||
wall("wall-e-north", { x: 4.65, y: WALL_HALF, z: -2.3 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.7 }, STONE),
|
||||
wall("wall-e-south", { x: 4.65, y: WALL_HALF, z: 2.3 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.7 }, STONE),
|
||||
wall("wall-w", { x: -4.65, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 4 }, STONE),
|
||||
prop("console", { x: -2.8, y: 0.45, z: 1.5 }, { x: 1.2, y: 0.9, z: 0.5 }, TIMBER),
|
||||
prop("bench", { x: 2.8, y: 0.3, z: 2.4 }, { x: 1.4, y: 0.6, z: 0.5 }, 0x3a3530),
|
||||
prop("umbrella-stand", { x: -3.4, y: 0.4, z: -2.6 }, { x: 0.35, y: 0.8, z: 0.35 }, 0x3a3530),
|
||||
],
|
||||
cameraZones: [
|
||||
{
|
||||
id: "foyer",
|
||||
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 4.5, y: 2, z: 4 } },
|
||||
camera: {
|
||||
position: { x: 3.6, y: 3.2, z: 3.6 },
|
||||
lookAt: { x: -0.5, y: 1, z: -1 },
|
||||
fov: 55,
|
||||
trackPlayer: true,
|
||||
trackRadius: 1.8,
|
||||
},
|
||||
blend: { mode: "cut" },
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
interactables: [
|
||||
{
|
||||
id: "foyer-plaque",
|
||||
label: "Brass Plaque",
|
||||
position: { x: -2.8, y: 1.2, z: 1.5 },
|
||||
halfExtents: { x: 1.1, y: 1.1, z: 1 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
'"Ashgrove Precinct — Est. 1891. Those who enter leave something of themselves behind." Someone scratched last night\'s date into the brass under the motto.',
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "box", size: { x: 0.4, y: 0.25, z: 0.04 }, color: 0xb0a060 },
|
||||
},
|
||||
{
|
||||
id: "duty-log",
|
||||
label: "Duty Log",
|
||||
position: { x: -2.8, y: 1.0, z: 1.1 },
|
||||
halfExtents: { x: 1.0, y: 1.0, z: 0.9 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
"Night board: REYES — office. MARROW — evidence vault. HALE — records (sealed). CIVILIANS: Voss / Kane — hold for interview. ELLIS — on grounds.",
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "box", size: { x: 0.35, y: 0.03, z: 0.28 }, color: 0xd8d0c0 },
|
||||
},
|
||||
{
|
||||
id: "talk-ellis",
|
||||
label: "Ellis",
|
||||
position: { x: 3.2, y: 0.7, z: -1.8 },
|
||||
halfExtents: { x: 1.2, y: 1.1, z: 1.2 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
'Ellis wipes grease off a ring of keys. "Night shift only. I mopped the lobby, then the upstairs hall went quiet. No shouting — just the lights flickering once. If you want the cellar, that key left the board years ago."',
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "none" },
|
||||
},
|
||||
door({
|
||||
id: "door-foyer-to-gallery",
|
||||
label: "Squad Hall",
|
||||
position: { x: 0, y: 1.1, z: -4 },
|
||||
targetRoomId: "manor-gallery",
|
||||
spawnPointId: "from-foyer",
|
||||
}),
|
||||
door({
|
||||
id: "door-foyer-to-lounge",
|
||||
label: "Witness Lounge",
|
||||
position: { x: 4.5, y: 1.1, z: 0 },
|
||||
targetRoomId: "precinct-lounge",
|
||||
spawnPointId: "from-foyer",
|
||||
}),
|
||||
],
|
||||
spawnPoints: [
|
||||
{ id: "start", position: { x: 0, y: 0, z: 2.5 }, yaw: Math.PI },
|
||||
{ id: "from-gallery", position: { x: 0, y: 0, z: -2.5 }, yaw: 0 },
|
||||
{ id: "from-lounge", position: { x: 2.8, y: 0, z: 0 }, yaw: Math.PI / 2 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { RoomDef, Vec3 } from "@psx/shared";
|
||||
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* Squad hall — hub of Level 1.
|
||||
*
|
||||
* Routes to corridor / kitchen / detective office / interview room.
|
||||
* Crank puzzle pieces still live here (records shutter).
|
||||
*/
|
||||
|
||||
const RAMP_POINTS: Vec3[] = [
|
||||
{ x: -1.4, y: 0, z: 1 },
|
||||
{ x: 1.4, y: 0, z: 1 },
|
||||
{ x: -1.4, y: 0, z: -1 },
|
||||
{ x: 1.4, y: 0, z: -1 },
|
||||
{ x: -1.4, y: 0.9, z: -1 },
|
||||
{ x: 1.4, y: 0.9, z: -1 },
|
||||
];
|
||||
|
||||
export const GALLERY: RoomDef = {
|
||||
id: "manor-gallery",
|
||||
displayName: "Ashgrove Precinct — Squad Hall",
|
||||
fog: { color: 0x0a0c10, near: 4, far: 24 },
|
||||
ambientLight: { color: 0x3a4050, intensity: 1 },
|
||||
lights: [
|
||||
{
|
||||
kind: "point",
|
||||
color: 0xffd9a0,
|
||||
intensity: 32,
|
||||
position: { x: 0, y: 2.6, z: 1 },
|
||||
distance: 16,
|
||||
},
|
||||
{
|
||||
kind: "directional",
|
||||
color: 0x404a60,
|
||||
intensity: 0.5,
|
||||
position: { x: 4, y: 8, z: 6 },
|
||||
},
|
||||
],
|
||||
geometry: [
|
||||
floor("floor", { x: 0, y: 0, z: 0 }, 6, 5),
|
||||
// South: lobby door gap at x=0
|
||||
wall("wall-s-left", { x: -3.5, y: WALL_HALF, z: 5.15 }, { x: 2.5, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-s-right", { x: 3.5, y: WALL_HALF, z: 5.15 }, { x: 2.5, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
// East: office door gap at z=0
|
||||
wall("wall-e-north", { x: 6.15, y: WALL_HALF, z: -2.8 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2.2 }),
|
||||
wall("wall-e-south", { x: 6.15, y: WALL_HALF, z: 2.8 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2.2 }),
|
||||
// West: interview door gap at z=0
|
||||
wall("wall-w-north", { x: -6.15, y: WALL_HALF, z: -2.8 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2.2 }, STONE),
|
||||
wall("wall-w-south", { x: -6.15, y: WALL_HALF, z: 2.8 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2.2 }, STONE),
|
||||
// North: corridor gap at x=0, kitchen gap at x=-3.3
|
||||
wall("wall-n-far-left", { x: -5.1, y: WALL_HALF, z: -5.15 }, { x: 0.9, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-n-between", { x: -1.65, y: WALL_HALF, z: -5.15 }, { x: 0.85, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-n-right", { x: 3.5, y: WALL_HALF, z: -5.15 }, { x: 2.5, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
{
|
||||
id: "low-step",
|
||||
position: { x: -3, y: 0.15, z: 2 },
|
||||
visual: { kind: "box", size: { x: 2, y: 0.3, z: 2 }, color: TIMBER },
|
||||
colliders: [{ kind: "box", halfExtents: { x: 1, y: 0.15, z: 1 } }],
|
||||
},
|
||||
prop("crate", { x: 3, y: 0.4, z: 1.5 }, { x: 0.8, y: 0.8, z: 0.8 }, TIMBER),
|
||||
prop("bullpen-desk", { x: -3.5, y: 0.4, z: -1.5 }, { x: 1.6, y: 0.8, z: 0.9 }, TIMBER),
|
||||
{
|
||||
id: "ramp",
|
||||
position: { x: 4.5, y: 0, z: -2 },
|
||||
visual: { kind: "hull", points: RAMP_POINTS, color: 0x55504a },
|
||||
colliders: [{ kind: "hull", points: RAMP_POINTS }],
|
||||
material: { friction: 0.9 },
|
||||
},
|
||||
{
|
||||
id: "ledge",
|
||||
position: { x: 4.5, y: 0.45, z: -3.6 },
|
||||
visual: { kind: "box", size: { x: 2.8, y: 0.9, z: 1.2 }, color: 0x55504a },
|
||||
colliders: [{ kind: "box", halfExtents: { x: 1.4, y: 0.45, z: 0.6 } }],
|
||||
},
|
||||
],
|
||||
cameraZones: [
|
||||
{
|
||||
id: "gallery",
|
||||
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 6, y: 2, z: 5 } },
|
||||
camera: {
|
||||
position: { x: 5.2, y: 3.4, z: 4.2 },
|
||||
lookAt: { x: -0.5, y: 1, z: -1 },
|
||||
fov: 55,
|
||||
trackPlayer: true,
|
||||
trackRadius: 2.2,
|
||||
},
|
||||
blend: { mode: "cut" },
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
interactables: [
|
||||
{
|
||||
id: "portrait",
|
||||
label: "Precinct Portrait",
|
||||
position: { x: -0.5, y: 1.4, z: 4.6 },
|
||||
halfExtents: { x: 1.1, y: 1.4, z: 1.2 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
"A water-damaged group photo of the night shift. One face has been scratched out — the same grey as the wall behind it.",
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "box", size: { x: 0.8, y: 1.0, z: 0.1 }, color: 0x2e2a26 },
|
||||
},
|
||||
{
|
||||
id: "evidence-board",
|
||||
label: "Hall Board",
|
||||
position: { x: -3.5, y: 1.2, z: -1.5 },
|
||||
halfExtents: { x: 1.2, y: 1.2, z: 1.1 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
"OPEN ROOMS: Lobby · Lounge · Interview · Detective Office · Corridor. SEALED: Records (valve) · Break Room (brass key) · Vault (cellar key). Question the living. Read the dead.",
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "box", size: { x: 1.2, y: 0.9, z: 0.06 }, color: 0x3a3830 },
|
||||
},
|
||||
{
|
||||
id: "crank-handle-pickup",
|
||||
label: "Crank Handle",
|
||||
position: { x: 3, y: 0.95, z: 1.5 },
|
||||
halfExtents: { x: 1.1, y: 1.1, z: 1.1 },
|
||||
action: { kind: "pickup", itemId: "crank-handle" },
|
||||
visual: { kind: "box", size: { x: 0.35, y: 0.1, z: 0.1 }, color: 0x8a7f6d },
|
||||
},
|
||||
{
|
||||
id: "broken-shaft-pickup",
|
||||
label: "Broken Shaft",
|
||||
position: { x: 4.5, y: 1.05, z: -3.6 },
|
||||
halfExtents: { x: 1.2, y: 1.1, z: 1 },
|
||||
action: { kind: "pickup", itemId: "broken-shaft" },
|
||||
visual: { kind: "box", size: { x: 0.1, y: 0.1, z: 0.5 }, color: 0x6d6a63 },
|
||||
},
|
||||
door({
|
||||
id: "door-gallery-to-foyer",
|
||||
label: "Lobby",
|
||||
position: { x: 0, y: 1.1, z: 5 },
|
||||
targetRoomId: "manor-foyer",
|
||||
spawnPointId: "from-gallery",
|
||||
}),
|
||||
door({
|
||||
id: "door-gallery-to-corridor",
|
||||
label: "Corridor",
|
||||
position: { x: 0, y: 1.1, z: -5 },
|
||||
targetRoomId: "manor-corridor",
|
||||
spawnPointId: "from-gallery",
|
||||
}),
|
||||
door({
|
||||
id: "door-gallery-to-kitchen",
|
||||
label: "Break Room",
|
||||
position: { x: -3.3, y: 1.1, z: -5 },
|
||||
targetRoomId: "manor-kitchen",
|
||||
spawnPointId: "from-gallery",
|
||||
requiresItemId: "brass-key",
|
||||
}),
|
||||
door({
|
||||
id: "door-gallery-to-office",
|
||||
label: "Detective Office",
|
||||
position: { x: 6, y: 1.1, z: 0 },
|
||||
targetRoomId: "precinct-office",
|
||||
spawnPointId: "from-gallery",
|
||||
}),
|
||||
door({
|
||||
id: "door-gallery-to-interview",
|
||||
label: "Interview Room",
|
||||
position: { x: -6, y: 1.1, z: 0 },
|
||||
targetRoomId: "precinct-interview",
|
||||
spawnPointId: "from-gallery",
|
||||
}),
|
||||
],
|
||||
spawnPoints: [
|
||||
{ id: "from-foyer", position: { x: 0, y: 0, z: 3.5 }, yaw: Math.PI },
|
||||
{ id: "from-corridor", position: { x: 0, y: 0, z: -3.5 }, yaw: 0 },
|
||||
{ id: "from-kitchen", position: { x: -3.3, y: 0, z: -3.5 }, yaw: 0 },
|
||||
{ id: "from-office", position: { x: 4.2, y: 0, z: 0 }, yaw: Math.PI / 2 },
|
||||
{ id: "from-interview", position: { x: -4.2, y: 0, z: 0 }, yaw: -Math.PI / 2 },
|
||||
{ id: "start", position: { x: 0, y: 0, z: 3.2 }, yaw: Math.PI },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { InteractableDef, StaticGeometryDef, Vec3 } from "@psx/shared";
|
||||
|
||||
/** Shared materials for Ashgrove Precinct. */
|
||||
export const STONE = 0x4a4f57;
|
||||
export const PLASTER = 0x6b6357;
|
||||
export const TIMBER = 0x4a3c2e;
|
||||
export const FLOOR_COLOR = 0x3b3f45;
|
||||
export const DOOR_COLOR = 0x5c4a3a;
|
||||
export const METAL = 0x6a6e74;
|
||||
|
||||
export const WALL_HALF = 1.5;
|
||||
export const WALL_THICKNESS = 0.15;
|
||||
export const FLOOR_HALF = 0.15;
|
||||
|
||||
/** Wall slab: one box visual, one box collider. */
|
||||
export function wall(
|
||||
id: string,
|
||||
center: Vec3,
|
||||
halfExtents: Vec3,
|
||||
color = PLASTER,
|
||||
): StaticGeometryDef {
|
||||
return {
|
||||
id,
|
||||
position: center,
|
||||
visual: {
|
||||
kind: "box",
|
||||
size: { x: halfExtents.x * 2, y: halfExtents.y * 2, z: halfExtents.z * 2 },
|
||||
color,
|
||||
},
|
||||
colliders: [{ kind: "box", halfExtents }],
|
||||
material: { friction: 0.7 },
|
||||
};
|
||||
}
|
||||
|
||||
export function floor(id: string, center: Vec3, halfX: number, halfZ: number): StaticGeometryDef {
|
||||
return wall(
|
||||
id,
|
||||
{ x: center.x, y: -FLOOR_HALF, z: center.z },
|
||||
{ x: halfX, y: FLOOR_HALF, z: halfZ },
|
||||
FLOOR_COLOR,
|
||||
);
|
||||
}
|
||||
|
||||
/** Box prop with matching collider. */
|
||||
export function prop(
|
||||
id: string,
|
||||
center: Vec3,
|
||||
size: Vec3,
|
||||
color: number,
|
||||
): StaticGeometryDef {
|
||||
return {
|
||||
id,
|
||||
position: center,
|
||||
visual: { kind: "box", size, color },
|
||||
colliders: [
|
||||
{
|
||||
kind: "box",
|
||||
halfExtents: { x: size.x / 2, y: size.y / 2, z: size.z / 2 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Door interactable — walk up and press E to transition rooms. */
|
||||
export function door(options: {
|
||||
id: string;
|
||||
label: string;
|
||||
position: Vec3;
|
||||
targetRoomId: string;
|
||||
spawnPointId: string;
|
||||
requiresItemId?: string;
|
||||
requiresFlags?: string[];
|
||||
/** Visual slab; omit for an invisible trigger (e.g. open archway). */
|
||||
visual?: boolean;
|
||||
yawFacing?: number;
|
||||
}): InteractableDef {
|
||||
return {
|
||||
id: options.id,
|
||||
label: options.label,
|
||||
position: options.position,
|
||||
halfExtents: { x: 1.1, y: 1.4, z: 1.1 },
|
||||
action: {
|
||||
kind: "door",
|
||||
targetRoomId: options.targetRoomId,
|
||||
spawnPointId: options.spawnPointId,
|
||||
...(options.requiresItemId ? { requiresItemId: options.requiresItemId } : {}),
|
||||
},
|
||||
...(options.requiresFlags ? { requiresFlags: options.requiresFlags } : {}),
|
||||
repeatable: true,
|
||||
visual:
|
||||
options.visual === false
|
||||
? { kind: "none" }
|
||||
: { kind: "box", size: { x: 1.2, y: 2.2, z: 0.12 }, color: DOOR_COLOR },
|
||||
};
|
||||
}
|
||||
|
||||
/** Sealed passage shown only while a flag is *not* set. */
|
||||
export function sealedPassage(options: {
|
||||
id: string;
|
||||
label: string;
|
||||
position: Vec3;
|
||||
text: string;
|
||||
closedFlag: string;
|
||||
}): InteractableDef {
|
||||
return {
|
||||
id: options.id,
|
||||
label: options.label,
|
||||
position: options.position,
|
||||
halfExtents: { x: 1.2, y: 1.4, z: 1.1 },
|
||||
action: { kind: "examine", text: options.text },
|
||||
requiresFlags: [`!${options.closedFlag}`],
|
||||
repeatable: true,
|
||||
visual: { kind: "box", size: { x: 1.4, y: 2.4, z: 0.18 }, color: 0x5c4a3a },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { RoomDef } from "@psx/shared";
|
||||
import { CELLAR } from "./cellar.js";
|
||||
import { CORRIDOR } from "./corridor.js";
|
||||
import { FOYER } from "./foyer.js";
|
||||
import { GALLERY } from "./gallery.js";
|
||||
import { INTERVIEW } from "./interview.js";
|
||||
import { KITCHEN } from "./kitchen.js";
|
||||
import { LOUNGE } from "./lounge.js";
|
||||
import { OFFICE } from "./office.js";
|
||||
import { STUDY } from "./study.js";
|
||||
|
||||
/**
|
||||
* Ashgrove Precinct — Level 1 building (all cast NPCs).
|
||||
*
|
||||
* Lobby (Ellis) ⇄ Lounge (Kane)
|
||||
* ↓
|
||||
* Squad Hall ⇄ Office (Reyes) ⇄ Interview (Voss)
|
||||
* ↓ ↓ (brass key)
|
||||
* Corridor → Records (Hale) Break Room → Vault (Marrow)
|
||||
*/
|
||||
export const ROOM_CATALOGUE: ReadonlyMap<string, RoomDef> = new Map(
|
||||
[FOYER, GALLERY, CORRIDOR, STUDY, KITCHEN, CELLAR, OFFICE, INTERVIEW, LOUNGE].map(
|
||||
(room) => [room.id, room],
|
||||
),
|
||||
);
|
||||
|
||||
export const START_ROOM = FOYER;
|
||||
export const START_SPAWN = "start";
|
||||
|
||||
export function getRoom(id: string): RoomDef | undefined {
|
||||
return ROOM_CATALOGUE.get(id);
|
||||
}
|
||||
|
||||
export {
|
||||
FOYER,
|
||||
GALLERY,
|
||||
CORRIDOR,
|
||||
STUDY,
|
||||
KITCHEN,
|
||||
CELLAR,
|
||||
OFFICE,
|
||||
INTERVIEW,
|
||||
LOUNGE,
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { RoomDef } from "@psx/shared";
|
||||
import { door, floor, prop, STONE, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
|
||||
|
||||
const METAL_TABLE = 0x5a6068;
|
||||
|
||||
/**
|
||||
* Interview room — Nora Voss (civilian-f).
|
||||
*
|
||||
* Two-way glass, metal table, classic interrogation staging. Free access from
|
||||
* the squad hall so the player can start gathering testimony immediately.
|
||||
*/
|
||||
export const INTERVIEW: RoomDef = {
|
||||
id: "precinct-interview",
|
||||
displayName: "Ashgrove Precinct — Interview Room",
|
||||
fog: { color: 0x0a0c10, near: 2.5, far: 12 },
|
||||
ambientLight: { color: 0x384048, intensity: 0.9 },
|
||||
lights: [
|
||||
{
|
||||
kind: "point",
|
||||
color: 0xc8d0e0,
|
||||
intensity: 24,
|
||||
position: { x: 0, y: 2.5, z: 0 },
|
||||
distance: 11,
|
||||
},
|
||||
],
|
||||
geometry: [
|
||||
floor("floor", { x: 0, y: 0, z: 0 }, 3.2, 3.2),
|
||||
wall("wall-n", { x: 0, y: WALL_HALF, z: -3.35 }, { x: 3.4, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
|
||||
wall("wall-s-left", { x: -1.9, y: WALL_HALF, z: 3.35 }, { x: 1.1, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-s-right", { x: 1.9, y: WALL_HALF, z: 3.35 }, { x: 1.1, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-e", { x: 3.35, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.2 }, STONE),
|
||||
wall("wall-w", { x: -3.35, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.2 }, STONE),
|
||||
prop("table", { x: 0, y: 0.4, z: -0.4 }, { x: 1.6, y: 0.8, z: 0.9 }, METAL_TABLE),
|
||||
prop("chair-suspect", { x: 0, y: 0.35, z: -1.5 }, { x: 0.45, y: 0.7, z: 0.45 }, 0x3a3a40),
|
||||
prop("chair-cop", { x: 0, y: 0.35, z: 0.7 }, { x: 0.45, y: 0.7, z: 0.45 }, 0x3a3a40),
|
||||
prop("mirror-slab", { x: -3.15, y: 1.3, z: 0 }, { x: 0.08, y: 1.4, z: 2.2 }, 0x6a7888),
|
||||
],
|
||||
cameraZones: [
|
||||
{
|
||||
id: "interview",
|
||||
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 3.2, y: 2, z: 3.2 } },
|
||||
camera: {
|
||||
position: { x: 2.6, y: 2.8, z: 2.8 },
|
||||
lookAt: { x: 0, y: 1.0, z: -0.6 },
|
||||
fov: 58,
|
||||
trackPlayer: true,
|
||||
trackRadius: 1.2,
|
||||
},
|
||||
blend: { mode: "cut" },
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
interactables: [
|
||||
{
|
||||
id: "two-way-glass",
|
||||
label: "Two-Way Glass",
|
||||
position: { x: -3.0, y: 1.3, z: 0 },
|
||||
halfExtents: { x: 1.1, y: 1.3, z: 1.4 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
"Your reflection sits where the observer should. Someone watched this room last night — a coffee ring on the sill, still sticky.",
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "none" },
|
||||
},
|
||||
{
|
||||
id: "statement-pad",
|
||||
label: "Statement Pad",
|
||||
position: { x: 0, y: 0.95, z: -0.4 },
|
||||
halfExtents: { x: 1.1, y: 1.0, z: 1.0 },
|
||||
action: { kind: "pickup", itemId: "witness-statement" },
|
||||
visual: { kind: "box", size: { x: 0.28, y: 0.02, z: 0.36 }, color: 0xf0e8d8 },
|
||||
},
|
||||
{
|
||||
id: "talk-nora",
|
||||
label: "Nora Voss",
|
||||
position: { x: 1.6, y: 0.7, z: -1.2 },
|
||||
halfExtents: { x: 1.3, y: 1.2, z: 1.3 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
'"I was in the lounge. Kane said he was getting coffee — he was gone too long. When he came back he smelled like the cellar, not the kitchen. That\'s all I\'m saying without counsel." Her hands shake, but her eyes stay fixed on the two-way glass.',
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "none" },
|
||||
},
|
||||
door({
|
||||
id: "door-interview-to-gallery",
|
||||
label: "Squad Hall",
|
||||
position: { x: 0, y: 1.1, z: 3.2 },
|
||||
targetRoomId: "manor-gallery",
|
||||
spawnPointId: "from-interview",
|
||||
}),
|
||||
],
|
||||
spawnPoints: [
|
||||
{ id: "from-gallery", position: { x: 0, y: 0, z: 2.0 }, yaw: Math.PI },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { RoomDef } from "@psx/shared";
|
||||
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* Break room — opened with the brass key from Records.
|
||||
*
|
||||
* Cellar key and ammo live here; door to the evidence vault is locked until
|
||||
* that key is held.
|
||||
*/
|
||||
export const KITCHEN: RoomDef = {
|
||||
id: "manor-kitchen",
|
||||
displayName: "Ashgrove Precinct — Break Room",
|
||||
fog: { color: 0x0e100c, near: 3, far: 16 },
|
||||
ambientLight: { color: 0x404838, intensity: 0.9 },
|
||||
lights: [
|
||||
{
|
||||
kind: "point",
|
||||
color: 0xffb070,
|
||||
intensity: 26,
|
||||
position: { x: 0, y: 2.4, z: 0 },
|
||||
distance: 12,
|
||||
},
|
||||
],
|
||||
geometry: [
|
||||
floor("floor", { x: 0, y: 0, z: 0 }, 4, 3.5),
|
||||
wall("wall-n", { x: 0, y: WALL_HALF, z: -3.65 }, { x: 4.2, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
|
||||
wall("wall-s-left", { x: -2.4, y: WALL_HALF, z: 3.65 }, { x: 1.4, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-s-right", { x: 2.4, y: WALL_HALF, z: 3.65 }, { x: 1.4, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-e", { x: 4.15, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.5 }),
|
||||
wall("wall-w-left", { x: -4.15, y: WALL_HALF, z: 1.5 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 2 }),
|
||||
wall("wall-w-right", { x: -4.15, y: WALL_HALF, z: -1.8 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.5 }),
|
||||
prop("counter", { x: 2.5, y: 0.45, z: -1.5 }, { x: 2.2, y: 0.9, z: 0.7 }, TIMBER),
|
||||
prop("stove", { x: -2.2, y: 0.4, z: -2 }, { x: 1, y: 0.8, z: 0.8 }, 0x3a3a40),
|
||||
prop("table", { x: 0, y: 0.4, z: 1 }, { x: 1.4, y: 0.8, z: 0.9 }, 0x5a4a38),
|
||||
],
|
||||
cameraZones: [
|
||||
{
|
||||
id: "kitchen",
|
||||
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 4, y: 2, z: 3.5 } },
|
||||
camera: {
|
||||
position: { x: 3.2, y: 3.1, z: 3 },
|
||||
lookAt: { x: -0.5, y: 1, z: -1 },
|
||||
fov: 58,
|
||||
trackPlayer: true,
|
||||
trackRadius: 1.6,
|
||||
},
|
||||
blend: { mode: "cut" },
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
interactables: [
|
||||
{
|
||||
id: "cellar-key-pickup",
|
||||
label: "Cellar Key",
|
||||
position: { x: 2.5, y: 1, z: -1.5 },
|
||||
halfExtents: { x: 1.1, y: 1.1, z: 1 },
|
||||
action: { kind: "pickup", itemId: "cellar-key" },
|
||||
visual: { kind: "box", size: { x: 0.12, y: 0.04, z: 0.3 }, color: 0x8a8070 },
|
||||
},
|
||||
{
|
||||
id: "ammo-pickup",
|
||||
label: "Handgun Rounds",
|
||||
position: { x: -2.2, y: 0.95, z: -2 },
|
||||
halfExtents: { x: 1, y: 1.1, z: 1 },
|
||||
action: { kind: "pickup", itemId: "handgun-rounds", quantity: 12 },
|
||||
visual: { kind: "box", size: { x: 0.2, y: 0.12, z: 0.15 }, color: 0xc9a227 },
|
||||
},
|
||||
{
|
||||
id: "kitchen-sink",
|
||||
label: "Sink",
|
||||
position: { x: 2.5, y: 1, z: -1.2 },
|
||||
halfExtents: { x: 1, y: 1, z: 0.8 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text: "Rust-stained porcelain. The tap still drips, one beat every few seconds. Red clay rings the drain — same clay as the vault stairs.",
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "none" },
|
||||
},
|
||||
door({
|
||||
id: "door-kitchen-to-gallery",
|
||||
label: "Squad Hall",
|
||||
position: { x: 0, y: 1.1, z: 3.5 },
|
||||
targetRoomId: "manor-gallery",
|
||||
spawnPointId: "from-kitchen",
|
||||
}),
|
||||
door({
|
||||
id: "door-kitchen-to-cellar",
|
||||
label: "Evidence Vault",
|
||||
position: { x: -4, y: 1.1, z: 0 },
|
||||
targetRoomId: "manor-cellar",
|
||||
spawnPointId: "from-kitchen",
|
||||
requiresItemId: "cellar-key",
|
||||
}),
|
||||
],
|
||||
spawnPoints: [
|
||||
{ id: "from-gallery", position: { x: 0, y: 0, z: 2 }, yaw: Math.PI },
|
||||
{ id: "from-cellar", position: { x: -2.5, y: 0, z: 0 }, yaw: Math.PI / 2 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AMBIENT_PLACEMENTS,
|
||||
CHARACTER_CATALOGUE,
|
||||
LEVEL1_NPC_ROOM_IDS,
|
||||
PLAYER_CHARACTER_ID,
|
||||
} from "../characters.js";
|
||||
import { ROOM_CATALOGUE } from "./index.js";
|
||||
|
||||
/**
|
||||
* Level 1 contract: every non-player cast member has a dedicated room inside
|
||||
* the precinct building, and every placement points at a real catalogue room.
|
||||
*/
|
||||
describe("Ashgrove Precinct Level 1", () => {
|
||||
it("catalogues a room for every ambient NPC", () => {
|
||||
const ambientIds = [...CHARACTER_CATALOGUE.keys()].filter(
|
||||
(id) => id !== PLAYER_CHARACTER_ID,
|
||||
);
|
||||
expect(ambientIds.length).toBeGreaterThanOrEqual(6);
|
||||
|
||||
for (const id of ambientIds) {
|
||||
const roomId = LEVEL1_NPC_ROOM_IDS.get(id);
|
||||
expect(roomId, `${id} needs a Level-1 room`).toBeDefined();
|
||||
expect(ROOM_CATALOGUE.has(roomId!), `${id} room ${roomId} missing`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("places each ambient NPC in a unique room", () => {
|
||||
const rooms = AMBIENT_PLACEMENTS.map((p) => p.roomId);
|
||||
expect(new Set(rooms).size).toBe(rooms.length);
|
||||
});
|
||||
|
||||
it("wires door targets only to known rooms with valid spawns", () => {
|
||||
for (const room of ROOM_CATALOGUE.values()) {
|
||||
for (const interactable of room.interactables) {
|
||||
if (interactable.action.kind !== "door") continue;
|
||||
const { targetRoomId, spawnPointId } = interactable.action;
|
||||
expect(ROOM_CATALOGUE.has(targetRoomId), `${room.id} → ${targetRoomId}`).toBe(true);
|
||||
const dest = ROOM_CATALOGUE.get(targetRoomId)!;
|
||||
const hasSpawn = dest.spawnPoints.some((point) => point.id === spawnPointId);
|
||||
expect(
|
||||
hasSpawn,
|
||||
`${room.id} door ${interactable.id} spawn ${spawnPointId} missing on ${targetRoomId}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every ambient placement a matching examine interactable", () => {
|
||||
for (const placement of AMBIENT_PLACEMENTS) {
|
||||
expect(placement.examine).toBeDefined();
|
||||
const room = ROOM_CATALOGUE.get(placement.roomId);
|
||||
expect(room).toBeDefined();
|
||||
const talk = room!.interactables.find((entry) => entry.id === placement.examine!.id);
|
||||
expect(talk, `${placement.characterId} examine ${placement.examine!.id}`).toBeDefined();
|
||||
expect(talk!.action.kind).toBe("examine");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { RoomDef } from "@psx/shared";
|
||||
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* Witness lounge — Garvey Kane (civilian-m).
|
||||
*
|
||||
* Soft chairs, cold coffee, the alibi stage. Accessed from the lobby so the
|
||||
* player meets a witness before the squad hall opens the full case board.
|
||||
*/
|
||||
export const LOUNGE: RoomDef = {
|
||||
id: "precinct-lounge",
|
||||
displayName: "Ashgrove Precinct — Witness Lounge",
|
||||
fog: { color: 0x0e1014, near: 3, far: 14 },
|
||||
ambientLight: { color: 0x404848, intensity: 0.95 },
|
||||
lights: [
|
||||
{
|
||||
kind: "point",
|
||||
color: 0xffc090,
|
||||
intensity: 22,
|
||||
position: { x: 0, y: 2.3, z: 0.5 },
|
||||
distance: 12,
|
||||
},
|
||||
{
|
||||
kind: "directional",
|
||||
color: 0x304050,
|
||||
intensity: 0.35,
|
||||
position: { x: -2, y: 5, z: 3 },
|
||||
},
|
||||
],
|
||||
geometry: [
|
||||
floor("floor", { x: 0, y: 0, z: 0 }, 3.8, 3.5),
|
||||
wall("wall-n", { x: 0, y: WALL_HALF, z: -3.65 }, { x: 4.0, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
|
||||
wall("wall-s", { x: 0, y: WALL_HALF, z: 3.65 }, { x: 4.0, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-e-left", { x: 3.95, y: WALL_HALF, z: -2.0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.5 }),
|
||||
wall("wall-e-right", { x: 3.95, y: WALL_HALF, z: 2.0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 1.5 }),
|
||||
wall("wall-w", { x: -3.95, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.5 }, STONE),
|
||||
prop("sofa", { x: -2.0, y: 0.35, z: 1.6 }, { x: 2.0, y: 0.7, z: 0.8 }, 0x4a3c48),
|
||||
prop("coffee-table", { x: -1.6, y: 0.25, z: 0.2 }, { x: 1.2, y: 0.5, z: 0.7 }, TIMBER),
|
||||
prop("sideboard", { x: 2.6, y: 0.5, z: -2.2 }, { x: 1.4, y: 1.0, z: 0.5 }, 0x3e3830),
|
||||
prop("plant", { x: 2.8, y: 0.5, z: 2.2 }, { x: 0.4, y: 1.0, z: 0.4 }, 0x3a5a38),
|
||||
],
|
||||
cameraZones: [
|
||||
{
|
||||
id: "lounge",
|
||||
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 3.8, y: 2, z: 3.5 } },
|
||||
camera: {
|
||||
position: { x: 3.2, y: 2.9, z: 2.8 },
|
||||
lookAt: { x: -1.0, y: 1.0, z: 0.2 },
|
||||
fov: 56,
|
||||
trackPlayer: true,
|
||||
trackRadius: 1.5,
|
||||
},
|
||||
blend: { mode: "blend", duration: 0.4 },
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
interactables: [
|
||||
{
|
||||
id: "cold-coffee",
|
||||
label: "Coffee Cup",
|
||||
position: { x: -1.6, y: 0.6, z: 0.2 },
|
||||
halfExtents: { x: 1.0, y: 1.0, z: 1.0 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
"Two cups. One lipstick-smudged, empty. One black, half full, cold — and a smear of red clay on the saucer that matches the vault stairs.",
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "box", size: { x: 0.12, y: 0.14, z: 0.12 }, color: 0xd8d0c4 },
|
||||
},
|
||||
{
|
||||
id: "magazine-stack",
|
||||
label: "Magazines",
|
||||
position: { x: 2.6, y: 1.1, z: -2.2 },
|
||||
halfExtents: { x: 1.1, y: 1.1, z: 1.0 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
"Last month's city paper. Ashgrove Station renovation delayed. Someone circled the cellar blueprint in ballpoint.",
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "box", size: { x: 0.35, y: 0.04, z: 0.28 }, color: 0xc0a878 },
|
||||
},
|
||||
{
|
||||
id: "talk-garvey",
|
||||
label: "Garvey Kane",
|
||||
position: { x: -2.2, y: 0.7, z: 1.4 },
|
||||
halfExtents: { x: 1.3, y: 1.2, z: 1.3 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
'Kane straightens his tie. "I never went downstairs. Nora is scared and inventing stairs. I sat here, I drank cold coffee, I heard the shutter in the corridor grind. Ask Marrow — if he ever comes up from evidence."',
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "none" },
|
||||
},
|
||||
door({
|
||||
id: "door-lounge-to-foyer",
|
||||
label: "Lobby",
|
||||
position: { x: 3.8, y: 1.1, z: 0 },
|
||||
targetRoomId: "manor-foyer",
|
||||
spawnPointId: "from-lounge",
|
||||
}),
|
||||
],
|
||||
spawnPoints: [
|
||||
{ id: "from-foyer", position: { x: 2.4, y: 0, z: 0 }, yaw: Math.PI / 2 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { RoomDef } from "@psx/shared";
|
||||
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* Detective office — Det. Reyes (Olivia).
|
||||
*
|
||||
* Case board and partner briefing. Open from the squad hall; sets the
|
||||
* investigation tone before the player fans out to witnesses.
|
||||
*/
|
||||
export const OFFICE: RoomDef = {
|
||||
id: "precinct-office",
|
||||
displayName: "Ashgrove Precinct — Detective Office",
|
||||
fog: { color: 0x0c1016, near: 3, far: 16 },
|
||||
ambientLight: { color: 0x3a4250, intensity: 1 },
|
||||
lights: [
|
||||
{
|
||||
kind: "point",
|
||||
color: 0xffd0a0,
|
||||
intensity: 28,
|
||||
position: { x: -1.2, y: 2.4, z: -0.5 },
|
||||
distance: 12,
|
||||
},
|
||||
{
|
||||
kind: "point",
|
||||
color: 0x7080a0,
|
||||
intensity: 10,
|
||||
position: { x: 2, y: 2.2, z: 2 },
|
||||
distance: 9,
|
||||
},
|
||||
],
|
||||
geometry: [
|
||||
floor("floor", { x: 0, y: 0, z: 0 }, 4, 3.5),
|
||||
wall("wall-n", { x: 0, y: WALL_HALF, z: -3.65 }, { x: 4.2, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
|
||||
wall("wall-s-left", { x: -2.4, y: WALL_HALF, z: 3.65 }, { x: 1.4, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-s-right", { x: 2.4, y: WALL_HALF, z: 3.65 }, { x: 1.4, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-e", { x: 4.15, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.5 }, STONE),
|
||||
wall("wall-w", { x: -4.15, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 3.5 }, STONE),
|
||||
prop("desk", { x: -2.2, y: 0.4, z: -1.2 }, { x: 1.8, y: 0.8, z: 1.0 }, TIMBER),
|
||||
prop("case-board", { x: 3.4, y: 1.2, z: -1.5 }, { x: 0.12, y: 1.8, z: 2.4 }, 0x2a2824),
|
||||
prop("filing", { x: 3.2, y: 0.7, z: 2 }, { x: 0.7, y: 1.4, z: 0.9 }, 0x4a4e54),
|
||||
prop("chair", { x: -2.2, y: 0.35, z: 0.2 }, { x: 0.5, y: 0.7, z: 0.5 }, 0x3a3530),
|
||||
],
|
||||
cameraZones: [
|
||||
{
|
||||
id: "office",
|
||||
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 4, y: 2, z: 3.5 } },
|
||||
camera: {
|
||||
position: { x: 3.0, y: 3.0, z: 3.0 },
|
||||
lookAt: { x: -1.2, y: 1.1, z: -1 },
|
||||
fov: 56,
|
||||
trackPlayer: true,
|
||||
trackRadius: 1.6,
|
||||
},
|
||||
blend: { mode: "cut" },
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
interactables: [
|
||||
{
|
||||
id: "case-board",
|
||||
label: "Case Board",
|
||||
position: { x: 3.2, y: 1.3, z: -1.5 },
|
||||
halfExtents: { x: 1.2, y: 1.4, z: 1.4 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
"ASHGROVE — NIGHT SHIFT. Red string pins Lobby → Records → Vault. Three living witnesses. One body in evidence. Kane's timeline is marked with a question; Voss's with a star.",
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "none" },
|
||||
},
|
||||
{
|
||||
id: "partner-notes",
|
||||
label: "Partner Notes",
|
||||
position: { x: -2.2, y: 0.95, z: -1.2 },
|
||||
halfExtents: { x: 1.2, y: 1.1, z: 1.1 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
"Reyes' handwriting: \"Question everyone. Touch nothing in the vault until Marrow's journal is read. The crank pieces still open the records shutter — old building, old puzzles.\"",
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "box", size: { x: 0.32, y: 0.02, z: 0.26 }, color: 0xe8dcc8 },
|
||||
},
|
||||
{
|
||||
id: "talk-olivia",
|
||||
label: "Det. Reyes",
|
||||
position: { x: -2.4, y: 0.7, z: -1.6 },
|
||||
halfExtents: { x: 1.3, y: 1.2, z: 1.3 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
'Reyes doesn\'t look up from the case board. "Victim was last seen leaving the records wing. Interview Voss and Kane first — their stories don\'t line up. Hale\'s notes are sealed until you turn the corridor valve. I\'ll hold the line here."',
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "none" },
|
||||
},
|
||||
door({
|
||||
id: "door-office-to-gallery",
|
||||
label: "Squad Hall",
|
||||
position: { x: 0, y: 1.1, z: 3.5 },
|
||||
targetRoomId: "manor-gallery",
|
||||
spawnPointId: "from-office",
|
||||
}),
|
||||
],
|
||||
spawnPoints: [
|
||||
{ id: "from-gallery", position: { x: 0, y: 0, z: 2.2 }, yaw: Math.PI },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { RoomDef } from "@psx/shared";
|
||||
import { door, floor, prop, STONE, TIMBER, wall, WALL_HALF, WALL_THICKNESS } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* Records office — payoff room after the valve puzzle.
|
||||
*
|
||||
* Dr. Hale waits here with the brass key to the break-room wing.
|
||||
*/
|
||||
export const STUDY: RoomDef = {
|
||||
id: "manor-study",
|
||||
displayName: "Ashgrove Precinct — Records",
|
||||
fog: { color: 0x0c1014, near: 3.5, far: 18 },
|
||||
ambientLight: { color: 0x3a4050, intensity: 1 },
|
||||
lights: [
|
||||
{
|
||||
kind: "point",
|
||||
color: 0xffc98a,
|
||||
intensity: 30,
|
||||
position: { x: -1, y: 2.5, z: -1 },
|
||||
distance: 14,
|
||||
},
|
||||
{
|
||||
kind: "point",
|
||||
color: 0x8090b0,
|
||||
intensity: 12,
|
||||
position: { x: 2, y: 2.2, z: 2 },
|
||||
distance: 10,
|
||||
},
|
||||
],
|
||||
geometry: [
|
||||
floor("floor", { x: 0, y: 0, z: 0 }, 4.5, 4),
|
||||
wall("wall-s-left", { x: -2.7, y: WALL_HALF, z: 4.15 }, { x: 1.5, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-s-right", { x: 2.7, y: WALL_HALF, z: 4.15 }, { x: 1.5, y: WALL_HALF, z: WALL_THICKNESS }),
|
||||
wall("wall-n", { x: 0, y: WALL_HALF, z: -4.15 }, { x: 4.65, y: WALL_HALF, z: WALL_THICKNESS }, STONE),
|
||||
wall("wall-w", { x: -4.65, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 4 }, STONE),
|
||||
wall("wall-e", { x: 4.65, y: WALL_HALF, z: 0 }, { x: WALL_THICKNESS, y: WALL_HALF, z: 4 }, STONE),
|
||||
prop("desk", { x: -2.5, y: 0.4, z: 0 }, { x: 1.6, y: 0.8, z: 0.9 }, TIMBER),
|
||||
prop("bookshelf", { x: 3.5, y: 1.1, z: -2 }, { x: 0.5, y: 2.2, z: 2.4 }, 0x3a3228),
|
||||
prop("chair", { x: -2.5, y: 0.35, z: 1.2 }, { x: 0.5, y: 0.7, z: 0.5 }, 0x4a4034),
|
||||
],
|
||||
cameraZones: [
|
||||
{
|
||||
id: "study",
|
||||
trigger: { center: { x: 0, y: 1.5, z: 0 }, halfExtents: { x: 4.5, y: 2, z: 4 } },
|
||||
camera: {
|
||||
position: { x: 3.6, y: 3.4, z: 3.2 },
|
||||
lookAt: { x: -1.5, y: 1, z: -1 },
|
||||
fov: 55,
|
||||
trackPlayer: true,
|
||||
trackRadius: 2.2,
|
||||
},
|
||||
blend: { mode: "blend", duration: 0.45 },
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
interactables: [
|
||||
{
|
||||
id: "photograph-pickup",
|
||||
label: "Torn Photograph",
|
||||
position: { x: -2.5, y: 0.95, z: 0 },
|
||||
halfExtents: { x: 1.3, y: 1.1, z: 1.1 },
|
||||
action: { kind: "pickup", itemId: "torn-photograph" },
|
||||
visual: { kind: "box", size: { x: 0.3, y: 0.02, z: 0.24 }, color: 0xd8cdb8 },
|
||||
},
|
||||
{
|
||||
id: "herb-pickup",
|
||||
label: "Green Herb",
|
||||
position: { x: 2.5, y: 0.3, z: -2.8 },
|
||||
halfExtents: { x: 1, y: 1.1, z: 1 },
|
||||
action: { kind: "pickup", itemId: "green-herb", quantity: 2 },
|
||||
visual: { kind: "box", size: { x: 0.25, y: 0.3, z: 0.25 }, color: 0x4a7f42 },
|
||||
},
|
||||
{
|
||||
id: "brass-key-pickup",
|
||||
label: "Brass Key",
|
||||
position: { x: 3.2, y: 0.5, z: -2 },
|
||||
halfExtents: { x: 1, y: 1.1, z: 1 },
|
||||
action: { kind: "pickup", itemId: "brass-key" },
|
||||
visual: { kind: "box", size: { x: 0.15, y: 0.05, z: 0.35 }, color: 0xc9a227 },
|
||||
},
|
||||
{
|
||||
id: "study-notes",
|
||||
label: "Research Notes",
|
||||
position: { x: -2.5, y: 0.95, z: -0.4 },
|
||||
halfExtents: { x: 1.1, y: 1, z: 1 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text: '"Subject still responds to the vault air. Do not open evidence alone. Kane\'s clay sample matches the stair risers." The handwriting trails into a smear.',
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "box", size: { x: 0.35, y: 0.02, z: 0.28 }, color: 0xe8dcc8 },
|
||||
},
|
||||
{
|
||||
id: "talk-hale",
|
||||
label: "Dr. Hale",
|
||||
position: { x: -3.0, y: 0.7, z: -2.2 },
|
||||
halfExtents: { x: 1.2, y: 1.1, z: 1.2 },
|
||||
action: {
|
||||
kind: "examine",
|
||||
text:
|
||||
'Dr. Hale keeps one glove on. "The sample from the vault reacts to the manor\'s lower air — not poison, not mould. Something that writes itself into the floorplan. The brass key on my desk opens the break-room wing. Don\'t go alone."',
|
||||
},
|
||||
repeatable: true,
|
||||
visual: { kind: "none" },
|
||||
},
|
||||
door({
|
||||
id: "door-study-to-corridor",
|
||||
label: "Corridor",
|
||||
position: { x: 0, y: 1.1, z: 4 },
|
||||
targetRoomId: "manor-corridor",
|
||||
spawnPointId: "from-study",
|
||||
}),
|
||||
],
|
||||
spawnPoints: [
|
||||
{ id: "from-corridor", position: { x: 0, y: 0, z: 2.5 }, yaw: Math.PI },
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user