/* 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: ()=>({}) }; }