- flake.nix: devShell, frontend package, backend package, combined default - spatial-office.nix: standalone NixOS module for importing into n97 config - packages/frontend: React + @react-three/fiber + Zustand (Vite build) - packages/backend: Node.js + ws + Zod (WebSocket server, port 18790) - pnpm-workspace.yaml: monorepo workspace config nix build .#frontend — bundled static site nix build .#backend — runnable WebSocket server nix build — full spatial-office application nix develop — dev shell with Node.js 22 + pnpm
190 lines
5.5 KiB
JavaScript
190 lines
5.5 KiB
JavaScript
import React, { useRef, useState } from 'react';
|
|
import { useFrame } from '@react-three/fiber';
|
|
import { Text, Html } from '@react-three/drei';
|
|
import * as THREE from 'three';
|
|
|
|
// ── Isometric grid helper: convert grid coords → world position ──
|
|
// Isometric: x = (col - row) * tileW/2, z = (col + row) * tileH/2
|
|
const TILE_W = 1.0;
|
|
const TILE_H = 0.5;
|
|
|
|
export function gridToWorld(row, col) {
|
|
return [
|
|
(col - row) * (TILE_W / 2),
|
|
0,
|
|
(col + row) * (TILE_H / 2),
|
|
];
|
|
}
|
|
|
|
// ── World pos to grid (inverse) ──────────────────────────────────
|
|
export function worldToGrid(x, z) {
|
|
const col = (x / (TILE_W / 2) + z / (TILE_H / 2)) / 2;
|
|
const row = (z / (TILE_H / 2) - x / (TILE_W / 2)) / 2;
|
|
return { row: Math.round(row), col: Math.round(col) };
|
|
}
|
|
|
|
// ── Activity → glow color ────────────────────────────────────────
|
|
const ACTIVITY_COLORS = {
|
|
idle: '#44cc44',
|
|
thinking: '#ffcc00',
|
|
working: '#4488ff',
|
|
blocked: '#ff4444',
|
|
offline: '#666666',
|
|
};
|
|
|
|
const LERP_SPEED = 0.08;
|
|
|
|
export default function AgentSprite({ agent, hovered, onHover }) {
|
|
const meshRef = useRef();
|
|
const glowRef = useRef();
|
|
const [targetPos, setTargetPos] = useState(null);
|
|
|
|
// Initialize position
|
|
if (!targetPos && agent.position) {
|
|
setTargetPos(gridToWorld(agent.position.row, agent.position.col));
|
|
} else if (agent.targetPosition) {
|
|
setTargetPos(gridToWorld(agent.targetPosition.row, agent.targetPosition.col));
|
|
}
|
|
|
|
useFrame((state, delta) => {
|
|
if (!meshRef.current || !agent) return;
|
|
|
|
// Calculate target world position
|
|
const tgt = agent.targetPosition || agent.position;
|
|
if (!tgt) return;
|
|
|
|
const [tx, , tz] = gridToWorld(tgt.row, tgt.col);
|
|
|
|
// Smooth lerp toward target
|
|
meshRef.current.position.x += (tx - meshRef.current.position.x) * LERP_SPEED;
|
|
meshRef.current.position.z += (tz - meshRef.current.position.z) * LERP_SPEED;
|
|
|
|
// If close enough, snap to target
|
|
if (
|
|
Math.abs(tx - meshRef.current.position.x) < 0.01 &&
|
|
Math.abs(tz - meshRef.current.position.z) < 0.01
|
|
) {
|
|
meshRef.current.position.x = tx;
|
|
meshRef.current.position.z = tz;
|
|
}
|
|
|
|
// Floating bob
|
|
const bob = Math.sin(state.clock.elapsedTime * 2 + agent.position?.row || 0) * 0.05;
|
|
meshRef.current.position.y = 0.15 + bob;
|
|
|
|
// Glow pulse based on activity
|
|
if (glowRef.current) {
|
|
const pulse = Math.sin(state.clock.elapsedTime * 3) * 0.5 + 0.5;
|
|
glowRef.current.material.opacity = agent.activity === 'working' ? 0.3 + pulse * 0.4 :
|
|
agent.activity === 'thinking' ? 0.2 + pulse * 0.3 :
|
|
agent.activity === 'offline' ? 0 :
|
|
0.15;
|
|
}
|
|
});
|
|
|
|
if (!agent) return null;
|
|
|
|
const color = agent.activity === 'offline' ? '#666666' : agent.color;
|
|
const glowColor = ACTIVITY_COLORS[agent.activity] || '#44cc44';
|
|
|
|
return (
|
|
<group>
|
|
{/* Agent body — isometric cube */}
|
|
<mesh
|
|
ref={meshRef}
|
|
position={targetPos || [0, 0.15, 0]}
|
|
onPointerEnter={(e) => {
|
|
e.stopPropagation();
|
|
onHover(agent.id, true);
|
|
}}
|
|
onPointerLeave={(e) => {
|
|
e.stopPropagation();
|
|
onHover(agent.id, false);
|
|
}}
|
|
castShadow
|
|
>
|
|
<boxGeometry args={[0.4, 0.5, 0.3]} />
|
|
<meshStandardMaterial
|
|
color={color}
|
|
emissive={color}
|
|
emissiveIntensity={agent.activity === 'offline' ? 0 : 0.3}
|
|
roughness={0.6}
|
|
metalness={0.1}
|
|
/>
|
|
</mesh>
|
|
|
|
{/* Glow ring below agent */}
|
|
<mesh
|
|
ref={glowRef}
|
|
position={
|
|
targetPos
|
|
? [targetPos[0], 0.02, targetPos[2]]
|
|
: [0, 0.02, 0]
|
|
}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
>
|
|
<ringGeometry args={[0.25, 0.35, 32]} />
|
|
<meshBasicMaterial
|
|
color={glowColor}
|
|
transparent
|
|
opacity={0.2}
|
|
side={THREE.DoubleSide}
|
|
/>
|
|
</mesh>
|
|
|
|
{/* Agent name label */}
|
|
<Text
|
|
position={
|
|
targetPos
|
|
? [targetPos[0], 0.7, targetPos[2]]
|
|
: [0, 0.7, 0]
|
|
}
|
|
fontSize={0.15}
|
|
color={agent.activity === 'offline' ? '#888888' : '#ffffff'}
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.01}
|
|
outlineColor="#000000"
|
|
>
|
|
{agent.name}
|
|
</Text>
|
|
|
|
{/* Hover tooltip */}
|
|
{hovered && (
|
|
<Html
|
|
position={
|
|
targetPos
|
|
? [targetPos[0], 0.9, targetPos[2]]
|
|
: [0, 0.9, 0]
|
|
}
|
|
center
|
|
distanceFactor={15}
|
|
style={{ pointerEvents: 'none' }}
|
|
>
|
|
<div style={{
|
|
background: 'rgba(20,20,40,0.95)',
|
|
color: '#eee',
|
|
padding: '8px 12px',
|
|
borderRadius: '6px',
|
|
fontSize: '12px',
|
|
whiteSpace: 'nowrap',
|
|
border: '1px solid rgba(255,255,255,0.15)',
|
|
backdropFilter: 'blur(8px)',
|
|
}}>
|
|
<div style={{ fontWeight: 'bold', color: agent.color, marginBottom: 4 }}>
|
|
{agent.name}
|
|
</div>
|
|
<div style={{ opacity: 0.8 }}>
|
|
Status: <span style={{ color: glowColor }}>{agent.activity}</span>
|
|
</div>
|
|
{agent.currentTask && (
|
|
<div style={{ opacity: 0.7, marginTop: 2 }}>
|
|
{agent.currentTask}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Html>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|