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'); + } });