teach/public/js/avatar.js

727 lines
55 KiB
JavaScript

/* teach - personage (avatar): eigen figuur samenstellen (soort, kleuren, haar,
accessoires), gedeeld tussen de Instellingen-sectie "Mijn personage" en het
avatar-widget. Getekend als één SVG rond vaste anatomische ankers; de tekenvolgorde =
laagvolgorde - geen z-index-gepuzzel zoals bij de eerdere CSS-laagjesversie)
zodat elk onderdeel z'n vaste plek op het silhouet overlapt in plaats van
los ernaast te zweven. */
"use strict";
const AVATAR_SPECIES = ["dino", "monster", "animal"];
const AVATAR_VARIANTS = Object.freeze({
dino: ["rex", "triceratops", "stegosaurus", "raptor"],
monster: ["horned", "cyclops", "slime", "alien"],
animal: ["cat", "dog", "rabbit", "bear"],
});
const AVATAR_HAIR = ["none", "tuft", "spiky", "curly", "wavy", "bob", "ponytail", "braids", "mohawk"];
const AVATAR_HEAD_ACC = ["none", "cap", "beanie", "crown", "wizardhat", "glasses", "sunglasses", "eyepatch", "bow", "flower", "headphones", "partyhat"];
const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge", "necklace", "bowtie", "belt", "backpack", "satchel", "wings"];
const AVATAR_DANCES = Object.freeze(["bounce", "twist", "spin", "shuffle", "wiggle"]);
const AVATAR_DEFAULT = Object.freeze({
species: "dino", speciesVariant: "rex", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29",
accessoryHead: "none", accessoryBody: "none",
});
const AVATAR_SVG_NS = "http://www.w3.org/2000/svg";
function avatarSanitize(config){
const c = config || {};
const species = AVATAR_SPECIES.includes(c.species) ? c.species : AVATAR_DEFAULT.species;
const variants = AVATAR_VARIANTS[species];
return {
species,
speciesVariant: variants.includes(c.speciesVariant) ? c.speciesVariant : variants[0],
bodyColor: /^#[0-9a-fA-F]{6}$/.test(c.bodyColor||"") ? c.bodyColor : AVATAR_DEFAULT.bodyColor,
hairStyle: AVATAR_HAIR.includes(c.hairStyle) ? c.hairStyle : AVATAR_DEFAULT.hairStyle,
hairColor: /^#[0-9a-fA-F]{6}$/.test(c.hairColor||"") ? c.hairColor : AVATAR_DEFAULT.hairColor,
accessoryHead: AVATAR_HEAD_ACC.includes(c.accessoryHead) ? c.accessoryHead : AVATAR_DEFAULT.accessoryHead,
accessoryBody: AVATAR_BODY_ACC.includes(c.accessoryBody) ? c.accessoryBody : AVATAR_DEFAULT.accessoryBody,
};
}
/* mengt een hex-kleur amt (-1..1) richting zwart (negatief) of wit (positief) -
gebruikt voor buik/snuit-highlights en pootjes/staart-schaduw, zodat die
altijd bij de gekozen lichaamskleur passen zonder een aparte kleurkiezer. */
function avatarShade(hex, amt){
const n = parseInt(hex.slice(1), 16);
let r = (n>>16)&255, g = (n>>8)&255, b = n&255;
if(amt < 0){ r *= 1+amt; g *= 1+amt; b *= 1+amt; }
else{ r += (255-r)*amt; g += (255-g)*amt; b += (255-b)*amt; }
const clamp = v => Math.max(0, Math.min(255, Math.round(v)));
return "#" + [r,g,b].map(v=>clamp(v).toString(16).padStart(2,"0")).join("");
}
/* Groot hoofd en compact, soortspecifiek lijf. De kop beslaat circa twee
derde van de figuurbreedte; romp en ledematen blijven kort en sluiten onder
de wangen aan. Alle soorten delen dezelfde accessoire-ankers rond hals y=61,
borst y=72 en middel y=87. */
function avatarBodyMarkup(species, bodyColor, speciesVariant){
const variants = AVATAR_VARIANTS[species] || AVATAR_VARIANTS[AVATAR_DEFAULT.species];
const variant = variants.includes(speciesVariant) ? speciesVariant : variants[0];
const outline = avatarShade(bodyColor, -0.48);
const dark = avatarShade(bodyColor, -0.24);
const light = avatarShade(bodyColor, 0.4);
const highlight = avatarShade(bodyColor, 0.72);
let m = `<g class="av-creature av-creature-${species} av-creature-${variant}">`;
/* Silhouetdetails achter kop en lijf: iedere variant is direct herkenbaar. */
if(species === "dino"){
if(variant === "rex"){
m += `<path class="av-part av-tail av-tail-rex" d="M 39 79 C 25 83 14 81 3 72 C 10 88 24 94 43 87 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
}else if(variant === "triceratops"){
m += `<path class="av-part av-tail av-tail-triceratops" d="M 38 82 C 29 83 22 88 16 85 C 22 92 31 94 42 89 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4"/>`;
m += `<path class="av-part av-frill av-frill-triceratops" d="M 18 45 C 8 41 7 32 13 25 C 7 18 12 8 22 9 C 25 0 36 -4 45 2 C 53 -5 65 -2 69 7 C 80 4 89 12 85 22 C 93 29 91 39 82 45 Z" fill="${dark}" stroke="${outline}" stroke-width="1.5" stroke-linejoin="round"/>`;
}else if(variant === "stegosaurus"){
m += `<path class="av-part av-tail av-tail-stegosaurus" d="M 40 79 C 25 81 13 76 2 68 C 11 85 25 91 43 87 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4"/>`;
m += `<path class="av-part av-dino-plates" d="M 17 31 L 7 22 L 20 20 L 13 8 L 28 12 L 28 -1 L 40 7 L 46 -5 L 54 6 L 66 -3 L 67 10 L 82 4 L 78 17 L 93 17 L 83 29 L 94 36 L 81 40 Z" fill="${light}" stroke="${outline}" stroke-width="1.3" stroke-linejoin="round"/>`;
m += `<path d="M 13 73 L 7 66 L 18 67 M 23 80 L 17 71 L 29 74" fill="${light}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round"/>`;
}else{
m += `<path class="av-part av-tail av-tail-raptor" d="M 40 80 C 25 78 12 69 -2 57 C 8 76 22 89 43 88 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4"/>`;
m += `<path class="av-part av-feather-crest av-feather-crest-raptor" d="M 71 13 C 78 3 87 -1 94 1 C 88 7 85 13 82 19 C 90 14 97 15 101 19 C 92 22 86 27 81 33 Z" fill="${light}" stroke="${outline}" stroke-width="1.3" stroke-linejoin="round"/>`;
}
}else if(species === "monster"){
if(variant === "horned"){
m += `<path class="av-part av-horn av-horn-left" d="M 28 15 C 19 8 21 -3 28 -10 C 28 2 34 7 40 13 Z" fill="${outline}"/>`;
m += `<path class="av-part av-horn av-horn-right" d="M 72 15 C 81 8 79 -3 72 -10 C 72 2 66 7 60 13 Z" fill="${outline}"/>`;
}else if(variant === "cyclops"){
m += `<path class="av-part av-cyclops-crest" d="M 41 4 L 50 -12 L 59 4 C 53 1 47 1 41 4 Z" fill="${dark}" stroke="${outline}" stroke-width="1.3"/>`;
}else if(variant === "slime"){
m += `<circle class="av-part av-slime-drop" cx="24" cy="83" r="7" fill="${dark}" stroke="${outline}" stroke-width="1.2"/><circle class="av-part av-slime-drop" cx="76" cy="86" r="6" fill="${dark}" stroke="${outline}" stroke-width="1.2"/>`;
}else{
m += `<path class="av-part av-antenna" d="M 34 12 Q 29 -2 23 -8 M 66 12 Q 71 -2 77 -8" fill="none" stroke="${outline}" stroke-width="2.4" stroke-linecap="round"/>`;
m += `<circle class="av-part av-antenna-tip" cx="22" cy="-9" r="4" fill="${light}" stroke="${outline}" stroke-width="1.2"/><circle class="av-part av-antenna-tip" cx="78" cy="-9" r="4" fill="${light}" stroke="${outline}" stroke-width="1.2"/>`;
}
}else{
if(variant === "cat"){
m += `<path class="av-part av-tail av-tail-cat" d="M 65 82 C 83 86 91 76 84 67 C 81 63 76 67 79 71 C 82 75 76 79 68 76 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4" stroke-linecap="round"/>`;
m += `<path class="av-part av-ear av-ear-left av-ear-cat" d="M 30 15 L 18 -6 L 39 5 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5" stroke-linejoin="round"/><path d="M 29 9 L 21 -2 L 35 5 Z" fill="${light}"/>`;
m += `<path class="av-part av-ear av-ear-right av-ear-cat" d="M 70 15 L 82 -6 L 61 5 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5" stroke-linejoin="round"/><path d="M 71 9 L 79 -2 L 65 5 Z" fill="${light}"/>`;
}else if(variant === "dog"){
m += `<path class="av-part av-tail av-tail-dog" d="M 64 80 C 78 82 86 74 86 65 C 92 72 87 89 67 88 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4"/>`;
m += `<path class="av-part av-ear av-ear-left av-ear-dog" d="M 31 13 C 18 2 8 8 12 23 C 14 35 22 39 29 30 L 38 14 Z" fill="${dark}" stroke="${outline}" stroke-width="1.5"/>`;
m += `<path class="av-part av-ear av-ear-right av-ear-dog" d="M 69 13 C 82 2 92 8 88 23 C 86 35 78 39 71 30 L 62 14 Z" fill="${dark}" stroke="${outline}" stroke-width="1.5"/>`;
}else if(variant === "rabbit"){
m += `<circle class="av-part av-tail av-tail-rabbit" cx="72" cy="83" r="8" fill="${highlight}" stroke="${outline}" stroke-width="1.3"/>`;
m += `<path class="av-part av-ear av-ear-left av-ear-rabbit" d="M 30 15 C 21 5 18 -13 26 -19 C 36 -18 39 2 38 16 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5"/><path d="M 29 10 C 25 1 24 -11 27 -14 C 32 -10 34 2 34 12 Z" fill="${light}"/>`;
m += `<path class="av-part av-ear av-ear-right av-ear-rabbit" d="M 70 15 C 79 5 82 -13 74 -19 C 64 -18 61 2 62 16 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5"/><path d="M 71 10 C 75 1 76 -11 73 -14 C 68 -10 66 2 66 12 Z" fill="${light}"/>`;
}else{
m += `<circle class="av-part av-tail av-tail-bear" cx="70" cy="84" r="6" fill="${dark}" stroke="${outline}" stroke-width="1.2"/>`;
m += `<circle class="av-part av-ear av-ear-left av-ear-bear" cx="24" cy="9" r="11" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5"/><circle cx="24" cy="9" r="5.5" fill="${light}"/>`;
m += `<circle class="av-part av-ear av-ear-right av-ear-bear" cx="76" cy="9" r="11" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5"/><circle cx="76" cy="9" r="5.5" fill="${light}"/>`;
}
}
/* Korte ledematen blijven op gedeelde ankers voor alle accessoires. */
if(species === "dino"){
m += `<path class="av-part av-leg av-leg-left" d="M 38 84 C 35 90 35 98 35 100 C 29 101 27 104 30 106 C 35 108 42 106 43 102 L 45 86 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
m += `<path class="av-part av-leg av-leg-right" d="M 62 84 C 65 90 65 98 65 100 C 71 101 73 104 70 106 C 65 108 58 106 57 102 L 55 86 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
if(variant === "triceratops"){
m += `<path class="av-part av-arm av-arm-left av-foreleg" d="M 39 64 C 31 65 29 74 29 82 C 26 85 29 88 34 86 C 37 82 35 73 40 72 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4"/>`;
m += `<path class="av-part av-arm av-arm-right av-foreleg" d="M 61 64 C 69 65 71 74 71 82 C 74 85 71 88 66 86 C 63 82 65 73 60 72 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4"/>`;
}else if(variant === "raptor"){
m += `<path class="av-part av-arm av-arm-left av-raptor-arm" d="M 39 65 C 31 63 28 68 25 74 L 20 76 L 26 79 L 31 75 C 34 73 36 75 39 77 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
m += `<path class="av-part av-arm av-arm-right av-raptor-arm" d="M 61 65 C 69 63 72 68 75 74 L 80 76 L 74 79 L 69 75 C 66 73 64 75 61 77 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
}else{
m += `<path class="av-part av-arm av-arm-left" d="M 39 65 C 33 63 29 67 26 72 C 23 73 21 76 23 79 C 26 81 30 78 31 75 C 34 73 36 75 39 77 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
m += `<path class="av-part av-arm av-arm-right" d="M 61 65 C 67 63 71 67 74 72 C 77 73 79 76 77 79 C 74 81 70 78 69 75 C 66 73 64 75 61 77 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
}
}else if(species === "monster"){
m += `<path class="av-part av-leg av-leg-left" d="M 36 84 C 32 91 32 98 28 102 C 27 105 34 108 42 104 L 44 86 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
m += `<path class="av-part av-leg av-leg-right" d="M 64 84 C 68 91 68 98 72 102 C 73 105 66 108 58 104 L 56 86 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
m += `<path class="av-part av-arm av-arm-left" d="M 36 64 C 28 61 23 67 19 74 C 14 76 14 82 18 83 C 23 84 26 78 29 74 C 32 72 34 75 37 78 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
m += `<path class="av-part av-arm av-arm-right" d="M 64 64 C 72 61 77 67 81 74 C 86 76 86 82 82 83 C 77 84 74 78 71 74 C 68 72 66 75 63 78 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`;
}else{
m += `<path class="av-part av-leg av-leg-left" d="M 38 84 C 35 91 35 98 33 101 C 31 104 35 107 41 106 C 45 104 44 94 45 86 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4"/>`;
m += `<path class="av-part av-leg av-leg-right" d="M 62 84 C 65 91 65 98 67 101 C 69 104 65 107 59 106 C 55 104 56 94 55 86 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4"/>`;
m += `<path class="av-part av-arm av-arm-left" d="M 38 65 C 32 63 27 68 25 75 C 22 78 24 82 28 81 C 32 79 32 73 38 76 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4"/>`;
m += `<path class="av-part av-arm av-arm-right" d="M 62 65 C 68 63 73 68 75 75 C 78 78 76 82 72 81 C 68 79 68 73 62 76 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4"/>`;
}
m += `<path class="av-part av-neck" d="M 42 52 C 43 59 40 62 37 64 L 63 64 C 60 62 57 59 58 52 Z" fill="${dark}" stroke="${outline}" stroke-width="1.3"/>`;
const torsoPath = species === "dino"
? "M 39 61 C 45 58 55 58 61 61 C 67 68 67 83 62 91 C 56 96 44 96 38 91 C 33 83 33 68 39 61 Z"
: species === "monster"
? "M 35 62 C 42 57 58 57 65 62 C 71 70 70 85 64 92 C 56 97 44 97 36 92 C 30 85 29 70 35 62 Z"
: "M 38 61 C 44 58 56 58 62 61 C 67 69 66 84 61 93 C 54 97 46 97 39 93 C 34 84 33 69 38 61 Z";
m += `<path class="av-part av-torso av-torso-${species} av-torso-${variant}" d="${torsoPath}" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5" stroke-linejoin="round"/>`;
if(species === "dino"){
if(variant === "raptor"){
m += `<path class="av-part av-feather-chest" d="M 39 64 L 45 68 L 50 64 L 55 68 L 61 64 C 59 78 56 87 50 92 C 44 87 41 78 39 64 Z" fill="${light}"/>`;
}else{
m += `<path class="av-part av-belly" d="M 43 66 C 47 63 53 63 57 66 C 61 73 60 86 56 91 C 52 93 48 93 44 91 C 40 86 39 73 43 66 Z" fill="${light}" opacity=".9"/>`;
if(variant === "stegosaurus") m += `<path class="av-part av-side-plates" d="M 38 72 L 32 68 L 35 77 L 31 82 L 39 83" fill="${dark}" stroke="${outline}" stroke-width="1"/>`;
}
}else if(species === "monster"){
m += `<ellipse class="av-part av-belly" cx="50" cy="77" rx="12" ry="14" fill="${light}" opacity=".72"/>`;
if(variant === "horned") m += `<circle cx="43" cy="71" r="2" fill="${dark}"/><circle cx="58" cy="82" r="2.5" fill="${dark}"/>`;
if(variant === "cyclops") m += `<path class="av-part av-belly-spiral" d="M 44 77 Q 50 69 56 76 Q 58 83 50 85 Q 44 85 45 80" fill="none" stroke="${dark}" stroke-width="1.6" stroke-linecap="round"/>`;
if(variant === "slime") m += `<path class="av-part av-slime-highlight" d="M 40 66 C 36 75 38 86 42 91" fill="none" stroke="${highlight}" stroke-width="2.2" stroke-linecap="round" opacity=".7"/>`;
if(variant === "alien") m += `<path class="av-part av-alien-emblem" d="M 43 78 Q 50 70 57 78 Q 50 86 43 78 Z" fill="${dark}"/><circle cx="50" cy="78" r="2" fill="${highlight}"/>`;
}else{
if(variant === "cat") m += `<path class="av-part av-chest-fluff" d="M 39 64 L 45 67 L 50 64 L 55 67 L 61 64 C 60 76 57 85 50 90 C 43 85 40 76 39 64 Z" fill="${light}" opacity=".9"/>`;
if(variant === "dog") m += `<path class="av-part av-dog-chest" d="M 39 65 C 45 61 54 63 58 69 C 55 79 57 87 50 92 C 43 86 45 76 39 65 Z" fill="${light}" opacity=".85"/>`;
if(variant === "rabbit") m += `<ellipse class="av-part av-rabbit-belly" cx="50" cy="78" rx="10" ry="14" fill="${highlight}" opacity=".72"/>`;
if(variant === "bear") m += `<path class="av-part av-bear-chest" d="M 39 68 Q 50 61 61 68 Q 59 83 50 90 Q 41 83 39 68 Z" fill="${light}" opacity=".78"/>`;
}
const headPaths = {
dino: {
rex: "M 47 -3 C 28 -3 15 10 16 31 C 16 49 28 60 45 63 C 64 67 83 55 85 35 C 87 15 73 -1 55 -2 Z",
triceratops: "M 50 -1 C 29 -2 16 11 17 31 C 17 51 30 63 50 64 C 70 64 83 51 83 31 C 84 11 71 -1 50 -1 Z",
stegosaurus: "M 48 -2 C 29 -2 16 10 16 31 C 16 50 29 62 48 64 C 68 66 84 53 84 32 C 84 11 69 -2 48 -2 Z",
raptor: "M 43 -2 C 26 1 16 14 18 33 C 19 50 30 60 46 62 C 64 65 80 55 87 40 C 91 30 83 24 76 21 C 74 7 61 -4 43 -2 Z",
},
monster: {
horned: "M 50 -3 C 29 -5 16 8 16 30 C 15 51 29 63 48 64 C 69 66 85 53 84 30 C 84 8 71 -2 50 -3 Z",
cyclops: "M 50 -2 C 29 -3 16 10 16 31 C 16 53 30 65 50 65 C 70 65 84 53 84 31 C 84 10 71 -2 50 -2 Z",
slime: "M 49 0 C 30 -4 17 9 18 29 C 12 39 18 48 23 51 C 24 62 34 65 43 61 C 49 68 59 67 63 61 C 75 64 85 55 81 45 C 89 36 84 25 80 21 C 78 6 66 -2 49 0 Z",
alien: "M 50 -2 C 32 -2 20 9 18 28 C 16 48 28 62 50 65 C 72 62 84 48 82 28 C 80 9 68 -2 50 -2 Z",
},
animal: {
cat: "M 50 -3 C 30 -3 17 10 17 31 C 17 52 30 64 50 64 C 70 64 83 52 83 31 C 83 10 70 -3 50 -3 Z",
dog: "M 50 0 C 29 -1 16 12 17 33 C 18 53 31 64 50 64 C 69 64 82 53 83 33 C 84 12 71 -1 50 0 Z",
rabbit: "M 50 -1 C 32 -1 20 11 19 31 C 18 51 30 64 50 66 C 70 64 82 51 81 31 C 80 11 68 -1 50 -1 Z",
bear: "M 50 -3 C 28 -3 15 10 16 32 C 17 54 30 65 50 65 C 70 65 83 54 84 32 C 85 10 72 -3 50 -3 Z",
},
};
m += `<path class="av-part av-head av-head-large av-head-${variant}" d="${headPaths[species][variant]}" fill="${bodyColor}" stroke="${outline}" stroke-width="1.6" stroke-linejoin="round"/>`;
m += `<path d="M 27 12 C 33 4 41 1 49 1" stroke="${highlight}" stroke-width="2.5" fill="none" stroke-linecap="round" opacity=".68"/>`;
if(species === "dino"){
if(variant === "triceratops"){
m += `<path class="av-part av-dino-horn av-dino-horn-left" d="M 27 18 C 24 10 24 3 29 0 C 30 8 34 14 39 18 Z" fill="${highlight}" stroke="${outline}" stroke-width="1.1"/>`;
m += `<path class="av-part av-dino-horn av-dino-horn-right" d="M 73 18 C 76 10 76 3 71 0 C 70 8 66 14 61 18 Z" fill="${highlight}" stroke="${outline}" stroke-width="1.1"/>`;
m += `<path class="av-part av-muzzle" d="M 34 39 C 42 34 59 34 67 40 C 70 50 62 58 50 59 C 38 58 30 50 34 39 Z" fill="${light}"/>`;
m += `<path class="av-part av-dino-horn av-nose-horn" d="M 47 42 L 51 27 L 56 42 Z" fill="${highlight}" stroke="${outline}" stroke-width="1"/>`;
m += `<ellipse cx="42" cy="47" rx="2" ry="2.6" fill="${outline}"/><ellipse cx="59" cy="47" rx="2" ry="2.6" fill="${outline}"/>`;
}else if(variant === "raptor"){
m += `<path class="av-part av-muzzle" d="M 35 39 C 46 34 71 34 80 40 C 82 49 70 56 53 57 C 40 57 32 50 35 39 Z" fill="${light}"/>`;
m += `<ellipse cx="48" cy="44" rx="1.8" ry="2.5" fill="${outline}"/><ellipse cx="68" cy="43" rx="1.8" ry="2.5" fill="${outline}"/>`;
}else{
m += `<path class="av-part av-muzzle" d="M 28 39 C 37 34 67 34 75 41 C 79 51 67 59 51 59 C 35 59 24 51 28 39 Z" fill="${light}"/>`;
m += `<ellipse cx="41" cy="45" rx="2.2" ry="3" fill="${outline}"/><ellipse cx="63" cy="45" rx="2.2" ry="3" fill="${outline}"/>`;
if(variant === "stegosaurus") m += `<circle class="av-part av-cheek-plate" cx="25" cy="39" r="3" fill="${dark}"/><circle class="av-part av-cheek-plate" cx="78" cy="39" r="3" fill="${dark}"/>`;
}
m += `<ellipse class="av-part av-eye" cx="35" cy="27" rx="8.5" ry="9.5" fill="#fff" stroke="${outline}" stroke-width="1"/><ellipse class="av-part av-eye" cx="65" cy="27" rx="8.5" ry="9.5" fill="#fff" stroke="${outline}" stroke-width="1"/>`;
m += `<circle cx="37" cy="29" r="3.7" fill="#232733"/><circle cx="63" cy="29" r="3.7" fill="#232733"/><circle cx="38.2" cy="27.5" r="1.2" fill="#fff"/><circle cx="64.2" cy="27.5" r="1.2" fill="#fff"/>`;
m += `<path d="M 42 54 Q 51 59 60 54" stroke="${outline}" stroke-width="1.7" fill="none" stroke-linecap="round"/>`;
if(variant === "raptor") m += `<path d="M 56 55 L 59 59 L 62 54" fill="#fff" stroke="${outline}" stroke-width=".7"/>`;
}else if(species === "monster"){
if(variant === "cyclops"){
m += `<ellipse class="av-part av-eye av-cyclops-eye" cx="50" cy="27" rx="13" ry="14" fill="#fff" stroke="${outline}" stroke-width="1.2"/><circle cx="51" cy="29" r="5.2" fill="#232733"/><circle cx="53" cy="26" r="1.7" fill="#fff"/>`;
m += `<path class="av-part av-mouth" d="M 34 47 Q 50 58 66 47" fill="none" stroke="${outline}" stroke-width="2" stroke-linecap="round"/>`;
}else if(variant === "alien"){
for(const x of [34,50,66]) m += `<ellipse class="av-part av-eye av-alien-eye" cx="${x}" cy="28" rx="7" ry="8.5" fill="#fff" stroke="${outline}" stroke-width="1"/><circle cx="${x}" cy="29" r="3" fill="#232733"/><circle cx="${x+1}" cy="27.5" r="1" fill="#fff"/>`;
m += `<ellipse class="av-part av-mouth av-alien-mouth" cx="50" cy="50" rx="8" ry="4" fill="${dark}"/>`;
}else{
const leftSize = variant === "slime" ? 8.5 : 10;
const rightSize = variant === "slime" ? 8.5 : 8.2;
m += `<ellipse class="av-part av-eye" cx="35" cy="27" rx="${leftSize}" ry="${leftSize+0.8}" fill="#fff" stroke="${outline}" stroke-width="1"/><ellipse class="av-part av-eye" cx="66" cy="27" rx="${rightSize}" ry="${rightSize+0.8}" fill="#fff" stroke="${outline}" stroke-width="1"/>`;
m += `<circle cx="37" cy="29" r="4" fill="#232733"/><circle cx="67" cy="29" r="3.4" fill="#232733"/><circle cx="38.2" cy="27.5" r="1.2" fill="#fff"/><circle cx="68" cy="27.5" r="1.1" fill="#fff"/>`;
if(variant === "slime"){
m += `<path class="av-part av-mouth" d="M 36 47 Q 50 57 64 47" fill="none" stroke="${outline}" stroke-width="2" stroke-linecap="round"/><path d="M 48 52 L 52 52 L 50 57 Z" fill="#fff" stroke="${outline}" stroke-width=".6"/>`;
}else{
m += `<path class="av-part av-mouth" d="M 29 43 Q 50 60 71 43 Q 66 58 50 59 Q 34 58 29 43 Z" fill="#3c2158" stroke="${outline}" stroke-width="1"/><path d="M 39 46 L 43 54 L 47 46 Z M 53 46 L 57 54 L 61 46 Z" fill="#fff"/>`;
}
}
}else{
m += `<ellipse class="av-part av-eye" cx="35" cy="27" rx="8.5" ry="9.5" fill="#fff" stroke="${outline}" stroke-width="1"/><ellipse class="av-part av-eye" cx="65" cy="27" rx="8.5" ry="9.5" fill="#fff" stroke="${outline}" stroke-width="1"/>`;
m += `<circle cx="37" cy="29" r="3.7" fill="#232733"/><circle cx="63" cy="29" r="3.7" fill="#232733"/><circle cx="38.2" cy="27.5" r="1.2" fill="#fff"/><circle cx="64.2" cy="27.5" r="1.2" fill="#fff"/>`;
if(variant === "cat"){
m += `<path class="av-part av-muzzle" d="M 33 40 C 38 35 46 36 50 41 C 54 36 62 35 67 40 C 73 49 64 59 50 59 C 36 59 27 49 33 40 Z" fill="${light}"/><path d="M 45 40 Q 50 36 55 40 Q 54 46 50 47 Q 46 46 45 40 Z" fill="#3a2a18"/>`;
m += `<path class="av-part av-whiskers" d="M 39 48 L 20 44 M 39 52 L 19 53 M 61 48 L 80 44 M 61 52 L 81 53" stroke="${outline}" stroke-width="1.1" stroke-linecap="round"/><path d="M 50 47 L 50 51 M 40 51 Q 45 57 50 51 Q 55 57 60 51" stroke="#3a2a18" stroke-width="1.5" fill="none" stroke-linecap="round"/>`;
}else if(variant === "dog"){
m += `<ellipse class="av-part av-muzzle av-muzzle-dog" cx="50" cy="47" rx="19" ry="14" fill="${light}"/><ellipse cx="50" cy="40" rx="7" ry="5" fill="#34271f"/>`;
m += `<path d="M 50 45 Q 50 52 43 53 M 50 45 Q 50 52 57 53" fill="none" stroke="#34271f" stroke-width="1.6" stroke-linecap="round"/><path class="av-part av-tongue" d="M 46 54 Q 50 62 54 54 Z" fill="#e98391" stroke="${outline}" stroke-width=".8"/>`;
}else if(variant === "rabbit"){
m += `<path class="av-part av-muzzle" d="M 34 42 C 39 36 47 38 50 43 C 53 38 61 36 66 42 C 70 51 62 57 50 57 C 38 57 30 51 34 42 Z" fill="${light}"/><path d="M 46 41 Q 50 38 54 41 Q 53 45 50 46 Q 47 45 46 41 Z" fill="#b95f72"/>`;
m += `<path class="av-part av-rabbit-teeth" d="M 45 50 L 50 49 L 55 50 L 54 58 L 46 58 Z" fill="#fff" stroke="${outline}" stroke-width=".8"/><path d="M 50 50 L 50 57" stroke="${outline}" stroke-width=".7"/>`;
}else{
m += `<ellipse class="av-part av-muzzle av-muzzle-bear" cx="50" cy="47" rx="18" ry="14" fill="${light}"/><path d="M 43 40 Q 50 35 57 40 Q 56 47 50 48 Q 44 47 43 40 Z" fill="#34271f"/>`;
m += `<path d="M 50 48 L 50 52 M 40 52 Q 45 58 50 52 Q 55 58 60 52" stroke="#34271f" stroke-width="1.6" fill="none" stroke-linecap="round"/>`;
}
}
m += `</g>`;
return m;
}
const AVATAR_HAIR_MARKUP = {
none: { back: () => "", front: () => "" },
tuft: {
back: () => "",
front: (c) => `<g class="av-part av-hair av-hair-tuft" fill="${c}" stroke="${avatarShade(c,-0.4)}" stroke-linejoin="round">
<path d="M 37 11 C 39 5 42 2 47 1 C 43 -3 45 -9 50 -11 C 52 -6 53 -2 52 2 C 56 -3 62 -3 65 1 C 61 2 59 5 58 8 C 53 6 47 7 42 12 Z" stroke-width="1.25"/>
<path class="av-hair-texture" d="M 48 0 Q 50 4 48 7 M 56 1 Q 54 4 54 7" fill="none" stroke="${avatarShade(c,0.28)}" stroke-width="1.15" stroke-linecap="round"/>
</g>`,
},
spiky: {
back: () => "",
front: (c) => `<g class="av-part av-hair av-hair-spiky" fill="${c}" stroke="${avatarShade(c,-0.4)}" stroke-linejoin="round">
<path d="M 18 22 C 18 12 21 5 27 1 L 28 -7 L 36 -2 L 41 -11 L 48 -3 L 55 -13 L 61 -3 L 71 -9 L 70 0 L 79 -3 L 76 7 C 80 11 82 16 82 22 C 70 16 62 14 51 14 C 39 14 29 17 18 22 Z" stroke-width="1.35"/>
<path class="av-hairline" d="M 20 20 C 35 13 65 11 80 20" fill="none" stroke="${avatarShade(c,-0.18)}" stroke-width="1.2" stroke-linecap="round"/>
<path class="av-hair-texture" d="M 31 2 L 35 9 M 43 -3 L 45 8 M 57 -4 L 55 8 M 69 0 L 64 10" fill="none" stroke="${avatarShade(c,0.25)}" stroke-width="1.05" stroke-linecap="round"/>
</g>`,
},
curly: {
back: (c) => `<path class="av-part av-hair-back av-hair-curly-back" d="M 13 21 C 11 12 16 5 23 2 C 22 -6 31 -11 38 -8 C 43 -16 55 -16 61 -9 C 69 -12 78 -6 77 2 C 85 5 89 14 86 23 C 83 31 77 35 72 38 L 28 38 C 21 35 15 30 13 21 Z" fill="${avatarShade(c,-0.08)}" stroke="${avatarShade(c,-0.42)}" stroke-width="1.35"/>`,
front: (c) => `<g class="av-part av-hair av-hair-curly" fill="${c}" stroke="${avatarShade(c,-0.34)}" stroke-width="1.05">
<circle cx="19" cy="18" r="7.2"/><circle cx="23" cy="8" r="7.7"/><circle cx="31" cy="1" r="8.2"/>
<circle cx="41" cy="-3" r="8.5"/><circle cx="51" cy="-4" r="8.8"/><circle cx="61" cy="-2" r="8.4"/>
<circle cx="70" cy="2" r="8.1"/><circle cx="78" cy="9" r="7.7"/><circle cx="82" cy="19" r="7.1"/>
<path class="av-hairline" d="M 18 21 C 25 18 27 13 29 10 C 34 16 40 15 43 10 C 48 16 54 16 58 10 C 63 15 70 16 73 11 C 75 16 78 19 82 21" fill="none" stroke="${avatarShade(c,-0.3)}" stroke-width="1.3" stroke-linecap="round"/>
<path class="av-hair-texture" d="M 28 3 Q 34 -1 38 4 M 46 -5 Q 52 -1 55 -6 M 64 0 Q 70 3 72 8" fill="none" stroke="${avatarShade(c,0.3)}" stroke-width="1.15" stroke-linecap="round"/>
</g>`,
},
wavy: {
back: (c) => `<path class="av-part av-hair-back av-hair-wavy" d="M 14 19 C 13 0 28 -12 49 -12 C 71 -12 86 1 86 22 C 88 31 85 39 81 45 C 85 51 82 58 75 62 C 76 54 70 50 72 43 C 66 48 61 51 57 52 L 43 52 C 37 50 32 47 28 43 C 30 51 24 55 25 62 C 17 58 14 51 18 44 C 14 37 12 28 14 19 Z" fill="${avatarShade(c,-0.07)}" stroke="${avatarShade(c,-0.42)}" stroke-width="1.4" stroke-linejoin="round"/>`,
front: (c) => `<g class="av-part av-hair av-hair-wavy-fringe" fill="${c}" stroke="${avatarShade(c,-0.38)}" stroke-linejoin="round">
<path d="M 17 21 C 17 2 31 -9 50 -9 C 69 -9 82 2 83 20 C 76 18 71 14 67 9 C 63 15 58 18 52 19 C 47 20 42 17 40 12 C 34 17 26 20 17 21 Z" stroke-width="1.3"/>
<path class="av-side-lock av-side-lock-left" d="M 18 18 C 16 25 18 31 23 35 C 21 29 25 25 28 20 Z" stroke-width="1.15"/>
<path class="av-side-lock av-side-lock-right" d="M 82 17 C 85 25 82 31 77 35 C 79 29 75 25 72 20 Z" stroke-width="1.15"/>
<path class="av-hair-part" d="M 57 -5 C 53 2 48 8 40 12" fill="none" stroke="${avatarShade(c,0.35)}" stroke-width="1.15" stroke-linecap="round"/>
<path class="av-hair-texture" d="M 29 2 Q 34 6 31 11 M 69 1 Q 73 6 70 11" fill="none" stroke="${avatarShade(c,0.24)}" stroke-width="1" stroke-linecap="round"/>
</g>`,
},
bob: {
back: (c) => `<path class="av-part av-hair-back av-hair-bob" d="M 14 20 C 14 0 29 -12 50 -12 C 71 -12 86 0 86 21 C 87 34 85 47 81 57 C 77 61 72 61 67 58 L 66 42 C 61 46 57 48 50 48 C 43 48 38 46 33 42 L 32 58 C 27 61 21 61 17 57 C 14 46 13 33 14 20 Z" fill="${avatarShade(c,-0.06)}" stroke="${avatarShade(c,-0.42)}" stroke-width="1.4"/>`,
front: (c) => `<g class="av-part av-hair av-hair-bob-fringe" fill="${c}" stroke="${avatarShade(c,-0.38)}" stroke-linejoin="round">
<path d="M 16 21 C 16 2 31 -9 50 -9 C 69 -9 83 2 84 21 C 76 19 69 15 63 8 C 58 13 53 17 47 18 C 39 20 31 17 24 13 C 23 17 21 20 16 21 Z" stroke-width="1.3"/>
<path class="av-side-lock av-side-lock-left" d="M 17 18 C 15 28 17 39 23 47 C 27 44 29 39 28 32 C 25 27 23 23 24 15 Z" stroke-width="1.15"/>
<path class="av-side-lock av-side-lock-right" d="M 83 18 C 85 28 83 39 77 47 C 73 44 71 39 72 32 C 75 27 77 23 76 15 Z" stroke-width="1.15"/>
<path class="av-hair-part" d="M 62 -4 C 59 2 58 5 63 8" fill="none" stroke="${avatarShade(c,0.34)}" stroke-width="1.15" stroke-linecap="round"/>
<path class="av-hair-texture" d="M 24 23 Q 21 34 24 40 M 76 23 Q 79 34 76 40" fill="none" stroke="${avatarShade(c,0.24)}" stroke-width="1" stroke-linecap="round"/>
</g>`,
},
ponytail: {
back: (c) => `<g class="av-part av-hair-back av-hair-ponytail" stroke="${avatarShade(c,-0.42)}" stroke-linejoin="round">
<path d="M 72 8 C 83 1 94 7 96 18 C 99 32 91 47 78 58 C 82 45 80 34 74 29 C 86 31 88 19 76 17 Z" fill="${avatarShade(c,-0.06)}" stroke-width="1.4"/>
<path class="av-hair-texture" d="M 83 13 C 92 22 89 36 83 45" fill="none" stroke="${avatarShade(c,0.22)}" stroke-width="1.15" stroke-linecap="round"/>
<ellipse class="av-hair-tie" cx="75" cy="13" rx="5.2" ry="4.2" fill="#d84f78" stroke="#96304f" stroke-width="1"/>
</g>`,
front: (c) => `<g class="av-part av-hair av-hair-ponytail-front" fill="${c}" stroke="${avatarShade(c,-0.38)}" stroke-linejoin="round">
<path d="M 16 21 C 17 1 31 -10 50 -10 C 68 -10 80 0 83 17 C 76 16 70 13 65 8 C 57 15 47 18 38 16 C 31 15 25 16 16 21 Z" stroke-width="1.3"/>
<path class="av-hair-part" d="M 63 -5 C 61 1 61 5 65 8" fill="none" stroke="${avatarShade(c,0.34)}" stroke-width="1.15" stroke-linecap="round"/>
<path class="av-sideburn" d="M 18 19 C 17 25 19 29 23 32 C 22 26 25 22 28 18 Z" stroke-width="1.05"/>
</g>`,
},
braids: {
back: (c) => `<g class="av-part av-hair-back av-hair-braids" fill="${c}" stroke="${avatarShade(c,-0.42)}" stroke-width="1.05">
<path d="M 22 17 C 15 24 15 31 20 36 C 14 40 14 47 19 51 C 14 56 16 64 21 68 C 27 65 28 58 23 53 C 29 49 28 42 23 38 C 29 32 28 23 22 17 Z"/>
<path d="M 78 17 C 85 24 85 31 80 36 C 86 40 86 47 81 51 C 86 56 84 64 79 68 C 73 65 72 58 77 53 C 71 49 72 42 77 38 C 71 32 72 23 78 17 Z"/>
<path class="av-hair-texture" d="M 18 31 L 25 35 M 17 45 L 25 49 M 18 58 L 24 62 M 82 31 L 75 35 M 83 45 L 75 49 M 82 58 L 76 62" fill="none" stroke="${avatarShade(c,0.26)}" stroke-width="1.55" stroke-linecap="round"/>
<path class="av-hair-tie" d="M 17 66 Q 21 70 25 66 M 75 66 Q 79 70 83 66" fill="none" stroke="#d84f78" stroke-width="2.3" stroke-linecap="round"/>
</g>`,
front: (c) => `<g class="av-part av-hair av-hair-braids-front" fill="${c}" stroke="${avatarShade(c,-0.38)}" stroke-linejoin="round">
<path d="M 16 21 C 17 1 31 -10 50 -10 C 69 -10 82 1 84 21 C 76 19 70 14 65 8 C 60 14 54 17 48 18 C 39 19 31 16 23 12 C 22 17 20 19 16 21 Z" stroke-width="1.3"/>
<path class="av-hair-part" d="M 63 -5 C 61 1 61 5 65 8" fill="none" stroke="${avatarShade(c,0.34)}" stroke-width="1.15" stroke-linecap="round"/>
<path class="av-sideburn" d="M 18 18 C 17 25 19 29 23 32 C 22 26 25 22 27 18 Z M 82 18 C 83 25 81 29 77 32 C 78 26 75 22 73 18 Z" stroke-width="1.05"/>
</g>`,
},
mohawk: {
back: () => "",
front: (c) => `<g class="av-part av-hair av-hair-mohawk" fill="${c}" stroke="${avatarShade(c,-0.42)}" stroke-linejoin="round">
<path d="M 41 14 C 40 8 40 2 42 -3 L 45 -13 L 50 -6 L 55 -16 L 59 -6 L 62 -11 L 60 2 C 60 7 59 11 58 15 C 53 12 47 12 41 14 Z" stroke-width="1.35"/>
<path class="av-hair-texture" d="M 47 -9 L 48 8 M 55 -10 L 53 8" fill="none" stroke="${avatarShade(c,0.28)}" stroke-width="1.1" stroke-linecap="round"/>
</g>`,
},
};
/* Iedere kop gebruikt dezelfde haarankers, met een minimale correctie voor
het asymmetrische dinosaurusprofiel. De wrapper maakt de pasvorm expliciet
en houdt alle lokken als een laag bij elkaar. */
function avatarHairLayer(markup, species, layer){
if(!markup) return "";
const transform = species === "dino" ? "translate(1 0)" : species === "monster" ? "translate(0 -1)" : "translate(0 0)";
return `<g class="av-hair-layer av-hair-${layer}-layer av-hair-fit-${species}" transform="${transform}">${markup}</g>`;
}
const AVATAR_HEAD_ACC_MARKUP = {
none: () => "",
cap: () => `
<path class="av-part av-headwear av-cap" d="M 19 17 C 21 0 34 -8 51 -7 C 68 -6 79 3 81 18 C 63 11 39 11 19 17 Z" fill="#3d7ddb" stroke="#234f91" stroke-width="1.4"/>
<path d="M 20 16 C 37 11 59 12 75 17 C 61 22 39 22 15 22 C 13 20 15 18 20 16 Z" fill="#2f63b3" stroke="#234f91" stroke-width="1.3"/>
<path d="M 51 -7 L 51 11" stroke="#72a3e8" stroke-width="1.2" opacity=".8"/>`,
beanie: () => `
<path class="av-part av-headwear av-beanie" d="M 19 16 C 19 -2 31 -11 50 -11 C 69 -11 81 -2 81 16 Z" fill="#e56a55" stroke="#9f3e31" stroke-width="1.4"/>
<path d="M 18 13 C 34 10 66 10 82 13 L 81 22 C 64 18 36 18 19 22 Z" fill="#c94f3d" stroke="#9f3e31" stroke-width="1.3"/>
<circle cx="50" cy="-12" r="5" fill="#f1a15f" stroke="#9f3e31" stroke-width="1"/>`,
crown: () => `
<path class="av-part av-headwear av-crown" d="M 22 17 L 25 -2 L 37 10 L 50 -12 L 63 10 L 75 -2 L 78 17 C 61 12 39 12 22 17 Z" fill="#f0b73a" stroke="#b77b0b" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M 23 15 C 40 11 60 11 77 15 L 77 20 C 60 16 40 16 23 20 Z" fill="#d99a22"/>
<circle cx="25" cy="-1" r="2.8" fill="#e0483c"/><circle cx="50" cy="-11" r="3" fill="#3a8ee0"/><circle cx="75" cy="-1" r="2.8" fill="#e0483c"/>`,
wizardhat: () => `
<path class="av-part av-headwear av-wizardhat" d="M 49 -16 C 58 -7 64 2 67 15 C 54 12 42 12 30 16 C 39 5 44 -5 49 -16 Z" fill="#6850bd" stroke="#3e2d84" stroke-width="1.4" stroke-linejoin="round"/>
<path d="M 51 -9 C 57 -3 59 4 61 10" stroke="#9b88df" stroke-width="2" fill="none" stroke-linecap="round"/>
<path d="M 16 17 C 35 12 66 12 84 17 C 87 20 82 23 75 22 C 57 19 39 19 23 22 C 16 23 12 20 16 17 Z" fill="#513b9d" stroke="#3e2d84" stroke-width="1.4"/>
<path d="M 45 1 L 47 5 L 52 5 L 48 8 L 50 13 L 45 10 L 41 13 L 42 8 L 38 5 L 43 5 Z" fill="#f1c84c"/>`,
glasses: () => `
<circle class="av-part av-eyewear av-glasses" cx="35" cy="27" r="10.5" fill="rgba(255,255,255,.28)" stroke="#232733" stroke-width="2.5"/>
<circle class="av-part av-eyewear av-glasses" cx="65" cy="27" r="10.5" fill="rgba(255,255,255,.28)" stroke="#232733" stroke-width="2.5"/>
<path d="M 45.5 27 L 54.5 27 M 24.5 24 L 16 20 M 75.5 24 L 84 20" stroke="#232733" stroke-width="2.4" stroke-linecap="round"/>`,
sunglasses: () => `
<path class="av-part av-eyewear av-sunglasses" d="M 23 21 C 23 35 29 39 37 39 C 46 39 50 33 48 23 C 44 19 29 18 23 21 Z" fill="#1a1d24"/>
<path d="M 52 23 C 50 33 54 39 63 39 C 71 39 77 35 77 21 C 71 18 56 19 52 23 Z" fill="#1a1d24"/>
<path d="M 48 24 Q 50 21 52 24 M 23 22 L 15 19 M 77 22 L 85 19" stroke="#1a1d24" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<path d="M 28 23 Q 32 20 36 22 M 57 23 Q 61 20 65 22" stroke="#fff" stroke-width="1.7" fill="none" opacity=".55" stroke-linecap="round"/>`,
eyepatch: () => `
<path class="av-part av-eyewear av-eyepatch" d="M 16 17 C 38 22 61 25 84 17" stroke="#2a2630" stroke-width="2.4" fill="none"/>
<path d="M 54 23 C 58 18 70 18 75 24 C 75 34 70 40 64 40 C 58 40 54 34 54 23 Z" fill="#34303b" stroke="#17151b" stroke-width="1.3"/>
<path d="M 60 24 Q 65 21 70 24" stroke="#666170" stroke-width="1.4" fill="none" stroke-linecap="round"/>`,
bow: () => `
<path class="av-part av-headwear av-bow" d="M 76 13 C 68 3 60 6 61 14 C 62 22 69 22 76 17 C 83 23 91 22 92 14 C 93 6 84 3 76 13 Z" fill="#e05a8a" stroke="#a93660" stroke-width="1.3"/>
<circle cx="76" cy="15" r="4" fill="#c23f6e"/>`,
flower: () => `
<g class="av-part av-headwear av-flower" fill="#f08bb0" stroke="#b74470" stroke-width="1">
<ellipse cx="78" cy="7" rx="4" ry="7"/><ellipse cx="78" cy="21" rx="4" ry="7"/>
<ellipse cx="71" cy="14" rx="7" ry="4"/><ellipse cx="85" cy="14" rx="7" ry="4"/>
<ellipse cx="73" cy="9" rx="4" ry="6" transform="rotate(-45 73 9)"/><ellipse cx="83" cy="19" rx="4" ry="6" transform="rotate(-45 83 19)"/>
</g><circle cx="78" cy="14" r="4" fill="#f0c84a" stroke="#b77b0b" stroke-width="1"/>`,
headphones: () => `
<path class="av-part av-headwear av-headphones" d="M 12 29 C 12 3 27 -11 50 -11 C 73 -11 88 3 88 29" stroke="#333" stroke-width="5" fill="none" stroke-linecap="round"/>
<rect x="8" y="22" width="12" height="23" rx="6" fill="#333" stroke="#17181c" stroke-width="1.3"/>
<rect x="80" y="22" width="12" height="23" rx="6" fill="#333" stroke="#17181c" stroke-width="1.3"/>
<rect x="12" y="27" width="4" height="13" rx="2" fill="#667"/><rect x="84" y="27" width="4" height="13" rx="2" fill="#667"/>`,
partyhat: () => `
<path class="av-part av-headwear av-partyhat" d="M 50 -15 L 64 18 C 55 22 45 22 36 18 Z" fill="#e05a8a" stroke="#a93660" stroke-width="1.3"/>
<path d="M 36.5 17 C 45 21 55 21 63.5 17" stroke="#f4c34e" stroke-width="2.2" fill="none"/>
<circle cx="45" cy="3" r="2" fill="#f0b73a"/><circle cx="56" cy="8" r="2" fill="#3a8ee0"/><circle cx="47" cy="14" r="2" fill="#f0b73a"/>
<circle cx="50" cy="-15" r="4.2" fill="#f0b73a"/>`,
};
/* Oogaccessoires volgen afwijkende oogindelingen van cycloop en alien. */
function avatarHeadAccessoryMarkup(c){
if(c.species === "monster" && c.speciesVariant === "cyclops"){
if(c.accessoryHead === "glasses") return `
<circle class="av-part av-eyewear av-glasses av-glasses-cyclops" cx="50" cy="27" r="15" fill="rgba(255,255,255,.24)" stroke="#232733" stroke-width="2.5"/>
<path d="M 35 24 L 16 19 M 65 24 L 84 19" stroke="#232733" stroke-width="2.4" stroke-linecap="round"/>`;
if(c.accessoryHead === "sunglasses") return `
<path class="av-part av-eyewear av-sunglasses av-sunglasses-cyclops" d="M 34 19 C 31 33 38 41 50 41 C 62 41 69 33 66 19 C 57 16 43 16 34 19 Z" fill="#1a1d24"/>
<path d="M 34 21 L 16 18 M 66 21 L 84 18 M 40 22 Q 46 18 52 21" stroke="#1a1d24" stroke-width="2.5" fill="none" stroke-linecap="round"/><path d="M 41 22 Q 47 19 53 21" stroke="#fff" stroke-width="1.7" fill="none" opacity=".55"/>`;
if(c.accessoryHead === "eyepatch") return `
<path class="av-part av-eyewear av-eyepatch av-eyepatch-cyclops" d="M 16 17 C 38 23 62 23 84 17" stroke="#2a2630" stroke-width="2.4" fill="none"/>
<path d="M 36 21 C 42 16 58 16 64 21 C 65 35 59 43 50 43 C 41 43 35 35 36 21 Z" fill="#34303b" stroke="#17151b" stroke-width="1.3"/>`;
}
if(c.species === "monster" && c.speciesVariant === "alien"){
if(c.accessoryHead === "glasses") return `
<g class="av-part av-eyewear av-glasses av-glasses-alien" fill="rgba(255,255,255,.24)" stroke="#232733" stroke-width="2">
<ellipse cx="34" cy="28" rx="8.5" ry="10"/><ellipse cx="50" cy="28" rx="8.5" ry="10"/><ellipse cx="66" cy="28" rx="8.5" ry="10"/>
</g><path d="M 25 25 L 17 20 M 75 25 L 83 20" stroke="#232733" stroke-width="2.2" stroke-linecap="round"/>`;
if(c.accessoryHead === "sunglasses") return `
<path class="av-part av-eyewear av-sunglasses av-sunglasses-alien" d="M 22 20 C 24 36 33 40 50 39 C 67 40 76 36 78 20 C 64 16 36 16 22 20 Z" fill="#1a1d24"/>
<path d="M 22 21 L 15 18 M 78 21 L 85 18" stroke="#1a1d24" stroke-width="2.5" stroke-linecap="round"/><path d="M 29 22 Q 39 18 48 21" stroke="#fff" stroke-width="1.7" fill="none" opacity=".55"/>`;
}
return AVATAR_HEAD_ACC_MARKUP[c.accessoryHead]();
}
const AVATAR_BODY_ACC_MARKUP = {
none: { back: () => "", front: () => "" },
scarf: {
back: () => "",
front: () => `
<path class="av-part av-neckwear av-scarf" d="M 34 58 C 42 63 58 63 66 58 L 67 67 C 58 72 42 72 33 67 Z" fill="#d94f4f" stroke="#9d3030" stroke-width="1.3"/>
<path d="M 57 68 L 63 87 L 54 83 L 50 69 Z" fill="#c23f3f" stroke="#9d3030" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M 56 79 L 62 82" stroke="#f08080" stroke-width="1.2"/>`,
},
cape: {
back: () => `
<path class="av-part av-backwear av-cape" d="M 35 59 C 22 67 17 82 17 108 C 29 103 40 99 50 92 C 60 99 71 103 83 108 C 83 82 78 67 65 59 C 57 64 43 64 35 59 Z" fill="#7a4bc7" stroke="#54308f" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M 28 67 C 24 80 25 94 28 102" stroke="#a67de0" stroke-width="2" fill="none" opacity=".65"/>`,
front: () => `
<path class="av-part av-cape-clasp" d="M 36 61 C 43 66 57 66 64 61" stroke="#f0b73a" stroke-width="2.4" fill="none" stroke-linecap="round"/>
<circle cx="36" cy="61" r="2.5" fill="#f0b73a"/><circle cx="64" cy="61" r="2.5" fill="#f0b73a"/>`,
},
badge: {
back: () => "",
front: () => `
<path class="av-part av-chestwear av-badge" d="M 55 70 L 61 69 L 65 74 L 63 80 L 57 81 L 53 76 Z" fill="#f0b73a" stroke="#fff" stroke-width="1.4"/>
<path d="M 59 72 L 60 75 L 63 75 L 60.5 77 L 61.5 80 L 59 78 L 56.5 80 L 57.5 77 L 55 75 L 58 75 Z" fill="#b8750b"/>`,
},
necklace: {
back: () => "",
front: () => `
<path class="av-part av-neckwear av-necklace" d="M 37 62 C 39 74 45 82 50 83 C 55 82 61 74 63 62" stroke="#f0b73a" stroke-width="2" fill="none" stroke-linecap="round"/>
<path d="M 50 80 L 55 85 L 50 91 L 45 85 Z" fill="#3a8ee0" stroke="#f0b73a" stroke-width="1.4"/>`,
},
bowtie: {
back: () => "",
front: () => `
<path class="av-part av-neckwear av-bowtie" d="M 49 67 C 42 61 36 63 37 70 C 38 76 44 75 49 71 C 54 75 62 76 63 70 C 64 63 57 61 51 67 Z" fill="#3a8ee0" stroke="#205d9c" stroke-width="1.2"/>
<circle cx="50" cy="69" r="3.5" fill="#f0b73a" stroke="#b77b0b" stroke-width="1"/>`,
},
belt: {
back: () => "",
front: () => `
<path class="av-part av-bodywear av-belt" d="M 35 84 C 43 87 57 87 65 84 L 65 91 C 56 94 44 94 35 91 Z" fill="#5a4029" stroke="#392818" stroke-width="1.2"/>
<rect x="46" y="85" width="9" height="8" rx="1.5" fill="#f0b73a" stroke="#b77b0b" stroke-width="1.2"/>
<rect x="49" y="87" width="3" height="4" fill="#5a4029"/>`,
},
backpack: {
back: () => `
<path class="av-part av-backwear av-backpack" d="M 30 65 C 31 56 39 53 50 53 C 61 53 69 56 70 65 L 75 91 C 68 97 32 97 25 91 Z" fill="#8a6a45" stroke="#5a4029" stroke-width="1.6"/>
<path d="M 40 55 C 41 49 59 49 60 55" stroke="#5a4029" stroke-width="3" fill="none"/>`,
front: () => `
<path class="av-part av-backpack-strap" d="M 39 62 C 33 69 33 80 36 88 M 61 62 C 67 69 67 80 64 88" stroke="#5a4632" stroke-width="3.5" fill="none" stroke-linecap="round"/>
<path d="M 35 87 L 40 90 M 65 87 L 60 90" stroke="#d0a36d" stroke-width="1.4"/>`,
},
satchel: {
back: () => `
<path class="av-part av-backwear av-satchel" d="M 61 75 C 68 72 79 74 83 80 L 82 95 C 76 99 65 98 60 93 Z" fill="#b87545" stroke="#704329" stroke-width="1.5"/>
<path d="M 63 81 C 69 84 76 84 81 81" stroke="#e3ad72" stroke-width="1.4" fill="none"/>`,
front: () => `
<path class="av-part av-satchel-strap" d="M 36 62 C 49 71 58 80 68 94" stroke="#704329" stroke-width="2.8" fill="none" stroke-linecap="round"/>
<circle cx="67" cy="93" r="2" fill="#f0b73a"/>`,
},
wings: {
back: () => `
<path class="av-part av-backwear av-wings" d="M 37 65 C 25 51 11 48 5 55 C 1 64 12 72 24 74 C 10 77 6 86 13 91 C 23 96 33 85 41 75 Z" fill="#b8e8ef" stroke="#5799ad" stroke-width="1.5" stroke-linejoin="round" opacity=".95"/>
<path d="M 63 65 C 75 51 89 48 95 55 C 99 64 88 72 76 74 C 90 77 94 86 87 91 C 77 96 67 85 59 75 Z" fill="#b8e8ef" stroke="#5799ad" stroke-width="1.5" stroke-linejoin="round" opacity=".95"/>
<path d="M 11 58 C 23 61 30 67 37 74 M 89 58 C 77 61 70 67 63 74" stroke="#fff" stroke-width="2" fill="none" opacity=".7"/>`,
front: () => `<circle class="av-part av-wing-clasp" cx="37" cy="65" r="2.4" fill="#5799ad"/><circle cx="63" cy="65" r="2.4" fill="#5799ad"/>`,
},
};
/* Pet en muts bedekken de voorste haarlaag. Lange stijlen houden hun
achterlaag, zodat staarten, vlechten en lokken naast de kop zichtbaar zijn. */
function avatarHairSuppressed(c){
return c.accessoryHead === "cap" || c.accessoryHead === "beanie";
}
/* bouwt de SVG-weergave van een personage; config==null → neutrale
standaardweergave (nooit een lege/kapotte weergave). Tekenvolgorde bepaalt
de laagvolgorde (SVG kent geen z-index) - vandaar cape/staart/poten eerst,
accessoires als laatste. */
function renderAvatarFace(config, size){
const c = avatarSanitize(config);
const svg = document.createElementNS(AVATAR_SVG_NS, "svg");
/* Extra ruimte voorkomt afsnijden van hoofddeksels, haar en voetzolen. */
svg.setAttribute("viewBox", "-8 -21 116 132");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.setAttribute("class", "avatar-face " + (size || "md") + " av-species-" + c.species + " av-variant-" + c.speciesVariant);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", T("avatarAlt"));
const bodyAccessory = AVATAR_BODY_ACC_MARKUP[c.accessoryBody];
const hair = AVATAR_HAIR_MARKUP[c.hairStyle];
let markup = bodyAccessory.back();
markup += avatarHairLayer(hair.back(c.hairColor), c.species, "back");
markup += avatarBodyMarkup(c.species, c.bodyColor, c.speciesVariant);
if(!avatarHairSuppressed(c)) markup += avatarHairLayer(hair.front(c.hairColor), c.species, "front");
markup += avatarHeadAccessoryMarkup(c);
markup += bodyAccessory.front();
svg.innerHTML = markup;
return svg;
}
/* Voltooide opdrachten krijgen een korte feestlaag met het eigen personage.
De hele SVG beweegt als één figuur, zodat haar en accessoires altijd op hun
anatomische ankers blijven. Zonder expliciete keuze rouleren de dansen. */
let avatarDanceCursor = -1;
function avatarDanceChoice(preferred){
if(AVATAR_DANCES.includes(preferred)) return preferred;
avatarDanceCursor = (avatarDanceCursor + 1) % AVATAR_DANCES.length;
return AVATAR_DANCES[avatarDanceCursor];
}
function celebrateAvatar(host, options){
if(!host || typeof currentUser === "undefined" || !currentUser || !currentUser.avatar) return null;
host.querySelectorAll(".avatar-dance-stage").forEach(stage=>stage.remove());
host.classList.add("avatar-dance-host");
const dance = avatarDanceChoice(options && options.dance);
const reduced = typeof window !== "undefined" && window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const stage = document.createElement("div");
stage.className = "avatar-dance-stage av-dance-" + dance + (reduced ? " av-dance-reduced" : "");
stage.dataset.dance = dance;
stage.setAttribute("aria-hidden", "true");
const face = renderAvatarFace(currentUser.avatar, "lg");
face.classList.add("av-dancer");
stage.appendChild(face);
host.appendChild(stage);
setTimeout(()=>{
stage.remove();
if(!host.querySelector(".avatar-dance-stage")) host.classList.remove("avatar-dance-host");
}, reduced ? 1400 : 3400);
return stage;
}
/* bouwt de interactieve kiezer + live voorbeeld; roept onChange(config) aan
bij elke wijziging. Hergebruikt door zowel de instellingen-sectie als het
widget - één UI, geen duplicatie. */
function buildAvatarEditor(container, initialConfig, onChange){
let config = avatarSanitize(initialConfig);
container.innerHTML = "";
const editor = document.createElement("div");
editor.className = "avatar-editor";
const preview = document.createElement("div");
preview.className = "avatar-preview";
const controls = document.createElement("div");
controls.className = "avatar-controls";
editor.append(preview, controls);
container.appendChild(editor);
const speciesRow = document.createElement("div");
speciesRow.className = "avatar-species-row";
AVATAR_SPECIES.forEach(id=>{
const b = document.createElement("button");
b.type = "button";
b.dataset.species = id;
b.addEventListener("click", ()=>{ config.species = id; config.speciesVariant = AVATAR_VARIANTS[id][0]; update(); });
speciesRow.appendChild(b);
});
controls.appendChild(speciesRow);
const variantRow = document.createElement("div"); variantRow.className = "avatar-row";
const variantField = document.createElement("label");
const variantSpan = document.createElement("span");
const variantSel = document.createElement("select");
variantSel.addEventListener("change", ()=>{ config.speciesVariant = variantSel.value; update(); });
variantField.append(variantSpan, variantSel);
variantRow.appendChild(variantField);
controls.appendChild(variantRow);
const row1 = document.createElement("div"); row1.className = "avatar-row";
const bodyColorField = document.createElement("label");
const bodyColorSpan = document.createElement("span");
const bodyColorInput = document.createElement("input"); bodyColorInput.type = "color";
bodyColorInput.addEventListener("input", ()=>{ config.bodyColor = bodyColorInput.value; update(); });
bodyColorField.append(bodyColorSpan, bodyColorInput);
const hairColorField = document.createElement("label");
const hairColorSpan = document.createElement("span");
const hairColorInput = document.createElement("input"); hairColorInput.type = "color";
hairColorInput.addEventListener("input", ()=>{ config.hairColor = hairColorInput.value; update(); });
hairColorField.append(hairColorSpan, hairColorInput);
row1.append(bodyColorField, hairColorField);
controls.appendChild(row1);
const row2 = document.createElement("div"); row2.className = "avatar-row";
const hairField = document.createElement("label");
const hairSpan = document.createElement("span");
const hairSel = document.createElement("select");
AVATAR_HAIR.forEach(id=>hairSel.appendChild(new Option("", id)));
hairSel.addEventListener("change", ()=>{ config.hairStyle = hairSel.value; update(); });
hairField.append(hairSpan, hairSel);
row2.append(hairField);
controls.appendChild(row2);
const row3 = document.createElement("div"); row3.className = "avatar-row";
const headAccField = document.createElement("label");
const headAccSpan = document.createElement("span");
const headAccSel = document.createElement("select");
AVATAR_HEAD_ACC.forEach(id=>headAccSel.appendChild(new Option("", id)));
headAccSel.addEventListener("change", ()=>{ config.accessoryHead = headAccSel.value; update(); });
headAccField.append(headAccSpan, headAccSel);
const bodyAccField = document.createElement("label");
const bodyAccSpan = document.createElement("span");
const bodyAccSel = document.createElement("select");
AVATAR_BODY_ACC.forEach(id=>bodyAccSel.appendChild(new Option("", id)));
bodyAccSel.addEventListener("change", ()=>{ config.accessoryBody = bodyAccSel.value; update(); });
bodyAccField.append(bodyAccSpan, bodyAccSel);
row3.append(headAccField, bodyAccField);
controls.appendChild(row3);
function syncLabels(){
speciesRow.querySelectorAll("button").forEach(b=>{
b.textContent = T("avatarSpecies_" + b.dataset.species);
b.classList.toggle("active", b.dataset.species === config.species);
});
const variantIds = AVATAR_VARIANTS[config.species];
if([...variantSel.options].map(o=>o.value).join(",") !== variantIds.join(",")){
variantSel.innerHTML = "";
variantIds.forEach(id=>variantSel.appendChild(new Option("", id)));
}
variantSpan.textContent = T("avatarVariant");
[...variantSel.options].forEach(o=>o.textContent = T("avatarVariant_" + o.value));
bodyColorSpan.textContent = T("avatarBodyColor");
hairColorSpan.textContent = T("avatarHairColor");
hairSpan.textContent = T("avatarHairStyle");
[...hairSel.options].forEach(o=>o.textContent = T("avatarHair_" + o.value));
headAccSpan.textContent = T("avatarAccHead");
[...headAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value));
bodyAccSpan.textContent = T("avatarAccBody");
[...bodyAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value));
}
function update(){
const variants = AVATAR_VARIANTS[config.species];
if(!variants.includes(config.speciesVariant)) config.speciesVariant = variants[0];
syncLabels();
bodyColorInput.value = config.bodyColor;
hairColorInput.value = config.hairColor;
variantSel.value = config.speciesVariant;
hairSel.value = config.hairStyle;
headAccSel.value = config.accessoryHead;
bodyAccSel.value = config.accessoryBody;
preview.innerHTML = "";
preview.appendChild(renderAvatarFace(config, "lg"));
if(onChange) onChange({ ...config });
}
document.addEventListener("langchange", syncLabels);
update();
return { getConfig: ()=>({ ...config }), setConfig(c){ config = avatarSanitize(c); update(); } };
}
async function saveAvatar(config){
const clean = avatarSanitize(config);
const res = await api("/me/avatar", { method: "PUT", body: clean });
currentUser.avatar = res.avatar;
document.dispatchEvent(new CustomEvent("avatarchange"));
return res.avatar;
}
/* ---- Instellingen-sectie "Mijn personage" (cms.js-patroon: eigen .st-section
zelf aanmaken, niet in index.html) ---- */
(function(){
const host = document.createElement("div");
host.className = "st-section";
host.dataset.section = "avatar";
host.style.display = "none";
document.getElementById("stContent").appendChild(host);
const msg = document.createElement("p");
msg.className = "avatar-save-msg";
let editorApi = null;
function build(){
host.innerHTML = "";
editorApi = buildAvatarEditor(host, currentUser && currentUser.avatar, ()=>{ msg.textContent = ""; });
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "tbtn";
saveBtn.textContent = T("avatarSave");
saveBtn.addEventListener("click", async ()=>{
try{ await saveAvatar(editorApi.getConfig()); msg.textContent = T("avatarSaved"); }
catch(e){ msg.textContent = e.message; }
});
host.appendChild(saveBtn);
host.appendChild(msg);
}
registerSettingsSection({
id: "avatar", icon: "🦖", group: "personal", order: 3,
label: ()=>T("stAvatar"), sub: ()=>T("stAvatarSub"),
visible: ()=>!!currentUser,
activate(){ showSettingsDom("avatar"); build(); },
keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"),
...Object.values(AVATAR_VARIANTS).flat().map(id=>T("avatarVariant_" + id)),
"personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"],
});
})();
/* ---- widget: dunne wrapper rond dezelfde editor ---- */
function mountAvatarWidget(root){
root.innerHTML = `<p class="play-mission"></p>`;
root.querySelector(".play-mission").textContent = T("wg_avatar_d");
const host = document.createElement("div");
host.style.cssText = "flex:1;min-height:0;display:flex;flex-direction:column;";
root.appendChild(host);
const msg = document.createElement("p");
msg.className = "avatar-save-msg";
let editorApi = null;
function build(){
host.innerHTML = "";
editorApi = buildAvatarEditor(host, currentUser && currentUser.avatar, ()=>{ msg.textContent = ""; });
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "tbtn";
saveBtn.textContent = T("avatarSave");
saveBtn.addEventListener("click", async ()=>{
try{ await saveAvatar(editorApi.getConfig()); msg.textContent = T("avatarSaved"); }
catch(e){ msg.textContent = e.message; }
});
host.appendChild(saveBtn);
host.appendChild(msg);
}
build();
document.addEventListener("langchange", ()=>{ root.querySelector(".play-mission").textContent = T("wg_avatar_d"); });
/* currentUser.avatar is de bron van waarheid (server), niet het widget-eigen
initState - geen dubbele opslag van dezelfde data op twee plekken. */
return { getState: ()=>({}) };
}