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,357 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Edit mode: the viewer's authoring flag. It's a property of the running INSTANCE,
|
|
4
|
+
// not the request — so a published museum simply has no edit mode and a visitor
|
|
5
|
+
// can't flip it on. Turn it on with `otherplane edit` (sets NEXT_PUBLIC_EDIT_MODE=1),
|
|
6
|
+
// which also starts the local writer sidecar this provider talks to.
|
|
7
|
+
//
|
|
8
|
+
// This provider owns the room's editable "draft" — its entryways, exits, and
|
|
9
|
+
// artifacts. Marking keys (EditCapture) and the editor panel both mutate the
|
|
10
|
+
// draft through here, and every mutation persists to disk via the writer
|
|
11
|
+
// (src/data/editApi.ts). Asset URLs are never touched: the writer merges only
|
|
12
|
+
// the three coordinate arrays into the source room.json.
|
|
13
|
+
|
|
14
|
+
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
15
|
+
import type { Entryway, Exit, Artifact, Vec3 } from '@/data/room';
|
|
16
|
+
import { fetchRooms, saveMarks, saveConfig, linkDoor, type RoomSummary, type Marks } from '@/data/editApi';
|
|
17
|
+
import { CONFIG as UNIVERSE_CONFIG } from '@/data/universeconfig';
|
|
18
|
+
|
|
19
|
+
// Fixed player standing offset (feet → body center). Entryway/exit positions are
|
|
20
|
+
// stored as body-center standing spots = floorY + STAND; only the floorY part
|
|
21
|
+
// scales with the room, so the player stays feet-on-floor at any room scale.
|
|
22
|
+
const STAND = UNIVERSE_CONFIG.PLAYER.HALF_HEIGHT + UNIVERSE_CONFIG.PLAYER.RADIUS;
|
|
23
|
+
|
|
24
|
+
const ENV_EDIT = process.env.NEXT_PUBLIC_EDIT_MODE === '1';
|
|
25
|
+
|
|
26
|
+
export type LiveCoords = {
|
|
27
|
+
pos: [number, number, number];
|
|
28
|
+
yaw: number;
|
|
29
|
+
pitch: number;
|
|
30
|
+
hasBody: boolean;
|
|
31
|
+
/** Floor-snapped feet-on-floor body-center below the player (for entryways). */
|
|
32
|
+
floorPos: Vec3 | null;
|
|
33
|
+
/** Last point the B beam hit (for artifacts). */
|
|
34
|
+
lastBeam: Vec3 | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type MarkerKind = 'entryway' | 'exit' | 'artifact';
|
|
38
|
+
export type Selection = { kind: MarkerKind; index: number } | null;
|
|
39
|
+
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
|
40
|
+
|
|
41
|
+
const emptyMarks = (): Marks => ({ entryways: [], exits: [], artifacts: [] });
|
|
42
|
+
|
|
43
|
+
// Drop not-yet-complete markers before persisting (an exit with no target, an
|
|
44
|
+
// artifact with no url, an entryway with no id). They stay in the local draft so
|
|
45
|
+
// you can finish them; they just aren't written until valid — the writer would
|
|
46
|
+
// reject them and, more importantly, an unfinished door shouldn't ship.
|
|
47
|
+
function sanitize(m: Marks): Marks {
|
|
48
|
+
return {
|
|
49
|
+
entryways: m.entryways.filter((e) => e.id.trim()),
|
|
50
|
+
exits: m.exits.filter((e) => e.to.trim()),
|
|
51
|
+
artifacts: m.artifacts.filter((a) => a.url.trim()),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const dist = (a: Vec3, b: Vec3) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
56
|
+
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
57
|
+
|
|
58
|
+
type EditCtx = {
|
|
59
|
+
editMode: boolean;
|
|
60
|
+
/** Mutated every frame by EditCapture (inside the canvas); read by the HUD. */
|
|
61
|
+
liveRef: React.MutableRefObject<LiveCoords>;
|
|
62
|
+
lastCopied: string | null;
|
|
63
|
+
setLastCopied: (s: string | null) => void;
|
|
64
|
+
|
|
65
|
+
specter: boolean;
|
|
66
|
+
specterRef: React.MutableRefObject<boolean>;
|
|
67
|
+
toggleSpecter: () => void;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Walk speed — a per-museum setting from otherplane.config.json (ships to every
|
|
71
|
+
* viewer). Editing it writes the config so it applies for everyone, not just
|
|
72
|
+
* this browser.
|
|
73
|
+
*/
|
|
74
|
+
moveSpeed: number;
|
|
75
|
+
setMoveSpeed: (n: number) => void;
|
|
76
|
+
|
|
77
|
+
// ── authoring ──────────────────────────────────────────────────────────
|
|
78
|
+
/** The current room's editable marks (null until a room is seeded). */
|
|
79
|
+
draft: Marks | null;
|
|
80
|
+
draftSlug: string | null;
|
|
81
|
+
/** Every room + its entryways, for wiring exits by menu. */
|
|
82
|
+
rooms: RoomSummary[];
|
|
83
|
+
selected: Selection;
|
|
84
|
+
setSelected: (s: Selection) => void;
|
|
85
|
+
saveStatus: SaveStatus;
|
|
86
|
+
|
|
87
|
+
/** Seed the draft from a freshly loaded room (RoomViewer calls this). */
|
|
88
|
+
seed: (slug: string, marks: Marks, scale: number) => void;
|
|
89
|
+
/** Step back through edit history (also bound to Cmd/Ctrl-Z). */
|
|
90
|
+
undo: () => void;
|
|
91
|
+
canUndo: boolean;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Room scale (calibration.scale) — real per-room data, written to room.json.
|
|
95
|
+
* Setting it live-resizes the splat + collider AND rescales every marker by the
|
|
96
|
+
* same ratio so orbs stay on the walls; undo history is cleared at that point.
|
|
97
|
+
*/
|
|
98
|
+
scale: number;
|
|
99
|
+
setScale: (n: number) => void;
|
|
100
|
+
|
|
101
|
+
addEntryway: (pos: Vec3, yaw: number) => void;
|
|
102
|
+
addArtifact: (pos: Vec3) => void;
|
|
103
|
+
addExit: (pos: Vec3) => void;
|
|
104
|
+
updateEntryway: (i: number, patch: Partial<Entryway>) => void;
|
|
105
|
+
updateExit: (i: number, patch: Partial<Exit>) => void;
|
|
106
|
+
updateArtifact: (i: number, patch: Partial<Artifact>) => void;
|
|
107
|
+
removeMarker: (kind: MarkerKind, i: number) => void;
|
|
108
|
+
/** Add an exit co-located with entryway i (a doorway is arrive + leave). */
|
|
109
|
+
promoteEntryway: (i: number) => void;
|
|
110
|
+
/** True-two-way: reuse a target entryway's position for the return exit. */
|
|
111
|
+
makeTwoWay: (entryIndex: number, toSlug: string, toEntryId: string) => Promise<void>;
|
|
112
|
+
/** The entryway (if any) sitting at this exit's position — the door's source. */
|
|
113
|
+
entrywayAt: (pos: Vec3) => Entryway | null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const Ctx = createContext<EditCtx | null>(null);
|
|
117
|
+
|
|
118
|
+
export function EditProvider({
|
|
119
|
+
children,
|
|
120
|
+
moveSpeed: initialMoveSpeed = 14,
|
|
121
|
+
}: {
|
|
122
|
+
children: React.ReactNode;
|
|
123
|
+
/** Seeded from otherplane.config.json by the root layout (server-read). */
|
|
124
|
+
moveSpeed?: number;
|
|
125
|
+
}) {
|
|
126
|
+
const [editMode] = useState(ENV_EDIT);
|
|
127
|
+
// Config-sourced walk speed; the slider writes it back to otherplane.config.json.
|
|
128
|
+
const [moveSpeed, setMoveSpeedState] = useState(initialMoveSpeed);
|
|
129
|
+
const setMoveSpeed = useCallback((n: number) => {
|
|
130
|
+
setMoveSpeedState(n);
|
|
131
|
+
saveConfig({ moveSpeed: n }).catch((e) => console.error('[edit] config save failed:', e));
|
|
132
|
+
}, []);
|
|
133
|
+
const liveRef = useRef<LiveCoords>({
|
|
134
|
+
pos: [0, 0, 0], yaw: 0, pitch: 0, hasBody: false, floorPos: null, lastBeam: null,
|
|
135
|
+
});
|
|
136
|
+
const [lastCopied, setLastCopied] = useState<string | null>(null);
|
|
137
|
+
const [specter, setSpecter] = useState(false);
|
|
138
|
+
const specterRef = useRef(false);
|
|
139
|
+
const toggleSpecter = () => {
|
|
140
|
+
specterRef.current = !specterRef.current;
|
|
141
|
+
setSpecter(specterRef.current);
|
|
142
|
+
};
|
|
143
|
+
const [draft, setDraft] = useState<Marks | null>(null);
|
|
144
|
+
const draftRef = useRef<Marks | null>(null);
|
|
145
|
+
const [draftSlug, setDraftSlug] = useState<string | null>(null);
|
|
146
|
+
const [rooms, setRooms] = useState<RoomSummary[]>([]);
|
|
147
|
+
const [selected, setSelected] = useState<Selection>(null);
|
|
148
|
+
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
|
149
|
+
// Undo stack of prior draft snapshots. Mutators always build fresh objects, so
|
|
150
|
+
// each stored reference is an immutable snapshot — no cloning needed.
|
|
151
|
+
const historyRef = useRef<Marks[]>([]);
|
|
152
|
+
const [undoDepth, setUndoDepth] = useState(0);
|
|
153
|
+
const [scale, setScaleState] = useState(1);
|
|
154
|
+
const scaleRef = useRef(1);
|
|
155
|
+
|
|
156
|
+
// Load the room list once for the exit dropdown (edit mode only).
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!editMode) return;
|
|
159
|
+
let cancelled = false;
|
|
160
|
+
fetchRooms().then((r) => { if (!cancelled) setRooms(r); }).catch(() => {});
|
|
161
|
+
return () => { cancelled = true; };
|
|
162
|
+
}, [editMode]);
|
|
163
|
+
|
|
164
|
+
const refreshRooms = useCallback(() => {
|
|
165
|
+
fetchRooms().then(setRooms).catch(() => {});
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
// Commit a new draft to state + disk. Sanitized before writing; the local copy
|
|
169
|
+
// keeps in-progress markers so you can finish them. `record` pushes the prior
|
|
170
|
+
// draft onto the undo stack (false when the change IS an undo).
|
|
171
|
+
const commit = useCallback((next: Marks, record = true) => {
|
|
172
|
+
const slug = draftSlug;
|
|
173
|
+
if (record && draftRef.current) {
|
|
174
|
+
historyRef.current.push(draftRef.current);
|
|
175
|
+
if (historyRef.current.length > 100) historyRef.current.shift();
|
|
176
|
+
setUndoDepth(historyRef.current.length);
|
|
177
|
+
}
|
|
178
|
+
draftRef.current = next;
|
|
179
|
+
setDraft(next);
|
|
180
|
+
if (!slug) return;
|
|
181
|
+
setSaveStatus('saving');
|
|
182
|
+
saveMarks(slug, sanitize(next))
|
|
183
|
+
.then(() => setSaveStatus('saved'))
|
|
184
|
+
.catch((e) => { setSaveStatus('error'); console.error('[edit] save failed:', e); });
|
|
185
|
+
}, [draftSlug]);
|
|
186
|
+
|
|
187
|
+
const undo = useCallback(() => {
|
|
188
|
+
const prev = historyRef.current.pop();
|
|
189
|
+
setUndoDepth(historyRef.current.length);
|
|
190
|
+
if (prev) { commit(prev, false); setSelected(null); }
|
|
191
|
+
}, [commit]);
|
|
192
|
+
|
|
193
|
+
const seed = useCallback((slug: string, marks: Marks, roomScale: number) => {
|
|
194
|
+
draftRef.current = marks;
|
|
195
|
+
setDraft(marks);
|
|
196
|
+
setDraftSlug(slug);
|
|
197
|
+
setSelected(null);
|
|
198
|
+
setSaveStatus('idle');
|
|
199
|
+
historyRef.current = [];
|
|
200
|
+
setUndoDepth(0);
|
|
201
|
+
scaleRef.current = roomScale;
|
|
202
|
+
setScaleState(roomScale);
|
|
203
|
+
}, []);
|
|
204
|
+
|
|
205
|
+
// Set room scale: rescale every marker by the ratio (so they stay on the walls),
|
|
206
|
+
// write marks + calibration.scale together, and clear undo (every position moved
|
|
207
|
+
// at once — stepping back one marker edit would desync from the old scale).
|
|
208
|
+
const setScale = useCallback((next: number) => {
|
|
209
|
+
if (!(next > 0)) return;
|
|
210
|
+
const slug = draftSlug;
|
|
211
|
+
const prev = scaleRef.current || 1;
|
|
212
|
+
const ratio = next / prev;
|
|
213
|
+
scaleRef.current = next;
|
|
214
|
+
setScaleState(next);
|
|
215
|
+
if (ratio === 1) return;
|
|
216
|
+
const m = draftRef.current ?? emptyMarks();
|
|
217
|
+
// Standing markers (entryway/exit): scale the floor part of Y, keep the fixed
|
|
218
|
+
// player standing offset, so they don't sink underground as the room shrinks.
|
|
219
|
+
const spFloor = (p: Vec3): Vec3 =>
|
|
220
|
+
[round2(p[0] * ratio), round2((p[1] - STAND) * ratio + STAND), round2(p[2] * ratio)];
|
|
221
|
+
// Artifacts are raw surface points → scale fully.
|
|
222
|
+
const spFull = (p: Vec3): Vec3 => [round2(p[0] * ratio), round2(p[1] * ratio), round2(p[2] * ratio)];
|
|
223
|
+
const rescaled: Marks = {
|
|
224
|
+
entryways: m.entryways.map((e) => ({ ...e, pos: spFloor(e.pos) })),
|
|
225
|
+
exits: m.exits.map((e) => ({ ...e, pos: spFloor(e.pos), radius: e.radius != null ? round2(e.radius * ratio) : e.radius })),
|
|
226
|
+
artifacts: m.artifacts.map((a) => ({ ...a, pos: spFull(a.pos), radius: round2(a.radius * ratio) })),
|
|
227
|
+
};
|
|
228
|
+
draftRef.current = rescaled;
|
|
229
|
+
setDraft(rescaled);
|
|
230
|
+
historyRef.current = [];
|
|
231
|
+
setUndoDepth(0);
|
|
232
|
+
if (!slug) return;
|
|
233
|
+
setSaveStatus('saving');
|
|
234
|
+
saveMarks(slug, sanitize(rescaled), { scale: next })
|
|
235
|
+
.then(() => setSaveStatus('saved'))
|
|
236
|
+
.catch((e) => { setSaveStatus('error'); console.error('[edit] scale save failed:', e); });
|
|
237
|
+
}, [draftSlug]);
|
|
238
|
+
|
|
239
|
+
// Cmd/Ctrl-Z undoes — unless you're typing in a field (let native text-undo win).
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (!editMode) return;
|
|
242
|
+
const onKey = (e: KeyboardEvent) => {
|
|
243
|
+
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === 'z') {
|
|
244
|
+
const el = document.activeElement as HTMLElement | null;
|
|
245
|
+
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return;
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
undo();
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
window.addEventListener('keydown', onKey);
|
|
251
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
252
|
+
}, [editMode, undo]);
|
|
253
|
+
|
|
254
|
+
const cur = () => draftRef.current ?? emptyMarks();
|
|
255
|
+
|
|
256
|
+
const addEntryway = useCallback((pos: Vec3, yaw: number) => {
|
|
257
|
+
const m = cur();
|
|
258
|
+
const has = new Set(m.entryways.map((e) => e.id));
|
|
259
|
+
let id = has.has('default') ? 'entry-2' : 'default';
|
|
260
|
+
let n = 2;
|
|
261
|
+
while (has.has(id)) id = `entry-${++n}`;
|
|
262
|
+
const next = { ...m, entryways: [...m.entryways, { id, pos, yaw }] };
|
|
263
|
+
commit(next);
|
|
264
|
+
setSelected({ kind: 'entryway', index: next.entryways.length - 1 });
|
|
265
|
+
}, [commit]);
|
|
266
|
+
|
|
267
|
+
const addArtifact = useCallback((pos: Vec3) => {
|
|
268
|
+
const m = cur();
|
|
269
|
+
const next = { ...m, artifacts: [...m.artifacts, { pos, radius: 1.0, url: '' }] };
|
|
270
|
+
commit(next);
|
|
271
|
+
setSelected({ kind: 'artifact', index: next.artifacts.length - 1 });
|
|
272
|
+
}, [commit]);
|
|
273
|
+
|
|
274
|
+
const addExit = useCallback((pos: Vec3) => {
|
|
275
|
+
const m = cur();
|
|
276
|
+
const next = { ...m, exits: [...m.exits, { pos, radius: 1.3, to: '' }] };
|
|
277
|
+
commit(next);
|
|
278
|
+
setSelected({ kind: 'exit', index: next.exits.length - 1 });
|
|
279
|
+
}, [commit]);
|
|
280
|
+
|
|
281
|
+
const updateEntryway = useCallback((i: number, patch: Partial<Entryway>) => {
|
|
282
|
+
const m = cur();
|
|
283
|
+
commit({ ...m, entryways: m.entryways.map((e, j) => (j === i ? { ...e, ...patch } : e)) });
|
|
284
|
+
}, [commit]);
|
|
285
|
+
const updateExit = useCallback((i: number, patch: Partial<Exit>) => {
|
|
286
|
+
const m = cur();
|
|
287
|
+
commit({ ...m, exits: m.exits.map((e, j) => (j === i ? { ...e, ...patch } : e)) });
|
|
288
|
+
}, [commit]);
|
|
289
|
+
const updateArtifact = useCallback((i: number, patch: Partial<Artifact>) => {
|
|
290
|
+
const m = cur();
|
|
291
|
+
commit({ ...m, artifacts: m.artifacts.map((a, j) => (j === i ? { ...a, ...patch } : a)) });
|
|
292
|
+
}, [commit]);
|
|
293
|
+
|
|
294
|
+
const removeMarker = useCallback((kind: MarkerKind, i: number) => {
|
|
295
|
+
const m = cur();
|
|
296
|
+
const key = (kind + 's') as 'entryways' | 'exits' | 'artifacts';
|
|
297
|
+
commit({ ...m, [key]: m[key].filter((_, j) => j !== i) });
|
|
298
|
+
setSelected(null);
|
|
299
|
+
}, [commit]);
|
|
300
|
+
|
|
301
|
+
const promoteEntryway = useCallback((i: number) => {
|
|
302
|
+
const m = cur();
|
|
303
|
+
const e = m.entryways[i];
|
|
304
|
+
if (!e) return;
|
|
305
|
+
const next = { ...m, exits: [...m.exits, { pos: e.pos, radius: 1.3, to: '' }] };
|
|
306
|
+
commit(next);
|
|
307
|
+
setSelected({ kind: 'exit', index: next.exits.length - 1 });
|
|
308
|
+
}, [commit]);
|
|
309
|
+
|
|
310
|
+
const entrywayAt = useCallback((pos: Vec3): Entryway | null => {
|
|
311
|
+
const m = cur();
|
|
312
|
+
return m.entryways.find((e) => dist(e.pos, pos) < 0.5) ?? null;
|
|
313
|
+
}, []);
|
|
314
|
+
|
|
315
|
+
const makeTwoWay = useCallback(async (entryIndex: number, toSlug: string, toEntryId: string) => {
|
|
316
|
+
const m = cur();
|
|
317
|
+
const from = m.entryways[entryIndex];
|
|
318
|
+
if (!from || !draftSlug) return;
|
|
319
|
+
const to = `../${toSlug}/#${toEntryId}`;
|
|
320
|
+
// Forward exit at this entryway, if not already present.
|
|
321
|
+
let exits = m.exits;
|
|
322
|
+
if (!exits.some((e) => e.to === to && dist(e.pos, from.pos) < 0.5)) {
|
|
323
|
+
exits = [...exits, { pos: from.pos, radius: 1.3, to }];
|
|
324
|
+
}
|
|
325
|
+
commit({ ...m, exits });
|
|
326
|
+
// Reciprocal: the writer adds the return exit in the target room, reusing the
|
|
327
|
+
// target entryway's own position. Both entryways must already exist.
|
|
328
|
+
try {
|
|
329
|
+
await linkDoor({ slug: draftSlug, entryId: from.id }, { slug: toSlug, entryId: toEntryId });
|
|
330
|
+
refreshRooms();
|
|
331
|
+
} catch (e) {
|
|
332
|
+
console.error('[edit] two-way link failed:', e);
|
|
333
|
+
setSaveStatus('error');
|
|
334
|
+
}
|
|
335
|
+
}, [commit, draftSlug, refreshRooms]);
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<Ctx.Provider value={{
|
|
339
|
+
editMode, liveRef, lastCopied, setLastCopied,
|
|
340
|
+
specter, specterRef, toggleSpecter,
|
|
341
|
+
moveSpeed, setMoveSpeed,
|
|
342
|
+
draft, draftSlug, rooms, selected, setSelected, saveStatus,
|
|
343
|
+
seed, undo, canUndo: undoDepth > 0, scale, setScale,
|
|
344
|
+
addEntryway, addArtifact, addExit,
|
|
345
|
+
updateEntryway, updateExit, updateArtifact, removeMarker,
|
|
346
|
+
promoteEntryway, makeTwoWay, entrywayAt,
|
|
347
|
+
}}>
|
|
348
|
+
{children}
|
|
349
|
+
</Ctx.Provider>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function useEdit() {
|
|
354
|
+
const c = useContext(Ctx);
|
|
355
|
+
if (!c) throw new Error('useEdit must be used within EditProvider');
|
|
356
|
+
return c;
|
|
357
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// providers/pointerLock.tsx
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import React, {
|
|
5
|
+
createContext, useCallback, useContext, useEffect,
|
|
6
|
+
useMemo, useRef, useState
|
|
7
|
+
} from 'react';
|
|
8
|
+
import type { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls.js';
|
|
9
|
+
|
|
10
|
+
type PublicAPI = {
|
|
11
|
+
isLocked: boolean;
|
|
12
|
+
lock: (opts?: { unadjustedMovement?: boolean }) => void;
|
|
13
|
+
unlock: () => void;
|
|
14
|
+
};
|
|
15
|
+
type InternalAPI = { register: (controls: PointerLockControls | null) => void };
|
|
16
|
+
|
|
17
|
+
const PointerLockPublicCtx = createContext<PublicAPI | null>(null);
|
|
18
|
+
const PointerLockInternalCtx = createContext<InternalAPI | null>(null);
|
|
19
|
+
|
|
20
|
+
export function PointerLockProvider({ children }: { children: React.ReactNode }) {
|
|
21
|
+
const controlsRef = useRef<PointerLockControls | null>(null);
|
|
22
|
+
const [isLocked, setLocked] = useState(false);
|
|
23
|
+
|
|
24
|
+
// Feature detection for Pointer Lock
|
|
25
|
+
const canPLRef = useRef(false);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (typeof document !== 'undefined' && document.body) {
|
|
28
|
+
// requestPointerLock is undefined on iOS Safari
|
|
29
|
+
canPLRef.current = typeof (document.body as HTMLElement & { requestPointerLock?: () => void }).requestPointerLock === 'function';
|
|
30
|
+
}
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
// Stable handlers for add/removeEventListener
|
|
34
|
+
const onLockRef = useRef<() => void>(() => setLocked(true));
|
|
35
|
+
const onUnlockRef = useRef<() => void>(() => setLocked(false));
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
onLockRef.current = () => setLocked(true);
|
|
38
|
+
onUnlockRef.current = () => setLocked(false);
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const lock = useCallback((opts?: { unadjustedMovement?: boolean }) => {
|
|
42
|
+
const c = controlsRef.current;
|
|
43
|
+
if (c && canPLRef.current) {
|
|
44
|
+
c.lock(opts?.unadjustedMovement ?? false);
|
|
45
|
+
} else {
|
|
46
|
+
// Mobile/touch fallback: enter play mode without Pointer Lock
|
|
47
|
+
setLocked(true);
|
|
48
|
+
}
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const unlock = useCallback(() => {
|
|
52
|
+
const c = controlsRef.current;
|
|
53
|
+
if (c && canPLRef.current) c.unlock();
|
|
54
|
+
else setLocked(false);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const register = useCallback((controls: PointerLockControls | null) => {
|
|
58
|
+
if (controlsRef.current) {
|
|
59
|
+
controlsRef.current.removeEventListener('lock', onLockRef.current);
|
|
60
|
+
controlsRef.current.removeEventListener('unlock', onUnlockRef.current);
|
|
61
|
+
}
|
|
62
|
+
controlsRef.current = controls ?? null;
|
|
63
|
+
if (controls) {
|
|
64
|
+
controls.addEventListener('lock', onLockRef.current);
|
|
65
|
+
controls.addEventListener('unlock', onUnlockRef.current);
|
|
66
|
+
}
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const pub = useMemo<PublicAPI>(() => ({ isLocked, lock, unlock }), [isLocked, lock, unlock]);
|
|
70
|
+
const internal = useMemo<InternalAPI>(() => ({ register }), [register]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<PointerLockInternalCtx.Provider value={internal}>
|
|
74
|
+
<PointerLockPublicCtx.Provider value={pub}>{children}</PointerLockPublicCtx.Provider>
|
|
75
|
+
</PointerLockInternalCtx.Provider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function usePointerLock(): PublicAPI {
|
|
80
|
+
const ctx = useContext(PointerLockPublicCtx);
|
|
81
|
+
if (!ctx) throw new Error('usePointerLock must be used within PointerLockProvider');
|
|
82
|
+
return ctx;
|
|
83
|
+
}
|
|
84
|
+
export function usePointerLockRegistration(): InternalAPI {
|
|
85
|
+
const ctx = useContext(PointerLockInternalCtx);
|
|
86
|
+
if (!ctx) throw new Error('usePointerLockRegistration must be used within PointerLockProvider');
|
|
87
|
+
return ctx;
|
|
88
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@theme {
|
|
4
|
+
/* Custom semantic colors for your design system */
|
|
5
|
+
--color-primary: var(--color-white);
|
|
6
|
+
--color-secondary: var(--color-gray-400);
|
|
7
|
+
--color-tertiary: var(--color-gray-500);
|
|
8
|
+
--color-normal: var(--color-gray-800);
|
|
9
|
+
--color-elevated: var(--color-gray-800);
|
|
10
|
+
|
|
11
|
+
--color-action-hover: var(--color-gray-700);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
:root {
|
|
15
|
+
/* Base colors */
|
|
16
|
+
--background: var(--color-black);
|
|
17
|
+
--foreground: #171717;
|
|
18
|
+
|
|
19
|
+
/* Text colors */
|
|
20
|
+
--text-primary: var(--color-white);
|
|
21
|
+
--text-secondary: var(--color-gray-400);
|
|
22
|
+
--text-tertiary: var(--color-gray-500);
|
|
23
|
+
--text-inverted-primary: var(--color-black);
|
|
24
|
+
--text-inverted-secondary: var(--color-gray-600);
|
|
25
|
+
|
|
26
|
+
/* Icon colors */
|
|
27
|
+
--stroke-primary: var(--color-white);
|
|
28
|
+
--stroke-secondary: var(--color-gray-400);
|
|
29
|
+
--stroke-tertiary: var(--color-gray-500);
|
|
30
|
+
--stroke-inverted-primary: var(--color-black);
|
|
31
|
+
|
|
32
|
+
/* Fill colors */
|
|
33
|
+
--fill-primary: var(--color-white);
|
|
34
|
+
--fill-secondary: var(--color-gray-400);
|
|
35
|
+
--fill-tertiary: var(--color-gray-500);
|
|
36
|
+
--fill-inverted-primary: var(--color-black);
|
|
37
|
+
|
|
38
|
+
/* Button background colors */
|
|
39
|
+
--background-action-primary: var(--color-gray-800);
|
|
40
|
+
--background-action-primary-hover: var(--color-gray-700);
|
|
41
|
+
--background-action-primary-active: var(--color-gray-600);
|
|
42
|
+
--background-action-primary-disabled: var(--color-gray-900);
|
|
43
|
+
--background-action-hover: var(--color-gray-800);
|
|
44
|
+
--background-action-active: var(--color-gray-700);
|
|
45
|
+
--background-action-transparent: transparent;
|
|
46
|
+
--background-elevated: var(--color-gray-800);
|
|
47
|
+
|
|
48
|
+
/* Border colors */
|
|
49
|
+
--border-normal: var(--color-gray-800);
|
|
50
|
+
--border-highlighted: var(--color-white);
|
|
51
|
+
--border-error: var(--color-rose-200);
|
|
52
|
+
--border-elevated: var(--color-gray-900);
|
|
53
|
+
|
|
54
|
+
/* NavHeader specific variables */
|
|
55
|
+
--color-border: var(--color-gray-800);
|
|
56
|
+
--color-surface-elevated: var(--color-gray-800);
|
|
57
|
+
--space-8: 2rem;
|
|
58
|
+
--size-md: 1rem;
|
|
59
|
+
--size-sm: 0.875rem;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* @theme inline {
|
|
63
|
+
--color-background: var(--background);
|
|
64
|
+
--color-foreground: var(--foreground);
|
|
65
|
+
} */
|
|
66
|
+
|
|
67
|
+
@media (prefers-color-scheme: dark) {
|
|
68
|
+
:root {
|
|
69
|
+
--background: #0a0a0a;
|
|
70
|
+
--foreground: #ededed;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
body {
|
|
75
|
+
height: 100%;
|
|
76
|
+
font-family: "Avenir", ui-sans-serif, system-ui, -apple-system,
|
|
77
|
+
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans",
|
|
78
|
+
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
|
79
|
+
"Noto Color Emoji";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Mobile controls: disable touch scrolling while playing */
|
|
83
|
+
.canvas-root {
|
|
84
|
+
touch-action: none;
|
|
85
|
+
-webkit-user-select: none;
|
|
86
|
+
user-select: none;
|
|
87
|
+
-webkit-touch-callout: none;
|
|
88
|
+
}
|