1799 lines
53 KiB
JavaScript
1799 lines
53 KiB
JavaScript
/**
|
|
* AvatarRenderer - Core rendering engine for Mii-style avatar system
|
|
* Draws avatars on canvas in different modes using Canvas 2D API
|
|
*
|
|
* Depends on: AvatarData, AvatarAnimations (must be loaded first)
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Rendering modes with their dimensions
|
|
var MODES = {
|
|
HEAD_ONLY: { width: 64, height: 64 },
|
|
HEAD_AND_SHOULDERS: { width: 64, height: 96 },
|
|
UPPER_BODY: { width: 64, height: 128 },
|
|
FULL_BODY: { width: 64, height: 160 }
|
|
};
|
|
|
|
// Gradient cache for performance
|
|
var gradientCache = {};
|
|
|
|
// ============================================
|
|
// DRAWING HELPERS
|
|
// ============================================
|
|
|
|
/**
|
|
* Draw a rounded rectangle
|
|
*/
|
|
function roundRect(ctx, x, y, w, h, r) {
|
|
if (typeof r === 'number') {
|
|
r = { tl: r, tr: r, br: r, bl: r };
|
|
} else {
|
|
r = Object.assign({ tl: 0, tr: 0, br: 0, bl: 0 }, r);
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + r.tl, y);
|
|
ctx.lineTo(x + w - r.tr, y);
|
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
|
|
ctx.lineTo(x + w, y + h - r.br);
|
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
|
|
ctx.lineTo(x + r.bl, y + h);
|
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
|
|
ctx.lineTo(x, y + r.tl);
|
|
ctx.quadraticCurveTo(x, y, x + r.tl, y);
|
|
ctx.closePath();
|
|
}
|
|
|
|
/**
|
|
* Draw a bezier curve through points
|
|
*/
|
|
function drawCurve(ctx, points) {
|
|
if (points.length < 2) return;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0].x, points[0].y);
|
|
|
|
if (points.length === 2) {
|
|
ctx.lineTo(points[1].x, points[1].y);
|
|
} else if (points.length === 3) {
|
|
ctx.quadraticCurveTo(points[1].x, points[1].y, points[2].x, points[2].y);
|
|
} else {
|
|
for (var i = 1; i < points.length - 2; i++) {
|
|
var xc = (points[i].x + points[i + 1].x) / 2;
|
|
var yc = (points[i].y + points[i + 1].y) / 2;
|
|
ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
|
|
}
|
|
ctx.quadraticCurveTo(
|
|
points[points.length - 2].x,
|
|
points[points.length - 2].y,
|
|
points[points.length - 1].x,
|
|
points[points.length - 1].y
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a gradient fill
|
|
*/
|
|
function gradientFill(ctx, x, y, w, h, color1, color2, vertical) {
|
|
var cacheKey = color1 + '-' + color2 + '-' + (vertical ? 'v' : 'h');
|
|
var gradient;
|
|
|
|
if (vertical) {
|
|
gradient = ctx.createLinearGradient(x, y, x, y + h);
|
|
} else {
|
|
gradient = ctx.createLinearGradient(x, y, x + w, y);
|
|
}
|
|
|
|
gradient.addColorStop(0, color1);
|
|
gradient.addColorStop(1, color2);
|
|
|
|
return gradient;
|
|
}
|
|
|
|
/**
|
|
* Adjust skin color for shading
|
|
*/
|
|
function skinShade(baseColor, amount) {
|
|
var hex = baseColor.replace('#', '');
|
|
var r = parseInt(hex.substr(0, 2), 16);
|
|
var g = parseInt(hex.substr(2, 2), 16);
|
|
var b = parseInt(hex.substr(4, 2), 16);
|
|
|
|
r = Math.max(0, Math.min(255, r + amount));
|
|
g = Math.max(0, Math.min(255, g + amount));
|
|
b = Math.max(0, Math.min(255, b + amount));
|
|
|
|
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
|
}
|
|
|
|
/**
|
|
* Adjust any color brightness
|
|
*/
|
|
function adjustBrightness(color, amount) {
|
|
return skinShade(color, amount);
|
|
}
|
|
|
|
/**
|
|
* Add shine highlight to hair
|
|
*/
|
|
function addHairShine(ctx, x, y, w, h, color) {
|
|
var lighter = adjustBrightness(color, 40);
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.3;
|
|
ctx.fillStyle = lighter;
|
|
ctx.beginPath();
|
|
ctx.ellipse(x + w / 2, y + h / 2, w / 2, h / 3, -0.3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw an ellipse (polyfill for older browsers)
|
|
*/
|
|
function drawEllipse(ctx, x, y, radiusX, radiusY) {
|
|
ctx.beginPath();
|
|
if (ctx.ellipse) {
|
|
ctx.ellipse(x, y, radiusX, radiusY, 0, 0, Math.PI * 2);
|
|
} else {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.scale(radiusX, radiusY);
|
|
ctx.arc(0, 0, 1, 0, Math.PI * 2);
|
|
ctx.restore();
|
|
}
|
|
ctx.closePath();
|
|
}
|
|
|
|
// ============================================
|
|
// FACE RENDERING
|
|
// ============================================
|
|
|
|
/**
|
|
* Render the base face shape
|
|
*/
|
|
function renderFaceBase(ctx, avatar, x, y, scale) {
|
|
var faceShape = window.AvatarData ? window.AvatarData.getFaceShape(avatar.base.faceShape) : null;
|
|
var skinTone = avatar.base.skinTone || '#FFD5B8';
|
|
var shapeId = faceShape ? faceShape.id : avatar.base.faceShape || 'oval';
|
|
|
|
var faceWidth = 44 * scale;
|
|
var faceHeight = 48 * scale;
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
ctx.fillStyle = skinTone;
|
|
|
|
switch (shapeId) {
|
|
case 'round':
|
|
drawEllipse(ctx, cx, cy, faceWidth / 2, faceWidth / 2);
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'oval':
|
|
drawEllipse(ctx, cx, cy, faceWidth / 2, faceHeight / 2);
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'square':
|
|
roundRect(ctx, cx - faceWidth / 2, cy - faceHeight / 2, faceWidth, faceHeight, 6 * scale);
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'heart':
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy + faceHeight / 2);
|
|
ctx.quadraticCurveTo(cx - faceWidth / 2, cy + faceHeight / 4, cx - faceWidth / 2, cy - faceHeight / 6);
|
|
ctx.quadraticCurveTo(cx - faceWidth / 2, cy - faceHeight / 2, cx, cy - faceHeight / 3);
|
|
ctx.quadraticCurveTo(cx + faceWidth / 2, cy - faceHeight / 2, cx + faceWidth / 2, cy - faceHeight / 6);
|
|
ctx.quadraticCurveTo(cx + faceWidth / 2, cy + faceHeight / 4, cx, cy + faceHeight / 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'long':
|
|
drawEllipse(ctx, cx, cy, faceWidth / 2 - 2 * scale, faceHeight / 2 + 4 * scale);
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'diamond':
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy - faceHeight / 2);
|
|
ctx.lineTo(cx + faceWidth / 2, cy);
|
|
ctx.lineTo(cx, cy + faceHeight / 2);
|
|
ctx.lineTo(cx - faceWidth / 2, cy);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
break;
|
|
|
|
default:
|
|
drawEllipse(ctx, cx, cy, faceWidth / 2, faceHeight / 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Add subtle shading
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.1;
|
|
ctx.fillStyle = skinShade(skinTone, -30);
|
|
drawEllipse(ctx, cx, cy + 5 * scale, faceWidth / 2 - 5 * scale, faceHeight / 3);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Render ears
|
|
*/
|
|
function renderEars(ctx, avatar, x, y, scale, transforms) {
|
|
var skinTone = avatar.base.skinTone || '#FFD5B8';
|
|
var earSize = 8 * scale;
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
ctx.fillStyle = skinTone;
|
|
|
|
// Left ear
|
|
drawEllipse(ctx, cx - 22 * scale, cy, earSize / 2, earSize);
|
|
ctx.fill();
|
|
|
|
// Right ear
|
|
drawEllipse(ctx, cx + 22 * scale, cy, earSize / 2, earSize);
|
|
ctx.fill();
|
|
|
|
// Inner ear detail
|
|
ctx.fillStyle = skinShade(skinTone, -20);
|
|
drawEllipse(ctx, cx - 22 * scale, cy, earSize / 4, earSize / 2);
|
|
ctx.fill();
|
|
drawEllipse(ctx, cx + 22 * scale, cy, earSize / 4, earSize / 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
/**
|
|
* Render eyes
|
|
*/
|
|
function renderEyes(ctx, avatar, x, y, scale, transforms) {
|
|
var eyes = avatar.base.eyes || {};
|
|
var eyeStyle = window.AvatarData ? window.AvatarData.getEyeStyle(eyes.style) : null;
|
|
var eyeColor = eyes.color || '#4A90D9';
|
|
var styleId = eyeStyle ? eyeStyle.id : eyes.style || 'normal';
|
|
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 28 * scale;
|
|
var eyeSpacing = 12 * scale;
|
|
var eyeWidth = 6 * scale;
|
|
var eyeHeight = 8 * scale;
|
|
|
|
// Apply eye transforms if animating
|
|
ctx.save();
|
|
if (transforms && transforms.eyes) {
|
|
applyTransforms(ctx, transforms, 'eyes');
|
|
}
|
|
|
|
// Draw based on style
|
|
switch (styleId) {
|
|
case 'round':
|
|
eyeWidth = 7 * scale;
|
|
eyeHeight = 7 * scale;
|
|
break;
|
|
case 'narrow':
|
|
eyeWidth = 8 * scale;
|
|
eyeHeight = 5 * scale;
|
|
break;
|
|
case 'wide':
|
|
eyeWidth = 9 * scale;
|
|
eyeHeight = 7 * scale;
|
|
break;
|
|
case 'almond':
|
|
eyeWidth = 8 * scale;
|
|
eyeHeight = 6 * scale;
|
|
break;
|
|
}
|
|
|
|
// Left eye white
|
|
ctx.fillStyle = '#FFFFFF';
|
|
drawEllipse(ctx, cx - eyeSpacing, cy, eyeWidth, eyeHeight);
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#333333';
|
|
ctx.lineWidth = 0.5 * scale;
|
|
ctx.stroke();
|
|
|
|
// Left iris
|
|
ctx.fillStyle = eyeColor;
|
|
drawEllipse(ctx, cx - eyeSpacing, cy + 1 * scale, eyeWidth * 0.6, eyeHeight * 0.7);
|
|
ctx.fill();
|
|
|
|
// Left pupil
|
|
ctx.fillStyle = '#000000';
|
|
drawEllipse(ctx, cx - eyeSpacing, cy + 1 * scale, eyeWidth * 0.3, eyeHeight * 0.4);
|
|
ctx.fill();
|
|
|
|
// Left highlight
|
|
ctx.fillStyle = '#FFFFFF';
|
|
drawEllipse(ctx, cx - eyeSpacing - 1 * scale, cy - 1 * scale, eyeWidth * 0.2, eyeHeight * 0.2);
|
|
ctx.fill();
|
|
|
|
// Right eye white
|
|
ctx.fillStyle = '#FFFFFF';
|
|
drawEllipse(ctx, cx + eyeSpacing, cy, eyeWidth, eyeHeight);
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#333333';
|
|
ctx.lineWidth = 0.5 * scale;
|
|
ctx.stroke();
|
|
|
|
// Right iris
|
|
ctx.fillStyle = eyeColor;
|
|
drawEllipse(ctx, cx + eyeSpacing, cy + 1 * scale, eyeWidth * 0.6, eyeHeight * 0.7);
|
|
ctx.fill();
|
|
|
|
// Right pupil
|
|
ctx.fillStyle = '#000000';
|
|
drawEllipse(ctx, cx + eyeSpacing, cy + 1 * scale, eyeWidth * 0.3, eyeHeight * 0.4);
|
|
ctx.fill();
|
|
|
|
// Right highlight
|
|
ctx.fillStyle = '#FFFFFF';
|
|
drawEllipse(ctx, cx + eyeSpacing - 1 * scale, cy - 1 * scale, eyeWidth * 0.2, eyeHeight * 0.2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Render eyebrows
|
|
*/
|
|
function renderEyebrows(ctx, avatar, x, y, scale) {
|
|
var eyebrows = avatar.base.eyebrows || {};
|
|
var browStyle = window.AvatarData ? window.AvatarData.getEyebrowStyle(eyebrows.style) : null;
|
|
var browColor = eyebrows.color || avatar.base.hair.color || '#3D2314';
|
|
var styleId = browStyle ? browStyle.id : eyebrows.style || 'normal';
|
|
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 20 * scale;
|
|
var browSpacing = 12 * scale;
|
|
var browWidth = 10 * scale;
|
|
|
|
ctx.strokeStyle = browColor;
|
|
ctx.lineWidth = 2 * scale;
|
|
ctx.lineCap = 'round';
|
|
|
|
switch (styleId) {
|
|
case 'thick':
|
|
ctx.lineWidth = 3 * scale;
|
|
break;
|
|
case 'thin':
|
|
ctx.lineWidth = 1.5 * scale;
|
|
break;
|
|
case 'arched':
|
|
// Draw arched brows
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - browSpacing - browWidth / 2, cy + 2 * scale);
|
|
ctx.quadraticCurveTo(cx - browSpacing, cy - 3 * scale, cx - browSpacing + browWidth / 2, cy);
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + browSpacing - browWidth / 2, cy);
|
|
ctx.quadraticCurveTo(cx + browSpacing, cy - 3 * scale, cx + browSpacing + browWidth / 2, cy + 2 * scale);
|
|
ctx.stroke();
|
|
return;
|
|
case 'angry':
|
|
// Draw angled angry brows
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - browSpacing - browWidth / 2, cy - 2 * scale);
|
|
ctx.lineTo(cx - browSpacing + browWidth / 2, cy + 2 * scale);
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + browSpacing - browWidth / 2, cy + 2 * scale);
|
|
ctx.lineTo(cx + browSpacing + browWidth / 2, cy - 2 * scale);
|
|
ctx.stroke();
|
|
return;
|
|
}
|
|
|
|
// Default straight brows
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - browSpacing - browWidth / 2, cy);
|
|
ctx.lineTo(cx - browSpacing + browWidth / 2, cy);
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + browSpacing - browWidth / 2, cy);
|
|
ctx.lineTo(cx + browSpacing + browWidth / 2, cy);
|
|
ctx.stroke();
|
|
}
|
|
|
|
/**
|
|
* Render nose
|
|
*/
|
|
function renderNose(ctx, avatar, x, y, scale) {
|
|
var nose = avatar.base.nose || {};
|
|
var skinTone = avatar.base.skinTone || '#FFD5B8';
|
|
var noseStyle = window.AvatarData ? window.AvatarData.getNoseStyle(nose.style) : null;
|
|
var styleId = noseStyle ? noseStyle.id : nose.style || 'normal';
|
|
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 36 * scale;
|
|
|
|
ctx.fillStyle = skinShade(skinTone, -15);
|
|
ctx.strokeStyle = skinShade(skinTone, -25);
|
|
ctx.lineWidth = 1 * scale;
|
|
|
|
switch (styleId) {
|
|
case 'small':
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, 2 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'large':
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy - 6 * scale);
|
|
ctx.lineTo(cx + 5 * scale, cy + 3 * scale);
|
|
ctx.lineTo(cx - 5 * scale, cy + 3 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
break;
|
|
|
|
case 'pointed':
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy - 5 * scale);
|
|
ctx.lineTo(cx + 3 * scale, cy + 2 * scale);
|
|
ctx.lineTo(cx, cy + 4 * scale);
|
|
ctx.lineTo(cx - 3 * scale, cy + 2 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'button':
|
|
drawEllipse(ctx, cx, cy, 3 * scale, 2 * scale);
|
|
ctx.fill();
|
|
break;
|
|
|
|
default:
|
|
// Normal nose - simple curved line
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 2 * scale, cy - 3 * scale);
|
|
ctx.quadraticCurveTo(cx, cy + 2 * scale, cx + 2 * scale, cy - 3 * scale);
|
|
ctx.stroke();
|
|
|
|
// Nostrils hint
|
|
ctx.beginPath();
|
|
ctx.arc(cx - 2 * scale, cy, 1 * scale, 0, Math.PI);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.arc(cx + 2 * scale, cy, 1 * scale, 0, Math.PI);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render mouth
|
|
*/
|
|
function renderMouth(ctx, avatar, x, y, scale) {
|
|
var mouth = avatar.base.mouth || {};
|
|
var mouthStyle = window.AvatarData ? window.AvatarData.getMouthStyle(mouth.style) : null;
|
|
var styleId = mouthStyle ? mouthStyle.id : mouth.style || 'smile';
|
|
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 46 * scale;
|
|
|
|
ctx.strokeStyle = '#8B4513';
|
|
ctx.fillStyle = '#CC6666';
|
|
ctx.lineWidth = 1.5 * scale;
|
|
ctx.lineCap = 'round';
|
|
|
|
switch (styleId) {
|
|
case 'smile':
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy - 2 * scale, 6 * scale, 0.2 * Math.PI, 0.8 * Math.PI);
|
|
ctx.stroke();
|
|
break;
|
|
|
|
case 'grin':
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy - 4 * scale, 8 * scale, 0.15 * Math.PI, 0.85 * Math.PI);
|
|
ctx.stroke();
|
|
// Teeth
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy - 4 * scale, 7 * scale, 0.2 * Math.PI, 0.8 * Math.PI);
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'neutral':
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 5 * scale, cy);
|
|
ctx.lineTo(cx + 5 * scale, cy);
|
|
ctx.stroke();
|
|
break;
|
|
|
|
case 'frown':
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy + 6 * scale, 6 * scale, 1.2 * Math.PI, 1.8 * Math.PI);
|
|
ctx.stroke();
|
|
break;
|
|
|
|
case 'open':
|
|
ctx.fillStyle = '#4A2020';
|
|
drawEllipse(ctx, cx, cy, 5 * scale, 4 * scale);
|
|
ctx.fill();
|
|
// Tongue hint
|
|
ctx.fillStyle = '#CC6666';
|
|
drawEllipse(ctx, cx, cy + 2 * scale, 3 * scale, 2 * scale);
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'smirk':
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 5 * scale, cy);
|
|
ctx.quadraticCurveTo(cx, cy, cx + 5 * scale, cy - 3 * scale);
|
|
ctx.stroke();
|
|
break;
|
|
|
|
default:
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy - 2 * scale, 5 * scale, 0.2 * Math.PI, 0.8 * Math.PI);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render facial hair
|
|
*/
|
|
function renderFacialHair(ctx, avatar, x, y, scale) {
|
|
var facialHair = avatar.base.facialHair;
|
|
var hairColor = avatar.base.hair ? avatar.base.hair.color : '#3D2314';
|
|
|
|
if (!facialHair || facialHair === 'none') return;
|
|
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 46 * scale;
|
|
|
|
ctx.fillStyle = hairColor;
|
|
ctx.strokeStyle = hairColor;
|
|
ctx.lineWidth = 1 * scale;
|
|
|
|
switch (facialHair) {
|
|
case 'stubble':
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.4;
|
|
for (var i = 0; i < 30; i++) {
|
|
var sx = cx + (Math.random() - 0.5) * 24 * scale;
|
|
var sy = cy + Math.random() * 12 * scale;
|
|
ctx.beginPath();
|
|
ctx.arc(sx, sy, 0.5 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
break;
|
|
|
|
case 'mustache':
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 10 * scale, cy - 2 * scale);
|
|
ctx.quadraticCurveTo(cx - 5 * scale, cy - 5 * scale, cx, cy - 3 * scale);
|
|
ctx.quadraticCurveTo(cx + 5 * scale, cy - 5 * scale, cx + 10 * scale, cy - 2 * scale);
|
|
ctx.quadraticCurveTo(cx + 5 * scale, cy, cx, cy - 1 * scale);
|
|
ctx.quadraticCurveTo(cx - 5 * scale, cy, cx - 10 * scale, cy - 2 * scale);
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'goatee':
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 6 * scale, cy + 2 * scale);
|
|
ctx.quadraticCurveTo(cx, cy + 12 * scale, cx + 6 * scale, cy + 2 * scale);
|
|
ctx.quadraticCurveTo(cx, cy + 4 * scale, cx - 6 * scale, cy + 2 * scale);
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'full-beard':
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 18 * scale, cy - 8 * scale);
|
|
ctx.quadraticCurveTo(cx - 20 * scale, cy + 5 * scale, cx - 10 * scale, cy + 15 * scale);
|
|
ctx.quadraticCurveTo(cx, cy + 20 * scale, cx + 10 * scale, cy + 15 * scale);
|
|
ctx.quadraticCurveTo(cx + 20 * scale, cy + 5 * scale, cx + 18 * scale, cy - 8 * scale);
|
|
ctx.quadraticCurveTo(cx, cy + 2 * scale, cx - 18 * scale, cy - 8 * scale);
|
|
ctx.fill();
|
|
break;
|
|
|
|
case 'sideburns':
|
|
// Left sideburn
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 20 * scale, cy - 20 * scale);
|
|
ctx.lineTo(cx - 22 * scale, cy + 5 * scale);
|
|
ctx.lineTo(cx - 18 * scale, cy + 5 * scale);
|
|
ctx.lineTo(cx - 16 * scale, cy - 20 * scale);
|
|
ctx.fill();
|
|
// Right sideburn
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + 20 * scale, cy - 20 * scale);
|
|
ctx.lineTo(cx + 22 * scale, cy + 5 * scale);
|
|
ctx.lineTo(cx + 18 * scale, cy + 5 * scale);
|
|
ctx.lineTo(cx + 16 * scale, cy - 20 * scale);
|
|
ctx.fill();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// HAIR RENDERING
|
|
// ============================================
|
|
|
|
/**
|
|
* Main hair rendering function
|
|
*/
|
|
function renderHair(ctx, avatar, x, y, scale, layer) {
|
|
var hair = avatar.base.hair || {};
|
|
var hairStyle = window.AvatarData ? window.AvatarData.getHairStyle(hair.style) : null;
|
|
var hairColor = hair.color || '#3D2314';
|
|
var styleId = hairStyle ? hairStyle.id : hair.style || 'short-messy';
|
|
|
|
if (!styleId || styleId === 'bald') return;
|
|
|
|
ctx.fillStyle = hairColor;
|
|
ctx.strokeStyle = adjustBrightness(hairColor, -20);
|
|
ctx.lineWidth = 1 * scale;
|
|
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
// Dispatch to specific hair drawing function
|
|
switch (styleId) {
|
|
case 'short-messy':
|
|
drawShortMessyHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'short-curly':
|
|
drawShortCurlyHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'short-neat':
|
|
drawShortNeatHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'ponytail':
|
|
drawPonytailHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'long-straight':
|
|
drawLongStraightHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'long-wavy':
|
|
drawLongWavyHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'mohawk':
|
|
drawMohawkHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'afro':
|
|
drawAfroHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'buzz':
|
|
drawBuzzHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'pigtails':
|
|
drawPigtailsHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'bob':
|
|
drawBobHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
case 'spiky':
|
|
drawSpikyHair(ctx, x, y, scale, hairColor, layer);
|
|
break;
|
|
default:
|
|
// Default to short messy
|
|
drawShortMessyHair(ctx, x, y, scale, hairColor, layer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Short messy hair style
|
|
*/
|
|
function drawShortMessyHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
if (layer === 'back') return; // No back layer for short hair
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
// Base hair shape
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 20 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx - 22 * scale, cy - 20 * scale, cx - 10 * scale, cy - 22 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 28 * scale, cx + 10 * scale, cy - 22 * scale);
|
|
ctx.quadraticCurveTo(cx + 22 * scale, cy - 20 * scale, cx + 20 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx + 15 * scale, cy - 10 * scale, cx, cy - 12 * scale);
|
|
ctx.quadraticCurveTo(cx - 15 * scale, cy - 10 * scale, cx - 20 * scale, cy - 5 * scale);
|
|
ctx.fill();
|
|
|
|
// Messy tufts
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 8 * scale, cy - 22 * scale);
|
|
ctx.lineTo(cx - 12 * scale, cy - 30 * scale);
|
|
ctx.lineTo(cx - 5 * scale, cy - 24 * scale);
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + 3 * scale, cy - 24 * scale);
|
|
ctx.lineTo(cx + 8 * scale, cy - 32 * scale);
|
|
ctx.lineTo(cx + 12 * scale, cy - 25 * scale);
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + 15 * scale, cy - 20 * scale);
|
|
ctx.lineTo(cx + 22 * scale, cy - 26 * scale);
|
|
ctx.lineTo(cx + 18 * scale, cy - 18 * scale);
|
|
ctx.fill();
|
|
|
|
// Add shine
|
|
addHairShine(ctx, cx - 5 * scale, cy - 22 * scale, 15 * scale, 8 * scale, hairColor);
|
|
}
|
|
|
|
/**
|
|
* Short curly hair style
|
|
*/
|
|
function drawShortCurlyHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
if (layer === 'back') return;
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
// Draw curly texture with multiple small circles
|
|
var curls = [
|
|
{ x: -15, y: -18 }, { x: -8, y: -22 }, { x: 0, y: -24 },
|
|
{ x: 8, y: -22 }, { x: 15, y: -18 }, { x: -18, y: -10 },
|
|
{ x: -12, y: -16 }, { x: -4, y: -20 }, { x: 4, y: -20 },
|
|
{ x: 12, y: -16 }, { x: 18, y: -10 }, { x: -10, y: -12 },
|
|
{ x: 0, y: -16 }, { x: 10, y: -12 }
|
|
];
|
|
|
|
curls.forEach(function(curl) {
|
|
ctx.beginPath();
|
|
ctx.arc(cx + curl.x * scale, cy + curl.y * scale, 6 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
});
|
|
|
|
// Add highlights
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.3;
|
|
ctx.fillStyle = adjustBrightness(hairColor, 40);
|
|
curls.slice(0, 5).forEach(function(curl) {
|
|
ctx.beginPath();
|
|
ctx.arc(cx + curl.x * scale - 1 * scale, cy + curl.y * scale - 1 * scale, 2 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Short neat hair style
|
|
*/
|
|
function drawShortNeatHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
if (layer === 'back') return;
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
// Clean curved shape
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 20 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx - 22 * scale, cy - 18 * scale, cx - 12 * scale, cy - 22 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 26 * scale, cx + 12 * scale, cy - 22 * scale);
|
|
ctx.quadraticCurveTo(cx + 22 * scale, cy - 18 * scale, cx + 20 * scale, cy - 5 * scale);
|
|
ctx.lineTo(cx + 18 * scale, cy - 8 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 14 * scale, cx - 18 * scale, cy - 8 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Part line
|
|
ctx.strokeStyle = adjustBrightness(hairColor, -30);
|
|
ctx.lineWidth = 1 * scale;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 5 * scale, cy - 24 * scale);
|
|
ctx.quadraticCurveTo(cx - 8 * scale, cy - 18 * scale, cx - 15 * scale, cy - 10 * scale);
|
|
ctx.stroke();
|
|
|
|
addHairShine(ctx, cx, cy - 20 * scale, 12 * scale, 6 * scale, hairColor);
|
|
}
|
|
|
|
/**
|
|
* Ponytail hair style
|
|
*/
|
|
function drawPonytailHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
if (layer === 'back') {
|
|
// Ponytail behind head
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy - 10 * scale);
|
|
ctx.quadraticCurveTo(cx + 25 * scale, cy - 5 * scale, cx + 20 * scale, cy + 30 * scale);
|
|
ctx.quadraticCurveTo(cx + 15 * scale, cy + 40 * scale, cx + 10 * scale, cy + 35 * scale);
|
|
ctx.quadraticCurveTo(cx + 5 * scale, cy + 25 * scale, cx + 8 * scale, cy);
|
|
ctx.quadraticCurveTo(cx + 5 * scale, cy - 8 * scale, cx, cy - 10 * scale);
|
|
ctx.fill();
|
|
|
|
// Hair tie
|
|
ctx.fillStyle = '#CC4444';
|
|
ctx.beginPath();
|
|
ctx.arc(cx + 12 * scale, cy - 2 * scale, 3 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
return;
|
|
}
|
|
|
|
// Front hair
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 20 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx - 22 * scale, cy - 18 * scale, cx - 10 * scale, cy - 22 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 26 * scale, cx + 10 * scale, cy - 22 * scale);
|
|
ctx.quadraticCurveTo(cx + 22 * scale, cy - 18 * scale, cx + 20 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx + 10 * scale, cy - 12 * scale, cx, cy - 14 * scale);
|
|
ctx.quadraticCurveTo(cx - 10 * scale, cy - 12 * scale, cx - 20 * scale, cy - 5 * scale);
|
|
ctx.fill();
|
|
|
|
// Side pieces
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 20 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx - 22 * scale, cy + 5 * scale, cx - 18 * scale, cy + 10 * scale);
|
|
ctx.lineTo(cx - 16 * scale, cy + 5 * scale);
|
|
ctx.quadraticCurveTo(cx - 18 * scale, cy, cx - 20 * scale, cy - 5 * scale);
|
|
ctx.fill();
|
|
|
|
addHairShine(ctx, cx - 5 * scale, cy - 20 * scale, 15 * scale, 8 * scale, hairColor);
|
|
}
|
|
|
|
/**
|
|
* Long straight hair style
|
|
*/
|
|
function drawLongStraightHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
if (layer === 'back') {
|
|
// Long hair behind body
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 22 * scale, cy - 10 * scale);
|
|
ctx.lineTo(cx - 25 * scale, cy + 60 * scale);
|
|
ctx.quadraticCurveTo(cx, cy + 70 * scale, cx + 25 * scale, cy + 60 * scale);
|
|
ctx.lineTo(cx + 22 * scale, cy - 10 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 5 * scale, cx - 22 * scale, cy - 10 * scale);
|
|
ctx.fill();
|
|
return;
|
|
}
|
|
|
|
// Front hair cap
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 22 * scale, cy);
|
|
ctx.quadraticCurveTo(cx - 24 * scale, cy - 20 * scale, cx - 12 * scale, cy - 24 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 28 * scale, cx + 12 * scale, cy - 24 * scale);
|
|
ctx.quadraticCurveTo(cx + 24 * scale, cy - 20 * scale, cx + 22 * scale, cy);
|
|
ctx.lineTo(cx + 20 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 16 * scale, cx - 20 * scale, cy - 5 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Side strands
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 22 * scale, cy);
|
|
ctx.lineTo(cx - 24 * scale, cy + 35 * scale);
|
|
ctx.lineTo(cx - 18 * scale, cy + 30 * scale);
|
|
ctx.lineTo(cx - 18 * scale, cy - 5 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + 22 * scale, cy);
|
|
ctx.lineTo(cx + 24 * scale, cy + 35 * scale);
|
|
ctx.lineTo(cx + 18 * scale, cy + 30 * scale);
|
|
ctx.lineTo(cx + 18 * scale, cy - 5 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
addHairShine(ctx, cx - 8 * scale, cy - 18 * scale, 20 * scale, 10 * scale, hairColor);
|
|
}
|
|
|
|
/**
|
|
* Long wavy hair style
|
|
*/
|
|
function drawLongWavyHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
if (layer === 'back') {
|
|
// Wavy back hair
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 22 * scale, cy - 10 * scale);
|
|
ctx.bezierCurveTo(
|
|
cx - 28 * scale, cy + 20 * scale,
|
|
cx - 20 * scale, cy + 40 * scale,
|
|
cx - 22 * scale, cy + 60 * scale
|
|
);
|
|
ctx.quadraticCurveTo(cx, cy + 65 * scale, cx + 22 * scale, cy + 60 * scale);
|
|
ctx.bezierCurveTo(
|
|
cx + 20 * scale, cy + 40 * scale,
|
|
cx + 28 * scale, cy + 20 * scale,
|
|
cx + 22 * scale, cy - 10 * scale
|
|
);
|
|
ctx.quadraticCurveTo(cx, cy - 5 * scale, cx - 22 * scale, cy - 10 * scale);
|
|
ctx.fill();
|
|
return;
|
|
}
|
|
|
|
// Front wavy cap
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 22 * scale, cy);
|
|
ctx.quadraticCurveTo(cx - 24 * scale, cy - 20 * scale, cx - 10 * scale, cy - 24 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 28 * scale, cx + 10 * scale, cy - 24 * scale);
|
|
ctx.quadraticCurveTo(cx + 24 * scale, cy - 20 * scale, cx + 22 * scale, cy);
|
|
ctx.lineTo(cx + 18 * scale, cy - 8 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 16 * scale, cx - 18 * scale, cy - 8 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Wavy side strands
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 22 * scale, cy);
|
|
ctx.bezierCurveTo(
|
|
cx - 26 * scale, cy + 15 * scale,
|
|
cx - 20 * scale, cy + 25 * scale,
|
|
cx - 24 * scale, cy + 40 * scale
|
|
);
|
|
ctx.lineTo(cx - 18 * scale, cy + 35 * scale);
|
|
ctx.bezierCurveTo(
|
|
cx - 16 * scale, cy + 20 * scale,
|
|
cx - 20 * scale, cy + 10 * scale,
|
|
cx - 18 * scale, cy - 5 * scale
|
|
);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + 22 * scale, cy);
|
|
ctx.bezierCurveTo(
|
|
cx + 26 * scale, cy + 15 * scale,
|
|
cx + 20 * scale, cy + 25 * scale,
|
|
cx + 24 * scale, cy + 40 * scale
|
|
);
|
|
ctx.lineTo(cx + 18 * scale, cy + 35 * scale);
|
|
ctx.bezierCurveTo(
|
|
cx + 16 * scale, cy + 20 * scale,
|
|
cx + 20 * scale, cy + 10 * scale,
|
|
cx + 18 * scale, cy - 5 * scale
|
|
);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
addHairShine(ctx, cx - 5 * scale, cy - 18 * scale, 15 * scale, 8 * scale, hairColor);
|
|
}
|
|
|
|
/**
|
|
* Mohawk hair style
|
|
*/
|
|
function drawMohawkHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
if (layer === 'back') return;
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
// Shaved sides (subtle)
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.3;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy - 10 * scale, 22 * scale, Math.PI, 0);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Mohawk strip
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 6 * scale, cy - 5 * scale);
|
|
ctx.lineTo(cx - 8 * scale, cy - 35 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 42 * scale, cx + 8 * scale, cy - 35 * scale);
|
|
ctx.lineTo(cx + 6 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 10 * scale, cx - 6 * scale, cy - 5 * scale);
|
|
ctx.fill();
|
|
|
|
// Spiky details
|
|
var spikes = [
|
|
{ x: -4, y: -30, h: 8 },
|
|
{ x: 0, y: -32, h: 12 },
|
|
{ x: 4, y: -30, h: 8 }
|
|
];
|
|
|
|
spikes.forEach(function(spike) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + (spike.x - 2) * scale, cy + spike.y * scale);
|
|
ctx.lineTo(cx + spike.x * scale, cy + (spike.y - spike.h) * scale);
|
|
ctx.lineTo(cx + (spike.x + 2) * scale, cy + spike.y * scale);
|
|
ctx.fill();
|
|
});
|
|
|
|
addHairShine(ctx, cx - 3 * scale, cy - 28 * scale, 8 * scale, 6 * scale, hairColor);
|
|
}
|
|
|
|
/**
|
|
* Afro hair style
|
|
*/
|
|
function drawAfroHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
if (layer === 'back') {
|
|
// Back of afro
|
|
ctx.fillStyle = hairColor;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy - 5 * scale, 32 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
return;
|
|
}
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
// Main afro shape
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy - 10 * scale, 30 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Texture with small circles
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.3;
|
|
ctx.fillStyle = adjustBrightness(hairColor, -20);
|
|
for (var i = 0; i < 20; i++) {
|
|
var angle = Math.random() * Math.PI * 2;
|
|
var dist = Math.random() * 20 * scale;
|
|
var tx = cx + Math.cos(angle) * dist;
|
|
var ty = cy - 10 * scale + Math.sin(angle) * dist;
|
|
ctx.beginPath();
|
|
ctx.arc(tx, ty, 4 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
|
|
// Cutout for face
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'destination-out';
|
|
drawEllipse(ctx, cx, cy + 5 * scale, 18 * scale, 22 * scale);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
addHairShine(ctx, cx - 10 * scale, cy - 25 * scale, 20 * scale, 12 * scale, hairColor);
|
|
}
|
|
|
|
/**
|
|
* Buzz cut hair style
|
|
*/
|
|
function drawBuzzHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
if (layer === 'back') return;
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.6;
|
|
ctx.fillStyle = hairColor;
|
|
|
|
// Very short hair - just a cap
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy - 8 * scale, 22 * scale, Math.PI, 0);
|
|
ctx.fill();
|
|
|
|
// Texture dots
|
|
ctx.globalAlpha = 0.4;
|
|
for (var i = 0; i < 40; i++) {
|
|
var angle = Math.PI + Math.random() * Math.PI;
|
|
var dist = 10 + Math.random() * 10;
|
|
var bx = cx + Math.cos(angle) * dist * scale;
|
|
var by = cy - 8 * scale + Math.sin(angle) * dist * scale;
|
|
if (by < cy - 2 * scale) {
|
|
ctx.beginPath();
|
|
ctx.arc(bx, by, 0.8 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Pigtails hair style
|
|
*/
|
|
function drawPigtailsHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
if (layer === 'back') {
|
|
// Back portions of pigtails
|
|
// Left pigtail
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 22 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx - 35 * scale, cy + 10 * scale, cx - 30 * scale, cy + 40 * scale);
|
|
ctx.quadraticCurveTo(cx - 25 * scale, cy + 50 * scale, cx - 20 * scale, cy + 40 * scale);
|
|
ctx.quadraticCurveTo(cx - 18 * scale, cy + 15 * scale, cx - 20 * scale, cy - 5 * scale);
|
|
ctx.fill();
|
|
|
|
// Right pigtail
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + 22 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx + 35 * scale, cy + 10 * scale, cx + 30 * scale, cy + 40 * scale);
|
|
ctx.quadraticCurveTo(cx + 25 * scale, cy + 50 * scale, cx + 20 * scale, cy + 40 * scale);
|
|
ctx.quadraticCurveTo(cx + 18 * scale, cy + 15 * scale, cx + 20 * scale, cy - 5 * scale);
|
|
ctx.fill();
|
|
return;
|
|
}
|
|
|
|
// Front hair cap
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 20 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx - 22 * scale, cy - 18 * scale, cx - 10 * scale, cy - 22 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 26 * scale, cx + 10 * scale, cy - 22 * scale);
|
|
ctx.quadraticCurveTo(cx + 22 * scale, cy - 18 * scale, cx + 20 * scale, cy - 5 * scale);
|
|
ctx.quadraticCurveTo(cx + 10 * scale, cy - 12 * scale, cx, cy - 14 * scale);
|
|
ctx.quadraticCurveTo(cx - 10 * scale, cy - 12 * scale, cx - 20 * scale, cy - 5 * scale);
|
|
ctx.fill();
|
|
|
|
// Hair ties
|
|
ctx.fillStyle = '#FF69B4';
|
|
ctx.beginPath();
|
|
ctx.arc(cx - 24 * scale, cy, 3 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.arc(cx + 24 * scale, cy, 3 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
addHairShine(ctx, cx - 5 * scale, cy - 20 * scale, 12 * scale, 6 * scale, hairColor);
|
|
}
|
|
|
|
/**
|
|
* Bob hair style
|
|
*/
|
|
function drawBobHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
if (layer === 'back') {
|
|
ctx.fillStyle = hairColor;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 24 * scale, cy - 15 * scale);
|
|
ctx.lineTo(cx - 26 * scale, cy + 15 * scale);
|
|
ctx.quadraticCurveTo(cx, cy + 20 * scale, cx + 26 * scale, cy + 15 * scale);
|
|
ctx.lineTo(cx + 24 * scale, cy - 15 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 10 * scale, cx - 24 * scale, cy - 15 * scale);
|
|
ctx.fill();
|
|
return;
|
|
}
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
// Front cap
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 22 * scale, cy);
|
|
ctx.quadraticCurveTo(cx - 24 * scale, cy - 20 * scale, cx - 10 * scale, cy - 24 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 28 * scale, cx + 10 * scale, cy - 24 * scale);
|
|
ctx.quadraticCurveTo(cx + 24 * scale, cy - 20 * scale, cx + 22 * scale, cy);
|
|
ctx.lineTo(cx + 18 * scale, cy - 8 * scale);
|
|
ctx.quadraticCurveTo(cx, cy - 16 * scale, cx - 18 * scale, cy - 8 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Side flips
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 22 * scale, cy);
|
|
ctx.quadraticCurveTo(cx - 26 * scale, cy + 10 * scale, cx - 20 * scale, cy + 18 * scale);
|
|
ctx.quadraticCurveTo(cx - 18 * scale, cy + 15 * scale, cx - 18 * scale, cy);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + 22 * scale, cy);
|
|
ctx.quadraticCurveTo(cx + 26 * scale, cy + 10 * scale, cx + 20 * scale, cy + 18 * scale);
|
|
ctx.quadraticCurveTo(cx + 18 * scale, cy + 15 * scale, cx + 18 * scale, cy);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
addHairShine(ctx, cx - 8 * scale, cy - 20 * scale, 18 * scale, 8 * scale, hairColor);
|
|
}
|
|
|
|
/**
|
|
* Spiky hair style
|
|
*/
|
|
function drawSpikyHair(ctx, x, y, scale, hairColor, layer) {
|
|
var cx = x + 32 * scale;
|
|
var cy = y + 32 * scale;
|
|
|
|
if (layer === 'back') return;
|
|
|
|
ctx.fillStyle = hairColor;
|
|
|
|
// Base
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy - 12 * scale, 20 * scale, Math.PI, 0);
|
|
ctx.fill();
|
|
|
|
// Spikes
|
|
var spikes = [
|
|
{ x: -18, y: -12, dx: -8, dy: -20 },
|
|
{ x: -12, y: -18, dx: -4, dy: -28 },
|
|
{ x: -5, y: -20, dx: 0, dy: -32 },
|
|
{ x: 5, y: -20, dx: 5, dy: -30 },
|
|
{ x: 12, y: -18, dx: 12, dy: -26 },
|
|
{ x: 18, y: -12, dx: 20, dy: -18 },
|
|
{ x: -15, y: -8, dx: -22, dy: -12 },
|
|
{ x: 15, y: -8, dx: 24, dy: -10 }
|
|
];
|
|
|
|
spikes.forEach(function(spike) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + spike.x * scale, cy + spike.y * scale);
|
|
ctx.lineTo(cx + spike.dx * scale, cy + spike.dy * scale);
|
|
ctx.lineTo(cx + (spike.x + 4) * scale, cy + spike.y * scale);
|
|
ctx.fill();
|
|
});
|
|
|
|
addHairShine(ctx, cx - 3 * scale, cy - 20 * scale, 10 * scale, 6 * scale, hairColor);
|
|
}
|
|
|
|
/**
|
|
* Render back hair layer
|
|
*/
|
|
function renderHairBack(ctx, avatar, x, y, scale) {
|
|
renderHair(ctx, avatar, x, y, scale, 'back');
|
|
}
|
|
|
|
/**
|
|
* Render front hair layer
|
|
*/
|
|
function renderHairFront(ctx, avatar, x, y, scale) {
|
|
renderHair(ctx, avatar, x, y, scale, 'front');
|
|
}
|
|
|
|
// ============================================
|
|
// BODY RENDERING
|
|
// ============================================
|
|
|
|
/**
|
|
* Render neck
|
|
*/
|
|
function renderNeck(ctx, avatar, x, y, scale) {
|
|
var skinTone = avatar.base.skinTone || '#FFD5B8';
|
|
var cx = x + 32 * scale;
|
|
var neckTop = y + 56 * scale;
|
|
|
|
ctx.fillStyle = skinTone;
|
|
roundRect(ctx, cx - 8 * scale, neckTop, 16 * scale, 12 * scale, 2 * scale);
|
|
ctx.fill();
|
|
|
|
// Neck shadow
|
|
ctx.fillStyle = skinShade(skinTone, -15);
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - 6 * scale, neckTop);
|
|
ctx.lineTo(cx + 6 * scale, neckTop);
|
|
ctx.lineTo(cx + 4 * scale, neckTop + 4 * scale);
|
|
ctx.lineTo(cx - 4 * scale, neckTop + 4 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
|
|
/**
|
|
* Render shoulders
|
|
*/
|
|
function renderShoulders(ctx, avatar, x, y, scale, bodyType, clothing) {
|
|
var cx = x + 32 * scale;
|
|
var shoulderY = y + 64 * scale;
|
|
var clothingColor = clothing ? clothing.color : '#3366CC';
|
|
var clothingStyle = clothing ? clothing.style : 'casual';
|
|
|
|
var shoulderWidth = 48 * scale;
|
|
if (bodyType && bodyType.id === 'large') shoulderWidth = 56 * scale;
|
|
if (bodyType && bodyType.id === 'slim') shoulderWidth = 42 * scale;
|
|
|
|
ctx.fillStyle = clothingColor;
|
|
roundRect(ctx, cx - shoulderWidth / 2, shoulderY, shoulderWidth, 20 * scale, { tl: 8, tr: 8, bl: 2, br: 2 });
|
|
ctx.fill();
|
|
|
|
// Add style details
|
|
renderClothingDetails(ctx, clothingStyle, clothingColor, cx - shoulderWidth / 2, shoulderY, shoulderWidth, 20 * scale, scale);
|
|
}
|
|
|
|
/**
|
|
* Render torso
|
|
*/
|
|
function renderTorso(ctx, avatar, x, y, scale, bodyType, clothing, transforms) {
|
|
var cx = x + 32 * scale;
|
|
var torsoY = y + 84 * scale;
|
|
var clothingColor = clothing ? clothing.color : '#3366CC';
|
|
var clothingStyle = clothing ? clothing.style : 'casual';
|
|
|
|
var torsoWidth = 44 * scale;
|
|
var torsoHeight = 40 * scale;
|
|
if (bodyType && bodyType.id === 'large') {
|
|
torsoWidth = 52 * scale;
|
|
torsoHeight = 44 * scale;
|
|
}
|
|
if (bodyType && bodyType.id === 'slim') {
|
|
torsoWidth = 38 * scale;
|
|
torsoHeight = 38 * scale;
|
|
}
|
|
|
|
ctx.save();
|
|
if (transforms && transforms.torso) {
|
|
ctx.translate(cx, torsoY);
|
|
applyTransforms(ctx, transforms, 'torso');
|
|
ctx.translate(-cx, -torsoY);
|
|
}
|
|
|
|
ctx.fillStyle = clothingColor;
|
|
roundRect(ctx, cx - torsoWidth / 2, torsoY, torsoWidth, torsoHeight, 4 * scale);
|
|
ctx.fill();
|
|
|
|
// Clothing details
|
|
renderClothingDetails(ctx, clothingStyle, clothingColor, cx - torsoWidth / 2, torsoY, torsoWidth, torsoHeight, scale);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Render arms
|
|
*/
|
|
function renderArms(ctx, avatar, x, y, scale, bodyType, clothing, transforms) {
|
|
var skinTone = avatar.base.skinTone || '#FFD5B8';
|
|
var cx = x + 32 * scale;
|
|
var armY = y + 68 * scale;
|
|
var clothingColor = clothing ? clothing.color : '#3366CC';
|
|
|
|
var armWidth = 10 * scale;
|
|
var armLength = 45 * scale;
|
|
var armOffset = 26 * scale;
|
|
|
|
if (bodyType && bodyType.id === 'large') {
|
|
armWidth = 12 * scale;
|
|
armOffset = 30 * scale;
|
|
}
|
|
if (bodyType && bodyType.id === 'slim') {
|
|
armWidth = 8 * scale;
|
|
armOffset = 22 * scale;
|
|
}
|
|
|
|
// Left arm
|
|
ctx.save();
|
|
if (transforms && transforms.leftArm) {
|
|
ctx.translate(cx - armOffset, armY);
|
|
applyTransforms(ctx, transforms, 'leftArm');
|
|
ctx.translate(-(cx - armOffset), -armY);
|
|
}
|
|
|
|
// Sleeve
|
|
ctx.fillStyle = clothingColor;
|
|
roundRect(ctx, cx - armOffset - armWidth / 2, armY, armWidth, armLength * 0.5, 3 * scale);
|
|
ctx.fill();
|
|
|
|
// Skin part
|
|
ctx.fillStyle = skinTone;
|
|
roundRect(ctx, cx - armOffset - armWidth / 2 + 1 * scale, armY + armLength * 0.45, armWidth - 2 * scale, armLength * 0.55, 3 * scale);
|
|
ctx.fill();
|
|
|
|
// Hand
|
|
ctx.beginPath();
|
|
ctx.arc(cx - armOffset, armY + armLength, armWidth / 2 + 1 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Right arm
|
|
ctx.save();
|
|
if (transforms && transforms.rightArm) {
|
|
ctx.translate(cx + armOffset, armY);
|
|
applyTransforms(ctx, transforms, 'rightArm');
|
|
ctx.translate(-(cx + armOffset), -armY);
|
|
}
|
|
|
|
// Sleeve
|
|
ctx.fillStyle = clothingColor;
|
|
roundRect(ctx, cx + armOffset - armWidth / 2, armY, armWidth, armLength * 0.5, 3 * scale);
|
|
ctx.fill();
|
|
|
|
// Skin part
|
|
ctx.fillStyle = skinTone;
|
|
roundRect(ctx, cx + armOffset - armWidth / 2 + 1 * scale, armY + armLength * 0.45, armWidth - 2 * scale, armLength * 0.55, 3 * scale);
|
|
ctx.fill();
|
|
|
|
// Hand
|
|
ctx.beginPath();
|
|
ctx.arc(cx + armOffset, armY + armLength, armWidth / 2 + 1 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Render legs
|
|
*/
|
|
function renderLegs(ctx, avatar, x, y, scale, bodyType, clothing, transforms) {
|
|
var cx = x + 32 * scale;
|
|
var legY = y + 124 * scale;
|
|
var pantsColor = clothing && clothing.pantsColor ? clothing.pantsColor : '#2D2D2D';
|
|
var shoeColor = clothing && clothing.shoeColor ? clothing.shoeColor : '#4A4A4A';
|
|
|
|
var legWidth = 12 * scale;
|
|
var legLength = 32 * scale;
|
|
var legSpacing = 6 * scale;
|
|
|
|
if (bodyType && bodyType.id === 'large') {
|
|
legWidth = 14 * scale;
|
|
}
|
|
if (bodyType && bodyType.id === 'slim') {
|
|
legWidth = 10 * scale;
|
|
}
|
|
|
|
// Left leg
|
|
ctx.save();
|
|
if (transforms && transforms.leftLeg) {
|
|
ctx.translate(cx - legSpacing - legWidth / 2, legY);
|
|
applyTransforms(ctx, transforms, 'leftLeg');
|
|
ctx.translate(-(cx - legSpacing - legWidth / 2), -legY);
|
|
}
|
|
|
|
ctx.fillStyle = pantsColor;
|
|
roundRect(ctx, cx - legSpacing - legWidth, legY, legWidth, legLength, 3 * scale);
|
|
ctx.fill();
|
|
|
|
// Left shoe
|
|
ctx.fillStyle = shoeColor;
|
|
roundRect(ctx, cx - legSpacing - legWidth - 2 * scale, legY + legLength - 4 * scale, legWidth + 4 * scale, 8 * scale, 2 * scale);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Right leg
|
|
ctx.save();
|
|
if (transforms && transforms.rightLeg) {
|
|
ctx.translate(cx + legSpacing + legWidth / 2, legY);
|
|
applyTransforms(ctx, transforms, 'rightLeg');
|
|
ctx.translate(-(cx + legSpacing + legWidth / 2), -legY);
|
|
}
|
|
|
|
ctx.fillStyle = pantsColor;
|
|
roundRect(ctx, cx + legSpacing, legY, legWidth, legLength, 3 * scale);
|
|
ctx.fill();
|
|
|
|
// Right shoe
|
|
ctx.fillStyle = shoeColor;
|
|
roundRect(ctx, cx + legSpacing - 2 * scale, legY + legLength - 4 * scale, legWidth + 4 * scale, 8 * scale, 2 * scale);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Render clothing details based on style
|
|
*/
|
|
function renderClothingDetails(ctx, style, baseColor, x, y, w, h, scale) {
|
|
var styleData = window.AvatarData ? window.AvatarData.getClothingStyle(style) : null;
|
|
var styleId = styleData ? styleData.id : style || 'casual';
|
|
|
|
switch (styleId) {
|
|
case 'sporty':
|
|
// Sport stripes
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.globalAlpha = 0.7;
|
|
roundRect(ctx, x + 2 * scale, y + h * 0.3, 3 * scale, h * 0.5, 1 * scale);
|
|
ctx.fill();
|
|
roundRect(ctx, x + w - 5 * scale, y + h * 0.3, 3 * scale, h * 0.5, 1 * scale);
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
break;
|
|
|
|
case 'formal':
|
|
// Collar
|
|
ctx.fillStyle = '#FFFFFF';
|
|
var collarY = y - 2 * scale;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + w / 2 - 8 * scale, collarY);
|
|
ctx.lineTo(x + w / 2 - 12 * scale, collarY + 10 * scale);
|
|
ctx.lineTo(x + w / 2 - 4 * scale, collarY + 8 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + w / 2 + 8 * scale, collarY);
|
|
ctx.lineTo(x + w / 2 + 12 * scale, collarY + 10 * scale);
|
|
ctx.lineTo(x + w / 2 + 4 * scale, collarY + 8 * scale);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Buttons
|
|
ctx.fillStyle = '#DDDDDD';
|
|
for (var i = 0; i < 3; i++) {
|
|
ctx.beginPath();
|
|
ctx.arc(x + w / 2, y + 12 * scale + i * 10 * scale, 2 * scale, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
break;
|
|
|
|
case 'scifi':
|
|
// Glowing trim lines
|
|
ctx.save();
|
|
ctx.strokeStyle = '#00FFFF';
|
|
ctx.lineWidth = 1.5 * scale;
|
|
ctx.shadowColor = '#00FFFF';
|
|
ctx.shadowBlur = 5 * scale;
|
|
|
|
// Horizontal line
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + 4 * scale, y + h * 0.4);
|
|
ctx.lineTo(x + w - 4 * scale, y + h * 0.4);
|
|
ctx.stroke();
|
|
|
|
// Diagonal accents
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + 4 * scale, y + h * 0.6);
|
|
ctx.lineTo(x + w * 0.3, y + h * 0.8);
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + w - 4 * scale, y + h * 0.6);
|
|
ctx.lineTo(x + w * 0.7, y + h * 0.8);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
break;
|
|
|
|
case 'casual':
|
|
default:
|
|
// Simple neckline
|
|
ctx.strokeStyle = adjustBrightness(baseColor, -30);
|
|
ctx.lineWidth = 1 * scale;
|
|
ctx.beginPath();
|
|
ctx.arc(x + w / 2, y - 2 * scale, 8 * scale, 0.1 * Math.PI, 0.9 * Math.PI);
|
|
ctx.stroke();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// HEAD COMPOSITE RENDERING
|
|
// ============================================
|
|
|
|
/**
|
|
* Render complete head
|
|
*/
|
|
function renderHead(ctx, avatar, x, y, scale, transforms) {
|
|
// Ears (behind head)
|
|
renderEars(ctx, avatar, x, y, scale, transforms);
|
|
|
|
// Face base
|
|
renderFaceBase(ctx, avatar, x, y, scale);
|
|
|
|
// Face features
|
|
renderEyes(ctx, avatar, x, y, scale, transforms);
|
|
renderEyebrows(ctx, avatar, x, y, scale);
|
|
renderNose(ctx, avatar, x, y, scale);
|
|
renderMouth(ctx, avatar, x, y, scale);
|
|
|
|
// Facial hair
|
|
if (avatar.base && avatar.base.facialHair && avatar.base.facialHair !== 'none') {
|
|
renderFacialHair(ctx, avatar, x, y, scale);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// BODY COMPOSITE RENDERING
|
|
// ============================================
|
|
|
|
/**
|
|
* Render body based on mode
|
|
*/
|
|
function renderBody(ctx, avatar, x, y, scale, mode, transforms) {
|
|
var bodyType = window.AvatarData ? window.AvatarData.getBodyType(avatar.base.body ? avatar.base.body.type : 'average') : null;
|
|
var clothing = avatar.base.clothing || {};
|
|
|
|
if (mode === 'HEAD_AND_SHOULDERS' || mode === 'UPPER_BODY' || mode === 'FULL_BODY') {
|
|
renderNeck(ctx, avatar, x, y, scale);
|
|
renderShoulders(ctx, avatar, x, y, scale, bodyType, clothing);
|
|
}
|
|
|
|
if (mode === 'UPPER_BODY' || mode === 'FULL_BODY') {
|
|
renderTorso(ctx, avatar, x, y, scale, bodyType, clothing, transforms);
|
|
renderArms(ctx, avatar, x, y, scale, bodyType, clothing, transforms);
|
|
}
|
|
|
|
if (mode === 'FULL_BODY') {
|
|
renderLegs(ctx, avatar, x, y, scale, bodyType, clothing, transforms);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// TRANSFORM HELPERS
|
|
// ============================================
|
|
|
|
/**
|
|
* Apply animation transforms to context
|
|
*/
|
|
function applyTransforms(ctx, transforms, partName) {
|
|
if (!transforms || !transforms[partName]) return;
|
|
var t = transforms[partName];
|
|
|
|
if (t.x || t.y) {
|
|
ctx.translate(t.x || 0, t.y || 0);
|
|
}
|
|
if (t.rotation) {
|
|
ctx.rotate(t.rotation * Math.PI / 180);
|
|
}
|
|
if (t.scaleX !== undefined || t.scaleY !== undefined) {
|
|
ctx.scale(t.scaleX !== undefined ? t.scaleX : 1, t.scaleY !== undefined ? t.scaleY : 1);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// MAIN RENDER FUNCTION
|
|
// ============================================
|
|
|
|
/**
|
|
* Main render function
|
|
*/
|
|
function render(ctx, avatar, mode, x, y, options) {
|
|
options = options || {};
|
|
var scale = options.scale || 1;
|
|
var transforms = options.transforms || {};
|
|
var modeData = MODES[mode] || MODES.HEAD_ONLY;
|
|
|
|
// Validate avatar data
|
|
if (!avatar || !avatar.base) {
|
|
console.warn('AvatarRenderer: Invalid avatar data');
|
|
return;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.scale(scale, scale);
|
|
|
|
// 1. Back hair layer (for long hair behind body)
|
|
if (mode !== 'HEAD_ONLY') {
|
|
renderHairBack(ctx, avatar, 0, 0, 1);
|
|
}
|
|
|
|
// 2. Body (if applicable)
|
|
if (mode !== 'HEAD_ONLY') {
|
|
renderBody(ctx, avatar, 0, 0, 1, mode, transforms);
|
|
}
|
|
|
|
// 3. Head
|
|
ctx.save();
|
|
applyTransforms(ctx, transforms, 'head');
|
|
renderHead(ctx, avatar, 0, 0, 1, transforms);
|
|
ctx.restore();
|
|
|
|
// 4. Front hair layer
|
|
renderHairFront(ctx, avatar, 0, 0, 1);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// ============================================
|
|
// PUBLIC API
|
|
// ============================================
|
|
|
|
window.AvatarRenderer = {
|
|
MODES: MODES,
|
|
|
|
/**
|
|
* Render an avatar to a canvas context
|
|
*/
|
|
render: render,
|
|
|
|
/**
|
|
* Render just the head
|
|
*/
|
|
renderHead: function(ctx, avatar, x, y, scale) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.scale(scale || 1, scale || 1);
|
|
renderHead(ctx, avatar, 0, 0, 1, {});
|
|
renderHairFront(ctx, avatar, 0, 0, 1);
|
|
ctx.restore();
|
|
},
|
|
|
|
/**
|
|
* Render just the body
|
|
*/
|
|
renderBody: function(ctx, avatar, x, y, scale, mode) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.scale(scale || 1, scale || 1);
|
|
renderBody(ctx, avatar, 0, 0, 1, mode || 'FULL_BODY', {});
|
|
ctx.restore();
|
|
},
|
|
|
|
/**
|
|
* Get dimensions for a rendering mode
|
|
*/
|
|
getModeSize: function(mode) {
|
|
return MODES[mode] || MODES.HEAD_ONLY;
|
|
},
|
|
|
|
/**
|
|
* Create an offscreen canvas with rendered avatar (for caching)
|
|
*/
|
|
createSprite: function(avatar, mode, scale) {
|
|
scale = scale || 1;
|
|
var size = MODES[mode] || MODES.HEAD_ONLY;
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = size.width * scale;
|
|
canvas.height = size.height * scale;
|
|
var ctx = canvas.getContext('2d');
|
|
this.render(ctx, avatar, mode, 0, 0, { scale: scale });
|
|
return canvas;
|
|
},
|
|
|
|
/**
|
|
* Batch render multiple avatars for performance
|
|
*/
|
|
renderBatch: function(ctx, avatars, mode, positions) {
|
|
if (!avatars || !positions || avatars.length !== positions.length) {
|
|
console.warn('AvatarRenderer.renderBatch: Invalid parameters');
|
|
return;
|
|
}
|
|
|
|
var canvasWidth = ctx.canvas.width;
|
|
var canvasHeight = ctx.canvas.height;
|
|
var modeSize = MODES[mode] || MODES.HEAD_ONLY;
|
|
|
|
for (var i = 0; i < avatars.length; i++) {
|
|
var pos = positions[i];
|
|
|
|
// Skip off-screen avatars
|
|
if (pos.x + modeSize.width < 0 || pos.x > canvasWidth ||
|
|
pos.y + modeSize.height < 0 || pos.y > canvasHeight) {
|
|
continue;
|
|
}
|
|
|
|
this.render(ctx, avatars[i], mode, pos.x, pos.y, pos.options || {});
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Pre-render avatars to sprite cache for performance
|
|
*/
|
|
createSpriteCache: function(avatars, mode, scale) {
|
|
var cache = {};
|
|
scale = scale || 1;
|
|
|
|
for (var i = 0; i < avatars.length; i++) {
|
|
var avatar = avatars[i];
|
|
if (avatar && avatar.id) {
|
|
cache[avatar.id] = this.createSprite(avatar, mode, scale);
|
|
}
|
|
}
|
|
|
|
return cache;
|
|
},
|
|
|
|
/**
|
|
* Render from cached sprite
|
|
*/
|
|
renderFromCache: function(ctx, cache, avatarId, x, y) {
|
|
var sprite = cache[avatarId];
|
|
if (sprite) {
|
|
ctx.drawImage(sprite, x, y);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
// Expose helper functions for custom rendering
|
|
helpers: {
|
|
roundRect: roundRect,
|
|
drawCurve: drawCurve,
|
|
drawEllipse: drawEllipse,
|
|
gradientFill: gradientFill,
|
|
skinShade: skinShade,
|
|
adjustBrightness: adjustBrightness,
|
|
addHairShine: addHairShine,
|
|
applyTransforms: applyTransforms
|
|
}
|
|
};
|
|
})();
|