import { AvatarSeed } from '../shared/protocol'; // Cartoon avatar renderer (skribbl-style blob characters), fully procedural. export const AVATAR_COLORS = [ '#f44336', '#ff8f00', '#ffd600', '#4caf50', '#00bcd4', '#2196f3', '#3f51b5', '#9c27b0', '#ec407a', '#8d6e63', '#607d8b', '#7cb342', ]; const SKIN = ['#ffd9b3', '#f2b880', '#c98d5a', '#8c5a33', '#ffe0c4', '#e8b06a']; export function drawAvatar(canvas: HTMLCanvasElement, seed: AvatarSeed, size = 80): void { const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = size * dpr; canvas.height = size * dpr; canvas.style.width = `${size}px`; canvas.style.height = `${size}px`; const g = canvas.getContext('2d'); if (!g) return; g.scale(dpr, dpr); g.clearRect(0, 0, size, size); const s = size / 80; // design space 80x80 g.save(); g.scale(s, s); const body = AVATAR_COLORS[seed.color % AVATAR_COLORS.length]; // body blob g.beginPath(); g.ellipse(40, 46, 26, 24, 0, 0, Math.PI * 2); g.fillStyle = body; g.fill(); g.lineWidth = 3; g.strokeStyle = 'rgba(0,0,0,0.55)'; g.stroke(); // little feet g.fillStyle = body; for (const fx of [28, 52]) { g.beginPath(); g.ellipse(fx, 68, 8, 5, 0, 0, Math.PI * 2); g.fill(); g.stroke(); } const skin = SKIN[(seed.color + seed.extra) % SKIN.length]; // face patch g.beginPath(); g.ellipse(40, 42, 18, 15, 0, 0, Math.PI * 2); g.fillStyle = skin; g.fill(); const eyeY = 40, eyeDX = 8; g.lineWidth = 2.4; g.strokeStyle = '#222'; g.fillStyle = '#222'; const style = seed.eyes % 6; const eye = (cx: number, happy: boolean, closed: boolean) => { if (happy) { g.beginPath(); g.arc(cx, eyeY + 1, 4, Math.PI * 1.1, Math.PI * 1.9); g.stroke(); } else if (closed) { g.beginPath(); g.moveTo(cx - 3.5, eyeY); g.lineTo(cx + 3.5, eyeY); g.stroke(); } else { g.beginPath(); g.arc(cx, eyeY, 3.6, 0, Math.PI * 2); g.fillStyle = '#fff'; g.fill(); g.stroke(); g.beginPath(); g.arc(cx + 1, eyeY + 0.6, 1.7, 0, Math.PI * 2); g.fillStyle = '#222'; g.fill(); } }; switch (style) { case 0: eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, false); break; // normal case 1: eye(40 - eyeDX, true, false); eye(40 + eyeDX, true, false); break; // happy case 2: eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, true); break; // wink case 3: // glasses eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, false); g.lineWidth = 2; g.strokeRect(40 - eyeDX - 6, eyeY - 6, 11, 10); g.strokeRect(40 + eyeDX - 5, eyeY - 6, 11, 10); g.beginPath(); g.moveTo(40 - eyeDX + 5, eyeY - 2); g.lineTo(40 + eyeDX - 5, eyeY - 2); g.stroke(); break; case 4: eye(40 - eyeDX, false, true); eye(40 + eyeDX, false, true); break; // sleepy case 5: // surprised big eyes for (const cx of [40 - eyeDX, 40 + eyeDX]) { g.beginPath(); g.arc(cx, eyeY, 5, 0, Math.PI * 2); g.fillStyle = '#fff'; g.fill(); g.stroke(); g.beginPath(); g.arc(cx + 1, eyeY + 1, 2.2, 0, Math.PI * 2); g.fillStyle = '#222'; g.fill(); } break; } // mouth g.lineWidth = 2.4; g.strokeStyle = '#7a3b2e'; const my = 51; switch (seed.mouth % 6) { case 0: g.beginPath(); g.arc(40, my - 3, 7, Math.PI * 0.15, Math.PI * 0.85); g.stroke(); break; // smile case 1: // open smile g.beginPath(); g.arc(40, my - 3, 7, Math.PI * 0.1, Math.PI * 0.9); g.closePath(); g.fillStyle = '#7a3b2e'; g.fill(); g.beginPath(); g.ellipse(40, my + 3, 4, 2.2, 0, 0, Math.PI * 2); g.fillStyle = '#e57373'; g.fill(); break; case 2: g.beginPath(); g.moveTo(34, my); g.quadraticCurveTo(41, my + 5, 47, my - 2); g.stroke(); break; // smirk case 3: g.beginPath(); g.ellipse(40, my, 3.6, 4.6, 0, 0, Math.PI * 2); g.fillStyle = '#7a3b2e'; g.fill(); break; // O case 4: // tongue g.beginPath(); g.arc(40, my - 3, 6, Math.PI * 0.15, Math.PI * 0.85); g.stroke(); g.beginPath(); g.ellipse(40, my + 3.5, 3, 3.8, 0, 0, Math.PI * 2); g.fillStyle = '#e57373'; g.fill(); g.stroke(); break; case 5: g.beginPath(); g.arc(40, my + 6, 6, Math.PI * 1.15, Math.PI * 1.85); g.stroke(); break; // sad } // extras on head g.strokeStyle = 'rgba(0,0,0,0.55)'; switch (seed.extra % 6) { case 1: // hair tuft g.fillStyle = '#4e342e'; g.beginPath(); g.moveTo(28, 26); g.quadraticCurveTo(34, 8, 40, 22); g.quadraticCurveTo(46, 8, 52, 26); g.quadraticCurveTo(40, 18, 28, 26); g.fill(); break; case 2: // cap g.fillStyle = '#e53935'; g.beginPath(); g.arc(40, 26, 15, Math.PI, 0); g.closePath(); g.fill(); g.stroke(); g.beginPath(); g.ellipse(52, 26, 9, 4, 0.15, 0, Math.PI * 2); g.fill(); g.stroke(); g.fillStyle = '#fff'; g.beginPath(); g.arc(40, 13, 3, 0, Math.PI * 2); g.fill(); break; case 3: // antenna g.beginPath(); g.moveTo(40, 24); g.quadraticCurveTo(42, 12, 40, 8); g.stroke(); g.fillStyle = '#ffd600'; g.beginPath(); g.arc(40, 7, 3.6, 0, Math.PI * 2); g.fill(); g.stroke(); break; case 4: // bow g.fillStyle = '#ec407a'; for (const dir of [-1, 1]) { g.beginPath(); g.moveTo(40 + dir * 6, 20); g.lineTo(40 + dir * 16, 13); g.lineTo(40 + dir * 16, 27); g.closePath(); g.fill(); g.stroke(); } g.beginPath(); g.arc(40, 20, 3.4, 0, Math.PI * 2); g.fill(); g.stroke(); break; case 5: // tiny crown g.fillStyle = '#ffd600'; g.beginPath(); g.moveTo(28, 22); g.lineTo(28, 12); g.lineTo(34, 18); g.lineTo(40, 9); g.lineTo(46, 18); g.lineTo(52, 12); g.lineTo(52, 22); g.closePath(); g.fill(); g.stroke(); break; default: break; } g.restore(); }