feat(frontend): Canvas 2D farm with real pixel art sprites and HTML overlay cards
80
src/frontend/AgentCard.jsx
Normal 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}>×</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
BIN
src/frontend/public/sprites/agent-content.png
Normal file
|
After Width: | Height: | Size: 279 B |
BIN
src/frontend/public/sprites/agent-cos.png
Normal file
|
After Width: | Height: | Size: 295 B |
BIN
src/frontend/public/sprites/agent-engineering.png
Normal file
|
After Width: | Height: | Size: 288 B |
BIN
src/frontend/public/sprites/agent-health.png
Normal file
|
After Width: | Height: | Size: 276 B |
BIN
src/frontend/public/sprites/agent-market.png
Normal file
|
After Width: | Height: | Size: 245 B |
BIN
src/frontend/public/sprites/agent-presentations.png
Normal file
|
After Width: | Height: | Size: 302 B |
BIN
src/frontend/public/sprites/agent-senior-eng.png
Normal file
|
After Width: | Height: | Size: 300 B |
BIN
src/frontend/public/sprites/agent-trust.png
Normal file
|
After Width: | Height: | Size: 279 B |
BIN
src/frontend/public/sprites/agent-vault.png
Normal file
|
After Width: | Height: | Size: 276 B |
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||