feat(frontend): Canvas 2D farm with real pixel art sprites and HTML overlay cards

This commit is contained in:
engineering Agent 2026-05-19 18:53:56 -07:00
parent 97816d9e27
commit 927585431e
16 changed files with 669 additions and 875 deletions

View file

@ -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 (
<div className="agent-card-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="agent-card">
<button className="agent-card-close" onClick={onClose}>&times;</button>
<div className="agent-card-header">
<span className="agent-card-dot" style={{ background: agent.color || statusColor }} />
<h3 style={{ color: agent.color || '#FFD700' }}>{agent.name || agent.id}</h3>
</div>
<div className="agent-card-body">
<div className="agent-card-row">
<span className="agent-card-label">Status</span>
<span className="agent-card-value">
<span className="agent-card-status-dot" style={{ background: statusColor }} />
{STATUS_LABELS[activity] || activity}
</span>
</div>
<div className="agent-card-row">
<span className="agent-card-label">Location</span>
<span className="agent-card-value">{zone}</span>
</div>
{agent.currentTask && (
<div className="agent-card-row">
<span className="agent-card-label">Task</span>
<span className="agent-card-value agent-card-task">{agent.currentTask}</span>
</div>
)}
<div className="agent-card-row">
<span className="agent-card-label">Department</span>
<span className="agent-card-value">{agent.department || agent.id}</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div
className={`agent-sprite ${departmentClass}`}
data-activity={activity}
style={{
left: `${left}px`,
top: `${top}px`,
transform: 'translate(-50%, -50%)',
transition: 'left 0.8s ease-in-out, top 0.8s ease-in-out',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Thought bubble */}
{thought && (
<div className="agent-thought">{thought}</div>
)}
{/* Name label */}
<div className="agent-name" style={{ top: -22 }}>
{agent.name}
</div>
{/* Sparkle particles for working */}
<div className="agent-sparkles">
<div className="sparkle" />
<div className="sparkle" />
<div className="sparkle" />
</div>
{/* Accessory icon above head */}
{accessory && (
<div style={{
position: 'absolute',
top: -18,
left: '50%',
transform: 'translateX(-50%)',
fontSize: '8px',
lineHeight: 1,
imageRendering: 'pixelated',
pointerEvents: 'none',
filter: 'drop-shadow(1px 1px 0 rgba(0,0,0,0.5))',
}}>
{accessory}
</div>
)}
{/* Pixel-art body */}
<div className="agent-body" />
{/* Status glow ring */}
<div
className="agent-glow"
style={{
background: glowColors[activity] || '#4c4',
boxShadow: `0 0 4px ${glowColors[activity] || '#4c4'}`,
}}
/>
{/* Hover tooltip */}
{hovered && (
<div className="agent-tooltip">
<strong>{agent.name}</strong>
<div>Status: {activity}</div>
{task && <div className="task">{task}</div>}
</div>
)}
</div>
);
}

View file

@ -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 (
<div className="farm-container">
{/* Connection indicator */}
<ConnectionIndicator />
{/* Scrollable farm viewport */}
<div className="farm-viewport">
<FarmScene />
<FarmCanvas onAgentClick={setSelectedAgent} />
</div>
{/* Bottom status bar */}
{selectedAgent && (
<AgentCard
agent={selectedAgent}
onClose={() => setSelectedAgent(null)}
/>
)}
<FarmBar />
</div>
);

483
src/frontend/FarmCanvas.jsx Normal file
View file

@ -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 (
<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',
}}
/>
);
}

View file

@ -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 (
<div className="farm-grid" style={{ width: 896, height: 640 }}>
{/* ── Dirt paths ──────────────────────────────────────── */}
{PATHS.map((p, i) => (
<div
key={`path-${i}`}
style={{
position: 'absolute',
left: p.left,
top: p.top,
width: p.width,
height: p.height,
background: '#8B6914',
opacity: 0.5,
boxShadow: 'inset 0 0 4px rgba(0,0,0,0.3)',
}}
/>
))}
{/* ── Buildings ───────────────────────────────────────── */}
{BUILDINGS.map(b => (
<div key={b.id}>
<div
className={`building ${b.cssClass}`}
style={{
left: b.left,
top: b.top,
width: b.width,
height: b.height,
}}
/>
<div
className="building-label"
style={{
left: b.left + 4,
top: b.top - 30,
color: '#FFD700',
}}
>
{b.label}
</div>
</div>
))}
{/* ── Decorations ─────────────────────────────────────── */}
{DECORATIONS.map((d, i) => {
if (d.type === 'tree') return <div key={`deco-${i}`} className="pixel-tree" style={{ left: d.left, top: d.top }} />;
if (d.type === 'rock') return <div key={`deco-${i}`} className="pixel-rock" style={{ left: d.left, top: d.top }} />;
if (d.type === 'flower') return <div key={`deco-${i}`} className="pixel-flower" style={{ left: d.left, top: d.top, background: d.color }} />;
if (d.type === 'fence') return <div key={`deco-${i}`} className="fence-post" style={{ left: d.left, top: d.top }} />;
return null;
})}
{/* ── Agent sprites ──────────────────────────────────── */}
{agentList.map(agent => (
<AgentSprite key={agent.id} agent={agent} />
))}
</div>
);
}

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spatial Office — Scobleclaw Farm</title>
<title>Spatial Office — Scobleclaw</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root {

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

View file

@ -1,58 +1,20 @@
/* ═══════════════════════════════════════════════════════════════
SPATIAL OFFICE — STARDEW VALLEY PIXEL ART THEME
Palette: warm earthy farm tones
SPATIAL OFFICE — CANVAS 2D FARM + HTML OVERLAY STYLES
═══════════════════════════════════════════════════════════════ */
/* ── CSS Custom Properties ─────────────────────────────────── */
:root {
/* Earthy browns */
--cl-earth-dark: #6B4226;
--cl-earth-mid: #8B6914;
--cl-earth-warm: #A0522D;
/* Farm greens */
--cl-green-dark: #2D5A27;
--cl-green-mid: #4A7C3F;
--cl-green-light: #6B8E23;
/* Sky/waters */
--cl-sky: #87CEEB;
--cl-water-mid: #5B8FA8;
--cl-water-deep: #2F5F7A;
/* Warm golds */
--cl-gold: #DAA520;
--cl-gold-bright: #F0C040;
--cl-gold-muted: #C68B3C;
/* Cabin woods */
--cl-wood-dark: #8B4513;
--cl-wood-light: #DEB887;
--cl-wood-mid: #D2B48C;
/* Cozy fires */
--cl-fire: #FF6347;
--cl-fire-mid: #FF8C42;
--cl-fire-light: #FFD700;
/* Background */
--cl-bg-light: #F5E6D3;
--cl-bg-dark: #2D1B0E;
/* Agent colors */
--agent-cos: #FF6B35;
--agent-eng: #4ECDC4;
--agent-senior: #7B2D8E;
--agent-trust: #45B7D1;
--agent-market: #96CEB4;
--agent-health: #FFEAA7;
--agent-content: #DDA0DD;
--agent-pres: #FF8A80;
--agent-vault: #A5D6A7;
/* Sizes */
--tile-size: 32px;
--agent-size: 28px;
}
/* ── Base reset ────────────────────────────────────────────── */
@ -66,8 +28,6 @@ body {
font-family: 'Courier New', 'Consolas', monospace;
background: var(--cl-bg-dark);
color: #e0d5c0;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* ── Farm container — full viewport ────────────────────────── */
@ -79,481 +39,125 @@ body {
background: var(--cl-bg-dark);
}
/* ── Farm viewport — scrollable game board ─────────────────── */
/* ── Farm viewport — contains the canvas ───────────────────── */
.farm-viewport {
width: 100%;
height: calc(100% - 44px); /* room for bottom bar */
height: calc(100% - 44px);
overflow: auto;
position: relative;
display: flex;
align-items: center;
justify-content: center;
scrollbar-width: thin;
scrollbar-color: var(--cl-earth-dark) transparent;
}
/* ── Farm grid — the actual game board ─────────────────────── */
.farm-grid {
position: relative;
min-width: max(100%, 896px); /* 28 cols × 32px */
min-height: max(100%, 640px); /* 20 rows × 32px */
background-color: var(--cl-green-dark);
/* Subtle grass texture via repeating pattern */
background-image:
radial-gradient(circle at 6px 6px, rgba(74,124,63,0.4) 1px, transparent 1px),
radial-gradient(circle at 22px 14px, rgba(107,142,35,0.3) 1px, transparent 1px),
radial-gradient(circle at 14px 26px, rgba(74,124,63,0.3) 1px, transparent 1px);
background-size: 32px 32px;
}
/* ── Dirt path grid overlay ────────────────────────────────── */
.farm-grid::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 31px,
rgba(107,66,38,0.15) 31px,
rgba(107,66,38,0.15) 32px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 31px,
rgba(107,66,38,0.15) 31px,
rgba(107,66,38,0.15) 32px
);
}
/* ═══════════════════════════════════════════════════════════════
BUILDINGS
AGENT CARD OVERLAY (slides from right)
═══════════════════════════════════════════════════════════════ */
.building {
position: absolute;
border: 2px solid var(--cl-earth-dark);
image-rendering: pixelated;
}
/* Workshop (formerly Pod A) */
.building--workshop {
background:
/* Brick pattern */
repeating-linear-gradient(
0deg,
var(--cl-earth-warm),
var(--cl-earth-warm) 15px,
var(--cl-earth-dark) 15px,
var(--cl-earth-dark) 16px
),
repeating-linear-gradient(
90deg,
var(--cl-earth-warm),
var(--cl-earth-warm) 47px,
rgba(0,0,0,0.15) 47px,
rgba(0,0,0,0.15) 48px
);
border-color: var(--cl-earth-dark);
box-shadow:
inset 0 0 0 2px rgba(0,0,0,0.2),
4px 4px 0 rgba(0,0,0,0.3);
}
.building--workshop::after {
content: '';
position: absolute;
top: -24px;
left: 0;
right: 0;
height: 24px;
background:
linear-gradient(135deg, var(--cl-earth-dark) 45%, transparent 45%) 0 0 / 16px 24px repeat-x;
}
/* Studio (formerly Pod B) */
.building--studio {
background:
linear-gradient(var(--cl-wood-light), var(--cl-wood-mid));
border-color: var(--cl-wood-dark);
box-shadow:
inset 0 0 0 2px rgba(0,0,0,0.15),
4px 4px 0 rgba(0,0,0,0.3);
}
.building--studio::after {
content: '';
position: absolute;
top: -24px;
left: 0;
right: 0;
height: 24px;
background:
linear-gradient(180deg, var(--cl-fire-mid), var(--cl-earth-dark));
/* Roof tiles */
background-image:
repeating-linear-gradient(
90deg,
transparent,
transparent 14px,
rgba(0,0,0,0.2) 14px,
rgba(0,0,0,0.2) 16px
);
}
/* Cabin (solo work) */
.building--cabin {
background:
repeating-linear-gradient(
0deg,
var(--cl-wood-dark) 0px,
var(--cl-wood-mid) 1px,
var(--cl-wood-mid) 7px,
var(--cl-wood-dark) 8px
);
border-color: var(--cl-wood-dark);
box-shadow:
3px 3px 0 rgba(0,0,0,0.3);
}
.building--cabin::after {
content: '';
position: absolute;
top: -16px;
left: -2px;
right: -2px;
height: 16px;
background:
conic-gradient(from 45deg, var(--cl-earth-mid) 90deg, var(--cl-earth-dark) 90deg 180deg, var(--cl-earth-mid) 180deg 270deg, var(--cl-earth-dark) 270deg);
background-size: 16px 16px;
border: 2px solid var(--cl-earth-dark);
border-bottom: none;
}
/* Library (solo east) */
.building--library {
background:
linear-gradient(180deg, var(--cl-wood-dark) 0%, var(--cl-wood-light) 100%);
border-color: var(--cl-wood-dark);
box-shadow:
inset 0 0 0 2px rgba(0,0,0,0.1),
4px 4px 0 rgba(0,0,0,0.3);
}
.building--library::after {
content: '';
position: absolute;
top: -20px;
left: -2px;
right: -2px;
height: 20px;
background:
linear-gradient(180deg, var(--cl-water-deep) 0%, var(--cl-water-mid) 60%, var(--cl-earth-dark) 100%);
border: 2px solid var(--cl-earth-dark);
border-bottom: none;
}
/* Shrine (coaching) */
.building--shrine {
background:
radial-gradient(ellipse at center, var(--cl-gold-bright) 0%, var(--cl-gold) 40%, var(--cl-gold-muted) 100%);
border-color: var(--cl-gold-muted);
box-shadow:
inset 0 0 12px rgba(218,165,32,0.3),
3px 3px 0 rgba(0,0,0,0.3);
}
.building--shrine::before {
content: '✦';
.agent-card-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 40;
display: flex;
justify-content: flex-end;
}
.agent-card {
position: relative;
width: 280px;
max-width: 90%;
height: 100%;
background: rgba(45, 27, 14, 0.97);
border-left: 2px solid var(--cl-earth-dark);
padding: 20px 16px;
overflow-y: auto;
animation: slideIn 0.2s ease-out;
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.5);
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.agent-card-close {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
color: #999;
font-size: 24px;
cursor: pointer;
line-height: 1;
padding: 0 4px;
font-family: monospace;
}
.agent-card-close:hover {
color: #FFD700;
}
.agent-card-header {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--cl-gold-bright);
text-shadow: 0 0 8px var(--cl-gold-bright);
opacity: 0.6;
gap: 10px;
margin-bottom: 20px;
}
/* Campfire (coffee) */
.building--campfire {
background:
radial-gradient(ellipse at 50% 30%, var(--cl-bg-light) 0%, rgba(139,105,20,0.3) 100%);
border-color: var(--cl-earth-dark);
border-radius: 50%;
}
.building--campfire::after {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
.agent-card-dot {
width: 12px;
height: 16px;
background: var(--cl-fire);
border-radius: 50% 50% 20% 20%;
box-shadow:
0 0 6px var(--cl-fire-light),
0 0 12px var(--cl-fire-mid);
animation: campfireFlicker 0.3s steps(3) infinite alternate;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
@keyframes campfireFlicker {
0% { height: 14px; background: var(--cl-fire); }
33% { height: 18px; background: var(--cl-fire-light); }
66% { height: 12px; background: var(--cl-fire-mid); }
100% { height: 16px; background: var(--cl-fire); }
}
/* ═══════════════════════════════════════════════════════════════
BUILDING LABELS
═══════════════════════════════════════════════════════════════ */
.building-label {
position: absolute;
.agent-card-header h3 {
font-size: 16px;
font-family: 'Courier New', monospace;
margin: 0;
}
.agent-card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.agent-card-row {
display: flex;
flex-direction: column;
gap: 2px;
}
.agent-card-label {
font-size: 10px;
font-weight: bold;
color: #888;
text-transform: uppercase;
letter-spacing: 1px;
pointer-events: none;
text-shadow: 1px 1px 0 rgba(0,0,0,0.8);
}
/* ═══════════════════════════════════════════════════════════════
AGENT SPRITES
═══════════════════════════════════════════════════════════════ */
.agent-sprite {
position: absolute;
width: var(--agent-size);
height: var(--agent-size);
image-rendering: pixelated;
transition: left 0.8s ease-in-out, top 0.8s ease-in-out;
z-index: 10;
cursor: default;
}
/* Agent body — pixel-art character using box-shadow technique */
.agent-body {
width: 18px;
height: 20px;
margin: 0 auto;
position: relative;
/* Base body shape */
background-color: var(--agent-color, #ccc);
box-shadow:
/* Head */
3px -10px 0 2px var(--agent-color, #ccc),
-3px -10px 0 2px var(--agent-color, #ccc),
/* Eyes */
1px -8px 0 1px #222,
-1px -8px 0 1px #222,
/* Body */
0px 0px 0 0px var(--agent-color, #ccc);
border-radius: 2px 2px 0 0;
image-rendering: pixelated;
}
/* Agent accessory — department-unique */
.agent-accessory {
position: absolute;
top: -14px;
left: 50%;
transform: translateX(-50%);
width: 8px;
height: 4px;
border-radius: 2px 2px 0 0;
}
/* Agent colors per department */
.agent--cos { --agent-color: var(--agent-cos); }
.agent--eng { --agent-color: var(--agent-eng); }
.agent--senior { --agent-color: var(--agent-senior); }
.agent--trust { --agent-color: var(--agent-trust); }
.agent--market { --agent-color: var(--agent-market); }
.agent--health { --agent-color: var(--agent-health); }
.agent--content { --agent-color: var(--agent-content); }
.agent--pres { --agent-color: var(--agent-pres); }
.agent--vault { --agent-color: var(--agent-vault); }
/* ── Agent name label ──────────────────────────────────────── */
.agent-name {
position: absolute;
top: -22px;
left: 50%;
transform: translateX(-50%);
font-family: 'Courier New', monospace;
font-size: 9px;
font-weight: bold;
color: #fff;
text-shadow: 1px 1px 0 #000, -1px 1px 0 #000, 1px -1px 0 #000, -1px -1px 0 #000;
white-space: nowrap;
pointer-events: none;
}
/* ── Idle bob animation ────────────────────────────────────── */
.agent-sprite[data-activity="idle"] .agent-body {
animation: agentBob 1.5s ease-in-out infinite;
}
@keyframes agentBob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
/* ── Working animation ─────────────────────────────────────── */
.agent-sprite[data-activity="working"] .agent-body {
animation: agentWork 0.4s steps(2) infinite;
}
@keyframes agentWork {
0% { transform: translateY(0) scale(1); }
100% { transform: translateY(-2px) scale(1.05); }
}
/* ── Thinking animation ────────────────────────────────────── */
.agent-sprite[data-activity="thinking"] .agent-body {
animation: agentThink 2s ease-in-out infinite;
}
@keyframes agentThink {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-1px) rotate(-2deg); }
75% { transform: translateY(-1px) rotate(2deg); }
}
/* ── Offline state ─────────────────────────────────────────── */
.agent-sprite[data-activity="offline"] {
opacity: 0.35;
filter: grayscale(80%);
}
.agent-sprite[data-activity="offline"] .agent-body {
animation: agentSleep 3s ease-in-out infinite;
}
@keyframes agentSleep {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(1px) scaleY(0.9); }
}
/* ── Blocked state ─────────────────────────────────────────── */
.agent-sprite[data-activity="blocked"] {
animation: agentBlocked 0.5s steps(2) infinite;
}
@keyframes agentBlocked {
0% { transform: translateX(-1px); }
100% { transform: translateX(1px); }
}
/* ── Thought bubble ────────────────────────────────────────── */
.agent-thought {
position: absolute;
top: -36px;
left: 50%;
transform: translateX(-50%);
background: #fff;
color: #333;
font-family: 'Courier New', monospace;
font-size: 7px;
padding: 2px 4px;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
border: 1px solid #ccc;
}
.agent-sprite[data-activity="thinking"] .agent-thought {
opacity: 1;
animation: thoughtFloat 2s ease-in-out infinite;
}
.agent-sprite[data-activity="blocked"] .agent-thought {
opacity: 1;
background: #ff4444;
color: #fff;
border-color: #ff2222;
animation: thoughtFloat 0.8s ease-in-out infinite;
}
@keyframes thoughtFloat {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-3px); }
}
/* ── Sparkle particles for working ─────────────────────────── */
.agent-sparkles {
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
display: none;
}
.agent-sprite[data-activity="working"] .agent-sparkles {
display: block;
}
.sparkle {
position: absolute;
width: 2px;
height: 2px;
background: var(--cl-gold-bright);
animation: sparkleTwinkle 0.5s steps(1) infinite;
}
.sparkle:nth-child(1) { left: -4px; animation-delay: 0s; }
.sparkle:nth-child(2) { left: 2px; animation-delay: 0.1s; }
.sparkle:nth-child(3) { top: -4px; left: -1px; animation-delay: 0.2s; }
@keyframes sparkleTwinkle {
0% { opacity: 1; background: var(--cl-gold-bright); }
50% { opacity: 0; background: var(--cl-fire-light); }
100% { opacity: 1; background: var(--cl-gold-bright); }
}
/* ── Status glow ring ──────────────────────────────────────── */
.agent-glow {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 22px;
height: 4px;
border-radius: 50%;
opacity: 0.5;
}
.agent-sprite[data-activity="idle"] .agent-glow { background: #4c4; box-shadow: 0 0 4px #4c4; }
.agent-sprite[data-activity="working"] .agent-glow { background: #48f; box-shadow: 0 0 6px #48f; animation: glowPulse 0.8s ease-in-out infinite; }
.agent-sprite[data-activity="thinking"] .agent-glow { background: #fc0; box-shadow: 0 0 4px #fc0; animation: glowPulse 1.5s ease-in-out infinite; }
.agent-sprite[data-activity="blocked"] .agent-glow { background: #f44; box-shadow: 0 0 6px #f44; animation: glowPulse 0.4s ease-in-out infinite; }
.agent-sprite[data-activity="offline"] .agent-glow { background: #666; box-shadow: 0 0 2px #666; }
@keyframes glowPulse {
0%, 100% { opacity: 0.3; transform: translateX(-50%) scaleX(0.8); }
50% { opacity: 0.7; transform: translateX(-50%) scaleX(1.2); }
}
/* ── Agent tooltip ─────────────────────────────────────────── */
.agent-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: rgba(45,27,14,0.95);
.agent-card-value {
font-size: 12px;
color: #e0d5c0;
padding: 6px 10px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 10px;
white-space: nowrap;
border: 1px solid var(--cl-earth-mid);
box-shadow: 2px 2px 0 rgba(0,0,0,0.5);
z-index: 100;
pointer-events: none;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.agent-tooltip strong {
color: var(--agent-color, var(--cl-gold-bright));
.agent-card-status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
}
.agent-tooltip .task {
.agent-card-task {
color: #aaa;
font-style: italic;
margin-top: 2px;
font-size: 11px;
}
/* ═══════════════════════════════════════════════════════════════
@ -566,7 +170,7 @@ body {
left: 0;
right: 0;
height: 44px;
background: rgba(45,27,14,0.92);
background: rgba(45, 27, 14, 0.92);
display: flex;
align-items: center;
justify-content: center;
@ -578,9 +182,7 @@ body {
border-top: 2px solid var(--cl-earth-dark);
z-index: 20;
}
.farm-bar_separator {
color: var(--cl-earth-dark);
}
.farm-bar_separator { color: var(--cl-earth-dark); }
.farm-bar_status {
display: flex;
align-items: center;
@ -591,7 +193,6 @@ body {
width: 7px;
height: 7px;
border-radius: 50%;
image-rendering: pixelated;
}
/* ═══════════════════════════════════════════════════════════════
@ -605,7 +206,7 @@ body {
display: flex;
align-items: center;
gap: 8px;
background: rgba(45,27,14,0.85);
background: rgba(45, 27, 14, 0.85);
padding: 5px 12px;
border: 1px solid var(--cl-earth-dark);
font-family: 'Courier New', monospace;
@ -617,80 +218,4 @@ body {
width: 8px;
height: 8px;
border-radius: 50%;
image-rendering: pixelated;
}
/* ═══════════════════════════════════════════════════════════════
MISC DECORATIVE
═══════════════════════════════════════════════════════════════ */
/* Trees — decorative pixel-art trees scattered around */
.pixel-tree {
position: absolute;
z-index: 5;
pointer-events: none;
}
.pixel-tree::before {
content: '';
display: block;
width: 8px;
height: 8px;
margin: 0 auto;
background: var(--cl-green-dark);
box-shadow:
-4px -4px 0 1px var(--cl-green-mid),
4px -4px 0 1px var(--cl-green-mid),
0px -14px 0 3px var(--cl-green-mid),
-6px -8px 0 1px var(--cl-green-light),
6px -8px 0 1px var(--cl-green-light);
}
.pixel-tree::after {
content: '';
display: block;
width: 2px;
height: 6px;
margin: -2px auto 0;
background: var(--cl-earth-dark);
}
/* Rocks */
.pixel-rock {
position: absolute;
z-index: 3;
pointer-events: none;
width: 8px;
height: 5px;
background: #666;
border-radius: 2px;
box-shadow: 2px 2px 0 rgba(0,0,0,0.2);
}
/* Fence posts */
.fence-post {
position: absolute;
z-index: 6;
pointer-events: none;
width: 4px;
height: 12px;
background: var(--cl-wood-dark);
}
.fence-post::after {
content: '';
display: block;
width: 16px;
height: 2px;
background: var(--cl-wood-dark);
position: absolute;
top: 4px;
left: 2px;
}
/* Flower decorations */
.pixel-flower {
position: absolute;
z-index: 4;
pointer-events: none;
width: 3px;
height: 3px;
border-radius: 50%;
}