feat: Agent Office — AI 에이전트 가상 오피스 (#2)

## Summary
- Canvas 2D 픽셀아트 오피스 렌더링 (SpriteSheet + TileMap + AgentSprite)
- WebSocket 실시간 에이전트 상태 동기화 (useAgentManager)
- ChatPanel (명령/승인) + TaskHistory (작업 이력) UI
- 다크 테마 + glassmorphism 패널

## Changes (7 commits)
- API helpers + route + Lab entry
- Canvas engine: SpriteSheet, TileMap, AgentSprite, OfficeRenderer
- React hooks: useAgentManager, useOfficeCanvas
- Components: ChatPanel, TaskHistory
- Main page + CSS
- Code review fixes: claude agent 참조 제거, rejected 배지 추가

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-04-11 13:35:35 +09:00
parent 7fc2d3aaf7
commit 25715a2198
14 changed files with 1206 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
import { drawAgent, getAnimSpeed } from './SpriteSheet';
export class AgentSprite {
constructor(agentId, waypoints) {
this.agentId = agentId;
this.waypoints = waypoints;
this.state = 'idle';
this.detail = '';
const deskKey = `${agentId}_desk`;
const desk = waypoints[deskKey] || { x: 5, y: 3 };
this.x = desk.x;
this.y = desk.y;
this.targetX = desk.x;
this.targetY = desk.y;
this.deskPos = { x: desk.x, y: desk.y };
this.frameIndex = 0;
this._lastFrameTime = 0;
this._moveSpeed = 0.05;
}
setState(newState, detail = '') {
this.state = newState;
this.detail = detail;
this.frameIndex = 0;
}
moveTo(target) {
const wp = this.waypoints[target];
if (wp) {
this.targetX = wp.x;
this.targetY = wp.y;
}
}
moveToDesk() {
this.targetX = this.deskPos.x;
this.targetY = this.deskPos.y;
}
update(now) {
const speed = getAnimSpeed(this.state);
if (now - this._lastFrameTime > speed) {
this.frameIndex++;
this._lastFrameTime = now;
}
const dx = this.targetX - this.x;
const dy = this.targetY - this.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0.1) {
const step = Math.min(this._moveSpeed, dist);
this.x += (dx / dist) * step;
this.y += (dy / dist) * step;
} else {
this.x = this.targetX;
this.y = this.targetY;
}
}
draw(ctx, renderInfo) {
const { scale, offsetX, offsetY, tileSize } = renderInfo;
const canvasX = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
const canvasY = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
const isMoving = Math.abs(this.targetX - this.x) > 0.1 || Math.abs(this.targetY - this.y) > 0.1;
const drawState = isMoving ? 'walk' : this.state;
drawAgent(ctx, this.agentId, canvasX, canvasY, drawState, this.frameIndex, scale * 1.5);
}
hitTest(canvasX, canvasY, renderInfo) {
const { scale, offsetX, offsetY, tileSize } = renderInfo;
const cx = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
const cy = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
const hitW = 20 * scale;
const hitH = 30 * scale;
return canvasX >= cx - hitW && canvasX <= cx + hitW &&
canvasY >= cy - hitH && canvasY <= cy + hitH;
}
}

View File

@@ -0,0 +1,129 @@
import { drawTileMap } from './TileMap';
import { AgentSprite } from './AgentSprite';
import { getCharLabel } from './SpriteSheet';
const STATUS_ICONS = {
idle: null,
working: null,
waiting: '❗',
reporting: '📋',
break: '☕',
};
export class OfficeRenderer {
constructor(canvas, mapData) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.mapData = mapData;
this.renderInfo = null;
this.agents = {};
this._animId = null;
this._onClick = null;
const agentIds = ['stock', 'music'];
for (const id of agentIds) {
this.agents[id] = new AgentSprite(id, mapData.waypoints);
}
}
start() {
this._loop = this._loop.bind(this);
this._animId = requestAnimationFrame(this._loop);
}
stop() {
if (this._animId) {
cancelAnimationFrame(this._animId);
this._animId = null;
}
}
resize(width, height) {
this.canvas.width = width;
this.canvas.height = height;
}
setOnClick(handler) {
this._onClick = handler;
}
handleClick(canvasX, canvasY) {
if (!this.renderInfo) return null;
for (const [id, sprite] of Object.entries(this.agents)) {
if (sprite.hitTest(canvasX, canvasY, this.renderInfo)) {
if (this._onClick) this._onClick(id);
return id;
}
}
return null;
}
updateAgentState(agentId, state, detail) {
const sprite = this.agents[agentId];
if (sprite) {
sprite.setState(state, detail);
if (state === 'idle' || state === 'working' || state === 'waiting') {
sprite.moveToDesk();
}
}
}
moveAgent(agentId, target) {
const sprite = this.agents[agentId];
if (sprite) {
sprite.moveTo(target);
}
}
_loop(timestamp) {
const { ctx, canvas, mapData } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height);
const now = Date.now();
for (const sprite of Object.values(this.agents)) {
sprite.update(now);
sprite.draw(ctx, this.renderInfo);
}
for (const [id, sprite] of Object.entries(this.agents)) {
this._drawOverlay(ctx, sprite, id);
}
this._animId = requestAnimationFrame(this._loop);
}
_drawOverlay(ctx, sprite, agentId) {
if (!this.renderInfo) return;
const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
const cx = offsetX + sprite.x * tileSize * scale + (tileSize * scale) / 2;
const cy = offsetY + sprite.y * tileSize * scale - 10 * scale;
const icon = STATUS_ICONS[sprite.state];
if (icon) {
ctx.font = `${14 * scale}px serif`;
ctx.textAlign = 'center';
ctx.fillText(icon, cx, cy - 15 * scale);
}
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = `${8 * scale}px monospace`;
ctx.textAlign = 'center';
ctx.fillText(getCharLabel(agentId), cx, cy + 30 * scale + 30);
if (sprite.detail && (sprite.state === 'working' || sprite.state === 'waiting')) {
const bubbleY = cy - 25 * scale;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const textW = ctx.measureText(sprite.detail).width;
ctx.fillRect(cx - textW / 2 - 6, bubbleY - 10, textW + 12, 16);
ctx.fillStyle = '#fff';
ctx.font = `${7 * scale}px monospace`;
ctx.fillText(sprite.detail, cx, bubbleY);
}
}
}

View File

@@ -0,0 +1,89 @@
const PIXEL_CHARS = {
stock: { body: '#4488cc', accent: '#cc4444', label: '주식', hair: '#332222' },
music: { body: '#44aa88', accent: '#ffaa00', label: '음악', hair: '#443322' },
claude: { body: '#8855cc', accent: '#cc88ff', label: 'Claude', hair: '#554466' },
};
const ANIM_FRAMES = {
idle: { frames: 2, speed: 800 },
working: { frames: 4, speed: 200 },
waiting: { frames: 2, speed: 400 },
break: { frames: 2, speed: 1000 },
walk: { frames: 4, speed: 150 },
};
export function drawAgent(ctx, agentId, x, y, state, frameIndex, scale = 2) {
const char = PIXEL_CHARS[agentId] || PIXEL_CHARS.claude;
const s = scale;
const anim = ANIM_FRAMES[state] || ANIM_FRAMES.idle;
const frame = frameIndex % anim.frames;
ctx.save();
ctx.translate(x, y);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillRect(-4 * s, 14 * s, 8 * s, 2 * s);
// Body
ctx.fillStyle = char.body;
ctx.fillRect(-3 * s, 2 * s, 6 * s, 8 * s);
// Head
ctx.fillStyle = '#ffcc99';
ctx.fillRect(-3 * s, -4 * s, 6 * s, 6 * s);
// Hair
ctx.fillStyle = char.hair;
ctx.fillRect(-3 * s, -5 * s, 6 * s, 2 * s);
// Eyes
ctx.fillStyle = '#222';
const eyeOffset = state === 'break' && frame === 1 ? 0 : 1;
ctx.fillRect(-2 * s, -1 * s, 1 * s, eyeOffset * s);
ctx.fillRect(1 * s, -1 * s, 1 * s, eyeOffset * s);
// Legs
ctx.fillStyle = '#335';
const legSpread = state === 'walk' ? (frame % 2 === 0 ? 1 : -1) : 0;
ctx.fillRect(-2 * s, 10 * s, 2 * s, 4 * s);
ctx.fillRect(0 + legSpread * s, 10 * s, 2 * s, 4 * s);
// Accent
ctx.fillStyle = char.accent;
if (agentId === 'stock') {
ctx.fillRect(0, 2 * s, 1 * s, 5 * s);
} else if (agentId === 'music') {
ctx.fillRect(-4 * s, -4 * s, 1 * s, 4 * s);
ctx.fillRect(3 * s, -4 * s, 1 * s, 4 * s);
ctx.fillRect(-4 * s, -5 * s, 8 * s, 1 * s);
} else if (agentId === 'claude') {
ctx.globalAlpha = 0.3 + 0.2 * Math.sin(Date.now() / 500);
ctx.fillRect(-4 * s, -6 * s, 8 * s, 1 * s);
ctx.globalAlpha = 1;
}
// Working: typing hands
if (state === 'working') {
ctx.fillStyle = '#ffcc99';
const handY = 6 * s + (frame % 2) * s;
ctx.fillRect(-4 * s, handY, 1 * s, 2 * s);
ctx.fillRect(3 * s, handY, 1 * s, 2 * s);
}
// Waiting wobble
if (state === 'waiting') {
const wobble = Math.sin(Date.now() / 200) * s;
ctx.translate(wobble, 0);
}
ctx.restore();
}
export function getAnimSpeed(state) {
return (ANIM_FRAMES[state] || ANIM_FRAMES.idle).speed;
}
export function getCharLabel(agentId) {
return (PIXEL_CHARS[agentId] || {}).label || agentId;
}

View File

@@ -0,0 +1,90 @@
const WALL_COLOR = '#2a2a3a';
const DESK_COLOR = '#6b5b3a';
const DESK_TOP = '#8b7b5a';
const TABLE_COLOR = '#5a4a2a';
const SOFA_COLOR = '#884444';
const MONITOR_COLOR = '#224466';
const MONITOR_SCREEN = '#44aacc';
export function drawTileMap(ctx, mapData, width, height) {
const { tileSize, cols, rows, layers, furniture, colors } = mapData;
const scaleX = width / (cols * tileSize);
const scaleY = height / (rows * tileSize);
const scale = Math.min(scaleX, scaleY);
const offsetX = (width - cols * tileSize * scale) / 2;
const offsetY = (height - rows * tileSize * scale) / 2;
ctx.save();
ctx.translate(offsetX, offsetY);
ctx.scale(scale, scale);
const floor = layers.floor;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const tile = floor[r][c];
ctx.fillStyle = colors[String(tile)] || '#3a3a50';
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.strokeRect(c * tileSize, r * tileSize, tileSize, tileSize);
}
}
ctx.fillStyle = WALL_COLOR;
ctx.fillRect(0, 0, cols * tileSize, 4);
for (const f of furniture) {
const fx = f.x * tileSize;
const fy = f.y * tileSize;
const fw = (f.w || 2) * tileSize;
const fh = (f.h || 2) * tileSize;
if (f.type === 'desk') {
ctx.fillStyle = DESK_COLOR;
ctx.fillRect(fx, fy, fw, fh);
ctx.fillStyle = DESK_TOP;
ctx.fillRect(fx + 2, fy + 2, fw - 4, 6);
const mx = fx + fw / 2 - 8;
ctx.fillStyle = MONITOR_COLOR;
ctx.fillRect(mx, fy + 4, 16, 12);
ctx.fillStyle = MONITOR_SCREEN;
ctx.fillRect(mx + 2, fy + 6, 12, 8);
if (f.label) {
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '8px monospace';
ctx.textAlign = 'center';
ctx.fillText(f.label, fx + fw / 2, fy + fh + 12);
}
} else if (f.type === 'table') {
ctx.fillStyle = TABLE_COLOR;
ctx.fillRect(fx, fy, fw, fh);
ctx.fillStyle = '#7a6a4a';
ctx.fillRect(fx + 4, fy + 4, fw - 8, fh - 8);
} else if (f.type === 'sofa') {
ctx.fillStyle = SOFA_COLOR;
ctx.fillRect(fx, fy, 48, 32);
ctx.fillStyle = '#aa5555';
ctx.fillRect(fx + 4, fy + 4, 40, 24);
} else if (f.type === 'coffee') {
ctx.fillStyle = '#664422';
ctx.fillRect(fx + 8, fy + 8, 16, 20);
ctx.fillStyle = '#886644';
ctx.fillRect(fx + 6, fy + 6, 20, 4);
}
}
ctx.restore();
return { scale, offsetX, offsetY, tileSize };
}
export function worldToTile(mapData, renderInfo, canvasX, canvasY) {
const { scale, offsetX, offsetY, tileSize } = renderInfo;
const wx = (canvasX - offsetX) / scale;
const wy = (canvasY - offsetY) / scale;
return { col: Math.floor(wx / tileSize), row: Math.floor(wy / tileSize), worldX: wx, worldY: wy };
}
export function tileToCanvas(mapData, renderInfo, col, row) {
const { scale, offsetX, offsetY, tileSize } = renderInfo;
return { x: offsetX + col * tileSize * scale + (tileSize * scale) / 2, y: offsetY + row * tileSize * scale + (tileSize * scale) / 2 };
}