Files
SteelXR/src/devkit/DevPanel.tsx
T
gpt-engineer-app[bot] 24affac229 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-12 11:29:39 +00:00

288 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* DEVKIT — HTML overlay panel with diagnostics + macro recorder + export.
*
* Tabs: Inputs · Cena · XR · Macro · Logs
* Export: snapshot JSON + canvas screenshot, both downloaded.
*/
import { useEffect, useRef, useState } from 'react';
import { fakeInput, isFakeActive } from './fakeInputStore';
import { sceneStats } from './sceneStatsStore';
import {
macroState, subscribeMacro, startRecording, stopRecording,
startPlaying, stopPlaying, downloadMacro, loadMacroFromFile,
} from './macroStore';
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' | 'cena' | 'xr' | 'macro' | 'logs';
interface DevPanelProps {
simXR: boolean;
onToggleSimXR: () => void;
}
export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
const [tab, setTab] = useState<Tab>('inputs');
const [collapsed, setCollapsed] = useState(false);
const [, tick] = useState(0);
const [logFilter, setLogFilter] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => { installLogger(); }, []);
useEffect(() => subscribeMacro(() => tick((n) => n + 1)), []);
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); };
}, []);
const exportReport = async () => {
const canvas = document.querySelector('canvas');
const screenshot = canvas ? canvas.toDataURL('image/png') : null;
const report = {
ts: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent,
simXR,
fakeActive: isFakeActive(),
stats: { ...sceneStats },
inputs: {
left: { grip: fakeInput.left.gripValue, trigger: fakeInput.left.triggerValue },
right: { grip: fakeInput.right.gripValue, trigger: fakeInput.right.triggerValue },
},
logs: logBuffer.slice(-100),
};
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `devkit-report-${Date.now()}.json`; a.click();
URL.revokeObjectURL(url);
if (screenshot) {
const a2 = document.createElement('a');
a2.href = screenshot; a2.download = `devkit-screenshot-${Date.now()}.png`; a2.click();
}
};
if (collapsed) {
return (
<button
onClick={() => setCollapsed(false)}
className="fixed top-20 right-2 z-50 rounded bg-black/85 px-2 py-1 font-mono text-[10px] text-green-400 border border-green-500/40 hover:bg-black"
>
🧪 DevKit
</button>
);
}
return (
<div className="fixed top-20 right-2 z-50 w-80 rounded-lg border border-green-500/40 bg-black/90 font-mono text-[10px] text-green-300 shadow-2xl backdrop-blur">
<div className="flex items-center justify-between border-b border-green-500/30 px-2 py-1.5">
<span className="font-bold text-green-400">🧪 DEVKIT</span>
<div className="flex items-center gap-1">
<button
onClick={exportReport}
className="rounded border border-green-500/40 px-1.5 py-0.5 text-[9px] text-green-300 hover:bg-green-500/20"
title="Baixa JSON + screenshot"
>
Relatório
</button>
<button
onClick={onToggleSimXR}
className={`rounded px-1.5 py-0.5 text-[9px] ${simXR ? 'bg-green-500 text-black' : 'border border-green-500/40 text-green-300 hover:bg-green-500/20'}`}
>
{simXR ? 'SIM ON' : 'Simular XR'}
</button>
<button onClick={() => setCollapsed(true)} className="px-1 hover:text-white"></button>
</div>
</div>
<div className="flex border-b border-green-500/20">
{(['inputs', 'cena', 'xr', 'macro', 'logs'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 px-1 py-1 text-[10px] uppercase ${tab === t ? 'bg-green-500/20 text-green-200' : 'text-green-500/70 hover:text-green-300'}`}
>
{t}
</button>
))}
</div>
<div className="max-h-[60vh] overflow-y-auto p-2">
{tab === 'inputs' && (
<div className="space-y-2">
<div className="text-green-500/70">fake: {isFakeActive() ? 'ATIVO' : 'inativo'}</div>
{(['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 (
<div key={h} className="rounded border border-green-500/20 p-1.5">
<div className={`font-bold ${grabbing ? 'text-yellow-300' : 'text-green-300'}`}>
{h.toUpperCase()} {grabbing && '✊ GRAB'}
</div>
<div>grip: {s.gripValue.toFixed(2)} trg: {s.triggerValue.toFixed(2)}</div>
<div>pose: {p.map((v) => v.toFixed(2)).join(', ')}</div>
</div>
);
})}
<div className="border-t border-green-500/20 pt-2 text-green-500/70">
<div className="font-bold text-green-400 mb-1">Atalhos teclado</div>
<div>Q/W = grip L/R · A/S = trigger L/R</div>
<div>1/2 = ativar mão · setas = mover</div>
<div>Shift+/ = Z (frente/trás) · R = reset</div>
</div>
</div>
)}
{tab === 'cena' && (
<div className="space-y-1">
<Row k="meshes" v={String(sceneStats.meshCount)} />
<Row k="triângulos" v={sceneStats.triCount.toLocaleString()} />
<Row k="bbox (m)" v={sceneStats.modelBboxSize.map(n => n.toFixed(3)).join(' × ')} />
<Row k="pos (m)" v={sceneStats.modelGroupPos.map(n => n.toFixed(3)).join(', ')} />
<Row k="rot (°)" v={sceneStats.modelGroupRotDeg.map(n => n.toFixed(1)).join(', ')} />
<Row k="escala" v={sceneStats.modelGroupScale.toFixed(3)} />
</div>
)}
{tab === 'xr' && (
<div className="space-y-1">
<Row k="modo" v={sceneStats.xrMode ?? '—'} />
<Row k="FPS" v={String(sceneStats.fps)} />
<Row k="inputs" v={String(sceneStats.xrInputCount)} />
<Row k="planos" v={String(sceneStats.xrPlanesCount)} />
<Row k="hit-test" v={sceneStats.xrHitTestActive ? 'sim' : 'não'} />
<Row k="navigator.xr" v={typeof navigator !== 'undefined' && 'xr' in navigator ? 'ok' : 'ausente'} />
<Row k="HTTPS" v={location.protocol === 'https:' ? 'sim' : 'NÃO'} />
</div>
)}
{tab === 'macro' && (
<div className="space-y-2">
<div className="text-green-500/70">fase: <span className="text-green-300">{macroState.phase}</span></div>
<div className="flex gap-1 flex-wrap">
{macroState.phase !== 'recording' ? (
<button onClick={startRecording} className="rounded border border-red-500/50 bg-red-500/10 px-2 py-0.5 text-red-300 hover:bg-red-500/20"> Gravar</button>
) : (
<button onClick={stopRecording} className="rounded border border-yellow-500/50 bg-yellow-500/10 px-2 py-0.5 text-yellow-300"> Parar</button>
)}
{macroState.phase !== 'playing' ? (
<button
onClick={() => startPlaying()}
disabled={!macroState.current}
className="rounded border border-green-500/50 bg-green-500/10 px-2 py-0.5 text-green-300 hover:bg-green-500/20 disabled:opacity-40"
> Play</button>
) : (
<button onClick={stopPlaying} className="rounded border border-yellow-500/50 bg-yellow-500/10 px-2 py-0.5 text-yellow-300"> Stop</button>
)}
<button
onClick={() => macroState.current && downloadMacro(macroState.current)}
disabled={!macroState.current}
className="rounded border border-green-500/40 px-2 py-0.5 hover:bg-green-500/20 disabled:opacity-40"
> Salvar</button>
<button
onClick={() => fileRef.current?.click()}
className="rounded border border-green-500/40 px-2 py-0.5 hover:bg-green-500/20"
> Carregar</button>
<input
ref={fileRef}
type="file"
accept="application/json"
className="hidden"
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
try {
const macro = await loadMacroFromFile(f);
macroState.current = macro;
tick((n) => n + 1);
} catch { /* noop */ }
e.target.value = '';
}}
/>
</div>
{macroState.current && (
<div className="rounded border border-green-500/20 p-1.5">
<div className="font-bold text-green-300">{macroState.current.name}</div>
<div>frames: {macroState.current.frames.length}</div>
<div>duração: {(macroState.current.duration / 1000).toFixed(1)}s</div>
</div>
)}
{macroState.phase === 'recording' && (
<div className="text-yellow-300">gravando {macroState.rec.length} frames</div>
)}
<div className="text-green-500/60 leading-tight border-t border-green-500/20 pt-1">
Use os atalhos do teclado para encenar o bug; depois clique em Parar e Salvar.
No próximo round, Carregar o JSON e clicar em Play reproduz exatamente.
</div>
</div>
)}
{tab === 'logs' && (
<div className="space-y-1">
<input
value={logFilter}
onChange={(e) => 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"
/>
<div className="space-y-0.5">
{logBuffer
.filter((l) => !logFilter || l.msg.toLowerCase().includes(logFilter.toLowerCase()))
.slice(-80).reverse()
.map((l, i) => (
<div
key={i}
className={`break-all leading-tight ${
l.level === 'error' ? 'text-red-400' : l.level === 'warn' ? 'text-yellow-300' : 'text-green-300/90'
}`}
>
{l.msg}
</div>
))}
{logBuffer.length === 0 && <div className="text-green-500/50 italic">sem logs ainda</div>}
</div>
</div>
)}
</div>
</div>
);
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-2 border-b border-green-500/10 py-0.5">
<span className="text-green-500/70">{k}</span>
<span className="text-green-200 break-all text-right">{v}</span>
</div>
);
}