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:
84
src/pages/agent-office/canvas/AgentSprite.js
Normal file
84
src/pages/agent-office/canvas/AgentSprite.js
Normal 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;
|
||||
}
|
||||
}
|
||||
129
src/pages/agent-office/canvas/OfficeRenderer.js
Normal file
129
src/pages/agent-office/canvas/OfficeRenderer.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/pages/agent-office/canvas/SpriteSheet.js
Normal file
89
src/pages/agent-office/canvas/SpriteSheet.js
Normal 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;
|
||||
}
|
||||
90
src/pages/agent-office/canvas/TileMap.js
Normal file
90
src/pages/agent-office/canvas/TileMap.js
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user