diff --git a/src/frontend/AgentCard.jsx b/src/frontend/AgentCard.jsx
new file mode 100644
index 0000000..ab4f54e
--- /dev/null
+++ b/src/frontend/AgentCard.jsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import useStore from './store.js';
+
+const STATUS_COLORS = {
+ idle: '#44cc44',
+ thinking: '#ffcc00',
+ working: '#4488ff',
+ blocked: '#ff4444',
+ offline: '#666666',
+};
+
+const STATUS_LABELS = {
+ idle: 'Idle',
+ thinking: 'Thinking',
+ working: 'Working',
+ blocked: 'Blocked',
+ offline: 'Offline',
+};
+
+function getZoneName(agent) {
+ if (!agent || !agent.position) return 'Outside';
+ const state = useStore.getState();
+ const zones = state.zones;
+ const { row, col } = agent._smoothPos || agent.targetPosition || agent.position;
+ for (const zone of Object.values(zones)) {
+ const { row: zr, col: zc, rows, cols } = zone.bounds;
+ if (row >= zr && row < zr + rows && col >= zc && col < zc + cols) {
+ return zone.name;
+ }
+ }
+ return 'Yard';
+}
+
+export default function AgentCard({ agent, onClose }) {
+ if (!agent) return null;
+
+ const activity = agent.activity || 'idle';
+ const statusColor = STATUS_COLORS[activity] || '#666';
+ const zone = getZoneName(agent);
+
+ return (
+
{ if (e.target === e.currentTarget) onClose(); }}>
+
+
+
+
+
+
{agent.name || agent.id}
+
+
+
+
+ Status
+
+
+ {STATUS_LABELS[activity] || activity}
+
+
+
+
+ Location
+ {zone}
+
+
+ {agent.currentTask && (
+
+ Task
+ {agent.currentTask}
+
+ )}
+
+
+ Department
+ {agent.department || agent.id}
+
+
+
+
+ );
+}
diff --git a/src/frontend/AgentSprite.jsx b/src/frontend/AgentSprite.jsx
deleted file mode 100644
index fb80a72..0000000
--- a/src/frontend/AgentSprite.jsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import React, { useState } from 'react';
-
-// ── Grid → pixel position helper ─────────────────────────────────
-// Simple top-down mapping: each tile is 32px × 32px
-export const TILE_SIZE = 32;
-
-export function gridToPixel(row, col) {
- return {
- left: col * TILE_SIZE + TILE_SIZE / 2,
- top: row * TILE_SIZE + TILE_SIZE / 2,
- };
-}
-
-// ── Thought bubble content map ───────────────────────────────────
-const THOUGHT_BUBBLES = {
- thinking: '...',
- blocked: '!',
-};
-
-// ── Department accessory emoji ───────────────────────────────────
-const DEPARTMENT_ACCESSORIES = {
- 'cos': '📋',
- 'engineering': '⚙️',
- 'senior-eng': '🔮',
- 'trust': '🛡️',
- 'market': '🔍',
- 'health': '🌿',
- 'content': '📝',
- 'presentations': '✨',
- 'vault': '🗝️',
-};
-
-export default function AgentSprite({ agent, tileSize = TILE_SIZE }) {
- const [hovered, setHovered] = useState(false);
-
- if (!agent || !agent.position) return null;
-
- const { row, col } = agent.targetPosition || agent.position;
- const { left, top } = gridToPixel(row, col);
-
- const deptKey = agent.department || agent.id;
- const departmentClass = `agent--${deptKey}`;
- const activity = agent.activity || 'idle';
- const task = agent.currentTask || null;
- const thought = THOUGHT_BUBBLES[activity] || null;
- const accessory = DEPARTMENT_ACCESSORIES[deptKey] || '';
-
- // Status glow colors
- const glowColors = {
- idle: '#44cc44',
- thinking: '#ffcc00',
- working: '#4488ff',
- blocked: '#ff4444',
- offline: '#666666',
- };
-
- return (
- setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- >
- {/* Thought bubble */}
- {thought && (
-
{thought}
- )}
-
- {/* Name label */}
-
- {agent.name}
-
-
- {/* Sparkle particles for working */}
-
-
- {/* Accessory icon above head */}
- {accessory && (
-
- {accessory}
-
- )}
-
- {/* Pixel-art body */}
-
-
- {/* Status glow ring */}
-
-
- {/* Hover tooltip */}
- {hovered && (
-
-
{agent.name}
-
Status: {activity}
- {task &&
{task}
}
-
- )}
-
- );
-}
diff --git a/src/frontend/App.jsx b/src/frontend/App.jsx
index f76ba0d..0eb39e2 100644
--- a/src/frontend/App.jsx
+++ b/src/frontend/App.jsx
@@ -1,10 +1,11 @@
-import React, { useEffect, useRef } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import useStore from './store.js';
import wsClient from './WebSocketClient.js';
-import FarmScene from './OfficeScene.jsx';
+import FarmCanvas from './FarmCanvas.jsx';
+import AgentCard from './AgentCard.jsx';
import './src/farm.css';
-// ── Connection indicator (top-right) ─────────────────────────────
+// ── Connection indicator (top-right) ─────────────────────────
function ConnectionIndicator() {
const connected = useStore(s => s.wsConnected);
const reconnecting = useStore(s => s.wsReconnecting);
@@ -51,17 +52,14 @@ function FarmBar() {
// ── App ───────────────────────────────────────────────────────────
export default function App() {
const hasConnected = useRef(false);
- const agents = useStore(s => s.agents);
- const connected = useStore(s => s.wsConnected);
+ const [selectedAgent, setSelectedAgent] = useState(null);
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) {
@@ -78,15 +76,19 @@ export default function App() {
return (
- {/* Connection indicator */}
- {/* Scrollable farm viewport */}
-
+
- {/* Bottom status bar */}
+ {selectedAgent && (
+
setSelectedAgent(null)}
+ />
+ )}
+
);
diff --git a/src/frontend/FarmCanvas.jsx b/src/frontend/FarmCanvas.jsx
new file mode 100644
index 0000000..d1a7881
--- /dev/null
+++ b/src/frontend/FarmCanvas.jsx
@@ -0,0 +1,483 @@
+import React, { useRef, useEffect, useState, useCallback } from 'react';
+import useStore, { ZONES } from './store.js';
+
+const TILE_SIZE = 32;
+const COLS = 28;
+const ROWS = 20;
+const CANVAS_W = COLS * TILE_SIZE; // 896
+const CANVAS_H = ROWS * TILE_SIZE; // 640
+
+const LERP_SPEED = 0.08;
+
+// ── Building definitions (grid coords) ─────────────────────────
+const BUILDINGS = [
+ { id: 'workshop', zoneId: 'pod-a', label: 'Workshop', color: '#A0522D', col: 8, row: 2, w: 5, h: 5, roofColor: '#6B4226' },
+ { id: 'studio', zoneId: 'pod-b', label: 'Studio', color: '#DEB887', col: 14, row: 2, w: 5, h: 5, roofColor: '#8B6914' },
+ { id: 'cabins', zoneId: 'solo-1', label: 'Cabins', color: '#8B4513', col: 0, row: 2, w: 6, h: 10, roofColor: '#6B4226' },
+ { id: 'library', zoneId: 'solo-2', label: 'Library', color: '#5B8FA8', col: 22, row: 2, w: 6, h: 10, roofColor: '#2F5F7A' },
+ { id: 'shrine', zoneId: 'coaching', label: 'Shrine', color: '#DAA520', col: 8, row: 13, w: 12, h: 3, roofColor: '#C68B3C' },
+];
+
+// ── Sprite loader ────────────────────────────────────────────────
+function loadSprite(id) {
+ const img = new Image();
+ img.src = `/office/sprites/agent-${id}.png`;
+ return img;
+}
+
+const AGENT_IDS = ['cos', 'engineering', 'senior-eng', 'trust', 'market', 'health', 'content', 'presentations', 'vault'];
+
+// Preload all sprites
+const sprites = {};
+AGENT_IDS.forEach(id => { sprites[id] = loadSprite(id); });
+
+// ── Particle system for sparkles ─────────────────────────────────
+let sparkles = [];
+
+function spawnSparkle(x, y) {
+ sparkles.push({
+ x, y,
+ vx: (Math.random() - 0.5) * 2,
+ vy: -Math.random() * 3 - 1,
+ life: 1,
+ decay: 0.02 + Math.random() * 0.03,
+ color: ['#F0C040', '#FFD700', '#FFEAA7', '#FFFFFF'][Math.floor(Math.random() * 4)],
+ });
+ if (sparkles.length > 60) sparkles.shift();
+}
+
+// ── Draw helpers ──────────────────────────────────────────────────
+function drawGrass(ctx) {
+ // Dark green base
+ ctx.fillStyle = '#2D5A27';
+ ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
+
+ // Random pixel texture dots
+ const imageData = ctx.getImageData(0, 0, CANVAS_W, CANVAS_H);
+ for (let i = 0; i < 2000; i++) {
+ const x = Math.floor(Math.random() * CANVAS_W);
+ const y = Math.floor(Math.random() * CANVAS_H);
+ const idx = (y * CANVAS_W + x) * 4;
+ const shade = 60 + Math.random() * 40;
+ imageData.data[idx] = shade + 20;
+ imageData.data[idx + 1] = shade + 40;
+ imageData.data[idx + 2] = shade;
+ imageData.data[idx + 3] = 255;
+ }
+ ctx.putImageData(imageData, 0, 0);
+}
+
+function drawDirtPaths(ctx) {
+ ctx.fillStyle = '#8B6914';
+ // Horizontal paths
+ ctx.fillRect(0, 48, CANVAS_W, 16);
+ ctx.fillRect(0, 224, CANVAS_W, 16);
+ ctx.fillRect(0, 544, CANVAS_W, 16);
+ // Vertical paths
+ ctx.fillRect(208, 0, 16, CANVAS_H);
+ ctx.fillRect(432, 0, 16, CANVAS_H);
+ ctx.fillRect(704, 0, 16, CANVAS_H);
+
+ // Path edge shading
+ ctx.fillStyle = 'rgba(0,0,0,0.15)';
+ ctx.fillRect(0, 48, CANVAS_W, 2);
+ ctx.fillRect(0, 224, CANVAS_W, 2);
+ ctx.fillRect(0, 544, CANVAS_W, 2);
+}
+
+function drawBuilding(ctx, b) {
+ const px = b.col * TILE_SIZE;
+ const py = b.row * TILE_SIZE;
+ const pw = b.w * TILE_SIZE;
+ const ph = b.h * TILE_SIZE;
+ const roofH = 24;
+
+ // Shadow under building
+ ctx.fillStyle = 'rgba(0,0,0,0.2)';
+ ctx.fillRect(px + 4, py + 4, pw, ph);
+
+ // Roof (triangle)
+ ctx.fillStyle = b.roofColor;
+ ctx.beginPath();
+ ctx.moveTo(px - 2, py);
+ ctx.lineTo(px + pw / 2, py - roofH);
+ ctx.lineTo(px + pw + 2, py);
+ ctx.closePath();
+ ctx.fill();
+
+ // Building body
+ ctx.fillStyle = b.color;
+ ctx.fillRect(px, py, pw, ph);
+
+ // Brick/wall texture pattern
+ ctx.strokeStyle = 'rgba(0,0,0,0.2)';
+ ctx.lineWidth = 1;
+ for (let r = 0; r < b.h; r++) {
+ const y = py + r * TILE_SIZE;
+ ctx.beginPath();
+ ctx.moveTo(px, y);
+ ctx.lineTo(px + pw, y);
+ ctx.stroke();
+ }
+ for (let c = 0; c < b.w; c++) {
+ const x = px + c * TILE_SIZE;
+ ctx.beginPath();
+ ctx.moveTo(x, py);
+ ctx.lineTo(x, py + ph);
+ ctx.stroke();
+ }
+
+ // Door
+ const doorW = 16;
+ const doorH = 24;
+ ctx.fillStyle = '#4A3520';
+ ctx.fillRect(px + pw / 2 - doorW / 2, py + ph - doorH - 4, doorW, doorH);
+ ctx.fillStyle = '#DAA520';
+ ctx.fillRect(px + pw / 2 + doorW / 2 - 4, py + ph - doorH / 2 - 2, 3, 3);
+
+ // Windows (small squares)
+ ctx.fillStyle = '#87CEEB';
+ if (b.w >= 4) {
+ for (let side = 0; side < 2; side++) {
+ const wx = side === 0 ? px + TILE_SIZE * 0.5 : px + pw - TILE_SIZE * 1.5;
+ ctx.fillRect(wx, py + TILE_SIZE * 1.5, 14, 12);
+ ctx.strokeStyle = '#6B4226';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(wx, py + TILE_SIZE * 1.5, 14, 12);
+ }
+ }
+
+ // Label pill
+ const labelW = ctx.measureText(b.label).width + 12;
+ const labelH = 16;
+ const labelX = px + pw / 2 - labelW / 2;
+ const labelY = py - roofH - labelH - 2;
+ ctx.fillStyle = 'rgba(0,0,0,0.65)';
+ ctx.beginPath();
+ ctx.roundRect(labelX, labelY, labelW, labelH, 4);
+ ctx.fill();
+ ctx.font = 'bold 10px "Courier New", monospace';
+ ctx.fillStyle = '#FFD700';
+ ctx.fillText(b.label, labelX + 6, labelY + 12);
+}
+
+function drawCampfire(ctx, time) {
+ const cx = 10 * TILE_SIZE + 4 * TILE_SIZE; // center of campfire (col 10, 8 wide)
+ const cy = 0 * TILE_SIZE + 32; // row 0, 2 tall
+
+ // Ground circle
+ ctx.fillStyle = '#E8D5B7';
+ ctx.beginPath();
+ ctx.ellipse(cx, cy + 16, 112, 24, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Logs
+ ctx.fillStyle = '#6B4226';
+ ctx.fillRect(cx - 48, cy + 20, 40, 8);
+ ctx.fillRect(cx + 8, cy + 20, 40, 8);
+ ctx.fillRect(cx - 20, cy + 16, 8, 40);
+
+ // Animated flame
+ const flicker = Math.sin(time * 0.05) * 4;
+ const f1 = Math.sin(time * 0.08 + 0.5) * 6;
+ const f2 = Math.sin(time * 0.11 + 1.2) * 5;
+
+ ctx.fillStyle = '#FF8C42';
+ ctx.beginPath();
+ ctx.moveTo(cx - 6 + f2, cy + 8);
+ ctx.quadraticCurveTo(cx - 4 + f1, cy - flicker - 8, cx, cy - flicker - 20);
+ ctx.quadraticCurveTo(cx + 4 + f1, cy - flicker - 8, cx + 6 + f2, cy + 8);
+ ctx.fill();
+
+ // Inner flame
+ ctx.fillStyle = '#FFD700';
+ ctx.beginPath();
+ ctx.moveTo(cx - 3, cy + 8);
+ ctx.quadraticCurveTo(cx - 2, cy - 8, cx, cy - 16);
+ ctx.quadraticCurveTo(cx + 2, cy - 8, cx + 3, cy + 8);
+ ctx.fill();
+
+ // Core
+ ctx.fillStyle = '#FFFFFF';
+ ctx.beginPath();
+ ctx.moveTo(cx - 1, cy + 8);
+ ctx.quadraticCurveTo(cx, cy - 4, cx, cy - 10);
+ ctx.quadraticCurveTo(cx + 1, cy - 4, cx + 1, cy + 8);
+ ctx.fill();
+
+ // Warm glow
+ const glow = ctx.createRadialGradient(cx, cy - 10, 2, cx, cy, 80);
+ glow.addColorStop(0, 'rgba(255,200,50,0.3)');
+ glow.addColorStop(0.5, 'rgba(255,140,40,0.1)');
+ glow.addColorStop(1, 'rgba(255,100,30,0)');
+ ctx.fillStyle = glow;
+ ctx.fillRect(cx - 80, cy - 40, 160, 100);
+
+ // Label
+ ctx.fillStyle = 'rgba(0,0,0,0.65)';
+ ctx.beginPath();
+ ctx.roundRect(cx - 35, cy - 42, 70, 16, 4);
+ ctx.fill();
+ ctx.font = 'bold 10px "Courier New", monospace';
+ ctx.fillStyle = '#FFD700';
+ ctx.fillText('Campfire', cx - 30, cy - 30);
+}
+
+function drawDecorations(ctx) {
+ // Trees
+ const treePositions = [
+ ...Array.from({ length: 7 }, (_, i) => ({ x: i * 20 + 10, y: 596 })),
+ ...Array.from({ length: 7 }, (_, i) => ({ x: i * 20 + 10, y: 40 })),
+ { x: 845, y: 80 }, { x: 845, y: 128 }, { x: 845, y: 176 },
+ { x: 10, y: 80 }, { x: 10, y: 128 },
+ ];
+
+ for (const t of treePositions) {
+ // Trunk
+ ctx.fillStyle = '#6B4226';
+ ctx.fillRect(t.x - 1, t.y + 8, 3, 8);
+ // Canopy
+ ctx.fillStyle = '#2D5A27';
+ ctx.beginPath();
+ ctx.moveTo(t.x, t.y - 8);
+ ctx.lineTo(t.x - 6, t.y + 8);
+ ctx.lineTo(t.x + 6, t.y + 8);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = '#4A7C3F';
+ ctx.beginPath();
+ ctx.moveTo(t.x, t.y - 4);
+ ctx.lineTo(t.x - 4, t.y + 4);
+ ctx.lineTo(t.x + 4, t.y + 4);
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ // Rocks
+ const rocks = [
+ { x: 250, y: 560 }, { x: 480, y: 570 }, { x: 420, y: 550 }, { x: 720, y: 560 },
+ { x: 380, y: 540 }, { x: 160, y: 570 },
+ ];
+ for (const r of rocks) {
+ ctx.fillStyle = '#777';
+ ctx.fillRect(r.x, r.y + 2, 8, 5);
+ ctx.fillStyle = '#999';
+ ctx.fillRect(r.x + 1, r.y, 7, 5);
+ }
+
+ // Flowers
+ const flowers = [
+ { x: 240, y: 580, c: '#FF6347' }, { x: 500, y: 590, c: '#FFD700' },
+ { x: 350, y: 585, c: '#87CEEB' }, { x: 650, y: 595, c: '#DDA0DD' },
+ { x: 130, y: 582, c: '#FF6347' }, { x: 580, y: 580, c: '#FFD700' },
+ ];
+ for (const f of flowers) {
+ ctx.fillStyle = '#4CAF50';
+ ctx.fillRect(f.x + 1, f.y + 3, 1, 5);
+ ctx.fillStyle = f.c;
+ ctx.fillRect(f.x, f.y, 4, 4);
+ }
+
+ // Fence posts along top path
+ for (let i = 0; i < 12; i++) {
+ const fx = 120 + i * 64;
+ ctx.fillStyle = '#8B4513';
+ ctx.fillRect(fx, 56, 3, 14);
+ }
+}
+
+function drawAgent(ctx, agent, time) {
+ if (!agent || !agent.position) return;
+
+ const img = sprites[agent.id];
+ if (!img) return;
+
+ const target = agent.targetPosition || agent.position;
+ const current = agent._smoothPos || { row: target.row, col: target.col };
+
+ // Lerp toward target
+ current.row += (target.row - current.row) * LERP_SPEED;
+ current.col += (target.col - current.col) * LERP_SPEED;
+
+ // If very close, snap
+ if (Math.abs(target.row - current.row) < 0.01) current.row = target.row;
+ if (Math.abs(target.col - current.col) < 0.01) current.col = target.col;
+
+ agent._smoothPos = current;
+
+ const activity = agent.activity || 'idle';
+ const px = current.col * TILE_SIZE + TILE_SIZE / 2;
+ const py = current.row * TILE_SIZE + TILE_SIZE / 2;
+
+ // Vertical bob for idle
+ let yOffset = 0;
+ if (activity === 'idle') {
+ yOffset = Math.sin(time * 0.003 + (agent.id?.charCodeAt(0) || 0)) * 2;
+ }
+
+ ctx.save();
+ ctx.translate(px, py + yOffset);
+
+ // Activity states
+ if (activity === 'offline') {
+ ctx.globalAlpha = 0.5;
+ ctx.filter = 'grayscale(100%)';
+ } else if (activity === 'blocked') {
+ // Red tint via overlay
+ ctx.globalAlpha = 1;
+ }
+
+ // Draw sprite centered
+ const sx = img.width > 32 ? 0 : 0;
+ const spriteW = img.width > 32 ? Math.min(32, img.width) : img.width;
+ const spriteH = img.height;
+ ctx.drawImage(img, sx, 0, spriteW, spriteH, -16, -16, 32, 32);
+
+ ctx.restore();
+
+ // Blocked: red tint overlay
+ if (activity === 'blocked') {
+ ctx.fillStyle = 'rgba(255,50,50,0.3)';
+ ctx.fillRect(px - 16, py + yOffset - 16, 32, 32);
+ // "!" indicator
+ ctx.font = 'bold 12px "Courier New", monospace';
+ ctx.fillStyle = '#FF4444';
+ ctx.fillText('!', px - 3, py + yOffset - 24);
+ }
+
+ // Agent name below
+ ctx.font = '9px "Courier New", monospace';
+ ctx.textAlign = 'center';
+ ctx.fillStyle = '#000';
+ ctx.fillText(agent.name || agent.id, px, py + yOffset + 24);
+ ctx.fillStyle = '#FFF';
+ ctx.fillText(agent.name || agent.id, px, py + yOffset + 23);
+
+ // Thinking bubble
+ if (activity === 'thinking') {
+ ctx.font = 'bold 10px "Courier New", monospace';
+ ctx.textAlign = 'center';
+ const bubbleText = '...';
+ const bubbleW = 24;
+ const bubbleH = 14;
+ const bx = px - bubbleW / 2;
+ const by = py + yOffset - 44;
+
+ ctx.fillStyle = '#FFF';
+ ctx.beginPath();
+ ctx.roundRect(bx, by, bubbleW, bubbleH, 3);
+ ctx.fill();
+ ctx.strokeStyle = '#CCC';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ ctx.fillStyle = '#333';
+ ctx.fillText(bubbleText, px, by + 11);
+ }
+
+ // Sparkles for working
+ if (activity === 'working') {
+ if (Math.random() < 0.3) {
+ spawnSparkle(px + (Math.random() - 0.5) * 24, py + yOffset - 12);
+ }
+ }
+
+ ctx.textAlign = 'start';
+}
+
+function drawParticles(ctx) {
+ sparkles = sparkles.filter(s => s.life > 0);
+ for (const s of sparkles) {
+ s.x += s.vx;
+ s.y += s.vy;
+ s.life -= s.decay;
+ const alpha = s.life;
+ ctx.fillStyle = s.color;
+ ctx.globalAlpha = alpha;
+ ctx.fillRect(s.x, s.y, 2, 2);
+ }
+ ctx.globalAlpha = 1;
+}
+
+// ── Component ─────────────────────────────────────────────────────
+export default function FarmCanvas({ onAgentClick }) {
+ const canvasRef = useRef(null);
+ const animRef = useRef(null);
+ const agents = useStore(s => s.agents);
+ const agentList = React.useMemo(() => Object.values(agents), [agents]);
+
+ // Canvas click → hit test agents
+ const handleCanvasClick = useCallback((e) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const rect = canvas.getBoundingClientRect();
+ const scaleX = CANVAS_W / rect.width;
+ const scaleY = CANVAS_H / rect.height;
+ const mx = (e.clientX - rect.left) * scaleX;
+ const my = (e.clientY - rect.top) * scaleY;
+
+ // Check agents in reverse draw order (topmost last)
+ for (const agent of agentList) {
+ const pos = agent._smoothPos || agent.position;
+ if (!pos) continue;
+ const px = pos.col * TILE_SIZE + TILE_SIZE / 2;
+ const py = pos.row * TILE_SIZE + TILE_SIZE / 2;
+ if (Math.abs(mx - px) < 20 && Math.abs(my - py) < 20) {
+ onAgentClick(agent);
+ return;
+ }
+ }
+ }, [agentList, onAgentClick]);
+
+ // Animation loop
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ ctx.imageSmoothingEnabled = false; // pixel-art crisp
+
+ let startTime = Date.now();
+
+ function loop() {
+ const time = Date.now() - startTime;
+ ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
+
+ drawGrass(ctx);
+ drawDirtPaths(ctx);
+ for (const b of BUILDINGS) drawBuilding(ctx, b);
+ drawCampfire(ctx, time);
+ drawDecorations(ctx);
+
+ // Draw agents
+ const currentAgents = useStore.getState().agents;
+ for (const agent of Object.values(currentAgents)) {
+ drawAgent(ctx, agent, time);
+ }
+
+ drawParticles(ctx);
+
+ animRef.current = requestAnimationFrame(loop);
+ }
+
+ loop();
+ return () => {
+ if (animRef.current) cancelAnimationFrame(animRef.current);
+ };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/src/frontend/OfficeScene.jsx b/src/frontend/OfficeScene.jsx
deleted file mode 100644
index 9e682c6..0000000
--- a/src/frontend/OfficeScene.jsx
+++ /dev/null
@@ -1,170 +0,0 @@
-import React from 'react';
-import useStore from './store.js';
-import AgentSprite, { TILE_SIZE } from './AgentSprite.jsx';
-
-// ── Zone → building mapping with pixel positions ──────────────────
-// Grid is 28 cols × 20 rows at 32px each → 896×640 px game board
-const BUILDINGS = [
- {
- id: 'workshop',
- zoneId: 'pod-a',
- label: 'Workshop',
- cssClass: 'building--workshop',
- left: 272, // col 8 → 272px
- top: 64, // row 2 → 64px
- width: 160, // 5 cols × 32
- height: 160, // 5 rows × 32
- },
- {
- id: 'studio',
- zoneId: 'pod-b',
- label: 'Studio',
- cssClass: 'building--studio',
- left: 464, // col 14 → 464px
- top: 64, // row 2
- width: 160,
- height: 160,
- },
- {
- id: 'cabins',
- zoneId: 'solo-1',
- label: 'Cabins',
- cssClass: 'building--cabin',
- left: 16, // col 0 → 16px (with padding)
- top: 80, // row 2
- width: 192, // 6 cols × 32
- height: 320, // 10 rows × 32
- },
- {
- id: 'library',
- zoneId: 'solo-2',
- label: 'Library',
- cssClass: 'building--library',
- left: 720, // col 22 → 720px
- top: 80, // row 2
- width: 192, // 6 cols
- height: 320, // 10 rows
- },
- {
- id: 'shrine',
- zoneId: 'coaching',
- label: 'Shrine',
- cssClass: 'building--shrine',
- left: 272, // col 8
- top: 416, // row 13
- width: 384, // 12 cols
- height: 128, // 4 rows
- },
- {
- id: 'campfire',
- zoneId: 'coffee',
- label: 'Campfire',
- cssClass: 'building--campfire',
- left: 336, // col 10
- top: 16, // row 0
- width: 256, // 8 cols
- height: 64, // 2 rows
- },
-];
-
-// ── Dirt paths (connecting roads between buildings) ───────────────
-// These are positioned absolutely as thin rectangles
-const PATHS = [
- // Horizontal main path (top)
- { left: 0, top: 48, width: 896, height: 16 },
- // Horizontal middle path
- { left: 0, top: 224, width: 896, height: 16 },
- // Vertical left path
- { left: 208, top: 0, width: 16, height: 640 },
- // Vertical center path
- { left: 432, top: 0, width: 16, height: 640 },
- // Vertical right path
- { left: 704, top: 0, width: 16, height: 640 },
- // Bottom horizontal
- { left: 0, top: 544, width: 896, height: 16 },
-];
-
-// ── Decorative trees/rocks/flowers ────────────────────────────────
-const DECORATIONS = [
- // Trees along edges
- ...Array.from({ length: 6 }, (_, i) => ({ type: 'tree', left: i * 24 + 12, top: 596 })),
- ...Array.from({ length: 6 }, (_, i) => ({ type: 'tree', left: i * 24 + 12, top: 44 })),
- ...Array.from({ length: 4 }, (_, i) => ({ type: 'tree', left: 840, top: i * 48 + 80 })),
- // Rocks
- { type: 'rock', left: 250, top: 560 },
- { type: 'rock', left: 480, top: 570 },
- { type: 'rock', left: 420, top: 550 },
- { type: 'rock', left: 720, top: 560 },
- // Flowers
- { type: 'flower', left: 240, top: 580, color: '#FF6347' },
- { type: 'flower', left: 500, top: 590, color: '#FFD700' },
- { type: 'flower', left: 350, top: 585, color: '#87CEEB' },
- { type: 'flower', left: 650, top: 595, color: '#DDA0DD' },
- // Fence posts along top path
- ...Array.from({ length: 10 }, (_, i) => ({ type: 'fence', left: i * 64 + 120, top: 56 })),
-];
-
-export default function FarmScene() {
- const agents = useStore(s => s.agents);
- const agentList = React.useMemo(() => Object.values(agents), [agents]);
-
- return (
-
- {/* ── Dirt paths ──────────────────────────────────────── */}
- {PATHS.map((p, i) => (
-
- ))}
-
- {/* ── Buildings ───────────────────────────────────────── */}
- {BUILDINGS.map(b => (
-
- ))}
-
- {/* ── Decorations ─────────────────────────────────────── */}
- {DECORATIONS.map((d, i) => {
- if (d.type === 'tree') return
;
- if (d.type === 'rock') return
;
- if (d.type === 'flower') return
;
- if (d.type === 'fence') return
;
- return null;
- })}
-
- {/* ── Agent sprites ──────────────────────────────────── */}
- {agentList.map(agent => (
-
- ))}
-
- );
-}
diff --git a/src/frontend/index.html b/src/frontend/index.html
index d48c10a..465062f 100644
--- a/src/frontend/index.html
+++ b/src/frontend/index.html
@@ -3,7 +3,7 @@
- Spatial Office — Scobleclaw Farm
+ Spatial Office — Scobleclaw