152 lines
5.7 KiB
TypeScript
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();
|
|
}
|