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.
- package/CLAUDE.md +130 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/bin/otherplane.mjs +489 -0
- package/engine/eslint.config.mjs +25 -0
- package/engine/next.config.ts +43 -0
- package/engine/package-lock.json +6848 -0
- package/engine/package.json +36 -0
- package/engine/postcss.config.mjs +5 -0
- package/engine/src/app/LandingRedirect.tsx +15 -0
- package/engine/src/app/[room]/RoomViewer.tsx +413 -0
- package/engine/src/app/[room]/page.tsx +30 -0
- package/engine/src/app/favicon.ico +0 -0
- package/engine/src/app/layout.tsx +45 -0
- package/engine/src/app/page.tsx +11 -0
- package/engine/src/app/providers.tsx +22 -0
- package/engine/src/components/controls/MobileHud.tsx +25 -0
- package/engine/src/components/controls/PlayerController.tsx +170 -0
- package/engine/src/components/controls/TouchLookController.tsx +93 -0
- package/engine/src/components/controls/VirtualStick.tsx +153 -0
- package/engine/src/components/edit/EditCapture.tsx +182 -0
- package/engine/src/components/edit/EditorPanel.tsx +265 -0
- package/engine/src/components/edit/Markers.tsx +91 -0
- package/engine/src/components/hud/Button.tsx +228 -0
- package/engine/src/components/hud/ClickToPlay.tsx +13 -0
- package/engine/src/components/hud/ContentOverlay.tsx +44 -0
- package/engine/src/components/hud/NavHeader.module.css +24 -0
- package/engine/src/components/scene/Artifacts.tsx +85 -0
- package/engine/src/components/scene/Exits.tsx +92 -0
- package/engine/src/components/scene/PointerLockBridge.tsx +28 -0
- package/engine/src/components/scene/WorldScene.tsx +164 -0
- package/engine/src/components/spark/SparkLayer.tsx +112 -0
- package/engine/src/components/spark/SplatWorld.tsx +156 -0
- package/engine/src/config/audio.ts +11 -0
- package/engine/src/data/editApi.ts +73 -0
- package/engine/src/data/presets.ts +34 -0
- package/engine/src/data/room.ts +100 -0
- package/engine/src/data/site.ts +50 -0
- package/engine/src/data/universeconfig.ts +19 -0
- package/engine/src/icons/ArrowLeft.tsx +20 -0
- package/engine/src/icons/ChevronDown.tsx +23 -0
- package/engine/src/icons/ChevronLeft.tsx +22 -0
- package/engine/src/icons/Home.tsx +22 -0
- package/engine/src/icons/Spinner.module.css +13 -0
- package/engine/src/icons/Spinner.tsx +28 -0
- package/engine/src/icons/VolumeMax.tsx +21 -0
- package/engine/src/icons/VolumeX.tsx +22 -0
- package/engine/src/icons/icons.interface.ts +7 -0
- package/engine/src/icons/index.ts +27 -0
- package/engine/src/physics/RapierProvider.tsx +302 -0
- package/engine/src/physics/index.ts +2 -0
- package/engine/src/physics/types.ts +9 -0
- package/engine/src/providers/audio.tsx +215 -0
- package/engine/src/providers/edit.tsx +357 -0
- package/engine/src/providers/pointerLock.tsx +88 -0
- package/engine/src/styles/globals.css +88 -0
- package/engine/tailwind.config.js +184 -0
- package/engine/tsconfig.json +27 -0
- package/otherplane.config.example.json +6 -0
- package/package.json +56 -0
- package/schema/room.schema.json +77 -0
- package/scripts/gen_world.py +147 -0
- 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,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
|
+
}
|