Files
skrible-2/src/client/avatar.ts
T
2026-07-18 20:41:22 -05:00

152 lines
5.7 KiB
TypeScript

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();
}