From 34c4d6e1a48f39e009a5ec0812b973175e360829 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 23 Jul 2026 07:05:23 +0200 Subject: [PATCH] feat: laat avatars dansen na afgeronde opdrachten Gewijzigde functionaliteiten: - Voegt vijf afwisselende avatardansen toe. - Houdt lichaam, haar en accessoires als een verbonden SVG-figuur. - Danst na afgeronde woordgroepen, flitsrondes, galgjerondes en rekenrondes. - Respecteert verminderde beweging met een stil feestbeeld. Tests: 159 geslaagd; npm audit: 0 kwetsbaarheden. --- VERSION | 2 +- public/css/teach.css | 17 +++++++++ public/js/avatar.js | 32 ++++++++++++++++ public/js/core.js | 2 +- public/js/widgets/ball.js | 6 ++- public/js/widgets/flits.js | 2 +- public/js/widgets/letters.js | 1 + public/js/widgets/math.js | 1 + test/avatar.test.js | 73 +++++++++++++++++++++++++++++++----- 9 files changed, 122 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index b714bda..b771fe7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.50-beta +0.4.51-beta diff --git a/public/css/teach.css b/public/css/teach.css index 2a812aa..fbebd85 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1785,6 +1785,23 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000; .avatar-face.sm{width:24px; height:24px;} .avatar-face.md{width:44px; height:44px;} .avatar-face.lg{width:180px; height:180px;} +.avatar-dance-host{isolation:isolate;} +.avatar-dance-stage{position:absolute;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;pointer-events:none;overflow:hidden;animation:avDanceStage 3.35s ease both;} +.avatar-dance-stage::before{content:"";position:absolute;width:min(70%,260px);aspect-ratio:1;border-radius:50%;background:radial-gradient(circle,color-mix(in srgb,var(--accent) 22%,transparent),transparent 68%);} +.avatar-dance-stage .avatar-face{position:relative;width:clamp(92px,34%,150px);height:clamp(92px,55%,150px);filter:drop-shadow(0 10px 8px rgba(29,52,97,.22));transform-box:fill-box;transform-origin:50% 88%;will-change:transform;} +.avatar-dance-stage.av-dance-bounce .avatar-face{animation:avDanceBounce 2.8s cubic-bezier(.36,.07,.19,.97) both;} +.avatar-dance-stage.av-dance-twist .avatar-face{animation:avDanceTwist 2.8s ease-in-out both;} +.avatar-dance-stage.av-dance-spin .avatar-face{animation:avDanceSpin 2.8s cubic-bezier(.4,0,.2,1) both;} +.avatar-dance-stage.av-dance-shuffle .avatar-face{animation:avDanceShuffle 2.8s ease-in-out both;} +.avatar-dance-stage.av-dance-wiggle .avatar-face{animation:avDanceWiggle 2.8s ease-in-out both;} +@keyframes avDanceStage{0%{opacity:0}8%,82%{opacity:1}100%{opacity:0}} +@keyframes avDanceBounce{0%,100%{transform:translateY(0) scale(1)}10%,36%,62%{transform:translateY(5px) scale(1.08,.92)}22%,48%,74%{transform:translateY(-34px) scale(.96,1.04)}86%{transform:translateY(-12px) rotate(5deg)}} +@keyframes avDanceTwist{0%,100%{transform:rotate(0) translateX(0)}12%,36%,60%,84%{transform:rotate(-13deg) translateX(-10px)}24%,48%,72%{transform:rotate(13deg) translateX(10px)}90%{transform:scale(1.08)}} +@keyframes avDanceSpin{0%,10%,100%{transform:rotate(0) scale(1)}20%{transform:translateY(-12px) rotate(-12deg)}62%{transform:translateY(-20px) rotate(372deg) scale(1.08)}78%{transform:translateY(0) rotate(360deg) scale(1)}88%{transform:scale(1.1,.9)}} +@keyframes avDanceShuffle{0%,100%{transform:translateX(0) rotate(0)}12%,36%,60%{transform:translateX(-30px) translateY(-7px) rotate(-7deg)}24%,48%,72%{transform:translateX(30px) translateY(0) rotate(7deg)}82%{transform:translateX(-10px) scale(1.08,.92)}90%{transform:translateX(10px) scale(.96,1.04)}} +@keyframes avDanceWiggle{0%,100%{transform:rotate(0) scale(1)}10%,30%,50%,70%{transform:rotate(-10deg) scale(1.04)}20%,40%,60%,80%{transform:rotate(10deg) scale(1.04)}88%{transform:translateY(-24px) scale(.95,1.08)}94%{transform:translateY(0) scale(1.1,.9)}} +.avatar-dance-stage.av-dance-reduced{animation:none;} +.avatar-dance-stage.av-dance-reduced .avatar-face{animation:none;transform:none;} .avatar-editor{display:flex; flex-direction:column; gap:12px; padding:14px; height:100%; min-height:0; overflow:auto;} .avatar-preview{display:flex; justify-content:center; padding:10px; background:var(--surface-2); border-radius:16px; flex:none;} .avatar-controls{display:flex; flex-direction:column; gap:10px;} diff --git a/public/js/avatar.js b/public/js/avatar.js index a7f0d6d..70e8303 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -14,6 +14,7 @@ const AVATAR_VARIANTS = Object.freeze({ 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_DANCES = Object.freeze(["bounce", "twist", "spin", "shuffle", "wiggle"]); const AVATAR_DEFAULT = Object.freeze({ species: "dino", speciesVariant: "rex", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29", accessoryHead: "none", accessoryBody: "none", @@ -505,6 +506,37 @@ function renderAvatarFace(config, size){ return svg; } +/* Voltooide opdrachten krijgen een korte feestlaag met het eigen personage. + De hele SVG beweegt als één figuur, zodat haar en accessoires altijd op hun + anatomische ankers blijven. Zonder expliciete keuze rouleren de dansen. */ +let avatarDanceCursor = -1; +function avatarDanceChoice(preferred){ + if(AVATAR_DANCES.includes(preferred)) return preferred; + avatarDanceCursor = (avatarDanceCursor + 1) % AVATAR_DANCES.length; + return AVATAR_DANCES[avatarDanceCursor]; +} +function celebrateAvatar(host, options){ + if(!host || typeof currentUser === "undefined" || !currentUser || !currentUser.avatar) return null; + host.querySelectorAll(".avatar-dance-stage").forEach(stage=>stage.remove()); + host.classList.add("avatar-dance-host"); + const dance = avatarDanceChoice(options && options.dance); + const reduced = typeof window !== "undefined" && window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const stage = document.createElement("div"); + stage.className = "avatar-dance-stage av-dance-" + dance + (reduced ? " av-dance-reduced" : ""); + stage.dataset.dance = dance; + stage.setAttribute("aria-hidden", "true"); + const face = renderAvatarFace(currentUser.avatar, "lg"); + face.classList.add("av-dancer"); + stage.appendChild(face); + host.appendChild(stage); + setTimeout(()=>{ + stage.remove(); + if(!host.querySelector(".avatar-dance-stage")) host.classList.remove("avatar-dance-host"); + }, reduced ? 1400 : 3400); + return stage; +} + /* 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. */ diff --git a/public/js/core.js b/public/js/core.js index 3a35fc0..24e6586 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -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.50-beta"; +const VERSION = "0.4.51-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; diff --git a/public/js/widgets/ball.js b/public/js/widgets/ball.js index 4801ea7..597fb6f 100644 --- a/public/js/widgets/ball.js +++ b/public/js/widgets/ball.js @@ -92,7 +92,11 @@ function mountBall(root, initState, opts){ } } } - nextBtn.addEventListener("click", ()=>{ S.idx++; newWord(); }); + nextBtn.addEventListener("click", ()=>{ + S.idx++; + if(S.idx >= S.order.length) celebrateAvatar(root); + newWord(); + }); srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; S.order = []; newWord(); diff --git a/public/js/widgets/flits.js b/public/js/widgets/flits.js index a6b8add..700acb6 100644 --- a/public/js/widgets/flits.js +++ b/public/js/widgets/flits.js @@ -88,7 +88,7 @@ function mountFlits(root, initState){ el(".fl-again").addEventListener("click",()=>assess(false)); el(".fl-next").addEventListener("click", ()=>{ S.idx++; - if(S.idx >= S.order.length) newRound(); + if(S.idx >= S.order.length){ celebrateAvatar(root); newRound(); } flash(); }); srcSel.addEventListener("change", ()=>{ diff --git a/public/js/widgets/letters.js b/public/js/widgets/letters.js index 7aa603e..b3ca7fd 100644 --- a/public/js/widgets/letters.js +++ b/public/js/widgets/letters.js @@ -309,6 +309,7 @@ function mountLetterGame(root, initState, opts){ doneEl.querySelector(".dp").textContent = T("pickCard"); doneEl.classList.add("show"); nextBtn.classList.remove("show"); + celebrateAvatar(root); newRound(); } diff --git a/public/js/widgets/math.js b/public/js/widgets/math.js index 61010fd..3c42b16 100644 --- a/public/js/widgets/math.js +++ b/public/js/widgets/math.js @@ -224,6 +224,7 @@ function mountMath(root, initState, mode, opts){ doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪"; doneEl.classList.add("show"); if(S.good>=8) play(sndWin); + celebrateAvatar(root); } function next(){ S.done++; diff --git a/test/avatar.test.js b/test/avatar.test.js index 8c50713..a883b08 100644 --- a/test/avatar.test.js +++ b/test/avatar.test.js @@ -3,25 +3,33 @@ import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import {runInNewContext} from 'node:vm'; +function fakeAvatarElement(){ + const classes=new Set(); + return { + attributes:{}, dataset:{}, children:[], innerHTML:"", removed:false, + classList:{add(...ids){ids.forEach(id=>classes.add(id));},remove(...ids){ids.forEach(id=>classes.delete(id));},contains(id){return classes.has(id);}}, + setAttribute(name,value){this.attributes[name]=String(value);}, + appendChild(child){this.children.push(child);return child;}, + remove(){this.removed=true;}, + }; +} + 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 timers=[]; const context={ T:key=>key, - document:{ - createElementNS(){ - return { - attributes:{}, - innerHTML:'', - setAttribute(name,value){this.attributes[name]=String(value);}, - }; - }, - }, + currentUser:{avatar:{species:"animal",speciesVariant:"cat",bodyColor:"#3fae6a"}}, + window:{matchMedia:()=>({matches:false})}, + setTimeout(fn,delay){timers.push({fn,delay});}, + document:{createElement:fakeAvatarElement,createElementNS:fakeAvatarElement}, }; runInNewContext(source.slice(0,cutoff)+ - ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,sanitize:avatarSanitize,variants:AVATAR_VARIANTS,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC};', + ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,sanitize:avatarSanitize,variants:AVATAR_VARIANTS,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC,dances:AVATAR_DANCES,nextDance:avatarDanceChoice,celebrate:celebrateAvatar};', context); + context.__avatar.timers=timers; return context.__avatar; } @@ -92,6 +100,51 @@ test("iedere categorie heeft vier anatomisch herkenbare avatarvarianten",async() assert.equal(api.sanitize({species:"animal",speciesVariant:"rabbit"}).speciesVariant,"rabbit"); assert.equal(api.sanitize({species:"monster",speciesVariant:"rabbit"}).speciesVariant,"horned"); }); + +test("voltooide opdrachten rouleren door vijf herkenbare avatardansen",async()=>{ + const api=await loadAvatarRenderer(); + const dances=["bounce","twist","spin","shuffle","wiggle"]; + assert.deepEqual(Array.from(api.dances),dances); + assert.deepEqual(dances.map(()=>api.nextDance()),dances); + assert.equal(api.nextDance("spin"),"spin"); + assert.equal(api.nextDance("onbekend"),"bounce"); + + const hostClasses=new Set(),stages=[]; + const host={ + classList:{add:id=>hostClasses.add(id),remove:id=>hostClasses.delete(id)}, + querySelectorAll:()=>stages.filter(stage=>!stage.removed), + querySelector:()=>stages.find(stage=>!stage.removed)||null, + appendChild(stage){stages.push(stage);}, + }; + const stage=api.celebrate(host,{dance:"shuffle"}); + assert.equal(stage.dataset.dance,"shuffle"); + assert.equal(stage.attributes["aria-hidden"],"true"); + assert.equal(stage.children.length,1); + assert.ok(stage.children[0].classList.contains("av-dancer")); + assert.ok(hostClasses.has("avatar-dance-host")); + assert.equal(api.timers[0].delay,3400); + api.timers[0].fn(); + assert.equal(stage.removed,true); + assert.equal(hostClasses.has("avatar-dance-host"),false); + + const [css,letters,math,flits,ball]=await Promise.all([ + readFile("public/css/teach.css","utf8"), + readFile("public/js/widgets/letters.js","utf8"), + readFile("public/js/widgets/math.js","utf8"), + readFile("public/js/widgets/flits.js","utf8"), + readFile("public/js/widgets/ball.js","utf8"), + ]); + for(const dance of dances){ + assert.ok(css.includes(".av-dance-"+dance),dance+" mist een dansklasse"); + assert.ok(css.includes("@keyframes avDance"+dance[0].toUpperCase()+dance.slice(1)),dance+" mist keyframes"); + } + assert.ok(css.includes(".av-dance-reduced"),"verminderde beweging mist een statische terugval"); + assert.match(letters,/function showCardDone\(\)[\s\S]*celebrateAvatar\(root\)/); + assert.match(math,/function endRound\(\)[\s\S]*celebrateAvatar\(root\)/); + assert.match(flits,/S\.idx >= S\.order\.length\)\{ celebrateAvatar\(root\); newRound\(\); \}/); + assert.match(ball,/S\.idx >= S\.order\.length\) celebrateAvatar\(root\)/); +}); + test("haar volgt de kop met natuurlijke details en correcte lagen",async()=>{ const api=await loadAvatarRenderer(); assert.deepEqual(Array.from(api.hairIds),["none","tuft","spiky","curly","wavy","bob","ponytail","braids","mohawk"]);