diff --git a/src/devkit/DevPanel.tsx b/src/devkit/DevPanel.tsx new file mode 100644 index 0000000..63115c3 --- /dev/null +++ b/src/devkit/DevPanel.tsx @@ -0,0 +1,166 @@ +/** + * DEVKIT — HTML overlay panel with diagnostics. + * + * Tabs: + * - Inputs: live grip/trigger/pose values for both controllers (real or fake) + * - Logs: captures last 100 console.log/warn/error entries with filter + * + * Always visible when ?devkit=1, top-right corner. + */ +import { useEffect, useRef, useState } from 'react'; +import { fakeInput, isFakeActive } from './fakeInputStore'; + +interface LogEntry { + ts: number; + level: 'log' | 'warn' | 'error'; + msg: string; +} + +const logBuffer: LogEntry[] = []; +let installed = false; + +function installLogger() { + if (installed) return; + installed = true; + (['log', 'warn', 'error'] as const).forEach((level) => { + const orig = console[level].bind(console); + console[level] = (...args: unknown[]) => { + try { + const msg = args.map((a) => { + if (typeof a === 'string') return a; + try { return JSON.stringify(a); } catch { return String(a); } + }).join(' '); + logBuffer.push({ ts: Date.now(), level, msg }); + if (logBuffer.length > 200) logBuffer.shift(); + } catch { /* noop */ } + orig(...args); + }; + }); +} + +type Tab = 'inputs' | 'logs'; + +interface DevPanelProps { + simXR: boolean; + onToggleSimXR: () => void; +} + +export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) { + const [tab, setTab] = useState('inputs'); + const [collapsed, setCollapsed] = useState(false); + const [, tick] = useState(0); + const [logFilter, setLogFilter] = useState(''); + const rafRef = useRef(null); + + useEffect(() => { installLogger(); }, []); + + useEffect(() => { + let last = 0; + const loop = (t: number) => { + if (t - last > 150) { tick((n) => n + 1); last = t; } + rafRef.current = requestAnimationFrame(loop); + }; + rafRef.current = requestAnimationFrame(loop); + return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; + }, []); + + if (collapsed) { + return ( + + ); + } + + return ( +
+ {/* Header */} +
+ 🧪 DEVKIT +
+ + +
+
+ + {/* Tabs */} +
+ {(['inputs', 'logs'] as Tab[]).map((t) => ( + + ))} +
+ + {/* Content */} +
+ {tab === 'inputs' && ( +
+
+ fake: {isFakeActive() ? 'ATIVO' : 'inativo'} +
+ {(['left', 'right'] as const).map((h) => { + const s = fakeInput[h]; + const grabbing = s.gripValue > 0.7; + const p = [s.gripWorld.elements[12], s.gripWorld.elements[13], s.gripWorld.elements[14]]; + return ( +
+
+ {h.toUpperCase()} {grabbing && '✊ GRAB'} +
+
grip: {s.gripValue.toFixed(2)} trg: {s.triggerValue.toFixed(2)}
+
pose: {p.map((v) => v.toFixed(2)).join(', ')}
+
+ ); + })} +
+
Atalhos teclado
+
Q/W = grip L/R · A/S = trigger L/R
+
1/2 = ativar mão · setas = mover
+
Shift+↑/↓ = Z (frente/trás) · R = reset
+
+
+ )} + {tab === 'logs' && ( +
+ setLogFilter(e.target.value)} + placeholder="filtro… (ex: [XR])" + className="w-full rounded border border-green-500/30 bg-black/60 px-1 py-0.5 text-green-300" + /> +
+ {logBuffer + .filter((l) => !logFilter || l.msg.toLowerCase().includes(logFilter.toLowerCase())) + .slice(-80) + .reverse() + .map((l, i) => ( +
+ {l.msg} +
+ ))} + {logBuffer.length === 0 &&
sem logs ainda…
} +
+
+ )} +
+
+ ); +} diff --git a/src/devkit/FakeControllers.tsx b/src/devkit/FakeControllers.tsx new file mode 100644 index 0000000..58e421f --- /dev/null +++ b/src/devkit/FakeControllers.tsx @@ -0,0 +1,119 @@ +/** + * DEVKIT — Visual fake controllers + keyboard driver. + * + * Renders two spheres (L=blue, R=red) at the fake controller positions and + * listens to global keyboard for grip/trigger/movement. Mounted only when + * SimXR is active. + * + * Keys: + * Q / W → hold grip Left / Right (analog = 1.0) + * A / S → hold trigger Left / Right (analog = 1.0) + * 1 / 2 → select Left / Right as the active hand + * Arrows → move active hand on X (←→) / Y (↑↓) + * Shift+Arrows→ move active hand on Z (↑=forward, ↓=back) + * R → reset hand poses + */ +import { useEffect, useRef, useState } from 'react'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; +import { fakeInput, setFakeActive } from './fakeInputStore'; + +const STEP = 0.03; // m per keypress + +export function FakeControllers() { + const leftMesh = useRef(null); + const rightMesh = useRef(null); + const [, force] = useState(0); + const activeHand = useRef<'left' | 'right'>('right'); + + useEffect(() => { + setFakeActive(true); + return () => setFakeActive(false); + }, []); + + useEffect(() => { + const keys = new Set(); + + const apply = () => { + const pos = new THREE.Vector3(); + const quat = new THREE.Quaternion(); + const scl = new THREE.Vector3(); + const hand = fakeInput[activeHand.current]; + hand.gripWorld.decompose(pos, quat, scl); + + const dx = (keys.has('ArrowRight') ? STEP : 0) - (keys.has('ArrowLeft') ? STEP : 0); + const dyOrZ = (keys.has('ArrowUp') ? STEP : 0) - (keys.has('ArrowDown') ? STEP : 0); + const shift = keys.has('Shift'); + pos.x += dx; + if (shift) pos.z -= dyOrZ; // up = forward (-Z) + else pos.y += dyOrZ; + + hand.gripWorld.compose(pos, quat, scl); + + // grip / trigger + fakeInput.left.gripValue = keys.has('q') ? 1 : 0; + fakeInput.right.gripValue = keys.has('w') ? 1 : 0; + fakeInput.left.triggerValue = keys.has('a') ? 1 : 0; + fakeInput.right.triggerValue = keys.has('s') ? 1 : 0; + + force((n) => n + 1); + }; + + const onDown = (e: KeyboardEvent) => { + const k = e.key === 'Shift' ? 'Shift' : e.key; + keys.add(k); + if (k === '1') activeHand.current = 'left'; + else if (k === '2') activeHand.current = 'right'; + else if (k === 'r' || k === 'R') { + fakeInput.left.gripWorld.makeTranslation(-0.25, 1.2, -0.5); + fakeInput.right.gripWorld.makeTranslation(0.25, 1.2, -0.5); + force((n) => n + 1); + return; + } + apply(); + }; + const onUp = (e: KeyboardEvent) => { + const k = e.key === 'Shift' ? 'Shift' : e.key; + keys.delete(k); + apply(); + }; + + window.addEventListener('keydown', onDown); + window.addEventListener('keyup', onUp); + return () => { + window.removeEventListener('keydown', onDown); + window.removeEventListener('keyup', onUp); + }; + }, []); + + // Sync mesh visuals to fake state each frame + useFrame(() => { + if (leftMesh.current) { + const p = new THREE.Vector3().setFromMatrixPosition(fakeInput.left.gripWorld); + leftMesh.current.position.copy(p); + const grabbing = fakeInput.left.gripValue > 0.7; + const mat = leftMesh.current.material as THREE.MeshStandardMaterial; + mat.emissiveIntensity = grabbing ? 1.5 : 0.4; + } + if (rightMesh.current) { + const p = new THREE.Vector3().setFromMatrixPosition(fakeInput.right.gripWorld); + rightMesh.current.position.copy(p); + const grabbing = fakeInput.right.gripValue > 0.7; + const mat = rightMesh.current.material as THREE.MeshStandardMaterial; + mat.emissiveIntensity = grabbing ? 1.5 : 0.4; + } + }); + + return ( + <> + + + + + + + + + + ); +}