feat(frontend): tile-based room rendering with LimeZu floor patterns

This commit is contained in:
engineering Agent 2026-05-20 09:14:27 -07:00
parent 1f52daf3c3
commit 464fb9e6ac
2 changed files with 451 additions and 322 deletions

View file

@ -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 <image> with clipPath for slicing
function TileSprite({ tileCol, tileRow, x, y }) {
const dx = -tileCol * TILE;
const dy = -tileRow * TILE;
return (
<image
href={TILESET_URL}
x={px(x) + dx * SCALE}
y={py(y) + dy * SCALE}
width={TILESET_W * SCALE}
height={TILESET_H * SCALE}
style={{ imageRendering: 'pixelated', pointerEvents: 'none' }}
/>
);
}
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 <image> element clipped to one tile
function FurnitureItem({ tileName, tx, ty }) {
const t = TILE_INDEX[tileName];
if (!t) return null;
return (
<g>
<clipPath id={`clip-f-${tx}-${ty}`}>
<rect x={px(tx)} y={py(ty)} width={px(1)} height={py(1)} />
</clipPath>
<image
href={TILESET_URL}
x={px(tx) - t.col * TILE * SCALE}
y={py(ty) - t.row * TILE * SCALE}
width={TILESET_W * SCALE}
height={TILESET_H * SCALE}
style={{ imageRendering: 'pixelated' }}
clipPath={`url(#clip-f-${tx}-${ty})`}
/>
</g>
);
}
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 (
<div ref={containerRef} style={{ position: 'relative', width: '100%', maxWidth: canvasWidth, margin: '0 auto' }}>
<canvas
ref={canvasRef}
width={canvasWidth}
height={canvasHeight}
onMouseMove={handleMouseMove}
onClick={handleClick}
style={{
width: '100%',
maxWidth: canvasWidth,
imageRendering: 'pixelated',
borderRadius: '8px',
cursor: hoveredAgent ? 'pointer' : 'default',
}}
/>
<div
ref={containerRef}
style={{
position: 'relative',
width: width,
height: height,
margin: '0 auto',
borderRadius: 12,
overflow: 'hidden',
background: '#1a1a28',
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
}}
onMouseMove={handleMouseMove}
onClick={handleClick}
>
<svg width={width} height={height} style={{ position: 'absolute', inset: 0 }}>
<defs>
<pattern id="floorGrid" width={px(1)} height={px(1)} patternUnits="userSpaceOnUse">
<rect width={px(1)} height={px(1)} fill="#2a2a3e" />
<path d={`M ${px(1)} 0 L 0 0 0 ${px(1)}`} fill="none" stroke="#3a3a4e" strokeWidth={0.5} />
</pattern>
{/* 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 (
<pattern key={`floor-${room.id}`} id={`floor-${room.id}`}
width={px(1)} height={py(1)} patternUnits="userSpaceOnUse">
<image
href={TILESET_URL}
x={-floor.col * TILE * SCALE}
y={-floor.row * TILE * SCALE}
width={TILESET_W * SCALE}
height={TILESET_H * SCALE}
style={{ imageRendering: 'pixelated' }}
/>
</pattern>
);
})}
{/* 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) => [
<clipPath key={`clip-wt-${room.id}-${i}`} id={`clip-wt-${room.id}-${i}`}>
<rect x={px(room.x + i)} y={py(room.y)} width={px(1)} height={py(1)} />
</clipPath>,
<clipPath key={`clip-wb-${room.id}-${i}`} id={`clip-wb-${room.id}-${i}`}>
<rect x={px(room.x + i)} y={py(room.y + room.h - 1)} width={px(1)} height={py(1)} />
</clipPath>,
])}
{Array.from({ length: room.h - 2 }, (_, i) => [
<clipPath key={`clip-wl-${room.id}-${i}`} id={`clip-wl-${room.id}-${i}`}>
<rect x={px(room.x)} y={py(room.y + 1 + i)} width={px(1)} height={py(1)} />
</clipPath>,
<clipPath key={`clip-wr-${room.id}-${i}`} id={`clip-wr-${room.id}-${i}`}>
<rect x={px(room.x + room.w - 1)} y={py(room.y + 1 + i)} width={px(1)} height={py(1)} />
</clipPath>,
])}
</>
);
})}
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="2" dy="4" stdDeviation="3" floodOpacity="0.3" />
</filter>
</defs>
{/* Background hallway */}
<rect x={0} y={0} width={width} height={height} fill="#1a1a28" />
<rect x={px(1)} y={px(6)} width={px(22)} height={px(2)} fill="url(#floorGrid)" />
<rect x={px(1)} y={px(14)} width={px(22)} height={px(2)} fill="url(#floorGrid)" />
<rect x={px(8)} y={px(6)} width={px(1)} height={px(10)} fill="url(#floorGrid)" />
<rect x={px(16)} y={px(6)} width={px(1)} height={px(10)} fill="url(#floorGrid)" />
{/* 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 (
<g key={room.id} filter={isHovered ? undefined : "url(#shadow)"}>
{/* Room floor — tiled from LimeZu sprite */}
<rect
x={px(room.x)} y={py(room.y)} width={px(room.w)} height={px(room.h)}
fill={`url(#floor-${room.id})`}
stroke={isHovered ? '#ffd700' : style.stroke}
strokeWidth={isHovered ? 3 : 2} rx={8}
/>
{/* 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) => (
<image key={`wt-${i}`} href={TILESET_URL}
x={px(room.x + i) + wallDx} y={py(room.y) + wallDy}
width={TILESET_W * SCALE} height={TILESET_H * SCALE}
style={{ imageRendering: 'pixelated', pointerEvents: 'none' }}
clipPath={`url(#clip-wt-${room.id}-${i})`} />
))}
{/* Bottom wall row */}
{Array.from({ length: room.w }, (_, i) => (
<image key={`wb-${i}`} href={TILESET_URL}
x={px(room.x + i) + wallDx} y={py(room.y + room.h - 1) + wallDy}
width={TILESET_W * SCALE} height={TILESET_H * SCALE}
style={{ imageRendering: 'pixelated', pointerEvents: 'none' }}
clipPath={`url(#clip-wb-${room.id}-${i})`} />
))}
{/* Left wall column (excluding corners) */}
{Array.from({ length: room.h - 2 }, (_, i) => (
<image key={`wl-${i}`} href={TILESET_URL}
x={px(room.x) + wallDx} y={py(room.y + 1 + i) + wallDy}
width={TILESET_W * SCALE} height={TILESET_H * SCALE}
style={{ imageRendering: 'pixelated', pointerEvents: 'none' }}
clipPath={`url(#clip-wl-${room.id}-${i})`} />
))}
{/* Right wall column (excluding corners) */}
{Array.from({ length: room.h - 2 }, (_, i) => (
<image key={`wr-${i}`} href={TILESET_URL}
x={px(room.x + room.w - 1) + wallDx} y={py(room.y + 1 + i) + wallDy}
width={TILESET_W * SCALE} height={TILESET_H * SCALE}
style={{ imageRendering: 'pixelated', pointerEvents: 'none' }}
clipPath={`url(#clip-wr-${room.id}-${i})`} />
))}
</>
);
})()}
{/* Furniture from tile set */}
{tileStyle.furniture.map((f, fi) => (
<FurnitureItem key={`furn-${fi}`} tileName={f.tile} tx={room.x + f.tx} ty={room.y + f.ty} />
))}
{/* Doorways */}
<rect x={px(room.x + 1) - 6} y={py(room.y + room.h) - 3} width={px(1) + 12} height={6}
fill="#8a7a6a" />
{room.w >= 5 && (
<rect x={px(room.x + room.w - 2) - 6} y={py(room.y + room.h) - 3} width={px(1) + 12} height={6}
fill="#8a7a6a" />
)}
{/* Room label */}
<g>
<rect
x={px(room.x) + 6} y={py(room.y) + 6}
width={style.name.length * 7 + 12} height={18}
fill="rgba(0,0,0,0.7)" rx={4}
/>
<text
x={px(room.x) + 12} y={py(room.y) + 19}
fill="#fff" fontSize="11" fontWeight="600"
fontFamily="'EB Garamond', Georgia, serif"
>
{style.name}
</text>
</g>
</g>
);
})}
</svg>
{/* 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 (
<div
key={agent.id}
style={{
position: 'absolute',
left: px(x) + (px(1) - 56) / 2,
top: py(y) + (py(1) - 56) / 2 - 8,
width: 56,
height: 56,
cursor: 'pointer',
zIndex: isHovered ? 10 : 1,
transition: 'transform 0.15s ease',
transform: isHovered ? 'scale(1.15)' : 'scale(1)',
}}
onMouseEnter={() => setHoveredAgent(agent.id)}
onMouseLeave={() => setHoveredAgent(null)}
onClick={() => selectAgent(agent.id)}
>
{/* Shadow */}
<div style={{
position: 'absolute',
bottom: 4,
left: '50%',
transform: 'translateX(-50%)',
width: 28,
height: 8,
background: 'rgba(0,0,0,0.25)',
borderRadius: '50%',
}} />
{/* Sprite at 48x48, centered in 56x56 container */}
<img
src={`/office/sprites/agent-${agent.id}.png`}
alt={agent.name}
style={{
width: 48,
height: 48,
imageRendering: 'pixelated',
position: 'absolute',
top: 4,
left: 4,
}}
/>
{/* Status dot */}
<div style={{
position: 'absolute',
top: 2,
right: 2,
width: 10,
height: 10,
borderRadius: '50%',
background: agentState?.status === 'active' ? '#4aff4a' :
agentState?.status === 'error' ? '#ff4444' : '#888',
border: '2px solid #000',
}} />
{/* Hover ring */}
{isHovered && (
<div style={{
position: 'absolute',
inset: -4,
border: '2px solid #ffd700',
borderRadius: 10,
pointerEvents: 'none',
}} />
)}
</div>
);
})}
</div>
);
}
}

View file

@ -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 }],
};