From f01a4323295e9f72cf54162eb8cbd25c1229562d Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 27 Apr 2026 08:32:13 +0900 Subject: [PATCH] feat(agent-office): add 16x32 procedural sprite with 5 states and 4 directions --- .../agent-office/canvas/ProceduralSprite.js | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/pages/agent-office/canvas/ProceduralSprite.js diff --git a/src/pages/agent-office/canvas/ProceduralSprite.js b/src/pages/agent-office/canvas/ProceduralSprite.js new file mode 100644 index 0000000..4492ec5 --- /dev/null +++ b/src/pages/agent-office/canvas/ProceduralSprite.js @@ -0,0 +1,164 @@ +// 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 }; + } +}