From e74e835d6b557966d365b5c23f84fa92d2ffee1c Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 5 Aug 2026 14:32:25 +0000 Subject: [PATCH] Upgrade TestXR to AAA Meta Quest 3 interaction & physics lab --- src/App.tsx | 171 ++++++++++------ src/components/InteractiveCube.tsx | 294 +++++++++++++++++++++++++++ src/components/QuestXRLocomotion.tsx | 86 ++++++++ src/components/SpatialHUD.tsx | 139 +++++++++++++ src/components/WorkbenchTable.tsx | 118 +++++++++++ src/components/XRScene.tsx | 118 +++++++++++ src/services/audio.ts | 130 ++++++++++++ 7 files changed, 993 insertions(+), 63 deletions(-) create mode 100644 src/components/InteractiveCube.tsx create mode 100644 src/components/QuestXRLocomotion.tsx create mode 100644 src/components/SpatialHUD.tsx create mode 100644 src/components/WorkbenchTable.tsx create mode 100644 src/components/XRScene.tsx create mode 100644 src/services/audio.ts 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 */} -
- -
+
+ + Meta Quest 3 — Laboratório XR + + + Física interativa, alinhamento de faces e joysticks ativos + +
- {/* Canvas 3D que ocupa a tela toda */} - + + + + + )} + + {/* 3D WebGL Canvas */} + { + gl.setClearColor(0x000000, 0); + }} + style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }} + > - - - - {/* Chão */} - - - - - {/* Mesa (Representação) */} - - - - - {/* Cubo interativo em cima da mesa */} - - - - - {/* Instruções Flutuantes no Mundo 3D */} - - Laboratório de Alinhamento XR - - + - - ) + + ); } 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();