feat(frontend): tile-based room rendering with LimeZu floor patterns
This commit is contained in:
parent
1f52daf3c3
commit
464fb9e6ac
2 changed files with 451 additions and 322 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
122
src/frontend/src/tileLoader.js
Normal file
122
src/frontend/src/tileLoader.js
Normal 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 }],
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue