Upgrade TestXR to AAA Meta Quest 3 interaction & physics lab
This commit is contained in:
+108
-63
@@ -1,75 +1,120 @@
|
|||||||
import { Canvas } from '@react-three/fiber'
|
import { useState } from 'react';
|
||||||
import { XR, createXRStore } from '@react-three/xr'
|
import { Canvas } from '@react-three/fiber';
|
||||||
import { Box, Plane, Text } from '@react-three/drei'
|
import { XR, createXRStore } from '@react-three/xr';
|
||||||
|
import { XRScene } from './components/XRScene';
|
||||||
|
|
||||||
// Cria a store de estado para o WebXR
|
// Initializing WebXR Store for Meta Quest 3
|
||||||
const store = createXRStore()
|
const store = createXRStore();
|
||||||
|
|
||||||
export default function App() {
|
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 (
|
return (
|
||||||
<>
|
<div style={{ width: '100vw', height: '100vh', position: 'relative', overflow: 'hidden', backgroundColor: '#090d16' }}>
|
||||||
{/* Botão de sobreposição para entrar em VR */}
|
{/* 2D Overlay Header & Start Buttons for Browser / Quest 3 Entry */}
|
||||||
<div
|
{!inXR && (
|
||||||
style={{
|
<div
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
pointerEvents: 'none', // Deixa o clique passar para o Canvas 3D
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => store.enterVR()}
|
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: 'auto',
|
position: 'absolute',
|
||||||
padding: '16px 32px',
|
top: '24px',
|
||||||
fontSize: '1.2rem',
|
left: '50%',
|
||||||
backgroundColor: '#0ea5e9',
|
transform: 'translateX(-50%)',
|
||||||
color: 'white',
|
zIndex: 10,
|
||||||
border: 'none',
|
display: 'flex',
|
||||||
borderRadius: '12px',
|
gap: '16px',
|
||||||
cursor: 'pointer',
|
alignItems: 'center',
|
||||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
backgroundColor: 'rgba(15, 23, 42, 0.85)',
|
||||||
fontWeight: 'bold',
|
padding: '12px 24px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Entrar em Realidade Virtual (Quest 3)
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
</button>
|
<span style={{ color: '#f59e0b', fontWeight: 'bold', fontSize: '1rem', fontFamily: 'sans-serif' }}>
|
||||||
</div>
|
Meta Quest 3 — Laboratório XR
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#94a3b8', fontSize: '0.8rem', fontFamily: 'sans-serif' }}>
|
||||||
|
Física interativa, alinhamento de faces e joysticks ativos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Canvas 3D que ocupa a tela toda */}
|
<button
|
||||||
<Canvas style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#1e293b' }}>
|
onClick={handleEnterVR}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
backgroundColor: '#0284c7',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🥽 Entrar em VR
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleEnterAR}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
backgroundColor: '#d97706',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📷 Modo Passthrough AR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3D WebGL Canvas */}
|
||||||
|
<Canvas
|
||||||
|
shadows
|
||||||
|
camera={{ position: [0, 1.5, 1.2], fov: 50 }}
|
||||||
|
gl={{
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
}}
|
||||||
|
onCreated={({ gl }) => {
|
||||||
|
gl.setClearColor(0x000000, 0);
|
||||||
|
}}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
<XR store={store}>
|
<XR store={store}>
|
||||||
<ambientLight intensity={0.6} />
|
<XRScene onEnterVR={handleEnterVR} onEnterAR={handleEnterAR} />
|
||||||
<directionalLight position={[10, 10, 10]} intensity={1.5} castShadow />
|
|
||||||
|
|
||||||
{/* Chão */}
|
|
||||||
<Plane args={[20, 20]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
|
||||||
<meshStandardMaterial color="#475569" />
|
|
||||||
</Plane>
|
|
||||||
|
|
||||||
{/* Mesa (Representação) */}
|
|
||||||
<Box position={[0, 0.5, -1]} args={[2, 1, 1]} castShadow receiveShadow>
|
|
||||||
<meshStandardMaterial color="#8B4513" />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Cubo interativo em cima da mesa */}
|
|
||||||
<Box position={[0, 1.15, -1]} args={[0.3, 0.3, 0.3]} castShadow receiveShadow>
|
|
||||||
<meshStandardMaterial color="#f59e0b" />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Instruções Flutuantes no Mundo 3D */}
|
|
||||||
<Text position={[0, 2, -2]} fontSize={0.2} color="white" anchorX="center" anchorY="middle">
|
|
||||||
Laboratório de Alinhamento XR
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
</XR>
|
</XR>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<THREE.Group>(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<FaceId | null>(null);
|
||||||
|
const [selectedFace, setSelectedFace] = useState<FaceId | null>(null);
|
||||||
|
|
||||||
|
// Grab state
|
||||||
|
const isGrabbed = useRef(false);
|
||||||
|
const grabControllerId = useRef<any>(null);
|
||||||
|
const prevControllerPos = useRef(new THREE.Vector3());
|
||||||
|
const grabOffsetPos = useRef(new THREE.Vector3());
|
||||||
|
|
||||||
|
// Two-hand scale/rotate state
|
||||||
|
const secondGrabControllerId = useRef<any>(null);
|
||||||
|
const initialHandDist = useRef<number>(1);
|
||||||
|
const initialCubeScale = useRef<number>(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<PointerEvent>) => {
|
||||||
|
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<PointerEvent>) => {
|
||||||
|
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<MouseEvent>) => {
|
||||||
|
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 (
|
||||||
|
<group
|
||||||
|
ref={meshRef}
|
||||||
|
onPointerOver={(e) => {
|
||||||
|
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 */}
|
||||||
|
<mesh castShadow receiveShadow>
|
||||||
|
<boxGeometry args={[size, size, size]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={hovered ? '#fbbf24' : '#334155'}
|
||||||
|
metalness={0.4}
|
||||||
|
roughness={0.3}
|
||||||
|
wireframe={locked}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Faces with colors, labels and snapping feedback */}
|
||||||
|
{faces.map((f) => {
|
||||||
|
const isSelected = selectedFace === f.id;
|
||||||
|
const isFaceHovered = hoveredFace === f.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={f.id} position={f.pos} rotation={f.rot}>
|
||||||
|
<mesh
|
||||||
|
onPointerOver={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setHoveredFace(f.id);
|
||||||
|
}}
|
||||||
|
onClick={(e) => handleFaceClick(f.id, e)}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[size * 0.94, size * 0.94]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={f.color}
|
||||||
|
emissive={isSelected ? '#ffffff' : isFaceHovered ? f.color : '#000000'}
|
||||||
|
emissiveIntensity={isSelected ? 0.8 : isFaceHovered ? 0.4 : 0}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
transparent
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Face Label Text */}
|
||||||
|
<Text
|
||||||
|
position={[0, 0, 0.002]}
|
||||||
|
fontSize={size * 0.28}
|
||||||
|
color="white"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Selection Glow Box Indicator */}
|
||||||
|
{selectedFace && (
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[size * 1.05, size * 1.05, size * 1.05]} />
|
||||||
|
<meshBasicMaterial color="#38bdf8" wireframe transparent opacity={0.6} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<group position={position}>
|
||||||
|
{/* HUD Header Title */}
|
||||||
|
<Text
|
||||||
|
position={[0, 0.45, 0]}
|
||||||
|
fontSize={0.14}
|
||||||
|
color="#f59e0b"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
outlineWidth={0.004}
|
||||||
|
outlineColor="#000000"
|
||||||
|
>
|
||||||
|
LABORATÓRIO XR — META QUEST 3
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Alignment Instructions Message */}
|
||||||
|
{alignmentStep && (
|
||||||
|
<group position={[0, 0.25, 0]}>
|
||||||
|
<mesh position={[0, 0, -0.005]}>
|
||||||
|
<planeGeometry args={[1.6, 0.12]} />
|
||||||
|
<meshBasicMaterial color="#0f172a" transparent opacity={0.85} />
|
||||||
|
</mesh>
|
||||||
|
<Text
|
||||||
|
position={[0, 0, 0]}
|
||||||
|
fontSize={0.07}
|
||||||
|
color="#38bdf8"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
>
|
||||||
|
{alignmentStep}
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Spatial Control Card Panel */}
|
||||||
|
<mesh position={[0, -0.1, -0.01]}>
|
||||||
|
<planeGeometry args={[1.8, 0.5]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#0f172a"
|
||||||
|
roughness={0.2}
|
||||||
|
metalness={0.8}
|
||||||
|
transparent
|
||||||
|
opacity={0.85}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Action Buttons Row */}
|
||||||
|
{/* 1. Lock/Unlock Physics Button */}
|
||||||
|
<group
|
||||||
|
position={[-0.55, -0.1, 0.01]}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
soundFx.playHover();
|
||||||
|
onToggleLock();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.45, 0.14, 0.02]} />
|
||||||
|
<meshStandardMaterial color={locked ? '#ef4444' : '#22c55e'} metalness={0.5} roughness={0.3} />
|
||||||
|
</mesh>
|
||||||
|
<Text position={[0, 0, 0.015]} fontSize={0.065} color="white" anchorX="center" anchorY="middle">
|
||||||
|
{locked ? '🔒 FÍSICA: PRESO' : '🔓 FÍSICA: LIVRE'}
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* 2. Reset Position Button */}
|
||||||
|
<group
|
||||||
|
position={[0, -0.1, 0.01]}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
soundFx.playSnap();
|
||||||
|
onReset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.45, 0.14, 0.02]} />
|
||||||
|
<meshStandardMaterial color="#3b82f6" metalness={0.5} roughness={0.3} />
|
||||||
|
</mesh>
|
||||||
|
<Text position={[0, 0, 0.015]} fontSize={0.065} color="white" anchorX="center" anchorY="middle">
|
||||||
|
🔄 RESETAR CUBO
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* 3. AR / VR Switcher Button */}
|
||||||
|
<group
|
||||||
|
position={[0.55, -0.1, 0.01]}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
soundFx.playHover();
|
||||||
|
if (isAR) onEnterVR();
|
||||||
|
else onEnterAR();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.45, 0.14, 0.02]} />
|
||||||
|
<meshStandardMaterial color="#8b5cf6" metalness={0.5} roughness={0.3} />
|
||||||
|
</mesh>
|
||||||
|
<Text position={[0, 0, 0.015]} fontSize={0.065} color="white" anchorX="center" anchorY="middle">
|
||||||
|
{isAR ? '🥽 MODO VR (3D)' : '📷 PASSTHROUGH AR'}
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Status Footer info */}
|
||||||
|
<Text
|
||||||
|
position={[0, -0.28, 0.01]}
|
||||||
|
fontSize={0.045}
|
||||||
|
color="#94a3b8"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
>
|
||||||
|
Controles: Gatilho/Grip (Agarrar/Arremessar) • Joystick Esq (Mover) • Joystick Dir (Girar 45°)
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
|
||||||
|
const handleSurfaceClick = (surfaceName: string, e: ThreeEvent<MouseEvent>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
soundFx.playSnap();
|
||||||
|
if (onSurfaceClick) onSurfaceClick(surfaceName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdgeClick = (edgeName: string, e: ThreeEvent<MouseEvent>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
soundFx.playSnap();
|
||||||
|
if (onEdgeClick) onEdgeClick(edgeName);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={position}>
|
||||||
|
{/* Principal Workbench Top Plate */}
|
||||||
|
<mesh
|
||||||
|
position={[0, 0.6, 0]}
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
onPointerOver={() => setHoveredSurface(true)}
|
||||||
|
onPointerOut={() => setHoveredSurface(false)}
|
||||||
|
onClick={(e) => handleSurfaceClick('Tampo Principal', e)}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[2, 0.05, 1]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={hoveredSurface ? '#475569' : '#1e293b'}
|
||||||
|
metalness={0.8}
|
||||||
|
roughness={0.2}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Target Alignment Mat Grid on Table Surface */}
|
||||||
|
<mesh position={[0, 0.626, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<planeGeometry args={[1.8, 0.8]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#0f172a"
|
||||||
|
roughness={0.9}
|
||||||
|
metalness={0.1}
|
||||||
|
wireframe
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Target Placement Circle (Laser Target) */}
|
||||||
|
<group position={[0, 0.628, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.2, 0.22, 32]} />
|
||||||
|
<meshBasicMaterial color="#38bdf8" transparent opacity={0.8} />
|
||||||
|
<Text position={[0, 0, 0.001]} fontSize={0.05} color="#38bdf8" anchorX="center" anchorY="middle">
|
||||||
|
ALVO DE ALINHAMENTO (X:0, Y:0)
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Lower Shelf */}
|
||||||
|
<mesh position={[0, 0.25, 0]} castShadow receiveShadow>
|
||||||
|
<boxGeometry args={[1.8, 0.03, 0.8]} />
|
||||||
|
<meshStandardMaterial color="#334155" metalness={0.6} roughness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 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) => (
|
||||||
|
<mesh key={i} position={legPos as [number, number, number]} castShadow receiveShadow>
|
||||||
|
<cylinderGeometry args={[0.03, 0.03, 0.6, 16]} />
|
||||||
|
<meshStandardMaterial color="#64748b" metalness={0.9} roughness={0.1} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<mesh
|
||||||
|
key={i}
|
||||||
|
position={edge.pos as [number, number, number]}
|
||||||
|
rotation={edge.rot as [number, number, number]}
|
||||||
|
onPointerOver={() => setHoveredEdge(edge.name)}
|
||||||
|
onPointerOut={() => setHoveredEdge(null)}
|
||||||
|
onClick={(e) => handleEdgeClick(edge.name, e)}
|
||||||
|
>
|
||||||
|
<boxGeometry args={edge.args as [number, number, number]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={isHovered ? '#f59e0b' : '#0284c7'}
|
||||||
|
emissive={isHovered ? '#f59e0b' : '#0284c7'}
|
||||||
|
emissiveIntensity={isHovered ? 0.9 : 0.4}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<FaceId | null>(null);
|
||||||
|
const [alignmentStep, setAlignmentStep] = useState<string>(
|
||||||
|
'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 */}
|
||||||
|
<QuestXRLocomotion />
|
||||||
|
|
||||||
|
{/* Dynamic Background Fog & Color for VR Mode */}
|
||||||
|
{!isAR && (
|
||||||
|
<>
|
||||||
|
<color attach="background" args={['#090d16']} />
|
||||||
|
<fog attach="fog" args={['#090d16', 4, 15]} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lighting Setup */}
|
||||||
|
<ambientLight intensity={isAR ? 1.4 : 0.6} />
|
||||||
|
<directionalLight
|
||||||
|
position={[4, 6, 4]}
|
||||||
|
intensity={isAR ? 1.6 : 1.4}
|
||||||
|
castShadow
|
||||||
|
shadow-mapSize={[2048, 2048]}
|
||||||
|
shadow-bias={-0.0001}
|
||||||
|
/>
|
||||||
|
<pointLight position={[-3, 4, -2]} intensity={0.5} color="#38bdf8" />
|
||||||
|
<pointLight position={[3, 4, -2]} intensity={0.5} color="#f59e0b" />
|
||||||
|
|
||||||
|
{/* Grid Floor */}
|
||||||
|
<Grid
|
||||||
|
position={[0, -0.01, 0]}
|
||||||
|
args={[20, 20]}
|
||||||
|
cellSize={0.25}
|
||||||
|
cellThickness={0.6}
|
||||||
|
cellColor="#334155"
|
||||||
|
sectionSize={1}
|
||||||
|
sectionThickness={1.2}
|
||||||
|
sectionColor="#0284c7"
|
||||||
|
fadeDistance={10}
|
||||||
|
fadeStrength={1.5}
|
||||||
|
infiniteGrid
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Environment Reflections */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Environment preset="city" />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Workbench Table */}
|
||||||
|
<WorkbenchTable
|
||||||
|
position={[0, 0, -1]}
|
||||||
|
onSurfaceClick={handleSurfaceSelect}
|
||||||
|
onEdgeClick={(edge) => setAlignmentStep(`Aresta selecionada: ${edge}`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Interactive Physics Cube */}
|
||||||
|
<InteractiveCube
|
||||||
|
position={[0, 1.2, -1]}
|
||||||
|
size={0.3}
|
||||||
|
locked={locked}
|
||||||
|
onFaceSelect={handleFaceSelect}
|
||||||
|
resetSignal={resetSignal}
|
||||||
|
tableY={0.625}
|
||||||
|
tableBounds={{ minX: -1, maxX: 1, minZ: -1.5, maxZ: -0.5 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating Spatial HUD */}
|
||||||
|
<SpatialHUD
|
||||||
|
position={[0, 1.85, -1.7]}
|
||||||
|
locked={locked}
|
||||||
|
onToggleLock={() => setLocked((l) => !l)}
|
||||||
|
onReset={() => setResetSignal((s) => s + 1)}
|
||||||
|
onEnterVR={onEnterVR}
|
||||||
|
onEnterAR={onEnterAR}
|
||||||
|
alignmentStep={alignmentStep}
|
||||||
|
isAR={isAR}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user