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,265 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// The edit-mode authoring panel — a DOM overlay (outside the Canvas) that turns
|
|
4
|
+
// the room's marks into editable lists: name/move/delete entryways, wire exits by
|
|
5
|
+
// menu or arbitrary URL, promote an entryway into a doorway, and set artifact
|
|
6
|
+
// URLs. Position comes from the world (press C/B, or "set here" which reads the
|
|
7
|
+
// live floor-snap); this panel owns everything else. Every change persists to
|
|
8
|
+
// room.json through the provider. Only mounts in edit mode.
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
import { useEdit, type MarkerKind } from '@/providers/edit';
|
|
12
|
+
import type { Vec3 } from '@/data/room';
|
|
13
|
+
|
|
14
|
+
const vec = (p: Vec3) => `${p[0]}, ${p[1]}, ${p[2]}`;
|
|
15
|
+
|
|
16
|
+
// "../green/#from-red" → { slug: "green", entryId: "from-red" }; null if not an
|
|
17
|
+
// internal room link.
|
|
18
|
+
function parseInternal(to: string): { slug: string; entryId: string } | null {
|
|
19
|
+
const m = to.match(/^\.\.\/([^/]+)\/#(.+)$/);
|
|
20
|
+
return m ? { slug: m[1], entryId: m[2] } : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CUSTOM = '__custom__';
|
|
24
|
+
|
|
25
|
+
export default function EditorPanel() {
|
|
26
|
+
const {
|
|
27
|
+
editMode, draft, draftSlug, rooms, selected, setSelected, saveStatus, liveRef,
|
|
28
|
+
addEntryway, addExit, updateEntryway, updateExit, updateArtifact,
|
|
29
|
+
removeMarker, promoteEntryway, makeTwoWay, entrywayAt,
|
|
30
|
+
undo, canUndo, moveSpeed, setMoveSpeed, scale, setScale,
|
|
31
|
+
} = useEdit();
|
|
32
|
+
|
|
33
|
+
// Room-scale slider: track live for the label, but only commit (resize + rescale
|
|
34
|
+
// markers + save) on release — the collider re-bake is too heavy to run per tick.
|
|
35
|
+
const [scaleInput, setScaleInput] = useState(scale);
|
|
36
|
+
useEffect(() => { setScaleInput(scale); }, [scale]);
|
|
37
|
+
// Move-speed slider: track live, write otherplane.config.json on release.
|
|
38
|
+
const [speedInput, setSpeedInput] = useState(moveSpeed);
|
|
39
|
+
useEffect(() => { setSpeedInput(moveSpeed); }, [moveSpeed]);
|
|
40
|
+
|
|
41
|
+
if (!editMode || !draft) return null;
|
|
42
|
+
|
|
43
|
+
const isSel = (kind: MarkerKind, i: number) => selected?.kind === kind && selected.index === i;
|
|
44
|
+
const rowCls = (kind: MarkerKind, i: number) =>
|
|
45
|
+
`rounded border px-2 py-1.5 ${isSel(kind, i) ? 'border-amber-400 bg-amber-500/10' : 'border-zinc-700 bg-zinc-800/40'}`;
|
|
46
|
+
|
|
47
|
+
// "Add … here" and "set … here" read the live floor-snap the player is standing
|
|
48
|
+
// on (updated every frame by EditCapture).
|
|
49
|
+
const floor = () => liveRef.current.floorPos;
|
|
50
|
+
const yawNow = () => liveRef.current.yaw;
|
|
51
|
+
|
|
52
|
+
const status =
|
|
53
|
+
saveStatus === 'saving' ? 'saving…' :
|
|
54
|
+
saveStatus === 'saved' ? 'saved ✓' :
|
|
55
|
+
saveStatus === 'error' ? 'save failed ✗' : '';
|
|
56
|
+
|
|
57
|
+
const selLabel = selected ? `${selected.kind} #${selected.index + 1}` : 'none';
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="pointer-events-auto absolute top-4 left-4 z-30 flex max-h-[92vh] w-80 flex-col gap-2 overflow-y-auto rounded-lg border border-amber-500/40 bg-zinc-900/90 p-3 font-mono text-xs text-zinc-200 backdrop-blur">
|
|
61
|
+
<div className="flex items-center justify-between">
|
|
62
|
+
<span className="font-semibold text-amber-400">EDITOR · {draftSlug}</span>
|
|
63
|
+
<div className="flex items-center gap-2">
|
|
64
|
+
<button
|
|
65
|
+
className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600 disabled:opacity-30"
|
|
66
|
+
disabled={!canUndo}
|
|
67
|
+
title="Undo (⌘Z)"
|
|
68
|
+
onClick={() => undo()}
|
|
69
|
+
>↶ undo</button>
|
|
70
|
+
<span className={saveStatus === 'error' ? 'text-red-400' : 'text-emerald-300'}>{status}</span>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* How-to: the one place these live now. */}
|
|
75
|
+
<div className="rounded border border-zinc-700 bg-zinc-800/50 p-2 text-[11px] leading-snug">
|
|
76
|
+
<div className="mb-1 text-zinc-400">
|
|
77
|
+
<b className="text-amber-300">Esc</b> = edit here · click scene = walk (WASD)
|
|
78
|
+
</div>
|
|
79
|
+
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
|
|
80
|
+
<b className="text-amber-300">F</b><span>select the orb you look at</span>
|
|
81
|
+
<b className="text-amber-300">C</b><span>add entryway · <i>move</i> selected one</span>
|
|
82
|
+
<b className="text-amber-300">B</b><span>add artifact (aim first) · <i>move</i> selected</span>
|
|
83
|
+
<b className="text-amber-300">Del</b><span>delete selected · <b className="text-amber-300">X</b> deselect</span>
|
|
84
|
+
<b className="text-amber-300">Z</b><span>toggle fly (↑/↓) · <b className="text-amber-300">⌘Z</b> undo</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="mt-1 text-zinc-400">selected: <span className="text-amber-200">{selLabel}</span></div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Walk speed — a per-museum setting, saved to otherplane.config.json so it
|
|
90
|
+
ships to every viewer (not a per-browser preference). */}
|
|
91
|
+
<div className="rounded border border-zinc-700 bg-zinc-800/50 p-2">
|
|
92
|
+
<label className="flex items-center gap-2">
|
|
93
|
+
<span className="w-16 text-zinc-400">speed</span>
|
|
94
|
+
<input type="range" min={2} max={40} step={1} value={speedInput}
|
|
95
|
+
className="flex-1 accent-amber-400"
|
|
96
|
+
onChange={(e) => setSpeedInput(Number(e.target.value))}
|
|
97
|
+
onPointerUp={() => setMoveSpeed(speedInput)}
|
|
98
|
+
onKeyUp={() => setMoveSpeed(speedInput)} />
|
|
99
|
+
<span className="w-10 text-right text-zinc-300">{speedInput}</span>
|
|
100
|
+
</label>
|
|
101
|
+
<div className="mt-1 text-[10px] text-zinc-500">saved to otherplane.config.json</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Room scale — saved to room.json; rescales markers on release. */}
|
|
105
|
+
<div className="rounded border border-zinc-700 bg-zinc-800/50 p-2">
|
|
106
|
+
<label className="flex items-center gap-2">
|
|
107
|
+
<span className="w-16 text-cyan-300">room scale</span>
|
|
108
|
+
<input type="range" min={0.1} max={5} step={0.05} value={scaleInput}
|
|
109
|
+
className="flex-1 accent-cyan-400"
|
|
110
|
+
onChange={(e) => setScaleInput(Number(e.target.value))}
|
|
111
|
+
onPointerUp={() => setScale(scaleInput)}
|
|
112
|
+
onKeyUp={() => setScale(scaleInput)}
|
|
113
|
+
/>
|
|
114
|
+
<span className="w-10 text-right text-zinc-300">{scaleInput.toFixed(2)}</span>
|
|
115
|
+
</label>
|
|
116
|
+
<div className="mt-1 text-[10px] text-zinc-500">
|
|
117
|
+
saved to room.json · markers rescale with it · fit the room to a ~1.7m
|
|
118
|
+
player (too small and you won’t fit — you’ll get stuck)
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Entryways */}
|
|
123
|
+
<section className="space-y-1">
|
|
124
|
+
<div className="flex items-center justify-between">
|
|
125
|
+
<span className="font-semibold text-emerald-300">Entryways</span>
|
|
126
|
+
<button
|
|
127
|
+
className="rounded bg-emerald-600/30 px-1.5 py-0.5 text-emerald-200 hover:bg-emerald-600/50"
|
|
128
|
+
onClick={() => { const p = floor(); if (p) addEntryway(p, yawNow()); }}
|
|
129
|
+
>+ here</button>
|
|
130
|
+
</div>
|
|
131
|
+
{draft.entryways.map((e, i) => (
|
|
132
|
+
<div key={i} className={rowCls('entryway', i)}>
|
|
133
|
+
<div className="flex items-center gap-1">
|
|
134
|
+
<input
|
|
135
|
+
className="w-full rounded bg-zinc-900 px-1 py-0.5 text-emerald-200 outline-none focus:ring-1 focus:ring-emerald-400"
|
|
136
|
+
value={e.id}
|
|
137
|
+
spellCheck={false}
|
|
138
|
+
onChange={(ev) => updateEntryway(i, { id: ev.target.value })}
|
|
139
|
+
onFocus={() => setSelected({ kind: 'entryway', index: i })}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="mt-1 flex flex-wrap items-center gap-1 text-[10px] text-zinc-400">
|
|
143
|
+
<span>[{vec(e.pos)}] yaw {e.yaw}</span>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
146
|
+
<button className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600"
|
|
147
|
+
onClick={() => setSelected({ kind: 'entryway', index: i })}>select</button>
|
|
148
|
+
<button className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600"
|
|
149
|
+
onClick={() => { const p = floor(); if (p) updateEntryway(i, { pos: p, yaw: yawNow() }); }}>set here</button>
|
|
150
|
+
<button className="rounded bg-cyan-700/50 px-1.5 py-0.5 hover:bg-cyan-700"
|
|
151
|
+
onClick={() => promoteEntryway(i)}>make exit</button>
|
|
152
|
+
<button className="rounded bg-red-800/50 px-1.5 py-0.5 hover:bg-red-800"
|
|
153
|
+
onClick={() => removeMarker('entryway', i)}>del</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
))}
|
|
157
|
+
</section>
|
|
158
|
+
|
|
159
|
+
{/* Exits */}
|
|
160
|
+
<section className="space-y-1">
|
|
161
|
+
<div className="flex items-center justify-between">
|
|
162
|
+
<span className="font-semibold text-cyan-300">Exits</span>
|
|
163
|
+
<button
|
|
164
|
+
className="rounded bg-cyan-600/30 px-1.5 py-0.5 text-cyan-200 hover:bg-cyan-600/50"
|
|
165
|
+
onClick={() => { const p = floor(); if (p) addExit(p); }}
|
|
166
|
+
>+ here</button>
|
|
167
|
+
</div>
|
|
168
|
+
{draft.exits.map((e, i) => {
|
|
169
|
+
const internal = parseInternal(e.to);
|
|
170
|
+
const dropdownValue = internal ? e.to : (e.to ? CUSTOM : '');
|
|
171
|
+
const src = entrywayAt(e.pos);
|
|
172
|
+
const srcIndex = src ? draft.entryways.findIndex((x) => x.id === src.id) : -1;
|
|
173
|
+
return (
|
|
174
|
+
<div key={i} className={rowCls('exit', i)}>
|
|
175
|
+
<select
|
|
176
|
+
className="w-full rounded bg-zinc-900 px-1 py-0.5 text-cyan-200 outline-none focus:ring-1 focus:ring-cyan-400"
|
|
177
|
+
value={dropdownValue}
|
|
178
|
+
onFocus={() => setSelected({ kind: 'exit', index: i })}
|
|
179
|
+
onChange={(ev) => {
|
|
180
|
+
const v = ev.target.value;
|
|
181
|
+
updateExit(i, { to: v === CUSTOM ? (internal ? '' : e.to) : v });
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
<option value="">— pick a destination —</option>
|
|
185
|
+
{rooms.flatMap((r) =>
|
|
186
|
+
r.entryways.map((en) => (
|
|
187
|
+
<option key={`${r.slug}#${en.id}`} value={`../${r.slug}/#${en.id}`}>
|
|
188
|
+
{r.display_name} → {en.id}
|
|
189
|
+
</option>
|
|
190
|
+
)),
|
|
191
|
+
)}
|
|
192
|
+
<option value={CUSTOM}>Custom URL…</option>
|
|
193
|
+
</select>
|
|
194
|
+
{dropdownValue === CUSTOM && (
|
|
195
|
+
<input
|
|
196
|
+
className="mt-1 w-full rounded bg-zinc-900 px-1 py-0.5 text-cyan-200 outline-none focus:ring-1 focus:ring-cyan-400"
|
|
197
|
+
placeholder="https://another-museum.example/room/#entry"
|
|
198
|
+
value={e.to}
|
|
199
|
+
spellCheck={false}
|
|
200
|
+
onChange={(ev) => updateExit(i, { to: ev.target.value })}
|
|
201
|
+
onFocus={() => setSelected({ kind: 'exit', index: i })}
|
|
202
|
+
/>
|
|
203
|
+
)}
|
|
204
|
+
<div className="mt-1 text-[10px] text-zinc-400">[{vec(e.pos)}] r {e.radius ?? 1.3}</div>
|
|
205
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
206
|
+
<button className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600"
|
|
207
|
+
onClick={() => setSelected({ kind: 'exit', index: i })}>select</button>
|
|
208
|
+
<button className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600"
|
|
209
|
+
onClick={() => { const p = floor(); if (p) updateExit(i, { pos: p }); }}>set here</button>
|
|
210
|
+
{internal && srcIndex >= 0 && (
|
|
211
|
+
<button className="rounded bg-emerald-700/50 px-1.5 py-0.5 hover:bg-emerald-700"
|
|
212
|
+
title={`add the return door in ${internal.slug}`}
|
|
213
|
+
onClick={() => makeTwoWay(srcIndex, internal.slug, internal.entryId)}>two-way</button>
|
|
214
|
+
)}
|
|
215
|
+
<button className="rounded bg-red-800/50 px-1.5 py-0.5 hover:bg-red-800"
|
|
216
|
+
onClick={() => removeMarker('exit', i)}>del</button>
|
|
217
|
+
</div>
|
|
218
|
+
{internal && srcIndex < 0 && (
|
|
219
|
+
<div className="mt-1 text-[10px] text-amber-400/80">
|
|
220
|
+
one-way — add an entryway here (C) to make it two-way
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
})}
|
|
226
|
+
</section>
|
|
227
|
+
|
|
228
|
+
{/* Artifacts */}
|
|
229
|
+
<section className="space-y-1">
|
|
230
|
+
<div className="flex items-center justify-between">
|
|
231
|
+
<span className="font-semibold text-amber-300">Artifacts</span>
|
|
232
|
+
<span className="text-[10px] text-zinc-500">aim + press B</span>
|
|
233
|
+
</div>
|
|
234
|
+
{draft.artifacts.map((a, i) => (
|
|
235
|
+
<div key={i} className={rowCls('artifact', i)}>
|
|
236
|
+
<input
|
|
237
|
+
className="w-full rounded bg-zinc-900 px-1 py-0.5 text-amber-200 outline-none focus:ring-1 focus:ring-amber-400"
|
|
238
|
+
placeholder="https://…"
|
|
239
|
+
value={a.url}
|
|
240
|
+
spellCheck={false}
|
|
241
|
+
onChange={(ev) => updateArtifact(i, { url: ev.target.value })}
|
|
242
|
+
onFocus={() => setSelected({ kind: 'artifact', index: i })}
|
|
243
|
+
/>
|
|
244
|
+
<div className="mt-1 flex items-center gap-2 text-[10px] text-zinc-400">
|
|
245
|
+
<span>[{vec(a.pos)}]</span>
|
|
246
|
+
<label className="flex items-center gap-1">r
|
|
247
|
+
<input type="number" step="0.1" min="0.1" value={a.radius}
|
|
248
|
+
className="w-14 rounded bg-zinc-900 px-1 py-0.5 text-amber-200 outline-none"
|
|
249
|
+
onChange={(ev) => updateArtifact(i, { radius: Number(ev.target.value) || a.radius })}
|
|
250
|
+
onFocus={() => setSelected({ kind: 'artifact', index: i })}
|
|
251
|
+
/>
|
|
252
|
+
</label>
|
|
253
|
+
</div>
|
|
254
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
255
|
+
<button className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600"
|
|
256
|
+
onClick={() => setSelected({ kind: 'artifact', index: i })}>select</button>
|
|
257
|
+
<button className="rounded bg-red-800/50 px-1.5 py-0.5 hover:bg-red-800"
|
|
258
|
+
onClick={() => removeMarker('artifact', i)}>del</button>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
))}
|
|
262
|
+
</section>
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Edit-mode-only: render every marker in the current draft as a glowing, labeled
|
|
4
|
+
// orb so the room's structure is readable at a glance. Green = entryway (arrive),
|
|
5
|
+
// cyan = exit (leave), amber = artifact (content). A doorway (entryway + exit at
|
|
6
|
+
// one spot) reads as a stacked green+cyan pair. The selected marker pulses larger.
|
|
7
|
+
// Published builds never mount this (editMode is false).
|
|
8
|
+
|
|
9
|
+
import { useMemo } from 'react';
|
|
10
|
+
import { Billboard, Text } from '@react-three/drei';
|
|
11
|
+
import { useEdit, type MarkerKind } from '@/providers/edit';
|
|
12
|
+
import type { Vec3 } from '@/data/room';
|
|
13
|
+
|
|
14
|
+
const COLORS: Record<MarkerKind, string> = {
|
|
15
|
+
entryway: '#39ff88',
|
|
16
|
+
exit: '#33ccff',
|
|
17
|
+
artifact: '#ffb020',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// A short label for an exit target: "../green/#from-red" → "→ green", an external
|
|
21
|
+
// URL → "→ host".
|
|
22
|
+
function exitLabel(to: string): string {
|
|
23
|
+
if (!to) return '→ (unset)';
|
|
24
|
+
try {
|
|
25
|
+
if (/^https?:\/\//.test(to)) return `→ ${new URL(to).host}`;
|
|
26
|
+
} catch { /* fall through */ }
|
|
27
|
+
const slug = to.replace(/^\.\.\//, '').split('/')[0];
|
|
28
|
+
return `→ ${slug || to}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function artifactLabel(url: string): string {
|
|
32
|
+
if (!url) return 'artifact (no url)';
|
|
33
|
+
try { return new URL(url).host; } catch { return 'artifact'; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Orb({
|
|
37
|
+
pos, color, label, selected, yOffset = 0,
|
|
38
|
+
}: { pos: Vec3; color: string; label: string; selected: boolean; yOffset?: number }) {
|
|
39
|
+
const p = useMemo<Vec3>(() => [pos[0], pos[1] + yOffset, pos[2]], [pos, yOffset]);
|
|
40
|
+
const r = selected ? 0.16 : 0.11;
|
|
41
|
+
return (
|
|
42
|
+
<group position={p}>
|
|
43
|
+
<mesh renderOrder={999}>
|
|
44
|
+
<sphereGeometry args={[r, 20, 20]} />
|
|
45
|
+
<meshBasicMaterial color={color} toneMapped={false} transparent opacity={0.95} depthTest={false} />
|
|
46
|
+
</mesh>
|
|
47
|
+
{selected && (
|
|
48
|
+
<mesh renderOrder={998}>
|
|
49
|
+
<sphereGeometry args={[r * 1.7, 20, 20]} />
|
|
50
|
+
<meshBasicMaterial color={color} toneMapped={false} transparent opacity={0.2} depthTest={false} />
|
|
51
|
+
</mesh>
|
|
52
|
+
)}
|
|
53
|
+
<Billboard position={[0, r + 0.18, 0]}>
|
|
54
|
+
<Text
|
|
55
|
+
fontSize={0.14}
|
|
56
|
+
color="#ffffff"
|
|
57
|
+
anchorX="center"
|
|
58
|
+
anchorY="middle"
|
|
59
|
+
outlineWidth={0.012}
|
|
60
|
+
outlineColor="#000000"
|
|
61
|
+
renderOrder={1000}
|
|
62
|
+
material-depthTest={false}
|
|
63
|
+
>
|
|
64
|
+
{label}
|
|
65
|
+
</Text>
|
|
66
|
+
</Billboard>
|
|
67
|
+
</group>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default function Markers() {
|
|
72
|
+
const { editMode, draft, selected } = useEdit();
|
|
73
|
+
if (!editMode || !draft) return null;
|
|
74
|
+
|
|
75
|
+
const sel = (kind: MarkerKind, i: number) => selected?.kind === kind && selected.index === i;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
{draft.entryways.map((e, i) => (
|
|
80
|
+
<Orb key={`en-${i}`} pos={e.pos} color={COLORS.entryway} label={e.id || '(unnamed)'} selected={sel('entryway', i)} />
|
|
81
|
+
))}
|
|
82
|
+
{draft.exits.map((e, i) => (
|
|
83
|
+
// Nudge exits up so a co-located entryway+exit stack reads as two orbs.
|
|
84
|
+
<Orb key={`ex-${i}`} pos={e.pos} color={COLORS.exit} label={exitLabel(e.to)} selected={sel('exit', i)} yOffset={0.34} />
|
|
85
|
+
))}
|
|
86
|
+
{draft.artifacts.map((a, i) => (
|
|
87
|
+
<Orb key={`ar-${i}`} pos={a.pos} color={COLORS.artifact} label={artifactLabel(a.url)} selected={sel('artifact', i)} />
|
|
88
|
+
))}
|
|
89
|
+
</>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { MouseEventHandler, forwardRef } from "react";
|
|
2
|
+
|
|
3
|
+
type IconButtonProps = BaseButtonProps & {
|
|
4
|
+
icon: React.ReactNode;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type ButtonProps = BaseButtonProps & {
|
|
8
|
+
label: string | React.ReactNode;
|
|
9
|
+
detail?: string;
|
|
10
|
+
icon?: React.ReactNode;
|
|
11
|
+
showLoading?: boolean;
|
|
12
|
+
compact?: boolean;
|
|
13
|
+
type?: 'button' | 'submit' | 'reset';
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface BaseButtonProps {
|
|
17
|
+
style?: ButtonStyle;
|
|
18
|
+
prominence?: ButtonProminence;
|
|
19
|
+
size?: ButtonSize;
|
|
20
|
+
state?: ButtonState;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
onClick: MouseEventHandler<HTMLButtonElement>;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export enum ButtonStyle {
|
|
27
|
+
Squared = "square",
|
|
28
|
+
Rounded = "rounded",
|
|
29
|
+
Pill = "pill",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export enum ButtonProminence {
|
|
33
|
+
Primary = "primary",
|
|
34
|
+
Secondary = "secondary",
|
|
35
|
+
Tertiary = "tertiary",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export enum ButtonSize {
|
|
39
|
+
Small = "small",
|
|
40
|
+
Medium = "medium",
|
|
41
|
+
Large = "large",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export enum ButtonState {
|
|
45
|
+
Unselected = "unselected",
|
|
46
|
+
Selected = "selected",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const borderStyle = (style: ButtonStyle = ButtonStyle.Rounded) => {
|
|
50
|
+
switch (style) {
|
|
51
|
+
case ButtonStyle.Squared:
|
|
52
|
+
return "rounded-none";
|
|
53
|
+
case ButtonStyle.Rounded:
|
|
54
|
+
return "rounded-lg";
|
|
55
|
+
case ButtonStyle.Pill:
|
|
56
|
+
return "rounded-full";
|
|
57
|
+
default:
|
|
58
|
+
return "rounded-lg";
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const prominenceStyle = (
|
|
63
|
+
prominence: ButtonProminence = ButtonProminence.Primary,
|
|
64
|
+
disabled: boolean = false
|
|
65
|
+
) => {
|
|
66
|
+
switch (prominence) {
|
|
67
|
+
case ButtonProminence.Primary:
|
|
68
|
+
return `bg-action-primary sans-heavy ${
|
|
69
|
+
disabled
|
|
70
|
+
? "text-tertiary bg-action-primary-disabled"
|
|
71
|
+
: "hover:bg-action-primary-hover active:bg-action-primary-active text-inverted-primary"
|
|
72
|
+
}`;
|
|
73
|
+
case ButtonProminence.Secondary:
|
|
74
|
+
return `bg-elevated sans-heavy ${
|
|
75
|
+
disabled
|
|
76
|
+
? "text-tertiary"
|
|
77
|
+
: "hover:bg-action-hover active:bg-action-active text-primary"
|
|
78
|
+
}`;
|
|
79
|
+
case ButtonProminence.Tertiary:
|
|
80
|
+
return `bg-transparent sans-heavy ${
|
|
81
|
+
disabled
|
|
82
|
+
? "text-tertiary"
|
|
83
|
+
: "hover:bg-action-hover active:bg-action-active text-primary"
|
|
84
|
+
}`;
|
|
85
|
+
default:
|
|
86
|
+
return `bg-action-primary sans-heavy ${
|
|
87
|
+
disabled
|
|
88
|
+
? "text-tertiary"
|
|
89
|
+
: "hover:bg-action-primary-hover active:bg-action-primary-active text-inverted-primary"
|
|
90
|
+
}`;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const sizeStyle = (
|
|
95
|
+
size: ButtonSize = ButtonSize.Medium,
|
|
96
|
+
hasLabel: boolean = false
|
|
97
|
+
) => {
|
|
98
|
+
switch (size) {
|
|
99
|
+
case ButtonSize.Small:
|
|
100
|
+
return `p-2 ${hasLabel ? "px-3" : ""}`;
|
|
101
|
+
case ButtonSize.Medium:
|
|
102
|
+
return "p-3";
|
|
103
|
+
case ButtonSize.Large:
|
|
104
|
+
return "p-4";
|
|
105
|
+
default:
|
|
106
|
+
return "p-4";
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const iconButtonStyleClass = (
|
|
111
|
+
style: ButtonStyle,
|
|
112
|
+
prominence: ButtonProminence,
|
|
113
|
+
size: ButtonSize,
|
|
114
|
+
disabled: boolean
|
|
115
|
+
): string => {
|
|
116
|
+
return `${borderStyle(style)} ${prominenceStyle(
|
|
117
|
+
prominence,
|
|
118
|
+
disabled
|
|
119
|
+
)} ${sizeStyle(size)} bg-elevated transition-colors duration-200`;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const buttonStyleClass = (
|
|
123
|
+
style: ButtonStyle,
|
|
124
|
+
prominence: ButtonProminence,
|
|
125
|
+
size: ButtonSize,
|
|
126
|
+
disabled: boolean
|
|
127
|
+
): string => {
|
|
128
|
+
return `${borderStyle(style)} ${prominenceStyle(
|
|
129
|
+
prominence,
|
|
130
|
+
disabled
|
|
131
|
+
)} ${sizeStyle(
|
|
132
|
+
size,
|
|
133
|
+
true
|
|
134
|
+
)} transition-colors duration-200 flex font-sans-heavy justify-center`;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
138
|
+
(
|
|
139
|
+
{
|
|
140
|
+
icon,
|
|
141
|
+
style = ButtonStyle.Rounded,
|
|
142
|
+
prominence = ButtonProminence.Secondary,
|
|
143
|
+
size = ButtonSize.Small,
|
|
144
|
+
disabled = false,
|
|
145
|
+
onClick,
|
|
146
|
+
className,
|
|
147
|
+
},
|
|
148
|
+
ref
|
|
149
|
+
) => {
|
|
150
|
+
return (
|
|
151
|
+
<button
|
|
152
|
+
contentEditable={false}
|
|
153
|
+
className={`${iconButtonStyleClass(
|
|
154
|
+
style,
|
|
155
|
+
prominence,
|
|
156
|
+
size,
|
|
157
|
+
disabled
|
|
158
|
+
)} ${className}`}
|
|
159
|
+
onClick={
|
|
160
|
+
disabled
|
|
161
|
+
? () => {}
|
|
162
|
+
: (e) => {
|
|
163
|
+
onClick(e);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
disabled={disabled}
|
|
167
|
+
ref={ref}
|
|
168
|
+
>
|
|
169
|
+
{icon}
|
|
170
|
+
</button>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
IconButton.displayName = "IconButton";
|
|
176
|
+
|
|
177
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
178
|
+
(
|
|
179
|
+
{
|
|
180
|
+
label,
|
|
181
|
+
detail,
|
|
182
|
+
icon,
|
|
183
|
+
style = ButtonStyle.Rounded,
|
|
184
|
+
prominence = ButtonProminence.Primary,
|
|
185
|
+
size = ButtonSize.Large,
|
|
186
|
+
disabled = false,
|
|
187
|
+
onClick,
|
|
188
|
+
className,
|
|
189
|
+
compact = true,
|
|
190
|
+
type = 'button',
|
|
191
|
+
},
|
|
192
|
+
ref
|
|
193
|
+
) => {
|
|
194
|
+
return (
|
|
195
|
+
<button
|
|
196
|
+
contentEditable={false}
|
|
197
|
+
type={type}
|
|
198
|
+
className={`${buttonStyleClass(
|
|
199
|
+
style,
|
|
200
|
+
prominence,
|
|
201
|
+
size,
|
|
202
|
+
disabled
|
|
203
|
+
)} ${className}`}
|
|
204
|
+
onClick={
|
|
205
|
+
disabled
|
|
206
|
+
? () => {}
|
|
207
|
+
: (e) => {
|
|
208
|
+
onClick(e);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
disabled={disabled}
|
|
212
|
+
ref={ref}
|
|
213
|
+
>
|
|
214
|
+
{icon && <span className={`${compact ? "mr-0 sm:mr-2" : "mr-2"}`}>{icon}</span>}
|
|
215
|
+
<div className={`${(icon && compact) ? "hidden sm:flex" : "flex"} flex-col`}>
|
|
216
|
+
<span className="line-clamp-1">{label}</span>
|
|
217
|
+
{detail ? (
|
|
218
|
+
<span className="line-clamp-1 font-sans-medium text-sm text-secondary">
|
|
219
|
+
{detail}
|
|
220
|
+
</span>
|
|
221
|
+
) : null}
|
|
222
|
+
</div>
|
|
223
|
+
</button>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
Button.displayName = "Button";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export function Reticle({ visible }: { visible: boolean }) {
|
|
4
|
+
if (!visible) return null;
|
|
5
|
+
return (
|
|
6
|
+
<div className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 opacity-85">
|
|
7
|
+
<div className="relative w-[12px] h-[12px]">
|
|
8
|
+
<div className="absolute left-1/2 top-0 w-[2px] h-full -translate-x-1/2 bg-zinc-200/40" />
|
|
9
|
+
<div className="absolute top-1/2 left-0 h-[2px] w-full -translate-y-1/2 bg-zinc-200/40" />
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// A content artifact opens here: the page, fullscreen, over the canvas. The 3D
|
|
4
|
+
// world is paused (the player controller ignores input while pointer-lock is
|
|
5
|
+
// released), not destroyed — closing returns you exactly where you were. Closing
|
|
6
|
+
// (the ✕ or Esc) re-acquires pointer-lock in the same gesture, so mouse-look
|
|
7
|
+
// resumes immediately with no extra click.
|
|
8
|
+
//
|
|
9
|
+
// We keep a small floating ✕ rather than going fully chromeless: once you click
|
|
10
|
+
// into a cross-origin iframe, key events go to it, so Esc alone can't be relied on.
|
|
11
|
+
|
|
12
|
+
import { useCallback, useEffect } from 'react';
|
|
13
|
+
import { usePointerLock } from '@/providers/pointerLock';
|
|
14
|
+
|
|
15
|
+
export default function ContentOverlay({ url, onClose }: { url: string; onClose: () => void }) {
|
|
16
|
+
const { lock, unlock } = usePointerLock();
|
|
17
|
+
|
|
18
|
+
// Release the mouse so the embedded page is usable.
|
|
19
|
+
useEffect(() => { unlock(); }, [unlock]);
|
|
20
|
+
|
|
21
|
+
const close = useCallback(() => {
|
|
22
|
+
lock(); // re-grab the mouse in this gesture's call stack
|
|
23
|
+
onClose();
|
|
24
|
+
}, [lock, onClose]);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const onKey = (e: KeyboardEvent) => { if (e.code === 'Escape') { e.preventDefault(); close(); } };
|
|
28
|
+
window.addEventListener('keydown', onKey);
|
|
29
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
30
|
+
}, [close]);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="absolute inset-0 z-30 bg-black">
|
|
34
|
+
<iframe src={url} className="h-full w-full border-0 bg-white" title="content" />
|
|
35
|
+
<button
|
|
36
|
+
onClick={close}
|
|
37
|
+
aria-label="Close (Esc)"
|
|
38
|
+
className="absolute top-3 right-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur hover:bg-black/80"
|
|
39
|
+
>
|
|
40
|
+
✕
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.header {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: row;
|
|
4
|
+
justify-content: space-between;
|
|
5
|
+
align-items: center;
|
|
6
|
+
gap: var(--space-8);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.title {
|
|
10
|
+
font-size: var(--size-md);
|
|
11
|
+
font-weight: 500;
|
|
12
|
+
color: inherit;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.detail {
|
|
16
|
+
font-size: var(--size-sm);
|
|
17
|
+
font-weight: 500;
|
|
18
|
+
color: inherit;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.spacer {
|
|
22
|
+
height: 40px;
|
|
23
|
+
width: 40px;
|
|
24
|
+
}
|