spatial-office/src/frontend/AgentSprite.jsx
Engineering (agent-engineering) bb06f2c48c build: add Nix flake and NixOS module for reproducible builds
- 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
2026-05-19 16:25:24 -07:00

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>
);
}