feat: Agent Office — AI 에이전트 가상 오피스 #2
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', 'claude'];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user