캔버스 엔진, 에이전트 시스템, 오버레이, 사이드 패널, 페이지 통합, 최종 검증. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3164 lines
92 KiB
Markdown
3164 lines
92 KiB
Markdown
# 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 (
|
||
<div className="ao-topbar">
|
||
<div className="ao-topbar-left">
|
||
<span className="ao-topbar-title">Agent Office</span>
|
||
<span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}>
|
||
● {connected ? 'Connected' : 'Disconnected'}
|
||
</span>
|
||
</div>
|
||
<div className="ao-topbar-right">
|
||
<select
|
||
className="ao-topbar-select"
|
||
value={theme}
|
||
onChange={(e) => onThemeChange(e.target.value)}
|
||
>
|
||
{themes.map(t => (
|
||
<option key={t.key} value={t.key}>{t.name}</option>
|
||
))}
|
||
</select>
|
||
<div className="ao-topbar-zoom">
|
||
<button onClick={() => onZoomChange(Math.max(1, zoom - 0.5))} disabled={zoom <= 1}>-</button>
|
||
<span>{zoom}x</span>
|
||
<button onClick={() => onZoomChange(Math.min(4, zoom + 0.5))} disabled={zoom >= 4}>+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add src/pages/agent-office/components/TopBar.jsx
|
||
git commit -m "feat(agent-office): add TopBar component with theme and zoom controls"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: CommandTab 컴포넌트
|
||
|
||
**Files:**
|
||
- Create: `src/pages/agent-office/components/CommandTab.jsx`
|
||
|
||
- [ ] **Step 1: CommandTab 작성 (기존 AgentColumn 명령 기능 추출)**
|
||
|
||
```jsx
|
||
// src/pages/agent-office/components/CommandTab.jsx
|
||
import { useState } from 'react';
|
||
import { sendAgentCommand, approveAgentTask } from '../../../api';
|
||
|
||
const QUICK_ACTIONS = {
|
||
stock: [{ action: 'fetch_news', label: 'Fetch News' }, { action: 'test_telegram', label: 'Test Telegram' }],
|
||
music: [{ action: 'credits', label: 'Check Credits' }],
|
||
blog: [{ action: 'list_trend_keywords', label: 'List Keywords' }],
|
||
realestate: [{ action: 'dashboard', label: 'Dashboard' }],
|
||
lotto: [{ action: 'status', label: 'Status' }, { action: 'curate_now', label: 'Curate Now' }]
|
||
};
|
||
|
||
const PARAM_ACTIONS = {
|
||
stock: { action: 'add_alert', label: 'Add Alert', placeholder: '{"symbol":"005930","target_price":70000,"direction":"above"}' },
|
||
music: { action: 'compose', label: 'Compose', placeholder: 'jazzy lo-fi piano beat' },
|
||
blog: { action: 'research', label: 'Research', placeholder: 'keyword to research' },
|
||
realestate: { action: 'fetch_matches', label: 'Fetch Matches', placeholder: '' },
|
||
lotto: null
|
||
};
|
||
|
||
export default function CommandTab({ agentId, agentState, pendingTask, onCommandResult }) {
|
||
const [customAction, setCustomAction] = useState('');
|
||
const [customParams, setCustomParams] = useState('');
|
||
const [paramInput, setParamInput] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const quickActions = QUICK_ACTIONS[agentId] || [];
|
||
const paramAction = PARAM_ACTIONS[agentId];
|
||
|
||
const handleQuickAction = async (action) => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await sendAgentCommand(agentId, action, {});
|
||
onCommandResult?.(result);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleParamAction = async () => {
|
||
if (!paramAction || !paramInput.trim()) return;
|
||
setLoading(true);
|
||
try {
|
||
let params = {};
|
||
if (paramAction.action === 'compose') {
|
||
params = { prompt: paramInput };
|
||
} else if (paramAction.action === 'research') {
|
||
params = { keyword: paramInput };
|
||
} else {
|
||
try { params = JSON.parse(paramInput); } catch { params = { value: paramInput }; }
|
||
}
|
||
const result = await sendAgentCommand(agentId, paramAction.action, params);
|
||
onCommandResult?.(result);
|
||
setParamInput('');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCustomCommand = async () => {
|
||
if (!customAction.trim()) return;
|
||
setLoading(true);
|
||
try {
|
||
let params = {};
|
||
if (customParams.trim()) {
|
||
try { params = JSON.parse(customParams); } catch { params = { value: customParams }; }
|
||
}
|
||
const result = await sendAgentCommand(agentId, customAction, params);
|
||
onCommandResult?.(result);
|
||
setCustomAction('');
|
||
setCustomParams('');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleApproval = async (approved) => {
|
||
if (!pendingTask) return;
|
||
setLoading(true);
|
||
try {
|
||
await approveAgentTask(agentId, pendingTask.id, approved);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="ao-command-tab">
|
||
{/* 승인 대기 UI */}
|
||
{agentState === 'waiting' && pendingTask && (
|
||
<div className="ao-approval-card">
|
||
<div className="ao-approval-title">Awaiting Approval</div>
|
||
<div className="ao-approval-desc">{pendingTask.task_type}: {pendingTask.detail || JSON.stringify(pendingTask.input_data)}</div>
|
||
<div className="ao-approval-actions">
|
||
<button className="ao-btn-approve" onClick={() => handleApproval(true)} disabled={loading}>Approve</button>
|
||
<button className="ao-btn-reject" onClick={() => handleApproval(false)} disabled={loading}>Reject</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick Actions */}
|
||
<div className="ao-section">
|
||
<div className="ao-section-label">Quick Actions</div>
|
||
<div className="ao-quick-actions">
|
||
{quickActions.map(qa => (
|
||
<button
|
||
key={qa.action}
|
||
className="ao-btn-quick"
|
||
onClick={() => handleQuickAction(qa.action)}
|
||
disabled={loading}
|
||
>
|
||
{qa.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Parameterized Action */}
|
||
{paramAction && (
|
||
<div className="ao-section">
|
||
<div className="ao-section-label">{paramAction.label}</div>
|
||
<div className="ao-param-row">
|
||
<input
|
||
className="ao-input"
|
||
value={paramInput}
|
||
onChange={e => setParamInput(e.target.value)}
|
||
placeholder={paramAction.placeholder}
|
||
onKeyDown={e => e.key === 'Enter' && handleParamAction()}
|
||
/>
|
||
<button className="ao-btn-send" onClick={handleParamAction} disabled={loading || !paramInput.trim()}>
|
||
Send
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Custom Command */}
|
||
<div className="ao-section">
|
||
<div className="ao-section-label">Custom Command</div>
|
||
<input
|
||
className="ao-input"
|
||
value={customAction}
|
||
onChange={e => setCustomAction(e.target.value)}
|
||
placeholder="Action name"
|
||
/>
|
||
<input
|
||
className="ao-input"
|
||
value={customParams}
|
||
onChange={e => setCustomParams(e.target.value)}
|
||
placeholder='Parameters (JSON)'
|
||
style={{ marginTop: 4 }}
|
||
/>
|
||
<button
|
||
className="ao-btn-send"
|
||
onClick={handleCustomCommand}
|
||
disabled={loading || !customAction.trim()}
|
||
style={{ marginTop: 4, width: '100%' }}
|
||
>
|
||
Send Command
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add src/pages/agent-office/components/CommandTab.jsx
|
||
git commit -m "feat(agent-office): add CommandTab with quick actions, params, and approval UI"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: TaskTab 컴포넌트
|
||
|
||
**Files:**
|
||
- Create: `src/pages/agent-office/components/TaskTab.jsx`
|
||
|
||
- [ ] **Step 1: TaskTab 작성**
|
||
|
||
```jsx
|
||
// src/pages/agent-office/components/TaskTab.jsx
|
||
import { useState, useEffect } from 'react';
|
||
import { getAgentTasks } from '../../../api';
|
||
|
||
const STATUS_STYLE = {
|
||
succeeded: { bg: '#065f46', fg: '#34d399' },
|
||
failed: { bg: '#7f1d1d', fg: '#fca5a5' },
|
||
working: { bg: '#1e3a5f', fg: '#60a5fa' },
|
||
pending: { bg: '#92400e', fg: '#fbbf24' },
|
||
approved: { bg: '#065f46', fg: '#34d399' },
|
||
rejected: { bg: '#7f1d1d', fg: '#fca5a5' }
|
||
};
|
||
|
||
function formatTime(ts) {
|
||
if (!ts) return '';
|
||
const d = new Date(ts);
|
||
const now = new Date();
|
||
const isToday = d.toDateString() === now.toDateString();
|
||
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
|
||
}
|
||
|
||
export default function TaskTab({ agentId, refreshTrigger }) {
|
||
const [tasks, setTasks] = useState([]);
|
||
const [expanded, setExpanded] = useState(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
getAgentTasks(agentId, 20).then(data => {
|
||
if (!cancelled) setTasks(data || []);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [agentId, refreshTrigger]);
|
||
|
||
return (
|
||
<div className="ao-task-tab">
|
||
{tasks.length === 0 && <div className="ao-empty">No tasks yet</div>}
|
||
{tasks.map(task => {
|
||
const style = STATUS_STYLE[task.status] || STATUS_STYLE.pending;
|
||
return (
|
||
<div key={task.id} className="ao-task-item" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>
|
||
<div className="ao-task-header">
|
||
<span className="ao-task-type">{task.task_type}</span>
|
||
<span className="ao-task-badge" style={{ background: style.bg, color: style.fg }}>{task.status}</span>
|
||
<span className="ao-task-time">{formatTime(task.created_at)}</span>
|
||
</div>
|
||
{expanded === task.id && task.result_data && (
|
||
<pre className="ao-task-result">{JSON.stringify(JSON.parse(task.result_data), null, 2)}</pre>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add src/pages/agent-office/components/TaskTab.jsx
|
||
git commit -m "feat(agent-office): add TaskTab component with expandable task history"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: TokenTab 컴포넌트
|
||
|
||
**Files:**
|
||
- Create: `src/pages/agent-office/components/TokenTab.jsx`
|
||
|
||
- [ ] **Step 1: TokenTab 작성**
|
||
|
||
```jsx
|
||
// src/pages/agent-office/components/TokenTab.jsx
|
||
import { useState, useEffect } from 'react';
|
||
import { getAgentTokenUsage } from '../../../api';
|
||
|
||
export default function TokenTab({ agentId }) {
|
||
const [usage, setUsage] = useState(null);
|
||
const [days, setDays] = useState(1);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
getAgentTokenUsage(agentId, days).then(data => {
|
||
if (!cancelled) setUsage(data);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [agentId, days]);
|
||
|
||
if (!usage) return <div className="ao-empty">Loading...</div>;
|
||
|
||
const inputTokens = usage.input_tokens || 0;
|
||
const outputTokens = usage.output_tokens || 0;
|
||
const cacheRead = usage.cache_read || 0;
|
||
const cacheWrite = usage.cache_write || 0;
|
||
const total = inputTokens + outputTokens;
|
||
const cacheHitRate = inputTokens > 0 ? Math.round((cacheRead / inputTokens) * 100) : 0;
|
||
|
||
return (
|
||
<div className="ao-token-tab">
|
||
<div className="ao-token-period">
|
||
{[1, 7, 30].map(d => (
|
||
<button
|
||
key={d}
|
||
className={`ao-btn-period ${days === d ? 'active' : ''}`}
|
||
onClick={() => setDays(d)}
|
||
>
|
||
{d === 1 ? 'Today' : d === 7 ? '7 Days' : '30 Days'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="ao-token-grid">
|
||
<div className="ao-token-card">
|
||
<div className="ao-token-label">Input Tokens</div>
|
||
<div className="ao-token-value">{inputTokens.toLocaleString()}</div>
|
||
</div>
|
||
<div className="ao-token-card">
|
||
<div className="ao-token-label">Output Tokens</div>
|
||
<div className="ao-token-value">{outputTokens.toLocaleString()}</div>
|
||
</div>
|
||
<div className="ao-token-card">
|
||
<div className="ao-token-label">Total</div>
|
||
<div className="ao-token-value">{total.toLocaleString()}</div>
|
||
</div>
|
||
<div className="ao-token-card">
|
||
<div className="ao-token-label">Cache Hit Rate</div>
|
||
<div className="ao-token-value">{cacheHitRate}%</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Simple bar chart */}
|
||
<div className="ao-token-bar">
|
||
<div className="ao-token-bar-label">Input vs Output</div>
|
||
<div className="ao-token-bar-track">
|
||
<div
|
||
className="ao-token-bar-fill input"
|
||
style={{ width: total > 0 ? `${(inputTokens / total) * 100}%` : '0%' }}
|
||
/>
|
||
<div
|
||
className="ao-token-bar-fill output"
|
||
style={{ width: total > 0 ? `${(outputTokens / total) * 100}%` : '0%' }}
|
||
/>
|
||
</div>
|
||
<div className="ao-token-bar-legend">
|
||
<span><span className="dot input" />Input</span>
|
||
<span><span className="dot output" />Output</span>
|
||
</div>
|
||
</div>
|
||
|
||
{cacheRead > 0 && (
|
||
<div className="ao-token-detail">
|
||
<span>Cache Read: {cacheRead.toLocaleString()}</span>
|
||
<span>Cache Write: {cacheWrite.toLocaleString()}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add src/pages/agent-office/components/TokenTab.jsx
|
||
git commit -m "feat(agent-office): add TokenTab with usage stats and cache hit rate"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: LogTab 컴포넌트
|
||
|
||
**Files:**
|
||
- Create: `src/pages/agent-office/components/LogTab.jsx`
|
||
|
||
- [ ] **Step 1: LogTab 작성**
|
||
|
||
```jsx
|
||
// src/pages/agent-office/components/LogTab.jsx
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import { getAgentLogs } from '../../../api';
|
||
|
||
const LEVEL_STYLE = {
|
||
info: { color: '#60a5fa' },
|
||
warning: { color: '#fbbf24' },
|
||
error: { color: '#ef4444' }
|
||
};
|
||
|
||
export default function LogTab({ agentId, refreshTrigger }) {
|
||
const [logs, setLogs] = useState([]);
|
||
const scrollRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
getAgentLogs(agentId, 50).then(data => {
|
||
if (!cancelled) setLogs(data || []);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [agentId, refreshTrigger]);
|
||
|
||
useEffect(() => {
|
||
if (scrollRef.current) {
|
||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||
}
|
||
}, [logs]);
|
||
|
||
return (
|
||
<div className="ao-log-tab" ref={scrollRef}>
|
||
{logs.length === 0 && <div className="ao-empty">No logs yet</div>}
|
||
{logs.map((log, i) => {
|
||
const style = LEVEL_STYLE[log.level] || LEVEL_STYLE.info;
|
||
const time = new Date(log.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
return (
|
||
<div key={log.id || i} className="ao-log-item">
|
||
<span className="ao-log-time">{time}</span>
|
||
<span className="ao-log-level" style={style}>[{log.level}]</span>
|
||
<span className="ao-log-msg">{log.message}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add src/pages/agent-office/components/LogTab.jsx
|
||
git commit -m "feat(agent-office): add LogTab with auto-scroll and level coloring"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: SidePanel 컨테이너 (4탭 통합)
|
||
|
||
**Files:**
|
||
- Create: `src/pages/agent-office/components/SidePanel.jsx`
|
||
|
||
- [ ] **Step 1: SidePanel 작성**
|
||
|
||
```jsx
|
||
// src/pages/agent-office/components/SidePanel.jsx
|
||
import { useState } from 'react';
|
||
import CommandTab from './CommandTab.jsx';
|
||
import TaskTab from './TaskTab.jsx';
|
||
import TokenTab from './TokenTab.jsx';
|
||
import LogTab from './LogTab.jsx';
|
||
|
||
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' }
|
||
};
|
||
|
||
const TABS = ['Commands', 'Tasks', 'Tokens', 'Logs'];
|
||
|
||
export default function SidePanel({ agentId, agentState, pendingTask, onClose, refreshTrigger }) {
|
||
const [activeTab, setActiveTab] = useState('Commands');
|
||
const meta = AGENT_META[agentId];
|
||
if (!meta) return null;
|
||
|
||
const stateText = agentState?.detail
|
||
? `${agentState.state} - ${agentState.detail}`
|
||
: agentState?.state || 'unknown';
|
||
|
||
return (
|
||
<div className="ao-sidepanel">
|
||
{/* Header */}
|
||
<div className="ao-sidepanel-header">
|
||
<div className="ao-sidepanel-agent">
|
||
<div className="ao-sidepanel-icon" style={{ background: meta.color }}>
|
||
{meta.emoji}
|
||
</div>
|
||
<div className="ao-sidepanel-info">
|
||
<div className="ao-sidepanel-name">{meta.displayName}</div>
|
||
<div className="ao-sidepanel-state">● {stateText}</div>
|
||
</div>
|
||
</div>
|
||
<button className="ao-sidepanel-close" onClick={onClose}>×</button>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="ao-sidepanel-tabs">
|
||
{TABS.map(tab => (
|
||
<button
|
||
key={tab}
|
||
className={`ao-sidepanel-tab ${activeTab === tab ? 'active' : ''}`}
|
||
onClick={() => setActiveTab(tab)}
|
||
>
|
||
{tab}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
<div className="ao-sidepanel-content">
|
||
{activeTab === 'Commands' && (
|
||
<CommandTab agentId={agentId} agentState={agentState?.state} pendingTask={pendingTask} />
|
||
)}
|
||
{activeTab === 'Tasks' && (
|
||
<TaskTab agentId={agentId} refreshTrigger={refreshTrigger} />
|
||
)}
|
||
{activeTab === 'Tokens' && (
|
||
<TokenTab agentId={agentId} />
|
||
)}
|
||
{activeTab === 'Logs' && (
|
||
<LogTab agentId={agentId} refreshTrigger={refreshTrigger} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add src/pages/agent-office/components/SidePanel.jsx
|
||
git commit -m "feat(agent-office): add SidePanel container with 4-tab layout"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5: 페이지 통합
|
||
|
||
### Task 17: useAgentManager 확장
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/agent-office/hooks/useAgentManager.js`
|
||
|
||
- [ ] **Step 1: lotto 에이전트 추가 + 상태 구조 개선**
|
||
|
||
기존 `useAgentManager.js` 전체를 다음으로 교체:
|
||
|
||
```javascript
|
||
// src/pages/agent-office/hooks/useAgentManager.js
|
||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
|
||
const WS_RECONNECT_DELAY = 3000;
|
||
|
||
export function useAgentManager() {
|
||
const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } }
|
||
const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}]
|
||
const [notifications, setNotifications] = useState({}); // { agentId: count }
|
||
const [connected, setConnected] = useState(false);
|
||
const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용
|
||
|
||
const wsRef = useRef(null);
|
||
const reconnectRef = useRef(null);
|
||
|
||
const connect = useCallback(() => {
|
||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`);
|
||
wsRef.current = ws;
|
||
|
||
ws.onopen = () => setConnected(true);
|
||
|
||
ws.onmessage = (e) => {
|
||
const msg = JSON.parse(e.data);
|
||
|
||
switch (msg.type) {
|
||
case 'init':
|
||
// 에이전트 초기 상태 세팅
|
||
const agentMap = {};
|
||
for (const a of msg.agents) {
|
||
agentMap[a.agent_id] = { state: a.state, detail: a.detail || '', task_id: a.task_id };
|
||
}
|
||
setAgents(agentMap);
|
||
setPendingTasks(msg.pending || []);
|
||
break;
|
||
|
||
case 'agent_state':
|
||
setAgents(prev => ({
|
||
...prev,
|
||
[msg.agent]: { state: msg.state, detail: msg.detail || '', task_id: msg.task_id }
|
||
}));
|
||
// idle 전환 시 데이터 리프레시
|
||
if (msg.state === 'idle') {
|
||
setRefreshTrigger(n => n + 1);
|
||
}
|
||
break;
|
||
|
||
case 'task_complete':
|
||
setRefreshTrigger(n => n + 1);
|
||
break;
|
||
|
||
case 'notification':
|
||
setNotifications(prev => ({
|
||
...prev,
|
||
[msg.agent]: (prev[msg.agent] || 0) + 1
|
||
}));
|
||
break;
|
||
|
||
case 'command_result':
|
||
// 사이드 패널에서 처리
|
||
break;
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
setConnected(false);
|
||
reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY);
|
||
};
|
||
|
||
ws.onerror = () => ws.close();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
connect();
|
||
return () => {
|
||
if (wsRef.current) wsRef.current.close();
|
||
if (reconnectRef.current) clearTimeout(reconnectRef.current);
|
||
};
|
||
}, [connect]);
|
||
|
||
const sendCommand = useCallback((agent, action, params = {}) => {
|
||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||
wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params }));
|
||
}
|
||
}, []);
|
||
|
||
const sendApproval = useCallback((agent, taskId, approved) => {
|
||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||
wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved }));
|
||
}
|
||
}, []);
|
||
|
||
const clearNotifications = useCallback((agentId) => {
|
||
setNotifications(prev => ({ ...prev, [agentId]: 0 }));
|
||
}, []);
|
||
|
||
return {
|
||
agents,
|
||
pendingTasks,
|
||
notifications,
|
||
connected,
|
||
refreshTrigger,
|
||
sendCommand,
|
||
sendApproval,
|
||
clearNotifications
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add src/pages/agent-office/hooks/useAgentManager.js
|
||
git commit -m "refactor(agent-office): extend useAgentManager with lotto agent and refresh triggers"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: useOfficeCanvas 재작성
|
||
|
||
**Files:**
|
||
- Rewrite: `src/pages/agent-office/hooks/useOfficeCanvas.js`
|
||
|
||
- [ ] **Step 1: useOfficeCanvas 재작성**
|
||
|
||
```javascript
|
||
// src/pages/agent-office/hooks/useOfficeCanvas.js
|
||
import { useRef, useEffect, useCallback } from 'react';
|
||
import { OfficeRenderer } from '../canvas/OfficeRenderer.js';
|
||
|
||
export function useOfficeCanvas() {
|
||
const canvasRef = useRef(null);
|
||
const rendererRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
if (!canvasRef.current) return;
|
||
|
||
const renderer = new OfficeRenderer(canvasRef.current);
|
||
rendererRef.current = renderer;
|
||
renderer.start();
|
||
|
||
const handleResize = () => renderer.resize();
|
||
window.addEventListener('resize', handleResize);
|
||
|
||
return () => {
|
||
window.removeEventListener('resize', handleResize);
|
||
renderer.destroy();
|
||
rendererRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
const updateAgentState = useCallback((agentId, state, detail) => {
|
||
rendererRef.current?.updateAgentState(agentId, state, detail);
|
||
}, []);
|
||
|
||
const setAgentNotification = useCallback((agentId, count) => {
|
||
rendererRef.current?.setAgentNotification(agentId, count);
|
||
}, []);
|
||
|
||
const setTheme = useCallback((themeName) => {
|
||
rendererRef.current?.setTheme(themeName);
|
||
}, []);
|
||
|
||
const setZoom = useCallback((level) => {
|
||
rendererRef.current?.setZoom(level);
|
||
}, []);
|
||
|
||
const hitTest = useCallback((clientX, clientY) => {
|
||
return rendererRef.current?.hitTest(clientX, clientY) || { type: 'empty' };
|
||
}, []);
|
||
|
||
const getZoom = useCallback(() => {
|
||
return rendererRef.current?.zoom || 2;
|
||
}, []);
|
||
|
||
return {
|
||
canvasRef,
|
||
updateAgentState,
|
||
setAgentNotification,
|
||
setTheme,
|
||
setZoom,
|
||
hitTest,
|
||
getZoom
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add src/pages/agent-office/hooks/useOfficeCanvas.js
|
||
git commit -m "refactor(agent-office): rewrite useOfficeCanvas hook for new renderer API"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: AgentOffice.jsx 재작성 (전체 화면 캔버스 + 사이드 패널)
|
||
|
||
**Files:**
|
||
- Rewrite: `src/pages/agent-office/AgentOffice.jsx`
|
||
|
||
- [ ] **Step 1: AgentOffice 전체 재작성**
|
||
|
||
```jsx
|
||
// src/pages/agent-office/AgentOffice.jsx
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { useAgentManager } from './hooks/useAgentManager.js';
|
||
import { useOfficeCanvas } from './hooks/useOfficeCanvas.js';
|
||
import TopBar from './components/TopBar.jsx';
|
||
import SidePanel from './components/SidePanel.jsx';
|
||
import './AgentOffice.css';
|
||
|
||
export default function AgentOffice() {
|
||
const {
|
||
agents, pendingTasks, notifications, connected,
|
||
refreshTrigger, clearNotifications
|
||
} = useAgentManager();
|
||
|
||
const {
|
||
canvasRef, updateAgentState, setAgentNotification,
|
||
setTheme, setZoom, hitTest, getZoom
|
||
} = useOfficeCanvas();
|
||
|
||
const [selectedAgent, setSelectedAgent] = useState(null);
|
||
const [theme, setThemeState] = useState(localStorage.getItem('agent-office-theme') || 'modern');
|
||
const [zoom, setZoomState] = useState(2);
|
||
|
||
// WebSocket 상태 → 캔버스 동기화
|
||
useEffect(() => {
|
||
for (const [id, agentState] of Object.entries(agents)) {
|
||
updateAgentState(id, agentState.state, agentState.detail);
|
||
}
|
||
}, [agents, updateAgentState]);
|
||
|
||
// 알림 → 캔버스 동기화
|
||
useEffect(() => {
|
||
for (const [id, count] of Object.entries(notifications)) {
|
||
setAgentNotification(id, count);
|
||
}
|
||
}, [notifications, setAgentNotification]);
|
||
|
||
// 캔버스 클릭 핸들러
|
||
const handleCanvasClick = useCallback((e) => {
|
||
const result = hitTest(e.clientX, e.clientY);
|
||
if (result.type === 'agent') {
|
||
setSelectedAgent(result.id);
|
||
clearNotifications(result.id);
|
||
setAgentNotification(result.id, 0);
|
||
} else {
|
||
setSelectedAgent(null);
|
||
}
|
||
}, [hitTest, clearNotifications, setAgentNotification]);
|
||
|
||
// 테마 변경
|
||
const handleThemeChange = useCallback((name) => {
|
||
setThemeState(name);
|
||
setTheme(name);
|
||
}, [setTheme]);
|
||
|
||
// 줌 변경
|
||
const handleZoomChange = useCallback((level) => {
|
||
setZoomState(level);
|
||
setZoom(level);
|
||
}, [setZoom]);
|
||
|
||
// 선택된 에이전트의 pending task
|
||
const pendingTask = selectedAgent
|
||
? pendingTasks.find(t => t.agent_id === selectedAgent)
|
||
: null;
|
||
|
||
return (
|
||
<div className="ao-root">
|
||
<TopBar
|
||
connected={connected}
|
||
theme={theme}
|
||
onThemeChange={handleThemeChange}
|
||
zoom={zoom}
|
||
onZoomChange={handleZoomChange}
|
||
/>
|
||
|
||
<div className="ao-main">
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="ao-canvas"
|
||
onClick={handleCanvasClick}
|
||
/>
|
||
|
||
{selectedAgent && (
|
||
<SidePanel
|
||
agentId={selectedAgent}
|
||
agentState={agents[selectedAgent]}
|
||
pendingTask={pendingTask}
|
||
onClose={() => setSelectedAgent(null)}
|
||
refreshTrigger={refreshTrigger}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add src/pages/agent-office/AgentOffice.jsx
|
||
git commit -m "refactor(agent-office): rewrite AgentOffice with full-screen canvas and side panel"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: CSS 전체 재작성
|
||
|
||
**Files:**
|
||
- Rewrite: `src/pages/agent-office/AgentOffice.css`
|
||
|
||
- [ ] **Step 1: CSS 재작성**
|
||
|
||
```css
|
||
/* src/pages/agent-office/AgentOffice.css */
|
||
|
||
/* ===== Root Layout ===== */
|
||
.ao-root {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
background: #0d0d1a;
|
||
color: #ffffff;
|
||
font-family: 'Courier New', monospace;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ===== Top Bar ===== */
|
||
.ao-topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
height: 44px;
|
||
padding: 0 16px;
|
||
background: #1a1a2e;
|
||
border-bottom: 1px solid #333;
|
||
flex-shrink: 0;
|
||
}
|
||
.ao-topbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.ao-topbar-title {
|
||
font-weight: bold;
|
||
font-size: 15px;
|
||
color: #8b5cf6;
|
||
}
|
||
.ao-topbar-status {
|
||
font-size: 11px;
|
||
}
|
||
.ao-topbar-status.connected { color: #22c55e; }
|
||
.ao-topbar-status.disconnected { color: #ef4444; }
|
||
.ao-topbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.ao-topbar-select {
|
||
background: #2a2a3e;
|
||
color: #aaa;
|
||
border: 1px solid #444;
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-family: inherit;
|
||
}
|
||
.ao-topbar-zoom {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.ao-topbar-zoom button {
|
||
background: #2a2a3e;
|
||
color: #aaa;
|
||
border: 1px solid #444;
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
}
|
||
.ao-topbar-zoom button:disabled {
|
||
opacity: 0.3;
|
||
cursor: default;
|
||
}
|
||
.ao-topbar-zoom span {
|
||
color: #888;
|
||
font-size: 12px;
|
||
min-width: 28px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* ===== Main Area ===== */
|
||
.ao-main {
|
||
flex: 1;
|
||
display: flex;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.ao-canvas {
|
||
flex: 1;
|
||
cursor: grab;
|
||
display: block;
|
||
}
|
||
.ao-canvas:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
/* ===== Side Panel ===== */
|
||
.ao-sidepanel {
|
||
width: 320px;
|
||
background: #111;
|
||
border-left: 1px solid #333;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
animation: slideIn 0.2s ease-out;
|
||
}
|
||
@keyframes slideIn {
|
||
from { transform: translateX(100%); }
|
||
to { transform: translateX(0); }
|
||
}
|
||
.ao-sidepanel-header {
|
||
padding: 12px;
|
||
border-bottom: 1px solid #333;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.ao-sidepanel-agent {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.ao-sidepanel-icon {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 18px;
|
||
}
|
||
.ao-sidepanel-name {
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
}
|
||
.ao-sidepanel-state {
|
||
font-size: 11px;
|
||
color: #22c55e;
|
||
}
|
||
.ao-sidepanel-close {
|
||
background: none;
|
||
border: none;
|
||
color: #666;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
padding: 0 4px;
|
||
}
|
||
.ao-sidepanel-close:hover {
|
||
color: #fff;
|
||
}
|
||
|
||
/* Tabs */
|
||
.ao-sidepanel-tabs {
|
||
display: flex;
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
.ao-sidepanel-tab {
|
||
flex: 1;
|
||
padding: 8px 4px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
font-family: inherit;
|
||
background: none;
|
||
border: none;
|
||
border-bottom: 2px solid transparent;
|
||
color: #666;
|
||
cursor: pointer;
|
||
}
|
||
.ao-sidepanel-tab.active {
|
||
color: #8b5cf6;
|
||
border-bottom-color: #8b5cf6;
|
||
font-weight: bold;
|
||
}
|
||
.ao-sidepanel-tab:hover {
|
||
color: #aaa;
|
||
}
|
||
.ao-sidepanel-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 12px;
|
||
}
|
||
|
||
/* ===== Command Tab ===== */
|
||
.ao-command-tab { display: flex; flex-direction: column; gap: 12px; }
|
||
.ao-section { margin-bottom: 4px; }
|
||
.ao-section-label {
|
||
color: #888;
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.ao-quick-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
.ao-btn-quick {
|
||
background: #2a2a4e;
|
||
color: #8b5cf6;
|
||
border: 1px solid #4c1d95;
|
||
padding: 5px 12px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
}
|
||
.ao-btn-quick:hover { background: #3a3a5e; }
|
||
.ao-btn-quick:disabled { opacity: 0.4; }
|
||
|
||
.ao-param-row {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
.ao-input {
|
||
flex: 1;
|
||
background: #1a1a2e;
|
||
border: 1px solid #333;
|
||
color: #fff;
|
||
padding: 7px 10px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-family: inherit;
|
||
}
|
||
.ao-input::placeholder { color: #555; }
|
||
.ao-btn-send {
|
||
background: #4c1d95;
|
||
color: #fff;
|
||
border: none;
|
||
padding: 7px 14px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
.ao-btn-send:hover { background: #5b21b6; }
|
||
.ao-btn-send:disabled { opacity: 0.4; }
|
||
|
||
/* Approval */
|
||
.ao-approval-card {
|
||
background: rgba(146,64,14,0.15);
|
||
border: 1px solid #92400e;
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
}
|
||
.ao-approval-title {
|
||
color: #fbbf24;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
margin-bottom: 4px;
|
||
}
|
||
.ao-approval-desc {
|
||
color: #ddd;
|
||
font-size: 11px;
|
||
margin-bottom: 8px;
|
||
word-break: break-all;
|
||
}
|
||
.ao-approval-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
.ao-btn-approve {
|
||
flex: 1;
|
||
background: #065f46;
|
||
color: #fff;
|
||
border: none;
|
||
padding: 7px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
.ao-btn-reject {
|
||
flex: 1;
|
||
background: #7f1d1d;
|
||
color: #fff;
|
||
border: none;
|
||
padding: 7px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* ===== Task Tab ===== */
|
||
.ao-task-tab { display: flex; flex-direction: column; gap: 4px; }
|
||
.ao-task-item {
|
||
background: #1a1a2e;
|
||
border-radius: 4px;
|
||
padding: 8px;
|
||
cursor: pointer;
|
||
}
|
||
.ao-task-item:hover { background: #222240; }
|
||
.ao-task-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
}
|
||
.ao-task-type { color: #ccc; font-weight: bold; flex: 1; }
|
||
.ao-task-badge {
|
||
padding: 1px 6px;
|
||
border-radius: 3px;
|
||
font-size: 10px;
|
||
}
|
||
.ao-task-time { color: #666; font-size: 10px; }
|
||
.ao-task-result {
|
||
margin-top: 6px;
|
||
background: #0d0d1a;
|
||
padding: 6px;
|
||
border-radius: 3px;
|
||
font-size: 10px;
|
||
color: #aaa;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
/* ===== Token Tab ===== */
|
||
.ao-token-tab { display: flex; flex-direction: column; gap: 12px; }
|
||
.ao-token-period {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
.ao-btn-period {
|
||
flex: 1;
|
||
background: #1a1a2e;
|
||
color: #888;
|
||
border: 1px solid #333;
|
||
padding: 5px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
}
|
||
.ao-btn-period.active {
|
||
background: #4c1d95;
|
||
color: #fff;
|
||
border-color: #4c1d95;
|
||
}
|
||
.ao-token-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
}
|
||
.ao-token-card {
|
||
background: #1a1a2e;
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
text-align: center;
|
||
}
|
||
.ao-token-label {
|
||
font-size: 10px;
|
||
color: #888;
|
||
text-transform: uppercase;
|
||
margin-bottom: 4px;
|
||
}
|
||
.ao-token-value {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
}
|
||
.ao-token-bar { margin-top: 4px; }
|
||
.ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; }
|
||
.ao-token-bar-track {
|
||
display: flex;
|
||
height: 8px;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
background: #1a1a2e;
|
||
}
|
||
.ao-token-bar-fill.input { background: #3b82f6; }
|
||
.ao-token-bar-fill.output { background: #8b5cf6; }
|
||
.ao-token-bar-legend {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 10px;
|
||
color: #888;
|
||
margin-top: 4px;
|
||
}
|
||
.ao-token-bar-legend .dot {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
margin-right: 4px;
|
||
}
|
||
.ao-token-bar-legend .dot.input { background: #3b82f6; }
|
||
.ao-token-bar-legend .dot.output { background: #8b5cf6; }
|
||
.ao-token-detail {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 10px;
|
||
color: #666;
|
||
}
|
||
|
||
/* ===== Log Tab ===== */
|
||
.ao-log-tab {
|
||
max-height: 100%;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.ao-log-item {
|
||
display: flex;
|
||
gap: 6px;
|
||
font-size: 11px;
|
||
padding: 3px 0;
|
||
border-bottom: 1px solid #1a1a2e;
|
||
}
|
||
.ao-log-time { color: #555; min-width: 60px; }
|
||
.ao-log-level { min-width: 48px; font-weight: bold; }
|
||
.ao-log-msg { color: #ccc; word-break: break-all; }
|
||
|
||
/* ===== Common ===== */
|
||
.ao-empty {
|
||
color: #555;
|
||
text-align: center;
|
||
padding: 24px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* ===== Mobile (< 768px) ===== */
|
||
@media (max-width: 768px) {
|
||
.ao-topbar-right { gap: 6px; }
|
||
.ao-topbar-select { font-size: 11px; padding: 2px 6px; }
|
||
|
||
.ao-main {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.ao-canvas {
|
||
flex: 1;
|
||
}
|
||
|
||
/* Side panel → bottom sheet */
|
||
.ao-sidepanel {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
width: 100%;
|
||
max-height: 55vh;
|
||
border-left: none;
|
||
border-top: 1px solid #333;
|
||
border-radius: 16px 16px 0 0;
|
||
animation: slideUp 0.25s ease-out;
|
||
z-index: 100;
|
||
}
|
||
@keyframes slideUp {
|
||
from { transform: translateY(100%); }
|
||
to { transform: translateY(0); }
|
||
}
|
||
|
||
.ao-sidepanel-header {
|
||
padding: 8px 12px;
|
||
}
|
||
.ao-sidepanel-header::before {
|
||
content: '';
|
||
display: block;
|
||
width: 32px;
|
||
height: 4px;
|
||
background: #555;
|
||
border-radius: 2px;
|
||
margin: 0 auto 8px;
|
||
}
|
||
|
||
.ao-sidepanel-tab {
|
||
font-size: 11px;
|
||
padding: 6px 2px;
|
||
}
|
||
|
||
.ao-sidepanel-content {
|
||
padding: 8px 12px;
|
||
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add src/pages/agent-office/AgentOffice.css
|
||
git commit -m "refactor(agent-office): rewrite CSS for full-screen canvas layout with mobile bottom sheet"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 21: 레거시 파일 정리
|
||
|
||
**Files:**
|
||
- Delete: `src/pages/agent-office/components/AgentColumn.jsx`
|
||
- Delete: `src/pages/agent-office/components/CommandColumn.jsx`
|
||
- Delete: `src/pages/agent-office/components/ChatPanel.jsx`
|
||
- Delete: `src/pages/agent-office/components/DocumentPanel.jsx`
|
||
- Delete: `src/pages/agent-office/canvas/SpriteSheet.js`
|
||
|
||
- [ ] **Step 1: 레거시 파일 삭제**
|
||
|
||
```bash
|
||
rm src/pages/agent-office/components/AgentColumn.jsx
|
||
rm src/pages/agent-office/components/CommandColumn.jsx
|
||
rm src/pages/agent-office/components/ChatPanel.jsx
|
||
rm src/pages/agent-office/components/DocumentPanel.jsx
|
||
rm src/pages/agent-office/canvas/SpriteSheet.js
|
||
```
|
||
|
||
- [ ] **Step 2: 빌드 확인**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: 빌드 성공 (삭제된 파일을 import하는 곳이 없어야 함)
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore(agent-office): remove legacy dashboard components replaced by v2 UI"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 22: 통합 테스트 (브라우저 수동)
|
||
|
||
- [ ] **Step 1: 개발 서버 시작**
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
- [ ] **Step 2: 브라우저에서 http://localhost:3007/agent-office 접속 후 확인 항목**
|
||
|
||
1. 전체 화면에 픽셀 오피스 캔버스가 표시되는가
|
||
2. 5명의 에이전트(stock, music, blog, realestate, lotto)가 맵에 있는가
|
||
3. 마우스 휠로 줌 인/아웃이 되는가 (1x~4x)
|
||
4. 드래그로 패닝이 되는가
|
||
5. 에이전트 클릭 시 사이드 패널이 열리는가
|
||
6. 사이드 패널 4탭 (Commands, Tasks, Tokens, Logs)이 전환되는가
|
||
7. Quick Action 버튼이 에이전트별로 다른가
|
||
8. 빈 공간 클릭 시 사이드 패널이 닫히는가
|
||
9. 테마 드롭다운으로 Modern/Retro/Minimal 전환이 되는가
|
||
10. 상단 바에 연결 상태가 표시되는가
|
||
|
||
- [ ] **Step 3: 모바일 확인 (DevTools → 모바일 뷰)**
|
||
|
||
1. 캔버스가 전체 화면을 차지하는가
|
||
2. 에이전트 탭 시 바텀 시트가 올라오는가
|
||
3. 바텀 시트 닫기가 동작하는가
|
||
|
||
- [ ] **Step 4: 문제 수정 후 커밋**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix(agent-office): address integration issues from manual testing"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 6: 최종 검증
|
||
|
||
### Task 23: 백엔드 agent_move 메시지 확인
|
||
|
||
**Files:**
|
||
- Check: `web-backend/agent-office/app/agents/base.py`
|
||
- Check: `web-backend/agent-office/app/websocket_manager.py`
|
||
|
||
- [ ] **Step 1: base.py의 transition 메서드에서 break 상태 시 agent_move 전송 확인**
|
||
|
||
`base.py`의 `transition()` 메서드를 읽고, `break` 상태 전환 시 `agent_move` WebSocket 메시지가 broadcast되는지 확인.
|
||
만약 누락되어 있다면 `transition()` 내부에 다음을 추가:
|
||
|
||
```python
|
||
# break 전환 시 프론트엔드에 이동 알림
|
||
if new_state == "break":
|
||
await self._ws_manager.broadcast_move(self.agent_id, "break_room")
|
||
elif new_state in ("working", "reporting", "waiting"):
|
||
await self._ws_manager.broadcast_move(self.agent_id, "desk")
|
||
```
|
||
|
||
단, 현재 프론트엔드는 `agent_state` 메시지만으로 이동을 처리하도록 설계했으므로 (`AgentSprite.onStateChange`가 상태에 따라 자동 이동), `agent_move`는 선택적. 프론트엔드가 `agent_state`만 사용하여 정상 동작하면 백엔드 수정 불필요.
|
||
|
||
- [ ] **Step 2: 확인 결과에 따라 커밋 (변경 있을 때만)**
|
||
|
||
---
|
||
|
||
### Task 24: CLAUDE.md 업데이트
|
||
|
||
**Files:**
|
||
- Modify: `web-ui` 저장소의 CLAUDE.md (해당사항 있으면)
|
||
|
||
- [ ] **Step 1: 프론트엔드 파일 구조 변경 반영**
|
||
|
||
Agent Office 섹션이 있다면, v2 파일 구조 (canvas/, components/, hooks/) 반영.
|
||
|
||
- [ ] **Step 2: 커밋**
|
||
|
||
```bash
|
||
git add CLAUDE.md
|
||
git commit -m "docs: update CLAUDE.md with Agent Office v2 file structure"
|
||
```
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
| Phase | Tasks | 설명 |
|
||
|-------|-------|------|
|
||
| 1. 캔버스 엔진 | 1-6 | 테마, 맵, BFS, 타일맵, 가구, 게임루프 |
|
||
| 2. 에이전트 시스템 | 7-9 | 프로시저럴 스프라이트, AgentSprite, SpriteLoader |
|
||
| 3. 오버레이 | 10 | 이름, 배지, 말풍선, 알림 |
|
||
| 4. 사이드 패널 | 11-16 | TopBar, CommandTab, TaskTab, TokenTab, LogTab, SidePanel |
|
||
| 5. 페이지 통합 | 17-22 | Hook 재작성, AgentOffice 재작성, CSS, 레거시 정리, 테스트 |
|
||
| 6. 최종 검증 | 23-24 | 백엔드 확인, 문서 업데이트 |
|