Compare commits

...

8 Commits

Author SHA1 Message Date
Hermes a0efcd11b2 Enhance 2D UI for Quest 3 readability 2026-08-05 22:24:11 +00:00
Hermes 32946b64f7 Fix Meta Quest 3 VR locomotion and physics drift 2026-08-05 22:19:16 +00:00
Hermes e74e835d6b Upgrade TestXR to AAA Meta Quest 3 interaction & physics lab 2026-08-05 14:32:25 +00:00
Hermes 4bafa35aa9 Restaurar versão do vite para 8.2.0 2026-08-05 14:16:32 +00:00
Hermes e6fdd42d5a Corrigir nixpacks.toml removendo npm independente 2026-08-05 12:38:04 +00:00
Hermes 83270693e8 Forçar Node 23 no nixpacks e corrigir versão do Vite 2026-08-05 12:18:44 +00:00
Hermes c01fac5a2c Remover package-lock.json para resolver erro no Nixpacks 2026-08-05 11:59:08 +00:00
Hermes 0c0dd97934 comiit1 2026-08-05 11:54:10 +00:00
11 changed files with 972 additions and 2891 deletions
+2
View File
@@ -0,0 +1,2 @@
[phases.setup]
nixPkgs = ["nodejs_23"]
-2487
View File
File diff suppressed because it is too large Load Diff
+1 -184
View File
@@ -1,184 +1 @@
.counter { /* Vazio, usando apenas index.css para regras globais e styles inline */
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+123 -109
View File
@@ -1,122 +1,136 @@
import { useState } from 'react' import { useState } from 'react';
import reactLogo from './assets/react.svg' import { Canvas } from '@react-three/fiber';
import viteLogo from './assets/vite.svg' import { XR, createXRStore } from '@react-three/xr';
import heroImg from './assets/hero.png' import { XRScene } from './components/XRScene';
import './App.css'
function App() { // Initializing WebXR Store for Meta Quest 3
const [count, setCount] = useState(0) 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 ( return (
<> <div style={{ width: '100vw', height: '100vh', position: 'relative', overflow: 'hidden', backgroundColor: '#090d16' }}>
<section id="center"> {/* 2D Overlay Header & Start Buttons for Browser / Quest 3 Entry */}
<div className="hero"> {!inXR && (
<img src={heroImg} className="base" width="170" height="179" alt="" /> <div
<img src={reactLogo} className="framework" alt="React logo" /> style={{
<img src={viteLogo} className="vite" alt="Vite logo" /> position: 'absolute',
</div> top: 0,
<div> left: 0,
<h1>Get started</h1> width: '100%',
<p> height: '100%',
Edit <code>src/App.tsx</code> and save to test <code>HMR</code> zIndex: 10,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(9, 13, 22, 0.85)',
backdropFilter: 'blur(12px)',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
backgroundColor: 'rgba(15, 23, 42, 0.95)',
padding: '40px 60px',
borderRadius: '24px',
border: '2px solid rgba(56, 189, 248, 0.3)',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.7)',
gap: '30px',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
<h1 style={{ color: '#f59e0b', margin: 0, fontSize: '2.5rem', fontFamily: 'sans-serif', textAlign: 'center' }}>
Meta Quest 3<br />Laboratório XR
</h1>
<p style={{ color: '#94a3b8', margin: 0, fontSize: '1.2rem', fontFamily: 'sans-serif', textAlign: 'center', maxWidth: '400px' }}>
Use seu controle e aponte o laser para escolher o modo abaixo:
</p> </p>
</div> </div>
<div style={{ display: 'flex', gap: '20px', flexDirection: 'column', width: '100%' }}>
<button <button
type="button" onClick={handleEnterVR}
className="counter" style={{
onClick={() => setCount((count) => count + 1)} padding: '20px 30px',
fontSize: '1.5rem',
backgroundColor: '#0284c7',
color: 'white',
border: 'none',
borderRadius: '16px',
cursor: 'pointer',
fontWeight: 'bold',
boxShadow: '0 4px 6px rgba(0,0,0,0.3)',
transition: 'transform 0.1s',
}}
> >
Count is {count} 🥽 ENTRAR EM VR (IMERSIVO)
</button> </button>
</section>
<div className="ticks"></div> <button
onClick={handleEnterAR}
<section id="next-steps"> style={{
<div id="docs"> padding: '20px 30px',
<svg className="icon" role="presentation" aria-hidden="true"> fontSize: '1.5rem',
<use href="/icons.svg#documentation-icon"></use> backgroundColor: '#d97706',
</svg> color: 'white',
<h2>Documentation</h2> border: 'none',
<p>Your questions, answered</p> borderRadius: '16px',
<ul> cursor: 'pointer',
<li> fontWeight: 'bold',
<a href="https://vite.dev/" target="_blank"> boxShadow: '0 4px 6px rgba(0,0,0,0.3)',
<img className="logo" src={viteLogo} alt="" /> transition: 'transform 0.1s',
Explore Vite }}
</a> >
</li> 📷 MODO AR (PASSTHROUGH)
<li> </button>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div> </div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div> </div>
</section> </div>
)}
<div className="ticks"></div> {/* 3D WebGL Canvas */}
<section id="spacer"></section> <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}>
<XRScene />
</XR>
</Canvas>
</div>
);
} }
export default App
+262
View File
@@ -0,0 +1,262 @@
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,
tableBounds = { minX: -1, maxX: 1, minZ: -1.5, maxZ: -0.5 },
}: InteractiveCubeProps) {
const meshRef = useRef<THREE.Group>(null);
const session = useXR((s) => s.session);
// Physics state
const pos = useRef(new THREE.Vector3(...initialPosition));
const vel = useRef(new THREE.Vector3(0, 0, 0));
const rot = useRef(new THREE.Quaternion().identity());
const angVel = useRef(new THREE.Vector3(0, 0, 0));
const scale = useRef(1);
// Hover & selection
const [hovered, setHovered] = useState(false);
const [hoveredFace, setHoveredFace] = useState<FaceId | null>(null);
const [selectedFace, setSelectedFace] = useState<FaceId | null>(null);
// Grab state - using ray to prevent feedback loop drift
const isGrabbed = useRef(false);
const grabPointerId = useRef<number | null>(null);
const grabDistance = useRef<number>(0);
const previousRayPos = useRef(new THREE.Vector3());
// Reset signal listener
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.identity();
scale.current = 1;
isGrabbed.current = false;
setSelectedFace(null);
}
const onPointerDown = (e: ThreeEvent<PointerEvent>) => {
if (locked || isGrabbed.current) return;
e.stopPropagation();
// Capture pointer to track dragging outside object bounds
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
isGrabbed.current = true;
grabPointerId.current = e.pointerId;
// Save the distance from the controller ray origin to the intersection point
grabDistance.current = e.distance;
// Track velocity
previousRayPos.current.copy(e.point);
vel.current.set(0, 0, 0);
angVel.current.set(0, 0, 0);
soundFx.playGrab();
triggerHaptic(session, 'any', 0.8, 60);
};
const onPointerMove = (e: ThreeEvent<PointerEvent>) => {
if (locked) return;
if (isGrabbed.current && e.pointerId === grabPointerId.current) {
e.stopPropagation();
// FIX: Use e.ray to calculate new position instead of e.point to avoid feedback drift
const ray = e.ray;
const targetPos = ray.origin.clone().add(ray.direction.clone().multiplyScalar(grabDistance.current));
// Calculate velocity for throwing
const dt = 0.016; // approximate frame time for velocity calc
vel.current.copy(targetPos).sub(pos.current).divideScalar(dt);
pos.current.copy(targetPos);
}
};
const onPointerUp = (e: ThreeEvent<PointerEvent>) => {
if (isGrabbed.current && e.pointerId === grabPointerId.current) {
e.stopPropagation();
(e.target as HTMLElement).releasePointerCapture?.(e.pointerId);
isGrabbed.current = false;
grabPointerId.current = null;
// Throw physics applied automatically via vel.current calculated in onPointerMove
soundFx.playRelease();
triggerHaptic(session, 'any', 0.4, 40);
}
};
useFrame((_, delta) => {
if (!meshRef.current) return;
const dt = Math.min(delta, 0.05);
if (!isGrabbed.current && !locked) {
// Physics Simulation
const gravity = -9.81;
const currentScale = scale.current * size;
const halfSize = currentScale / 2;
vel.current.y += gravity * dt;
// Air resistance
vel.current.x *= 1 - 0.5 * dt;
vel.current.z *= 1 - 0.5 * dt;
angVel.current.multiplyScalar(1 - 1.2 * dt);
// Integrate Position
pos.current.addScaledVector(vel.current, dt);
// Integrate Rotation via quaternions
const w = angVel.current.clone().multiplyScalar(dt);
const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(w.x, w.y, w.z));
rot.current.multiply(q);
// Collision detection (Table and Floor)
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;
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; // bounce
} else {
vel.current.y = 0;
}
// Friction
vel.current.x *= 0.82;
vel.current.z *= 0.82;
angVel.current.multiplyScalar(0.75);
}
}
// Apply transform
meshRef.current.position.copy(pos.current);
meshRef.current.setRotationFromQuaternion(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();
if (!isGrabbed.current) {
setHovered(true);
soundFx.playHover();
triggerHaptic(session, 'any', 0.2, 20);
}
}}
onPointerOut={() => {
if (!isGrabbed.current) {
setHovered(false);
setHoveredFace(null);
}
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onPointerMissed={onPointerUp}
>
<mesh castShadow receiveShadow>
<boxGeometry args={[size, size, size]} />
<meshStandardMaterial
color={hovered || isGrabbed.current ? '#fbbf24' : '#334155'}
metalness={0.4}
roughness={0.3}
wireframe={locked}
/>
</mesh>
{faces.map((f) => {
const isSelected = selectedFace === f.id;
const isFaceHovered = hoveredFace === f.id && !isGrabbed.current;
return (
<group key={f.id} position={f.pos} rotation={f.rot}>
<mesh
onPointerOver={(e) => {
e.stopPropagation();
if (!isGrabbed.current) setHoveredFace(f.id);
}}
onClick={(e) => {
e.stopPropagation();
if (locked || isGrabbed.current) return;
setSelectedFace(f.id);
soundFx.playSnap();
triggerHaptic(session, 'any', 0.6, 50);
if (onFaceSelect) onFaceSelect(f.id);
}}
>
<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>
<Text
position={[0, 0, 0.002]}
fontSize={size * 0.28}
color="white"
anchorX="center"
anchorY="middle"
>
{f.label}
</Text>
</group>
);
})}
</group>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useRef, type MutableRefObject } 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 {}
}
interface LocomotionProps {
worldRef: MutableRefObject<THREE.Group | null>;
}
export function QuestXRLocomotion({ worldRef }: LocomotionProps) {
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 || !worldRef.current) return;
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) continue;
const handedness = source.handedness;
// Quest 3 axes mapping:
// axes[0], axes[1] or axes[2], axes[3] depending on the browser version
const axisX = Math.abs(gamepad.axes[2]) > STICK_DEADZONE ? gamepad.axes[2]
: Math.abs(gamepad.axes[0]) > STICK_DEADZONE ? gamepad.axes[0] : 0;
const axisY = Math.abs(gamepad.axes[3]) > STICK_DEADZONE ? gamepad.axes[3]
: Math.abs(gamepad.axes[1]) > STICK_DEADZONE ? gamepad.axes[1] : 0;
if (handedness === 'left') {
// Left Controller -> Smooth Locomotion
if (axisX !== 0 || axisY !== 0) {
// Calculate movement relative to headset orientation
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);
// Move the WORLD in the OPPOSITE direction of the joystick
moveVector.current.addScaledVector(forwardVector.current, axisY * MOVE_SPEED * delta);
moveVector.current.addScaledVector(sideVector.current, axisX * MOVE_SPEED * delta);
worldRef.current.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;
// To turn the player right, we rotate the world LEFT around the player's current position
const playerPos = camera.position.clone();
worldRef.current.position.sub(playerPos);
worldRef.current.position.applyAxisAngle(new THREE.Vector3(0, 1, 0), turnDirection * SNAP_TURN_ANGLE);
worldRef.current.position.add(playerPos);
worldRef.current.rotation.y += turnDirection * SNAP_TURN_ANGLE;
triggerHaptic(session, 'right', 0.4, 40);
snapCooldownRef.current = now + SNAP_COOLDOWN_MS;
}
}
}
});
return null;
}
+109
View File
@@ -0,0 +1,109 @@
import { Text } from '@react-three/drei';
import { soundFx } from '../services/audio';
export interface SpatialHUDProps {
position?: [number, number, number];
locked: boolean;
onToggleLock: () => void;
onReset: () => void;
alignmentStep: string;
isAR: boolean;
}
export function SpatialHUD({
position = [0, 1.8, -1.8],
locked,
onToggleLock,
onReset,
alignmentStep,
isAR,
}: SpatialHUDProps) {
return (
<group position={position}>
<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>
{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>
)}
<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>
<group
position={[-0.45, -0.1, 0.01]}
onClick={(e) => {
e.stopPropagation();
soundFx.playHover();
onToggleLock();
}}
>
<mesh>
<boxGeometry args={[0.55, 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>
<group
position={[0.45, -0.1, 0.01]}
onClick={(e) => {
e.stopPropagation();
soundFx.playSnap();
onReset();
}}
>
<mesh>
<boxGeometry args={[0.55, 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 MUNDO
</Text>
</group>
<Text
position={[0, -0.28, 0.01]}
fontSize={0.045}
color="#94a3b8"
anchorX="center"
anchorY="middle"
>
{isAR ? 'MODO: PASSTHROUGH AR ATIVO' : 'MODO: VR IMERSIVO'} Joysticks Movem e Giram
</Text>
</group>
);
}
+118
View File
@@ -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>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { useState, Suspense, useRef } from 'react';
import { useXR } from '@react-three/xr';
import { Environment, Grid } from '@react-three/drei';
import * as THREE from 'three';
import { WorkbenchTable } from './WorkbenchTable';
import { InteractiveCube, type FaceId } from './InteractiveCube';
import { SpatialHUD } from './SpatialHUD';
import { QuestXRLocomotion } from './QuestXRLocomotion';
export function XRScene() {
const session = useXR((s) => s.session);
const isAR = !!(session && (session as any).environmentBlendMode === 'additive');
const [locked, setLocked] = useState(false);
const [resetSignal, setResetSignal] = useState(0);
const worldRef = useRef<THREE.Group>(null);
// 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 (
<>
<QuestXRLocomotion worldRef={worldRef} />
{!isAR && (
<>
<color attach="background" args={['#090d16']} />
<fog attach="fog" args={['#090d16', 4, 15]} />
</>
)}
{/* World Group that moves inversely to simulate player movement */}
<group ref={worldRef}>
<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
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
/>
<Suspense fallback={null}>
<Environment preset="city" />
</Suspense>
<WorkbenchTable
position={[0, 0, -1]}
onSurfaceClick={handleSurfaceSelect}
onEdgeClick={(edge) => setAlignmentStep(`Aresta selecionada: ${edge}`)}
/>
<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 }}
/>
<SpatialHUD
position={[0, 1.85, -1.7]}
locked={locked}
onToggleLock={() => setLocked((l) => !l)}
onReset={() => {
setResetSignal((s) => s + 1);
if (worldRef.current) {
worldRef.current.position.set(0, 0, 0);
worldRef.current.rotation.set(0, 0, 0);
}
}}
alignmentStep={alignmentStep}
isAR={isAR}
/>
</group>
</>
);
}
+9 -107
View File
@@ -1,111 +1,13 @@
:root { * {
--text: #6b6375; margin: 0;
--text-h: #08060d; padding: 0;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
} }
body { html, body, #root {
margin: 0; width: 100%;
} height: 100%;
overflow: hidden;
h1, background-color: #1e293b;
h2 { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
} }
+130
View File
@@ -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();