From 927585431ea2f94e6f139f0b4c7425c3ed8b9d1f Mon Sep 17 00:00:00 2001 From: "Engineering (agent-engineering)" Date: Tue, 19 May 2026 18:53:56 -0700 Subject: [PATCH] feat(frontend): Canvas 2D farm with real pixel art sprites and HTML overlay cards --- src/frontend/AgentCard.jsx | 80 +++ src/frontend/AgentSprite.jsx | 126 ---- src/frontend/App.jsx | 24 +- src/frontend/FarmCanvas.jsx | 483 +++++++++++++ src/frontend/OfficeScene.jsx | 170 ----- src/frontend/index.html | 2 +- src/frontend/public/sprites/agent-content.png | Bin 0 -> 279 bytes src/frontend/public/sprites/agent-cos.png | Bin 0 -> 295 bytes .../public/sprites/agent-engineering.png | Bin 0 -> 288 bytes src/frontend/public/sprites/agent-health.png | Bin 0 -> 276 bytes src/frontend/public/sprites/agent-market.png | Bin 0 -> 245 bytes .../public/sprites/agent-presentations.png | Bin 0 -> 302 bytes .../public/sprites/agent-senior-eng.png | Bin 0 -> 300 bytes src/frontend/public/sprites/agent-trust.png | Bin 0 -> 279 bytes src/frontend/public/sprites/agent-vault.png | Bin 0 -> 276 bytes src/frontend/src/farm.css | 659 +++--------------- 16 files changed, 669 insertions(+), 875 deletions(-) create mode 100644 src/frontend/AgentCard.jsx delete mode 100644 src/frontend/AgentSprite.jsx create mode 100644 src/frontend/FarmCanvas.jsx delete mode 100644 src/frontend/OfficeScene.jsx create mode 100644 src/frontend/public/sprites/agent-content.png create mode 100644 src/frontend/public/sprites/agent-cos.png create mode 100644 src/frontend/public/sprites/agent-engineering.png create mode 100644 src/frontend/public/sprites/agent-health.png create mode 100644 src/frontend/public/sprites/agent-market.png create mode 100644 src/frontend/public/sprites/agent-presentations.png create mode 100644 src/frontend/public/sprites/agent-senior-eng.png create mode 100644 src/frontend/public/sprites/agent-trust.png create mode 100644 src/frontend/public/sprites/agent-vault.png 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 => ( -
-
-
- {b.label} -
-
- ))} - - {/* ── 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