diff --git a/src/App.tsx b/src/App.tsx
index af26add..4de025b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,75 +1,120 @@
-import { Canvas } from '@react-three/fiber'
-import { XR, createXRStore } from '@react-three/xr'
-import { Box, Plane, Text } from '@react-three/drei'
+import { useState } from 'react';
+import { Canvas } from '@react-three/fiber';
+import { XR, createXRStore } from '@react-three/xr';
+import { XRScene } from './components/XRScene';
-// Cria a store de estado para o WebXR
-const store = createXRStore()
+// Initializing WebXR Store for Meta Quest 3
+const store = createXRStore();
export default function App() {
+ const [inXR, setInXR] = useState(false);
+
+ const handleEnterVR = async () => {
+ try {
+ await store.enterVR();
+ setInXR(true);
+ } catch (err) {
+ console.warn('Falha ao entrar em VR:', err);
+ }
+ };
+
+ const handleEnterAR = async () => {
+ try {
+ await store.enterAR();
+ setInXR(true);
+ } catch (err) {
+ console.warn('Falha ao entrar em AR Passthrough:', err);
+ // Fallback to VR if AR is not supported
+ handleEnterVR();
+ }
+ };
+
return (
- <>
- {/* Botão de sobreposição para entrar em VR */}
-
-
+ );
}
diff --git a/src/components/InteractiveCube.tsx b/src/components/InteractiveCube.tsx
new file mode 100644
index 0000000..2023180
--- /dev/null
+++ b/src/components/InteractiveCube.tsx
@@ -0,0 +1,294 @@
+import { useRef, useState } from 'react';
+import { useFrame, type ThreeEvent } from '@react-three/fiber';
+import { useXR } from '@react-three/xr';
+import { Text } from '@react-three/drei';
+import * as THREE from 'three';
+import { soundFx } from '../services/audio';
+import { triggerHaptic } from './QuestXRLocomotion';
+
+export type FaceId = 'x+' | 'x-' | 'y+' | 'y-' | 'z+' | 'z-';
+
+export interface InteractiveCubeProps {
+ position: [number, number, number];
+ size?: number;
+ locked: boolean;
+ onFaceSelect?: (face: FaceId) => void;
+ resetSignal: number;
+ tableY?: number;
+ tableBounds?: { minX: number; maxX: number; minZ: number; maxZ: number };
+}
+
+export function InteractiveCube({
+ position: initialPosition,
+ size = 0.3,
+ locked,
+ onFaceSelect,
+ resetSignal,
+ tableY = 0.625, // Height of table top
+ tableBounds = { minX: -1, maxX: 1, minZ: -1.5, maxZ: -0.5 },
+}: InteractiveCubeProps) {
+ const meshRef = useRef(null);
+ const session = useXR((s) => s.session);
+
+ // Cube state & transform
+ const pos = useRef(new THREE.Vector3(...initialPosition));
+ const vel = useRef(new THREE.Vector3(0, 0, 0));
+ const rot = useRef(new THREE.Euler(0, 0, 0));
+ const angVel = useRef(new THREE.Vector3(0, 0, 0));
+ const scale = useRef(1);
+
+ // Interaction tracking
+ const [hovered, setHovered] = useState(false);
+ const [hoveredFace, setHoveredFace] = useState(null);
+ const [selectedFace, setSelectedFace] = useState(null);
+
+ // Grab state
+ const isGrabbed = useRef(false);
+ const grabControllerId = useRef(null);
+ const prevControllerPos = useRef(new THREE.Vector3());
+ const grabOffsetPos = useRef(new THREE.Vector3());
+
+ // Two-hand scale/rotate state
+ const secondGrabControllerId = useRef(null);
+ const initialHandDist = useRef(1);
+ const initialCubeScale = useRef(1);
+
+ // Reset physics signal
+ const lastResetSignal = useRef(resetSignal);
+ if (resetSignal !== lastResetSignal.current) {
+ lastResetSignal.current = resetSignal;
+ pos.current.set(...initialPosition);
+ vel.current.set(0, 0, 0);
+ angVel.current.set(0, 0, 0);
+ rot.current.set(0, 0, 0);
+ scale.current = 1;
+ setSelectedFace(null);
+ if (meshRef.current) {
+ meshRef.current.position.copy(pos.current);
+ meshRef.current.rotation.copy(rot.current);
+ meshRef.current.scale.setScalar(1);
+ }
+ }
+
+ // Handle pointer hover / grab events (works natively with XR rays and hands)
+ const handlePointerDown = (e: ThreeEvent) => {
+ if (locked) return;
+ e.stopPropagation();
+
+ soundFx.playGrab();
+ triggerHaptic(session, 'any', 0.8, 60);
+
+ const controller = (e as any).pointerId ?? 'primary';
+
+ if (!isGrabbed.current) {
+ isGrabbed.current = true;
+ grabControllerId.current = controller;
+
+ if (e.point) {
+ grabOffsetPos.current.copy(pos.current).sub(e.point);
+ } else {
+ grabOffsetPos.current.set(0, 0, 0);
+ }
+ prevControllerPos.current.copy(e.point || pos.current);
+ vel.current.set(0, 0, 0);
+ angVel.current.set(0, 0, 0);
+ } else if (!secondGrabControllerId.current && controller !== grabControllerId.current) {
+ // Second hand grab -> Pinch scale / rotate mode
+ secondGrabControllerId.current = controller;
+ initialCubeScale.current = scale.current;
+ if (e.point) {
+ initialHandDist.current = prevControllerPos.current.distanceTo(e.point) || 0.3;
+ }
+ }
+ };
+
+ const handlePointerUp = (e: ThreeEvent) => {
+ e.stopPropagation();
+ const controller = (e as any).pointerId ?? 'primary';
+
+ if (secondGrabControllerId.current === controller) {
+ secondGrabControllerId.current = null;
+ } else if (grabControllerId.current === controller) {
+ if (secondGrabControllerId.current) {
+ grabControllerId.current = secondGrabControllerId.current;
+ secondGrabControllerId.current = null;
+ } else {
+ isGrabbed.current = false;
+ grabControllerId.current = null;
+
+ soundFx.playRelease();
+ triggerHaptic(session, 'any', 0.4, 40);
+
+ // Apply throw inertia boost
+ vel.current.multiplyScalar(1.2);
+ angVel.current.set(
+ (Math.random() - 0.5) * 4,
+ (Math.random() - 0.5) * 4,
+ (Math.random() - 0.5) * 4
+ );
+ }
+ }
+ };
+
+ const handleFaceClick = (face: FaceId, e: ThreeEvent) => {
+ e.stopPropagation();
+ if (locked) return;
+ setSelectedFace(face);
+ soundFx.playSnap();
+ triggerHaptic(session, 'any', 0.6, 50);
+ if (onFaceSelect) onFaceSelect(face);
+ };
+
+ // Main frame update for Physics & Grabbing
+ useFrame((_, delta) => {
+ if (!meshRef.current) return;
+
+ const clampedDelta = Math.min(delta, 0.05);
+
+ if (isGrabbed.current) {
+ // Calculate controller movement velocity
+ // Mesh is updated via event points or target tracking
+ } else if (!locked) {
+ // PHYSICS SIMULATION
+ const gravity = -9.81;
+ const currentScale = scale.current * size;
+ const halfSize = currentScale / 2;
+
+ // Apply Gravity & Damping
+ vel.current.y += gravity * clampedDelta;
+ vel.current.x *= 1 - 0.5 * clampedDelta;
+ vel.current.z *= 1 - 0.5 * clampedDelta;
+ angVel.current.multiplyScalar(1 - 1.2 * clampedDelta);
+
+ // Integrate Position & Rotation
+ pos.current.addScaledVector(vel.current, clampedDelta);
+ rot.current.x += angVel.current.x * clampedDelta;
+ rot.current.y += angVel.current.y * clampedDelta;
+ rot.current.z += angVel.current.z * clampedDelta;
+
+ // Table Collision Check
+ const isOnTable =
+ pos.current.x >= tableBounds.minX &&
+ pos.current.x <= tableBounds.maxX &&
+ pos.current.z >= tableBounds.minZ &&
+ pos.current.z <= tableBounds.maxZ;
+
+ const surfaceY = isOnTable ? tableY + halfSize : halfSize;
+
+ if (pos.current.y <= surfaceY) {
+ pos.current.y = surfaceY;
+
+ // Bounce / Restitution
+ if (Math.abs(vel.current.y) > 0.4) {
+ soundFx.playImpact(Math.abs(vel.current.y));
+ triggerHaptic(session, 'any', Math.min(Math.abs(vel.current.y) * 0.2, 0.8), 30);
+ vel.current.y = -vel.current.y * 0.35; // 35% restitution
+ } else {
+ vel.current.y = 0;
+ }
+
+ // Friction on surface
+ vel.current.x *= 0.82;
+ vel.current.z *= 0.82;
+ angVel.current.multiplyScalar(0.75);
+ }
+ }
+
+ // Apply transform to 3D object
+ meshRef.current.position.copy(pos.current);
+ meshRef.current.rotation.copy(rot.current);
+ meshRef.current.scale.setScalar(scale.current);
+ });
+
+ const faces: { id: FaceId; pos: [number, number, number]; rot: [number, number, number]; color: string; label: string }[] = [
+ { id: 'x+', pos: [size / 2 + 0.001, 0, 0], rot: [0, Math.PI / 2, 0], color: '#ef4444', label: 'X+' },
+ { id: 'x-', pos: [-size / 2 - 0.001, 0, 0], rot: [0, -Math.PI / 2, 0], color: '#22c55e', label: 'X-' },
+ { id: 'y+', pos: [0, size / 2 + 0.001, 0], rot: [-Math.PI / 2, 0, 0], color: '#eab308', label: 'Y+' },
+ { id: 'y-', pos: [0, -size / 2 - 0.001, 0], rot: [Math.PI / 2, 0, 0], color: '#3b82f6', label: 'Y-' },
+ { id: 'z+', pos: [0, 0, size / 2 + 0.001], rot: [0, 0, 0], color: '#a855f7', label: 'Z+' },
+ { id: 'z-', pos: [0, 0, -size / 2 - 0.001], rot: [0, Math.PI, 0], color: '#f97316', label: 'Z-' },
+ ];
+
+ return (
+ {
+ e.stopPropagation();
+ setHovered(true);
+ soundFx.playHover();
+ triggerHaptic(session, 'any', 0.2, 20);
+ }}
+ onPointerOut={() => {
+ setHovered(false);
+ setHoveredFace(null);
+ }}
+ onPointerDown={handlePointerDown}
+ onPointerUp={handlePointerUp}
+ onPointerMove={(e) => {
+ if (isGrabbed.current && e.point) {
+ const newPos = e.point.clone().add(grabOffsetPos.current);
+ const dt = 0.016;
+ vel.current.copy(newPos).sub(pos.current).divideScalar(dt);
+ pos.current.copy(newPos);
+ }
+ }}
+ >
+ {/* Base Cube Body */}
+
+
+
+
+
+ {/* Faces with colors, labels and snapping feedback */}
+ {faces.map((f) => {
+ const isSelected = selectedFace === f.id;
+ const isFaceHovered = hoveredFace === f.id;
+
+ return (
+
+ {
+ e.stopPropagation();
+ setHoveredFace(f.id);
+ }}
+ onClick={(e) => handleFaceClick(f.id, e)}
+ >
+
+
+
+ {/* Face Label Text */}
+
+ {f.label}
+
+
+ );
+ })}
+
+ {/* Selection Glow Box Indicator */}
+ {selectedFace && (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/QuestXRLocomotion.tsx b/src/components/QuestXRLocomotion.tsx
new file mode 100644
index 0000000..2a15587
--- /dev/null
+++ b/src/components/QuestXRLocomotion.tsx
@@ -0,0 +1,86 @@
+import { useRef } from 'react';
+import { useFrame, useThree } from '@react-three/fiber';
+import { useXR } from '@react-three/xr';
+import * as THREE from 'three';
+
+const STICK_DEADZONE = 0.15;
+const MOVE_SPEED = 1.8;
+const SNAP_TURN_ANGLE = Math.PI / 4; // 45 degrees
+const SNAP_COOLDOWN_MS = 300;
+
+export function triggerHaptic(session: any, hand: 'left' | 'right' | 'any' = 'any', intensity = 0.6, duration = 50) {
+ if (!session) return;
+ try {
+ for (const source of session.inputSources) {
+ if (source.gamepad && source.gamepad.hapticActuators && source.gamepad.hapticActuators.length > 0) {
+ if (hand === 'any' || source.handedness === hand) {
+ source.gamepad.hapticActuators[0].pulse(intensity, duration);
+ }
+ }
+ }
+ } catch {}
+}
+
+export function QuestXRLocomotion() {
+ const { camera } = useThree();
+ const session = useXR((s) => s.session);
+ const snapCooldownRef = useRef(0);
+
+ const moveVector = useRef(new THREE.Vector3());
+ const forwardVector = useRef(new THREE.Vector3());
+ const sideVector = useRef(new THREE.Vector3());
+
+ useFrame((_, delta) => {
+ if (!session) return;
+
+ // Get input sources from WebXR session
+ const inputSources = session.inputSources;
+ if (!inputSources) return;
+
+ const now = performance.now();
+
+ for (const source of inputSources as any) {
+ const gamepad = source.gamepad;
+ if (!gamepad || !gamepad.axes || gamepad.axes.length < 2) continue;
+
+ const handedness = source.handedness;
+
+ // Axis 0 = Thumbstick X, Axis 1 = Thumbstick Y (Standard WebXR Gamepad mapping)
+ const axisX = Math.abs(gamepad.axes[2] ?? gamepad.axes[0]) > STICK_DEADZONE ? (gamepad.axes[2] ?? gamepad.axes[0]) : 0;
+ const axisY = Math.abs(gamepad.axes[3] ?? gamepad.axes[1]) > STICK_DEADZONE ? (gamepad.axes[3] ?? gamepad.axes[1]) : 0;
+
+ if (handedness === 'left') {
+ // Left Controller -> Smooth Locomotion
+ if (axisX !== 0 || axisY !== 0) {
+ // Get camera yaw angle
+ camera.getWorldDirection(forwardVector.current);
+ forwardVector.current.y = 0;
+ forwardVector.current.normalize();
+
+ sideVector.current.crossVectors(camera.up, forwardVector.current).normalize();
+
+ moveVector.current.set(0, 0, 0);
+ moveVector.current.addScaledVector(forwardVector.current, -axisY * MOVE_SPEED * delta);
+ moveVector.current.addScaledVector(sideVector.current, -axisX * MOVE_SPEED * delta);
+
+ camera.position.add(moveVector.current);
+ }
+ } else if (handedness === 'right') {
+ // Right Controller -> Snap Turning
+ if (now > snapCooldownRef.current && Math.abs(axisX) > 0.6) {
+ const turnDirection = axisX > 0 ? -1 : 1; // Right stick right = turn right
+
+ // Rotate camera around Y axis at current camera position
+ const rotationMatrix = new THREE.Matrix4().makeRotationY(turnDirection * SNAP_TURN_ANGLE);
+ camera.position.applyMatrix4(rotationMatrix);
+ camera.rotation.y += turnDirection * SNAP_TURN_ANGLE;
+
+ triggerHaptic(session, 'right', 0.4, 40);
+ snapCooldownRef.current = now + SNAP_COOLDOWN_MS;
+ }
+ }
+ }
+ });
+
+ return null;
+}
diff --git a/src/components/SpatialHUD.tsx b/src/components/SpatialHUD.tsx
new file mode 100644
index 0000000..71f86a9
--- /dev/null
+++ b/src/components/SpatialHUD.tsx
@@ -0,0 +1,139 @@
+import { Text } from '@react-three/drei';
+import { soundFx } from '../services/audio';
+
+export interface SpatialHUDProps {
+ position?: [number, number, number];
+ locked: boolean;
+ onToggleLock: () => void;
+ onReset: () => void;
+ onEnterVR: () => void;
+ onEnterAR: () => void;
+ alignmentStep: string;
+ isAR: boolean;
+}
+
+export function SpatialHUD({
+ position = [0, 1.8, -1.8],
+ locked,
+ onToggleLock,
+ onReset,
+ onEnterVR,
+ onEnterAR,
+ alignmentStep,
+ isAR,
+}: SpatialHUDProps) {
+ return (
+
+ {/* HUD Header Title */}
+
+ LABORATÓRIO XR — META QUEST 3
+
+
+ {/* Alignment Instructions Message */}
+ {alignmentStep && (
+
+
+
+
+
+
+ {alignmentStep}
+
+
+ )}
+
+ {/* Main Spatial Control Card Panel */}
+
+
+
+
+
+ {/* Action Buttons Row */}
+ {/* 1. Lock/Unlock Physics Button */}
+ {
+ e.stopPropagation();
+ soundFx.playHover();
+ onToggleLock();
+ }}
+ >
+
+
+
+
+
+ {locked ? '🔒 FÍSICA: PRESO' : '🔓 FÍSICA: LIVRE'}
+
+
+
+ {/* 2. Reset Position Button */}
+ {
+ e.stopPropagation();
+ soundFx.playSnap();
+ onReset();
+ }}
+ >
+
+
+
+
+
+ 🔄 RESETAR CUBO
+
+
+
+ {/* 3. AR / VR Switcher Button */}
+ {
+ e.stopPropagation();
+ soundFx.playHover();
+ if (isAR) onEnterVR();
+ else onEnterAR();
+ }}
+ >
+
+
+
+
+
+ {isAR ? '🥽 MODO VR (3D)' : '📷 PASSTHROUGH AR'}
+
+
+
+ {/* Status Footer info */}
+
+ Controles: Gatilho/Grip (Agarrar/Arremessar) • Joystick Esq (Mover) • Joystick Dir (Girar 45°)
+
+
+ );
+}
diff --git a/src/components/WorkbenchTable.tsx b/src/components/WorkbenchTable.tsx
new file mode 100644
index 0000000..fbd9a28
--- /dev/null
+++ b/src/components/WorkbenchTable.tsx
@@ -0,0 +1,118 @@
+import { useState } from 'react';
+import { type ThreeEvent } from '@react-three/fiber';
+import { Text } from '@react-three/drei';
+import { soundFx } from '../services/audio';
+
+export interface WorkbenchTableProps {
+ position?: [number, number, number];
+ onSurfaceClick?: (surfaceName: string) => void;
+ onEdgeClick?: (edgeName: string) => void;
+}
+
+export function WorkbenchTable({
+ position = [0, 0, -1],
+ onSurfaceClick,
+ onEdgeClick,
+}: WorkbenchTableProps) {
+ const [hoveredSurface, setHoveredSurface] = useState(false);
+ const [hoveredEdge, setHoveredEdge] = useState(null);
+
+ const handleSurfaceClick = (surfaceName: string, e: ThreeEvent) => {
+ e.stopPropagation();
+ soundFx.playSnap();
+ if (onSurfaceClick) onSurfaceClick(surfaceName);
+ };
+
+ const handleEdgeClick = (edgeName: string, e: ThreeEvent) => {
+ e.stopPropagation();
+ soundFx.playSnap();
+ if (onEdgeClick) onEdgeClick(edgeName);
+ };
+
+ return (
+
+ {/* Principal Workbench Top Plate */}
+ setHoveredSurface(true)}
+ onPointerOut={() => setHoveredSurface(false)}
+ onClick={(e) => handleSurfaceClick('Tampo Principal', e)}
+ >
+
+
+
+
+ {/* Target Alignment Mat Grid on Table Surface */}
+
+
+
+
+
+ {/* Target Placement Circle (Laser Target) */}
+
+
+
+
+ ALVO DE ALINHAMENTO (X:0, Y:0)
+
+
+
+ {/* Lower Shelf */}
+
+
+
+
+
+ {/* 4 Metallic Legs */}
+ {[
+ [-0.92, 0.3, -0.42],
+ [0.92, 0.3, -0.42],
+ [-0.92, 0.3, 0.42],
+ [0.92, 0.3, 0.42],
+ ].map((legPos, i) => (
+
+
+
+
+ ))}
+
+ {/* Glowing Edge Alignment Markers (North, South, East, West) */}
+ {[
+ { name: 'Aresta Frontal', pos: [0, 0.627, 0.49], rot: [0, 0, 0], args: [1.9, 0.02, 0.02] },
+ { name: 'Aresta Traseira', pos: [0, 0.627, -0.49], rot: [0, 0, 0], args: [1.9, 0.02, 0.02] },
+ { name: 'Aresta Esquerda', pos: [-0.99, 0.627, 0], rot: [0, Math.PI / 2, 0], args: [0.9, 0.02, 0.02] },
+ { name: 'Aresta Direita', pos: [0.99, 0.627, 0], rot: [0, Math.PI / 2, 0], args: [0.9, 0.02, 0.02] },
+ ].map((edge, i) => {
+ const isHovered = hoveredEdge === edge.name;
+ return (
+ setHoveredEdge(edge.name)}
+ onPointerOut={() => setHoveredEdge(null)}
+ onClick={(e) => handleEdgeClick(edge.name, e)}
+ >
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/XRScene.tsx b/src/components/XRScene.tsx
new file mode 100644
index 0000000..5ed95fc
--- /dev/null
+++ b/src/components/XRScene.tsx
@@ -0,0 +1,118 @@
+import { useState, Suspense } from 'react';
+import { useXR } from '@react-three/xr';
+import { Environment, Grid } from '@react-three/drei';
+import { WorkbenchTable } from './WorkbenchTable';
+import { InteractiveCube, type FaceId } from './InteractiveCube';
+import { SpatialHUD } from './SpatialHUD';
+import { QuestXRLocomotion } from './QuestXRLocomotion';
+
+export interface XRSceneProps {
+ onEnterVR: () => void;
+ onEnterAR: () => void;
+}
+
+export function XRScene({ onEnterVR, onEnterAR }: XRSceneProps) {
+ const session = useXR((s) => s.session);
+ const isAR = !!(session && (session as any).environmentBlendMode === 'additive');
+
+ const [locked, setLocked] = useState(false);
+ const [resetSignal, setResetSignal] = useState(0);
+
+ // Alignment tracking
+ const [selectedCubeFace, setSelectedCubeFace] = useState(null);
+ const [alignmentStep, setAlignmentStep] = useState(
+ 'Selecione uma face no cubo para iniciar o alinhamento'
+ );
+
+ const handleFaceSelect = (face: FaceId) => {
+ setSelectedCubeFace(face);
+ setAlignmentStep(`Face [${face.toUpperCase()}] selecionada. Clique na superfície do tampo.`);
+ };
+
+ const handleSurfaceSelect = (surfaceName: string) => {
+ if (selectedCubeFace) {
+ setAlignmentStep(`Alinhando Face [${selectedCubeFace.toUpperCase()}] com ${surfaceName}... Concluído!`);
+ setTimeout(() => {
+ setSelectedCubeFace(null);
+ setAlignmentStep('Selecione uma face no cubo para iniciar o alinhamento');
+ }, 3000);
+ }
+ };
+
+ return (
+ <>
+ {/* VR Joystick Locomotion & Snap Turn */}
+
+
+ {/* Dynamic Background Fog & Color for VR Mode */}
+ {!isAR && (
+ <>
+
+
+ >
+ )}
+
+ {/* Lighting Setup */}
+
+
+
+
+
+ {/* Grid Floor */}
+
+
+ {/* Environment Reflections */}
+
+
+
+
+ {/* Workbench Table */}
+ setAlignmentStep(`Aresta selecionada: ${edge}`)}
+ />
+
+ {/* Interactive Physics Cube */}
+
+
+ {/* Floating Spatial HUD */}
+ setLocked((l) => !l)}
+ onReset={() => setResetSignal((s) => s + 1)}
+ onEnterVR={onEnterVR}
+ onEnterAR={onEnterAR}
+ alignmentStep={alignmentStep}
+ isAR={isAR}
+ />
+ >
+ );
+}
diff --git a/src/services/audio.ts b/src/services/audio.ts
new file mode 100644
index 0000000..9adb868
--- /dev/null
+++ b/src/services/audio.ts
@@ -0,0 +1,130 @@
+// Web Audio API Synthesizer for VR/AR Spatial Feedback
+
+class SoundManager {
+ private ctx: AudioContext | null = null;
+
+ private initCtx() {
+ if (!this.ctx) {
+ const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
+ if (AudioCtx) {
+ this.ctx = new AudioCtx();
+ }
+ }
+ if (this.ctx && this.ctx.state === 'suspended') {
+ this.ctx.resume();
+ }
+ }
+
+ // Soft click for UI / hover
+ playHover() {
+ this.initCtx();
+ if (!this.ctx) return;
+ try {
+ const osc = this.ctx.createOscillator();
+ const gain = this.ctx.createGain();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(440, this.ctx.currentTime);
+ osc.frequency.exponentialRampToValueAtTime(880, this.ctx.currentTime + 0.04);
+
+ gain.gain.setValueAtTime(0.05, this.ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.04);
+
+ osc.connect(gain);
+ gain.connect(this.ctx.destination);
+
+ osc.start();
+ osc.stop(this.ctx.currentTime + 0.04);
+ } catch {}
+ }
+
+ // Grab attach sound
+ playGrab() {
+ this.initCtx();
+ if (!this.ctx) return;
+ try {
+ const osc = this.ctx.createOscillator();
+ const gain = this.ctx.createGain();
+ osc.type = 'triangle';
+ osc.frequency.setValueAtTime(220, this.ctx.currentTime);
+ osc.frequency.exponentialRampToValueAtTime(440, this.ctx.currentTime + 0.08);
+
+ gain.gain.setValueAtTime(0.12, this.ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.08);
+
+ osc.connect(gain);
+ gain.connect(this.ctx.destination);
+
+ osc.start();
+ osc.stop(this.ctx.currentTime + 0.08);
+ } catch {}
+ }
+
+ // Release / Throw sound
+ playRelease() {
+ this.initCtx();
+ if (!this.ctx) return;
+ try {
+ const osc = this.ctx.createOscillator();
+ const gain = this.ctx.createGain();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(350, this.ctx.currentTime);
+ osc.frequency.exponentialRampToValueAtTime(180, this.ctx.currentTime + 0.09);
+
+ gain.gain.setValueAtTime(0.1, this.ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.09);
+
+ osc.connect(gain);
+ gain.connect(this.ctx.destination);
+
+ osc.start();
+ osc.stop(this.ctx.currentTime + 0.09);
+ } catch {}
+ }
+
+ // Impact thud on table collision
+ playImpact(intensity = 1) {
+ this.initCtx();
+ if (!this.ctx) return;
+ try {
+ const vol = Math.min(Math.max(intensity * 0.15, 0.02), 0.3);
+ const osc = this.ctx.createOscillator();
+ const gain = this.ctx.createGain();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(140, this.ctx.currentTime);
+ osc.frequency.exponentialRampToValueAtTime(40, this.ctx.currentTime + 0.12);
+
+ gain.gain.setValueAtTime(vol, this.ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.12);
+
+ osc.connect(gain);
+ gain.connect(this.ctx.destination);
+
+ osc.start();
+ osc.stop(this.ctx.currentTime + 0.12);
+ } catch {}
+ }
+
+ // Alignment snap beep
+ playSnap() {
+ this.initCtx();
+ if (!this.ctx) return;
+ try {
+ const osc = this.ctx.createOscillator();
+ const gain = this.ctx.createGain();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(587.33, this.ctx.currentTime); // D5
+ osc.frequency.setValueAtTime(880, this.ctx.currentTime + 0.06); // A5
+
+ gain.gain.setValueAtTime(0.12, this.ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.15);
+
+ osc.connect(gain);
+ gain.connect(this.ctx.destination);
+
+ osc.start();
+ osc.stop(this.ctx.currentTime + 0.15);
+ } catch {}
+ }
+}
+
+export const soundFx = new SoundManager();