From 464fb9e6acd3ee96fc7f9034a8f6084db64109ac Mon Sep 17 00:00:00 2001 From: "Engineering (agent-engineering)" Date: Wed, 20 May 2026 09:14:27 -0700 Subject: [PATCH] feat(frontend): tile-based room rendering with LimeZu floor patterns --- src/frontend/FarmCanvas.jsx | 651 +++++++++++++++++---------------- src/frontend/src/tileLoader.js | 122 ++++++ 2 files changed, 451 insertions(+), 322 deletions(-) create mode 100644 src/frontend/src/tileLoader.js diff --git a/src/frontend/FarmCanvas.jsx b/src/frontend/FarmCanvas.jsx index 9ff644b..530e9f3 100644 --- a/src/frontend/FarmCanvas.jsx +++ b/src/frontend/FarmCanvas.jsx @@ -1,346 +1,353 @@ -// FarmCanvas.jsx — Clean Canvas 2D renderer for the Virtual Office -// Draws rooms with colored floors/walls, simple furniture, and agent sprites. -// No tile-sheet dependencies. Pure Canvas drawing. - -import { useEffect, useRef, useCallback } from 'react'; +// FarmCanvas.jsx — SVG farm canvas with tile-based room interiors from LimeZu tileset +import { useRef, useCallback } from 'react'; import { useStore } from './store'; -import { TILE, COLS, ROWS, rooms, agentDesks, hallwayTiles, furniture } from './officeMap'; +import { rooms, agentDesks } from './officeMap'; +import { TILESET_URL, TILE_INDEX, ROOM_TILE_STYLES } from './src/tileLoader'; -const SCALE = 2.5; // render pixels per tile pixel -const SPRITE_SCALE = 2.5; +const TILE = 32; +const SCALE = 2; // 2x = 64px per tile -// Agent sprite image cache -const spriteImages = {}; -let spritesLoaded = 0; -let spritesTotal = 0; +function px(n) { return n * TILE * SCALE; } +function py(n) { return n * TILE * SCALE; } -function loadSprites() { - const agents = ['cos','engineering','senior-eng','trust','market','health','content','presentations','vault']; - spritesTotal = agents.length; - spritesLoaded = 0; - agents.forEach(id => { - const img = new Image(); - img.src = `/office/sprites/agent-${id}.png`; - img.onload = () => { spritesLoaded++; }; - img.onerror = () => { console.warn('Failed to load sprite:', id); spritesLoaded++; }; - spriteImages[id] = img; - }); +// Room style definitions — professional color palette +const ROOM_STYLES = { + briefing: { fill: '#e8dcc0', stroke: '#8b7355', accent: '#c9b896', name: 'Briefing Room' }, + commons: { fill: '#c8d8c0', stroke: '#5a7a4a', accent: '#a8c8a0', name: 'Commons' }, + warroom: { fill: '#d8d0c8', stroke: '#6a5a4a', accent: '#b8a898', name: 'War Room' }, + engineering:{ fill: '#c0d0e0', stroke: '#3a5a7a', accent: '#90b0d0', name: 'Engineering Bay' }, + quiet: { fill: '#e0d8e8', stroke: '#5a5070', accent: '#c0b0d8', name: 'Quiet Zone' }, + ops: { fill: '#d0e0d8', stroke: '#4a6a5a', accent: '#a0c8b0', name: 'Ops Center' }, +}; + +// Agent definitions with desk positions +const AGENTS = [ + { id: 'cos', name: 'Marcus', dept: 'Chief of Staff', color: '#e8833a', desk: 'briefing', dx: 4, dy: 3 }, + { id: 'health', name: 'Health', dept: 'Wellness', color: '#eacc4a', desk: 'briefing', dx: 2, dy: 3 }, + { id: 'content', name: 'Content', dept: 'Content', color: '#aa7adc', desk: 'commons', dx: 12, dy: 3 }, + { id: 'vault', name: 'Vault', dept: 'Knowledge', color: '#7adcaa', desk: 'commons', dx: 10, dy: 3 }, + { id: 'market', name: 'Market', dept: 'Market', color: '#4aaa4a', desk: 'warroom', dx: 20, dy: 3 }, + { id: 'engineering', name: 'Engineering', dept: 'Engineering', color: '#4ac4c4', desk: 'engineering', dx: 4, dy: 10 }, + { id: 'senior-eng', name: 'Senior Eng', dept: 'Architecture', color: '#9a6adc', desk: 'quiet', dx: 12, dy: 10 }, + { id: 'trust', name: 'Trust', dept: 'Security', color: '#4a7aff', desk: 'ops', dx: 20, dy: 10 }, + { id: 'presentations', name: 'Presentations', dept: 'Speaking', color: '#ea6a6a', desk: 'ops', dx: 22, dy: 10 }, +]; + +const TILESET_W = 512; +const TILESET_H = 2848; + +// Render a single tile from the spritesheet using SVG with clipPath for slicing +function TileSprite({ tileCol, tileRow, x, y }) { + const dx = -tileCol * TILE; + const dy = -tileRow * TILE; + return ( + + ); } -loadSprites(); - -// Draw a rounded rect room -function drawRoom(ctx, room, ox, oy) { - const x = room.x * TILE * SCALE + ox; - const y = room.y * TILE * SCALE + oy; - const w = room.w * TILE * SCALE; - const h = room.h * TILE * SCALE; - const r = 6; - - // Floor - ctx.fillStyle = room.floor; - ctx.beginPath(); - ctx.roundRect(x, y, w, h, r); - ctx.fill(); - - // Floor grain pattern (subtle diagonal lines for warmth) - ctx.save(); - ctx.beginPath(); - ctx.roundRect(x, y, w, h, r); - ctx.clip(); - ctx.strokeStyle = room.accent + '30'; // 30 = ~19% opacity - ctx.lineWidth = 1; - for (let i = -h; i < w + h; i += 12) { - ctx.beginPath(); - ctx.moveTo(x + i, y); - ctx.lineTo(x + i + h, y + h); - ctx.stroke(); - } - ctx.restore(); - - // Wall border - ctx.strokeStyle = room.wall; - ctx.lineWidth = 3; - ctx.beginPath(); - ctx.roundRect(x, y, w, h, r); - ctx.stroke(); - - // Doorways — gaps in the bottom wall - const doorWidth = TILE * SCALE; - ctx.fillStyle = '#8a7a6a'; // doorframe color - // Left door - ctx.fillRect(x + 1 * TILE * SCALE - doorWidth/2, y + h - 3, doorWidth, 6); - // Right door - if (room.w >= 5) { - ctx.fillRect(x + (room.w - 2) * TILE * SCALE - doorWidth/2, y + h - 3, doorWidth, 6); - } - - // Room name label — small, above room - ctx.fillStyle = room.wall; - ctx.font = `bold ${Math.round(11 * SCALE / 2.5)}px 'EB Garamond', serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText(room.name, x + w / 2, y - 4); -} - -// Draw simple furniture shapes -function drawFurniture(ctx, room, ox, oy) { - const items = furniture[room.id] || []; - items.forEach(item => { - const x = (room.x + item.rx) * TILE * SCALE + ox; - const y = (room.y + item.ry) * TILE * SCALE + oy; - const w = (item.w || 1) * TILE * SCALE; - const h = (item.h || 1) * TILE * SCALE; - - ctx.save(); - switch (item.type) { - case 'table': - ctx.fillStyle = room.accent; - ctx.beginPath(); - ctx.roundRect(x + 2, y + 2, w - 4, h - 4, 3); - ctx.fill(); - ctx.strokeStyle = room.wall + '60'; - ctx.lineWidth = 1; - ctx.stroke(); - break; - case 'desk': - ctx.fillStyle = room.accent; - ctx.fillRect(x + 2, y + 4, w - 4, h - 8); - ctx.strokeStyle = room.wall + '80'; - ctx.lineWidth = 1; - ctx.strokeRect(x + 2, y + 4, w - 4, h - 8); - break; - case 'chair': - ctx.fillStyle = room.wall; - ctx.fillRect(x + 8, y + 8, TILE * SCALE - 16, TILE * SCALE - 16); - break; - case 'monitor': - ctx.fillStyle = '#1a1a2e'; - ctx.fillRect(x + 10, y + 6, TILE * SCALE - 20, TILE * SCALE * 0.5); - ctx.fillStyle = '#4a9eff'; - ctx.fillRect(x + 12, y + 8, TILE * SCALE - 24, TILE * SCALE * 0.5 - 6); - break; - case 'sofa': - ctx.fillStyle = room.accent + 'cc'; - ctx.beginPath(); - ctx.roundRect(x + 2, y + 2, w - 4, h - 4, 4); - ctx.fill(); - ctx.strokeStyle = room.wall; - ctx.lineWidth = 1; - ctx.stroke(); - break; - case 'shelf': - case 'bookcase': - ctx.fillStyle = room.wall; - ctx.fillRect(x + 2, y + 2, w - 4, h - 4); - // Book spines - for (let b = 0; b < Math.min(w / 6, 8); b++) { - ctx.fillStyle = ['#c44','#4a4','#44a','#a4a','#ca4'][b % 5]; - ctx.fillRect(x + 4 + b * 6, y + 4, 4, h - 8); - } - break; - case 'plant': - ctx.fillStyle = '#2a6a2a'; - ctx.beginPath(); - ctx.arc(x + TILE * SCALE / 2, y + TILE * SCALE / 2, TILE * SCALE / 3, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = '#5a3a1a'; - ctx.fillRect(x + TILE * SCALE / 2 - 4, y + TILE * SCALE / 2 + 4, 8, 8); - break; - case 'lamp': - ctx.fillStyle = '#ffd700'; - ctx.beginPath(); - ctx.arc(x + TILE * SCALE / 2, y + TILE * SCALE / 2, 6, 0, Math.PI * 2); - ctx.fill(); - // Glow - const grad = ctx.createRadialGradient( - x + TILE * SCALE / 2, y + TILE * SCALE / 2, 2, - x + TILE * SCALE / 2, y + TILE * SCALE / 2, 20 - ); - grad.addColorStop(0, 'rgba(255,215,0,0.3)'); - grad.addColorStop(1, 'rgba(255,215,0,0)'); - ctx.fillStyle = grad; - ctx.beginPath(); - ctx.arc(x + TILE * SCALE / 2, y + TILE * SCALE / 2, 20, 0, Math.PI * 2); - ctx.fill(); - break; - case 'screen': - ctx.fillStyle = '#2a2a3e'; - ctx.fillRect(x + 2, y + 2, w - 4, h - 4); - ctx.fillStyle = '#3a5aff'; - ctx.fillRect(x + 4, y + 4, w - 8, h - 8); - break; - } - ctx.restore(); - }); -} - -// Draw hallway -function drawHallway(ctx, ox, oy) { - const hw = COLS * TILE * SCALE; - const hh = ROWS * TILE * SCALE; - - // Background - ctx.fillStyle = '#1a1a28'; - ctx.fillRect(ox, oy, hw, hh); - - // Hallway tiles - hallwayTiles.forEach(({ x, y }) => { - const px = x * TILE * SCALE + ox; - const py = y * TILE * SCALE + oy; - ctx.fillStyle = '#2a2a3e'; - ctx.fillRect(px, py, TILE * SCALE, TILE * SCALE); - // Subtle grid - ctx.strokeStyle = '#3a3a4e'; - ctx.lineWidth = 0.5; - ctx.strokeRect(px, py, TILE * SCALE, TILE * SCALE); - }); -} - -// Draw an agent -function drawAgent(ctx, agent, ox, oy) { - const img = spriteImages[agent.id]; - if (!img || !img.complete || img.naturalWidth === 0) return; - - const sx = agent.x * TILE * SCALE + ox; - const sy = agent.y * TILE * SCALE + oy; - const spriteSize = 32 * SPRITE_SCALE; - - // Center sprite on tile - const dx = sx + (TILE * SCALE - spriteSize) / 2; - const dy = sy + (TILE * SCALE - spriteSize) / 2 - 4; // slight lift - - // Shadow - ctx.fillStyle = 'rgba(0,0,0,0.2)'; - ctx.beginPath(); - ctx.ellipse(dx + spriteSize / 2, dy + spriteSize + 2, spriteSize / 3, 4, 0, 0, Math.PI * 2); - ctx.fill(); - - // Sprite - ctx.imageSmoothingEnabled = false; - ctx.drawImage(img, dx, dy, spriteSize, spriteSize); - - // Activity indicator dot - const statusColors = { - active: '#4aff4a', - idle: '#888', - error: '#ff4444', - offline: '#555', - }; - const color = statusColors[agent.status] || statusColors.idle; - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(dx + spriteSize - 4, dy + 4, 4, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = '#000'; - ctx.lineWidth = 1; - ctx.stroke(); +// Render a single furniture item: an element clipped to one tile +function FurnitureItem({ tileName, tx, ty }) { + const t = TILE_INDEX[tileName]; + if (!t) return null; + return ( + + + + + + + ); } export default function FarmCanvas() { - const canvasRef = useRef(null); + const containerRef = useRef(null); const agents = useStore(s => s.agents); const hoveredAgent = useStore(s => s.hoveredAgent); const setHoveredAgent = useStore(s => s.setHoveredAgent); const selectAgent = useStore(s => s.selectAgent); - const containerRef = useRef(null); - const canvasWidth = COLS * TILE * SCALE; - const canvasHeight = ROWS * TILE * SCALE; + const width = px(24); + const height = px(16); - const render = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - - // Clear - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - - // Background - ctx.fillStyle = '#1a1a28'; - ctx.fillRect(0, 0, canvasWidth, canvasHeight); - - // Draw hallways - drawHallway(ctx, 0, 0); - - // Draw rooms - rooms.forEach(room => { - drawRoom(ctx, room, 0, 0); - drawFurniture(ctx, room, 0, 0); - }); - - // Draw agents (sorted so hovered agent renders on top) - const sortedAgents = [...agents].sort((a, b) => { - if (a.id === hoveredAgent) return 1; - if (b.id === hoveredAgent) return -1; - return 0; - }); - sortedAgents.forEach(agent => drawAgent(ctx, agent, 0, 0)); - - // Hover highlight - if (hoveredAgent) { - const agent = agents.find(a => a.id === hoveredAgent); - if (agent) { - const hx = agent.x * TILE * SCALE; - const hy = agent.y * TILE * SCALE; - ctx.strokeStyle = '#ffd700'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.roundRect(hx + 2, hy + 2, TILE * SCALE - 4, TILE * SCALE - 4, 4); - ctx.stroke(); - } - } - }, [agents, hoveredAgent]); - - useEffect(() => { - render(); - }, [render]); - - // Handle mouse for hover detection const handleMouseMove = useCallback((e) => { - const canvas = canvasRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const scaleX = canvasWidth / rect.width; - const scaleY = canvasHeight / rect.height; - const mx = (e.clientX - rect.left) * scaleX; - const my = (e.clientY - rect.top) * scaleY; - - const tx = Math.floor(mx / (TILE * SCALE)); - const ty = Math.floor(my / (TILE * SCALE)); - - // Find agent at this tile - const found = agents.find(a => { - const ax = Math.floor(a.x); - const ay = Math.floor(a.y); - return (tx === ax && ty === ay) || (tx === ax && ty === ay - 1); - }); - + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const scaleX = (24 * TILE) / rect.width; + const scaleY = (16 * TILE) / rect.height; + const tx = Math.floor((e.clientX - rect.left) * scaleX); + const ty = Math.floor((e.clientY - rect.top) * scaleY); + const found = agents.find(a => Math.floor(a.x) === tx && Math.floor(a.y) === ty); setHoveredAgent(found ? found.id : null); }, [agents, setHoveredAgent]); - const handleClick = useCallback((e) => { - if (hoveredAgent) { - selectAgent(hoveredAgent); - } + const handleClick = useCallback(() => { + if (hoveredAgent) selectAgent(hoveredAgent); }, [hoveredAgent, selectAgent]); return ( -
- +
+ + + + + + + + {/* Per-room floor tile patterns from LimeZu tileset */} + {rooms.map(room => { + const tileStyle = ROOM_TILE_STYLES[room.id]; + const floor = TILE_INDEX[tileStyle.floor]; + if (!floor) return null; + return ( + + + + ); + })} + + {/* Wall tile clip paths */} + {rooms.map(room => { + const tileStyle = ROOM_TILE_STYLES[room.id]; + const wall = TILE_INDEX[tileStyle.wall]; + if (!wall) return null; + return ( + <> + {Array.from({ length: room.w }, (_, i) => [ + + + , + + + , + ])} + {Array.from({ length: room.h - 2 }, (_, i) => [ + + + , + + + , + ])} + + ); + })} + + + + + + + {/* Background hallway */} + + + + + + + {/* Rooms */} + {rooms.map(room => { + const style = ROOM_STYLES[room.id]; + const tileStyle = ROOM_TILE_STYLES[room.id]; + const isHovered = hoveredAgent && agents.find(a => a.id === hoveredAgent)?.desk === room.id; + + return ( + + {/* Room floor — tiled from LimeZu sprite */} + + + {/* Wall tiles on room perimeter — sliced from spritesheet */} + {(() => { + const wall = TILE_INDEX[tileStyle.wall]; + if (!wall) return null; + const wallDx = -wall.col * TILE * SCALE; + const wallDy = -wall.row * TILE * SCALE; + return ( + <> + {/* Top wall row */} + {Array.from({ length: room.w }, (_, i) => ( + + ))} + {/* Bottom wall row */} + {Array.from({ length: room.w }, (_, i) => ( + + ))} + {/* Left wall column (excluding corners) */} + {Array.from({ length: room.h - 2 }, (_, i) => ( + + ))} + {/* Right wall column (excluding corners) */} + {Array.from({ length: room.h - 2 }, (_, i) => ( + + ))} + + ); + })()} + + {/* Furniture from tile set */} + {tileStyle.furniture.map((f, fi) => ( + + ))} + + {/* Doorways */} + + {room.w >= 5 && ( + + )} + + {/* Room label */} + + + + {style.name} + + + + ); + })} + + + {/* Agents — HTML overlays for crisp sprites */} + {AGENTS.map(agent => { + const agentState = agents.find(a => a.id === agent.id); + const x = agentState ? agentState.x : agent.dx; + const y = agentState ? agentState.y : agent.dy; + const isHovered = hoveredAgent === agent.id; + + return ( +
setHoveredAgent(agent.id)} + onMouseLeave={() => setHoveredAgent(null)} + onClick={() => selectAgent(agent.id)} + > + {/* Shadow */} +
+ {/* Sprite at 48x48, centered in 56x56 container */} + {agent.name} + {/* Status dot */} +
+ {/* Hover ring */} + {isHovered && ( +
+ )} +
+ ); + })}
); -} \ No newline at end of file +} diff --git a/src/frontend/src/tileLoader.js b/src/frontend/src/tileLoader.js new file mode 100644 index 0000000..98e89ba --- /dev/null +++ b/src/frontend/src/tileLoader.js @@ -0,0 +1,122 @@ +// tileLoader.js — Tile sprite index and room mapping for LimeZu Modern Interiors tileset +// Tilesheet: Interiors_free_32x32.png (512×2848 = 16 cols × 89 rows of 32px tiles) + +export const TILESET_URL = '/office/tiles/interiors_32x32.png'; + +// Standard RPG Maker MV tile sheet layout: +// R0: Auto-tiles A1 (animated) — not used +// R1-R5: Auto-tiles A2 (floors/terrain) +// R6-R10: Auto-tiles A3 (walls/decorations) +// R11: Auto-tiles A4 (inner walls) +// R12+: B-E sheets (furniture, objects, decorations) +export const TILE_INDEX = { + // Floors (A2: rows 1-5) + floor_light_wood: { row: 1, col: 0 }, + floor_dark_wood: { row: 1, col: 1 }, + floor_carpet: { row: 2, col: 0 }, + floor_tile: { row: 2, col: 1 }, + floor_modern: { row: 3, col: 0 }, + floor_rug: { row: 4, col: 0 }, + floor_blue_dots: { row: 3, col: 1 }, + + // Walls (A4: row 11) + wall_inner: { row: 11, col: 0 }, + wall_window: { row: 11, col: 1 }, + + // Furniture (B-E sheets: rows 12+) + desk: { row: 12, col: 0 }, + computer_desk: { row: 12, col: 1 }, + conf_table: { row: 12, col: 2 }, + chair_down: { row: 12, col: 3 }, + chair_up: { row: 12, col: 4 }, + sofa: { row: 12, col: 5 }, + bookshelf: { row: 12, col: 6 }, + cabinet: { row: 12, col: 7 }, + plant: { row: 12, col: 8 }, + whiteboard: { row: 12, col: 9 }, + monitor: { row: 12, col: 10 }, +}; + +// Per-room tile style assignments +// furniture: array of { tile: TILE_INDEX key, tx: room-relative x, ty: room-relative y } +export const ROOM_TILE_STYLES = { + briefing: { + floor: 'floor_light_wood', + wall: 'wall_inner', + furniture: [ + { tile: 'conf_table', tx: 2.5, ty: 2 }, + { tile: 'chair_down', tx: 1.5, ty: 2.5 }, + { tile: 'chair_up', tx: 5.7, ty: 2.5 }, + { tile: 'plant', tx: 0.5, ty: 0.5 }, + ], + }, + commons: { + floor: 'floor_modern', + wall: 'wall_inner', + furniture: [ + { tile: 'sofa', tx: 1, ty: 1.5 }, + { tile: 'sofa', tx: 1, ty: 2.5 }, + { tile: 'desk', tx: 4, ty: 2.5 }, + { tile: 'plant', tx: 0.5, ty: 0.5 }, + { tile: 'whiteboard', tx: 5, ty: 0.5 }, + ], + }, + warroom: { + floor: 'floor_dark_wood', + wall: 'wall_inner', + furniture: [ + { tile: 'conf_table', tx: 2.5, ty: 2 }, + { tile: 'chair_down', tx: 1.5, ty: 3 }, + { tile: 'chair_up', tx: 5.5, ty: 3 }, + { tile: 'monitor', tx: 0.5, ty: 1.5 }, + ], + }, + engineering: { + floor: 'floor_tile', + wall: 'wall_inner', + furniture: [ + { tile: 'computer_desk', tx: 2.5, ty: 2.5 }, + { tile: 'monitor', tx: 3.5, ty: 2 }, + { tile: 'chair_down', tx: 3, ty: 3.5 }, + { tile: 'bookshelf', tx: 0.5, ty: 0.5 }, + ], + }, + quiet: { + floor: 'floor_rug', + wall: 'wall_inner', + furniture: [ + { tile: 'sofa', tx: 2.5, ty: 2.5 }, + { tile: 'bookshelf', tx: 0.5, ty: 0.5 }, + { tile: 'plant', tx: 5, ty: 0.5 }, + ], + }, + ops: { + floor: 'floor_tile', + wall: 'wall_inner', + furniture: [ + { tile: 'desk', tx: 2, ty: 2 }, + { tile: 'desk', tx: 2, ty: 3 }, + { tile: 'desk', tx: 4, ty: 2 }, + { tile: 'desk', tx: 4, ty: 3 }, + { tile: 'monitor', tx: 2.5, ty: 1.5 }, + { tile: 'chair_down', tx: 3, ty: 2.5 }, + ], + }, +}; + +// Given a tile name from TILE_INDEX, return the source pixel offset on the spritesheet +export function tileSourceOffset(tileName) { + const t = TILE_INDEX[tileName]; + if (!t) return { sx: 0, sy: 0 }; + return { sx: t.col * 32, sy: t.row * 32 }; +} + +// Room → hallway connection points (tile coordinates for doorways) +export const DOORWAYS = { + briefing: [{ x: 4, y: 6 }], + commons: [{ x: 12, y: 6 }], + warroom: [{ x: 20, y: 6 }], + engineering: [{ x: 4, y: 13 }], + quiet: [{ x: 12, y: 13 }], + ops: [{ x: 20, y: 13 }], +};