# Agent Office v2 — Pixel Office UX 대규모 업데이트 구현 계획 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 대시보드 칼럼 중심 UI를 전체 화면 픽셀 오피스 캔버스 중심으로 전환하여 가상 오피스 몰입감 제공 **Architecture:** Canvas 2D 게임 루프 기반 렌더링 엔진 + BFS 경로 탐색 이동 시스템 + 3테마 프리셋. 에이전트 클릭 시 320px 사이드 패널(4탭)로 상세 정보 표시. 기존 백엔드 WebSocket 프로토콜 100% 호환. **Tech Stack:** React (기존), Canvas 2D API, requestAnimationFrame 게임 루프, BFS 경로 탐색, CSS transitions/transforms **Spec:** `docs/superpowers/specs/2026-04-27-agent-office-v2-design.md` **작업 대상 저장소:** `web-ui` (프론트엔드) — `C:\Users\jaeoh\Desktop\workspace\web-ui\` --- ## Phase 1: 캔버스 엔진 기초 ### Task 1: 테마 데이터 정의 **Files:** - Create: `src/pages/agent-office/canvas/themes.js` - [ ] **Step 1: 테마 데이터 파일 생성** ```javascript // src/pages/agent-office/canvas/themes.js export const THEMES = { modern: { name: 'Modern', wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' }, floor: { color1: '#2a2a3e', color2: '#323248', grid: 'rgba(255,255,255,0.03)' }, furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', monitorScreen: '#1a3a5a', shelf: '#2a2a4e', table: '#3a3a5a', sofa: '#2a2a4e', coffee: '#3a3a2a' }, decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' }, lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' }, text: { primary: '#ffffff', secondary: '#aaaaaa', label: '#888888' }, ui: { panelBg: '#111111', headerBg: '#1a1a2e', border: '#333333', accent: '#8b5cf6' } }, retro: { name: 'Retro', wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' }, floor: { color1: '#4a3a1a', color2: '#3a2a10', grid: 'rgba(255,255,255,0.02)' }, furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', monitorScreen: '#1a3a1a', shelf: '#5a3a1a', table: '#5a4a2a', sofa: '#5a3a2a', coffee: '#4a3a1a' }, decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' }, lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' }, text: { primary: '#ffe0b0', secondary: '#aa8866', label: '#887766' }, ui: { panelBg: '#1a1008', headerBg: '#2a1a0a', border: '#4a3a2a', accent: '#cc8844' } }, minimal: { name: 'Minimal', wall: { color: '#fafafa', border: '#dddddd', accent: '#3b82f6' }, floor: { color1: '#e8e8e8', color2: '#f0f0f0', grid: 'rgba(0,0,0,0.04)' }, furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', monitorScreen: '#e0e8f0', shelf: '#f5f5f5', table: '#ffffff', sofa: '#e8e8e8', coffee: '#f0f0f0' }, decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' }, lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' }, text: { primary: '#1a1a1a', secondary: '#666666', label: '#999999' }, ui: { panelBg: '#ffffff', headerBg: '#fafafa', border: '#e0e0e0', accent: '#3b82f6' } } }; export function getTheme(name) { return THEMES[name] || THEMES.modern; } export function getThemeNames() { return Object.entries(THEMES).map(([key, val]) => ({ key, name: val.name })); } ``` - [ ] **Step 2: 커밋** ```bash git add src/pages/agent-office/canvas/themes.js git commit -m "feat(agent-office): add theme data definitions (modern/retro/minimal)" ``` --- ### Task 2: 오피스 맵 데이터 확장 (32x20) **Files:** - Rewrite: `src/pages/agent-office/assets/office-map.json` - [ ] **Step 1: 32x20 맵 데이터 작성** ```json { "cols": 32, "rows": 20, "tileSize": 32, "floor": [ [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] ], "furniture": [ {"type": "desk_monitor", "col": 3, "row": 3, "agent": "stock", "monitors": 3}, {"type": "desk_monitor", "col": 10, "row": 3, "agent": "music", "monitors": 1, "accent": "instrument"}, {"type": "desk_monitor", "col": 17, "row": 3, "agent": "blog", "monitors": 2, "accent": "papers"}, {"type": "desk_monitor", "col": 24, "row": 3, "agent": "realestate", "monitors": 2, "accent": "briefcase"}, {"type": "desk_monitor", "col": 14, "row": 7, "agent": "lotto", "monitors": 1, "accent": "dice"}, {"type": "meeting_table","col": 13, "row": 11,"width": 6, "height": 2}, {"type": "sofa", "col": 2, "row": 17}, {"type": "coffee_machine","col": 5, "row": 16}, {"type": "bookshelf", "col": 27, "row": 16, "height": 3}, {"type": "plant", "col": 1, "row": 1}, {"type": "plant", "col": 30, "row": 1}, {"type": "plant", "col": 1, "row": 14}, {"type": "plant", "col": 30, "row": 14}, {"type": "water_cooler", "col": 8, "row": 17} ], "waypoints": { "desk_stock": {"col": 3, "row": 4}, "desk_music": {"col": 10, "row": 4}, "desk_blog": {"col": 17, "row": 4}, "desk_realestate": {"col": 24, "row": 4}, "desk_lotto": {"col": 14, "row": 8}, "meeting": {"col": 16, "row": 13}, "break_room": {"col": 4, "row": 17}, "coffee": {"col": 6, "row": 17}, "water_cooler": {"col": 8, "row": 18} }, "blocked": [ [3,3],[4,3],[5,3], [10,3],[11,3], [17,3],[18,3],[19,3], [24,3],[25,3],[26,3], [14,7],[15,7], [13,11],[14,11],[15,11],[16,11],[17,11],[18,11], [13,12],[14,12],[15,12],[16,12],[17,12],[18,12], [2,17],[3,17], [5,16],[6,16], [27,16],[27,17],[27,18], [8,17] ], "tileTypes": { "0": "wall", "1": "floor", "2": "floor_break" } } ``` - [ ] **Step 2: 커밋** ```bash git add src/pages/agent-office/assets/office-map.json git commit -m "feat(agent-office): expand office map to 32x20 with 5 agents and break room" ``` --- ### Task 3: BFS 경로 탐색 엔진 **Files:** - Create: `src/pages/agent-office/canvas/Pathfinder.js` - [ ] **Step 1: Pathfinder 모듈 작성** ```javascript // src/pages/agent-office/canvas/Pathfinder.js /** * BFS 4방향 경로 탐색 (대각선 없음) * blocked 타일과 벽 타일을 회피하여 최단 경로 반환 */ export class Pathfinder { constructor(cols, rows) { this.cols = cols; this.rows = rows; this.blocked = new Set(); } /** blocked 타일 세팅 (wall + furniture footprint) */ setBlocked(blockedList) { this.blocked.clear(); for (const [col, row] of blockedList) { this.blocked.add(`${col},${row}`); } } /** wall 타일도 blocked로 추가 (floor 배열에서 0인 셀) */ setWalls(floorGrid) { for (let r = 0; r < this.rows; r++) { for (let c = 0; c < this.cols; c++) { if (floorGrid[r][c] === 0) { this.blocked.add(`${c},${r}`); } } } } isBlocked(col, row) { if (col < 0 || col >= this.cols || row < 0 || row >= this.rows) return true; return this.blocked.has(`${col},${row}`); } /** * BFS 최단 경로 * @returns {Array<{col, row}>} 시작점 제외, 도착점 포함 경로. 경로 없으면 빈 배열. */ findPath(startCol, startRow, goalCol, goalRow) { if (startCol === goalCol && startRow === goalRow) return []; const key = (c, r) => `${c},${r}`; const startKey = key(startCol, startRow); const goalKey = key(goalCol, goalRow); const queue = [{ col: startCol, row: startRow }]; const visited = new Set([startKey]); const parent = new Map(); const dirs = [ { dc: 0, dr: -1 }, // up { dc: 0, dr: 1 }, // down { dc: -1, dr: 0 }, // left { dc: 1, dr: 0 } // right ]; while (queue.length > 0) { const current = queue.shift(); for (const { dc, dr } of dirs) { const nc = current.col + dc; const nr = current.row + dr; const nk = key(nc, nr); if (visited.has(nk) || this.isBlocked(nc, nr)) continue; // 골 지점은 blocked여도 이동 가능 (에이전트가 자기 자리에 앉으려면) if (nk !== goalKey && this.blocked.has(nk)) continue; visited.add(nk); parent.set(nk, key(current.col, current.row)); queue.push({ col: nc, row: nr }); if (nc === goalCol && nr === goalRow) { return this._reconstructPath(parent, startKey, goalKey); } } } return []; // 경로 없음 } _reconstructPath(parent, startKey, goalKey) { const path = []; let current = goalKey; while (current !== startKey) { const [c, r] = current.split(',').map(Number); path.unshift({ col: c, row: r }); current = parent.get(current); } return path; } /** idle 배회용: start 주변 반경 내 랜덤 walkable 타일 */ getRandomNearbyFloor(col, row, radius = 4) { const candidates = []; for (let dr = -radius; dr <= radius; dr++) { for (let dc = -radius; dc <= radius; dc++) { const nc = col + dc; const nr = row + dr; if (nc === col && nr === row) continue; if (!this.isBlocked(nc, nr)) { candidates.push({ col: nc, row: nr }); } } } if (candidates.length === 0) return null; return candidates[Math.floor(Math.random() * candidates.length)]; } } ``` - [ ] **Step 2: 커밋** ```bash git add src/pages/agent-office/canvas/Pathfinder.js git commit -m "feat(agent-office): add BFS pathfinder for agent movement" ``` --- ### Task 4: 타일맵 렌더러 재작성 (테마 지원) **Files:** - Rewrite: `src/pages/agent-office/canvas/TileMap.js` - [ ] **Step 1: TileMap 재작성** ```javascript // src/pages/agent-office/canvas/TileMap.js /** * 타일맵 렌더러 — 바닥, 벽, 그리드를 테마 팔레트로 렌더링 * 가구는 FurnitureRenderer가 별도 처리 */ export class TileMap { constructor(mapData) { this.cols = mapData.cols; this.rows = mapData.rows; this.tileSize = mapData.tileSize; this.floor = mapData.floor; this.tileTypes = mapData.tileTypes; } /** * 바닥 + 벽 렌더링 * @param {CanvasRenderingContext2D} ctx * @param {object} theme - themes.js 에서 가져온 테마 객체 * @param {number} scale - 줌 레벨 * @param {number} offsetX - 패닝 X 오프셋 * @param {number} offsetY - 패닝 Y 오프셋 */ render(ctx, theme, scale, offsetX, offsetY) { const ts = this.tileSize * scale; for (let r = 0; r < this.rows; r++) { for (let c = 0; c < this.cols; c++) { const tileType = this.floor[r][c]; const x = c * ts + offsetX; const y = r * ts + offsetY; // 화면 밖이면 스킵 if (x + ts < 0 || y + ts < 0 || x > ctx.canvas.width || y > ctx.canvas.height) continue; if (tileType === 0) { // 벽 ctx.fillStyle = theme.wall.color; ctx.fillRect(x, y, ts, ts); // 벽 하단 경계선 ctx.fillStyle = theme.wall.border; ctx.fillRect(x, y + ts - scale, ts, scale); } else { // 바닥 const isBreak = this.tileTypes[String(tileType)] === 'floor_break'; ctx.fillStyle = isBreak ? theme.floor.color2 : theme.floor.color1; ctx.fillRect(x, y, ts, ts); // 체커보드 패턴 if ((r + c) % 2 === 0) { ctx.fillStyle = theme.floor.grid; ctx.fillRect(x, y, ts, ts); } // 그리드 선 ctx.strokeStyle = theme.floor.grid; ctx.lineWidth = scale * 0.5; ctx.strokeRect(x, y, ts, ts); } } } } /** 화면 좌표 → 타일 좌표 변환 */ screenToTile(screenX, screenY, scale, offsetX, offsetY) { const ts = this.tileSize * scale; const col = Math.floor((screenX - offsetX) / ts); const row = Math.floor((screenY - offsetY) / ts); return { col, row }; } /** 타일 좌표 → 화면 좌표 (타일 중앙) */ tileToScreen(col, row, scale, offsetX, offsetY) { const ts = this.tileSize * scale; return { x: col * ts + offsetX + ts / 2, y: row * ts + offsetY + ts / 2 }; } } ``` - [ ] **Step 2: 커밋** ```bash git add src/pages/agent-office/canvas/TileMap.js git commit -m "refactor(agent-office): rewrite TileMap with theme support and viewport culling" ``` --- ### Task 5: 가구 렌더러 (테마 기반 프로시저럴) **Files:** - Create: `src/pages/agent-office/canvas/FurnitureRenderer.js` - [ ] **Step 1: FurnitureRenderer 작성** ```javascript // src/pages/agent-office/canvas/FurnitureRenderer.js /** * 가구 프로시저럴 렌더러 — 테마 팔레트 기반 * 각 가구 타입별 draw 함수, Y-sort를 위한 zY 반환 */ export class FurnitureRenderer { constructor(furnitureList, tileSize) { this.furnitureList = furnitureList; this.tileSize = tileSize; } /** * 모든 가구를 Y-sort 순서로 반환 (에이전트와 함께 정렬하기 위함) * @returns {Array<{type, col, row, zY, draw: Function}>} */ getRenderables(theme, scale, offsetX, offsetY) { const ts = this.tileSize * scale; return this.furnitureList.map(f => ({ ...f, zY: f.row, draw: (ctx) => this._drawFurniture(ctx, f, theme, ts, offsetX, offsetY) })); } _drawFurniture(ctx, f, theme, ts, ox, oy) { const x = f.col * ts + ox; const y = f.row * ts + oy; switch (f.type) { case 'desk_monitor': this._drawDesk(ctx, f, theme, ts, x, y); break; case 'meeting_table': this._drawMeetingTable(ctx, f, theme, ts, x, y); break; case 'sofa': this._drawSofa(ctx, theme, ts, x, y); break; case 'coffee_machine':this._drawCoffeeMachine(ctx, theme, ts, x, y); break; case 'bookshelf': this._drawBookshelf(ctx, f, theme, ts, x, y); break; case 'plant': this._drawPlant(ctx, theme, ts, x, y); break; case 'water_cooler': this._drawWaterCooler(ctx, theme, ts, x, y); break; } } _drawDesk(ctx, f, theme, ts, x, y) { // 책상 상판 const dw = ts * 2; const dh = ts * 0.6; ctx.fillStyle = theme.furniture.desk; ctx.fillRect(x, y + ts * 0.2, dw, dh); // 책상 다리 ctx.fillStyle = theme.wall.border; ctx.fillRect(x + ts * 0.1, y + dh + ts * 0.2, ts * 0.15, ts * 0.3); ctx.fillRect(x + dw - ts * 0.25, y + dh + ts * 0.2, ts * 0.15, ts * 0.3); // 모니터들 const monCount = f.monitors || 1; const monW = ts * 0.5; const monH = ts * 0.4; const totalW = monCount * monW + (monCount - 1) * ts * 0.1; let monX = x + (dw - totalW) / 2; for (let i = 0; i < monCount; i++) { // 모니터 프레임 ctx.fillStyle = theme.furniture.monitor; ctx.fillRect(monX, y - monH + ts * 0.2, monW, monH); // 화면 ctx.fillStyle = theme.furniture.monitorScreen; ctx.fillRect(monX + ts * 0.05, y - monH + ts * 0.25, monW - ts * 0.1, monH - ts * 0.1); // 모니터 받침대 ctx.fillStyle = theme.furniture.monitor; ctx.fillRect(monX + monW * 0.35, y + ts * 0.2 - ts * 0.05, monW * 0.3, ts * 0.08); monX += monW + ts * 0.1; } // 의자 (책상 아래) ctx.fillStyle = theme.furniture.chair; ctx.fillRect(x + dw * 0.35, y + ts, dw * 0.3, ts * 0.5); ctx.fillRect(x + dw * 0.3, y + ts * 0.8, dw * 0.4, ts * 0.25); // 에이전트별 악센트 소품 if (f.accent === 'instrument') { // 음표 모양 ctx.fillStyle = theme.ui.accent; ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.3, ts * 0.1, ts * 0.5); ctx.beginPath(); ctx.arc(x + dw + ts * 0.2, y + ts * 0.8, ts * 0.15, 0, Math.PI * 2); ctx.fill(); } else if (f.accent === 'papers') { // 서류 더미 ctx.fillStyle = '#ffffff'; ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.3, ts * 0.35, ts * 0.45); ctx.fillStyle = theme.text.label; for (let i = 0; i < 3; i++) { ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.38 + i * ts * 0.1, ts * 0.25, ts * 0.02); } } else if (f.accent === 'briefcase') { ctx.fillStyle = '#8B4513'; ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.5, ts * 0.4, ts * 0.3); ctx.fillStyle = '#D4A06A'; ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.45, ts * 0.2, ts * 0.08); } else if (f.accent === 'dice') { ctx.fillStyle = '#ef4444'; ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.4, ts * 0.3, ts * 0.3); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(x + dw + ts * 0.3, y + ts * 0.55, ts * 0.05, 0, Math.PI * 2); ctx.fill(); } } _drawMeetingTable(ctx, f, theme, ts, x, y) { const w = (f.width || 4) * ts; const h = (f.height || 2) * ts; // 테이블 상판 ctx.fillStyle = theme.furniture.table; ctx.fillRect(x + ts * 0.1, y + ts * 0.1, w - ts * 0.2, h - ts * 0.2); // 테이블 그림자 ctx.fillStyle = 'rgba(0,0,0,0.15)'; ctx.fillRect(x + ts * 0.15, y + h - ts * 0.1, w - ts * 0.25, ts * 0.1); // 의자들 (상하 4개씩) for (let i = 0; i < 4; i++) { const cx = x + ts * 0.5 + i * (w - ts) / 3; ctx.fillStyle = theme.furniture.chair; ctx.fillRect(cx, y - ts * 0.3, ts * 0.4, ts * 0.35); ctx.fillRect(cx, y + h - ts * 0.05, ts * 0.4, ts * 0.35); } } _drawSofa(ctx, theme, ts, x, y) { ctx.fillStyle = theme.furniture.sofa; ctx.fillRect(x, y, ts * 2, ts * 0.8); // 등받이 ctx.fillStyle = theme.furniture.sofa; ctx.fillRect(x, y - ts * 0.3, ts * 2, ts * 0.35); // 쿠션 구분선 ctx.strokeStyle = theme.wall.border; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x + ts, y); ctx.lineTo(x + ts, y + ts * 0.8); ctx.stroke(); } _drawCoffeeMachine(ctx, theme, ts, x, y) { ctx.fillStyle = theme.furniture.coffee; ctx.fillRect(x + ts * 0.15, y, ts * 0.7, ts * 0.8); // 디스펜서 ctx.fillStyle = theme.furniture.monitor; ctx.fillRect(x + ts * 0.25, y + ts * 0.15, ts * 0.5, ts * 0.3); // 커피 잔 ctx.fillStyle = '#ffffff'; ctx.fillRect(x + ts * 0.3, y + ts * 0.55, ts * 0.2, ts * 0.15); // 스팀 ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x + ts * 0.4, y + ts * 0.5); ctx.quadraticCurveTo(x + ts * 0.45, y + ts * 0.35, x + ts * 0.4, y + ts * 0.2); ctx.stroke(); } _drawBookshelf(ctx, f, theme, ts, x, y) { const h = (f.height || 3) * ts; ctx.fillStyle = theme.furniture.shelf; ctx.fillRect(x, y, ts * 0.9, h); // 선반 및 책 const bookColors = ['#aa4444', '#4444aa', '#44aa44', '#aaaa44', '#aa44aa', '#44aaaa']; const shelfCount = f.height || 3; for (let i = 0; i < shelfCount; i++) { const sy = y + i * ts + ts * 0.1; // 선반 판 ctx.fillStyle = theme.furniture.shelf; ctx.fillRect(x, sy + ts * 0.7, ts * 0.9, ts * 0.05); // 책들 for (let b = 0; b < 4; b++) { ctx.fillStyle = bookColors[(i * 4 + b) % bookColors.length]; ctx.fillRect(x + ts * 0.05 + b * ts * 0.2, sy + ts * 0.1, ts * 0.15, ts * 0.6); } } } _drawPlant(ctx, theme, ts, x, y) { // 화분 ctx.fillStyle = theme.decor.pot; ctx.fillRect(x + ts * 0.25, y + ts * 0.6, ts * 0.5, ts * 0.35); ctx.fillRect(x + ts * 0.2, y + ts * 0.55, ts * 0.6, ts * 0.1); // 잎 ctx.fillStyle = theme.decor.plant; ctx.beginPath(); ctx.ellipse(x + ts * 0.5, y + ts * 0.35, ts * 0.3, ts * 0.25, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(x + ts * 0.35, y + ts * 0.25, ts * 0.15, ts * 0.2, -0.3, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(x + ts * 0.65, y + ts * 0.25, ts * 0.15, ts * 0.2, 0.3, 0, Math.PI * 2); ctx.fill(); } _drawWaterCooler(ctx, theme, ts, x, y) { // 본체 ctx.fillStyle = theme.furniture.shelf; ctx.fillRect(x + ts * 0.2, y + ts * 0.3, ts * 0.6, ts * 0.6); // 물통 ctx.fillStyle = 'rgba(100,180,255,0.5)'; ctx.fillRect(x + ts * 0.3, y, ts * 0.4, ts * 0.35); ctx.fillStyle = 'rgba(100,180,255,0.3)'; ctx.beginPath(); ctx.arc(x + ts * 0.5, y, ts * 0.2, 0, Math.PI * 2); ctx.fill(); } } ``` - [ ] **Step 2: 커밋** ```bash git add src/pages/agent-office/canvas/FurnitureRenderer.js git commit -m "feat(agent-office): add procedural furniture renderer with theme support" ``` --- ### Task 6: 게임 루프 + 줌/팬 시스템 (OfficeRenderer 재작성) **Files:** - Rewrite: `src/pages/agent-office/canvas/OfficeRenderer.js` - [ ] **Step 1: OfficeRenderer 재작성** ```javascript // src/pages/agent-office/canvas/OfficeRenderer.js import mapData from '../assets/office-map.json'; import { TileMap } from './TileMap.js'; import { FurnitureRenderer } from './FurnitureRenderer.js'; import { Pathfinder } from './Pathfinder.js'; import { AgentSprite } from './AgentSprite.js'; import { OverlayRenderer } from './OverlayRenderer.js'; import { getTheme } from './themes.js'; const AGENT_META = { stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' }, music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' }, blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' }, realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' }, lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' } }; export class OfficeRenderer { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); // 맵 & 렌더러 this.tileMap = new TileMap(mapData); this.furnitureRenderer = new FurnitureRenderer(mapData.furniture, mapData.tileSize); this.pathfinder = new Pathfinder(mapData.cols, mapData.rows); this.overlayRenderer = new OverlayRenderer(); // blocked 타일 설정 this.pathfinder.setWalls(mapData.floor); this.pathfinder.setBlocked(mapData.blocked); // 테마 & 뷰포트 this.theme = getTheme(localStorage.getItem('agent-office-theme') || 'modern'); this.zoom = 2; this.panX = 0; this.panY = 0; this._isPanning = false; this._panStart = { x: 0, y: 0 }; // 에이전트 this.agents = new Map(); this._initAgents(); // 게임 루프 this._lastTime = 0; this._animId = null; // 이벤트 this._setupInputHandlers(); } _initAgents() { for (const [id, meta] of Object.entries(AGENT_META)) { const waypoint = mapData.waypoints[`desk_${id}`]; if (!waypoint) continue; const sprite = new AgentSprite(id, meta, waypoint.col, waypoint.row, this.pathfinder); sprite.deskCol = waypoint.col; sprite.deskRow = waypoint.row; this.agents.set(id, sprite); } } /** 줌/팬/클릭 이벤트 핸들러 */ _setupInputHandlers() { // 마우스 휠 줌 this.canvas.addEventListener('wheel', (e) => { e.preventDefault(); const oldZoom = this.zoom; if (e.deltaY < 0) { this.zoom = Math.min(this.zoom + 0.5, 4); } else { this.zoom = Math.max(this.zoom - 0.5, 1); } // 마우스 위치 기준 줌 if (this.zoom !== oldZoom) { const rect = this.canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const ratio = this.zoom / oldZoom; this.panX = mx - (mx - this.panX) * ratio; this.panY = my - (my - this.panY) * ratio; } }, { passive: false }); // 마우스 드래그 패닝 this.canvas.addEventListener('mousedown', (e) => { if (e.button === 0) { this._isPanning = true; this._panStart = { x: e.clientX - this.panX, y: e.clientY - this.panY }; } }); window.addEventListener('mousemove', (e) => { if (this._isPanning) { this.panX = e.clientX - this._panStart.x; this.panY = e.clientY - this._panStart.y; } }); window.addEventListener('mouseup', () => { this._isPanning = false; }); // 터치 (모바일) let lastTouchDist = 0; let lastTouchCenter = { x: 0, y: 0 }; this.canvas.addEventListener('touchstart', (e) => { if (e.touches.length === 1) { this._isPanning = true; this._panStart = { x: e.touches[0].clientX - this.panX, y: e.touches[0].clientY - this.panY }; } else if (e.touches.length === 2) { this._isPanning = false; const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; lastTouchDist = Math.hypot(dx, dy); lastTouchCenter = { x: (e.touches[0].clientX + e.touches[1].clientX) / 2, y: (e.touches[0].clientY + e.touches[1].clientY) / 2 }; } }, { passive: false }); this.canvas.addEventListener('touchmove', (e) => { e.preventDefault(); if (e.touches.length === 1 && this._isPanning) { this.panX = e.touches[0].clientX - this._panStart.x; this.panY = e.touches[0].clientY - this._panStart.y; } else if (e.touches.length === 2) { const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; const dist = Math.hypot(dx, dy); const oldZoom = this.zoom; this.zoom = Math.min(4, Math.max(1, this.zoom * (dist / lastTouchDist))); lastTouchDist = dist; const rect = this.canvas.getBoundingClientRect(); const cx = lastTouchCenter.x - rect.left; const cy = lastTouchCenter.y - rect.top; const ratio = this.zoom / oldZoom; this.panX = cx - (cx - this.panX) * ratio; this.panY = cy - (cy - this.panY) * ratio; } }, { passive: false }); this.canvas.addEventListener('touchend', () => { this._isPanning = false; }); } /** 클릭 히트 테스트 — AgentOffice에서 호출 */ hitTest(clientX, clientY) { const rect = this.canvas.getBoundingClientRect(); const screenX = clientX - rect.left; const screenY = clientY - rect.top; const { col, row } = this.tileMap.screenToTile(screenX, screenY, this.zoom, this.panX, this.panY); // 에이전트 히트 (역순, 최상위 우선) for (const [id, sprite] of [...this.agents.entries()].reverse()) { const dx = Math.abs(sprite.x - col); const dy = Math.abs(sprite.y - row); if (dx < 1.2 && dy < 1.5) { return { type: 'agent', id }; } } return { type: 'empty' }; } /** 에이전트 상태 업데이트 (WebSocket에서 호출) */ updateAgentState(agentId, state, detail) { const sprite = this.agents.get(agentId); if (!sprite) return; sprite.onStateChange(state, detail, mapData.waypoints); } /** 에이전트 알림 배지 설정 */ setAgentNotification(agentId, count) { const sprite = this.agents.get(agentId); if (sprite) sprite.notificationCount = count; } /** 테마 변경 */ setTheme(themeName) { this.theme = getTheme(themeName); localStorage.setItem('agent-office-theme', themeName); } /** 줌 레벨 설정 */ setZoom(level) { const cx = this.canvas.width / 2; const cy = this.canvas.height / 2; const oldZoom = this.zoom; this.zoom = Math.min(4, Math.max(1, level)); const ratio = this.zoom / oldZoom; this.panX = cx - (cx - this.panX) * ratio; this.panY = cy - (cy - this.panY) * ratio; } /** 카메라를 맵 중앙에 맞추기 */ centerCamera() { const mapW = mapData.cols * mapData.tileSize * this.zoom; const mapH = mapData.rows * mapData.tileSize * this.zoom; this.panX = (this.canvas.width - mapW) / 2; this.panY = (this.canvas.height - mapH) / 2; } /** 게임 루프 시작 */ start() { this.centerCamera(); this._lastTime = performance.now(); this._loop(this._lastTime); } /** 게임 루프 중지 */ stop() { if (this._animId) { cancelAnimationFrame(this._animId); this._animId = null; } } _loop(timestamp) { const dt = Math.min((timestamp - this._lastTime) / 1000, 0.1); // cap dt to avoid spiral this._lastTime = timestamp; this._update(dt); this._render(); this._animId = requestAnimationFrame((t) => this._loop(t)); } _update(dt) { for (const sprite of this.agents.values()) { sprite.update(dt); } } _render() { const ctx = this.ctx; const dpr = window.devicePixelRatio || 1; // 캔버스 크기 조정 const displayW = this.canvas.clientWidth; const displayH = this.canvas.clientHeight; if (this.canvas.width !== displayW * dpr || this.canvas.height !== displayH * dpr) { this.canvas.width = displayW * dpr; this.canvas.height = displayH * dpr; ctx.scale(dpr, dpr); } ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, displayW, displayH); // 배경 ctx.fillStyle = this.theme.wall.color; ctx.fillRect(0, 0, displayW, displayH); // 1. 타일맵 (바닥 + 벽) this.tileMap.render(ctx, this.theme, this.zoom, this.panX, this.panY); // 2. Y-sorted: 가구 + 에이전트 const renderables = []; // 가구 const furnitureItems = this.furnitureRenderer.getRenderables(this.theme, this.zoom, this.panX, this.panY); renderables.push(...furnitureItems); // 에이전트 for (const sprite of this.agents.values()) { renderables.push({ zY: sprite.y, draw: (ctx2) => sprite.draw(ctx2, this.zoom, this.panX, this.panY, mapData.tileSize) }); } // Y좌표 정렬 renderables.sort((a, b) => a.zY - b.zY); for (const item of renderables) { item.draw(ctx); } // 3. 오버레이 (항상 최상위) for (const sprite of this.agents.values()) { this.overlayRenderer.draw(ctx, sprite, this.theme, this.zoom, this.panX, this.panY, mapData.tileSize); } } /** 리사이즈 처리 */ resize() { // 다음 프레임에서 자동 조정됨 (_render에서 크기 체크) } destroy() { this.stop(); // 이벤트 리스너는 canvas와 함께 GC됨 } } ``` - [ ] **Step 2: 커밋** ```bash git add src/pages/agent-office/canvas/OfficeRenderer.js git commit -m "refactor(agent-office): rewrite OfficeRenderer with game loop, zoom/pan, Y-sorting" ``` --- ## Phase 2: 에이전트 캐릭터 시스템 ### Task 7: 프로시저럴 스프라이트 고도화 (16x32px) **Files:** - Rewrite: `src/pages/agent-office/canvas/SpriteSheet.js` → renamed to `ProceduralSprite.js` - Create: `src/pages/agent-office/canvas/ProceduralSprite.js` - [ ] **Step 1: ProceduralSprite 작성 (16x32 해상도, 4방향, 5상태)** ```javascript // src/pages/agent-office/canvas/ProceduralSprite.js /** * 프로시저럴 픽셀 캐릭터 렌더러 (16×32px 기본 해상도) * Phase 1: 코드로 캐릭터를 그림 * Phase 2: SpriteLoader가 PNG 스프라이트로 대체 */ const AGENT_COLORS = { stock: { body: '#4488cc', hair: '#2255aa', accent: '#66aaee' }, music: { body: '#44aa88', hair: '#228866', accent: '#66ccaa' }, blog: { body: '#d97706', hair: '#b45e04', accent: '#f59e0b' }, realestate: { body: '#c026d3', hair: '#9b1dab', accent: '#e044f0' }, lotto: { body: '#ef4444', hair: '#cc2222', accent: '#ff6666' } }; /** 애니메이션 프레임 설정 */ const ANIM_CONFIG = { idle: { frames: 2, speed: 0.8 }, walk: { frames: 4, speed: 0.15, cycle: [0, 1, 2, 1] }, type: { frames: 2, speed: 0.3 }, wait: { frames: 2, speed: 0.5 }, break_anim:{ frames: 2, speed: 1.0 } }; export class ProceduralSprite { /** * 캐릭터 1프레임 렌더링 * @param {CanvasRenderingContext2D} ctx * @param {string} agentId * @param {string} state - idle|walk|type|wait|break_anim * @param {string} direction - down|up|right|left * @param {number} frame - 현재 애니메이션 프레임 인덱스 * @param {number} x - 캔버스 X 좌표 (캐릭터 중앙 하단) * @param {number} y - 캔버스 Y 좌표 (캐릭터 중앙 하단) * @param {number} scale - 렌더링 스케일 */ static draw(ctx, agentId, state, direction, frame, x, y, scale) { const colors = AGENT_COLORS[agentId] || AGENT_COLORS.stock; const px = scale; // 1 pixel = scale 크기 const w = 16 * px; const h = 32 * px; const bx = x - w / 2; // 좌상단 기준 const by = y - h; ctx.save(); // 좌우 반전 (left = right 플립) if (direction === 'left') { ctx.translate(x, 0); ctx.scale(-1, 1); ctx.translate(-x, 0); } // 그림자 ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.ellipse(x, y, w * 0.35, px * 2, 0, 0, Math.PI * 2); ctx.fill(); // 상태별 오프셋 let bodyOffsetY = 0; let legSpread = 0; let armAngle = 0; if (state === 'walk') { const walkFrame = ANIM_CONFIG.walk.cycle[frame % 4]; legSpread = (walkFrame - 1) * px * 2; bodyOffsetY = walkFrame === 1 ? -px : 0; } else if (state === 'type') { armAngle = frame % 2 === 0 ? 1 : -1; bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5; } else if (state === 'wait') { bodyOffsetY = Math.sin(frame * Math.PI) * px; } else if (state === 'idle') { bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5; } else if (state === 'break_anim') { bodyOffsetY = frame % 2 === 0 ? 0 : px * 0.5; // 졸기 } const by2 = by + bodyOffsetY; // 다리 ctx.fillStyle = '#2a2a3e'; // 왼쪽 다리 ctx.fillRect(bx + px * 4 - legSpread, by2 + px * 24, px * 3, px * 8); // 오른쪽 다리 ctx.fillRect(bx + px * 9 + legSpread, by2 + px * 24, px * 3, px * 8); // 신발 ctx.fillStyle = '#333'; ctx.fillRect(bx + px * 3 - legSpread, by2 + px * 30, px * 5, px * 2); ctx.fillRect(bx + px * 8 + legSpread, by2 + px * 30, px * 5, px * 2); // 몸통 ctx.fillStyle = colors.body; ctx.fillRect(bx + px * 3, by2 + px * 12, px * 10, px * 13); // 팔 if (state === 'type') { // 타이핑: 팔 앞으로 뻗음 ctx.fillStyle = colors.body; ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 8 + armAngle * px); ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 8 - armAngle * px); // 손 ctx.fillStyle = '#ffcc99'; ctx.fillRect(bx + px * 1, by2 + px * 20 + armAngle * px, px * 3, px * 3); ctx.fillRect(bx + px * 12, by2 + px * 20 - armAngle * px, px * 3, px * 3); } else { // 기본 팔 ctx.fillStyle = colors.body; ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 10); ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 10); // 손 ctx.fillStyle = '#ffcc99'; ctx.fillRect(bx + px * 1, by2 + px * 22, px * 3, px * 3); ctx.fillRect(bx + px * 12, by2 + px * 22, px * 3, px * 3); } // 머리 ctx.fillStyle = '#ffcc99'; // 피부색 ctx.fillRect(bx + px * 4, by2 + px * 2, px * 8, px * 10); // 머리카락 ctx.fillStyle = colors.hair; ctx.fillRect(bx + px * 3, by2 + px * 1, px * 10, px * 4); if (direction === 'down' || direction === 'left' || direction === 'right') { // 앞머리 ctx.fillRect(bx + px * 4, by2 + px * 3, px * 2, px * 2); } // 눈 if (direction !== 'up') { ctx.fillStyle = '#222'; if (state === 'break_anim' && frame % 2 === 1) { // 졸기: 눈 감음 ctx.fillRect(bx + px * 5, by2 + px * 6, px * 2, px); ctx.fillRect(bx + px * 9, by2 + px * 6, px * 2, px); } else { ctx.fillRect(bx + px * 5, by2 + px * 5, px * 2, px * 2); ctx.fillRect(bx + px * 9, by2 + px * 5, px * 2, px * 2); } } // break 소품: 커피잔 if (state === 'break_anim') { ctx.fillStyle = '#ffffff'; ctx.fillRect(bx + px * 14, by2 + px * 16, px * 3, px * 4); ctx.fillStyle = '#8B4513'; ctx.fillRect(bx + px * 14.5, by2 + px * 16.5, px * 2, px * 2); } ctx.restore(); } static getAnimConfig(state) { const mapped = state === 'working' ? 'type' : state === 'waiting' ? 'wait' : state === 'reporting' ? 'type' : state === 'break' ? 'break_anim' : state === 'walk' ? 'walk' : 'idle'; return { ...(ANIM_CONFIG[mapped] || ANIM_CONFIG.idle), mapped }; } } ``` - [ ] **Step 2: 커밋** ```bash git add src/pages/agent-office/canvas/ProceduralSprite.js git commit -m "feat(agent-office): add 16x32 procedural sprite with 5 states and 4 directions" ``` --- ### Task 8: AgentSprite 재작성 (BFS 이동 + 배회) **Files:** - Rewrite: `src/pages/agent-office/canvas/AgentSprite.js` - [ ] **Step 1: AgentSprite 재작성** ```javascript // src/pages/agent-office/canvas/AgentSprite.js import { ProceduralSprite } from './ProceduralSprite.js'; const WALK_SPEED = 3; // tiles per second const WANDER_DELAY_MIN = 3; const WANDER_DELAY_MAX = 8; const WANDER_LIMIT_MIN = 3; const WANDER_LIMIT_MAX = 6; const REST_DELAY_MIN = 2; const REST_DELAY_MAX = 20; export class AgentSprite { constructor(id, meta, col, row, pathfinder) { this.id = id; this.meta = meta; this.pathfinder = pathfinder; // 위치 (타일 좌표, 실수) this.x = col; this.y = row; this.deskCol = col; this.deskRow = row; // 상태 this.state = 'idle'; // FSM 상태 (from backend) this.detail = ''; this.notificationCount = 0; // 애니메이션 this.animState = 'idle'; // 렌더링용 상태 this.direction = 'down'; this.animFrame = 0; this.animTimer = 0; // 이동 this.path = []; // BFS 경로 [{col, row}, ...] this.moveProgress = 0; // 0~1 현재 타일 → 다음 타일 this.moveFrom = { col, row }; this.moveTo_target = null; // 배회 this._wandering = false; this._wanderTimer = 0; this._wanderCount = 0; this._wanderLimit = 0; this._restTimer = 0; this._isResting = false; this._isAtDesk = true; } /** 매 프레임 호출 */ update(dt) { // 이동 처리 if (this.path.length > 0) { this._updateMovement(dt); } else if (this._wandering) { this._updateWander(dt); } // 애니메이션 프레임 업데이트 this._updateAnimation(dt); } _updateMovement(dt) { this.animState = 'walk'; this.moveProgress += WALK_SPEED * dt; if (this.moveProgress >= 1) { // 현재 구간 완료 const arrived = this.path.shift(); this.x = arrived.col; this.y = arrived.row; this.moveFrom = { col: arrived.col, row: arrived.row }; this.moveProgress = 0; if (this.path.length === 0) { // 최종 목적지 도착 this._onArrival(); } else { // 다음 구간의 방향 설정 this._updateDirection(this.path[0]); } } else { // 보간 const next = this.path[0]; this.x = this.moveFrom.col + (next.col - this.moveFrom.col) * this.moveProgress; this.y = this.moveFrom.row + (next.row - this.moveFrom.row) * this.moveProgress; } } _onArrival() { const atDesk = Math.abs(this.x - this.deskCol) < 0.5 && Math.abs(this.y - this.deskRow) < 0.5; this._isAtDesk = atDesk; if (this.state === 'working' || this.state === 'reporting') { this.animState = 'type'; this.direction = 'up'; // 모니터를 바라봄 } else if (this.state === 'waiting') { this.animState = 'wait'; } else if (this.state === 'break') { this.animState = 'break_anim'; } else { // idle 도착 — 배회 계속 또는 자리에서 쉬기 if (this._wandering && this._wanderCount < this._wanderLimit) { // 다음 배회 타이머 설정 this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN); } else if (this._wandering) { // 배회 끝, 휴식 this._wandering = false; this._isResting = true; this._restTimer = REST_DELAY_MIN + Math.random() * (REST_DELAY_MAX - REST_DELAY_MIN); } this.animState = 'idle'; } } _updateWander(dt) { if (this._isResting) { this._restTimer -= dt; if (this._restTimer <= 0) { this._isResting = false; this._startWandering(); } return; } this._wanderTimer -= dt; if (this._wanderTimer <= 0) { // 랜덤 인접 타일로 이동 const target = this.pathfinder.getRandomNearbyFloor( Math.round(this.x), Math.round(this.y), 4 ); if (target) { const path = this.pathfinder.findPath( Math.round(this.x), Math.round(this.y), target.col, target.row ); if (path.length > 0 && path.length <= 6) { this.path = path; this.moveFrom = { col: Math.round(this.x), row: Math.round(this.y) }; this.moveProgress = 0; this._updateDirection(path[0]); this._wanderCount++; } } // 실패해도 타이머 리셋 this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN); } } _updateDirection(nextTile) { const dx = nextTile.col - Math.round(this.x); const dy = nextTile.row - Math.round(this.y); if (Math.abs(dx) > Math.abs(dy)) { this.direction = dx > 0 ? 'right' : 'left'; } else { this.direction = dy > 0 ? 'down' : 'up'; } } _updateAnimation(dt) { const config = ProceduralSprite.getAnimConfig( this.animState === 'walk' ? 'walk' : this.state ); this.animTimer += dt; if (this.animTimer >= config.speed) { this.animTimer = 0; this.animFrame = (this.animFrame + 1) % config.frames; } } /** 백엔드 상태 변경 시 호출 */ onStateChange(newState, detail, waypoints) { const prevState = this.state; this.state = newState; this.detail = detail || ''; // 배회 중단 this._wandering = false; this._isResting = false; switch (newState) { case 'working': case 'reporting': case 'waiting': // 자리에 없으면 자리로 이동 if (!this._isAtDesk) { this._moveToDesk(); } else { this.animState = newState === 'waiting' ? 'wait' : 'type'; this.direction = 'up'; } break; case 'break': { // 휴게실로 이동 const breakWp = waypoints.break_room || waypoints.coffee; if (breakWp) { this._navigateTo(breakWp.col, breakWp.row); } break; } case 'idle': if (prevState === 'break') { // 휴게실에서 자리로 복귀 this._moveToDesk(); } // 복귀 후 배회 시작 (도착 콜백에서 처리) this._startWanderingAfterDelay(3); break; } } _moveToDesk() { this._navigateTo(this.deskCol, this.deskRow); } _navigateTo(goalCol, goalRow) { const startCol = Math.round(this.x); const startRow = Math.round(this.y); const path = this.pathfinder.findPath(startCol, startRow, goalCol, goalRow); if (path.length > 0) { this.path = path; this.moveFrom = { col: startCol, row: startRow }; this.moveProgress = 0; this._updateDirection(path[0]); } } _startWanderingAfterDelay(delay) { this._wandering = true; this._wanderCount = 0; this._wanderLimit = WANDER_LIMIT_MIN + Math.floor(Math.random() * (WANDER_LIMIT_MAX - WANDER_LIMIT_MIN)); this._wanderTimer = delay; this._isResting = false; } _startWandering() { this._startWanderingAfterDelay(WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN)); } isAtDesk() { return this._isAtDesk; } /** 렌더링 */ draw(ctx, zoom, panX, panY, tileSize) { const ts = tileSize * zoom; const screenX = this.x * ts + panX + ts / 2; const screenY = this.y * ts + panY + ts; const spriteScale = zoom * 1.5; // 캐릭터 약간 크게 ProceduralSprite.draw( ctx, this.id, this.animState === 'walk' ? 'walk' : this.state, this.direction, this.animFrame, screenX, screenY, spriteScale ); } } ``` - [ ] **Step 2: 커밋** ```bash git add src/pages/agent-office/canvas/AgentSprite.js git commit -m "refactor(agent-office): rewrite AgentSprite with BFS movement and idle wandering" ``` --- ### Task 9: SpriteLoader (Phase 2 준비, 폴백 지원) **Files:** - Create: `src/pages/agent-office/canvas/SpriteLoader.js` - [ ] **Step 1: SpriteLoader 작성** ```javascript // src/pages/agent-office/canvas/SpriteLoader.js import { ProceduralSprite } from './ProceduralSprite.js'; /** * 스프라이트 로더 — PNG 스프라이트시트가 있으면 사용, 없으면 프로시저럴 폴백 * * 스프라이트시트 규격 (Phase 2): * - 프레임 크기: 16×32px * - 행: 방향 (0=down, 1=up, 2=right) * - 열: 상태별 프레임 (idle 2 | walk 4 | type 2 | wait 2 | break 2 = 12열) */ export class SpriteLoader { constructor() { this.sprites = new Map(); // agentId → { image: Image, loaded: boolean } } /** PNG 스프라이트시트 로드 시도 */ async tryLoad(agentId, url) { return new Promise((resolve) => { const img = new Image(); img.onload = () => { this.sprites.set(agentId, { image: img, loaded: true }); resolve(true); }; img.onerror = () => { resolve(false); // 폴백 사용 }; img.src = url; }); } hasSprite(agentId) { return this.sprites.has(agentId) && this.sprites.get(agentId).loaded; } /** * 에이전트 1프레임 그리기 (스프라이트 또는 프로시저럴) */ draw(ctx, agentId, state, direction, frame, x, y, scale) { if (this.hasSprite(agentId)) { this._drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale); } else { ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y, scale); } } _drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale) { const { image } = this.sprites.get(agentId); const frameW = 16; const frameH = 32; // 방향 → 행 const dirRow = direction === 'up' ? 1 : direction === 'right' || direction === 'left' ? 2 : 0; // 상태 → 열 오프셋 const stateOffsets = { idle: 0, walk: 2, type: 6, wait: 8, break_anim: 10 }; const mappedState = state === 'working' ? 'type' : state === 'waiting' ? 'wait' : state === 'reporting' ? 'type' : state === 'break' ? 'break_anim' : state === 'walk' ? 'walk' : 'idle'; const colOffset = stateOffsets[mappedState] || 0; const srcX = (colOffset + frame) * frameW; const srcY = dirRow * frameH; const destW = frameW * scale; const destH = frameH * scale; ctx.save(); if (direction === 'left') { ctx.translate(x, 0); ctx.scale(-1, 1); ctx.translate(-x, 0); } ctx.drawImage(image, srcX, srcY, frameW, frameH, x - destW / 2, y - destH, destW, destH); ctx.restore(); } } ``` - [ ] **Step 2: 커밋** ```bash git add src/pages/agent-office/canvas/SpriteLoader.js git commit -m "feat(agent-office): add SpriteLoader with procedural fallback for Phase 2" ``` --- ## Phase 3: 오버레이 시스템 ### Task 10: 오버레이 렌더러 (이름, 배지, 말풍선) **Files:** - Create: `src/pages/agent-office/canvas/OverlayRenderer.js` - [ ] **Step 1: OverlayRenderer 작성** ```javascript // src/pages/agent-office/canvas/OverlayRenderer.js /** * 캔버스 위 오버레이 렌더링: * - 이름 라벨 (항상) * - 상태 배지 (항상) * - 말풍선 (waiting 상태에서만) * - 알림 배지 (notification > 0 일 때) */ const STATE_BADGE = { idle: { text: 'idle', bg: '#374151', fg: '#9ca3af' }, working: { text: 'working', bg: '#1e3a5f', fg: '#60a5fa' }, waiting: { text: 'waiting', bg: '#92400e', fg: '#fbbf24' }, reporting: { text: 'reporting', bg: '#1e3a5f', fg: '#60a5fa' }, break: { text: 'break', bg: '#065f46', fg: '#34d399' } }; export class OverlayRenderer { constructor() { this._bubbleAlpha = new Map(); // agentId → alpha (fade in/out) } draw(ctx, sprite, theme, zoom, panX, panY, tileSize) { const ts = tileSize * zoom; const centerX = sprite.x * ts + panX + ts / 2; const topY = sprite.y * ts + panY - ts * 0.3; const fontSize = Math.max(10, 11 * zoom / 2); const smallFontSize = Math.max(8, 9 * zoom / 2); // 1. 이름 라벨 ctx.font = `bold ${fontSize}px 'Courier New', monospace`; ctx.textAlign = 'center'; ctx.fillStyle = sprite.meta.color; ctx.fillText(sprite.meta.displayName, centerX, topY + ts * 1.85); // 2. 상태 배지 const badge = STATE_BADGE[sprite.state] || STATE_BADGE.idle; const badgeText = badge.text; ctx.font = `${smallFontSize}px 'Courier New', monospace`; const badgeW = ctx.measureText(badgeText).width + 8; const badgeH = smallFontSize + 4; const badgeX = centerX - badgeW / 2; const badgeY = topY + ts * 1.9; ctx.fillStyle = badge.bg; this._roundRect(ctx, badgeX, badgeY, badgeW, badgeH, 3); ctx.fill(); ctx.fillStyle = badge.fg; ctx.textAlign = 'center'; ctx.fillText(badgeText, centerX, badgeY + badgeH - 3); // 3. 말풍선 (waiting 상태에서만) if (sprite.state === 'waiting') { this._drawBubble(ctx, sprite, centerX, topY - ts * 0.2, zoom); } // 4. 알림 배지 if (sprite.notificationCount > 0) { this._drawNotificationBadge(ctx, centerX + ts * 0.5, topY + ts * 0.2, sprite.notificationCount, zoom); } } _drawBubble(ctx, sprite, x, y, zoom) { const text = '승인 대기!'; const fontSize = Math.max(10, 11 * zoom / 2); ctx.font = `bold ${fontSize}px 'Courier New', monospace`; const tw = ctx.measureText(text).width; const pw = tw + 16; const ph = fontSize + 12; const px = x - pw / 2; const py = y - ph; // 말풍선 배경 ctx.fillStyle = '#fbbf24'; this._roundRect(ctx, px, py, pw, ph, 6); ctx.fill(); // 꼬리 삼각형 ctx.beginPath(); ctx.moveTo(x - 5, py + ph); ctx.lineTo(x + 5, py + ph); ctx.lineTo(x, py + ph + 6); ctx.closePath(); ctx.fill(); // 텍스트 ctx.fillStyle = '#000000'; ctx.textAlign = 'center'; ctx.fillText(text, x, py + ph - 5); } _drawNotificationBadge(ctx, x, y, count, zoom) { const r = Math.max(7, 8 * zoom / 2); ctx.fillStyle = '#ef4444'; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.font = `bold ${r}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(count > 9 ? '9+' : String(count), x, y); ctx.textBaseline = 'alphabetic'; } _roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } } ``` - [ ] **Step 2: 커밋** ```bash git add src/pages/agent-office/canvas/OverlayRenderer.js git commit -m "feat(agent-office): add overlay renderer with labels, badges, and speech bubbles" ``` --- ## Phase 4: 사이드 패널 (4탭) ### Task 11: TopBar 컴포넌트 **Files:** - Create: `src/pages/agent-office/components/TopBar.jsx` - [ ] **Step 1: TopBar 작성** ```jsx // src/pages/agent-office/components/TopBar.jsx import { getThemeNames } from '../canvas/themes.js'; export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomChange }) { const themes = getThemeNames(); return (
{JSON.stringify(JSON.parse(task.result_data), null, 2)}
)}