teach/public/js/avatar.js
Ramon 09a1326c7e
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m10s
feat: meer personage-accessoires incl. zonnebril (v0.4.28-beta)
- Nieuwe hoofdaccessoires: zonnebril, koptelefoon, feesthoedje.
- Nieuwe lichaamsaccessoires: ketting, rugtas (bandjes over de
  schouders, zichtbaar van voren).
- Server-validatie (src/api.js) en vertalingen (nl+en) meegewerkt met
  dezelfde nieuwe waarden - anders zou de client een optie tonen die de
  server als "ongeldig hoofdaccessoire" afwijst.
- Geen wijziging aan de bestaande pet/kroon-onderdrukkingsregel nodig:
  zonnebril/koptelefoon/feesthoedje bedekken het bovenhoofd niet volledig,
  dus haar blijft er gewoon zichtbaar naast/onder.
- Geverifieerd: alle select-opties komen aan in de instellingen-editor,
  een PUT met de nieuwe waarden slaagt (bestaande server-validatie
  accepteert ze nu), en alle soort/accessoire-combinaties oogden goed in
  screenshot (zonnebril op de dino, koptelefoon op het monster,
  feesthoedje + ketting samen, rugtas op het dier).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ
2026-07-19 11:55:25 +02:00

388 lines
19 KiB
JavaScript

/* 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 += `<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"/>`;
}
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"/>`;
}
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"/>`;
}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"/>`;
}
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}"/>`;
}
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"/>`;
}
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"/>`;
}
return m;
}
const AVATAR_HAIR_MARKUP = {
none: () => "",
spiky: (c) => `
<path d="M 27 18 L 24 4 L 32 15 Z" fill="${c}"/>
<path d="M 37 12 L 36 -1 L 44 10 Z" fill="${c}"/>
<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}"/>
<path d="M 68 15 L 76 4 L 73 18 Z" fill="${c}"/>`,
curly: (c) => `
<circle cx="26" cy="14" r="9" fill="${c}"/>
<circle cx="37" cy="6" r="10" fill="${c}"/>
<circle cx="50" cy="3" r="10.5" fill="${c}"/>
<circle cx="63" cy="6" r="10" fill="${c}"/>
<circle cx="74" cy="14" r="9" fill="${c}"/>`,
ponytail: (c) => `
<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}"/>
<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)}"/>`,
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}"/>`,
};
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"/>`,
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"/>
<circle cx="30" cy="1" r="2.6" fill="#e0483c"/>
<circle cx="50" cy="-3" 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"/>
<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 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"/>`,
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"/>`,
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"/>
<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"/>`,
};
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"/>`,
};
/* 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 = `<p class="play-mission"></p>`;
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: ()=>({}) };
}