feat: verbeter anatomie en accessoires van avatars (v0.4.46-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 58s

This commit is contained in:
Ramon 2026-07-21 23:25:12 +02:00
parent a922caaa56
commit d0522d7e33
5 changed files with 248 additions and 100 deletions

View file

@ -1 +1 @@
0.4.45-beta
0.4.46-beta

View file

@ -1779,8 +1779,8 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000;
.bd-candle::before{content:""; position:absolute; width:8px; height:12px; left:-1.5px; top:-12px; border-radius:70% 30% 60% 40%; background:linear-gradient(#fff7a0,var(--orange)); box-shadow:0 0 8px var(--orange);}
.bd-age-badge{position:absolute; right:8%; top:20%; z-index:4; min-width:58px; height:58px; padding:0 10px; border-radius:20px; display:grid; place-items:center; color:#fff; font-size:29px; font-weight:900; transform:rotate(7deg); box-shadow:0 5px 12px rgba(0,0,0,.2); border:3px solid #fff;}
.bd-plate{width:min(94%,420px); height:30px; border-radius:50%; margin-top:-12px; box-shadow:0 8px 12px rgba(29,52,97,.22); z-index:1;}
/* =========== Personage (avatar): gelaagde div's + CSS custom properties,
zelfde aanpak als de taart hierboven (--cake/--icing), geen SVG/afbeeldingen. ==== */
/* =========== Personage (avatar): schaalbare SVG met anatomische ankerpunten
en vaste voor-/achterlagen voor haar en accessoires. ====================== */
.avatar-face{display:inline-block; flex:none; overflow:visible;}
.avatar-face.sm{width:24px; height:24px;}
.avatar-face.md{width:44px; height:44px;}

View file

@ -1,6 +1,6 @@
/* 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 (viewBox 0 0 100 100, tekenvolgorde =
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. */
@ -39,70 +39,82 @@ function avatarShade(hex, amt){
return "#" + [r,g,b].map(v=>clamp(v).toString(16).padStart(2,"0")).join("");
}
/* per-soort silhouet: elk onderdeel overlapt het vorige ruim (poten/armen/
staart tot ver ín het lijf, rugstekels/hoorns pas de kop getekend) zodat
niets los oogt. Volgorde = tekenvolgorde = zichtbare laagvolgorde. */
/* Per-soort silhouet met vaste anatomische ankers: kruin y=2, ogen y=25,
hals y=53, schouders y=60, heupen y=88 en voetzolen y=108. Armen en benen
lopen bewust onder het lijf door. Daardoor blijven gewrichten verbonden en
kunnen accessoires steeds aan hetzelfde lichaamsdeel worden vastgemaakt. */
function avatarBodyMarkup(species, bodyColor){
const dark = avatarShade(bodyColor, -0.35);
const outline = avatarShade(bodyColor, -0.48);
const dark = avatarShade(bodyColor, -0.24);
const light = avatarShade(bodyColor, 0.4);
const highlight = avatarShade(bodyColor, 0.7);
let m = "";
/* Onderdelen achter het lijf: soortdetails, benen en doorlopende armen. */
if(species === "dino"){
m += `<path d="M 38 88 C 18 92, 2 82, 1 64 C 3 78, 18 86, 35 76 Z" fill="${dark}"/>`;
}
m += `<ellipse cx="34" cy="92" rx="10" ry="8" fill="${dark}"/>`;
m += `<ellipse cx="66" cy="92" rx="10" ry="8" fill="${dark}"/>`;
m += `<ellipse cx="50" cy="72" rx="24" ry="19" fill="${bodyColor}"/>`;
m += `<ellipse cx="50" cy="75" rx="13" ry="13" fill="${light}"/>`;
m += `<ellipse cx="25" cy="68" rx="10" ry="8" fill="${dark}"/>`;
m += `<ellipse cx="75" cy="68" rx="10" ry="8" fill="${dark}"/>`;
if(species === "animal"){
m += `<circle cx="27" cy="10" r="11" fill="${bodyColor}"/>`;
m += `<circle cx="73" cy="10" r="11" fill="${bodyColor}"/>`;
m += `<circle cx="27" cy="10" r="6" fill="${light}"/>`;
m += `<circle cx="73" cy="10" r="6" fill="${light}"/>`;
}
if(species === "monster"){
const darker = avatarShade(bodyColor, -0.5);
m += `<path d="M 30 20 C 26 12, 27 4, 32 0 C 33 8, 34 15, 38 20 Z" fill="${darker}"/>`;
m += `<path d="M 70 20 C 74 12, 73 4, 68 0 C 67 8, 66 15, 62 20 Z" fill="${darker}"/>`;
}
m += `<ellipse cx="50" cy="28" rx="26" ry="26" fill="${bodyColor}"/>`;
if(species === "dino"){
m += `<ellipse cx="50" cy="43" rx="15" ry="10" fill="${light}"/>`;
m += `<ellipse cx="44.5" cy="41" rx="2.1" ry="3" fill="#234"/>`;
m += `<ellipse cx="55.5" cy="41" rx="2.1" ry="3" fill="#234"/>`;
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-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"/>`;
}
if(species === "animal"){
m += `<ellipse cx="50" cy="42" rx="14" ry="10" fill="${light}"/>`;
m += `<ellipse cx="50" cy="38" rx="4.4" ry="3.4" fill="#3a2a18"/>`;
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 d="M 28 13 C 21 12 21 6 24 4 C 29 3 32 8 32 13 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 d="M 72 13 C 79 12 79 6 76 4 C 71 3 68 8 68 13 Z" fill="${light}"/>`;
}
if(species === "monster"){
m += `<circle cx="37" cy="25" r="8.4" fill="#fff"/>`;
m += `<circle cx="63" cy="25" r="6.8" fill="#fff"/>`;
m += `<circle cx="38.6" cy="27" r="3.6" fill="#232733"/>`;
m += `<circle cx="64.2" cy="27" r="2.9" fill="#232733"/>`;
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 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-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"/>`;
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"){
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 += `<ellipse cx="43" cy="42" rx="2" ry="2.8" fill="${outline}"/>`;
m += `<ellipse cx="60" cy="42" rx="2" ry="2.8" fill="${outline}"/>`;
}
if(species === "animal"){
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}"/>`;
m += `<path d="M 46 37 Q 50 34 54 37 Q 53 42 50 43 Q 47 42 46 37 Z" fill="#3a2a18"/>`;
}
if(species === "monster"){
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 += `<ellipse class="av-part av-eye" cx="63" cy="25" rx="7" ry="7.8" fill="#fff" stroke="${outline}" stroke-width="1"/>`;
m += `<circle cx="39" cy="27" r="3.6" fill="#232733"/><circle cx="64" cy="27" r="3" fill="#232733"/>`;
}else{
m += `<circle cx="38" cy="24" r="7.6" fill="#fff"/>`;
m += `<circle cx="62" cy="24" r="7.6" fill="#fff"/>`;
m += `<circle cx="39.4" cy="26" r="3.2" fill="#232733"/>`;
m += `<circle cx="60.4" cy="26" r="3.2" fill="#232733"/>`;
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 += `<ellipse class="av-part av-eye" cx="62" cy="25" rx="7.5" ry="8.2" fill="#fff" stroke="${outline}" stroke-width="1"/>`;
m += `<circle cx="39.5" cy="27" r="3.2" fill="#232733"/><circle cx="60.5" cy="27" r="3.2" fill="#232733"/>`;
}
m += `<circle cx="40.5" cy="25.5" r="1" fill="#fff"/><circle cx="61.5" cy="25.5" r="1" fill="#fff"/>`;
if(species === "dino"){
m += `<path d="M 43 51 Q 50 55.5 57 51" stroke="${avatarShade(bodyColor,-0.55)}" stroke-width="1.6" fill="none" stroke-linecap="round"/>`;
/* rugstekels als klein randje bovenop de kop i.p.v. voor de hals - een
rij spitse vormen op de borst/hals oogde als een vreemde kraag; dit
zit anatomisch dichter bij de rug en is ook stiller op klein formaat. */
const plate = avatarShade(bodyColor, -0.45);
m += `<path d="M 39 13 Q 38 2 44 0 Q 46 7 45 15 Z" fill="${plate}"/>`;
m += `<path d="M 55 15 Q 54 7 56 0 Q 62 2 61 13 Z" fill="${plate}"/>`;
m += `<path d="M 43 48 Q 51 53 59 48" stroke="${outline}" stroke-width="1.6" fill="none" stroke-linecap="round"/>`;
}
if(species === "animal"){
m += `<path d="M 50 41 L 50 45 M 44 49 Q 50 52 56 49" stroke="#3a2a18" stroke-width="1.6" fill="none" stroke-linecap="round"/>`;
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"/>`;
}
if(species === "monster"){
m += `<path d="M 34 42 Q 50 54 66 42 Q 50 50 34 42 Z" fill="#3c2158"/>`;
m += `<path d="M 42 43 L 44 49 L 46 43 Z" fill="#fff"/>`;
m += `<path d="M 54 43 L 56 49 L 58 43 Z" fill="#fff"/>`;
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 += `<path d="M 41 44 L 44 50 L 47 44 Z M 53 44 L 56 50 L 59 44 Z" fill="#fff"/>`;
}
return m;
}
@ -131,69 +143,89 @@ const AVATAR_HAIR_MARKUP = {
const AVATAR_HEAD_ACC_MARKUP = {
none: () => "",
cap: () => `
<path d="M 22 14 Q 24 -6 50 -6 Q 76 -6 78 14 Q 64 4 50 4 Q 36 4 22 14 Z" fill="#3d7ddb"/>
<path d="M 22 14 Q 34 8 50 8 L 50 14 Q 34 15 21 20 Z" fill="#2f63b3"/>`,
<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 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 50 -4 L 50 11" stroke="#72a3e8" stroke-width="1.1" opacity=".8"/>`,
crown: () => `
<path d="M 26 16 L 30 0 L 38 12 L 50 -4 L 62 12 L 70 0 L 74 16 Q 50 8 26 16 Z" fill="#f0b73a"/>
<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 d="M 28 14 C 42 11 58 11 72 14 L 72 18 C 58 15 42 15 28 18 Z" fill="#d99a22"/>
<circle cx="30" cy="1" r="2.6" fill="#e0483c"/>
<circle cx="50" cy="-3" r="2.6" fill="#3a8ee0"/>
<circle cx="50" cy="-4" r="2.6" fill="#3a8ee0"/>
<circle cx="70" cy="1" r="2.6" fill="#e0483c"/>`,
glasses: () => `
<circle cx="38" cy="25" r="9.5" fill="rgba(255,255,255,.3)" stroke="#232733" stroke-width="2.4"/>
<circle 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" 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" cx="62" cy="25" r="9.5" fill="rgba(255,255,255,.3)" stroke="#232733" stroke-width="2.4"/>
<path d="M 47.5 25 L 52.5 25" stroke="#232733" stroke-width="2.4"/>
<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"/>`,
bow: () => `
<path d="M 68 14 Q 58 8 58 16 Q 58 22 68 18 Q 74 24 78 18 Q 80 12 74 10 Q 78 6 74 2 Q 68 2 68 14 Z" fill="#e05a8a"/>
<circle cx="69" cy="14" r="3" fill="#c23f6e"/>`,
sunglasses: () => `
<path 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 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 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 48 22 Q 50 20 52 22" stroke="#1a1d24" stroke-width="2.4" fill="none"/>
<path d="M 27 21 L 20 19" stroke="#1a1d24" stroke-width="2.4" stroke-linecap="round"/>
<path d="M 73 21 L 80 19" stroke="#1a1d24" stroke-width="2.4" stroke-linecap="round"/>
<path d="M 31 22 Q 33 20 36 21" stroke="#fff" stroke-width="1.6" fill="none" opacity=".55" stroke-linecap="round"/>
<path d="M 56 22 Q 58 20 61 21" stroke="#fff" stroke-width="1.6" fill="none" opacity=".55" 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 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"/>`,
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"/>
<circle cx="69" cy="14" r="3.5" fill="#c23f6e"/>`,
headphones: () => `
<path d="M 20 20 Q 24 -6 50 -6 Q 76 -6 80 20" stroke="#333" stroke-width="4.5" fill="none" stroke-linecap="round"/>
<ellipse cx="19" cy="26" rx="7" ry="9" fill="#333"/>
<ellipse cx="81" cy="26" rx="7" ry="9" fill="#333"/>
<ellipse cx="19" cy="26" rx="3.6" ry="5.4" fill="#556"/>
<ellipse cx="81" cy="26" rx="3.6" ry="5.4" fill="#556"/>`,
<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"/>
<rect x="15" y="20" width="10" height="19" rx="5" fill="#333" stroke="#17181c" stroke-width="1.2"/>
<rect x="75" y="20" width="10" height="19" rx="5" fill="#333" stroke="#17181c" stroke-width="1.2"/>
<rect x="18" y="24" width="4" height="11" rx="2" fill="#667"/>
<rect x="78" y="24" width="4" height="11" rx="2" fill="#667"/>`,
partyhat: () => `
<path d="M 50 -6 L 60 21 Q 50 25 40 21 Z" fill="#e05a8a"/>
<path d="M 40.5 19 Q 50 23 59.5 19" stroke="#c23f6e" stroke-width="1.4" fill="none"/>
<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 d="M 39.5 18 C 46 21 54 21 60.5 18" stroke="#f4c34e" stroke-width="2" fill="none"/>
<circle cx="45.5" cy="8" r="1.8" fill="#f0b73a"/>
<circle cx="54" cy="12" r="1.8" fill="#3a8ee0"/>
<circle cx="47.5" cy="16" r="1.8" fill="#f0b73a"/>
<circle cx="50" cy="-7" r="3.6" fill="#f0b73a"/>`,
<circle cx="50" cy="-8" r="3.6" fill="#f0b73a"/>`,
};
const AVATAR_BODY_ACC_MARKUP = {
none: () => "",
scarf: () => `
<path d="M 33 58 Q 50 68 67 58 L 65 66 Q 50 74 35 66 Z" fill="#d94f4f"/>
<path d="M 46 65 L 43 80 L 50 76 Z" fill="#c23f3f"/>`,
cape: () => `
<path d="M 30 55 Q 10 75 16 100 L 30 90 Q 26 70 40 58 Z" fill="#7a4bc7" opacity=".92"/>
<path d="M 70 55 Q 90 75 84 100 L 70 90 Q 74 70 60 58 Z" fill="#7a4bc7" opacity=".92"/>`,
badge: () => `<circle cx="50" cy="78" r="6" fill="#f0b73a" stroke="#fff" stroke-width="1.6"/>`,
necklace: () => `
<path d="M 34 58 Q 50 70 66 58" stroke="#f0b73a" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="70" r="4.4" fill="#3a8ee0" stroke="#f0b73a" stroke-width="1.4"/>`,
backpack: () => `
<path d="M 30 58 Q 26 62 27 70" stroke="#5a4632" stroke-width="4.5" fill="none" stroke-linecap="round"/>
<path d="M 70 58 Q 74 62 73 70" stroke="#5a4632" stroke-width="4.5" fill="none" stroke-linecap="round"/>
<circle cx="27" cy="59" r="2" fill="#8a6a45"/>
<circle cx="73" cy="59" r="2" fill="#8a6a45"/>`,
none: { back: () => "", front: () => "" },
scarf: {
back: () => "",
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 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 55 75 L 62 78" stroke="#f08080" stroke-width="1.2"/>`,
},
cape: {
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 d="M 26 63 C 22 76 23 91 26 101" stroke="#a67de0" stroke-width="2" fill="none" opacity=".65"/>`,
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"/>
<circle cx="35" cy="56" r="2.5" fill="#f0b73a"/>
<circle cx="65" cy="56" r="2.5" fill="#f0b73a"/>`,
},
badge: {
back: () => "",
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 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"/>`,
},
necklace: {
back: () => "",
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 d="M 50 74 L 55 80 L 50 85 L 45 80 Z" fill="#3a8ee0" stroke="#f0b73a" stroke-width="1.4"/>`,
},
backpack: {
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 d="M 39 53 C 40 46 60 46 61 53" stroke="#5a4029" stroke-width="3" fill="none"/>`,
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 d="M 33 83 L 38 86 M 67 83 L 62 86" stroke="#d0a36d" stroke-width="1.5"/>`,
},
};
/* een pet/kroon dekt het hele bovenhoofd af - loshangend haar eronder tonen
gaf rommelige, half-verstopte plukjes rond de rand (zie roadmap-memory).
Een paardenstaart mag wel blijven: die hangt naar opzij, niet over de kop. */
/* Alleen een pet bedekt het bovenhoofd volledig. Een kroon rust juist op het
haar; een paardenstaart blijft naast de pet zichtbaar. */
function avatarHairSuppressed(c){
return (c.accessoryHead === "cap" || c.accessoryHead === "crown") && c.hairStyle !== "ponytail";
return c.accessoryHead === "cap" && c.hairStyle !== "ponytail";
}
/* bouwt de SVG-weergave van een personage; config==null neutrale
@ -203,16 +235,18 @@ function avatarHairSuppressed(c){
function renderAvatarFace(config, size){
const c = avatarSanitize(config);
const svg = document.createElementNS(AVATAR_SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 100 100");
/* Extra ruimte voorkomt afsnijden van hoofddeksels, haar en voetzolen. */
svg.setAttribute("viewBox", "-6 -12 112 124");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.setAttribute("class", "avatar-face " + (size || "md") + " av-species-" + c.species);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", T("avatarAlt"));
let markup = "";
if(c.accessoryBody === "cape") markup += AVATAR_BODY_ACC_MARKUP.cape();
const bodyAccessory = AVATAR_BODY_ACC_MARKUP[c.accessoryBody];
let markup = bodyAccessory.back();
markup += avatarBodyMarkup(c.species, c.bodyColor);
if(!avatarHairSuppressed(c)) markup += AVATAR_HAIR_MARKUP[c.hairStyle](c.hairColor);
markup += AVATAR_HEAD_ACC_MARKUP[c.accessoryHead]();
if(c.accessoryBody !== "cape") markup += AVATAR_BODY_ACC_MARKUP[c.accessoryBody]();
markup += bodyAccessory.front();
svg.innerHTML = markup;
return svg;
}

View file

@ -2,7 +2,7 @@
"use strict";
/* 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. */
const VERSION = "0.4.45-beta";
const VERSION = "0.4.46-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;

114
test/avatar.test.js Normal file
View file

@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {runInNewContext} from 'node:vm';
async function loadAvatarRenderer(){
const source=await readFile('public/js/avatar.js','utf8');
const cutoff=source.indexOf('/* bouwt de interactieve kiezer');
assert.ok(cutoff>0,'rendergedeelte van avatar.js ontbreekt');
const created=[];
const context={
T:key=>key,
document:{
createElementNS(){
const element={
attributes:{},
innerHTML:'',
setAttribute(name,value){this.attributes[name]=String(value);},
};
created.push(element);
return element;
},
},
};
runInNewContext(source.slice(0,cutoff)+
';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP};',
context);
return {api:context.__avatar,created};
}
const base={
species:'dino',
bodyColor:'#3fae6a',
hairStyle:'none',
hairColor:'#5b3a29',
accessoryHead:'none',
accessoryBody:'none',
};
test('avatars hebben verbonden en herkenbare lichaamsdelen per soort',async()=>{
const {api}=await loadAvatarRenderer();
for(const species of ['dino','monster','animal']){
const markup=api.avatarBodyMarkup(species,base.bodyColor);
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');
for(const part of ['av-neck','av-torso','av-head','av-eye']){
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-leg')<markup.indexOf('av-torso'),species+'-benen moeten onder de romp aansluiten');
}
const dino=api.avatarBodyMarkup('dino',base.bodyColor);
const monster=api.avatarBodyMarkup('monster',base.bodyColor);
const animal=api.avatarBodyMarkup('animal',base.bodyColor);
assert.ok(dino.indexOf('av-tail')<dino.indexOf('av-head'));
assert.ok(dino.indexOf('av-crest')<dino.indexOf('av-head'));
assert.equal((monster.match(/av-horn/g)||[]).length,2);
assert.equal((animal.match(/av-ear av-ear-/g)||[]).length,2);
});
test('lichaamsaccessoires gebruiken anatomisch correcte voor- en achterlagen',async()=>{
const {api}=await loadAvatarRenderer();
for(const id of ['none','scarf','cape','badge','necklace','backpack']){
assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag');
assert.equal(typeof api.bodyAccessories[id].front,'function',id+' mist voorlaag');
}
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-clasp')>cape.indexOf('av-head\"'));
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-strap')>backpack.indexOf('av-head\"'));
for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace']]){
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');
}
});
test('alle avatarcombinaties renderen volledig binnen de verruimde SVG',async()=>{
const {api}=await loadAvatarRenderer();
const species=['dino','monster','animal'];
const hair=['none','spiky','curly','ponytail','mohawk'];
const head=['none','cap','crown','glasses','sunglasses','bow','headphones','partyhat'];
const body=['none','scarf','cape','badge','necklace','backpack'];
for(const speciesId of species){
for(const hairId of hair){
for(const headId of head){
for(const bodyId of body){
const svg=api.renderAvatarFace({
...base,
species:speciesId,
hairStyle:hairId,
accessoryHead:headId,
accessoryBody:bodyId,
},'sm');
assert.equal(svg.attributes.viewBox,'-6 -12 112 124');
assert.equal(svg.attributes.preserveAspectRatio,'xMidYMid meet');
assert.doesNotMatch(svg.innerHTML,/undefined|NaN/);
assert.ok(svg.innerHTML.includes('av-head'));
}
}
}
}
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');
assert.ok(crown.includes('#123456'),'kroon hoort op het haar te rusten');
for(const id of head)assert.equal(typeof api.headAccessories[id],'function');
});