spatial-office/src/frontend/FarmCanvas.jsx

483 lines
14 KiB
JavaScript

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 (
<canvas
ref={canvasRef}
width={CANVAS_W}
height={CANVAS_H}
onClick={handleCanvasClick}
style={{
display: 'block',
margin: '0 auto',
imageRendering: 'pixelated',
cursor: 'default',
maxWidth: '100%',
height: 'auto',
}}
/>
);
}