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,85 @@
1
+ 'use client';
2
+
3
+ // Artifacts. Lives inside the Canvas. Detects when the player is near an artifact
4
+ // AND looking at it (gaze from the camera/eye, 3D — artifacts can be above/below
5
+ // eye level), reports it active so a DOM hint can show, and opens its URL when E
6
+ // is pressed. The doorway/object in the splat IS the artifact — no visual marker.
7
+
8
+ import { useEffect, useMemo, useRef } from 'react';
9
+ import { useFrame, useThree } from '@react-three/fiber';
10
+ import * as THREE from 'three';
11
+ import { useRapierWorld } from '@/physics';
12
+ import { usePointerLock } from '@/providers/pointerLock';
13
+ import type { Artifact } from '@/data/room';
14
+
15
+ const INTERACT_CODE = 'KeyE';
16
+ const GAZE_DOT = Math.cos((45 * Math.PI) / 180); // within ~45° of looking at it
17
+
18
+ export default function Artifacts({
19
+ artifacts,
20
+ onOpen,
21
+ onActiveChange,
22
+ }: {
23
+ artifacts: Artifact[];
24
+ onOpen: (url: string) => void;
25
+ onActiveChange: (active: boolean) => void;
26
+ }) {
27
+ const { playerBody } = useRapierWorld();
28
+ const { camera } = useThree();
29
+ const { isLocked } = usePointerLock();
30
+ const activeRef = useRef<string | null>(null);
31
+ const fwd = useMemo(() => new THREE.Vector3(), []);
32
+ const eye = useMemo(() => new THREE.Vector3(), []);
33
+
34
+ const setActive = useMemo(
35
+ () => (url: string | null) => {
36
+ if (activeRef.current !== url) {
37
+ activeRef.current = url;
38
+ onActiveChange(url != null);
39
+ }
40
+ },
41
+ [onActiveChange],
42
+ );
43
+
44
+ useFrame(() => {
45
+ if (!playerBody || artifacts.length === 0 || !isLocked) {
46
+ setActive(null);
47
+ return;
48
+ }
49
+ camera.getWorldPosition(eye);
50
+ camera.getWorldDirection(fwd); // normalized
51
+
52
+ let active: string | null = null;
53
+ for (const a of artifacts) {
54
+ const r = a.radius || 1.0;
55
+ const dx = a.pos[0] - eye.x;
56
+ const dy = a.pos[1] - eye.y;
57
+ const dz = a.pos[2] - eye.z;
58
+ const dist2 = dx * dx + dy * dy + dz * dz;
59
+ if (dist2 > r * r) continue;
60
+ const dLen = Math.sqrt(dist2) || 1;
61
+ const dot = (dx / dLen) * fwd.x + (dy / dLen) * fwd.y + (dz / dLen) * fwd.z;
62
+ if (dot >= GAZE_DOT) {
63
+ active = a.url;
64
+ break;
65
+ }
66
+ }
67
+ setActive(active);
68
+ });
69
+
70
+ // Interact key opens the active artifact's URL.
71
+ useEffect(() => {
72
+ const onKey = (e: KeyboardEvent) => {
73
+ if (e.code !== INTERACT_CODE) return;
74
+ const url = activeRef.current;
75
+ if (url) {
76
+ setActive(null);
77
+ onOpen(url);
78
+ }
79
+ };
80
+ window.addEventListener('keydown', onKey);
81
+ return () => window.removeEventListener('keydown', onKey);
82
+ }, [onOpen, setActive]);
83
+
84
+ return null;
85
+ }
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ // Exits. Lives inside the Canvas. Given the current room's exits, it (1) detects
4
+ // when the player is near an exit AND looking at it, reporting it as "active" so a
5
+ // DOM hint ("Press E") can show, and (2) follows the exit's link when E is pressed
6
+ // while one is active. No visual marker — the doorway in the splat IS the exit.
7
+
8
+ import { useEffect, useMemo, useRef } from 'react';
9
+ import { useFrame, useThree } from '@react-three/fiber';
10
+ import * as THREE from 'three';
11
+ import { useRapierWorld } from '@/physics';
12
+ import { usePointerLock } from '@/providers/pointerLock';
13
+ import type { Exit } from '@/data/room';
14
+
15
+ const INTERACT_CODE = 'KeyE';
16
+ const GAZE_DOT = Math.cos((45 * Math.PI) / 180); // within ~45° of looking at it
17
+
18
+ export default function Exits({
19
+ exits,
20
+ onExit,
21
+ onActiveChange,
22
+ }: {
23
+ exits: Exit[];
24
+ onExit: (to: string) => void;
25
+ onActiveChange: (active: boolean) => void;
26
+ }) {
27
+ const { playerBody } = useRapierWorld();
28
+ const { camera } = useThree();
29
+ const { isLocked } = usePointerLock();
30
+ const activeRef = useRef<string | null>(null);
31
+ const armAt = useRef(0); // suppress interaction until performance.now() >= this
32
+ const fwd = useMemo(() => new THREE.Vector3(), []);
33
+
34
+ const setActive = useMemo(
35
+ () => (to: string | null) => {
36
+ if (activeRef.current !== to) {
37
+ activeRef.current = to;
38
+ onActiveChange(to != null);
39
+ }
40
+ },
41
+ [onActiveChange],
42
+ );
43
+
44
+ // On room switch, clear the active exit and suppress interaction for a beat
45
+ // while the player settles into the new room.
46
+ useEffect(() => {
47
+ armAt.current = performance.now() + 600;
48
+ setActive(null);
49
+ }, [exits, setActive]);
50
+
51
+ useFrame(() => {
52
+ if (!playerBody || exits.length === 0 || !isLocked || performance.now() < armAt.current) {
53
+ setActive(null);
54
+ return;
55
+ }
56
+ const p = playerBody.translation();
57
+ camera.getWorldDirection(fwd);
58
+ const fhLen = Math.hypot(fwd.x, fwd.z) || 1;
59
+
60
+ let active: string | null = null;
61
+ for (const e of exits) {
62
+ const r = e.radius || 1.3;
63
+ const dx = e.pos[0] - p.x;
64
+ const dz = e.pos[2] - p.z;
65
+ if (dx * dx + dz * dz > r * r) continue;
66
+ // gaze: is the exit roughly in front of where we're looking?
67
+ const dLen = Math.hypot(dx, dz) || 1;
68
+ const dot = (dx / dLen) * (fwd.x / fhLen) + (dz / dLen) * (fwd.z / fhLen);
69
+ if (dot >= GAZE_DOT) {
70
+ active = e.to;
71
+ break;
72
+ }
73
+ }
74
+ setActive(active);
75
+ });
76
+
77
+ // Interact key follows the active exit's link.
78
+ useEffect(() => {
79
+ const onKey = (e: KeyboardEvent) => {
80
+ if (e.code !== INTERACT_CODE) return;
81
+ const to = activeRef.current;
82
+ if (to && performance.now() >= armAt.current) {
83
+ setActive(null);
84
+ onExit(to);
85
+ }
86
+ };
87
+ window.addEventListener('keydown', onKey);
88
+ return () => window.removeEventListener('keydown', onKey);
89
+ }, [onExit, setActive]);
90
+
91
+ return null;
92
+ }
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { useThree } from '@react-three/fiber';
5
+ import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls.js';
6
+ import { usePointerLockRegistration } from '@/providers/pointerLock';
7
+
8
+ export default function PointerLockBridge() {
9
+ const { camera, gl } = useThree();
10
+ const { register } = usePointerLockRegistration();
11
+
12
+ useEffect(() => {
13
+ const controls = new PointerLockControls(camera, gl.domElement);
14
+
15
+ // optional tuning
16
+ controls.pointerSpeed = 1.0;
17
+ controls.minPolarAngle = 0; // looking straight up/down clamp
18
+ controls.maxPolarAngle = Math.PI; // leave default free
19
+
20
+ register(controls);
21
+ return () => {
22
+ register(null);
23
+ controls.dispose();
24
+ };
25
+ }, [camera, gl, register]);
26
+
27
+ return null;
28
+ }
@@ -0,0 +1,164 @@
1
+ 'use client';
2
+
3
+ import React, { useCallback, useRef } from 'react';
4
+ import { Canvas } from '@react-three/fiber';
5
+
6
+ import SparkLayer from '@/components/spark/SparkLayer';
7
+ import SplatWorld from '@/components/spark/SplatWorld';
8
+ import PlayerController from '@/components/controls/PlayerController';
9
+ import PointerLockBridge from '@/components/scene/PointerLockBridge';
10
+ import TouchLookController from '@/components/controls/TouchLookController';
11
+ import EditCapture from '@/components/edit/EditCapture';
12
+ import Markers from '@/components/edit/Markers';
13
+ import Exits from '@/components/scene/Exits';
14
+ import ArtifactsLayer from '@/components/scene/Artifacts';
15
+ import type { WorldDef } from '@/data/presets';
16
+ import type { Exit, Artifact } from '@/data/room';
17
+
18
+ type Props = {
19
+ world: WorldDef;
20
+ playerMoveSpeed?: number;
21
+ onLoadingChange?: (isLoading: boolean, error?: string) => void;
22
+ mobileInputRef?: React.MutableRefObject<{x:number;y:number}>;
23
+ exits?: Exit[];
24
+ onExit?: (to: string) => void;
25
+ onActiveExitChange?: (active: boolean) => void;
26
+ artifacts?: Artifact[];
27
+ onArtifactOpen?: (url: string) => void;
28
+ onActiveArtifactChange?: (active: boolean) => void;
29
+ spawnYaw?: number;
30
+ spawnKey?: string;
31
+ };
32
+
33
+ function SceneInner({
34
+ world,
35
+ playerMoveSpeed,
36
+ onLoadingChange,
37
+ mobileInputRef,
38
+ exits,
39
+ onExit,
40
+ onActiveExitChange,
41
+ artifacts,
42
+ onArtifactOpen,
43
+ onActiveArtifactChange,
44
+ spawnYaw,
45
+ spawnKey }: Props) {
46
+ const localMobileInputRef = useRef<{x:number;y:number}>({x:0,y:0});
47
+ const inputRef = mobileInputRef || localMobileInputRef;
48
+
49
+ const handleLoadingChange = useCallback((loading: boolean, error?: string) => {
50
+ onLoadingChange?.(loading, error);
51
+ }, [onLoadingChange]);
52
+
53
+ return (
54
+ <>
55
+ {/* FPS-style player controls */}
56
+ <PlayerController
57
+ mobileInputRef={inputRef}
58
+ moveSpeed={playerMoveSpeed}
59
+ spawnYaw={spawnYaw}
60
+ spawnKey={spawnKey}
61
+ />
62
+
63
+ {/* Edit-mode marking capture + marker orbs (no-op unless edit mode) */}
64
+ <EditCapture />
65
+ <Markers />
66
+
67
+ {/* Exits → links to other rooms */}
68
+ {exits && onExit && (
69
+ <Exits
70
+ exits={exits}
71
+ onExit={onExit}
72
+ onActiveChange={onActiveExitChange ?? (() => {})}
73
+ />
74
+ )}
75
+
76
+ {/* Artifacts → open a web URL in an overlay */}
77
+ {artifacts && onArtifactOpen && (
78
+ <ArtifactsLayer
79
+ artifacts={artifacts}
80
+ onOpen={onArtifactOpen}
81
+ onActiveChange={onActiveArtifactChange ?? (() => {})}
82
+ />
83
+ )}
84
+
85
+ {/* Touch-based camera look for mobile */}
86
+ <TouchLookController />
87
+
88
+ {/* Spark renderer + the current Splat world */}
89
+ <SparkLayer />
90
+ <SplatWorld
91
+ key={world.url}
92
+ url={world.url}
93
+ position={world.position}
94
+ quaternion={world.quaternion}
95
+ scale={world.scale}
96
+ onLoadingChange={handleLoadingChange}
97
+ />
98
+
99
+ {/* Usual lighting for mesh-based objects */}
100
+ <ambientLight intensity={0.5} />
101
+ <directionalLight position={[5, 10, 5]} intensity={0.8} />
102
+ </>
103
+ );
104
+ }
105
+
106
+ export default function WorldScene({
107
+ world,
108
+ playerMoveSpeed,
109
+ onLoadingChange,
110
+ mobileInputRef,
111
+ exits,
112
+ onExit,
113
+ onActiveExitChange,
114
+ artifacts,
115
+ onArtifactOpen,
116
+ onActiveArtifactChange,
117
+ spawnYaw,
118
+ spawnKey }: Props) {
119
+ const handleLoadingChange = useCallback((loading: boolean, error?: string) => {
120
+ onLoadingChange?.(loading, error);
121
+ }, [onLoadingChange]);
122
+
123
+ // Cap DPR more aggressively on mobile for performance
124
+ const dprCap = typeof window !== 'undefined' && matchMedia('(pointer: coarse)').matches ? 1.0 : 1.5;
125
+
126
+ return (
127
+ <div className="canvas-root absolute inset-0">
128
+ <Canvas
129
+ // Spark guidance: leave antialias off for better performance with splats
130
+ gl={{
131
+ antialias: false,
132
+ // Avoid preserveDrawingBuffer; it increases memory pressure
133
+ preserveDrawingBuffer: false,
134
+ // Enable context loss recovery
135
+ failIfMajorPerformanceCaveat: false,
136
+ // Power preference for better compatibility
137
+ powerPreference: "high-performance"
138
+ }}
139
+ dpr={[1, dprCap]}
140
+ // Disable shadows for now to reduce GPU pressure
141
+ shadows={false}
142
+ camera={{ fov: 60, near: 0.1, far: 1000, position: [0, 1.2, 3] }}
143
+ >
144
+ <PointerLockBridge />
145
+ <TouchLookController />
146
+
147
+ <SceneInner
148
+ world={world}
149
+ playerMoveSpeed={playerMoveSpeed}
150
+ onLoadingChange={handleLoadingChange}
151
+ mobileInputRef={mobileInputRef}
152
+ exits={exits}
153
+ onExit={onExit}
154
+ onActiveExitChange={onActiveExitChange}
155
+ artifacts={artifacts}
156
+ onArtifactOpen={onArtifactOpen}
157
+ onActiveArtifactChange={onActiveArtifactChange}
158
+ spawnYaw={spawnYaw}
159
+ spawnKey={spawnKey}
160
+ />
161
+ </Canvas>
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,112 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useCallback } from 'react';
4
+ import { useThree } from '@react-three/fiber';
5
+ import type { SparkRenderer } from '@sparkjsdev/spark';
6
+
7
+ /**
8
+ * Adds a SparkRenderer and parents it to the camera.
9
+ * Per docs, putting SparkRenderer under the camera improves precision across large scenes.
10
+ */
11
+ export default function SparkLayer() {
12
+ const { gl, camera, scene, invalidate } = useThree();
13
+ const sparkRef = useRef<SparkRenderer | null>(null);
14
+ // Keep a symbol on WebGLRenderer to avoid double SparkRenderer attachment
15
+ const SPARK_TAG = '__spark_attached__' as const;
16
+ const contextLostRef = useRef(false);
17
+
18
+ const initializeSparkRenderer = useCallback(async () => {
19
+ try {
20
+ // Clean up existing renderer if it exists
21
+ if (sparkRef.current) {
22
+ camera.remove(sparkRef.current);
23
+ sparkRef.current = null;
24
+ }
25
+
26
+ // Ensure we only attach one SparkRenderer per WebGLRenderer
27
+ if ((gl as unknown as Record<string, unknown>)[SPARK_TAG]) {
28
+ console.log('SparkRenderer already attached to this WebGLRenderer');
29
+ return;
30
+ }
31
+
32
+ // Create SparkRenderer with the existing WebGL context (conservative settings)
33
+ const { SparkRenderer } = await import('@sparkjsdev/spark');
34
+ const spark = new SparkRenderer({
35
+ renderer: gl,
36
+ // Lower splat pixel radius to reduce overdraw and memory pressure
37
+ maxPixelRadius: 256,
38
+ // Slightly reduce kernel width to limit very large splats
39
+ maxStdDev: Math.sqrt(6),
40
+ // Keep alpha threshold modest to limit long tails
41
+ minAlpha: 1 / 255,
42
+ });
43
+
44
+ sparkRef.current = spark;
45
+ camera.add(spark); // follows camera; improves float16 locality
46
+ (gl as unknown as Record<string, unknown>)[SPARK_TAG] = true;
47
+
48
+ // ensure camera is in the scene graph
49
+ if (!camera.parent) scene.add(camera);
50
+
51
+ console.log('SparkRenderer initialized successfully');
52
+ // Log GL caps/extensions for diagnostics
53
+ try {
54
+ const webgl2 = (gl.domElement as HTMLCanvasElement).getContext('webgl2') as WebGL2RenderingContext | null;
55
+ const webgl = webgl2 ?? ((gl.domElement as HTMLCanvasElement).getContext('webgl') as WebGLRenderingContext | null);
56
+ const glCtx: WebGLRenderingContext | WebGL2RenderingContext | null = webgl;
57
+ const debugInfo = glCtx ? {
58
+ version: glCtx.getParameter(glCtx.VERSION),
59
+ shadingLanguageVersion: glCtx.getParameter(glCtx.SHADING_LANGUAGE_VERSION),
60
+ vendor: glCtx.getParameter(glCtx.VENDOR),
61
+ renderer: glCtx.getParameter(glCtx.RENDERER),
62
+ maxTextureSize: gl.capabilities.maxTextureSize,
63
+ isWebGL2: 'bindBufferBase' in glCtx,
64
+ } : null;
65
+ console.log('GL diagnostics', debugInfo);
66
+ } catch {}
67
+ contextLostRef.current = false;
68
+
69
+ } catch (error) {
70
+ console.error('Failed to initialize SparkRenderer:', error);
71
+ }
72
+ }, [gl, camera, scene]);
73
+
74
+ useEffect(() => {
75
+ initializeSparkRenderer();
76
+
77
+ // Add WebGL context loss handlers
78
+ const handleContextLost = (event: Event) => {
79
+ console.warn('WebGL context lost, preventing default behavior');
80
+ contextLostRef.current = true;
81
+ event.preventDefault();
82
+ };
83
+
84
+ const handleContextRestored = () => {
85
+ console.log('WebGL context restored, reinitializing...');
86
+ // Small delay to ensure context is fully restored
87
+ setTimeout(() => {
88
+ initializeSparkRenderer();
89
+ invalidate(); // Force a re-render
90
+ // Dispatch a custom event to notify other components
91
+ window.dispatchEvent(new CustomEvent('webgl-context-restored'));
92
+ }, 100);
93
+ };
94
+
95
+ gl.domElement.addEventListener('webglcontextlost', handleContextLost);
96
+ gl.domElement.addEventListener('webglcontextrestored', handleContextRestored);
97
+
98
+ return () => {
99
+ gl.domElement.removeEventListener('webglcontextlost', handleContextLost);
100
+ gl.domElement.removeEventListener('webglcontextrestored', handleContextRestored);
101
+
102
+ if (sparkRef.current) {
103
+ camera.remove(sparkRef.current);
104
+ sparkRef.current = null;
105
+ }
106
+ // Clear the tag so a new SparkRenderer can be attached after dispose
107
+ delete (gl as unknown as Record<string, unknown>)[SPARK_TAG];
108
+ };
109
+ }, [gl, camera, scene, initializeSparkRenderer, invalidate]);
110
+
111
+ return null;
112
+ }
@@ -0,0 +1,156 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, useCallback } from 'react';
4
+ import { useThree } from '@react-three/fiber';
5
+ import type { SplatMesh } from '@sparkjsdev/spark';
6
+
7
+ type Props = {
8
+ url: string;
9
+ position?: [number, number, number];
10
+ quaternion?: [number, number, number, number]; // x,y,z,w
11
+ scale?: number;
12
+ onLoadingChange?: (isLoading: boolean, error?: string) => void;
13
+ };
14
+
15
+ /**
16
+ * Imperatively adds a SplatMesh to the scene (simplest interop).
17
+ * You could also "extend" Spark classes to use JSX <primitive />, but this is robust & minimal.
18
+ */
19
+ export default function SplatWorld({ url, position = [0, 0, 0], quaternion, scale = 1, onLoadingChange }: Props) {
20
+ const { scene } = useThree();
21
+ const meshRef = useRef<SplatMesh | null>(null);
22
+ const [loadError, setLoadError] = useState<string | null>(null);
23
+ const [isLoading, setIsLoading] = useState(true);
24
+
25
+ const loadSplatMesh = useCallback(async () => {
26
+ // token to ignore stale async completions
27
+ const loadToken = crypto.randomUUID();
28
+ (loadSplatMesh as unknown as { currentToken?: string }).currentToken = loadToken;
29
+
30
+ setLoadError(null);
31
+ setIsLoading(true);
32
+
33
+ // Clean up existing mesh first
34
+ if (meshRef.current) {
35
+ scene.remove(meshRef.current);
36
+ try {
37
+ meshRef.current.dispose?.();
38
+ } catch (error) {
39
+ console.warn('Error disposing existing splat mesh:', error);
40
+ }
41
+ meshRef.current = null;
42
+ }
43
+
44
+ try {
45
+ const t0 = performance.now();
46
+ console.log(`Loading splat mesh from: ${url}`);
47
+
48
+ // Check if URL is accessible (for local files)
49
+ let abortController: AbortController | null = null;
50
+ if (url.startsWith('/')) {
51
+ try {
52
+ abortController = new AbortController();
53
+ const response = await fetch(url, { method: 'HEAD', signal: abortController.signal });
54
+ if (!response.ok) {
55
+ throw new Error(`Failed to load ${url}: ${response.status} ${response.statusText}`);
56
+ }
57
+ } catch (fetchError) {
58
+ console.error('Failed to fetch splat file:', fetchError);
59
+ setLoadError(`Failed to load splat file: ${url}`);
60
+ setIsLoading(false);
61
+ return;
62
+ }
63
+ }
64
+
65
+ // If a newer call started, bail out before heavy work
66
+ if ((loadSplatMesh as unknown as { currentToken?: string }).currentToken !== loadToken) {
67
+ abortController?.abort();
68
+ return;
69
+ }
70
+
71
+ const { SplatMesh } = await import('@sparkjsdev/spark');
72
+ const mesh = new SplatMesh({ url });
73
+ meshRef.current = mesh;
74
+ const t1 = performance.now();
75
+ // Wait for mesh to be initialized before setting transform
76
+ try {
77
+ await mesh.initialized;
78
+ const t2 = performance.now();
79
+
80
+ // If a newer load started or component unmounted, dispose and stop
81
+ if ((loadSplatMesh as unknown as { currentToken?: string }).currentToken !== loadToken || !meshRef.current) {
82
+ try { mesh.dispose?.(); } catch {}
83
+ return;
84
+ }
85
+
86
+ console.log('Splat mesh initialized successfully', { createMs: Math.round(t1 - t0), initMs: Math.round(t2 - t1) });
87
+
88
+ mesh.position.set(...position);
89
+ if (quaternion) {
90
+ mesh.quaternion.set(quaternion[0], quaternion[1], quaternion[2], quaternion[3]);
91
+ }
92
+ if (scale !== 1) {
93
+ mesh.scale.setScalar(scale);
94
+ }
95
+
96
+ scene.add(mesh);
97
+ setIsLoading(false);
98
+
99
+ } catch (initError) {
100
+ console.error('Failed to initialize splat mesh:', initError);
101
+ setLoadError(`Failed to initialize splat mesh: ${initError instanceof Error ? initError.message : 'Unknown error'}`);
102
+ setIsLoading(false);
103
+ try { mesh.dispose?.(); } catch {}
104
+ }
105
+
106
+ } catch (error) {
107
+ console.error('Error creating splat mesh:', error);
108
+ setLoadError(`Error loading splat: ${error instanceof Error ? error.message : 'Unknown error'}`);
109
+ setIsLoading(false);
110
+ }
111
+ }, [scene, url, position, quaternion, scale]);
112
+
113
+ useEffect(() => {
114
+ loadSplatMesh();
115
+
116
+ // Listen for WebGL context restoration
117
+ const handleContextRestored = () => {
118
+ console.log('SplatWorld: WebGL context restored, reloading splat mesh');
119
+ setTimeout(() => {
120
+ loadSplatMesh();
121
+ }, 200); // Small delay to ensure SparkRenderer is ready
122
+ };
123
+
124
+ window.addEventListener('webgl-context-restored', handleContextRestored);
125
+
126
+ return () => {
127
+ // Invalidate any in-flight load calls by bumping the token
128
+ (loadSplatMesh as unknown as { currentToken?: string }).currentToken = crypto.randomUUID();
129
+ window.removeEventListener('webgl-context-restored', handleContextRestored);
130
+ if (meshRef.current) {
131
+ scene.remove(meshRef.current);
132
+ // Free GPU memory
133
+ try {
134
+ meshRef.current.dispose?.();
135
+ } catch (error) {
136
+ console.warn('Error disposing splat mesh:', error);
137
+ }
138
+ meshRef.current = null;
139
+ }
140
+ };
141
+ }, [loadSplatMesh, scene]);
142
+
143
+ // Log loading state for debugging and notify parent
144
+ useEffect(() => {
145
+ if (loadError) {
146
+ console.error('SplatWorld load error:', loadError);
147
+ } else if (!isLoading) {
148
+ console.log('SplatWorld loaded successfully');
149
+ }
150
+
151
+ // Notify parent component about loading state changes
152
+ onLoadingChange?.(isLoading, loadError || undefined);
153
+ }, [loadError, isLoading, onLoadingChange]);
154
+
155
+ return null;
156
+ }
@@ -0,0 +1,11 @@
1
+ // Audio configuration
2
+ export const AUDIO_CONFIG = {
3
+ MUSIC_VOLUME: 0.3, // 0.0 to 1.0
4
+ MUSIC_FILES: {
5
+ SUNLIT_GROVE: '/music/Sunlit_Grove_Ambient.mp3',
6
+ },
7
+ // Add more configuration as needed
8
+ FADE_DURATION: 1.0, // seconds for fade in/out
9
+ } as const;
10
+
11
+ export type MusicTrack = keyof typeof AUDIO_CONFIG.MUSIC_FILES;