- React + @react-three/fiber + Zustand frontend - Isometric tile grid with 6 colored zones - Agent sprites as colored cubes with glow effects - Smooth movement animation via lerp - Zone labels (HTML overlay) - Hover tooltips with agent activity/task - WebSocket client with reconnect backoff - Mock mode for standalone operation (no backend needed) - Orthographic camera setup for true isometric projection - Vite build configuration (port 18790)
176 lines
5.1 KiB
JavaScript
176 lines
5.1 KiB
JavaScript
import React, { useEffect, useRef } from 'react';
|
|
import { Canvas } from '@react-three/fiber';
|
|
import { OrbitControls } from '@react-three/drei';
|
|
import * as THREE from 'three';
|
|
import useStore from './store.js';
|
|
import wsClient from './WebSocketClient.js';
|
|
import OfficeScene from './OfficeScene.jsx';
|
|
|
|
// ── Camera setup for true isometric ─────────────────────────────
|
|
// Orthographic with rotation: 45° around Y, then ~35.264° tilt down (atan(1/√2))
|
|
// But the React-idiomatic way: position the camera and use lookAt
|
|
// Simpler: use orbit controls locked to isometric-ish angle
|
|
function CameraSetup() {
|
|
// We use OrbitControls with limited rotation for a fixed-ish isometric feel
|
|
return null; // Controls are in the Canvas
|
|
}
|
|
|
|
// ── UI Overlay ──────────────────────────────────────────────────
|
|
function UIOverlay() {
|
|
const agents = useStore(s => s.agents);
|
|
const connected = useStore(s => s.wsConnected);
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 40,
|
|
background: 'rgba(0,0,0,0.8)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 16,
|
|
padding: '0 16px',
|
|
fontSize: 11,
|
|
color: '#999',
|
|
borderTop: '1px solid #333',
|
|
zIndex: 10,
|
|
}}>
|
|
<span>Spatial Office</span>
|
|
<span>|</span>
|
|
<span>
|
|
<span style={{
|
|
display: 'inline-block',
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: '50%',
|
|
background: connected ? '#44cc44' : '#ff4444',
|
|
marginRight: 4,
|
|
verticalAlign: 'middle',
|
|
}} />
|
|
{connected ? 'Live' : 'Offline'}
|
|
</span>
|
|
<span>|</span>
|
|
<span>{Object.keys(agents).length} agents</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── App ─────────────────────────────────────────────────────────
|
|
export default function App() {
|
|
const hasConnected = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (hasConnected.current) return;
|
|
hasConnected.current = true;
|
|
|
|
// Try to connect to WebSocket backend
|
|
wsClient.connect();
|
|
|
|
// After 2 seconds, if no backend, start mock mode
|
|
const mockTimer = setTimeout(() => {
|
|
const state = useStore.getState();
|
|
if (!state.wsConnected) {
|
|
console.log('[App] No backend found, starting mock mode');
|
|
wsClient.startMock();
|
|
}
|
|
}, 2000);
|
|
|
|
return () => {
|
|
clearTimeout(mockTimer);
|
|
wsClient.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
<Canvas
|
|
shadows
|
|
gl={{
|
|
antialias: true,
|
|
toneMapping: THREE.ACESFilmicToneMapping,
|
|
toneMappingExposure: 1.2,
|
|
}}
|
|
camera={{
|
|
position: [18, 20, 18],
|
|
near: 0.1,
|
|
far: 200,
|
|
}}
|
|
style={{ background: '#1a1a2e' }}
|
|
>
|
|
{/* Orthographic camera for true isometric */}
|
|
<OrthoCamera />
|
|
|
|
{/* Lighting */}
|
|
<ambientLight intensity={0.4} />
|
|
<directionalLight
|
|
position={[10, 20, 5]}
|
|
intensity={0.8}
|
|
castShadow
|
|
shadow-mapSize-width={1024}
|
|
shadow-mapSize-height={1024}
|
|
/>
|
|
<hemisphereLight
|
|
args={['#8ec8f2', '#332211', 0.3]}
|
|
/>
|
|
|
|
{/* Limited orbit controls — zoom + pan, but locked rotation for isometric feel */}
|
|
<OrbitControls
|
|
enableRotate={true}
|
|
enableZoom={true}
|
|
enablePan={true}
|
|
minZoom={5}
|
|
maxZoom={40}
|
|
maxPolarAngle={Math.PI / 2.2}
|
|
dampingFactor={0.1}
|
|
target={[0, 0, 6]}
|
|
/>
|
|
|
|
{/* Office scene */}
|
|
<OfficeScene />
|
|
</Canvas>
|
|
|
|
<UIOverlay />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Ortho camera wrapper ────────────────────────────────────────
|
|
function OrthoCamera() {
|
|
const ref = useRef();
|
|
|
|
useEffect(() => {
|
|
if (!ref.current) return;
|
|
|
|
// On mount, set orthographic projection
|
|
const camera = ref.current;
|
|
const aspect = window.innerWidth / window.innerHeight;
|
|
const frustumSize = 18;
|
|
|
|
camera.left = -frustumSize * aspect / 2;
|
|
camera.right = frustumSize * aspect / 2;
|
|
camera.top = frustumSize / 2;
|
|
camera.bottom = -frustumSize / 2;
|
|
camera.near = 0.1;
|
|
camera.far = 200;
|
|
camera.position.set(14, 18, 14);
|
|
camera.lookAt(0, 0, 5);
|
|
camera.updateProjectionMatrix();
|
|
|
|
// Handle resize
|
|
const onResize = () => {
|
|
const a = window.innerWidth / window.innerHeight;
|
|
camera.left = -frustumSize * a / 2;
|
|
camera.right = frustumSize * a / 2;
|
|
camera.top = frustumSize / 2;
|
|
camera.bottom = -frustumSize / 2;
|
|
camera.updateProjectionMatrix();
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, []);
|
|
|
|
return <orthographicCamera ref={ref} />;
|
|
}
|