diff --git a/packages/frontend/src/vite.config.js b/packages/frontend/src/vite.config.js deleted file mode 100644 index 5af3800..0000000 --- a/packages/frontend/src/vite.config.js +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import path from 'path'; - -export default defineConfig({ - plugins: [react()], - root: '.', - base: '/', - server: { - port: 18790, - host: '0.0.0.0', - }, - build: { - outDir: 'dist', - emptyOutDir: true, - }, - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, - }, -}); diff --git a/src/frontend/AgentSprite.jsx b/src/frontend/AgentSprite.jsx index 8b4a68e..fb80a72 100644 --- a/src/frontend/AgentSprite.jsx +++ b/src/frontend/AgentSprite.jsx @@ -1,190 +1,126 @@ -import React, { useRef, useState } from 'react'; -import { useFrame } from '@react-three/fiber'; -import { Text, Html } from '@react-three/drei'; -import * as THREE from 'three'; +import React, { useState } from 'react'; -// ── Isometric grid helper: convert grid coords → world position ── -// Isometric: x = (col - row) * tileW/2, z = (col + row) * tileH/2 -const TILE_W = 1.0; -const TILE_H = 0.5; +// ── Grid → pixel position helper ───────────────────────────────── +// Simple top-down mapping: each tile is 32px × 32px +export const TILE_SIZE = 32; -export function gridToWorld(row, col) { - return [ - (col - row) * (TILE_W / 2), - 0, - (col + row) * (TILE_H / 2), - ]; +export function gridToPixel(row, col) { + return { + left: col * TILE_SIZE + TILE_SIZE / 2, + top: row * TILE_SIZE + TILE_SIZE / 2, + }; } -// ── World pos to grid (inverse) ────────────────────────────────── -export function worldToGrid(x, z) { - const col = (x / (TILE_W / 2) + z / (TILE_H / 2)) / 2; - const row = (z / (TILE_H / 2) - x / (TILE_W / 2)) / 2; - return { row: Math.round(row), col: Math.round(col) }; -} - -// ── Activity → glow color ──────────────────────────────────────── -const ACTIVITY_COLORS = { - idle: '#44cc44', - thinking: '#ffcc00', - working: '#4488ff', - blocked: '#ff4444', - offline: '#666666', +// ── Thought bubble content map ─────────────────────────────────── +const THOUGHT_BUBBLES = { + thinking: '...', + blocked: '!', }; -const LERP_SPEED = 0.08; +// ── Department accessory emoji ─────────────────────────────────── +const DEPARTMENT_ACCESSORIES = { + 'cos': '📋', + 'engineering': '⚙️', + 'senior-eng': '🔮', + 'trust': '🛡️', + 'market': '🔍', + 'health': '🌿', + 'content': '📝', + 'presentations': '✨', + 'vault': '🗝️', +}; -export default function AgentSprite({ agent, hovered, onHover }) { - const meshRef = useRef(); - const glowRef = useRef(); - const [targetPos, setTargetPos] = useState(null); +export default function AgentSprite({ agent, tileSize = TILE_SIZE }) { + const [hovered, setHovered] = useState(false); - // Initialize position - if (!targetPos && agent.position) { - setTargetPos(gridToWorld(agent.position.row, agent.position.col)); - } else if (agent.targetPosition) { - setTargetPos(gridToWorld(agent.targetPosition.row, agent.targetPosition.col)); - } + if (!agent || !agent.position) return null; - useFrame((state, delta) => { - if (!meshRef.current || !agent) return; + const { row, col } = agent.targetPosition || agent.position; + const { left, top } = gridToPixel(row, col); - // Calculate target world position - const tgt = agent.targetPosition || agent.position; - if (!tgt) return; + 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] || ''; - const [tx, , tz] = gridToWorld(tgt.row, tgt.col); - - // Smooth lerp toward target - meshRef.current.position.x += (tx - meshRef.current.position.x) * LERP_SPEED; - meshRef.current.position.z += (tz - meshRef.current.position.z) * LERP_SPEED; - - // If close enough, snap to target - if ( - Math.abs(tx - meshRef.current.position.x) < 0.01 && - Math.abs(tz - meshRef.current.position.z) < 0.01 - ) { - meshRef.current.position.x = tx; - meshRef.current.position.z = tz; - } - - // Floating bob - const bob = Math.sin(state.clock.elapsedTime * 2 + agent.position?.row || 0) * 0.05; - meshRef.current.position.y = 0.15 + bob; - - // Glow pulse based on activity - if (glowRef.current) { - const pulse = Math.sin(state.clock.elapsedTime * 3) * 0.5 + 0.5; - glowRef.current.material.opacity = agent.activity === 'working' ? 0.3 + pulse * 0.4 : - agent.activity === 'thinking' ? 0.2 + pulse * 0.3 : - agent.activity === 'offline' ? 0 : - 0.15; - } - }); - - if (!agent) return null; - - const color = agent.activity === 'offline' ? '#666666' : agent.color; - const glowColor = ACTIVITY_COLORS[agent.activity] || '#44cc44'; + // Status glow colors + const glowColors = { + idle: '#44cc44', + thinking: '#ffcc00', + working: '#4488ff', + blocked: '#ff4444', + offline: '#666666', + }; return ( - - {/* Agent body — isometric cube */} - { - e.stopPropagation(); - onHover(agent.id, true); - }} - onPointerLeave={(e) => { - e.stopPropagation(); - onHover(agent.id, false); - }} - castShadow - > - - - +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* Thought bubble */} + {thought && ( +
{thought}
+ )} - {/* Glow ring below agent */} - - - - - - {/* Agent name label */} - + {/* Name label */} +
{agent.name} - +
+ + {/* Sparkle particles for working */} +
+
+
+
+
+ + {/* Accessory icon above head */} + {accessory && ( +
+ {accessory} +
+ )} + + {/* Pixel-art body */} +
+ + {/* Status glow ring */} +
{/* Hover tooltip */} {hovered && ( - -
-
- {agent.name} -
-
- Status: {agent.activity} -
- {agent.currentTask && ( -
- {agent.currentTask} -
- )} -
- +
+ {agent.name} +
Status: {activity}
+ {task &&
{task}
} +
)} - +
); } diff --git a/src/frontend/App.jsx b/src/frontend/App.jsx index 0da2cf5..f76ba0d 100644 --- a/src/frontend/App.jsx +++ b/src/frontend/App.jsx @@ -1,66 +1,58 @@ import React, { useEffect, useRef } from 'react'; -import { Canvas } from '@react-three/fiber'; -import { OrbitControls } from '@react-three/drei'; -import * as THREE from 'three'; import useStore from './store.js'; import wsClient from './WebSocketClient.js'; -import OfficeScene from './OfficeScene.jsx'; +import FarmScene from './OfficeScene.jsx'; +import './src/farm.css'; -// ── Camera setup for true isometric ───────────────────────────── -// Orthographic with rotation: 45° around Y, then ~35.264° tilt down (atan(1/√2)) -// But the React-idiomatic way: position the camera and use lookAt -// Simpler: use orbit controls locked to isometric-ish angle -function CameraSetup() { - // We use OrbitControls with limited rotation for a fixed-ish isometric feel - return null; // Controls are in the Canvas -} - -// ── UI Overlay ────────────────────────────────────────────────── -function UIOverlay() { - const agents = useStore(s => s.agents); +// ── Connection indicator (top-right) ───────────────────────────── +function ConnectionIndicator() { const connected = useStore(s => s.wsConnected); + const reconnecting = useStore(s => s.wsReconnecting); + + const status = connected ? 'Connected' : reconnecting ? 'Reconnecting' : 'Offline'; + const dotColor = connected ? '#44cc44' : reconnecting ? '#ffcc00' : '#ff4444'; return ( -
- Spatial Office - | - - - {connected ? 'Live' : 'Offline'} - - | - {Object.keys(agents).length} agents +
+
+ {status}
); } -// ── App ───────────────────────────────────────────────────────── +// ── Bottom bar ──────────────────────────────────────────────────── +function FarmBar() { + const agents = useStore(s => s.agents); + const connected = useStore(s => s.wsConnected); + + return ( +
+ 🏡 Spatial Office + | + + + {connected ? 'Live' : 'Offline'} + + | + {Object.keys(agents).length} farmhands +
+ ); +} + +// ── App ─────────────────────────────────────────────────────────── export default function App() { const hasConnected = useRef(false); + const agents = useStore(s => s.agents); + const connected = useStore(s => s.wsConnected); useEffect(() => { if (hasConnected.current) return; @@ -85,92 +77,17 @@ export default function App() { }, []); return ( -
- - {/* Orthographic camera for true isometric */} - +
+ {/* Connection indicator */} + - {/* Lighting */} - - - + {/* Scrollable farm viewport */} +
+ +
- {/* Limited orbit controls — zoom + pan, but locked rotation for isometric feel */} - - - {/* Office scene */} - - - - + {/* Bottom status bar */} +
); } - -// ── Ortho camera wrapper ──────────────────────────────────────── -function OrthoCamera() { - const ref = useRef(); - - useEffect(() => { - if (!ref.current) return; - - // On mount, set orthographic projection - const camera = ref.current; - const aspect = window.innerWidth / window.innerHeight; - const frustumSize = 18; - - camera.left = -frustumSize * aspect / 2; - camera.right = frustumSize * aspect / 2; - camera.top = frustumSize / 2; - camera.bottom = -frustumSize / 2; - camera.near = 0.1; - camera.far = 200; - camera.position.set(14, 18, 14); - camera.lookAt(0, 0, 5); - camera.updateProjectionMatrix(); - - // Handle resize - const onResize = () => { - const a = window.innerWidth / window.innerHeight; - camera.left = -frustumSize * a / 2; - camera.right = frustumSize * a / 2; - camera.top = frustumSize / 2; - camera.bottom = -frustumSize / 2; - camera.updateProjectionMatrix(); - }; - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); - }, []); - - return ; -} diff --git a/src/frontend/OfficeScene.jsx b/src/frontend/OfficeScene.jsx index 6ef9cd6..9e682c6 100644 --- a/src/frontend/OfficeScene.jsx +++ b/src/frontend/OfficeScene.jsx @@ -1,195 +1,170 @@ -import React, { useMemo, useState } from 'react'; -import { useFrame } from '@react-three/fiber'; -import { Text, Html } from '@react-three/drei'; -import * as THREE from 'three'; -import useStore, { ZONES } from './store.js'; -import AgentSprite, { gridToWorld } from './AgentSprite.jsx'; +import React from 'react'; +import useStore from './store.js'; +import AgentSprite, { TILE_SIZE } from './AgentSprite.jsx'; -// ── Tile rendering constants ───────────────────────────────────── -const TILE_W = 1.0; -const TILE_H = 0.5; -const GRID_ROWS = 20; -const GRID_COLS = 28; +// ── 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 + }, +]; -// ── Zone colors ────────────────────────────────────────────────── -const ZONE_COLORS = { - 'pod-a': '#C68B3C', - 'pod-b': '#C68B3C', - 'solo-1': '#3A7CA5', - 'solo-2': '#3A7CA5', - 'coaching': '#5C8A5C', - 'coffee': '#8B6914', -}; -const FLOOR_COLOR = '#1a1a2e'; -const GRID_LINE_COLOR = '#2a2a4a'; +// ── 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 }, +]; -// ── Isometric floor tile ───────────────────────────────────────── -function FloorTile({ row, col, zoneId }) { - const [wx, _, wz] = gridToWorld(row, col); - const color = zoneId ? ZONE_COLORS[zoneId] || FLOOR_COLOR : FLOOR_COLOR; +// ── 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 })), +]; - // Create isometric diamond shape - const shape = useMemo(() => { - const s = new THREE.Shape(); - const hw = TILE_W / 2; - const hh = TILE_H / 2; - s.moveTo(0, hh); - s.lineTo(hw, 0); - s.lineTo(0, -hh); - s.lineTo(-hw, 0); - s.closePath(); - return s; - }, []); - - return ( - - {/* Floor diamond */} - - - - - - {/* Grid line border */} - - - - - - - - ); -} - -// ── Zone label overlay ────────────────────────────────────────── -function ZoneLabel({ zone }) { - const centerRow = zone.bounds.row + zone.bounds.rows / 2; - const centerCol = zone.bounds.col + zone.bounds.cols / 2; - const [wx, , wz] = gridToWorld(centerRow, centerCol); - - return ( - -
- {zone.name} -
- - ); -} - -// ── Connection indicator ──────────────────────────────────────── -function ConnectionIndicator() { - const connected = useStore(s => s.wsConnected); - const reconnecting = useStore(s => s.wsReconnecting); - - return ( - -
-
- {connected ? 'Connected' : reconnecting ? 'Reconnecting...' : 'Disconnected'} -
- - ); -} - -// ── Main scene ────────────────────────────────────────────────── -export default function OfficeScene() { +export default function FarmScene() { const agents = useStore(s => s.agents); - const grid = useStore(s => s.grid); - - // Hover state: { [agentId]: boolean } - const [hoveredAgents, setHoveredAgents] = useState({}); - - const tiles = useMemo(() => { - const result = []; - for (let row = 0; row < GRID_ROWS; row++) { - for (let col = 0; col < GRID_COLS; col++) { - const tile = grid.tiles?.[row]?.[col]; - result.push({ row, col, zoneId: tile?.zoneId || null }); - } - } - return result; - }, [grid]); - - const agentList = useMemo(() => Object.values(agents), [agents]); + const agentList = React.useMemo(() => Object.values(agents), [agents]); return ( - - {/* Base ground plane */} - - - - - - {/* Floor tiles */} - {tiles.map(({ row, col, zoneId }) => ( - - ))} - - {/* Zone labels */} - {Object.values(ZONES).map(zone => ( - - ))} - - {/* Agent sprites */} - {agentList.map(agent => ( - - setHoveredAgents(prev => ({ ...prev, [id]: isHovered })) - } +
+ {/* ── Dirt paths ──────────────────────────────────────── */} + {PATHS.map((p, i) => ( +
))} - {/* Connection status */} - - + {/* ── Buildings ───────────────────────────────────────── */} + {BUILDINGS.map(b => ( +
+
+
+ {b.label} +
+
+ ))} + + {/* ── Decorations ─────────────────────────────────────── */} + {DECORATIONS.map((d, i) => { + if (d.type === 'tree') return
; + if (d.type === 'rock') return
; + if (d.type === 'flower') return
; + if (d.type === 'fence') return
; + return null; + })} + + {/* ── Agent sprites ──────────────────────────────────── */} + {agentList.map(agent => ( + + ))} +
); } diff --git a/src/frontend/WebSocketClient.js b/src/frontend/WebSocketClient.js index 3966c52..ad5cd0a 100644 --- a/src/frontend/WebSocketClient.js +++ b/src/frontend/WebSocketClient.js @@ -19,7 +19,19 @@ class WebSocketClient { this.reconnectTimer = null; this.reconnectDelay = 1000; this.maxReconnectDelay = 30000; - this.url = 'ws://localhost:18790'; + // Auto-detect WS URL: use relative path when behind nginx, direct when dev + this.url = (() => { + if (typeof window !== 'undefined') { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + // When accessed via /office/, connect through same host + path + if (window.location.pathname.startsWith('/office/')) { + return `${proto}//${host}/office/`; + } + return `ws://${host}:18790`; + } + return 'ws://localhost:18790'; + })(); this.mockInterval = null; } diff --git a/src/frontend/index.html b/src/frontend/index.html index 843ef69..d48c10a 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -3,38 +3,59 @@ - Spatial Office — Scobleclaw + Spatial Office — Scobleclaw Farm -
+
+
+
+ Loading farm... +
+
diff --git a/src/frontend/package.json b/src/frontend/package.json index 2985ed0..98d3911 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -9,17 +9,13 @@ "preview": "vite preview" }, "dependencies": { - "@react-three/drei": "^9.117.0", - "@react-three/fiber": "^8.17.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "three": "^0.170.0", "zustand": "^5.0.0" }, "devDependencies": { "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", - "@types/three": "^0.170.0", "@vitejs/plugin-react": "^4.3.0", "vite": "^6.0.0" } diff --git a/src/frontend/src/farm.css b/src/frontend/src/farm.css new file mode 100644 index 0000000..c253c54 --- /dev/null +++ b/src/frontend/src/farm.css @@ -0,0 +1,696 @@ +/* ═══════════════════════════════════════════════════════════════ + SPATIAL OFFICE — STARDEW VALLEY PIXEL ART THEME + Palette: warm earthy farm tones + ═══════════════════════════════════════════════════════════════ */ + +/* ── 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 ────────────────────────────────────────────── */ +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body, #root { + width: 100%; + height: 100%; + overflow: hidden; +} +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 ────────────────────────── */ +.farm-container { + width: 100%; + height: 100%; + position: relative; + overflow: hidden; + background: var(--cl-bg-dark); +} + +/* ── Farm viewport — scrollable game board ─────────────────── */ +.farm-viewport { + width: 100%; + height: calc(100% - 44px); /* room for bottom bar */ + overflow: auto; + position: relative; + 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 + ═══════════════════════════════════════════════════════════════ */ + +.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: '✦'; + position: absolute; + inset: 0; + 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; +} + +/* 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%); + 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; +} + +@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; + font-family: 'Courier New', monospace; + font-size: 10px; + font-weight: bold; + 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); + 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; +} +.agent-tooltip strong { + color: var(--agent-color, var(--cl-gold-bright)); +} +.agent-tooltip .task { + color: #aaa; + font-style: italic; + margin-top: 2px; +} + +/* ═══════════════════════════════════════════════════════════════ + BOTTOM BAR + ═══════════════════════════════════════════════════════════════ */ + +.farm-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 44px; + background: rgba(45,27,14,0.92); + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 0 16px; + font-family: 'Courier New', monospace; + font-size: 11px; + color: var(--cl-wood-mid); + border-top: 2px solid var(--cl-earth-dark); + z-index: 20; +} +.farm-bar_separator { + color: var(--cl-earth-dark); +} +.farm-bar_status { + display: flex; + align-items: center; + gap: 6px; +} +.farm-bar_dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + image-rendering: pixelated; +} + +/* ═══════════════════════════════════════════════════════════════ + CONNECTION INDICATOR (top-right) + ═══════════════════════════════════════════════════════════════ */ + +.connection-indicator { + position: absolute; + top: 12px; + right: 12px; + display: flex; + align-items: center; + gap: 8px; + background: rgba(45,27,14,0.85); + padding: 5px 12px; + border: 1px solid var(--cl-earth-dark); + font-family: 'Courier New', monospace; + font-size: 10px; + color: var(--cl-wood-mid); + z-index: 30; +} +.connection-indicator_dot { + 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%; +} diff --git a/src/frontend/store.js b/src/frontend/store.js index 8a88641..e25411f 100644 --- a/src/frontend/store.js +++ b/src/frontend/store.js @@ -1,47 +1,47 @@ import { create } from 'zustand'; -// ── Zone definitions ─────────────────────────────────────────────── +// ── Stardew-inspired Zone definitions ─────────────────────────────── const ZONES = { 'pod-a': { id: 'pod-a', - name: 'Collaboration Pod A', + name: 'Workshop', type: 'collaboration', - color: '#C68B3C', + color: '#A0522D', bounds: { row: 2, col: 8, rows: 5, cols: 5 }, }, 'pod-b': { id: 'pod-b', - name: 'Collaboration Pod B', + name: 'Studio', type: 'collaboration', - color: '#C68B3C', + color: '#DEB887', bounds: { row: 2, col: 14, rows: 5, cols: 5 }, }, 'solo-1': { id: 'solo-1', - name: 'Solo Work Area 1', + name: 'Cabins', type: 'solo', - color: '#3A7CA5', + color: '#8B4513', bounds: { row: 2, col: 0, rows: 10, cols: 6 }, }, 'solo-2': { id: 'solo-2', - name: 'Solo Work Area 2', + name: 'Library', type: 'solo', - color: '#3A7CA5', + color: '#5B8FA8', bounds: { row: 2, col: 22, rows: 10, cols: 6 }, }, 'coaching': { id: 'coaching', - name: 'Coaching Corner', + name: 'Shrine', type: 'self-improvement', - color: '#5C8A5C', + color: '#DAA520', bounds: { row: 13, col: 8, rows: 4, cols: 12 }, }, 'coffee': { id: 'coffee', - name: 'Coffee Bar', + name: 'Campfire', type: 'social', - color: '#8B6914', + color: '#FF8C42', bounds: { row: 0, col: 10, rows: 2, cols: 8 }, }, }; @@ -59,17 +59,17 @@ const AGENT_DEFS = { vault: { id: 'vault', name: 'Vault', department: 'vault', color: '#A5D6A7' }, }; -// ── Default agent positions (row, col on the isometric grid) ─────── +// ── Default agent positions (row, col on the pixel grid) ─────────── const DEFAULT_POSITIONS = { - cos: { row: 3, col: 2 }, - engineering: { row: 4, col: 10 }, - 'senior-eng': { row: 5, col: 10 }, - trust: { row: 7, col: 2 }, - market: { row: 4, col: 16 }, - health: { row: 3, col: 24 }, - content: { row: 5, col: 16 }, - presentations: { row: 6, col: 16 }, - vault: { row: 7, col: 24 }, + cos: { row: 4, col: 2 }, + engineering: { row: 5, col: 10 }, + 'senior-eng': { row: 6, col: 10 }, + trust: { row: 8, col: 2 }, + market: { row: 5, col: 16 }, + health: { row: 4, col: 24 }, + content: { row: 6, col: 16 }, + presentations: { row: 7, col: 16 }, + vault: { row: 8, col: 24 }, }; // ── Helpers ──────────────────────────────────────────────────────── @@ -142,7 +142,7 @@ const useStore = create((set, get) => ({ for (const diff of diffs) { if (diff.op === 'agent.move' || diff.op === 'agent.update') { - const agentId = diff.path.split('.')[1]; // agents.engineering → engineering + const agentId = diff.path.split('.')[1]; if (updated[agentId]) { const delta = diff.value || {}; if (delta.position) { diff --git a/src/frontend/vite.config.js b/src/frontend/vite.config.js index 5af3800..d654d26 100644 --- a/src/frontend/vite.config.js +++ b/src/frontend/vite.config.js @@ -5,7 +5,7 @@ import path from 'path'; export default defineConfig({ plugins: [react()], root: '.', - base: '/', + base: '/office/', server: { port: 18790, host: '0.0.0.0',