// 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 }; } }