/* 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 = 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_HAIR = ["none", "spiky", "curly", "ponytail", "mohawk"]; const AVATAR_HEAD_ACC = ["none", "cap", "crown", "glasses", "sunglasses", "bow", "headphones", "partyhat"]; const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge", "necklace", "backpack"]; const AVATAR_DEFAULT = Object.freeze({ species: "dino", 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 || {}; return { species: AVATAR_SPECIES.includes(c.species) ? c.species : AVATAR_DEFAULT.species, 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(""); } /* per-soort silhouet: elk onderdeel overlapt het vorige ruim (poten/armen/ staart tot ver ín het lijf, rugstekels/hoorns pas ná de kop getekend) zodat niets los oogt. Volgorde = tekenvolgorde = zichtbare laagvolgorde. */ function avatarBodyMarkup(species, bodyColor){ const dark = avatarShade(bodyColor, -0.35); const light = avatarShade(bodyColor, 0.4); let m = ""; if(species === "dino"){ m += ``; } m += ``; m += ``; m += ``; m += ``; m += ``; m += ``; if(species === "animal"){ m += ``; m += ``; m += ``; m += ``; } if(species === "monster"){ const darker = avatarShade(bodyColor, -0.5); m += ``; m += ``; } m += ``; if(species === "dino"){ m += ``; m += ``; m += ``; } if(species === "animal"){ m += ``; m += ``; } if(species === "monster"){ m += ``; m += ``; m += ``; m += ``; }else{ m += ``; m += ``; m += ``; m += ``; } if(species === "dino"){ m += ``; /* 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 += ``; m += ``; } if(species === "animal"){ m += ``; } if(species === "monster"){ m += ``; m += ``; m += ``; } return m; } const AVATAR_HAIR_MARKUP = { none: () => "", spiky: (c) => ` `, curly: (c) => ` `, ponytail: (c) => ` `, mohawk: (c) => ``, }; const AVATAR_HEAD_ACC_MARKUP = { none: () => "", cap: () => ` `, crown: () => ` `, glasses: () => ` `, bow: () => ` `, sunglasses: () => ` `, headphones: () => ` `, partyhat: () => ` `, }; const AVATAR_BODY_ACC_MARKUP = { none: () => "", scarf: () => ` `, cape: () => ` `, badge: () => ``, necklace: () => ` `, backpack: () => ` `, }; /* 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. */ function avatarHairSuppressed(c){ return (c.accessoryHead === "cap" || c.accessoryHead === "crown") && c.hairStyle !== "ponytail"; } /* 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"); svg.setAttribute("viewBox", "0 0 100 100"); 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(); 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](); 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; update(); }); speciesRow.appendChild(b); }); controls.appendChild(speciesRow); 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); }); 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(){ bodyColorInput.value = config.bodyColor; hairColorInput.value = config.hairColor; hairSel.value = config.hairStyle; headAccSel.value = config.accessoryHead; bodyAccSel.value = config.accessoryBody; syncLabels(); 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"), "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: ()=>({}) }; }