Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-66 into main AGit
5 changed files with 332 additions and 190 deletions
Showing only changes of commit 7237cb6290 - Show all commits

View file

@ -1 +1 @@
0.4.46-beta 0.4.47-beta

View file

@ -6,9 +6,9 @@
los ernaast te zweven. */ los ernaast te zweven. */
"use strict"; "use strict";
const AVATAR_SPECIES = ["dino", "monster", "animal"]; const AVATAR_SPECIES = ["dino", "monster", "animal"];
const AVATAR_HAIR = ["none", "spiky", "curly", "ponytail", "mohawk"]; const AVATAR_HAIR = ["none", "tuft", "spiky", "curly", "wavy", "bob", "ponytail", "braids", "mohawk"];
const AVATAR_HEAD_ACC = ["none", "cap", "crown", "glasses", "sunglasses", "bow", "headphones", "partyhat"]; 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", "backpack"]; const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge", "necklace", "bowtie", "belt", "backpack", "satchel", "wings"];
const AVATAR_DEFAULT = Object.freeze({ const AVATAR_DEFAULT = Object.freeze({
species: "dino", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29", species: "dino", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29",
accessoryHead: "none", accessoryBody: "none", accessoryHead: "none", accessoryBody: "none",
@ -39,147 +39,204 @@ function avatarShade(hex, amt){
return "#" + [r,g,b].map(v=>clamp(v).toString(16).padStart(2,"0")).join(""); return "#" + [r,g,b].map(v=>clamp(v).toString(16).padStart(2,"0")).join("");
} }
/* Per-soort silhouet met vaste anatomische ankers: kruin y=2, ogen y=25, /* Groot hoofd en compact, soortspecifiek lijf. De kop beslaat circa twee
hals y=53, schouders y=60, heupen y=88 en voetzolen y=108. Armen en benen derde van de figuurbreedte; romp en ledematen blijven kort en sluiten onder
lopen bewust onder het lijf door. Daardoor blijven gewrichten verbonden en de wangen aan. Alle soorten delen dezelfde accessoire-ankers rond hals y=61,
kunnen accessoires steeds aan hetzelfde lichaamsdeel worden vastgemaakt. */ borst y=72 en middel y=87. */
function avatarBodyMarkup(species, bodyColor){ function avatarBodyMarkup(species, bodyColor){
const outline = avatarShade(bodyColor, -0.48); const outline = avatarShade(bodyColor, -0.48);
const dark = avatarShade(bodyColor, -0.24); const dark = avatarShade(bodyColor, -0.24);
const light = avatarShade(bodyColor, 0.4); const light = avatarShade(bodyColor, 0.4);
const highlight = avatarShade(bodyColor, 0.7); const highlight = avatarShade(bodyColor, 0.72);
let m = ""; let m = "";
/* Onderdelen achter het lijf: soortdetails, benen en doorlopende armen. */ /* Soortdetails achter kop en lijf. */
if(species === "dino"){ if(species === "dino"){
m += `<path class="av-part av-tail" d="M 36 78 C 24 82 15 83 5 76 C 12 90 25 96 42 87 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`; m += `<path class="av-part av-tail" 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"/>`;
m += `<path class="av-part av-crest" d="M 68 14 L 78 9 L 75 21 L 84 20 L 77 31" fill="${dark}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round"/>`; m += `<path class="av-part av-crest" d="M 73 10 L 85 3 L 81 17 L 91 15 L 84 29" fill="${dark}" stroke="${outline}" stroke-width="1.3" stroke-linejoin="round"/>`;
} }
if(species === "animal"){ if(species === "animal"){
m += `<path class="av-part av-ear av-ear-left" d="M 30 17 C 16 15 16 2 22 0 C 31 -2 37 7 36 17 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4"/>`; m += `<path class="av-part av-ear av-ear-left" d="M 29 16 C 13 15 10 1 18 -4 C 29 -8 38 4 37 17 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5"/>`;
m += `<path d="M 28 13 C 21 12 21 6 24 4 C 29 3 32 8 32 13 Z" fill="${light}"/>`; m += `<path d="M 27 11 C 19 11 17 4 21 1 C 27 -1 32 6 32 12 Z" fill="${light}"/>`;
m += `<path class="av-part av-ear av-ear-right" d="M 70 17 C 84 15 84 2 78 0 C 69 -2 63 7 64 17 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4"/>`; m += `<path class="av-part av-ear av-ear-right" d="M 71 16 C 87 15 90 1 82 -4 C 71 -8 62 4 63 17 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5"/>`;
m += `<path d="M 72 13 C 79 12 79 6 76 4 C 71 3 68 8 68 13 Z" fill="${light}"/>`; m += `<path d="M 73 11 C 81 11 83 4 79 1 C 73 -1 68 6 68 12 Z" fill="${light}"/>`;
} }
if(species === "monster"){ if(species === "monster"){
m += `<path class="av-part av-horn" d="M 31 18 C 24 12 25 3 30 -3 C 31 7 35 11 39 16 Z" fill="${outline}"/>`; m += `<path class="av-part av-horn" 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" d="M 69 18 C 76 12 75 3 70 -3 C 69 7 65 11 61 16 Z" fill="${outline}"/>`; m += `<path class="av-part av-horn" d="M 72 15 C 81 8 79 -3 72 -10 C 72 2 66 7 60 13 Z" fill="${outline}"/>`;
} }
m += `<path class="av-part av-leg av-leg-left" d="M 34 80 C 30 87 29 97 30 101 C 23 102 20 106 23 108 C 29 111 39 109 40 104 L 43 84 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4" stroke-linejoin="round"/>`; /* Korte ledematen verschillen per wezen en verdwijnen onder de romp. */
m += `<path class="av-part av-leg av-leg-right" d="M 66 80 C 70 87 71 97 70 101 C 77 102 80 106 77 108 C 71 111 61 109 60 104 L 57 84 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 61 C 29 58 23 62 19 69 L 14 78 C 11 78 8 80 9 83 C 10 87 16 87 19 84 L 26 75 C 29 72 31 73 35 76 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`;
m += `<path class="av-part av-arm av-arm-right" d="M 64 61 C 71 58 77 62 81 69 L 86 78 C 89 78 92 80 91 83 C 90 87 84 87 81 84 L 74 75 C 71 72 69 73 65 76 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`;
m += `<path d="M 11 81 L 15 83 M 89 81 L 85 83" stroke="${outline}" stroke-width="1.1" stroke-linecap="round"/>`;
/* Hals en romp bedekken de aanzetten van armen en benen. */
m += `<path class="av-part av-neck" d="M 40 45 C 42 55 39 58 35 61 L 65 61 C 61 58 58 55 60 45 Z" fill="${dark}" stroke="${outline}" stroke-width="1.4"/>`;
m += `<path class="av-part av-torso" d="M 35 57 C 42 53 58 53 65 57 C 72 63 73 79 67 91 C 60 98 40 98 33 91 C 27 79 28 63 35 57 Z" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5" stroke-linejoin="round"/>`;
m += `<path class="av-part av-belly" d="M 41 63 C 46 59 54 59 59 63 C 64 70 63 85 58 91 C 53 94 47 94 42 91 C 37 85 36 70 41 63 Z" fill="${light}" opacity=".88"/>`;
m += `<path d="M 43 64 C 48 61 53 61 57 63" stroke="${highlight}" stroke-width="2" fill="none" stroke-linecap="round" opacity=".7"/>`;
/* Kop en gelaat; oren en hoorns blijven achter de schedel. */
const headPath = species === "monster"
? "M 50 1 C 34 -1 23 9 23 26 C 22 42 31 53 48 55 C 65 57 78 47 77 28 C 77 10 67 2 50 1 Z"
: species === "dino"
? "M 48 1 C 33 1 23 11 23 27 C 23 41 31 51 43 54 C 58 59 75 51 77 35 C 79 19 69 4 54 2 Z"
: "M 50 1 C 34 1 23 11 23 28 C 23 45 34 55 50 55 C 66 55 77 45 77 28 C 77 11 66 1 50 1 Z";
m += `<path class="av-part av-head" d="${headPath}" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5" stroke-linejoin="round"/>`;
m += `<path d="M 31 13 C 35 7 42 5 48 5" stroke="${highlight}" stroke-width="2.2" fill="none" stroke-linecap="round" opacity=".65"/>`;
if(species === "dino"){ if(species === "dino"){
m += `<path class="av-part av-muzzle" d="M 34 38 C 40 34 62 34 69 40 C 72 47 63 53 51 53 C 39 53 31 47 34 38 Z" fill="${light}"/>`; 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 += `<ellipse cx="43" cy="42" rx="2" ry="2.8" fill="${outline}"/>`; 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"/>`;
m += `<ellipse cx="60" cy="42" rx="2" ry="2.8" fill="${outline}"/>`; 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"/>`;
if(species === "animal"){ m += `<path d="M 23 76 L 27 77 M 77 76 L 73 77" stroke="${outline}" stroke-width="1" stroke-linecap="round"/>`;
m += `<path class="av-part av-muzzle" d="M 37 37 C 41 33 47 34 50 38 C 53 34 59 33 63 37 C 68 43 61 52 50 52 C 39 52 32 43 37 37 Z" fill="${light}"/>`; }else if(species === "monster"){
m += `<path d="M 46 37 Q 50 34 54 37 Q 53 42 50 43 Q 47 42 46 37 Z" fill="#3a2a18"/>`; 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"/>`;
if(species === "monster"){ 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 += `<ellipse class="av-part av-eye" cx="37" cy="25" rx="8.5" ry="9.2" fill="#fff" stroke="${outline}" stroke-width="1"/>`; 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"/>`;
m += `<ellipse class="av-part av-eye" cx="63" cy="25" rx="7" ry="7.8" fill="#fff" stroke="${outline}" stroke-width="1"/>`; m += `<path d="M 16 79 L 21 80 M 84 79 L 79 80 M 29 103 L 34 102 M 71 103 L 66 102" stroke="${outline}" stroke-width="1.1" stroke-linecap="round"/>`;
m += `<circle cx="39" cy="27" r="3.6" fill="#232733"/><circle cx="64" cy="27" r="3" fill="#232733"/>`;
}else{ }else{
m += `<ellipse class="av-part av-eye" cx="38" cy="25" rx="7.5" ry="8.2" fill="#fff" stroke="${outline}" stroke-width="1"/>`; 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 += `<ellipse class="av-part av-eye" cx="62" cy="25" rx="7.5" ry="8.2" fill="#fff" stroke="${outline}" stroke-width="1"/>`; 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 += `<circle cx="39.5" cy="27" r="3.2" fill="#232733"/><circle cx="60.5" cy="27" r="3.2" fill="#232733"/>`; 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 d="M 25 78 Q 28 76 31 78 M 75 78 Q 72 76 69 78" stroke="${outline}" stroke-width="1" fill="none" stroke-linecap="round"/>`;
} }
m += `<circle cx="40.5" cy="25.5" r="1" fill="#fff"/><circle cx="61.5" cy="25.5" r="1" fill="#fff"/>`;
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}" d="${torsoPath}" fill="${bodyColor}" stroke="${outline}" stroke-width="1.5" stroke-linejoin="round"/>`;
if(species === "dino"){ if(species === "dino"){
m += `<path d="M 43 48 Q 51 53 59 48" stroke="${outline}" stroke-width="1.6" fill="none" stroke-linecap="round"/>`; 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"/>`;
m += `<circle cx="38" cy="80" r="1.7" fill="${dark}"/><circle cx="63" cy="74" r="1.5" fill="${dark}"/>`;
}else if(species === "monster"){
m += `<ellipse class="av-part av-belly" cx="50" cy="77" rx="12" ry="14" fill="${light}" opacity=".72"/>`;
m += `<circle cx="43" cy="71" r="2" fill="${dark}"/><circle cx="58" cy="82" r="2.5" fill="${dark}"/>`;
}else{
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(species === "animal"){
m += `<path d="M 50 43 L 50 46 M 42 47 Q 46 52 50 47 Q 54 52 58 47" stroke="#3a2a18" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`; /* De forse kop overlapt de hals en de bovenkant van het kleine lijf. */
const headPath = species === "monster"
? "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"
: species === "dino"
? "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"
: "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";
m += `<path class="av-part av-head av-head-large" d="${headPath}" 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"){
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}"/>`;
}else if(species === "animal"){
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}"/>`;
m += `<path d="M 45 40 Q 50 36 55 40 Q 54 46 50 47 Q 46 46 45 40 Z" fill="#3a2a18"/>`;
} }
if(species === "monster"){ if(species === "monster"){
m += `<path class="av-part av-mouth" d="M 33 41 Q 50 55 67 41 Q 63 52 50 53 Q 37 52 33 41 Z" fill="#3c2158" stroke="${outline}" stroke-width="1"/>`; m += `<ellipse class="av-part av-eye" cx="35" cy="27" rx="10" ry="10.8" fill="#fff" stroke="${outline}" stroke-width="1"/>`;
m += `<path d="M 41 44 L 44 50 L 47 44 Z M 53 44 L 56 50 L 59 44 Z" fill="#fff"/>`; m += `<ellipse class="av-part av-eye" cx="66" cy="27" rx="8.2" ry="9" 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"/>`;
}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"/>`;
m += `<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"/>`;
}
m += `<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(species === "dino"){
m += `<path d="M 42 54 Q 51 59 60 54" stroke="${outline}" stroke-width="1.7" fill="none" stroke-linecap="round"/>`;
}else if(species === "animal"){
m += `<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.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`;
}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"/>`;
m += `<path d="M 39 46 L 43 54 L 47 46 Z M 53 46 L 57 54 L 61 46 Z" fill="#fff"/>`;
} }
return m; return m;
} }
const AVATAR_HAIR_MARKUP = { const AVATAR_HAIR_MARKUP = {
none: () => "", none: { back: () => "", front: () => "" },
spiky: (c) => ` tuft: {
<path d="M 27 18 L 24 4 L 32 15 Z" fill="${c}"/> back: () => "",
<path d="M 37 12 L 36 -1 L 44 10 Z" fill="${c}"/> front: (c) => `<path class="av-part av-hair av-hair-tuft" d="M 40 8 C 34 0 40 -8 48 0 C 49 -10 59 -9 58 1 C 66 -5 72 3 64 10 C 56 6 48 6 40 8 Z" fill="${c}" stroke="${avatarShade(c,-0.35)}" stroke-width="1.3" stroke-linejoin="round"/>`,
<path d="M 48 10 L 50 -3 L 55 10 Z" fill="${c}"/> },
<path d="M 60 10 L 64 -1 L 63 12 Z" fill="${c}"/> spiky: {
<path d="M 68 15 L 76 4 L 73 18 Z" fill="${c}"/>`, back: () => "",
curly: (c) => ` front: (c) => `<path class="av-part av-hair av-hair-spiky" d="M 18 21 C 18 8 22 1 28 -3 L 27 -11 L 38 -3 L 44 -13 L 51 -3 L 62 -13 L 65 -2 L 78 -9 L 76 3 C 81 7 83 14 82 22 C 68 11 32 11 18 21 Z" fill="${c}" stroke="${avatarShade(c,-0.35)}" stroke-width="1.3" stroke-linejoin="round"/>`,
<circle cx="26" cy="14" r="9" fill="${c}"/> },
<circle cx="37" cy="6" r="10" fill="${c}"/> curly: {
<circle cx="50" cy="3" r="10.5" fill="${c}"/> back: () => "",
<circle cx="63" cy="6" r="10" fill="${c}"/> front: (c) => `<g class="av-part av-hair av-hair-curly" fill="${c}" stroke="${avatarShade(c,-0.32)}" stroke-width="1.1">
<circle cx="74" cy="14" r="9" fill="${c}"/>`, <circle cx="19" cy="16" r="8"/><circle cx="27" cy="5" r="9"/><circle cx="39" cy="-1" r="10"/>
ponytail: (c) => ` <circle cx="51" cy="-3" r="10.5"/><circle cx="64" cy="0" r="10"/><circle cx="76" cy="7" r="9"/><circle cx="82" cy="18" r="8"/>
<path d="M 22 20 Q 24 -6 50 -6 Q 76 -6 78 20 Q 62 2 50 2 Q 38 2 22 20 Z" fill="${c}"/> </g>`,
<path d="M 71 9 Q 89 12 87 28 Q 85 42 72 47 Q 81 34 77 22 Q 75 13 67 11 Z" fill="${c}"/> },
<ellipse cx="72" cy="13" rx="4.2" ry="3.2" fill="${avatarShade(c,-0.25)}"/>`, wavy: {
mohawk: (c) => `<path d="M 44 20 Q 43 4 50 1 Q 57 4 56 20 Q 50 15 44 20 Z" fill="${c}"/>`, back: (c) => `<path class="av-part av-hair-back av-hair-wavy" d="M 15 17 C 14 -2 30 -11 50 -11 C 71 -11 87 1 86 23 C 88 37 84 50 76 58 C 76 48 70 43 73 33 C 62 42 37 42 26 33 C 29 44 23 50 23 58 C 15 48 12 34 15 17 Z" fill="${c}" stroke="${avatarShade(c,-0.35)}" stroke-width="1.4"/>`,
front: (c) => `<path class="av-part av-hair av-hair-wavy-fringe" d="M 17 19 C 17 2 31 -8 50 -8 C 69 -8 82 2 83 19 C 76 15 74 9 68 9 C 66 17 58 16 54 10 C 49 18 40 18 37 10 C 31 16 24 17 17 19 Z" fill="${c}" stroke="${avatarShade(c,-0.35)}" stroke-width="1.2"/>`,
},
bob: {
back: (c) => `<path class="av-part av-hair-back av-hair-bob" d="M 13 18 C 13 -2 29 -12 50 -12 C 72 -12 87 0 87 21 L 84 59 L 70 58 L 67 36 L 33 36 L 30 58 L 16 59 Z" fill="${c}" stroke="${avatarShade(c,-0.38)}" stroke-width="1.4" stroke-linejoin="round"/>`,
front: (c) => `<path class="av-part av-hair av-hair-bob-fringe" d="M 16 20 C 17 1 31 -8 50 -8 C 69 -8 82 2 84 20 C 72 18 68 12 64 7 C 58 14 51 17 43 17 C 35 17 28 14 22 20 Z" fill="${c}" stroke="${avatarShade(c,-0.35)}" stroke-width="1.2"/>`,
},
ponytail: {
back: (c) => `<path class="av-part av-hair-back av-hair-ponytail" d="M 72 5 C 91 3 99 16 94 31 C 91 44 82 54 73 59 C 80 45 78 32 70 23 Z" fill="${c}" stroke="${avatarShade(c,-0.38)}" stroke-width="1.4"/><ellipse cx="75" cy="10" rx="5" ry="4" fill="${avatarShade(c,-0.25)}"/>`,
front: (c) => `<path class="av-part av-hair av-hair-ponytail-front" d="M 16 20 C 17 0 31 -10 50 -10 C 69 -10 82 1 84 20 C 68 11 32 11 16 20 Z" fill="${c}" stroke="${avatarShade(c,-0.35)}" stroke-width="1.3"/>`,
},
braids: {
back: (c) => `<g class="av-part av-hair-back av-hair-braids" fill="${c}" stroke="${avatarShade(c,-0.38)}" stroke-width="1">
<path d="M 22 18 C 11 31 15 45 13 58 C 12 65 17 72 22 67 C 26 62 20 57 23 49 C 27 38 30 27 22 18 Z"/>
<path d="M 78 18 C 89 31 85 45 87 58 C 88 65 83 72 78 67 C 74 62 80 57 77 49 C 73 38 70 27 78 18 Z"/>
<path d="M 15 38 L 23 42 M 14 51 L 22 55 M 85 38 L 77 42 M 86 51 L 78 55" fill="none" stroke="${avatarShade(c,0.2)}" stroke-width="2"/>
</g>`,
front: (c) => `<path class="av-part av-hair av-hair-braids-front" d="M 16 20 C 17 0 31 -10 50 -10 C 69 -10 82 1 84 20 C 75 17 69 12 64 7 C 58 14 50 17 42 16 C 34 15 28 12 22 20 Z" fill="${c}" stroke="${avatarShade(c,-0.35)}" stroke-width="1.3"/>`,
},
mohawk: {
back: () => "",
front: (c) => `<path class="av-part av-hair av-hair-mohawk" d="M 40 13 C 38 3 39 -7 45 -13 L 50 -5 L 56 -15 C 62 -6 62 4 59 14 C 53 10 46 10 40 13 Z" fill="${c}" stroke="${avatarShade(c,-0.38)}" stroke-width="1.3" stroke-linejoin="round"/>`,
},
}; };
const AVATAR_HEAD_ACC_MARKUP = { const AVATAR_HEAD_ACC_MARKUP = {
none: () => "", none: () => "",
cap: () => ` cap: () => `
<path class="av-part av-headwear av-cap" d="M 26 15 C 28 1 39 -5 52 -4 C 65 -3 73 4 74 16 C 60 10 41 9 26 15 Z" fill="#3d7ddb" stroke="#234f91" stroke-width="1.3"/> <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 26 14 C 38 10 55 10 67 14 C 57 19 40 19 22 20 C 20 19 22 16 26 14 Z" fill="#2f63b3" stroke="#234f91" stroke-width="1.2"/> <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 50 -4 L 50 11" stroke="#72a3e8" stroke-width="1.1" opacity=".8"/>`, <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: () => ` crown: () => `
<path class="av-part av-headwear av-crown" d="M 27 16 L 30 0 L 39 11 L 50 -5 L 61 11 L 70 0 L 73 16 C 59 12 41 12 27 16 Z" fill="#f0b73a" stroke="#b77b0b" stroke-width="1.2" stroke-linejoin="round"/> <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 28 14 C 42 11 58 11 72 14 L 72 18 C 58 15 42 15 28 18 Z" fill="#d99a22"/> <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="30" cy="1" r="2.6" fill="#e0483c"/> <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"/>`,
<circle cx="50" cy="-4" r="2.6" fill="#3a8ee0"/> wizardhat: () => `
<circle cx="70" cy="1" r="2.6" fill="#e0483c"/>`, <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: () => ` glasses: () => `
<circle class="av-part av-eyewear" cx="38" cy="25" r="9.5" fill="rgba(255,255,255,.3)" stroke="#232733" stroke-width="2.4"/> <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" cx="62" cy="25" r="9.5" fill="rgba(255,255,255,.3)" stroke="#232733" stroke-width="2.4"/> <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 47.5 25 L 52.5 25" stroke="#232733" stroke-width="2.4"/> <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"/>`,
<path d="M 28.5 23 L 22 20" stroke="#232733" stroke-width="2.2" stroke-linecap="round"/>
<path d="M 71.5 23 L 78 20" stroke="#232733" stroke-width="2.2" stroke-linecap="round"/>`,
sunglasses: () => ` sunglasses: () => `
<path class="av-part av-eyewear av-sunglasses" d="M 27 20 Q 27 32 38 32 Q 49 32 48 22 Q 48 18 38 18 Q 27 18 27 20 Z" fill="#1a1d24"/> <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 22 Q 51 32 62 32 Q 73 32 73 20 Q 73 18 62 18 Q 52 18 52 22 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 22 Q 50 20 52 22" stroke="#1a1d24" stroke-width="2.4" fill="none"/> <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 27 21 L 20 19 M 73 21 L 80 19" stroke="#1a1d24" stroke-width="2.4" 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"/>`,
<path d="M 31 22 Q 33 20 36 21 M 56 22 Q 58 20 61 21" stroke="#fff" stroke-width="1.6" 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: () => ` bow: () => `
<path class="av-part av-headwear av-bow" d="M 69 13 C 62 5 56 7 57 14 C 58 20 63 20 69 16 C 75 20 81 20 82 14 C 83 7 76 5 69 13 Z" fill="#e05a8a" stroke="#a93660" stroke-width="1.2"/> <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="69" cy="14" r="3.5" fill="#c23f6e"/>`, <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: () => ` headphones: () => `
<path class="av-part av-headwear av-headphones" d="M 20 25 C 20 4 32 -7 50 -7 C 68 -7 80 4 80 25" stroke="#333" stroke-width="4.5" fill="none" stroke-linecap="round"/> <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="15" y="20" width="10" height="19" rx="5" fill="#333" stroke="#17181c" stroke-width="1.2"/> <rect x="8" y="22" width="12" height="23" rx="6" fill="#333" stroke="#17181c" stroke-width="1.3"/>
<rect x="75" y="20" width="10" height="19" rx="5" fill="#333" stroke="#17181c" stroke-width="1.2"/> <rect x="80" y="22" width="12" height="23" rx="6" fill="#333" stroke="#17181c" stroke-width="1.3"/>
<rect x="18" y="24" width="4" height="11" rx="2" fill="#667"/> <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"/>`,
<rect x="78" y="24" width="4" height="11" rx="2" fill="#667"/>`,
partyhat: () => ` partyhat: () => `
<path class="av-part av-headwear av-partyhat" d="M 50 -8 L 61 19 C 54 22 46 22 39 19 Z" fill="#e05a8a" stroke="#a93660" stroke-width="1.2"/> <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 39.5 18 C 46 21 54 21 60.5 18" stroke="#f4c34e" stroke-width="2" fill="none"/> <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.5" cy="8" r="1.8" fill="#f0b73a"/> <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="54" cy="12" r="1.8" fill="#3a8ee0"/> <circle cx="50" cy="-15" r="4.2" fill="#f0b73a"/>`,
<circle cx="47.5" cy="16" r="1.8" fill="#f0b73a"/>
<circle cx="50" cy="-8" r="3.6" fill="#f0b73a"/>`,
}; };
const AVATAR_BODY_ACC_MARKUP = { const AVATAR_BODY_ACC_MARKUP = {
@ -187,45 +244,72 @@ const AVATAR_BODY_ACC_MARKUP = {
scarf: { scarf: {
back: () => "", back: () => "",
front: () => ` front: () => `
<path class="av-part av-neckwear av-scarf" d="M 35 53 C 43 57 57 57 65 53 L 66 62 C 57 67 43 67 34 62 Z" fill="#d94f4f" stroke="#9d3030" stroke-width="1.2"/> <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 63 L 62 84 L 53 79 L 50 64 Z" fill="#c23f3f" stroke="#9d3030" stroke-width="1.1" stroke-linejoin="round"/> <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 55 75 L 62 78" stroke="#f08080" stroke-width="1.2"/>`, <path d="M 56 79 L 62 82" stroke="#f08080" stroke-width="1.2"/>`,
}, },
cape: { cape: {
back: () => ` back: () => `
<path class="av-part av-backwear av-cape" d="M 33 55 C 19 62 13 79 13 108 C 26 103 38 99 50 91 C 62 99 74 103 87 108 C 87 79 81 62 67 55 C 58 61 42 61 33 55 Z" fill="#7a4bc7" stroke="#54308f" stroke-width="1.5" stroke-linejoin="round"/> <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 26 63 C 22 76 23 91 26 101" stroke="#a67de0" stroke-width="2" fill="none" opacity=".65"/>`, <path d="M 28 67 C 24 80 25 94 28 102" stroke="#a67de0" stroke-width="2" fill="none" opacity=".65"/>`,
front: () => ` front: () => `
<path class="av-part av-cape-clasp" d="M 35 56 C 42 61 58 61 65 56" stroke="#f0b73a" stroke-width="2.4" fill="none" stroke-linecap="round"/> <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="35" cy="56" r="2.5" fill="#f0b73a"/> <circle cx="36" cy="61" r="2.5" fill="#f0b73a"/><circle cx="64" cy="61" r="2.5" fill="#f0b73a"/>`,
<circle cx="65" cy="56" r="2.5" fill="#f0b73a"/>`,
}, },
badge: { badge: {
back: () => "", back: () => "",
front: () => ` front: () => `
<path class="av-part av-chestwear av-badge" d="M 57 67 L 63 67 L 66 72 L 63 78 L 57 78 L 54 72 Z" fill="#f0b73a" stroke="#fff" stroke-width="1.5"/> <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 60 69 L 61 72 L 64 72 L 61.5 74 L 62.5 77 L 60 75 L 57.5 77 L 58.5 74 L 56 72 L 59 72 Z" fill="#b8750b"/>`, <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: { necklace: {
back: () => "", back: () => "",
front: () => ` front: () => `
<path class="av-part av-neckwear av-necklace" d="M 35 57 C 38 69 44 76 50 77 C 56 76 62 69 65 57" stroke="#f0b73a" stroke-width="2" fill="none" stroke-linecap="round"/> <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 74 L 55 80 L 50 85 L 45 80 Z" fill="#3a8ee0" stroke="#f0b73a" stroke-width="1.4"/>`, <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: { backpack: {
back: () => ` back: () => `
<path class="av-part av-backwear av-backpack" d="M 28 63 C 29 54 39 50 50 50 C 61 50 71 54 72 63 L 77 89 C 70 96 30 96 23 89 Z" fill="#8a6a45" stroke="#5a4029" stroke-width="1.6"/> <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 39 53 C 40 46 60 46 61 53" stroke="#5a4029" stroke-width="3" fill="none"/>`, <path d="M 40 55 C 41 49 59 49 60 55" stroke="#5a4029" stroke-width="3" fill="none"/>`,
front: () => ` front: () => `
<path class="av-part av-backpack-strap" d="M 37 57 C 29 63 29 76 33 84 M 63 57 C 71 63 71 76 67 84" stroke="#5a4632" stroke-width="4" fill="none" stroke-linecap="round"/> <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 33 83 L 38 86 M 67 83 L 62 86" stroke="#d0a36d" stroke-width="1.5"/>`, <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"/>`,
}, },
}; };
/* Alleen een pet bedekt het bovenhoofd volledig. Een kroon rust juist op het /* Pet en muts bedekken de voorste haarlaag. Lange stijlen houden hun
haar; een paardenstaart blijft naast de pet zichtbaar. */ achterlaag, zodat staarten, vlechten en lokken naast de kop zichtbaar zijn. */
function avatarHairSuppressed(c){ function avatarHairSuppressed(c){
return c.accessoryHead === "cap" && c.hairStyle !== "ponytail"; return c.accessoryHead === "cap" || c.accessoryHead === "beanie";
} }
/* bouwt de SVG-weergave van een personage; config==null neutrale /* bouwt de SVG-weergave van een personage; config==null neutrale
@ -236,15 +320,17 @@ function renderAvatarFace(config, size){
const c = avatarSanitize(config); const c = avatarSanitize(config);
const svg = document.createElementNS(AVATAR_SVG_NS, "svg"); const svg = document.createElementNS(AVATAR_SVG_NS, "svg");
/* Extra ruimte voorkomt afsnijden van hoofddeksels, haar en voetzolen. */ /* Extra ruimte voorkomt afsnijden van hoofddeksels, haar en voetzolen. */
svg.setAttribute("viewBox", "-6 -12 112 124"); svg.setAttribute("viewBox", "-8 -21 116 132");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.setAttribute("class", "avatar-face " + (size || "md") + " av-species-" + c.species); svg.setAttribute("class", "avatar-face " + (size || "md") + " av-species-" + c.species);
svg.setAttribute("role", "img"); svg.setAttribute("role", "img");
svg.setAttribute("aria-label", T("avatarAlt")); svg.setAttribute("aria-label", T("avatarAlt"));
const bodyAccessory = AVATAR_BODY_ACC_MARKUP[c.accessoryBody]; const bodyAccessory = AVATAR_BODY_ACC_MARKUP[c.accessoryBody];
const hair = AVATAR_HAIR_MARKUP[c.hairStyle];
let markup = bodyAccessory.back(); let markup = bodyAccessory.back();
markup += hair.back(c.hairColor);
markup += avatarBodyMarkup(c.species, c.bodyColor); markup += avatarBodyMarkup(c.species, c.bodyColor);
if(!avatarHairSuppressed(c)) markup += AVATAR_HAIR_MARKUP[c.hairStyle](c.hairColor); if(!avatarHairSuppressed(c)) markup += hair.front(c.hairColor);
markup += AVATAR_HEAD_ACC_MARKUP[c.accessoryHead](); markup += AVATAR_HEAD_ACC_MARKUP[c.accessoryHead]();
markup += bodyAccessory.front(); markup += bodyAccessory.front();
svg.innerHTML = markup; svg.innerHTML = markup;

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.46-beta"; const VERSION = "0.4.47-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -166,12 +166,13 @@ const I18N = {
stAvatar:"Mijn personage", stAvatarSub:"Stel je eigen personage samen.", stAvatar:"Mijn personage", stAvatarSub:"Stel je eigen personage samen.",
avatarAlt:"Mijn personage", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Dier", avatarAlt:"Mijn personage", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Dier",
avatarBodyColor:"Lichaamskleur", avatarHairColor:"Haarkleur", avatarHairStyle:"Haarstijl", avatarBodyColor:"Lichaamskleur", avatarHairColor:"Haarkleur", avatarHairStyle:"Haarstijl",
avatarHair_none:"Geen", avatarHair_spiky:"Piekerig", avatarHair_curly:"Krullen", avatarHair_ponytail:"Staartje", avatarHair_mohawk:"Hanenkam", avatarHair_none:"Geen", avatarHair_tuft:"Kuifje", avatarHair_spiky:"Piekerig", avatarHair_curly:"Krullen", avatarHair_wavy:"Golvend", avatarHair_bob:"Boblijn", avatarHair_ponytail:"Staartje", avatarHair_braids:"Vlechten", avatarHair_mohawk:"Hanenkam",
avatarAccHead:"Hoofdaccessoire", avatarAccBody:"Lichaamsaccessoire", avatarAccHead:"Hoofdaccessoire", avatarAccBody:"Lichaamsaccessoire",
avatarAcc_none:"Geen", avatarAcc_cap:"Petje", avatarAcc_crown:"Kroon", avatarAcc_glasses:"Bril", avatarAcc_bow:"Strik", avatarAcc_none:"Geen", avatarAcc_cap:"Petje", avatarAcc_beanie:"Muts", avatarAcc_crown:"Kroon", avatarAcc_wizardhat:"Tovenaarshoed",
avatarAcc_sunglasses:"Zonnebril", avatarAcc_headphones:"Koptelefoon", avatarAcc_partyhat:"Feesthoedje", avatarAcc_glasses:"Bril", avatarAcc_sunglasses:"Zonnebril", avatarAcc_eyepatch:"Ooglapje", avatarAcc_bow:"Strik", avatarAcc_flower:"Bloem",
avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape", avatarAcc_badge:"Insigne", avatarAcc_headphones:"Koptelefoon", avatarAcc_partyhat:"Feesthoedje", avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape",
avatarAcc_necklace:"Ketting", avatarAcc_backpack:"Rugtas", avatarAcc_badge:"Insigne", avatarAcc_necklace:"Ketting", avatarAcc_bowtie:"Vlinderdas", avatarAcc_belt:"Riem",
avatarAcc_backpack:"Rugtas", avatarAcc_satchel:"Schoudertas", avatarAcc_wings:"Vleugels",
avatarSave:"Opslaan", avatarSaved:"Personage opgeslagen ✓", avatarSave:"Opslaan", avatarSaved:"Personage opgeslagen ✓",
clock24Hour:"24-uursklok", clock24Hour:"24-uursklok",
widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies Nieuwe opdracht om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw", widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies Nieuwe opdracht om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw",
@ -552,12 +553,13 @@ const I18N = {
stAvatar:"My character", stAvatarSub:"Build your own character.", stAvatar:"My character", stAvatarSub:"Build your own character.",
avatarAlt:"My character", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Animal", avatarAlt:"My character", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Animal",
avatarBodyColor:"Body colour", avatarHairColor:"Hair colour", avatarHairStyle:"Hair style", avatarBodyColor:"Body colour", avatarHairColor:"Hair colour", avatarHairStyle:"Hair style",
avatarHair_none:"None", avatarHair_spiky:"Spiky", avatarHair_curly:"Curly", avatarHair_ponytail:"Ponytail", avatarHair_mohawk:"Mohawk", avatarHair_none:"None", avatarHair_tuft:"Tuft", avatarHair_spiky:"Spiky", avatarHair_curly:"Curly", avatarHair_wavy:"Wavy", avatarHair_bob:"Bob", avatarHair_ponytail:"Ponytail", avatarHair_braids:"Braids", avatarHair_mohawk:"Mohawk",
avatarAccHead:"Head accessory", avatarAccBody:"Body accessory", avatarAccHead:"Head accessory", avatarAccBody:"Body accessory",
avatarAcc_none:"None", avatarAcc_cap:"Cap", avatarAcc_crown:"Crown", avatarAcc_glasses:"Glasses", avatarAcc_bow:"Bow", avatarAcc_none:"None", avatarAcc_cap:"Cap", avatarAcc_beanie:"Beanie", avatarAcc_crown:"Crown", avatarAcc_wizardhat:"Wizard hat",
avatarAcc_sunglasses:"Sunglasses", avatarAcc_headphones:"Headphones", avatarAcc_partyhat:"Party hat", avatarAcc_glasses:"Glasses", avatarAcc_sunglasses:"Sunglasses", avatarAcc_eyepatch:"Eye patch", avatarAcc_bow:"Bow", avatarAcc_flower:"Flower",
avatarAcc_scarf:"Scarf", avatarAcc_cape:"Cape", avatarAcc_badge:"Badge", avatarAcc_headphones:"Headphones", avatarAcc_partyhat:"Party hat", avatarAcc_scarf:"Scarf", avatarAcc_cape:"Cape",
avatarAcc_necklace:"Necklace", avatarAcc_backpack:"Backpack", avatarAcc_badge:"Badge", avatarAcc_necklace:"Necklace", avatarAcc_bowtie:"Bow tie", avatarAcc_belt:"Belt",
avatarAcc_backpack:"Backpack", avatarAcc_satchel:"Satchel", avatarAcc_wings:"Wings",
avatarSave:"Save", avatarSaved:"Character saved ✓", avatarSave:"Save", avatarSaved:"Character saved ✓",
clock24Hour:"24-hour clock", clock24Hour:"24-hour clock",
widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose New task to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo", widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose New task to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo",

View file

@ -195,9 +195,9 @@ export default async function api(app) {
// Open voor iedere ingelogde rol (ook leerling/ouder) - net als /me/data hierboven, // Open voor iedere ingelogde rol (ook leerling/ouder) - net als /me/data hierboven,
// geen rol-beperkte PERMISSIONS-sleutel nodig, altijd de eigen rij. // geen rol-beperkte PERMISSIONS-sleutel nodig, altijd de eigen rij.
const AVATAR_SPECIES = ['dino', 'monster', 'animal']; const AVATAR_SPECIES = ['dino', 'monster', 'animal'];
const AVATAR_HAIR = ['none', 'spiky', 'curly', 'ponytail', 'mohawk']; const AVATAR_HAIR = ['none', 'tuft', 'spiky', 'curly', 'wavy', 'bob', 'ponytail', 'braids', 'mohawk'];
const AVATAR_HEAD_ACC = ['none', 'cap', 'crown', 'glasses', 'sunglasses', 'bow', 'headphones', 'partyhat']; 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', 'backpack']; const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge', 'necklace', 'bowtie', 'belt', 'backpack', 'satchel', 'wings'];
const isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v); const isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v);
app.put('/me/avatar', async (req, reply) => { app.put('/me/avatar', async (req, reply) => {
need(req, reply); need(req, reply);

View file

@ -7,25 +7,22 @@ async function loadAvatarRenderer(){
const source=await readFile('public/js/avatar.js','utf8'); const source=await readFile('public/js/avatar.js','utf8');
const cutoff=source.indexOf('/* bouwt de interactieve kiezer'); const cutoff=source.indexOf('/* bouwt de interactieve kiezer');
assert.ok(cutoff>0,'rendergedeelte van avatar.js ontbreekt'); assert.ok(cutoff>0,'rendergedeelte van avatar.js ontbreekt');
const created=[];
const context={ const context={
T:key=>key, T:key=>key,
document:{ document:{
createElementNS(){ createElementNS(){
const element={ return {
attributes:{}, attributes:{},
innerHTML:'', innerHTML:'',
setAttribute(name,value){this.attributes[name]=String(value);}, setAttribute(name,value){this.attributes[name]=String(value);},
}; };
created.push(element);
return element;
}, },
}, },
}; };
runInNewContext(source.slice(0,cutoff)+ runInNewContext(source.slice(0,cutoff)+
';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP};', ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC};',
context); context);
return {api:context.__avatar,created}; return context.__avatar;
} }
const base={ const base={
@ -37,59 +34,103 @@ const base={
accessoryBody:'none', accessoryBody:'none',
}; };
test('avatars hebben verbonden en herkenbare lichaamsdelen per soort',async()=>{ test('avatars combineren een grote kop met een compact soortspecifiek lijf',async()=>{
const {api}=await loadAvatarRenderer(); const api=await loadAvatarRenderer();
const bodies={};
for(const species of ['dino','monster','animal']){ for(const species of ['dino','monster','animal']){
const markup=api.avatarBodyMarkup(species,base.bodyColor); const markup=api.avatarBodyMarkup(species,base.bodyColor);
bodies[species]=markup;
assert.equal((markup.match(/av-arm av-arm-/g)||[]).length,2,species+' mist twee armen'); assert.equal((markup.match(/av-arm av-arm-/g)||[]).length,2,species+' mist twee armen');
assert.equal((markup.match(/av-leg av-leg-/g)||[]).length,2,species+' mist twee benen'); assert.equal((markup.match(/av-leg av-leg-/g)||[]).length,2,species+' mist twee benen');
for(const part of ['av-neck','av-torso','av-head','av-eye']){ for(const part of ['av-neck','av-torso-'+species,'av-head-large','av-eye']){
assert.ok(markup.includes(part),species+' mist '+part); assert.ok(markup.includes(part),species+' mist '+part);
} }
assert.ok(markup.indexOf('av-arm')<markup.indexOf('av-torso'),species+'-armen moeten achter de romp aansluiten'); assert.ok(markup.indexOf('av-arm')<markup.indexOf('av-torso'),species+'-armen moeten achter de compacte romp aansluiten');
assert.ok(markup.indexOf('av-leg')<markup.indexOf('av-torso'),species+'-benen moeten onder de romp aansluiten'); assert.ok(markup.indexOf('av-leg')<markup.indexOf('av-torso'),species+'-benen moeten onder de compacte romp aansluiten');
assert.ok(markup.indexOf('av-torso')<markup.indexOf('av-head-large'),species+'-kop moet de bovenkant van het lijf overlappen');
} }
const dino=api.avatarBodyMarkup('dino',base.bodyColor); assert.ok(bodies.dino.indexOf('av-tail')<bodies.dino.indexOf('av-head-large'));
const monster=api.avatarBodyMarkup('monster',base.bodyColor); assert.ok(bodies.dino.indexOf('av-crest')<bodies.dino.indexOf('av-head-large'));
const animal=api.avatarBodyMarkup('animal',base.bodyColor); assert.equal((bodies.monster.match(/av-horn/g)||[]).length,2);
assert.ok(dino.indexOf('av-tail')<dino.indexOf('av-head')); assert.equal((bodies.animal.match(/av-ear av-ear-/g)||[]).length,2);
assert.ok(dino.indexOf('av-crest')<dino.indexOf('av-head')); assert.ok(bodies.animal.includes('av-chest-fluff'));
assert.equal((monster.match(/av-horn/g)||[]).length,2); assert.notEqual(bodies.dino,bodies.monster);
assert.equal((animal.match(/av-ear av-ear-/g)||[]).length,2); assert.notEqual(bodies.monster,bodies.animal);
}); });
test('lichaamsaccessoires gebruiken anatomisch correcte voor- en achterlagen',async()=>{ test('haar heeft natuurlijke voor- en achterlagen en vier nieuwe stijlen',async()=>{
const {api}=await loadAvatarRenderer(); const api=await loadAvatarRenderer();
for(const id of ['none','scarf','cape','badge','necklace','backpack']){ assert.deepEqual(Array.from(api.hairIds),['none','tuft','spiky','curly','wavy','bob','ponytail','braids','mohawk']);
for(const id of api.hairIds){
assert.equal(typeof api.hair[id].back,'function',id+' mist haar-achterlaag');
assert.equal(typeof api.hair[id].front,'function',id+' mist haar-voorlaag');
}
for(const id of ['tuft','wavy','bob','braids']){
const markup=api.renderAvatarFace({...base,hairStyle:id,hairColor:'#123456'},'lg').innerHTML;
assert.ok(markup.includes('av-hair-'+id),id+' wordt niet getekend');
assert.ok(markup.includes('#123456'),id+' gebruikt de gekozen haarkleur niet');
}
const bob=api.renderAvatarFace({...base,hairStyle:'bob'},'lg').innerHTML;
assert.ok(bob.indexOf('av-hair-bob')<bob.indexOf('av-head-large'));
assert.ok(bob.indexOf('av-hair-bob-fringe')>bob.indexOf('av-head-large'));
const braids=api.renderAvatarFace({...base,hairStyle:'braids'},'lg').innerHTML;
assert.ok(braids.indexOf('av-hair-braids')<braids.indexOf('av-head-large'));
assert.ok(braids.indexOf('av-hair-braids-front')>braids.indexOf('av-head-large'));
const cap=api.renderAvatarFace({...base,hairStyle:'spiky',hairColor:'#123456',accessoryHead:'cap'},'lg').innerHTML;
const beanie=api.renderAvatarFace({...base,hairStyle:'spiky',hairColor:'#123456',accessoryHead:'beanie'},'lg').innerHTML;
const crown=api.renderAvatarFace({...base,hairStyle:'spiky',hairColor:'#123456',accessoryHead:'crown'},'lg').innerHTML;
const cappedBraids=api.renderAvatarFace({...base,hairStyle:'braids',hairColor:'#123456',accessoryHead:'cap'},'lg').innerHTML;
assert.ok(!cap.includes('#123456'),'pet hoort de voorste haarlaag af te dekken');
assert.ok(!beanie.includes('#123456'),'muts hoort de voorste haarlaag af te dekken');
assert.ok(crown.includes('#123456'),'kroon hoort op het haar te rusten');
assert.ok(cappedBraids.includes('#123456'),'vlechten horen naast een pet zichtbaar te blijven');
});
test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async()=>{
const api=await loadAvatarRenderer();
assert.deepEqual(Array.from(api.headIds),['none','cap','beanie','crown','wizardhat','glasses','sunglasses','eyepatch','bow','flower','headphones','partyhat']);
assert.deepEqual(Array.from(api.bodyIds),['none','scarf','cape','badge','necklace','bowtie','belt','backpack','satchel','wings']);
for(const id of api.bodyIds){
assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag'); assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag');
assert.equal(typeof api.bodyAccessories[id].front,'function',id+' mist voorlaag'); assert.equal(typeof api.bodyAccessories[id].front,'function',id+' mist voorlaag');
} }
for(const [id,part] of Object.entries({beanie:'av-beanie',wizardhat:'av-wizardhat',eyepatch:'av-eyepatch',flower:'av-flower'})){
const markup=api.renderAvatarFace({...base,accessoryHead:id},'lg').innerHTML;
assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort op de kop');
}
const cape=api.renderAvatarFace({...base,accessoryBody:'cape'},'lg').innerHTML; const cape=api.renderAvatarFace({...base,accessoryBody:'cape'},'lg').innerHTML;
assert.ok(cape.indexOf('av-cape\"')<cape.indexOf('av-torso\"')); assert.ok(cape.indexOf('av-cape"')<cape.indexOf('av-torso'));
assert.ok(cape.indexOf('av-cape-clasp')>cape.indexOf('av-head\"')); assert.ok(cape.indexOf('av-cape-clasp')>cape.indexOf('av-head-large'));
const backpack=api.renderAvatarFace({...base,accessoryBody:'backpack'},'lg').innerHTML; const backpack=api.renderAvatarFace({...base,accessoryBody:'backpack'},'lg').innerHTML;
assert.ok(backpack.indexOf('av-backpack\"')<backpack.indexOf('av-torso\"')); assert.ok(backpack.indexOf('av-backpack"')<backpack.indexOf('av-torso'));
assert.ok(backpack.indexOf('av-backpack-strap')>backpack.indexOf('av-head\"')); assert.ok(backpack.indexOf('av-backpack-strap')>backpack.indexOf('av-head-large'));
for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace']]){ const satchel=api.renderAvatarFace({...base,accessoryBody:'satchel'},'lg').innerHTML;
assert.ok(satchel.indexOf('av-satchel"')<satchel.indexOf('av-torso'));
assert.ok(satchel.indexOf('av-satchel-strap')>satchel.indexOf('av-head-large'));
const wings=api.renderAvatarFace({...base,accessoryBody:'wings'},'lg').innerHTML;
assert.ok(wings.indexOf('av-wings')<wings.indexOf('av-torso'));
assert.ok(wings.indexOf('av-wing-clasp')>wings.indexOf('av-head-large'));
for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace'],['bowtie','av-bowtie'],['belt','av-belt']]){
const markup=api.renderAvatarFace({...base,accessoryBody:id},'lg').innerHTML; const markup=api.renderAvatarFace({...base,accessoryBody:id},'lg').innerHTML;
assert.ok(markup.indexOf(part)>markup.indexOf('av-head\"'),id+' hoort vóór op het lichaam'); assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort voor op het lijf');
} }
}); });
test('alle avatarcombinaties renderen volledig binnen de verruimde SVG',async()=>{ test('alle 3240 avatarcombinaties renderen volledig binnen de SVG',async()=>{
const {api}=await loadAvatarRenderer(); const api=await loadAvatarRenderer();
const species=['dino','monster','animal']; let combinations=0;
const hair=['none','spiky','curly','ponytail','mohawk']; for(const speciesId of ['dino','monster','animal']){
const head=['none','cap','crown','glasses','sunglasses','bow','headphones','partyhat']; for(const hairId of api.hairIds){
const body=['none','scarf','cape','badge','necklace','backpack']; for(const headId of api.headIds){
for(const bodyId of api.bodyIds){
for(const speciesId of species){
for(const hairId of hair){
for(const headId of head){
for(const bodyId of body){
const svg=api.renderAvatarFace({ const svg=api.renderAvatarFace({
...base, ...base,
species:speciesId, species:speciesId,
@ -97,18 +138,31 @@ test('alle avatarcombinaties renderen volledig binnen de verruimde SVG',async()=
accessoryHead:headId, accessoryHead:headId,
accessoryBody:bodyId, accessoryBody:bodyId,
},'sm'); },'sm');
assert.equal(svg.attributes.viewBox,'-6 -12 112 124'); assert.equal(svg.attributes.viewBox,'-8 -21 116 132');
assert.equal(svg.attributes.preserveAspectRatio,'xMidYMid meet'); assert.equal(svg.attributes.preserveAspectRatio,'xMidYMid meet');
assert.doesNotMatch(svg.innerHTML,/undefined|NaN/); assert.doesNotMatch(svg.innerHTML,/undefined|NaN/);
assert.ok(svg.innerHTML.includes('av-head')); assert.ok(svg.innerHTML.includes('av-head-large'));
combinations++;
} }
} }
} }
} }
assert.equal(combinations,3240);
const cap=api.renderAvatarFace({...base,hairStyle:'spiky',hairColor:'#123456',accessoryHead:'cap'},'lg').innerHTML; });
const crown=api.renderAvatarFace({...base,hairStyle:'spiky',hairColor:'#123456',accessoryHead:'crown'},'lg').innerHTML;
assert.ok(!cap.includes('#123456'),'pet hoort het bovenhaar af te dekken'); test('client, API en beide talen bevatten dezelfde nieuwe avataropties',async()=>{
assert.ok(crown.includes('#123456'),'kroon hoort op het haar te rusten'); const [api,core]=await Promise.all([
for(const id of head)assert.equal(typeof api.headAccessories[id],'function'); readFile('src/api.js','utf8'),
readFile('public/js/core.js','utf8'),
]);
const hair=['tuft','wavy','bob','braids'];
const accessories=['beanie','wizardhat','eyepatch','flower','bowtie','belt','satchel','wings'];
for(const id of hair){
assert.ok(api.includes("'"+id+"'"),id+' ontbreekt in API-validatie');
assert.equal(core.split('avatarHair_'+id+':').length-1,2,id+' mist een vertaling');
}
for(const id of accessories){
assert.ok(api.includes("'"+id+"'"),id+' ontbreekt in API-validatie');
assert.equal(core.split('avatarAcc_'+id+':').length-1,2,id+' mist een vertaling');
}
}); });