Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1

Open
bes-r wants to merge 192 commits from bes-r/avatar-realism into main AGit
9 changed files with 122 additions and 14 deletions
Showing only changes of commit 34c4d6e1a4 - Show all commits

View file

@ -1 +1 @@
0.4.50-beta
0.4.51-beta

View file

@ -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;}

View file

@ -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. */

View file

@ -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;

View file

@ -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();

View file

@ -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", ()=>{

View file

@ -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();
}

View file

@ -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++;

View file

@ -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"]);