otherplane 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CLAUDE.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/bin/otherplane.mjs +489 -0
  5. package/engine/eslint.config.mjs +25 -0
  6. package/engine/next.config.ts +43 -0
  7. package/engine/package-lock.json +6848 -0
  8. package/engine/package.json +36 -0
  9. package/engine/postcss.config.mjs +5 -0
  10. package/engine/src/app/LandingRedirect.tsx +15 -0
  11. package/engine/src/app/[room]/RoomViewer.tsx +413 -0
  12. package/engine/src/app/[room]/page.tsx +30 -0
  13. package/engine/src/app/favicon.ico +0 -0
  14. package/engine/src/app/layout.tsx +45 -0
  15. package/engine/src/app/page.tsx +11 -0
  16. package/engine/src/app/providers.tsx +22 -0
  17. package/engine/src/components/controls/MobileHud.tsx +25 -0
  18. package/engine/src/components/controls/PlayerController.tsx +170 -0
  19. package/engine/src/components/controls/TouchLookController.tsx +93 -0
  20. package/engine/src/components/controls/VirtualStick.tsx +153 -0
  21. package/engine/src/components/edit/EditCapture.tsx +182 -0
  22. package/engine/src/components/edit/EditorPanel.tsx +265 -0
  23. package/engine/src/components/edit/Markers.tsx +91 -0
  24. package/engine/src/components/hud/Button.tsx +228 -0
  25. package/engine/src/components/hud/ClickToPlay.tsx +13 -0
  26. package/engine/src/components/hud/ContentOverlay.tsx +44 -0
  27. package/engine/src/components/hud/NavHeader.module.css +24 -0
  28. package/engine/src/components/scene/Artifacts.tsx +85 -0
  29. package/engine/src/components/scene/Exits.tsx +92 -0
  30. package/engine/src/components/scene/PointerLockBridge.tsx +28 -0
  31. package/engine/src/components/scene/WorldScene.tsx +164 -0
  32. package/engine/src/components/spark/SparkLayer.tsx +112 -0
  33. package/engine/src/components/spark/SplatWorld.tsx +156 -0
  34. package/engine/src/config/audio.ts +11 -0
  35. package/engine/src/data/editApi.ts +73 -0
  36. package/engine/src/data/presets.ts +34 -0
  37. package/engine/src/data/room.ts +100 -0
  38. package/engine/src/data/site.ts +50 -0
  39. package/engine/src/data/universeconfig.ts +19 -0
  40. package/engine/src/icons/ArrowLeft.tsx +20 -0
  41. package/engine/src/icons/ChevronDown.tsx +23 -0
  42. package/engine/src/icons/ChevronLeft.tsx +22 -0
  43. package/engine/src/icons/Home.tsx +22 -0
  44. package/engine/src/icons/Spinner.module.css +13 -0
  45. package/engine/src/icons/Spinner.tsx +28 -0
  46. package/engine/src/icons/VolumeMax.tsx +21 -0
  47. package/engine/src/icons/VolumeX.tsx +22 -0
  48. package/engine/src/icons/icons.interface.ts +7 -0
  49. package/engine/src/icons/index.ts +27 -0
  50. package/engine/src/physics/RapierProvider.tsx +302 -0
  51. package/engine/src/physics/index.ts +2 -0
  52. package/engine/src/physics/types.ts +9 -0
  53. package/engine/src/providers/audio.tsx +215 -0
  54. package/engine/src/providers/edit.tsx +357 -0
  55. package/engine/src/providers/pointerLock.tsx +88 -0
  56. package/engine/src/styles/globals.css +88 -0
  57. package/engine/tailwind.config.js +184 -0
  58. package/engine/tsconfig.json +27 -0
  59. package/otherplane.config.example.json +6 -0
  60. package/package.json +56 -0
  61. package/schema/room.schema.json +77 -0
  62. package/scripts/gen_world.py +147 -0
  63. package/skill.md +94 -0
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@otherplane/engine",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev --turbopack",
7
+ "edit": "NEXT_PUBLIC_EDIT_MODE=1 next dev --turbopack",
8
+ "build": "next build --turbopack",
9
+ "start": "next start",
10
+ "lint": "eslint"
11
+ },
12
+ "dependencies": {
13
+ "@dimforge/rapier3d-compat": "^0.12.0",
14
+ "@react-three/drei": "^10.7.6",
15
+ "@react-three/fiber": "^9.3.0",
16
+ "@react-three/rapier": "^2.1.0",
17
+ "@sparkjsdev/spark": "^0.1.8",
18
+ "@types/three": "^0.180.0",
19
+ "next": "15.5.3",
20
+ "react": "19.1.0",
21
+ "react-dom": "19.1.0",
22
+ "three": "^0.174.0",
23
+ "clsx": "^1.2.1"
24
+ },
25
+ "devDependencies": {
26
+ "@eslint/eslintrc": "^3",
27
+ "@tailwindcss/postcss": "^4",
28
+ "@types/node": "^20",
29
+ "@types/react": "^19",
30
+ "@types/react-dom": "^19",
31
+ "eslint": "^9",
32
+ "eslint-config-next": "15.5.3",
33
+ "tailwindcss": "^4",
34
+ "typescript": "^5"
35
+ }
36
+ }
@@ -0,0 +1,5 @@
1
+ const config = {
2
+ plugins: ["@tailwindcss/postcss"],
3
+ };
4
+
5
+ export default config;
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+
6
+ // Client redirect to the deploy's landing room. The room slug is resolved at
7
+ // build time (server) and passed in; the redirect itself must be client-side
8
+ // because a static export has no server to redirect at request time.
9
+ export default function LandingRedirect({ to }: { to: string }) {
10
+ const router = useRouter();
11
+ useEffect(() => {
12
+ if (to) router.replace(`/${to}`);
13
+ }, [router, to]);
14
+ return null;
15
+ }
@@ -0,0 +1,413 @@
1
+ 'use client';
2
+
3
+ // The room viewer. Renders the room named by the path (/<room>/), spawning the
4
+ // player at the entryway named by the URL fragment (/<room>/#<entryway>, default
5
+ // if absent). Exits are hyperlinks: same-origin links navigate client-side (smooth
6
+ // room-to-room); cross-origin (another museum) links do a full navigation. There
7
+ // is no manifest and no "museum" object — the graph is emergent from linked rooms.
8
+
9
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
10
+ import { useRouter, usePathname } from 'next/navigation';
11
+ import { RapierProvider } from '@/physics';
12
+ import { Spinner, VolumeMaxLine, VolumeXLine, HomeLine } from '@/icons';
13
+
14
+ import WorldScene from '@/components/scene/WorldScene';
15
+ import { usePointerLock } from '@/providers/pointerLock';
16
+ import { useAudio } from '@/providers/audio';
17
+ import { useEdit } from '@/providers/edit';
18
+ import EditorPanel from '@/components/edit/EditorPanel';
19
+ import {
20
+ loadRoom,
21
+ resolveEntryway,
22
+ entrywayIdFromHash,
23
+ type Room,
24
+ type Exit,
25
+ type Artifact,
26
+ } from '@/data/room';
27
+ import { roomToWorldDef } from '@/data/presets';
28
+ import { Reticle } from '@/components/hud/ClickToPlay';
29
+ import { IconButton } from '@/components/hud/Button';
30
+ import ContentOverlay from '@/components/hud/ContentOverlay';
31
+ import MobileHud from '@/components/controls/MobileHud';
32
+
33
+ const EMPTY_EXITS: Exit[] = [];
34
+ const EMPTY_ARTIFACTS: Artifact[] = [];
35
+
36
+ type Spawn = { pos: [number, number, number]; yaw: number; key: string } | null;
37
+
38
+ // The world renders immediately — the user is "in the room" on page load. But
39
+ // browsers require a user gesture before pointer-lock + audio can start, so the
40
+ // FIRST interaction the user makes anyway (any click, or the first WASD keypress)
41
+ // silently engages look controls and starts sound. No button, no prompt.
42
+ function ClickToEngage({ isLoading, loadError }: { isLoading: boolean; loadError?: string }) {
43
+ const { isLocked, lock } = usePointerLock();
44
+ const { init } = useAudio();
45
+ const blocked = isLocked || isLoading || !!loadError;
46
+
47
+ const engage = useCallback(async () => {
48
+ try { await init(); } catch (e) { console.error('Failed to initialize audio:', e); }
49
+ try {
50
+ if (typeof DeviceMotionEvent !== 'undefined' &&
51
+ // @ts-expect-error - iOS-specific
52
+ typeof DeviceMotionEvent.requestPermission === 'function') {
53
+ // @ts-expect-error - iOS-specific
54
+ await DeviceMotionEvent.requestPermission();
55
+ }
56
+ } catch (e) { console.log('Motion permission not available or denied:', e); }
57
+ lock({ unadjustedMovement: false });
58
+ }, [init, lock]);
59
+
60
+ // First keypress is a valid user gesture too — engage on it so movement keys
61
+ // double as the "enter" action and the mouse grabs without a separate click.
62
+ useEffect(() => {
63
+ if (blocked) return;
64
+ const onKey = () => { void engage(); };
65
+ window.addEventListener('keydown', onKey, { once: true });
66
+ return () => window.removeEventListener('keydown', onKey);
67
+ }, [blocked, engage]);
68
+
69
+ if (blocked) return null;
70
+
71
+ // Invisible full-screen catcher: the first click anywhere engages, no label.
72
+ return <button onClick={engage} aria-label="Enter room" className="absolute inset-0 z-20 bg-transparent" />;
73
+ }
74
+
75
+ // On-screen "Press E" hint, shown while playing and near an exit.
76
+ function ExitHint({ active }: { active: boolean }) {
77
+ const { isLocked } = usePointerLock();
78
+ if (!active || !isLocked) return null;
79
+ return (
80
+ <div className="absolute left-1/2 bottom-24 z-20 -translate-x-1/2 pointer-events-none select-none">
81
+ {/* Sharp-edged retro HUD prompt — monochrome pixel font, keycap glyph. */}
82
+ <div
83
+ className="flex items-center gap-2 bg-black/70 px-3 py-1.5 text-white/90 backdrop-blur-sm"
84
+ style={{ fontFamily: 'var(--font-retro)' }}
85
+ >
86
+ <span className="text-[8px] uppercase leading-none">Press</span>
87
+ <kbd className="grid h-5 min-w-[1.25rem] place-items-center border border-white/60 px-1 text-[10px] uppercase leading-none">
88
+ E
89
+ </kbd>
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ function RootUIOverlays({ covered, loadError }: { covered: boolean; loadError?: string }) {
96
+ const { isLocked, unlock } = usePointerLock();
97
+ const { muted, setMuted } = useAudio();
98
+ // Reticle + veil follow the blink (`covered`), not raw loading, so the crosshair
99
+ // stays hidden behind the black and reappears only once the room is revealed.
100
+ return (
101
+ <>
102
+ <Reticle visible={isLocked && !covered && !loadError} />
103
+ <IconButton
104
+ aria-label="Toggle volume"
105
+ onClick={() => setMuted(!muted)}
106
+ className="absolute top-4 right-4 sm:top-4 sm:right-4 max-sm:top-auto max-sm:bottom-4 z-10 stroke-secondary"
107
+ icon={muted ? <VolumeXLine /> : <VolumeMaxLine />}
108
+ />
109
+ {isLocked && (
110
+ <IconButton
111
+ aria-label="Exit play"
112
+ onClick={unlock}
113
+ className="absolute bottom-20 right-4 sm:hidden z-10 stroke-secondary"
114
+ icon={<HomeLine />}
115
+ />
116
+ )}
117
+ {/* Cinematic "doorway blink": a full-screen black div whose opacity ramps
118
+ 0→1→0, so it literally dims every pixel to black and back. Driven by
119
+ `covered` (see RoomViewer). The fade OUT is ~280ms — deliberately a hair
120
+ shorter than the 320ms swap gate, so the veil is fully opaque before the
121
+ new room is committed and nothing can flash through. Fade IN is a slower
122
+ ~420ms reveal. The <Canvas> stays mounted underneath, so pointer lock,
123
+ audio, and the GPU context persist across the swap. */}
124
+ <div
125
+ aria-hidden
126
+ className={`pointer-events-none absolute inset-0 z-10 bg-black transition-opacity ${
127
+ covered && !loadError
128
+ ? 'opacity-100 duration-[280ms] ease-in-out'
129
+ : 'opacity-0 duration-[420ms] ease-out'
130
+ }`}
131
+ />
132
+
133
+ {loadError && (
134
+ <div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm z-10">
135
+ <div className="flex flex-col items-center gap-4 p-6 rounded-xl bg-zinc-900/90 border border-zinc-800">
136
+ <div className="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center">
137
+ <span className="text-white text-sm font-bold">!</span>
138
+ </div>
139
+ <div className="text-center">
140
+ <p className="text-red-400 text-sm font-medium">Failed to load room</p>
141
+ <p className="text-zinc-400 text-xs mt-1 max-w-xs">{loadError}</p>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ )}
146
+ </>
147
+ );
148
+ }
149
+
150
+ // roomId is read from the URL via usePathname() — NOT a prop — because this
151
+ // component is mounted in the ROOT layout (app/layout.tsx), not the page. The
152
+ // root layout is the only layout that survives navigation between sibling
153
+ // dynamic-segment values (/study → /library); a layout INSIDE the [room] segment
154
+ // remounts when the param changes. Because this instance (and the <Canvas> +
155
+ // pointer lock it owns) is never unmounted across a room switch, usePathname()
156
+ // just re-renders it with the new slug, the load effect below re-runs, and the
157
+ // room swaps in place with the mouse still locked. (If this were a page-level
158
+ // prop, the page subtree would remount on every router.push and the browser
159
+ // would drop pointer lock with the removed canvas element.)
160
+ export default function RoomViewer() {
161
+ const router = useRouter();
162
+ const pathname = usePathname();
163
+ // Routes are a single segment: /<room>/. Take the first path part as the slug.
164
+ // Off a room route (e.g. "/" before the landing redirect, or Next-internal
165
+ // paths like "/_not-found") there is no room — render nothing and let the page
166
+ // handle it. Reserved segments starting with "_" are ignored.
167
+ const seg = (pathname ?? '').split('/').filter(Boolean)[0] ?? '';
168
+ const roomId = seg.startsWith('_') ? '' : seg;
169
+
170
+ const [room, setRoom] = useState<Room | null>(null);
171
+ // The id the loaded `room` belongs to. Tracked separately from the `roomId`
172
+ // prop so that, mid-transition, `world` is built from the room we're actually
173
+ // still rendering (the old one) — never the new id paired with stale data.
174
+ const [loadedId, setLoadedId] = useState<string | null>(null);
175
+ const [spawn, setSpawn] = useState<Spawn>(null);
176
+ const [roomError, setRoomError] = useState<string | undefined>();
177
+ const [isLoading, setIsLoading] = useState(true);
178
+ const [loadError, setLoadError] = useState<string | undefined>();
179
+ const [exitActive, setExitActive] = useState(false);
180
+ const [artifactActive, setArtifactActive] = useState(false);
181
+ const [overlayUrl, setOverlayUrl] = useState<string | null>(null);
182
+ const mobileInputRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
183
+ const { setMusic } = useAudio();
184
+ const { editMode, seed: seedEdit, scale: editScale, moveSpeed } = useEdit();
185
+
186
+ // Client-only gate. This viewer is hosted in the ROOT layout, which is
187
+ // prerendered for every route during static export — but it renders nothing
188
+ // meaningful on the server (no window, no splat, no pointer lock). Holding the
189
+ // first client render to match the server's (null) avoids a hydration mismatch;
190
+ // everything real mounts on the effect tick.
191
+ const [mounted, setMounted] = useState(false);
192
+ useEffect(() => { setMounted(true); }, []);
193
+
194
+ // ---- Cinematic "doorway blink" ----
195
+ // The black veil's opacity is driven by `covered`. The key to a smooth, glitch-
196
+ // free transition is that the room SWAP is GATED on the veil being fully black:
197
+ // the OLD world dims to black first, the NEW world is committed only behind an
198
+ // opaque veil (see the load effect), then we fade back in. Binding the veil to
199
+ // raw isLoading instead let the incoming splat finish mid-fade and flash its
200
+ // brightest parts through the half-transparent veil.
201
+ const FADE_OUT_MS = 320; // gate: wait this long (veil fully black) before swapping
202
+ const HOLD_MS = 140; // min extra black hold after the new splat is ready
203
+ const [covered, setCovered] = useState(true); // start covered for the cold load
204
+ const coverStartRef = useRef(0);
205
+ const loadedIdRef = useRef<string | null>(null); // committed room id, sync (no stale dep)
206
+
207
+ // Load the room for the current path id, and resolve the spawn entryway from the
208
+ // URL fragment (#entryway). Re-runs whenever the room id changes.
209
+ //
210
+ // We do NOT null the old room first. Room-to-room moves are same-origin soft
211
+ // navigations (router.push, no document reload), so the <Canvas> below stays
212
+ // mounted the whole time — which means the WebGL context, the audio, and the
213
+ // pointer lock all survive the swap. Keeping the old room rendered until the
214
+ // new one's data is in flips the room over in a single atomic state update
215
+ // (room + id + spawn together) with no teardown, so the player never loses
216
+ // mouselook and resumes walking the instant the new collider loads. The brief
217
+ // splat/collider load is hidden by the cross-fade veil, not a re-engage gate.
218
+ useEffect(() => {
219
+ if (!roomId) return;
220
+ let cancelled = false;
221
+ setRoomError(undefined);
222
+ // Begin the blink: dim the CURRENT world to black, and mark loading so the
223
+ // reveal waits for the NEW splat (not the old one that's still on screen).
224
+ setCovered(true);
225
+ setIsLoading(true);
226
+ coverStartRef.current = performance.now();
227
+ const hadWorld = loadedIdRef.current !== null; // cold start has nothing to fade
228
+
229
+ (async () => {
230
+ let r: Room;
231
+ try {
232
+ r = await loadRoom(`/rooms/${roomId}/room.json`);
233
+ } catch (e) {
234
+ if (cancelled) return;
235
+ console.error('Room load failed:', e);
236
+ setRoomError(e instanceof Error ? e.message : String(e));
237
+ return;
238
+ }
239
+ if (cancelled) return;
240
+ // Gate the swap on the fade-to-black having fully finished, so the new room
241
+ // mounts behind an opaque veil and can never flash through it. (Skip on cold
242
+ // start — there's no old world to fade, just the loading screen.)
243
+ const elapsed = performance.now() - coverStartRef.current;
244
+ if (hadWorld && elapsed < FADE_OUT_MS) {
245
+ await new Promise((res) => setTimeout(res, FADE_OUT_MS - elapsed));
246
+ if (cancelled) return;
247
+ }
248
+ const ew = resolveEntryway(r, entrywayIdFromHash(window.location.hash));
249
+ setRoom(r);
250
+ setLoadedId(roomId);
251
+ loadedIdRef.current = roomId;
252
+ setSpawn(ew ? { pos: ew.pos, yaw: ew.yaw, key: `${roomId}#${ew.id}` } : null);
253
+ })();
254
+ return () => { cancelled = true; };
255
+ }, [roomId]);
256
+
257
+ // Reveal: once the committed room matches the URL AND its splat has finished
258
+ // loading (behind the black), hold briefly then fade the veil back in. The
259
+ // `loadedId === roomId` gate keeps us from revealing the OLD world during the
260
+ // fade-out, and the manual setIsLoading(true) above keeps us from revealing in
261
+ // the stale-false window right after the swap, before the new splat reports.
262
+ useEffect(() => {
263
+ if (loadedId !== roomId || isLoading) return;
264
+ const elapsed = performance.now() - coverStartRef.current;
265
+ const wait = Math.max(0, FADE_OUT_MS + HOLD_MS - elapsed);
266
+ const t = setTimeout(() => setCovered(false), wait);
267
+ return () => clearTimeout(t);
268
+ }, [loadedId, roomId, isLoading]);
269
+
270
+ // Preload neighbors: warm the HTTP cache for each same-origin exit's room.json
271
+ // and its splat + collider, so walking through a door swaps in near-instantly
272
+ // (the scene's loaders hit cache instead of the network). Best-effort and
273
+ // fire-and-forget; dead/cross-origin links are skipped (the live nav handles
274
+ // them). Plain fetch() → works on any static host, no server needed.
275
+ useEffect(() => {
276
+ if (!room) return;
277
+ let cancelled = false;
278
+ (async () => {
279
+ for (const exit of room.exits) {
280
+ let target: URL;
281
+ try { target = new URL(exit.to, window.location.href); } catch { continue; }
282
+ if (target.origin !== window.location.origin) continue;
283
+ const slug = target.pathname.replace(/^\/+|\/+$/g, '').split('/').pop();
284
+ if (!slug) continue;
285
+ try {
286
+ const neighbor = await loadRoom(`/rooms/${slug}/room.json`);
287
+ if (cancelled) return;
288
+ void fetch(neighbor.splat_url).catch(() => {});
289
+ void fetch(neighbor.collider_url).catch(() => {});
290
+ // Warm the music too, so the cross-fade on arrival starts immediately
291
+ // instead of after a first-visit fetch.
292
+ if (neighbor.music_url) void fetch(neighbor.music_url).catch(() => {});
293
+ } catch { /* missing/dead room — skip */ }
294
+ }
295
+ })();
296
+ return () => { cancelled = true; };
297
+ }, [room]);
298
+
299
+ // Memoize so position/quaternion/scale keep stable references — otherwise
300
+ // SplatWorld's load effect (and the collider build) re-fire every render.
301
+ // Built from `loadedId` (the id the loaded room actually belongs to), so a
302
+ // mid-transition render never pairs the new id with the old room's assets.
303
+ // In edit mode, scale is driven live by the room-scale slider; published builds
304
+ // use the room's saved calibration.
305
+ const world = React.useMemo(
306
+ () => (room && loadedId ? roomToWorldDef(loadedId, room, editMode ? editScale : undefined) : null),
307
+ [room, loadedId, editMode, editScale],
308
+ );
309
+
310
+ // Seed the editor's draft from the committed room (edit mode only). The room's
311
+ // entryways/exits/artifacts are already in authored form (loadRoom absolutizes
312
+ // only asset URLs), so they round-trip cleanly back through the writer.
313
+ useEffect(() => {
314
+ if (!editMode || !room || !loadedId) return;
315
+ seedEdit(loadedId, {
316
+ entryways: room.entryways ?? [],
317
+ exits: room.exits ?? [],
318
+ artifacts: room.artifacts ?? [],
319
+ }, room.calibration.scale);
320
+ }, [editMode, room, loadedId, seedEdit]);
321
+ const exits = room?.exits ?? EMPTY_EXITS;
322
+ const artifacts = room?.artifacts ?? EMPTY_ARTIFACTS;
323
+ const onArtifactOpen = useCallback((url: string) => setOverlayUrl(url), []);
324
+
325
+ // Follow an exit's link. Same-origin links navigate client-side (smooth room
326
+ // swap); other origins do a full navigation (cross-museum). Dead links 404 —
327
+ // acceptable by design.
328
+ const onExit = useCallback((to: string) => {
329
+ const url = new URL(to, window.location.href);
330
+ if (url.origin === window.location.origin) {
331
+ router.push(url.pathname + url.hash);
332
+ } else {
333
+ window.location.href = url.href;
334
+ }
335
+ }, [router]);
336
+
337
+ // Switch music when the room changes — a long cross-fade so the old room's
338
+ // track ebbs out as the new one swells in, matching the visual doorway blink
339
+ // (setMusic overlaps the two tracks over fadeMs). world (hence musicUrl) flips
340
+ // at the swap, i.e. under the black, so the music turns over with the world.
341
+ const MUSIC_CROSSFADE_MS = 900;
342
+ const musicUrl = world?.musicUrl;
343
+ useEffect(() => {
344
+ if (musicUrl) setMusic(musicUrl, { fadeMs: MUSIC_CROSSFADE_MS });
345
+ }, [musicUrl, setMusic]);
346
+
347
+ const handleLoadingChange = (loading: boolean, error?: string) => {
348
+ setIsLoading(loading);
349
+ setLoadError(error);
350
+ };
351
+
352
+ // Render nothing until mounted on the client, or when off a room route (e.g.
353
+ // "/" before the landing redirect) — so the canvas host stays inert and the
354
+ // page shows through.
355
+ if (!mounted || !roomId) return null;
356
+
357
+ if (roomError) {
358
+ return (
359
+ <div className="relative h-dvh w-dvw bg-black text-white font-sans flex items-center justify-center">
360
+ <div className="flex flex-col items-center gap-3 text-center">
361
+ <div className="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center">
362
+ <span className="text-white text-sm font-bold">!</span>
363
+ </div>
364
+ <p className="text-sm text-zinc-300">Room “{roomId}” couldn’t be loaded.</p>
365
+ <p className="text-xs text-zinc-500 max-w-xs">{roomError}</p>
366
+ </div>
367
+ </div>
368
+ );
369
+ }
370
+
371
+ if (!world) {
372
+ return (
373
+ <div className="relative h-dvh w-dvw bg-black text-white font-sans flex items-center justify-center">
374
+ <div className="flex flex-col items-center gap-4">
375
+ <Spinner size={32} className="text-white" />
376
+ <p className="text-sm text-zinc-300">Loading room…</p>
377
+ </div>
378
+ </div>
379
+ );
380
+ }
381
+
382
+ return (
383
+ <div className="relative h-dvh w-dvw bg-black text-white font-sans">
384
+ <RapierProvider
385
+ world={world}
386
+ spawn={spawn ? { x: spawn.pos[0], y: spawn.pos[1], z: spawn.pos[2] } : null}
387
+ >
388
+ <WorldScene
389
+ world={world}
390
+ playerMoveSpeed={moveSpeed}
391
+ onLoadingChange={handleLoadingChange}
392
+ mobileInputRef={mobileInputRef}
393
+ exits={exits}
394
+ onExit={onExit}
395
+ onActiveExitChange={setExitActive}
396
+ artifacts={artifacts}
397
+ onArtifactOpen={onArtifactOpen}
398
+ onActiveArtifactChange={setArtifactActive}
399
+ spawnYaw={spawn?.yaw}
400
+ spawnKey={spawn?.key}
401
+ />
402
+ </RapierProvider>
403
+
404
+ <ClickToEngage isLoading={isLoading} loadError={loadError} />
405
+ <RootUIOverlays covered={covered} loadError={loadError} />
406
+ <ExitHint active={exitActive || artifactActive} />
407
+ {overlayUrl && <ContentOverlay url={overlayUrl} onClose={() => setOverlayUrl(null)} />}
408
+
409
+ <EditorPanel />
410
+ <MobileHud mobileInputRef={mobileInputRef} />
411
+ </div>
412
+ );
413
+ }
@@ -0,0 +1,30 @@
1
+ // One room = one URL: /<room>/ (pretty path, e.g. /welcome-room/), entryway via
2
+ // the #fragment (/welcome-room/#frontdoor).
3
+ //
4
+ // This page is intentionally EMPTY: the viewer lives in the persistent [room]
5
+ // layout (layout.tsx), so it survives navigation between rooms and never drops
6
+ // pointer lock. The page exists only to (1) make /<room>/ a real route and
7
+ // (2) enumerate which rooms to pre-render for static export.
8
+ //
9
+ // generateStaticParams reads the room folders that exist at BUILD time. For a
10
+ // published site the rooms live under public/rooms/<slug>/room.json, so each gets
11
+ // its own /<slug>/index.html in the static export.
12
+
13
+ import { readdirSync } from 'fs';
14
+ import { join } from 'path';
15
+
16
+ export function generateStaticParams() {
17
+ try {
18
+ const dir = join(process.cwd(), 'public', 'rooms');
19
+ return readdirSync(dir, { withFileTypes: true })
20
+ .filter((d) => d.isDirectory())
21
+ .map((d) => ({ room: d.name }));
22
+ } catch {
23
+ return []; // no rooms yet — a content-free engine still builds
24
+ }
25
+ }
26
+
27
+ export default function Page() {
28
+ // Rendered as the layout's `children`; the layout's <RoomViewer /> is the UI.
29
+ return null;
30
+ }
Binary file
@@ -0,0 +1,45 @@
1
+ import type { Metadata } from "next";
2
+ import { Press_Start_2P } from "next/font/google";
3
+ import "@/styles/globals.css";
4
+ import Providers from "./providers";
5
+ import RoomViewer from "./[room]/RoomViewer";
6
+ import { SITE } from "@/data/site";
7
+
8
+ // Classic arcade pixel font for diegetic HUD bits (the exit prompt). Exposed as
9
+ // a CSS variable; next/font self-hosts it at build time, so it ships with the
10
+ // static export — no runtime network fetch.
11
+ const retro = Press_Start_2P({
12
+ variable: "--font-retro",
13
+ weight: "400",
14
+ subsets: ["latin"],
15
+ });
16
+
17
+ export const metadata: Metadata = {
18
+ title: SITE.siteTitle,
19
+ description: "A walkable Gaussian-splat museum.",
20
+ };
21
+
22
+ export default function RootLayout({
23
+ children,
24
+ }: Readonly<{
25
+ children: React.ReactNode;
26
+ }>) {
27
+ return (
28
+ <html lang="en">
29
+ <body className={`${retro.variable} antialiased`}>
30
+ <Providers moveSpeed={SITE.moveSpeed}>
31
+ {/* The viewer lives in the ROOT layout — the only layout that persists
32
+ across navigations between different /<room>/ values (a layout INSIDE
33
+ the [room] dynamic segment remounts when the param changes, dropping
34
+ the <Canvas> and with it pointer lock). Mounted once here, the canvas
35
+ survives every door, so the mouse stays locked. RoomViewer reads the
36
+ current room from usePathname() and renders nothing off room routes.
37
+ `children` is the route's page (a null stub for [room]; the redirect
38
+ for /). */}
39
+ <RoomViewer />
40
+ {children}
41
+ </Providers>
42
+ </body>
43
+ </html>
44
+ );
45
+ }
@@ -0,0 +1,11 @@
1
+ import { LANDING_ROOM } from '@/data/site';
2
+ import LandingRedirect from './LandingRedirect';
3
+
4
+ // The viewer renders one room per URL at /<room>/. The root has no museum of its
5
+ // own — it just sends you to this deploy's landing room (otherplane.config.json's
6
+ // `landingRoom`). This is a server component so the slug is baked in at build;
7
+ // the redirect happens client-side (LandingRedirect) since a static export has
8
+ // no server to redirect at request time.
9
+ export default function Home() {
10
+ return <LandingRedirect to={LANDING_ROOM} />;
11
+ }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import { PointerLockProvider } from '@/providers/pointerLock';
4
+ import { AudioProvider } from '@/providers/audio';
5
+ import { EditProvider } from '@/providers/edit';
6
+
7
+ export default function Providers({
8
+ children,
9
+ moveSpeed,
10
+ }: {
11
+ children: React.ReactNode;
12
+ /** From otherplane.config.json (read server-side in the root layout). */
13
+ moveSpeed?: number;
14
+ }) {
15
+ return (
16
+ <PointerLockProvider>
17
+ <AudioProvider>
18
+ <EditProvider moveSpeed={moveSpeed}>{children}</EditProvider>
19
+ </AudioProvider>
20
+ </PointerLockProvider>
21
+ );
22
+ }
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+ import VirtualStick from '@/components/controls/VirtualStick';
3
+ import { usePointerLock } from '@/providers/pointerLock';
4
+
5
+ export default function MobileHud({
6
+ mobileInputRef
7
+ }: {
8
+ mobileInputRef: React.MutableRefObject<{x:number;y:number}>
9
+ }) {
10
+ const { isLocked } = usePointerLock();
11
+
12
+ // Check if device is touch-capable
13
+ const isTouch = typeof window !== 'undefined' && matchMedia('(pointer: coarse)').matches;
14
+
15
+ if (!isTouch || !isLocked) return null;
16
+
17
+ return (
18
+ <VirtualStick
19
+ onChange={(v) => {
20
+ mobileInputRef.current.x = v.x;
21
+ mobileInputRef.current.y = v.y;
22
+ }}
23
+ />
24
+ );
25
+ }