Compare commits
1 commit
9fc045f0be
...
34c4d6e1a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34c4d6e1a4 |
9 changed files with 122 additions and 14 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.4.50-beta
|
||||
0.4.51-beta
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", ()=>{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue