483 lines
14 KiB
JavaScript
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',
|
|
}}
|
|
/>
|
|
);
|
|
}
|