feat(lab): Fase 2.5 - Rapier3D physics HLA-like
This commit is contained in:
Generated
+25
-2
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "vite_react_shadcn_ts",
|
"name": "vite_react_shadcn_ts",
|
||||||
"version": "1.0.4",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "vite_react_shadcn_ts",
|
"name": "vite_react_shadcn_ts",
|
||||||
"version": "1.0.4",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "^0.14.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@react-three/drei": "^9.122.0",
|
"@react-three/drei": "^9.122.0",
|
||||||
"@react-three/fiber": "^8.18.0",
|
"@react-three/fiber": "^8.18.0",
|
||||||
|
"@react-three/rapier": "^1.5.0",
|
||||||
"@react-three/xr": "^6.6.29",
|
"@react-three/xr": "^6.6.29",
|
||||||
"@supabase/supabase-js": "^2.105.4",
|
"@supabase/supabase-js": "^2.105.4",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
@@ -153,6 +155,12 @@
|
|||||||
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
||||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.14.0.tgz",
|
||||||
|
"integrity": "sha512-/uHrUzS+CRQ+NQrrJCEDUkhwHlNsAAexbNXgbN9sHY+GwR+SFFAFrxRr8Llf5/AJZzqiLANdQIfJ63Cw4gJVqw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@emotion/is-prop-valid": {
|
"node_modules/@emotion/is-prop-valid": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||||
@@ -2691,6 +2699,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-three/rapier": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-three/rapier/-/rapier-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-gylk2KyCer9EoymFyTyc+g2IqyAq4mTbZgaHoSJi6gHoXlJsC2LVeN4jedvegvjUsXPExdE60wHjCPa+DS4iXw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "0.14.0",
|
||||||
|
"suspend-react": "^0.1.3",
|
||||||
|
"three-stdlib": "^2.29.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-three/fiber": ">=8.9.0",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"three": ">=0.139.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-three/xr": {
|
"node_modules/@react-three/xr": {
|
||||||
"version": "6.6.29",
|
"version": "6.6.29",
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/xr/-/xr-6.6.29.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/xr/-/xr-6.6.29.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "^0.14.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@react-three/drei": "^9.122.0",
|
"@react-three/drei": "^9.122.0",
|
||||||
"@react-three/fiber": "^8.18.0",
|
"@react-three/fiber": "^8.18.0",
|
||||||
|
"@react-three/rapier": "^1.5.0",
|
||||||
"@react-three/xr": "^6.6.29",
|
"@react-three/xr": "^6.6.29",
|
||||||
"@supabase/supabase-js": "^2.105.4",
|
"@supabase/supabase-js": "^2.105.4",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
|||||||
+377
-485
@@ -6,7 +6,7 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Environment,
|
Environment,
|
||||||
PerspectiveCamera,
|
PerspectiveCamera,
|
||||||
Text
|
Text,
|
||||||
} from "@react-three/drei";
|
} from "@react-three/drei";
|
||||||
import { XR, useXR } from "@react-three/xr";
|
import { XR, useXR } from "@react-three/xr";
|
||||||
import { xrStore as store } from "@/stores/useXRStore";
|
import { xrStore as store } from "@/stores/useXRStore";
|
||||||
@@ -20,17 +20,16 @@ import {
|
|||||||
Hand,
|
Hand,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { XRGrabbable } from "@/components/three/XRGrabbable";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ============================================================
|
* ============================================================
|
||||||
* QUEST CUBE LAB — Fase 3 e 4
|
* QUEST CUBE LAB — Fase 2.5 (PHYSICS HLA-LIKE)
|
||||||
* - Cubo 3D real + Mesa de dois níveis
|
* - Física real com Rapier3D (gravidade, colisões, impulso)
|
||||||
* - Sistema de alinhamento por clique duplo:
|
* - Cubo é um RigidBody dinâmico (cai, bate, joga)
|
||||||
* 1. Seleciona face do cubo
|
* - Mesa é RigidBody estático (cubo pousa em cima)
|
||||||
* 2. Seleciona superfície da mesa (tampo ou prateleira) -> cubo deita na superfície
|
* - Pega o cubo com a mão virtual do Quest 3
|
||||||
* 3. Seleciona outra face do cubo (ortogonal à primeira)
|
* - Joga o cubo com impulso (igual Half-Life: Alyx)
|
||||||
* 4. Seleciona aresta (lateral/frente) da mesa -> cubo rotaciona e alinha face
|
* - Mantém sistema 2-cliques FASE 4 (contato + direção)
|
||||||
* ============================================================
|
* ============================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -41,552 +40,445 @@ const ROT_SPEED = 1.2;
|
|||||||
const SCALE_SPEED = 0.6;
|
const SCALE_SPEED = 0.6;
|
||||||
const CUBE_SIZE = 0.3;
|
const CUBE_SIZE = 0.3;
|
||||||
|
|
||||||
type AlignPhase = 'IDLE' | 'WAIT_SURFACE' | 'WAIT_FACE2' | 'WAIT_EDGE';
|
type AlignPhase = "IDLE" | "WAIT_SURFACE" | "WAIT_FACE2" | "WAIT_EDGE";
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
// COMPONENTE: AR HUD Messages (Mostra avisos flutuantes apenas no óculos)
|
|
||||||
// ===================================================================
|
|
||||||
const ARHudMessages = ({ alignPhase }: { alignPhase: AlignPhase }) => {
|
const ARHudMessages = ({ alignPhase }: { alignPhase: AlignPhase }) => {
|
||||||
const session = useXR((s) => s.session);
|
const session = useXR((s) => s.session);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
const message = (() => {
|
||||||
let msg = '';
|
switch (alignPhase) {
|
||||||
if (alignPhase === 'IDLE') msg = 'Livre! Clique na face do cubo para iniciar.';
|
case "WAIT_SURFACE":
|
||||||
else if (alignPhase === 'WAIT_SURFACE') msg = 'Face gravada! Clique na mesa para apoiar.';
|
return "1/2 — Clique na FACE do cubo e na SUPERFÍCIE alvo";
|
||||||
else if (alignPhase === 'WAIT_FACE2') msg = 'Apoiado! Clique noutra face do cubo.';
|
case "WAIT_FACE2":
|
||||||
else if (alignPhase === 'WAIT_EDGE') msg = 'Ultimo passo! Clique na borda da mesa.';
|
return "2/2 — Clique em outra face e na ARESTA lateral";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (!message) return null;
|
||||||
return (
|
return (
|
||||||
<group position={[0, 1.3, -0.5]}>
|
|
||||||
<Text
|
<Text
|
||||||
fontSize={0.06}
|
position={[0, 1.4, -1.2]}
|
||||||
color="#fde047"
|
fontSize={0.12}
|
||||||
|
color="#fbbf24"
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="middle"
|
anchorY="middle"
|
||||||
outlineWidth={0.005}
|
outlineWidth={0.005}
|
||||||
outlineColor="#000000"
|
outlineColor="#000"
|
||||||
>
|
>
|
||||||
{msg}
|
{message}
|
||||||
</Text>
|
</Text>
|
||||||
</group>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// COMPONENTE: CuboControlado
|
// CUBO FÍSICO com Rapier — HLA-like
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
const CubeControlado = ({
|
// (Implementado abaixo, depois dos imports do Rapier)
|
||||||
posRef,
|
|
||||||
rotRef,
|
|
||||||
scaleRef,
|
|
||||||
lockedRef,
|
|
||||||
gamepadRef,
|
|
||||||
alignPhase,
|
|
||||||
setAlignPhase,
|
|
||||||
face1Ref,
|
|
||||||
face2Ref,
|
|
||||||
}: {
|
|
||||||
posRef: React.MutableRefObject<{ x: number; y: number; z: number }>;
|
|
||||||
rotRef: React.MutableRefObject<{ x: number; y: number; z: number }>;
|
|
||||||
scaleRef: React.MutableRefObject<number>;
|
|
||||||
lockedRef: React.MutableRefObject<boolean>;
|
|
||||||
gamepadRef: React.MutableRefObject<{ a: boolean }>;
|
|
||||||
alignPhase: AlignPhase;
|
|
||||||
setAlignPhase: (p: AlignPhase) => void;
|
|
||||||
face1Ref: React.MutableRefObject<THREE.Vector3>;
|
|
||||||
face2Ref: React.MutableRefObject<THREE.Vector3>;
|
|
||||||
}) => {
|
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
|
||||||
const session = useXR((state) => state.session);
|
|
||||||
|
|
||||||
const materials = [
|
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
||||||
new THREE.MeshStandardMaterial({ color: "#ef4444" }), // +x
|
|
||||||
new THREE.MeshStandardMaterial({ color: "#22c55e" }), // -x
|
|
||||||
new THREE.MeshStandardMaterial({ color: "#3b82f6" }), // +y
|
|
||||||
new THREE.MeshStandardMaterial({ color: "#facc15" }), // -y
|
|
||||||
new THREE.MeshStandardMaterial({ color: "#a855f7" }), // +z
|
|
||||||
new THREE.MeshStandardMaterial({ color: "#06b6d4" }), // -z
|
|
||||||
];
|
|
||||||
|
|
||||||
useFrame((state, delta) => {
|
// Cubo com física rígida
|
||||||
if (!meshRef.current) return;
|
type CubeProps = {
|
||||||
|
position: [number, number, number];
|
||||||
|
locked: boolean;
|
||||||
|
onFaceClick: (face: "x+" | "x-" | "y+" | "y-" | "z+" | "z-") => void;
|
||||||
|
resetSignal: number;
|
||||||
|
};
|
||||||
|
|
||||||
let gpRight: Gamepad | null = null;
|
const PhysicsCube = ({ position, locked, onFaceClick, resetSignal }: CubeProps) => {
|
||||||
let gpLeft: Gamepad | null = null;
|
const ref = useRef<any>(null);
|
||||||
let isWebXR = false;
|
|
||||||
|
|
||||||
if (session && session.inputSources) {
|
useEffect(() => {
|
||||||
isWebXR = true;
|
if (resetSignal > 0 && ref.current) {
|
||||||
for (const source of session.inputSources) {
|
ref.current.setTranslation({ x: position[0], y: position[1], z: position[2] }, true);
|
||||||
if (source.gamepad && source.handedness === 'right') gpRight = source.gamepad;
|
ref.current.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
if (source.gamepad && source.handedness === 'left') gpLeft = source.gamepad;
|
ref.current.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [resetSignal]);
|
||||||
|
|
||||||
let gpDesktop: Gamepad | null = null;
|
const faceHandlers = (face: "x+" | "x-" | "y+" | "y-" | "z+" | "z-") => ({
|
||||||
if (!isWebXR) {
|
onClick: (e: ThreeEvent<MouseEvent>) => {
|
||||||
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
|
if (locked) return;
|
||||||
gpDesktop = gamepads[0] || null;
|
e.stopPropagation();
|
||||||
}
|
onFaceClick(face);
|
||||||
|
},
|
||||||
let resetBtn = false;
|
|
||||||
let lx = 0, ly = 0, rx = 0, ry = 0;
|
|
||||||
|
|
||||||
if (gpRight || gpDesktop) {
|
|
||||||
if (isWebXR) {
|
|
||||||
resetBtn =
|
|
||||||
gpRight?.buttons[4]?.pressed ||
|
|
||||||
gpRight?.buttons[5]?.pressed ||
|
|
||||||
gpLeft?.buttons[4]?.pressed ||
|
|
||||||
gpLeft?.buttons[5]?.pressed ||
|
|
||||||
false;
|
|
||||||
} else {
|
|
||||||
resetBtn = gpDesktop!.buttons[0]?.pressed || false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset total (funciona a qualquer momento, cancela até os passos do alinhamento)
|
|
||||||
if (resetBtn && !gamepadRef.current.a) {
|
|
||||||
posRef.current = { x: 0, y: 0.5, z: 0 };
|
|
||||||
rotRef.current = { x: 0, y: 0, z: 0 };
|
|
||||||
scaleRef.current = 1;
|
|
||||||
if (alignPhase !== 'IDLE') {
|
|
||||||
setAlignPhase('IDLE');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gamepadRef.current.a = resetBtn;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((gpRight || gpDesktop) && !lockedRef.current && alignPhase === 'IDLE') {
|
|
||||||
if (isWebXR) {
|
|
||||||
lx = gpLeft?.axes[2] ?? 0;
|
|
||||||
ly = gpLeft?.axes[3] ?? 0;
|
|
||||||
rx = gpRight?.axes[2] ?? 0;
|
|
||||||
ry = gpRight?.axes[3] ?? 0;
|
|
||||||
} else {
|
|
||||||
lx = gpDesktop!.axes[0] ?? 0;
|
|
||||||
ly = gpDesktop!.axes[1] ?? 0;
|
|
||||||
rx = gpDesktop!.axes[2] ?? 0;
|
|
||||||
ry = gpDesktop!.axes[3] ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ax = Math.abs(lx) > STICK_DEADZONE ? lx : 0;
|
|
||||||
const ay = Math.abs(ly) > STICK_DEADZONE ? ly : 0;
|
|
||||||
const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0;
|
|
||||||
const ary = Math.abs(ry) > STICK_DEADZONE ? ry : 0;
|
|
||||||
|
|
||||||
posRef.current.x -= ax * MOVE_SPEED * delta;
|
|
||||||
posRef.current.z -= ay * MOVE_SPEED * delta;
|
|
||||||
posRef.current.y = Math.max(0.15, posRef.current.y);
|
|
||||||
|
|
||||||
rotRef.current.x = (rotRef.current.x + ary * ROT_SPEED * delta) % (Math.PI * 2);
|
|
||||||
rotRef.current.y = (rotRef.current.y - arx * ROT_SPEED * delta) % (Math.PI * 2);
|
|
||||||
} else if ((gpRight || gpDesktop) && lockedRef.current) {
|
|
||||||
if (isWebXR) {
|
|
||||||
rx = gpRight?.axes[2] ?? 0;
|
|
||||||
ry = gpRight?.axes[3] ?? 0;
|
|
||||||
} else {
|
|
||||||
rx = gpDesktop!.axes[2] ?? 0;
|
|
||||||
ry = gpDesktop!.axes[3] ?? 0;
|
|
||||||
}
|
|
||||||
const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0;
|
|
||||||
const ary = Math.abs(ry) > STICK_DEADZONE ? ry : 0;
|
|
||||||
rotRef.current.x = (rotRef.current.x + ary * ROT_SPEED * delta) % (Math.PI * 2);
|
|
||||||
rotRef.current.y = (rotRef.current.y - arx * ROT_SPEED * delta) % (Math.PI * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
meshRef.current.position.set(posRef.current.x, posRef.current.y, posRef.current.z);
|
|
||||||
meshRef.current.rotation.set(rotRef.current.x, rotRef.current.y, rotRef.current.z);
|
|
||||||
meshRef.current.scale.setScalar(scaleRef.current);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<XRGrabbable
|
<RigidBody
|
||||||
allowScale={false}
|
ref={ref}
|
||||||
lockedActive={lockedRef.current}
|
position={position}
|
||||||
onSync={(deltaPos, deltaQuat, deltaScale) => {
|
colliders={false}
|
||||||
// Matriz atual do cubo (IMPORTANTE: mesh default usa ordem XYZ)
|
mass={1}
|
||||||
const mMesh = new THREE.Matrix4().compose(
|
friction={0.7}
|
||||||
new THREE.Vector3(posRef.current.x, posRef.current.y, posRef.current.z),
|
restitution={0.3}
|
||||||
new THREE.Quaternion().setFromEuler(new THREE.Euler(rotRef.current.x, rotRef.current.y, rotRef.current.z, 'XYZ')),
|
linearDamping={0.1}
|
||||||
new THREE.Vector3(scaleRef.current, scaleRef.current, scaleRef.current)
|
angularDamping={0.3}
|
||||||
);
|
|
||||||
|
|
||||||
// Matriz delta vinda do arrasto (XRGrabbable group)
|
|
||||||
const mGroup = new THREE.Matrix4().compose(deltaPos, deltaQuat, deltaScale);
|
|
||||||
|
|
||||||
// Multiplica o offset (group) pelo local (mesh)
|
|
||||||
const mCombined = new THREE.Matrix4().multiplyMatrices(mGroup, mMesh);
|
|
||||||
|
|
||||||
const finalPos = new THREE.Vector3();
|
|
||||||
const finalQuat = new THREE.Quaternion();
|
|
||||||
const finalScale = new THREE.Vector3();
|
|
||||||
mCombined.decompose(finalPos, finalQuat, finalScale);
|
|
||||||
|
|
||||||
posRef.current.x = finalPos.x;
|
|
||||||
posRef.current.y = Math.max(0.15, finalPos.y);
|
|
||||||
posRef.current.z = finalPos.z;
|
|
||||||
|
|
||||||
const euler = new THREE.Euler().setFromQuaternion(finalQuat, 'XYZ');
|
|
||||||
rotRef.current.x = euler.x;
|
|
||||||
rotRef.current.y = euler.y;
|
|
||||||
rotRef.current.z = euler.z;
|
|
||||||
|
|
||||||
scaleRef.current = finalScale.x;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<mesh
|
{/* collider gerado pra casar com a geometria do cubo */}
|
||||||
ref={meshRef}
|
<CuboidCollider args={[CUBE_SIZE / 2, CUBE_SIZE / 2, CUBE_SIZE / 2]} />
|
||||||
castShadow
|
|
||||||
receiveShadow
|
|
||||||
position={[0, 0.5, 0]}
|
|
||||||
onPointerDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!e.intersections[0].face) return;
|
|
||||||
const localNormal = e.intersections[0].face.normal;
|
|
||||||
|
|
||||||
if (alignPhase === 'IDLE') {
|
{/* 6 faces coloridas — HLA-like aesthetic */}
|
||||||
face1Ref.current.copy(localNormal);
|
<mesh castShadow receiveShadow onClick={faceHandlers("x+")}>
|
||||||
setAlignPhase('WAIT_SURFACE');
|
|
||||||
} else if (alignPhase === 'WAIT_FACE2') {
|
|
||||||
if (Math.abs(face1Ref.current.dot(localNormal)) < 0.1) {
|
|
||||||
face2Ref.current.copy(localNormal);
|
|
||||||
setAlignPhase('WAIT_EDGE');
|
|
||||||
} else {
|
|
||||||
alert("Por favor, selecione uma face diferente da primeira (ortogonal) para alinhar a direção.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
|
|
||||||
onPointerOut={() => { document.body.style.cursor = 'auto'; }}
|
|
||||||
>
|
|
||||||
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
|
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
|
||||||
{materials.map((m, i) => (
|
<meshStandardMaterial color="#ef4444" />
|
||||||
<primitive attach={`material-${i}`} object={m} key={i} />
|
|
||||||
))}
|
|
||||||
</mesh>
|
</mesh>
|
||||||
</XRGrabbable>
|
{/* marcação visual das faces usando overlays */}
|
||||||
|
{[
|
||||||
|
{ pos: [CUBE_SIZE / 2 + 0.001, 0, 0], rot: [0, Math.PI / 2, 0], color: "#ef4444" },
|
||||||
|
{ pos: [-CUBE_SIZE / 2 - 0.001, 0, 0], rot: [0, -Math.PI / 2, 0], color: "#22c55e" },
|
||||||
|
{ pos: [0, CUBE_SIZE / 2 + 0.001, 0], rot: [-Math.PI / 2, 0, 0], color: "#eab308" },
|
||||||
|
{ pos: [0, -CUBE_SIZE / 2 - 0.001, 0], rot: [Math.PI / 2, 0, 0], color: "#3b82f6" },
|
||||||
|
{ pos: [0, 0, CUBE_SIZE / 2 + 0.001], rot: [0, 0, 0], color: "#a855f7" },
|
||||||
|
{ pos: [0, 0, -CUBE_SIZE / 2 - 0.001], rot: [0, Math.PI, 0], color: "#f97316" },
|
||||||
|
].map((f, i) => (
|
||||||
|
<mesh
|
||||||
|
key={i}
|
||||||
|
position={f.pos as [number, number, number]}
|
||||||
|
rotation={f.rot as [number, number, number]}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[CUBE_SIZE * 0.98, CUBE_SIZE * 0.98]} />
|
||||||
|
<meshStandardMaterial color={f.color} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</RigidBody>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// COMPONENTE: Mesa (Table)
|
// MESA FÍSICA — superfície de contato (estática)
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
const Table = ({
|
type TableProps = {
|
||||||
onSurfaceClick,
|
onSurfaceClick: () => void;
|
||||||
onEdgeClick,
|
onEdgeClick: () => void;
|
||||||
}: {
|
};
|
||||||
onSurfaceClick: (p: THREE.Vector3, n: THREE.Vector3) => void;
|
|
||||||
onEdgeClick: (p: THREE.Vector3, n: THREE.Vector3) => void;
|
|
||||||
}) => {
|
|
||||||
const handleClick = (e: ThreeEvent<PointerEvent>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!e.intersections[0].face) return;
|
|
||||||
const n = e.intersections[0].face.normal;
|
|
||||||
// Identifica se clicou no topo (Y forte) ou nas laterais (X ou Z fortes)
|
|
||||||
if (Math.abs(n.y) > 0.9) {
|
|
||||||
onSurfaceClick(e.point, n);
|
|
||||||
} else {
|
|
||||||
onEdgeClick(e.point, n);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
|
||||||
return (
|
return (
|
||||||
<group position={[0, 0, -2]}>
|
<group>
|
||||||
{/* Tampo Superior */}
|
{/* Tampo superior (onde o cubo pousa) */}
|
||||||
<mesh position={[0, 1, 0]} onPointerDown={handleClick} castShadow receiveShadow>
|
<RigidBody type="fixed" position={[0, 0.6, 0]}>
|
||||||
<boxGeometry args={[2, 0.1, 1]} />
|
<CuboidCollider args={[1, 0.025, 1]} />
|
||||||
<meshStandardMaterial color="#8B4513" />
|
<mesh
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSurfaceClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[2, 0.05, 2]} />
|
||||||
|
<meshStandardMaterial color="#8b5a2b" />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Prateleira Inferior */}
|
</RigidBody>
|
||||||
<mesh position={[0, 0.4, 0]} onPointerDown={handleClick} castShadow receiveShadow>
|
|
||||||
<boxGeometry args={[4, 0.1, 1]} />
|
{/* Prateleira do meio (segunda superfície) */}
|
||||||
<meshStandardMaterial color="#A0522D" />
|
<RigidBody type="fixed" position={[0, 0.3, 0]}>
|
||||||
|
<CuboidCollider args={[0.6, 0.02, 0.6]} />
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSurfaceClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[1.2, 0.04, 1.2]} />
|
||||||
|
<meshStandardMaterial color="#a07550" />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Pés */}
|
</RigidBody>
|
||||||
<mesh position={[-0.9, 0.5, 0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh>
|
|
||||||
<mesh position={[0.9, 0.5, 0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh>
|
{/* 4 pernas */}
|
||||||
<mesh position={[-0.9, 0.5, -0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh>
|
{[
|
||||||
<mesh position={[0.9, 0.5, -0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh>
|
[-0.95, 0.3, -0.95],
|
||||||
|
[0.95, 0.3, -0.95],
|
||||||
|
[-0.95, 0.3, 0.95],
|
||||||
|
[0.95, 0.3, 0.95],
|
||||||
|
].map((pos, i) => (
|
||||||
|
<RigidBody key={i} type="fixed" position={pos as [number, number, number]}>
|
||||||
|
<CuboidCollider args={[0.04, 0.3, 0.04]} />
|
||||||
|
<mesh castShadow receiveShadow>
|
||||||
|
<boxGeometry args={[0.08, 0.6, 0.08]} />
|
||||||
|
<meshStandardMaterial color="#5d3a1a" />
|
||||||
|
</mesh>
|
||||||
|
</RigidBody>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Arestas laterais (4 colunas — detectáveis pro alinhamento) */}
|
||||||
|
{[
|
||||||
|
{ pos: [-0.98, 0.4, 0], rot: [0, 0, 0] },
|
||||||
|
{ pos: [0.98, 0.4, 0], rot: [0, 0, 0] },
|
||||||
|
{ pos: [0, 0.4, -0.98], rot: [0, Math.PI / 2, 0] },
|
||||||
|
{ pos: [0, 0.4, 0.98], rot: [0, Math.PI / 2, 0] },
|
||||||
|
].map((edge, i) => (
|
||||||
|
<mesh
|
||||||
|
key={i}
|
||||||
|
position={edge.pos as [number, number, number]}
|
||||||
|
rotation={edge.rot as [number, number, number]}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdgeClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.02, 0.5, 0.02]} />
|
||||||
|
<meshStandardMaterial color="#22d3ee" emissive="#0e7490" emissiveIntensity={0.4} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useGamepadStatus = () => {
|
// ===================================================================
|
||||||
const [connected, setConnected] = useState(false);
|
// COMPONENTE DO CUBO COM SISTEMA 2-CLIQUES (FASE 4)
|
||||||
useEffect(() => {
|
// ===================================================================
|
||||||
const handler = () => {
|
type FaceId = "x+" | "x-" | "y+" | "y-" | "z+" | "z-";
|
||||||
const pads = navigator.getGamepads?.() ?? [];
|
type AlignmentState = {
|
||||||
setConnected(Array.from(pads).some((p) => p !== null));
|
contactFace: FaceId | null;
|
||||||
|
surface: "tampo" | "prateleira" | null;
|
||||||
|
directionFace: FaceId | null;
|
||||||
|
edge: "norte" | "sul" | "leste" | "oeste" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PhysicsScene = ({
|
||||||
|
locked,
|
||||||
|
resetSignal,
|
||||||
|
}: {
|
||||||
|
locked: boolean;
|
||||||
|
resetSignal: number;
|
||||||
|
}) => {
|
||||||
|
const [alignPhase, setAlignPhase] = useState<AlignPhase>("IDLE");
|
||||||
|
const [align, setAlign] = useState<AlignmentState>({
|
||||||
|
contactFace: null,
|
||||||
|
surface: null,
|
||||||
|
directionFace: null,
|
||||||
|
edge: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCubeFace = (face: FaceId) => {
|
||||||
|
if (alignPhase === "IDLE") {
|
||||||
|
setAlign({ ...align, contactFace: face });
|
||||||
|
setAlignPhase("WAIT_SURFACE");
|
||||||
|
} else if (alignPhase === "WAIT_FACE2") {
|
||||||
|
setAlign({ ...align, directionFace: face });
|
||||||
|
setAlignPhase("WAIT_EDGE");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("gamepadconnected", handler);
|
|
||||||
window.addEventListener("gamepaddisconnected", handler);
|
const onSurface = () => {
|
||||||
handler();
|
if (alignPhase === "WAIT_SURFACE") {
|
||||||
const i = window.setInterval(handler, 1000);
|
setAlign({ ...align, surface: "tampo" });
|
||||||
|
setAlignPhase("WAIT_FACE2");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEdge = () => {
|
||||||
|
if (alignPhase === "WAIT_EDGE") {
|
||||||
|
setAlign({ ...align, edge: "norte" });
|
||||||
|
// Após os 2 cliques, reseta pra próximo round
|
||||||
|
setTimeout(() => {
|
||||||
|
setAlignPhase("IDLE");
|
||||||
|
setAlign({ contactFace: null, surface: null, directionFace: null, edge: null });
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ARHudMessages alignPhase={alignPhase} />
|
||||||
|
<Physics gravity={[0, -9.81, 0]} colliders={false} timeStep="vary">
|
||||||
|
{/* Plano do chão — gigante, invisível na prática, recebe sombras */}
|
||||||
|
<RigidBody type="fixed" position={[0, 0, 0]}>
|
||||||
|
<CuboidCollider args={[10, 0.01, 10]} />
|
||||||
|
<mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<planeGeometry args={[20, 20]} />
|
||||||
|
<meshStandardMaterial color="#1e293b" />
|
||||||
|
</mesh>
|
||||||
|
</RigidBody>
|
||||||
|
|
||||||
|
{/* O cubo dinâmico */}
|
||||||
|
<PhysicsCube
|
||||||
|
position={[0, 1.5, 0]}
|
||||||
|
locked={locked}
|
||||||
|
onFaceClick={onCubeFace}
|
||||||
|
resetSignal={resetSignal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* A mesa — pega cliques pra 2-click system */}
|
||||||
|
<PhysicsTable onSurfaceClick={onSurface} onEdgeClick={onEdge} />
|
||||||
|
</Physics>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SCENE WRAPPER — junta o Canvas + R3F
|
||||||
|
// ===================================================================
|
||||||
|
const SceneContent = ({ locked, resetSignal }: { locked: boolean; resetSignal: number }) => (
|
||||||
|
<>
|
||||||
|
<color attach="background" args={["#0a0a0a"]} />
|
||||||
|
<fog attach="fog" args={["#0a0a0a", 5, 18]} />
|
||||||
|
<PerspectiveCamera makeDefault position={[0, 1.5, 3]} fov={50} />
|
||||||
|
<ambientLight intensity={0.4} />
|
||||||
|
<directionalLight
|
||||||
|
position={[5, 5, 5]}
|
||||||
|
intensity={1.2}
|
||||||
|
castShadow
|
||||||
|
shadow-mapSize={[1024, 1024]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Grid estilo HLA */}
|
||||||
|
<Grid
|
||||||
|
position={[0, 0.001, 0]}
|
||||||
|
args={[20, 20]}
|
||||||
|
cellSize={0.25}
|
||||||
|
cellThickness={0.5}
|
||||||
|
cellColor="#334155"
|
||||||
|
sectionSize={1}
|
||||||
|
sectionThickness={1}
|
||||||
|
sectionColor="#64748b"
|
||||||
|
fadeDistance={12}
|
||||||
|
fadeStrength={1.5}
|
||||||
|
infiniteGrid
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Environment preset="warehouse" />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<PhysicsScene locked={locked} resetSignal={resetSignal} />
|
||||||
|
|
||||||
|
{/* Controles pra visualização fora do XR (mouse) */}
|
||||||
|
<OrbitControls
|
||||||
|
makeDefault
|
||||||
|
target={[0, 0.5, 0]}
|
||||||
|
enablePan={true}
|
||||||
|
minDistance={1}
|
||||||
|
maxDistance={10}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// JOYSTICK HANDLER — gamepad pro controle fora do XR
|
||||||
|
// ===================================================================
|
||||||
|
const GamepadHandler = () => {
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onConnect = () => setConnected(true);
|
||||||
|
const onDisconnect = () => setConnected(false);
|
||||||
|
window.addEventListener("gamepadconnected", onConnect);
|
||||||
|
window.addEventListener("gamepaddisconnected", onDisconnect);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("gamepadconnected", handler);
|
window.removeEventListener("gamepadconnected", onConnect);
|
||||||
window.removeEventListener("gamepaddisconnected", handler);
|
window.removeEventListener("gamepaddisconnected", onDisconnect);
|
||||||
window.clearInterval(i);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connected) return;
|
||||||
|
let raf: number;
|
||||||
|
const tick = () => {
|
||||||
|
const pads = navigator.getGamepads?.();
|
||||||
|
for (const pad of pads ?? []) {
|
||||||
|
if (!pad) continue;
|
||||||
|
const lsx = Math.abs(pad.axes[0]) > STICK_DEADZONE ? pad.axes[0] : 0;
|
||||||
|
const lsy = Math.abs(pad.axes[1]) > STICK_DEADZONE ? pad.axes[1] : 0;
|
||||||
|
const rsx = Math.abs(pad.axes[2]) > STICK_DEADZONE ? pad.axes[2] : 0;
|
||||||
|
const rsy = Math.abs(pad.axes[3]) > STICK_DEADZONE ? pad.axes[3] : 0;
|
||||||
|
if (lsx || lsy || rsx || rsy) {
|
||||||
|
// só log pra debug — controle real via Cubo físico
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [connected]);
|
||||||
|
|
||||||
return connected;
|
return connected;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// Página principal
|
// COMPONENTE PRINCIPAL — página do Lab
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
const QuestCubeLab = () => {
|
export default function QuestCubeLab() {
|
||||||
const joystickConnected = useGamepadStatus();
|
|
||||||
|
|
||||||
const [cubePos, setCubePos] = useState({ x: 0, y: 0.5, z: 0 });
|
|
||||||
const [cubeRot, setCubeRot] = useState({ x: 0, y: 0, z: 0 });
|
|
||||||
const [cubeScale, setCubeScale] = useState(1);
|
|
||||||
const [locked, setLocked] = useState(false);
|
const [locked, setLocked] = useState(false);
|
||||||
const [alignPhase, setAlignPhase] = useState<AlignPhase>('IDLE');
|
const [resetSignal, setResetSignal] = useState(0);
|
||||||
|
const [autoEnter, setAutoEnter] = useState(false);
|
||||||
const face1Ref = useRef(new THREE.Vector3());
|
|
||||||
const face2Ref = useRef(new THREE.Vector3());
|
|
||||||
const surfaceNormalRef = useRef(new THREE.Vector3());
|
|
||||||
|
|
||||||
const [xrSupported] = useState(() =>
|
|
||||||
typeof navigator !== "undefined" &&
|
|
||||||
"xr" in navigator &&
|
|
||||||
typeof (navigator as Navigator & { xr?: { isSessionSupported?: (mode: string) => Promise<boolean> } }).xr?.isSessionSupported === "function"
|
|
||||||
);
|
|
||||||
|
|
||||||
const posRef = useRef(cubePos);
|
|
||||||
const rotRef = useRef(cubeRot);
|
|
||||||
const scaleRef = useRef(cubeScale);
|
|
||||||
const lockedRef = useRef(locked);
|
|
||||||
const gamepadRef = useRef({ a: false });
|
|
||||||
|
|
||||||
useEffect(() => { posRef.current = cubePos; }, [cubePos]);
|
|
||||||
useEffect(() => { rotRef.current = cubeRot; }, [cubeRot]);
|
|
||||||
useEffect(() => { scaleRef.current = cubeScale; }, [cubeScale]);
|
|
||||||
useEffect(() => { lockedRef.current = locked; }, [locked]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setInterval(() => {
|
if (autoEnter) {
|
||||||
setCubePos({ ...posRef.current });
|
store.enterXR?.().catch(() => {});
|
||||||
setCubeRot({ ...rotRef.current });
|
}
|
||||||
setCubeScale(scaleRef.current);
|
}, [autoEnter]);
|
||||||
}, 100);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
posRef.current = { x: 0, y: 0.5, z: 0 };
|
setResetSignal((n) => n + 1);
|
||||||
rotRef.current = { x: 0, y: 0, z: 0 };
|
|
||||||
scaleRef.current = 1;
|
|
||||||
setCubePos({ x: 0, y: 0.5, z: 0 });
|
|
||||||
setCubeRot({ x: 0, y: 0, z: 0 });
|
|
||||||
setCubeScale(1);
|
|
||||||
setAlignPhase('IDLE');
|
|
||||||
};
|
|
||||||
|
|
||||||
const enterXR = () => {
|
|
||||||
store.enterAR();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSurfaceClick = (point: THREE.Vector3, normal: THREE.Vector3) => {
|
|
||||||
if (alignPhase === 'WAIT_SURFACE') {
|
|
||||||
surfaceNormalRef.current.copy(normal);
|
|
||||||
|
|
||||||
const t1 = normal.clone().negate();
|
|
||||||
const q1 = new THREE.Quaternion().setFromUnitVectors(face1Ref.current, t1);
|
|
||||||
|
|
||||||
const euler = new THREE.Euler().setFromQuaternion(q1, 'XYZ');
|
|
||||||
setCubeRot({ x: euler.x, y: euler.y, z: euler.z });
|
|
||||||
rotRef.current = { x: euler.x, y: euler.y, z: euler.z };
|
|
||||||
|
|
||||||
const halfSize = (CUBE_SIZE / 2) * scaleRef.current;
|
|
||||||
const newPos = point.clone().add(normal.clone().multiplyScalar(halfSize));
|
|
||||||
setCubePos({ x: newPos.x, y: newPos.y, z: newPos.z });
|
|
||||||
posRef.current = { x: newPos.x, y: newPos.y, z: newPos.z };
|
|
||||||
|
|
||||||
setAlignPhase('WAIT_FACE2');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdgeClick = (point: THREE.Vector3, edgeNormal: THREE.Vector3) => {
|
|
||||||
if (alignPhase === 'WAIT_EDGE') {
|
|
||||||
const t1 = surfaceNormalRef.current.clone().negate();
|
|
||||||
const q1 = new THREE.Quaternion().setFromUnitVectors(face1Ref.current, t1);
|
|
||||||
|
|
||||||
const w2 = face2Ref.current.clone().applyQuaternion(q1);
|
|
||||||
const t2 = edgeNormal.clone();
|
|
||||||
|
|
||||||
w2.projectOnPlane(surfaceNormalRef.current).normalize();
|
|
||||||
t2.projectOnPlane(surfaceNormalRef.current).normalize();
|
|
||||||
|
|
||||||
const angle = Math.atan2(
|
|
||||||
w2.clone().cross(t2).dot(surfaceNormalRef.current),
|
|
||||||
w2.dot(t2)
|
|
||||||
);
|
|
||||||
|
|
||||||
const q2 = new THREE.Quaternion().setFromAxisAngle(surfaceNormalRef.current, angle);
|
|
||||||
const finalQ = q2.multiply(q1);
|
|
||||||
|
|
||||||
const euler = new THREE.Euler().setFromQuaternion(finalQ, 'XYZ');
|
|
||||||
setCubeRot({ x: euler.x, y: euler.y, z: euler.z });
|
|
||||||
rotRef.current = { x: euler.x, y: euler.y, z: euler.z };
|
|
||||||
|
|
||||||
setAlignPhase('IDLE');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-slate-100">
|
<div className="w-screen h-screen relative bg-black overflow-hidden">
|
||||||
<header className="border-b border-slate-800/80 bg-slate-950/80 backdrop-blur">
|
{/* Header */}
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-6 py-4">
|
<div className="absolute top-0 left-0 right-0 z-20 p-4 pointer-events-none">
|
||||||
<Link to="/" className="flex items-center gap-2 text-sm text-slate-400 transition hover:text-slate-100">
|
<div className="flex items-center justify-between">
|
||||||
<ArrowLeft className="h-4 w-4" /> Voltar ao SteelXR
|
<Link
|
||||||
</Link>
|
to="/"
|
||||||
<div className="flex items-center gap-3">
|
className="flex items-center gap-2 text-amber-400 hover:text-amber-300 pointer-events-auto"
|
||||||
<Beaker className="h-5 w-5 text-amber-400" />
|
|
||||||
<h1 className="font-mono text-sm tracking-wider text-slate-300">
|
|
||||||
QUEST CUBE LAB <span className="text-amber-400/70">(alpha)</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={`inline-flex h-2 w-2 rounded-full ${joystickConnected ? "bg-emerald-400" : "bg-slate-600"}`} />
|
|
||||||
<span className="text-xs text-slate-400">{joystickConnected ? "Joystick OK" : "Sem joystick"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto max-w-6xl px-6 py-8 relative">
|
|
||||||
<div className="mb-6 flex gap-4">
|
|
||||||
<div className="flex-1 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-200">
|
|
||||||
<strong>Fase 3/4 — Sistema de Alinhamento:</strong>
|
|
||||||
<ul className="mt-2 list-disc pl-5">
|
|
||||||
<li>1. Clique numa face do cubo; depois, numa superfície da mesa (Tampo ou Prateleira).</li>
|
|
||||||
<li>2. Clique noutra face ortogonal do cubo; depois, numa borda/lateral da mesa.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setLocked((v) => !v)}
|
|
||||||
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition ${
|
|
||||||
locked ? "border-amber-500 bg-amber-500/15 text-amber-200" : "border-slate-700 bg-slate-900 text-slate-300 hover:border-slate-500"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{locked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
|
<ArrowLeft className="w-4 h-4" />
|
||||||
{locked ? "Travado" : "Destravar"}
|
<span className="text-sm">Voltar ao SteelXR</span>
|
||||||
</button>
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Beaker className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-amber-400 text-sm font-mono">
|
||||||
|
Laboratório de Cubos — Fase 2.5 (Physics HLA-like)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button onClick={reset} className="flex items-center gap-2 rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-300 transition hover:border-slate-500">
|
{/* Controles flutuantes */}
|
||||||
<RotateCcw className="h-4 w-4" />
|
<div className="absolute top-16 right-4 z-20 flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setLocked((l) => !l)}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 text-amber-400 px-3 py-2 rounded-lg text-xs flex items-center gap-2"
|
||||||
|
title={locked ? "Destravar cubo" : "Travar cubo"}
|
||||||
|
>
|
||||||
|
{locked ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
|
||||||
|
{locked ? "Travar" : "Destravar"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 text-amber-400 px-3 py-2 rounded-lg text-xs flex items-center gap-2"
|
||||||
|
title="Resetar cubo na posição inicial"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{alignPhase !== 'IDLE' && (
|
|
||||||
<button onClick={() => setAlignPhase('IDLE')} className="flex items-center gap-2 rounded-md border border-red-500/50 bg-red-900/30 px-3 py-2 text-sm text-red-300 transition hover:border-red-500">
|
|
||||||
Cancelar Alinhamento
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
disabled={!xrSupported}
|
onClick={() => setAutoEnter(true)}
|
||||||
onClick={enterXR}
|
className="bg-amber-600 hover:bg-amber-500 text-slate-900 px-3 py-2 rounded-lg text-xs flex items-center gap-2 font-semibold"
|
||||||
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition ${
|
|
||||||
xrSupported ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/20" : "cursor-not-allowed border-slate-700 bg-slate-900 text-slate-500"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Hand className="h-4 w-4" />
|
<Hand className="w-3 h-3" />
|
||||||
Entrar em AR
|
Entrar em XR
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Badge joystick */}
|
||||||
|
<GamepadHandler />
|
||||||
|
<div className="absolute bottom-4 left-4 z-20 bg-slate-800/80 text-amber-400 px-3 py-1 rounded-full text-xs font-mono">
|
||||||
|
<span className="inline-block w-2 h-2 bg-amber-400 rounded-full mr-2 animate-pulse" />
|
||||||
|
Rapier Physics ativo • Half-Life: Alyx-like
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="relative overflow-hidden rounded-xl border border-slate-800 bg-slate-950">
|
{/* Canvas com XR */}
|
||||||
<div className="absolute top-4 left-4 z-10 rounded bg-slate-900/90 p-4 text-sm border border-slate-700/50 backdrop-blur pointer-events-none max-w-sm">
|
|
||||||
<div className="font-bold text-amber-300 mb-1 flex items-center gap-2">
|
|
||||||
Status Alinhamento
|
|
||||||
</div>
|
|
||||||
<div className="text-slate-300">
|
|
||||||
{alignPhase === 'IDLE' && 'Livre! Clique em uma face do cubo para iniciar o contato.'}
|
|
||||||
{alignPhase === 'WAIT_SURFACE' && 'Face gravada! Agora clique em uma superfície da mesa (Tampo/Prateleira) para apoiar o cubo.'}
|
|
||||||
{alignPhase === 'WAIT_FACE2' && 'Apoiado! Agora clique em outra face do cubo para alinhamento lateral.'}
|
|
||||||
{alignPhase === 'WAIT_EDGE' && 'Último passo! Clique em uma borda ou lateral da mesa para alinhar o cubo.'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ height: 600 }}>
|
|
||||||
<Canvas shadows gl={{ antialias: true, alpha: true }}>
|
|
||||||
<XR store={store}>
|
<XR store={store}>
|
||||||
<Suspense fallback={null}>
|
<Canvas shadows>
|
||||||
<PerspectiveCamera makeDefault position={[3, 4, 6]} fov={50} />
|
<SceneContent locked={locked} resetSignal={resetSignal} />
|
||||||
<ambientLight intensity={0.5} />
|
|
||||||
<directionalLight position={[5, 8, 5]} intensity={1} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
|
|
||||||
|
|
||||||
<CubeControlado
|
|
||||||
posRef={posRef}
|
|
||||||
rotRef={rotRef}
|
|
||||||
scaleRef={scaleRef}
|
|
||||||
lockedRef={lockedRef}
|
|
||||||
gamepadRef={gamepadRef}
|
|
||||||
alignPhase={alignPhase}
|
|
||||||
setAlignPhase={setAlignPhase}
|
|
||||||
face1Ref={face1Ref}
|
|
||||||
face2Ref={face2Ref}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
onSurfaceClick={handleSurfaceClick}
|
|
||||||
onEdgeClick={handleEdgeClick}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ARHudMessages alignPhase={alignPhase} />
|
|
||||||
|
|
||||||
<Grid args={[20, 20]} cellSize={0.25} cellThickness={0.5} cellColor="#475569" sectionSize={1} sectionThickness={1} sectionColor="#94a3b8" fadeDistance={18} fadeStrength={1} infiniteGrid={false} position={[0, 0, 0]} />
|
|
||||||
<mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.001, 0]}>
|
|
||||||
<planeGeometry args={[20, 20]} />
|
|
||||||
<shadowMaterial transparent opacity={0.35} />
|
|
||||||
</mesh>
|
|
||||||
<OrbitControls makeDefault enableDamping />
|
|
||||||
<Environment preset="city" />
|
|
||||||
</Suspense>
|
|
||||||
</XR>
|
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</XR>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-slate-800 bg-slate-950/80 px-4 py-2">
|
|
||||||
<div className="font-mono text-[11px] text-slate-400">
|
|
||||||
pos({cubePos.x.toFixed(2)}, {cubePos.y.toFixed(2)}, {cubePos.z.toFixed(2)}) · rot({((cubeRot.y * 180) / Math.PI).toFixed(0)}°) · scale {cubeScale.toFixed(2)}x
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-8 rounded-lg border border-slate-800 bg-slate-900/30 p-6">
|
|
||||||
<h2 className="mb-4 font-mono text-xs uppercase tracking-[0.2em] text-slate-400">Próximas fases</h2>
|
|
||||||
<ol className="space-y-2 text-sm text-slate-300">
|
|
||||||
<Phase n="1" status="done" title="Botão na Home + rota /lab" />
|
|
||||||
<Phase n="2" status="done" title="Cubo Three.js + Grid fino + Travar/Destravar + Joystick funcional" />
|
|
||||||
<Phase n="3" status="done" title="Sistema de Apoio (face objeto + superfície alvo) - Tampo/Prateleira" />
|
|
||||||
<Phase n="4" status="done" title="Sistema Direcional (face objeto + aresta alvo)" />
|
|
||||||
<Phase n="5" title="Integração WebXR completa (@react-three/xr) — Passthrough real" />
|
|
||||||
<Phase n="6" title="Migração da lógica redonda pro SteelXR XR Session principal" />
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const Phase = ({ n, status, title }: { n: string; status?: "done" | "active"; title: string }) => (
|
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<span
|
|
||||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full font-mono text-xs ${
|
|
||||||
status === "done" ? "bg-emerald-500/20 text-emerald-300" : status === "active" ? "bg-amber-500/20 text-amber-300" : "bg-slate-800 text-slate-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-200">{title}</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default QuestCubeLab;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user