otherplane 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CLAUDE.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/bin/otherplane.mjs +489 -0
  5. package/engine/eslint.config.mjs +25 -0
  6. package/engine/next.config.ts +43 -0
  7. package/engine/package-lock.json +6848 -0
  8. package/engine/package.json +36 -0
  9. package/engine/postcss.config.mjs +5 -0
  10. package/engine/src/app/LandingRedirect.tsx +15 -0
  11. package/engine/src/app/[room]/RoomViewer.tsx +413 -0
  12. package/engine/src/app/[room]/page.tsx +30 -0
  13. package/engine/src/app/favicon.ico +0 -0
  14. package/engine/src/app/layout.tsx +45 -0
  15. package/engine/src/app/page.tsx +11 -0
  16. package/engine/src/app/providers.tsx +22 -0
  17. package/engine/src/components/controls/MobileHud.tsx +25 -0
  18. package/engine/src/components/controls/PlayerController.tsx +170 -0
  19. package/engine/src/components/controls/TouchLookController.tsx +93 -0
  20. package/engine/src/components/controls/VirtualStick.tsx +153 -0
  21. package/engine/src/components/edit/EditCapture.tsx +182 -0
  22. package/engine/src/components/edit/EditorPanel.tsx +265 -0
  23. package/engine/src/components/edit/Markers.tsx +91 -0
  24. package/engine/src/components/hud/Button.tsx +228 -0
  25. package/engine/src/components/hud/ClickToPlay.tsx +13 -0
  26. package/engine/src/components/hud/ContentOverlay.tsx +44 -0
  27. package/engine/src/components/hud/NavHeader.module.css +24 -0
  28. package/engine/src/components/scene/Artifacts.tsx +85 -0
  29. package/engine/src/components/scene/Exits.tsx +92 -0
  30. package/engine/src/components/scene/PointerLockBridge.tsx +28 -0
  31. package/engine/src/components/scene/WorldScene.tsx +164 -0
  32. package/engine/src/components/spark/SparkLayer.tsx +112 -0
  33. package/engine/src/components/spark/SplatWorld.tsx +156 -0
  34. package/engine/src/config/audio.ts +11 -0
  35. package/engine/src/data/editApi.ts +73 -0
  36. package/engine/src/data/presets.ts +34 -0
  37. package/engine/src/data/room.ts +100 -0
  38. package/engine/src/data/site.ts +50 -0
  39. package/engine/src/data/universeconfig.ts +19 -0
  40. package/engine/src/icons/ArrowLeft.tsx +20 -0
  41. package/engine/src/icons/ChevronDown.tsx +23 -0
  42. package/engine/src/icons/ChevronLeft.tsx +22 -0
  43. package/engine/src/icons/Home.tsx +22 -0
  44. package/engine/src/icons/Spinner.module.css +13 -0
  45. package/engine/src/icons/Spinner.tsx +28 -0
  46. package/engine/src/icons/VolumeMax.tsx +21 -0
  47. package/engine/src/icons/VolumeX.tsx +22 -0
  48. package/engine/src/icons/icons.interface.ts +7 -0
  49. package/engine/src/icons/index.ts +27 -0
  50. package/engine/src/physics/RapierProvider.tsx +302 -0
  51. package/engine/src/physics/index.ts +2 -0
  52. package/engine/src/physics/types.ts +9 -0
  53. package/engine/src/providers/audio.tsx +215 -0
  54. package/engine/src/providers/edit.tsx +357 -0
  55. package/engine/src/providers/pointerLock.tsx +88 -0
  56. package/engine/src/styles/globals.css +88 -0
  57. package/engine/tailwind.config.js +184 -0
  58. package/engine/tsconfig.json +27 -0
  59. package/otherplane.config.example.json +6 -0
  60. package/package.json +56 -0
  61. package/schema/room.schema.json +77 -0
  62. package/scripts/gen_world.py +147 -0
  63. package/skill.md +94 -0
@@ -0,0 +1,170 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef } from 'react';
4
+ import { useFrame, useThree } from '@react-three/fiber';
5
+ import { usePointerLock } from '@/providers/pointerLock';
6
+ import * as THREE from 'three';
7
+ import { useRapierWorld, RapierRigidBody } from '@/physics';
8
+ import { useEdit } from '@/providers/edit';
9
+
10
+ // Rapier collision groups are a 32-bit value: high 16 bits = membership,
11
+ // low 16 bits = filter (which groups this collider collides with).
12
+ // NORMAL is Rapier's default (member of all, collides with all). SPECTER keeps
13
+ // membership but clears the filter, so the player collides with nothing —
14
+ // passing through walls. Beams are separate raycasts, so they are unaffected.
15
+ const NORMAL_GROUPS = 0xffffffff;
16
+ const SPECTER_GROUPS = 0x00010000;
17
+
18
+ type Props = {
19
+ onLockChange?: (locked: boolean) => void;
20
+ radius?: number;
21
+ halfHeight?: number;
22
+ eyeHeight?: number;
23
+ moveSpeed?: number;
24
+ start?: [number, number, number];
25
+ mobileInputRef?: React.MutableRefObject<{x:number;y:number}>;
26
+ /** Yaw (degrees) to face on arrival; bump `spawnKey` to re-apply it. */
27
+ spawnYaw?: number;
28
+ spawnKey?: string;
29
+ };
30
+
31
+ export default function PlayerController({
32
+ onLockChange,
33
+ radius = 0.33,
34
+ halfHeight = 0.55,
35
+ eyeHeight = 1.0,
36
+ moveSpeed = 14.0,
37
+ start = [0, 1.4, 0],
38
+ mobileInputRef,
39
+ spawnYaw,
40
+ spawnKey,
41
+ }: Props) {
42
+ const { camera } = useThree();
43
+ const { playerBody } = useRapierWorld();
44
+ const { isLocked } = usePointerLock();
45
+ const { specter, specterRef } = useEdit();
46
+ const bodyRef = useRef<RapierRigidBody | null>(playerBody);
47
+ const key = useRef<Record<string, boolean>>({});
48
+ const forward = useMemo(() => new THREE.Vector3(), []);
49
+ const right = useMemo(() => new THREE.Vector3(), []);
50
+ const localMobileVec = useRef<{x:number;y:number}>({x:0,y:0});
51
+ const mobileVec = mobileInputRef || localMobileVec;
52
+
53
+ // Sync to provider-owned body
54
+ useEffect(() => {
55
+ bodyRef.current = playerBody;
56
+ }, [playerBody]);
57
+
58
+ // Face the entryway's yaw on arrival (and on each room/entryway change). lookAt
59
+ // sets orientation from the direction only (position-independent), and
60
+ // PointerLockControls reads the camera's orientation on the next mouse move, so
61
+ // this persists. yaw convention matches the C-key readout: atan2(fwd.x, fwd.z).
62
+ useEffect(() => {
63
+ if (spawnYaw == null) return;
64
+ const yawRad = (spawnYaw * Math.PI) / 180;
65
+ const p = camera.position;
66
+ camera.lookAt(p.x + Math.sin(yawRad), p.y, p.z + Math.cos(yawRad));
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps
68
+ }, [spawnKey]);
69
+
70
+ // Apply specter mode to the player body: clear the collider's collision filter
71
+ // (pass through everything) and zero gravity (so you hover / fly). Reverting
72
+ // restores normal collision + gravity. Re-runs when the body is recreated.
73
+ useEffect(() => {
74
+ if (!playerBody) return;
75
+ const collider = playerBody.collider(0);
76
+ if (specter) {
77
+ collider?.setCollisionGroups(SPECTER_GROUPS);
78
+ playerBody.setGravityScale(0, true);
79
+ } else {
80
+ collider?.setCollisionGroups(NORMAL_GROUPS);
81
+ playerBody.setGravityScale(1, true);
82
+ playerBody.setLinvel({ x: 0, y: 0, z: 0 }, true);
83
+ }
84
+ }, [specter, playerBody]);
85
+
86
+ // Input
87
+ useEffect(() => {
88
+ const down = (e: KeyboardEvent) => {
89
+ key.current[e.code] = true;
90
+ if (e.code === 'KeyP') {
91
+ const rb = bodyRef.current;
92
+ if (rb) {
93
+ const p = rb.translation();
94
+ const fwd = new THREE.Vector3();
95
+ camera.getWorldDirection(fwd).normalize();
96
+ const yaw = Math.atan2(fwd.x, fwd.z) * 180 / Math.PI;
97
+ const pitch = Math.asin(THREE.MathUtils.clamp(fwd.y, -1, 1)) * 180 / Math.PI;
98
+ console.log(`[Player] pos=(${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)}) yaw=${yaw.toFixed(1)} pitch=${pitch.toFixed(1)}`);
99
+ } else {
100
+ console.warn('[Player] body not initialized yet');
101
+ }
102
+ }
103
+ // Arrows fly up/down in specter mode; stop them scrolling the page either way.
104
+ if (e.code === 'ArrowUp' || e.code === 'ArrowDown') {
105
+ e.preventDefault();
106
+ }
107
+ };
108
+ const up = (e: KeyboardEvent) => { key.current[e.code] = false; };
109
+ window.addEventListener('keydown', down);
110
+ window.addEventListener('keyup', up);
111
+ return () => {
112
+ window.removeEventListener('keydown', down);
113
+ window.removeEventListener('keyup', up);
114
+ };
115
+ }, []);
116
+
117
+ useFrame(() => {
118
+ const rb = bodyRef.current;
119
+ if (!rb) return;
120
+
121
+ // Movement is driven only while pointer lock is engaged. Camera positioning
122
+ // (below) happens every frame regardless, so the room is framed correctly
123
+ // the instant you spawn — even before you click to engage.
124
+ if (isLocked) {
125
+ camera.getWorldDirection(forward);
126
+ forward.y = 0; forward.normalize();
127
+ right.crossVectors(forward, camera.up).normalize();
128
+
129
+ // Keyboard input
130
+ const z = (key.current['KeyW'] ? 1 : 0) - (key.current['KeyS'] ? 1 : 0);
131
+ const xk = (key.current['KeyD'] ? 1 : 0) - (key.current['KeyA'] ? 1 : 0);
132
+
133
+ // Mobile joystick input (override keyboard if non-zero)
134
+ const xm = mobileVec.current.x;
135
+ const zm = -mobileVec.current.y; // invert: up is negative y
136
+ const x = Math.abs(xm) > 0.01 ? xm : xk;
137
+ const zFinal = Math.abs(zm) > 0.01 ? zm : z;
138
+
139
+ const dir = new THREE.Vector3();
140
+ if (zFinal) dir.addScaledVector(forward, zFinal);
141
+ if (x) dir.addScaledVector(right, x);
142
+ if (dir.lengthSq() > 0) dir.normalize();
143
+
144
+ const speed = moveSpeed * 0.1;
145
+ const cur = rb.linvel();
146
+ const target = { x: dir.x * speed, y: cur.y, z: dir.z * speed };
147
+ if (dir.lengthSq() > 0 && !Number.isFinite(target.x + target.y + target.z)) {
148
+ console.warn('[Player] non-finite velocity target', target);
149
+ }
150
+
151
+ if (specterRef.current) {
152
+ // Specter mode: no gravity, so drive vertical directly with the arrows
153
+ // (up/down). No key held → 0 → hover in place.
154
+ const upDir = (key.current['ArrowUp'] ? 1 : 0) - (key.current['ArrowDown'] ? 1 : 0);
155
+ target.y = upDir * speed;
156
+ }
157
+
158
+ rb.setLinvel(target, true);
159
+ }
160
+
161
+ // Camera at eye height above feet — always, so the spawn view is correct on
162
+ // load (otherwise the camera sits at the default canvas position staring at
163
+ // the floor until the first click engages pointer lock).
164
+ const p = rb.translation();
165
+ const feetY = p.y - (halfHeight + radius);
166
+ camera.position.set(p.x, feetY + eyeHeight, p.z);
167
+ });
168
+
169
+ return null;
170
+ }
@@ -0,0 +1,93 @@
1
+ // components/controls/TouchLookController.tsx
2
+ 'use client';
3
+ import { useEffect, useMemo, useRef } from 'react';
4
+ import { useThree } from '@react-three/fiber';
5
+ import * as THREE from 'three';
6
+ import { usePointerLock } from '@/providers/pointerLock';
7
+
8
+ export default function TouchLookController({
9
+ enabled = true,
10
+ sensitivity = 0.18, // degrees per pixel
11
+ maxPitch = 89,
12
+ }: { enabled?: boolean; sensitivity?: number; maxPitch?: number }) {
13
+ const { camera, gl } = useThree();
14
+ const { isLocked } = usePointerLock();
15
+
16
+ // Only enable on touch devices
17
+ const isTouch = useMemo(
18
+ () => typeof window !== 'undefined' && matchMedia('(pointer: coarse)').matches,
19
+ []
20
+ );
21
+
22
+ const active = useRef(false);
23
+ const last = useRef<{ x: number; y: number } | null>(null);
24
+ const yawPitch = useRef({ yaw: 0, pitch: 0 });
25
+
26
+ // Keep canvas from scrolling when in play mode on touch
27
+ useEffect(() => {
28
+ if (!isTouch) return;
29
+ const el = gl.domElement as HTMLCanvasElement;
30
+ el.style.touchAction = isLocked ? 'none' : 'auto';
31
+ (el.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect = 'none';
32
+ return () => { el.style.touchAction = 'auto'; };
33
+ }, [gl, isLocked, isTouch]);
34
+
35
+ // Initialize yaw/pitch from current camera direction whenever we (re)enter play
36
+ useEffect(() => {
37
+ if (!isTouch) return;
38
+ const fwd = new THREE.Vector3(); camera.getWorldDirection(fwd);
39
+ yawPitch.current.yaw = Math.atan2(fwd.x, fwd.z);
40
+ yawPitch.current.pitch = Math.asin(THREE.MathUtils.clamp(fwd.y, -1, 1));
41
+ }, [camera, isTouch, isLocked]);
42
+
43
+ useEffect(() => {
44
+ if (!enabled || !isTouch) return;
45
+ const el = gl.domElement as HTMLCanvasElement;
46
+
47
+ const onStart = (e: TouchEvent) => {
48
+ if (!isLocked) return;
49
+ const t = e.touches[0]; if (!t) return;
50
+ active.current = true;
51
+ last.current = { x: t.clientX, y: t.clientY };
52
+ e.preventDefault();
53
+ };
54
+ const onMove = (e: TouchEvent) => {
55
+ if (!isLocked || !active.current) return;
56
+ const t = e.touches[0]; if (!t || !last.current) return;
57
+ const dx = t.clientX - last.current.x;
58
+ const dy = t.clientY - last.current.y;
59
+ last.current = { x: t.clientX, y: t.clientY };
60
+
61
+ const S = (sensitivity * Math.PI) / 180; // to radians
62
+ const yp = yawPitch.current;
63
+ yp.yaw -= dx * S; // typical FPS invert X
64
+ yp.pitch -= dy * S;
65
+
66
+ const max = (maxPitch * Math.PI) / 180;
67
+ yp.pitch = THREE.MathUtils.clamp(yp.pitch, -max, max);
68
+
69
+ // apply to camera
70
+ const c = Math.cos(yp.pitch), s = Math.sin(yp.pitch);
71
+ const dir = new THREE.Vector3(Math.sin(yp.yaw) * c, s, Math.cos(yp.yaw) * c);
72
+ const pos = camera.position.clone();
73
+ camera.lookAt(pos.clone().add(dir));
74
+
75
+ e.preventDefault();
76
+ };
77
+ const onEnd = () => { active.current = false; last.current = null; };
78
+
79
+ // Non-passive so preventDefault works on iOS
80
+ el.addEventListener('touchstart', onStart, { passive: false });
81
+ el.addEventListener('touchmove', onMove, { passive: false });
82
+ el.addEventListener('touchend', onEnd, { passive: true });
83
+ el.addEventListener('touchcancel',onEnd, { passive: true });
84
+ return () => {
85
+ el.removeEventListener('touchstart', onStart);
86
+ el.removeEventListener('touchmove', onMove);
87
+ el.removeEventListener('touchend', onEnd);
88
+ el.removeEventListener('touchcancel',onEnd);
89
+ };
90
+ }, [gl, camera, isLocked, enabled, isTouch, sensitivity, maxPitch]);
91
+
92
+ return null;
93
+ }
@@ -0,0 +1,153 @@
1
+ // VirtualStick.tsx
2
+ 'use client';
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ export type StickVec = { x: number; y: number };
6
+
7
+ export default function VirtualStick({
8
+ onChange,
9
+ radius = 60, // visual radius in px
10
+ dead = 0.08, // deadzone in [0..1] of full travel
11
+ curve = 1.0, // 1 = linear; >1 = softer near center
12
+ }: {
13
+ onChange: (v: StickVec) => void;
14
+ radius?: number;
15
+ dead?: number;
16
+ curve?: number;
17
+ }) {
18
+ const ref = useRef<HTMLDivElement | null>(null);
19
+ const [drag, setDrag] = useState(false);
20
+ const [knob, setKnob] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
21
+ const touchIdRef = useRef<number | null>(null);
22
+ const centerRef = useRef<{ cx: number; cy: number }>({ cx: 0, cy: 0 });
23
+
24
+ useEffect(() => {
25
+ const el = ref.current;
26
+ if (!el) return;
27
+
28
+ const rect = () => el.getBoundingClientRect();
29
+
30
+ const findTouch = (e: TouchEvent) => {
31
+ if (touchIdRef.current == null) return e.touches[0] || null;
32
+ for (let i = 0; i < e.touches.length; i++) {
33
+ if (e.touches[i].identifier === touchIdRef.current) return e.touches[i];
34
+ }
35
+ return null;
36
+ };
37
+
38
+ const start = (e: TouchEvent) => {
39
+ // lock the first touch to this stick
40
+ const t = e.touches[0];
41
+ if (!t) return;
42
+ touchIdRef.current = t.identifier;
43
+ setDrag(true);
44
+
45
+ const r = rect();
46
+ centerRef.current.cx = r.left + r.width / 2;
47
+ centerRef.current.cy = r.top + r.height / 2;
48
+
49
+ move(e); // emit initial value
50
+ e.preventDefault();
51
+ };
52
+
53
+ const move = (e: TouchEvent) => {
54
+ if (!drag) return;
55
+ const t = findTouch(e);
56
+ if (!t) return;
57
+
58
+ const { cx, cy } = centerRef.current;
59
+ const dx = t.clientX - cx;
60
+ const dy = t.clientY - cy;
61
+
62
+ // magnitude in pixels
63
+ const len = Math.hypot(dx, dy);
64
+ // direction (normalized)
65
+ const nx = len > 0 ? dx / len : 0;
66
+ const ny = len > 0 ? dy / len : 0;
67
+
68
+ // clamp magnitude to radius
69
+ const m = Math.min(1, len / radius);
70
+
71
+ // apply deadzone + optional response curve
72
+ const mAdj = m < dead ? 0 : Math.pow((m - dead) / (1 - dead), curve);
73
+
74
+ const x = nx * mAdj; // [-1..1]
75
+ const y = ny * mAdj; // [-1..1], NOTE: up = negative y
76
+
77
+ setKnob({ x: nx * Math.min(1, len / radius), y: ny * Math.min(1, len / radius) });
78
+ onChange({ x, y });
79
+
80
+ e.preventDefault();
81
+ };
82
+
83
+ const end = (e: TouchEvent) => {
84
+ if (touchIdRef.current == null) return;
85
+ // If our finger lifted, reset
86
+ let stillDown = false;
87
+ for (let i = 0; i < e.touches.length; i++) {
88
+ if (e.touches[i].identifier === touchIdRef.current) {
89
+ stillDown = true;
90
+ break;
91
+ }
92
+ }
93
+ if (!stillDown) {
94
+ touchIdRef.current = null;
95
+ setDrag(false);
96
+ setKnob({ x: 0, y: 0 });
97
+ onChange({ x: 0, y: 0 });
98
+ }
99
+ };
100
+
101
+ // non-passive so preventDefault works on iOS
102
+ el.addEventListener('touchstart', start, { passive: false });
103
+ window.addEventListener('touchmove', move, { passive: false });
104
+ window.addEventListener('touchend', end, { passive: false });
105
+ window.addEventListener('touchcancel', end, { passive: false });
106
+
107
+ return () => {
108
+ el.removeEventListener('touchstart', start);
109
+ window.removeEventListener('touchmove', move);
110
+ window.removeEventListener('touchend', end);
111
+ window.removeEventListener('touchcancel', end);
112
+ };
113
+ }, [onChange, radius, dead, curve, drag]);
114
+
115
+ const size = radius * 2;
116
+ const knobPxX = knob.x * radius;
117
+ const knobPxY = knob.y * radius;
118
+
119
+ return (
120
+ <div
121
+ ref={ref}
122
+ className="absolute bottom-6 left-6 z-10"
123
+ style={{
124
+ width: size,
125
+ height: size,
126
+ borderRadius: '9999px',
127
+ background: 'rgba(255,255,255,0.06)',
128
+ border: '1px solid rgba(255,255,255,0.12)',
129
+ touchAction: 'none', // disable scroll while interacting
130
+ WebkitUserSelect: 'none',
131
+ userSelect: 'none',
132
+ }}
133
+ >
134
+ {/* knob */}
135
+ <div
136
+ style={{
137
+ position: 'absolute',
138
+ left: '50%',
139
+ top: '50%',
140
+ width: radius * 0.9,
141
+ height: radius * 0.9,
142
+ borderRadius: '9999px',
143
+ transform: `translate(${knobPxX - (radius * 0.45)}px, ${knobPxY - (radius * 0.45)}px)`,
144
+ background: 'rgba(255,255,255,0.18)',
145
+ border: '1px solid rgba(255,255,255,0.22)',
146
+ boxShadow: '0 2px 8px rgba(0,0,0,0.35)',
147
+ transition: drag ? 'none' : 'transform 120ms ease-out',
148
+ pointerEvents: 'none',
149
+ }}
150
+ />
151
+ </div>
152
+ );
153
+ }
@@ -0,0 +1,182 @@
1
+ 'use client';
2
+
3
+ // Lives INSIDE the r3f Canvas (next to PlayerController) so it can read the camera
4
+ // and the Rapier collider. In edit mode it (1) feeds live pos+yaw+floor-snap to
5
+ // the provider every frame, and (2) turns keypresses into edits that persist to
6
+ // room.json through the provider's writer:
7
+ //
8
+ // C floor-snapped spawn — add an entryway, or reposition the selected
9
+ // entryway/exit (feet-on-floor, valid however high you're flying).
10
+ // B "beam": raycast from the camera — add an artifact at the hit point, or
11
+ // reposition the selected artifact (walls/ceilings included). Cast against
12
+ // the Rapier collider since a splat cloud isn't raycastable.
13
+ // F select the marker you're looking at (nearest within the gaze cone).
14
+ // Delete / Backspace remove the selected marker.
15
+ // Esc-like X clear the selection.
16
+ // Z toggle specter (noclip + fly).
17
+ //
18
+ // The orbs you see are rendered by <Markers>; this component only captures input.
19
+
20
+ import { useEffect } from 'react';
21
+ import { useFrame, useThree } from '@react-three/fiber';
22
+ import * as THREE from 'three';
23
+ import { useRapierWorld } from '@/physics';
24
+ import { useEdit, type MarkerKind, type Selection } from '@/providers/edit';
25
+ import { CONFIG as UNIVERSE_CONFIG } from '@/data/universeconfig';
26
+ import type { Vec3 } from '@/data/room';
27
+
28
+ const f2 = (n: number) => Number(n.toFixed(2));
29
+ // Body-center height above the feet (so a copied spawn lands feet-on-floor).
30
+ const STAND = UNIVERSE_CONFIG.PLAYER.HALF_HEIGHT + UNIVERSE_CONFIG.PLAYER.RADIUS;
31
+ const GAZE_DOT = Math.cos((25 * Math.PI) / 180); // select markers within ~25°
32
+
33
+ export default function EditCapture() {
34
+ const { camera } = useThree();
35
+ const { world, rapier, playerBody } = useRapierWorld();
36
+ const {
37
+ editMode, liveRef, setLastCopied, toggleSpecter, specterRef,
38
+ draft, selected, setSelected,
39
+ addEntryway, addArtifact, updateEntryway, updateExit, updateArtifact, removeMarker,
40
+ } = useEdit();
41
+
42
+ // Feed live values (incl. floor-snap) to the provider/HUD every frame.
43
+ useFrame(() => {
44
+ if (!editMode) return;
45
+ const fwd = new THREE.Vector3();
46
+ camera.getWorldDirection(fwd).normalize();
47
+ const yaw = (Math.atan2(fwd.x, fwd.z) * 180) / Math.PI;
48
+ const pitch = (Math.asin(THREE.MathUtils.clamp(fwd.y, -1, 1)) * 180) / Math.PI;
49
+ if (playerBody) {
50
+ const p = playerBody.translation();
51
+ let floorPos: Vec3 | null = null;
52
+ if (world && rapier) {
53
+ const down = new rapier.Ray({ x: p.x, y: p.y, z: p.z }, { x: 0, y: -1, z: 0 });
54
+ const hit = world.castRay(down, 1000, true, undefined, undefined, undefined, playerBody ?? undefined);
55
+ if (hit) floorPos = [f2(p.x), f2(p.y - hit.toi + STAND), f2(p.z)];
56
+ }
57
+ liveRef.current = { ...liveRef.current, pos: [p.x, p.y, p.z], yaw, pitch, hasBody: true, floorPos };
58
+ } else {
59
+ liveRef.current = { ...liveRef.current, yaw, pitch, hasBody: false };
60
+ }
61
+ });
62
+
63
+ // Marking + selection keys.
64
+ useEffect(() => {
65
+ if (!editMode) return;
66
+
67
+ // Floor-snapped standing spot directly below the player (valid at any fly
68
+ // height), with the facing yaw. Null if there's no floor beneath.
69
+ const floorSpot = (): { pos: Vec3; yaw: number } | null => {
70
+ if (!playerBody) return null;
71
+ const p = playerBody.translation();
72
+ const fwd = new THREE.Vector3();
73
+ camera.getWorldDirection(fwd).normalize();
74
+ const yaw = f2((Math.atan2(fwd.x, fwd.z) * 180) / Math.PI);
75
+ let y = p.y;
76
+ if (world && rapier) {
77
+ const down = new rapier.Ray({ x: p.x, y: p.y, z: p.z }, { x: 0, y: -1, z: 0 });
78
+ const hit = world.castRay(down, 1000, true, undefined, undefined, undefined, playerBody ?? undefined);
79
+ if (hit) y = p.y - hit.toi + STAND;
80
+ else return null;
81
+ }
82
+ return { pos: [f2(p.x), f2(y), f2(p.z)], yaw };
83
+ };
84
+
85
+ // Point the camera is looking at, against the collider (for artifacts).
86
+ const beamSpot = (): Vec3 | null => {
87
+ if (!world || !rapier) return null;
88
+ const origin = camera.getWorldPosition(new THREE.Vector3());
89
+ const dir = camera.getWorldDirection(new THREE.Vector3()).normalize();
90
+ const ray = new rapier.Ray(
91
+ { x: origin.x, y: origin.y, z: origin.z },
92
+ { x: dir.x, y: dir.y, z: dir.z },
93
+ );
94
+ const hit = world.castRay(ray, 100, true, undefined, undefined, undefined, playerBody ?? undefined);
95
+ if (!hit) return null;
96
+ return [f2(origin.x + dir.x * hit.toi), f2(origin.y + dir.y * hit.toi), f2(origin.z + dir.z * hit.toi)];
97
+ };
98
+
99
+ // Pick the marker nearest the crosshair within the gaze cone. The pick lives
100
+ // in its own function with an explicit return type so `best` isn't narrowed to
101
+ // null (it's only ever reassigned inside the nested scan closure).
102
+ const gazePick = () => {
103
+ if (!draft) return;
104
+ const eye = camera.getWorldPosition(new THREE.Vector3());
105
+ const fwd = camera.getWorldDirection(new THREE.Vector3()).normalize();
106
+ const findBest = (): Selection => {
107
+ let best: Selection = null;
108
+ let bestDot = GAZE_DOT;
109
+ const scan = (kind: MarkerKind, list: { pos: Vec3 }[]) => {
110
+ list.forEach((m, i) => {
111
+ const dx = m.pos[0] - eye.x, dy = m.pos[1] - eye.y, dz = m.pos[2] - eye.z;
112
+ const len = Math.hypot(dx, dy, dz) || 1;
113
+ const dot = (dx / len) * fwd.x + (dy / len) * fwd.y + (dz / len) * fwd.z;
114
+ if (dot > bestDot) { bestDot = dot; best = { kind, index: i }; }
115
+ });
116
+ };
117
+ scan('entryway', draft.entryways);
118
+ scan('exit', draft.exits);
119
+ scan('artifact', draft.artifacts);
120
+ return best;
121
+ };
122
+ const best = findBest();
123
+ setSelected(best);
124
+ setLastCopied(best ? `selected ${best.kind} #${best.index + 1}` : 'nothing under crosshair');
125
+ };
126
+
127
+ const onKey = (e: KeyboardEvent) => {
128
+ // Ignore marking keys while typing into the editor panel's fields.
129
+ const el = document.activeElement as HTMLElement | null;
130
+ if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) {
131
+ return;
132
+ }
133
+ if (e.code === 'KeyC') {
134
+ const s = floorSpot();
135
+ if (!s) { setLastCopied('no floor below — can’t place'); return; }
136
+ if (selected?.kind === 'entryway') {
137
+ updateEntryway(selected.index, { pos: s.pos, yaw: s.yaw });
138
+ setLastCopied(`moved entryway #${selected.index + 1}`);
139
+ } else if (selected?.kind === 'exit') {
140
+ updateExit(selected.index, { pos: s.pos });
141
+ setLastCopied(`moved exit #${selected.index + 1}`);
142
+ } else {
143
+ addEntryway(s.pos, s.yaw);
144
+ setLastCopied('added entryway');
145
+ }
146
+ } else if (e.code === 'KeyB') {
147
+ const p = beamSpot();
148
+ if (!p) { setLastCopied('beam: nothing in view'); return; }
149
+ if (selected?.kind === 'artifact') {
150
+ updateArtifact(selected.index, { pos: p });
151
+ setLastCopied(`moved artifact #${selected.index + 1}`);
152
+ } else {
153
+ addArtifact(p);
154
+ setLastCopied('added artifact — set its URL in the panel');
155
+ }
156
+ liveRef.current = { ...liveRef.current, lastBeam: p };
157
+ } else if (e.code === 'KeyF') {
158
+ gazePick();
159
+ } else if (e.code === 'Delete' || e.code === 'Backspace') {
160
+ if (selected) {
161
+ removeMarker(selected.kind, selected.index);
162
+ setLastCopied(`deleted ${selected.kind}`);
163
+ }
164
+ } else if (e.code === 'KeyX') {
165
+ setSelected(null);
166
+ setLastCopied('cleared selection');
167
+ } else if (e.code === 'KeyZ') {
168
+ toggleSpecter();
169
+ setLastCopied(`specter fly: ${specterRef.current ? 'ON (↑/↓)' : 'OFF'}`);
170
+ }
171
+ };
172
+
173
+ window.addEventListener('keydown', onKey);
174
+ return () => window.removeEventListener('keydown', onKey);
175
+ }, [
176
+ editMode, camera, world, rapier, playerBody, liveRef, setLastCopied, toggleSpecter, specterRef,
177
+ draft, selected, setSelected,
178
+ addEntryway, addArtifact, updateEntryway, updateExit, updateArtifact, removeMarker,
179
+ ]);
180
+
181
+ return null;
182
+ }