/* teach - personage (avatar): eigen figuur samenstellen (soort, kleuren, haar,
accessoires), gedeeld tussen de Instellingen-sectie "Mijn personage" en het
avatar-widget. Getekend als één SVG rond vaste anatomische ankers; de tekenvolgorde =
laagvolgorde - geen z-index-gepuzzel zoals bij de eerdere CSS-laagjesversie)
zodat elk onderdeel z'n vaste plek op het silhouet overlapt in plaats van
los ernaast te zweven. */
"use strict";
const AVATAR_SPECIES = ["dino", "monster", "animal"];
const AVATAR_VARIANTS = Object.freeze({
dino: ["rex", "triceratops", "stegosaurus", "raptor"],
monster: ["horned", "cyclops", "slime", "alien"],
animal: ["cat", "dog", "rabbit", "bear"],
});
const AVATAR_HAIR = ["none", "tuft", "spiky", "curly", "wavy", "bob", "ponytail", "braids", "mohawk"];
const AVATAR_HEAD_ACC = ["none", "cap", "beanie", "crown", "wizardhat", "glasses", "sunglasses", "eyepatch", "bow", "flower", "headphones", "partyhat"];
const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge", "necklace", "bowtie", "belt", "backpack", "satchel", "wings"];
const AVATAR_DEFAULT = Object.freeze({
species: "dino", speciesVariant: "rex", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29",
accessoryHead: "none", accessoryBody: "none",
});
const AVATAR_SVG_NS = "http://www.w3.org/2000/svg";
function avatarSanitize(config){
const c = config || {};
const species = AVATAR_SPECIES.includes(c.species) ? c.species : AVATAR_DEFAULT.species;
const variants = AVATAR_VARIANTS[species];
return {
species,
speciesVariant: variants.includes(c.speciesVariant) ? c.speciesVariant : variants[0],
bodyColor: /^#[0-9a-fA-F]{6}$/.test(c.bodyColor||"") ? c.bodyColor : AVATAR_DEFAULT.bodyColor,
hairStyle: AVATAR_HAIR.includes(c.hairStyle) ? c.hairStyle : AVATAR_DEFAULT.hairStyle,
hairColor: /^#[0-9a-fA-F]{6}$/.test(c.hairColor||"") ? c.hairColor : AVATAR_DEFAULT.hairColor,
accessoryHead: AVATAR_HEAD_ACC.includes(c.accessoryHead) ? c.accessoryHead : AVATAR_DEFAULT.accessoryHead,
accessoryBody: AVATAR_BODY_ACC.includes(c.accessoryBody) ? c.accessoryBody : AVATAR_DEFAULT.accessoryBody,
};
}
/* mengt een hex-kleur amt (-1..1) richting zwart (negatief) of wit (positief) -
gebruikt voor buik/snuit-highlights en pootjes/staart-schaduw, zodat die
altijd bij de gekozen lichaamskleur passen zonder een aparte kleurkiezer. */
function avatarShade(hex, amt){
const n = parseInt(hex.slice(1), 16);
let r = (n>>16)&255, g = (n>>8)&255, b = n&255;
if(amt < 0){ r *= 1+amt; g *= 1+amt; b *= 1+amt; }
else{ r += (255-r)*amt; g += (255-g)*amt; b += (255-b)*amt; }
const clamp = v => Math.max(0, Math.min(255, Math.round(v)));
return "#" + [r,g,b].map(v=>clamp(v).toString(16).padStart(2,"0")).join("");
}
/* Groot hoofd en compact, soortspecifiek lijf. De kop beslaat circa twee
derde van de figuurbreedte; romp en ledematen blijven kort en sluiten onder
de wangen aan. Alle soorten delen dezelfde accessoire-ankers rond hals y=61,
borst y=72 en middel y=87. */
function avatarBodyMarkup(species, bodyColor, speciesVariant){
const variants = AVATAR_VARIANTS[species] || AVATAR_VARIANTS[AVATAR_DEFAULT.species];
const variant = variants.includes(speciesVariant) ? speciesVariant : variants[0];
const outline = avatarShade(bodyColor, -0.48);
const dark = avatarShade(bodyColor, -0.24);
const light = avatarShade(bodyColor, 0.4);
const highlight = avatarShade(bodyColor, 0.72);
let m = ``;
/* Silhouetdetails achter kop en lijf: iedere variant is direct herkenbaar. */
if(species === "dino"){
if(variant === "rex"){
m += ``;
}else if(variant === "triceratops"){
m += ``;
m += ``;
}else if(variant === "stegosaurus"){
m += ``;
m += ``;
m += ``;
}else{
m += ``;
m += ``;
}
}else if(species === "monster"){
if(variant === "horned"){
m += ``;
m += ``;
}else if(variant === "cyclops"){
m += ``;
}else if(variant === "slime"){
m += ``;
}else{
m += ``;
m += ``;
}
}else{
if(variant === "cat"){
m += ``;
m += ``;
m += ``;
}else if(variant === "dog"){
m += ``;
m += ``;
m += ``;
}else if(variant === "rabbit"){
m += ``;
m += ``;
m += ``;
}else{
m += ``;
m += ``;
m += ``;
}
}
/* Korte ledematen blijven op gedeelde ankers voor alle accessoires. */
if(species === "dino"){
m += ``;
m += ``;
if(variant === "triceratops"){
m += ``;
m += ``;
}else if(variant === "raptor"){
m += ``;
m += ``;
}else{
m += ``;
m += ``;
}
}else if(species === "monster"){
m += ``;
m += ``;
m += ``;
m += ``;
}else{
m += ``;
m += ``;
m += ``;
m += ``;
}
m += ``;
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 += ``;
if(species === "dino"){
if(variant === "raptor"){
m += ``;
}else{
m += ``;
if(variant === "stegosaurus") m += ``;
}
}else if(species === "monster"){
m += ``;
if(variant === "horned") m += ``;
if(variant === "cyclops") m += ``;
if(variant === "slime") m += ``;
if(variant === "alien") m += ``;
}else{
if(variant === "cat") m += ``;
if(variant === "dog") m += ``;
if(variant === "rabbit") m += ``;
if(variant === "bear") m += ``;
}
const headPaths = {
dino: {
rex: "M 47 -3 C 28 -3 15 10 16 31 C 16 49 28 60 45 63 C 64 67 83 55 85 35 C 87 15 73 -1 55 -2 Z",
triceratops: "M 50 -1 C 29 -2 16 11 17 31 C 17 51 30 63 50 64 C 70 64 83 51 83 31 C 84 11 71 -1 50 -1 Z",
stegosaurus: "M 48 -2 C 29 -2 16 10 16 31 C 16 50 29 62 48 64 C 68 66 84 53 84 32 C 84 11 69 -2 48 -2 Z",
raptor: "M 43 -2 C 26 1 16 14 18 33 C 19 50 30 60 46 62 C 64 65 80 55 87 40 C 91 30 83 24 76 21 C 74 7 61 -4 43 -2 Z",
},
monster: {
horned: "M 50 -3 C 29 -5 16 8 16 30 C 15 51 29 63 48 64 C 69 66 85 53 84 30 C 84 8 71 -2 50 -3 Z",
cyclops: "M 50 -2 C 29 -3 16 10 16 31 C 16 53 30 65 50 65 C 70 65 84 53 84 31 C 84 10 71 -2 50 -2 Z",
slime: "M 49 0 C 30 -4 17 9 18 29 C 12 39 18 48 23 51 C 24 62 34 65 43 61 C 49 68 59 67 63 61 C 75 64 85 55 81 45 C 89 36 84 25 80 21 C 78 6 66 -2 49 0 Z",
alien: "M 50 -2 C 32 -2 20 9 18 28 C 16 48 28 62 50 65 C 72 62 84 48 82 28 C 80 9 68 -2 50 -2 Z",
},
animal: {
cat: "M 50 -3 C 30 -3 17 10 17 31 C 17 52 30 64 50 64 C 70 64 83 52 83 31 C 83 10 70 -3 50 -3 Z",
dog: "M 50 0 C 29 -1 16 12 17 33 C 18 53 31 64 50 64 C 69 64 82 53 83 33 C 84 12 71 -1 50 0 Z",
rabbit: "M 50 -1 C 32 -1 20 11 19 31 C 18 51 30 64 50 66 C 70 64 82 51 81 31 C 80 11 68 -1 50 -1 Z",
bear: "M 50 -3 C 28 -3 15 10 16 32 C 17 54 30 65 50 65 C 70 65 83 54 84 32 C 85 10 72 -3 50 -3 Z",
},
};
m += ``;
m += ``;
if(species === "dino"){
if(variant === "triceratops"){
m += ``;
m += ``;
m += ``;
m += ``;
m += ``;
}else if(variant === "raptor"){
m += ``;
m += ``;
}else{
m += ``;
m += ``;
if(variant === "stegosaurus") m += ``;
}
m += ``;
m += ``;
m += ``;
if(variant === "raptor") m += ``;
}else if(species === "monster"){
if(variant === "cyclops"){
m += ``;
m += ``;
}else if(variant === "alien"){
for(const x of [34,50,66]) m += ``;
m += ``;
}else{
const leftSize = variant === "slime" ? 8.5 : 10;
const rightSize = variant === "slime" ? 8.5 : 8.2;
m += ``;
m += ``;
if(variant === "slime"){
m += ``;
}else{
m += ``;
}
}
}else{
m += ``;
m += ``;
if(variant === "cat"){
m += ``;
m += ``;
}else if(variant === "dog"){
m += ``;
m += ``;
}else if(variant === "rabbit"){
m += ``;
m += ``;
}else{
m += ``;
m += ``;
}
}
m += ``;
return m;
}
const AVATAR_HAIR_MARKUP = {
none: { back: () => "", front: () => "" },
tuft: {
back: () => "",
front: (c) => `
`,
},
spiky: {
back: () => "",
front: (c) => `
`,
},
curly: {
back: (c) => ``,
front: (c) => `
`,
},
wavy: {
back: (c) => ``,
front: (c) => `
`,
},
bob: {
back: (c) => ``,
front: (c) => `
`,
},
ponytail: {
back: (c) => `
`,
front: (c) => `
`,
},
braids: {
back: (c) => `
`,
front: (c) => `
`,
},
mohawk: {
back: () => "",
front: (c) => `
`,
},
};
/* Iedere kop gebruikt dezelfde haarankers, met een minimale correctie voor
het asymmetrische dinosaurusprofiel. De wrapper maakt de pasvorm expliciet
en houdt alle lokken als een laag bij elkaar. */
function avatarHairLayer(markup, species, layer){
if(!markup) return "";
const transform = species === "dino" ? "translate(1 0)" : species === "monster" ? "translate(0 -1)" : "translate(0 0)";
return `${markup}`;
}
const AVATAR_HEAD_ACC_MARKUP = {
none: () => "",
cap: () => `
`,
beanie: () => `
`,
crown: () => `
`,
wizardhat: () => `
`,
glasses: () => `
`,
sunglasses: () => `
`,
eyepatch: () => `
`,
bow: () => `
`,
flower: () => `
`,
headphones: () => `
`,
partyhat: () => `
`,
};
/* Oogaccessoires volgen afwijkende oogindelingen van cycloop en alien. */
function avatarHeadAccessoryMarkup(c){
if(c.species === "monster" && c.speciesVariant === "cyclops"){
if(c.accessoryHead === "glasses") return `
`;
if(c.accessoryHead === "sunglasses") return `
`;
if(c.accessoryHead === "eyepatch") return `
`;
}
if(c.species === "monster" && c.speciesVariant === "alien"){
if(c.accessoryHead === "glasses") return `
`;
if(c.accessoryHead === "sunglasses") return `
`;
}
return AVATAR_HEAD_ACC_MARKUP[c.accessoryHead]();
}
const AVATAR_BODY_ACC_MARKUP = {
none: { back: () => "", front: () => "" },
scarf: {
back: () => "",
front: () => `
`,
},
cape: {
back: () => `
`,
front: () => `
`,
},
badge: {
back: () => "",
front: () => `
`,
},
necklace: {
back: () => "",
front: () => `
`,
},
bowtie: {
back: () => "",
front: () => `
`,
},
belt: {
back: () => "",
front: () => `
`,
},
backpack: {
back: () => `
`,
front: () => `
`,
},
satchel: {
back: () => `
`,
front: () => `
`,
},
wings: {
back: () => `
`,
front: () => ``,
},
};
/* Pet en muts bedekken de voorste haarlaag. Lange stijlen houden hun
achterlaag, zodat staarten, vlechten en lokken naast de kop zichtbaar zijn. */
function avatarHairSuppressed(c){
return c.accessoryHead === "cap" || c.accessoryHead === "beanie";
}
/* bouwt de SVG-weergave van een personage; config==null → neutrale
standaardweergave (nooit een lege/kapotte weergave). Tekenvolgorde bepaalt
de laagvolgorde (SVG kent geen z-index) - vandaar cape/staart/poten eerst,
accessoires als laatste. */
function renderAvatarFace(config, size){
const c = avatarSanitize(config);
const svg = document.createElementNS(AVATAR_SVG_NS, "svg");
/* Extra ruimte voorkomt afsnijden van hoofddeksels, haar en voetzolen. */
svg.setAttribute("viewBox", "-8 -21 116 132");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.setAttribute("class", "avatar-face " + (size || "md") + " av-species-" + c.species + " av-variant-" + c.speciesVariant);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", T("avatarAlt"));
const bodyAccessory = AVATAR_BODY_ACC_MARKUP[c.accessoryBody];
const hair = AVATAR_HAIR_MARKUP[c.hairStyle];
let markup = bodyAccessory.back();
markup += avatarHairLayer(hair.back(c.hairColor), c.species, "back");
markup += avatarBodyMarkup(c.species, c.bodyColor, c.speciesVariant);
if(!avatarHairSuppressed(c)) markup += avatarHairLayer(hair.front(c.hairColor), c.species, "front");
markup += avatarHeadAccessoryMarkup(c);
markup += bodyAccessory.front();
svg.innerHTML = markup;
return svg;
}
/* bouwt de interactieve kiezer + live voorbeeld; roept onChange(config) aan
bij elke wijziging. Hergebruikt door zowel de instellingen-sectie als het
widget - één UI, geen duplicatie. */
function buildAvatarEditor(container, initialConfig, onChange){
let config = avatarSanitize(initialConfig);
container.innerHTML = "";
const editor = document.createElement("div");
editor.className = "avatar-editor";
const preview = document.createElement("div");
preview.className = "avatar-preview";
const controls = document.createElement("div");
controls.className = "avatar-controls";
editor.append(preview, controls);
container.appendChild(editor);
const speciesRow = document.createElement("div");
speciesRow.className = "avatar-species-row";
AVATAR_SPECIES.forEach(id=>{
const b = document.createElement("button");
b.type = "button";
b.dataset.species = id;
b.addEventListener("click", ()=>{ config.species = id; config.speciesVariant = AVATAR_VARIANTS[id][0]; update(); });
speciesRow.appendChild(b);
});
controls.appendChild(speciesRow);
const variantRow = document.createElement("div"); variantRow.className = "avatar-row";
const variantField = document.createElement("label");
const variantSpan = document.createElement("span");
const variantSel = document.createElement("select");
variantSel.addEventListener("change", ()=>{ config.speciesVariant = variantSel.value; update(); });
variantField.append(variantSpan, variantSel);
variantRow.appendChild(variantField);
controls.appendChild(variantRow);
const row1 = document.createElement("div"); row1.className = "avatar-row";
const bodyColorField = document.createElement("label");
const bodyColorSpan = document.createElement("span");
const bodyColorInput = document.createElement("input"); bodyColorInput.type = "color";
bodyColorInput.addEventListener("input", ()=>{ config.bodyColor = bodyColorInput.value; update(); });
bodyColorField.append(bodyColorSpan, bodyColorInput);
const hairColorField = document.createElement("label");
const hairColorSpan = document.createElement("span");
const hairColorInput = document.createElement("input"); hairColorInput.type = "color";
hairColorInput.addEventListener("input", ()=>{ config.hairColor = hairColorInput.value; update(); });
hairColorField.append(hairColorSpan, hairColorInput);
row1.append(bodyColorField, hairColorField);
controls.appendChild(row1);
const row2 = document.createElement("div"); row2.className = "avatar-row";
const hairField = document.createElement("label");
const hairSpan = document.createElement("span");
const hairSel = document.createElement("select");
AVATAR_HAIR.forEach(id=>hairSel.appendChild(new Option("", id)));
hairSel.addEventListener("change", ()=>{ config.hairStyle = hairSel.value; update(); });
hairField.append(hairSpan, hairSel);
row2.append(hairField);
controls.appendChild(row2);
const row3 = document.createElement("div"); row3.className = "avatar-row";
const headAccField = document.createElement("label");
const headAccSpan = document.createElement("span");
const headAccSel = document.createElement("select");
AVATAR_HEAD_ACC.forEach(id=>headAccSel.appendChild(new Option("", id)));
headAccSel.addEventListener("change", ()=>{ config.accessoryHead = headAccSel.value; update(); });
headAccField.append(headAccSpan, headAccSel);
const bodyAccField = document.createElement("label");
const bodyAccSpan = document.createElement("span");
const bodyAccSel = document.createElement("select");
AVATAR_BODY_ACC.forEach(id=>bodyAccSel.appendChild(new Option("", id)));
bodyAccSel.addEventListener("change", ()=>{ config.accessoryBody = bodyAccSel.value; update(); });
bodyAccField.append(bodyAccSpan, bodyAccSel);
row3.append(headAccField, bodyAccField);
controls.appendChild(row3);
function syncLabels(){
speciesRow.querySelectorAll("button").forEach(b=>{
b.textContent = T("avatarSpecies_" + b.dataset.species);
b.classList.toggle("active", b.dataset.species === config.species);
});
const variantIds = AVATAR_VARIANTS[config.species];
if([...variantSel.options].map(o=>o.value).join(",") !== variantIds.join(",")){
variantSel.innerHTML = "";
variantIds.forEach(id=>variantSel.appendChild(new Option("", id)));
}
variantSpan.textContent = T("avatarVariant");
[...variantSel.options].forEach(o=>o.textContent = T("avatarVariant_" + o.value));
bodyColorSpan.textContent = T("avatarBodyColor");
hairColorSpan.textContent = T("avatarHairColor");
hairSpan.textContent = T("avatarHairStyle");
[...hairSel.options].forEach(o=>o.textContent = T("avatarHair_" + o.value));
headAccSpan.textContent = T("avatarAccHead");
[...headAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value));
bodyAccSpan.textContent = T("avatarAccBody");
[...bodyAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value));
}
function update(){
const variants = AVATAR_VARIANTS[config.species];
if(!variants.includes(config.speciesVariant)) config.speciesVariant = variants[0];
syncLabels();
bodyColorInput.value = config.bodyColor;
hairColorInput.value = config.hairColor;
variantSel.value = config.speciesVariant;
hairSel.value = config.hairStyle;
headAccSel.value = config.accessoryHead;
bodyAccSel.value = config.accessoryBody;
preview.innerHTML = "";
preview.appendChild(renderAvatarFace(config, "lg"));
if(onChange) onChange({ ...config });
}
document.addEventListener("langchange", syncLabels);
update();
return { getConfig: ()=>({ ...config }), setConfig(c){ config = avatarSanitize(c); update(); } };
}
async function saveAvatar(config){
const clean = avatarSanitize(config);
const res = await api("/me/avatar", { method: "PUT", body: clean });
currentUser.avatar = res.avatar;
document.dispatchEvent(new CustomEvent("avatarchange"));
return res.avatar;
}
/* ---- Instellingen-sectie "Mijn personage" (cms.js-patroon: eigen .st-section
zelf aanmaken, niet in index.html) ---- */
(function(){
const host = document.createElement("div");
host.className = "st-section";
host.dataset.section = "avatar";
host.style.display = "none";
document.getElementById("stContent").appendChild(host);
const msg = document.createElement("p");
msg.className = "avatar-save-msg";
let editorApi = null;
function build(){
host.innerHTML = "";
editorApi = buildAvatarEditor(host, currentUser && currentUser.avatar, ()=>{ msg.textContent = ""; });
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "tbtn";
saveBtn.textContent = T("avatarSave");
saveBtn.addEventListener("click", async ()=>{
try{ await saveAvatar(editorApi.getConfig()); msg.textContent = T("avatarSaved"); }
catch(e){ msg.textContent = e.message; }
});
host.appendChild(saveBtn);
host.appendChild(msg);
}
registerSettingsSection({
id: "avatar", icon: "🦖", group: "personal", order: 3,
label: ()=>T("stAvatar"), sub: ()=>T("stAvatarSub"),
visible: ()=>!!currentUser,
activate(){ showSettingsDom("avatar"); build(); },
keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"),
...Object.values(AVATAR_VARIANTS).flat().map(id=>T("avatarVariant_" + id)),
"personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"],
});
})();
/* ---- widget: dunne wrapper rond dezelfde editor ---- */
function mountAvatarWidget(root){
root.innerHTML = `
`;
root.querySelector(".play-mission").textContent = T("wg_avatar_d");
const host = document.createElement("div");
host.style.cssText = "flex:1;min-height:0;display:flex;flex-direction:column;";
root.appendChild(host);
const msg = document.createElement("p");
msg.className = "avatar-save-msg";
let editorApi = null;
function build(){
host.innerHTML = "";
editorApi = buildAvatarEditor(host, currentUser && currentUser.avatar, ()=>{ msg.textContent = ""; });
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "tbtn";
saveBtn.textContent = T("avatarSave");
saveBtn.addEventListener("click", async ()=>{
try{ await saveAvatar(editorApi.getConfig()); msg.textContent = T("avatarSaved"); }
catch(e){ msg.textContent = e.message; }
});
host.appendChild(saveBtn);
host.appendChild(msg);
}
build();
document.addEventListener("langchange", ()=>{ root.querySelector(".play-mission").textContent = T("wg_avatar_d"); });
/* currentUser.avatar is de bron van waarheid (server), niet het widget-eigen
initState - geen dubbele opslag van dezelfde data op twee plekken. */
return { getState: ()=>({}) };
}