diff --git a/VERSION b/VERSION
index 2cadb92..341341e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.46-beta
+0.4.47-beta
diff --git a/public/js/avatar.js b/public/js/avatar.js
index 7a7590c..2e61b8c 100644
--- a/public/js/avatar.js
+++ b/public/js/avatar.js
@@ -6,9 +6,9 @@
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_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_DEFAULT = Object.freeze({
species: "dino", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29",
accessoryHead: "none", accessoryBody: "none",
@@ -39,147 +39,204 @@ function avatarShade(hex, amt){
return "#" + [r,g,b].map(v=>clamp(v).toString(16).padStart(2,"0")).join("");
}
-/* Per-soort silhouet met vaste anatomische ankers: kruin y=2, ogen y=25,
- hals y=53, schouders y=60, heupen y=88 en voetzolen y=108. Armen en benen
- lopen bewust onder het lijf door. Daardoor blijven gewrichten verbonden en
- kunnen accessoires steeds aan hetzelfde lichaamsdeel worden vastgemaakt. */
+/* Groot hoofd en compact, soortspecifiek lijf. De kop beslaat circa twee
+ derde van de figuurbreedte; romp en ledematen blijven kort en sluiten onder
+ de wangen aan. Alle soorten delen dezelfde accessoire-ankers rond hals y=61,
+ borst y=72 en middel y=87. */
function avatarBodyMarkup(species, bodyColor){
const outline = avatarShade(bodyColor, -0.48);
const dark = avatarShade(bodyColor, -0.24);
const light = avatarShade(bodyColor, 0.4);
- const highlight = avatarShade(bodyColor, 0.7);
+ const highlight = avatarShade(bodyColor, 0.72);
let m = "";
- /* Onderdelen achter het lijf: soortdetails, benen en doorlopende armen. */
+ /* Soortdetails achter kop en lijf. */
if(species === "dino"){
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
}
if(species === "animal"){
- m += ``;
- m += ``;
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
}
if(species === "monster"){
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
}
- m += ``;
- m += ``;
- m += ``;
- m += ``;
- m += ``;
-
- /* Hals en romp bedekken de aanzetten van armen en benen. */
- m += ``;
- m += ``;
- m += ``;
- m += ``;
-
- /* Kop en gelaat; oren en hoorns blijven achter de schedel. */
- const headPath = species === "monster"
- ? "M 50 1 C 34 -1 23 9 23 26 C 22 42 31 53 48 55 C 65 57 78 47 77 28 C 77 10 67 2 50 1 Z"
- : species === "dino"
- ? "M 48 1 C 33 1 23 11 23 27 C 23 41 31 51 43 54 C 58 59 75 51 77 35 C 79 19 69 4 54 2 Z"
- : "M 50 1 C 34 1 23 11 23 28 C 23 45 34 55 50 55 C 66 55 77 45 77 28 C 77 11 66 1 50 1 Z";
- m += ``;
- m += ``;
-
+ /* Korte ledematen verschillen per wezen en verdwijnen onder de romp. */
if(species === "dino"){
- m += ``;
- m += ``;
- m += ``;
- }
- if(species === "animal"){
- m += ``;
- m += ``;
- }
- if(species === "monster"){
- m += ``;
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ }else if(species === "monster"){
+ m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
}else{
- m += ``;
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
}
- m += ``;
+
+ m += ``;
+ const torsoPath = species === "dino"
+ ? "M 39 61 C 45 58 55 58 61 61 C 67 68 67 83 62 91 C 56 96 44 96 38 91 C 33 83 33 68 39 61 Z"
+ : species === "monster"
+ ? "M 35 62 C 42 57 58 57 65 62 C 71 70 70 85 64 92 C 56 97 44 97 36 92 C 30 85 29 70 35 62 Z"
+ : "M 38 61 C 44 58 56 58 62 61 C 67 69 66 84 61 93 C 54 97 46 97 39 93 C 34 84 33 69 38 61 Z";
+ m += ``;
if(species === "dino"){
- m += ``;
+ m += ``;
+ m += ``;
+ }else if(species === "monster"){
+ m += ``;
+ m += ``;
+ }else{
+ m += ``;
}
- if(species === "animal"){
- m += ``;
+
+ /* De forse kop overlapt de hals en de bovenkant van het kleine lijf. */
+ const headPath = species === "monster"
+ ? "M 50 -3 C 29 -5 16 8 16 30 C 15 51 29 63 48 64 C 69 66 85 53 84 30 C 84 8 71 -2 50 -3 Z"
+ : species === "dino"
+ ? "M 47 -3 C 28 -3 15 10 16 31 C 16 49 28 60 45 63 C 64 67 83 55 85 35 C 87 15 73 -1 55 -2 Z"
+ : "M 50 -3 C 30 -3 17 10 17 31 C 17 52 30 64 50 64 C 70 64 83 52 83 31 C 83 10 70 -3 50 -3 Z";
+ m += ``;
+ m += ``;
+
+ if(species === "dino"){
+ m += ``;
+ m += ``;
+ }else if(species === "animal"){
+ m += ``;
+ m += ``;
}
+
if(species === "monster"){
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ }else{
+ m += ``;
+ m += ``;
+ m += ``;
+ }
+ m += ``;
+
+ if(species === "dino"){
+ m += ``;
+ }else if(species === "animal"){
+ m += ``;
+ }else{
+ m += ``;
+ m += ``;
}
return m;
}
const AVATAR_HAIR_MARKUP = {
- none: () => "",
- spiky: (c) => `
-
-
-
-
- `,
- curly: (c) => `
-
-
-
-
- `,
- ponytail: (c) => `
-
-
- `,
- mohawk: (c) => ``,
+ none: { back: () => "", front: () => "" },
+ tuft: {
+ back: () => "",
+ front: (c) => ``,
+ },
+ spiky: {
+ back: () => "",
+ front: (c) => ``,
+ },
+ curly: {
+ back: () => "",
+ front: (c) => `
+
+
+ `,
+ },
+ wavy: {
+ back: (c) => ``,
+ front: (c) => ``,
+ },
+ bob: {
+ back: (c) => ``,
+ front: (c) => ``,
+ },
+ ponytail: {
+ back: (c) => ``,
+ front: (c) => ``,
+ },
+ braids: {
+ back: (c) => `
+
+
+
+ `,
+ front: (c) => ``,
+ },
+ mohawk: {
+ back: () => "",
+ front: (c) => ``,
+ },
};
const AVATAR_HEAD_ACC_MARKUP = {
none: () => "",
cap: () => `
-
-
- `,
+
+
+ `,
+ beanie: () => `
+
+
+ `,
crown: () => `
-
-
-
-
- `,
+
+
+ `,
+ wizardhat: () => `
+
+
+
+ `,
glasses: () => `
-
-
-
-
- `,
+
+
+ `,
sunglasses: () => `
-
-
-
-
- `,
+
+
+
+ `,
+ eyepatch: () => `
+
+
+ `,
bow: () => `
-
- `,
+
+ `,
+ flower: () => `
+
+
+
+
+ `,
headphones: () => `
-
-
-
-
- `,
+
+
+
+ `,
partyhat: () => `
-
-
-
-
-
- `,
+
+
+
+ `,
};
const AVATAR_BODY_ACC_MARKUP = {
@@ -187,45 +244,72 @@ const AVATAR_BODY_ACC_MARKUP = {
scarf: {
back: () => "",
front: () => `
-
-
- `,
+
+
+ `,
},
cape: {
back: () => `
-
- `,
+
+ `,
front: () => `
-
-
- `,
+
+ `,
},
badge: {
back: () => "",
front: () => `
-
- `,
+
+ `,
},
necklace: {
back: () => "",
front: () => `
-
- `,
+
+ `,
+ },
+ bowtie: {
+ back: () => "",
+ front: () => `
+
+ `,
+ },
+ belt: {
+ back: () => "",
+ front: () => `
+
+
+ `,
},
backpack: {
back: () => `
-
- `,
+
+ `,
front: () => `
-
- `,
+
+ `,
+ },
+ satchel: {
+ back: () => `
+
+ `,
+ front: () => `
+
+ `,
+ },
+ wings: {
+ back: () => `
+
+
+ `,
+ front: () => ``,
},
};
-/* Alleen een pet bedekt het bovenhoofd volledig. Een kroon rust juist op het
- haar; een paardenstaart blijft naast de pet zichtbaar. */
+/* Pet en muts bedekken de voorste haarlaag. Lange stijlen houden hun
+ achterlaag, zodat staarten, vlechten en lokken naast de kop zichtbaar zijn. */
function avatarHairSuppressed(c){
- return c.accessoryHead === "cap" && c.hairStyle !== "ponytail";
+ return c.accessoryHead === "cap" || c.accessoryHead === "beanie";
}
/* bouwt de SVG-weergave van een personage; config==null → neutrale
@@ -236,15 +320,17 @@ function renderAvatarFace(config, size){
const c = avatarSanitize(config);
const svg = document.createElementNS(AVATAR_SVG_NS, "svg");
/* Extra ruimte voorkomt afsnijden van hoofddeksels, haar en voetzolen. */
- svg.setAttribute("viewBox", "-6 -12 112 124");
+ svg.setAttribute("viewBox", "-8 -21 116 132");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.setAttribute("class", "avatar-face " + (size || "md") + " av-species-" + c.species);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", T("avatarAlt"));
const bodyAccessory = AVATAR_BODY_ACC_MARKUP[c.accessoryBody];
+ const hair = AVATAR_HAIR_MARKUP[c.hairStyle];
let markup = bodyAccessory.back();
+ markup += hair.back(c.hairColor);
markup += avatarBodyMarkup(c.species, c.bodyColor);
- if(!avatarHairSuppressed(c)) markup += AVATAR_HAIR_MARKUP[c.hairStyle](c.hairColor);
+ if(!avatarHairSuppressed(c)) markup += hair.front(c.hairColor);
markup += AVATAR_HEAD_ACC_MARKUP[c.accessoryHead]();
markup += bodyAccessory.front();
svg.innerHTML = markup;
diff --git a/public/js/core.js b/public/js/core.js
index db242af..0ded29b 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.46-beta";
+const VERSION = "0.4.47-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@@ -166,12 +166,13 @@ const I18N = {
stAvatar:"Mijn personage", stAvatarSub:"Stel je eigen personage samen.",
avatarAlt:"Mijn personage", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Dier",
avatarBodyColor:"Lichaamskleur", avatarHairColor:"Haarkleur", avatarHairStyle:"Haarstijl",
- avatarHair_none:"Geen", avatarHair_spiky:"Piekerig", avatarHair_curly:"Krullen", avatarHair_ponytail:"Staartje", avatarHair_mohawk:"Hanenkam",
+ avatarHair_none:"Geen", avatarHair_tuft:"Kuifje", avatarHair_spiky:"Piekerig", avatarHair_curly:"Krullen", avatarHair_wavy:"Golvend", avatarHair_bob:"Boblijn", avatarHair_ponytail:"Staartje", avatarHair_braids:"Vlechten", avatarHair_mohawk:"Hanenkam",
avatarAccHead:"Hoofdaccessoire", avatarAccBody:"Lichaamsaccessoire",
- avatarAcc_none:"Geen", avatarAcc_cap:"Petje", avatarAcc_crown:"Kroon", avatarAcc_glasses:"Bril", avatarAcc_bow:"Strik",
- avatarAcc_sunglasses:"Zonnebril", avatarAcc_headphones:"Koptelefoon", avatarAcc_partyhat:"Feesthoedje",
- avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape", avatarAcc_badge:"Insigne",
- avatarAcc_necklace:"Ketting", avatarAcc_backpack:"Rugtas",
+ avatarAcc_none:"Geen", avatarAcc_cap:"Petje", avatarAcc_beanie:"Muts", avatarAcc_crown:"Kroon", avatarAcc_wizardhat:"Tovenaarshoed",
+ avatarAcc_glasses:"Bril", avatarAcc_sunglasses:"Zonnebril", avatarAcc_eyepatch:"Ooglapje", avatarAcc_bow:"Strik", avatarAcc_flower:"Bloem",
+ avatarAcc_headphones:"Koptelefoon", avatarAcc_partyhat:"Feesthoedje", avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape",
+ avatarAcc_badge:"Insigne", avatarAcc_necklace:"Ketting", avatarAcc_bowtie:"Vlinderdas", avatarAcc_belt:"Riem",
+ avatarAcc_backpack:"Rugtas", avatarAcc_satchel:"Schoudertas", avatarAcc_wings:"Vleugels",
avatarSave:"Opslaan", avatarSaved:"Personage opgeslagen ✓",
clock24Hour:"24-uursklok",
widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies ‘Nieuwe opdracht’ om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw",
@@ -552,12 +553,13 @@ const I18N = {
stAvatar:"My character", stAvatarSub:"Build your own character.",
avatarAlt:"My character", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Animal",
avatarBodyColor:"Body colour", avatarHairColor:"Hair colour", avatarHairStyle:"Hair style",
- avatarHair_none:"None", avatarHair_spiky:"Spiky", avatarHair_curly:"Curly", avatarHair_ponytail:"Ponytail", avatarHair_mohawk:"Mohawk",
+ avatarHair_none:"None", avatarHair_tuft:"Tuft", avatarHair_spiky:"Spiky", avatarHair_curly:"Curly", avatarHair_wavy:"Wavy", avatarHair_bob:"Bob", avatarHair_ponytail:"Ponytail", avatarHair_braids:"Braids", avatarHair_mohawk:"Mohawk",
avatarAccHead:"Head accessory", avatarAccBody:"Body accessory",
- avatarAcc_none:"None", avatarAcc_cap:"Cap", avatarAcc_crown:"Crown", avatarAcc_glasses:"Glasses", avatarAcc_bow:"Bow",
- avatarAcc_sunglasses:"Sunglasses", avatarAcc_headphones:"Headphones", avatarAcc_partyhat:"Party hat",
- avatarAcc_scarf:"Scarf", avatarAcc_cape:"Cape", avatarAcc_badge:"Badge",
- avatarAcc_necklace:"Necklace", avatarAcc_backpack:"Backpack",
+ avatarAcc_none:"None", avatarAcc_cap:"Cap", avatarAcc_beanie:"Beanie", avatarAcc_crown:"Crown", avatarAcc_wizardhat:"Wizard hat",
+ avatarAcc_glasses:"Glasses", avatarAcc_sunglasses:"Sunglasses", avatarAcc_eyepatch:"Eye patch", avatarAcc_bow:"Bow", avatarAcc_flower:"Flower",
+ avatarAcc_headphones:"Headphones", avatarAcc_partyhat:"Party hat", avatarAcc_scarf:"Scarf", avatarAcc_cape:"Cape",
+ avatarAcc_badge:"Badge", avatarAcc_necklace:"Necklace", avatarAcc_bowtie:"Bow tie", avatarAcc_belt:"Belt",
+ avatarAcc_backpack:"Backpack", avatarAcc_satchel:"Satchel", avatarAcc_wings:"Wings",
avatarSave:"Save", avatarSaved:"Character saved ✓",
clock24Hour:"24-hour clock",
widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose ‘New task’ to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo",
diff --git a/src/api.js b/src/api.js
index 3d8d67a..02756a8 100644
--- a/src/api.js
+++ b/src/api.js
@@ -195,9 +195,9 @@ export default async function api(app) {
// Open voor iedere ingelogde rol (ook leerling/ouder) - net als /me/data hierboven,
// geen rol-beperkte PERMISSIONS-sleutel nodig, altijd de eigen rij.
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_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 isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v);
app.put('/me/avatar', async (req, reply) => {
need(req, reply);
diff --git a/test/avatar.test.js b/test/avatar.test.js
index 26b4d5c..8f3dd6f 100644
--- a/test/avatar.test.js
+++ b/test/avatar.test.js
@@ -7,25 +7,22 @@ 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 created=[];
const context={
T:key=>key,
document:{
createElementNS(){
- const element={
+ return {
attributes:{},
innerHTML:'',
setAttribute(name,value){this.attributes[name]=String(value);},
};
- created.push(element);
- return element;
},
},
};
runInNewContext(source.slice(0,cutoff)+
- ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP};',
+ ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,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};',
context);
- return {api:context.__avatar,created};
+ return context.__avatar;
}
const base={
@@ -37,59 +34,103 @@ const base={
accessoryBody:'none',
};
-test('avatars hebben verbonden en herkenbare lichaamsdelen per soort',async()=>{
- const {api}=await loadAvatarRenderer();
+test('avatars combineren een grote kop met een compact soortspecifiek lijf',async()=>{
+ const api=await loadAvatarRenderer();
+ const bodies={};
for(const species of ['dino','monster','animal']){
const markup=api.avatarBodyMarkup(species,base.bodyColor);
+ bodies[species]=markup;
assert.equal((markup.match(/av-arm av-arm-/g)||[]).length,2,species+' mist twee armen');
assert.equal((markup.match(/av-leg av-leg-/g)||[]).length,2,species+' mist twee benen');
- for(const part of ['av-neck','av-torso','av-head','av-eye']){
+ for(const part of ['av-neck','av-torso-'+species,'av-head-large','av-eye']){
assert.ok(markup.includes(part),species+' mist '+part);
}
- assert.ok(markup.indexOf('av-arm'){
- const {api}=await loadAvatarRenderer();
- for(const id of ['none','scarf','cape','badge','necklace','backpack']){
+test('haar heeft natuurlijke voor- en achterlagen en vier nieuwe stijlen',async()=>{
+ const api=await loadAvatarRenderer();
+ assert.deepEqual(Array.from(api.hairIds),['none','tuft','spiky','curly','wavy','bob','ponytail','braids','mohawk']);
+ for(const id of api.hairIds){
+ assert.equal(typeof api.hair[id].back,'function',id+' mist haar-achterlaag');
+ assert.equal(typeof api.hair[id].front,'function',id+' mist haar-voorlaag');
+ }
+ for(const id of ['tuft','wavy','bob','braids']){
+ const markup=api.renderAvatarFace({...base,hairStyle:id,hairColor:'#123456'},'lg').innerHTML;
+ assert.ok(markup.includes('av-hair-'+id),id+' wordt niet getekend');
+ assert.ok(markup.includes('#123456'),id+' gebruikt de gekozen haarkleur niet');
+ }
+
+ const bob=api.renderAvatarFace({...base,hairStyle:'bob'},'lg').innerHTML;
+ assert.ok(bob.indexOf('av-hair-bob')bob.indexOf('av-head-large'));
+ const braids=api.renderAvatarFace({...base,hairStyle:'braids'},'lg').innerHTML;
+ assert.ok(braids.indexOf('av-hair-braids')braids.indexOf('av-head-large'));
+
+ const cap=api.renderAvatarFace({...base,hairStyle:'spiky',hairColor:'#123456',accessoryHead:'cap'},'lg').innerHTML;
+ const beanie=api.renderAvatarFace({...base,hairStyle:'spiky',hairColor:'#123456',accessoryHead:'beanie'},'lg').innerHTML;
+ const crown=api.renderAvatarFace({...base,hairStyle:'spiky',hairColor:'#123456',accessoryHead:'crown'},'lg').innerHTML;
+ const cappedBraids=api.renderAvatarFace({...base,hairStyle:'braids',hairColor:'#123456',accessoryHead:'cap'},'lg').innerHTML;
+ assert.ok(!cap.includes('#123456'),'pet hoort de voorste haarlaag af te dekken');
+ assert.ok(!beanie.includes('#123456'),'muts hoort de voorste haarlaag af te dekken');
+ assert.ok(crown.includes('#123456'),'kroon hoort op het haar te rusten');
+ assert.ok(cappedBraids.includes('#123456'),'vlechten horen naast een pet zichtbaar te blijven');
+});
+
+test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async()=>{
+ const api=await loadAvatarRenderer();
+ assert.deepEqual(Array.from(api.headIds),['none','cap','beanie','crown','wizardhat','glasses','sunglasses','eyepatch','bow','flower','headphones','partyhat']);
+ assert.deepEqual(Array.from(api.bodyIds),['none','scarf','cape','badge','necklace','bowtie','belt','backpack','satchel','wings']);
+
+ for(const id of api.bodyIds){
assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag');
assert.equal(typeof api.bodyAccessories[id].front,'function',id+' mist voorlaag');
}
+ for(const [id,part] of Object.entries({beanie:'av-beanie',wizardhat:'av-wizardhat',eyepatch:'av-eyepatch',flower:'av-flower'})){
+ const markup=api.renderAvatarFace({...base,accessoryHead:id},'lg').innerHTML;
+ assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort op de kop');
+ }
const cape=api.renderAvatarFace({...base,accessoryBody:'cape'},'lg').innerHTML;
- assert.ok(cape.indexOf('av-cape\"')cape.indexOf('av-head\"'));
+ assert.ok(cape.indexOf('av-cape"')cape.indexOf('av-head-large'));
const backpack=api.renderAvatarFace({...base,accessoryBody:'backpack'},'lg').innerHTML;
- assert.ok(backpack.indexOf('av-backpack\"')backpack.indexOf('av-head\"'));
+ assert.ok(backpack.indexOf('av-backpack"')backpack.indexOf('av-head-large'));
- for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace']]){
+ const satchel=api.renderAvatarFace({...base,accessoryBody:'satchel'},'lg').innerHTML;
+ assert.ok(satchel.indexOf('av-satchel"')satchel.indexOf('av-head-large'));
+
+ const wings=api.renderAvatarFace({...base,accessoryBody:'wings'},'lg').innerHTML;
+ assert.ok(wings.indexOf('av-wings')wings.indexOf('av-head-large'));
+
+ for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace'],['bowtie','av-bowtie'],['belt','av-belt']]){
const markup=api.renderAvatarFace({...base,accessoryBody:id},'lg').innerHTML;
- assert.ok(markup.indexOf(part)>markup.indexOf('av-head\"'),id+' hoort vóór op het lichaam');
+ assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort voor op het lijf');
}
});
-test('alle avatarcombinaties renderen volledig binnen de verruimde SVG',async()=>{
- const {api}=await loadAvatarRenderer();
- const species=['dino','monster','animal'];
- const hair=['none','spiky','curly','ponytail','mohawk'];
- const head=['none','cap','crown','glasses','sunglasses','bow','headphones','partyhat'];
- const body=['none','scarf','cape','badge','necklace','backpack'];
-
- for(const speciesId of species){
- for(const hairId of hair){
- for(const headId of head){
- for(const bodyId of body){
+test('alle 3240 avatarcombinaties renderen volledig binnen de SVG',async()=>{
+ const api=await loadAvatarRenderer();
+ let combinations=0;
+ for(const speciesId of ['dino','monster','animal']){
+ for(const hairId of api.hairIds){
+ for(const headId of api.headIds){
+ for(const bodyId of api.bodyIds){
const svg=api.renderAvatarFace({
...base,
species:speciesId,
@@ -97,18 +138,31 @@ test('alle avatarcombinaties renderen volledig binnen de verruimde SVG',async()=
accessoryHead:headId,
accessoryBody:bodyId,
},'sm');
- assert.equal(svg.attributes.viewBox,'-6 -12 112 124');
+ assert.equal(svg.attributes.viewBox,'-8 -21 116 132');
assert.equal(svg.attributes.preserveAspectRatio,'xMidYMid meet');
assert.doesNotMatch(svg.innerHTML,/undefined|NaN/);
- assert.ok(svg.innerHTML.includes('av-head'));
+ assert.ok(svg.innerHTML.includes('av-head-large'));
+ combinations++;
}
}
}
}
-
- const cap=api.renderAvatarFace({...base,hairStyle:'spiky',hairColor:'#123456',accessoryHead:'cap'},'lg').innerHTML;
- const crown=api.renderAvatarFace({...base,hairStyle:'spiky',hairColor:'#123456',accessoryHead:'crown'},'lg').innerHTML;
- assert.ok(!cap.includes('#123456'),'pet hoort het bovenhaar af te dekken');
- assert.ok(crown.includes('#123456'),'kroon hoort op het haar te rusten');
- for(const id of head)assert.equal(typeof api.headAccessories[id],'function');
+ assert.equal(combinations,3240);
+});
+
+test('client, API en beide talen bevatten dezelfde nieuwe avataropties',async()=>{
+ const [api,core]=await Promise.all([
+ readFile('src/api.js','utf8'),
+ readFile('public/js/core.js','utf8'),
+ ]);
+ const hair=['tuft','wavy','bob','braids'];
+ const accessories=['beanie','wizardhat','eyepatch','flower','bowtie','belt','satchel','wings'];
+ for(const id of hair){
+ assert.ok(api.includes("'"+id+"'"),id+' ontbreekt in API-validatie');
+ assert.equal(core.split('avatarHair_'+id+':').length-1,2,id+' mist een vertaling');
+ }
+ for(const id of accessories){
+ assert.ok(api.includes("'"+id+"'"),id+' ontbreekt in API-validatie');
+ assert.equal(core.split('avatarAcc_'+id+':').length-1,2,id+' mist een vertaling');
+ }
});