diff --git a/VERSION b/VERSION
index 762033b..b714bda 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.49-beta
+0.4.50-beta
diff --git a/public/js/avatar.js b/public/js/avatar.js
index ef0824c..a7f0d6d 100644
--- a/public/js/avatar.js
+++ b/public/js/avatar.js
@@ -6,19 +6,27 @@
los ernaast te zweven. */
"use strict";
const AVATAR_SPECIES = ["dino", "monster", "animal"];
+const AVATAR_VARIANTS = Object.freeze({
+ dino: ["rex", "triceratops", "stegosaurus", "raptor"],
+ monster: ["horned", "cyclops", "slime", "alien"],
+ animal: ["cat", "dog", "rabbit", "bear"],
+});
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",
+ species: "dino", speciesVariant: "rex", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29",
accessoryHead: "none", accessoryBody: "none",
});
const AVATAR_SVG_NS = "http://www.w3.org/2000/svg";
function avatarSanitize(config){
const c = config || {};
+ const species = AVATAR_SPECIES.includes(c.species) ? c.species : AVATAR_DEFAULT.species;
+ const variants = AVATAR_VARIANTS[species];
return {
- species: AVATAR_SPECIES.includes(c.species) ? c.species : AVATAR_DEFAULT.species,
+ species,
+ speciesVariant: variants.includes(c.speciesVariant) ? c.speciesVariant : variants[0],
bodyColor: /^#[0-9a-fA-F]{6}$/.test(c.bodyColor||"") ? c.bodyColor : AVATAR_DEFAULT.bodyColor,
hairStyle: AVATAR_HAIR.includes(c.hairStyle) ? c.hairStyle : AVATAR_DEFAULT.hairStyle,
hairColor: /^#[0-9a-fA-F]{6}$/.test(c.hairColor||"") ? c.hairColor : AVATAR_DEFAULT.hairColor,
@@ -43,48 +51,86 @@ function avatarShade(hex, amt){
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){
+function avatarBodyMarkup(species, bodyColor, speciesVariant){
+ const variants = AVATAR_VARIANTS[species] || AVATAR_VARIANTS[AVATAR_DEFAULT.species];
+ const variant = variants.includes(speciesVariant) ? speciesVariant : variants[0];
const outline = avatarShade(bodyColor, -0.48);
const dark = avatarShade(bodyColor, -0.24);
const light = avatarShade(bodyColor, 0.4);
const highlight = avatarShade(bodyColor, 0.72);
- let m = "";
+ let m = ``;
- /* Soortdetails achter kop en lijf. */
+ /* Silhouetdetails achter kop en lijf: iedere variant is direct herkenbaar. */
if(species === "dino"){
- m += ``;
- m += ``;
- }
- if(species === "animal"){
- m += ``;
- m += ``;
- m += ``;
- m += ``;
- }
- if(species === "monster"){
- m += ``;
- m += ``;
+ if(variant === "rex"){
+ m += ``;
+ }else if(variant === "triceratops"){
+ m += ``;
+ m += ``;
+ }else if(variant === "stegosaurus"){
+ m += ``;
+ m += ``;
+ m += ``;
+ }else{
+ m += ``;
+ m += ``;
+ }
+ }else if(species === "monster"){
+ if(variant === "horned"){
+ m += ``;
+ m += ``;
+ }else if(variant === "cyclops"){
+ m += ``;
+ }else if(variant === "slime"){
+ m += ``;
+ }else{
+ m += ``;
+ m += ``;
+ }
+ }else{
+ if(variant === "cat"){
+ m += ``;
+ m += ``;
+ m += ``;
+ }else if(variant === "dog"){
+ m += ``;
+ m += ``;
+ m += ``;
+ }else if(variant === "rabbit"){
+ m += ``;
+ m += ``;
+ m += ``;
+ }else{
+ m += ``;
+ m += ``;
+ m += ``;
+ }
}
- /* Korte ledematen verschillen per wezen en verdwijnen onder de romp. */
+ /* Korte ledematen blijven op gedeelde ankers voor alle accessoires. */
if(species === "dino"){
m += ``;
m += ``;
- m += ``;
- m += ``;
- m += ``;
+ if(variant === "triceratops"){
+ m += ``;
+ m += ``;
+ }else if(variant === "raptor"){
+ m += ``;
+ m += ``;
+ }else{
+ m += ``;
+ m += ``;
+ }
}else if(species === "monster"){
m += ``;
m += ``;
m += ``;
m += ``;
- m += ``;
}else{
m += ``;
m += ``;
m += ``;
m += ``;
- m += ``;
}
m += ``;
@@ -93,56 +139,108 @@ function avatarBodyMarkup(species, bodyColor){
: 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 += ``;
+ m += ``;
+
if(species === "dino"){
- m += ``;
- m += ``;
+ if(variant === "raptor"){
+ m += ``;
+ }else{
+ m += ``;
+ if(variant === "stegosaurus") m += ``;
+ }
}else if(species === "monster"){
m += ``;
- m += ``;
+ if(variant === "horned") m += ``;
+ if(variant === "cyclops") m += ``;
+ if(variant === "slime") m += ``;
+ if(variant === "alien") m += ``;
}else{
- m += ``;
+ if(variant === "cat") m += ``;
+ if(variant === "dog") m += ``;
+ if(variant === "rabbit") m += ``;
+ if(variant === "bear") 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 += ``;
+ const headPaths = {
+ dino: {
+ rex: "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",
+ triceratops: "M 50 -1 C 29 -2 16 11 17 31 C 17 51 30 63 50 64 C 70 64 83 51 83 31 C 84 11 71 -1 50 -1 Z",
+ stegosaurus: "M 48 -2 C 29 -2 16 10 16 31 C 16 50 29 62 48 64 C 68 66 84 53 84 32 C 84 11 69 -2 48 -2 Z",
+ raptor: "M 43 -2 C 26 1 16 14 18 33 C 19 50 30 60 46 62 C 64 65 80 55 87 40 C 91 30 83 24 76 21 C 74 7 61 -4 43 -2 Z",
+ },
+ monster: {
+ horned: "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",
+ cyclops: "M 50 -2 C 29 -3 16 10 16 31 C 16 53 30 65 50 65 C 70 65 84 53 84 31 C 84 10 71 -2 50 -2 Z",
+ slime: "M 49 0 C 30 -4 17 9 18 29 C 12 39 18 48 23 51 C 24 62 34 65 43 61 C 49 68 59 67 63 61 C 75 64 85 55 81 45 C 89 36 84 25 80 21 C 78 6 66 -2 49 0 Z",
+ alien: "M 50 -2 C 32 -2 20 9 18 28 C 16 48 28 62 50 65 C 72 62 84 48 82 28 C 80 9 68 -2 50 -2 Z",
+ },
+ animal: {
+ cat: "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",
+ dog: "M 50 0 C 29 -1 16 12 17 33 C 18 53 31 64 50 64 C 69 64 82 53 83 33 C 84 12 71 -1 50 0 Z",
+ rabbit: "M 50 -1 C 32 -1 20 11 19 31 C 18 51 30 64 50 66 C 70 64 82 51 81 31 C 80 11 68 -1 50 -1 Z",
+ bear: "M 50 -3 C 28 -3 15 10 16 32 C 17 54 30 65 50 65 C 70 65 83 54 84 32 C 85 10 72 -3 50 -3 Z",
+ },
+ };
+ m += ``;
m += ``;
if(species === "dino"){
- m += ``;
- m += ``;
- }else if(species === "animal"){
- m += ``;
- m += ``;
- }
-
- if(species === "monster"){
- m += ``;
- m += ``;
- m += ``;
- }else{
- m += ``;
- m += ``;
- m += ``;
- }
- m += ``;
-
- if(species === "dino"){
+ if(variant === "triceratops"){
+ m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ }else if(variant === "raptor"){
+ m += ``;
+ m += ``;
+ }else{
+ m += ``;
+ m += ``;
+ if(variant === "stegosaurus") m += ``;
+ }
+ m += ``;
+ m += ``;
m += ``;
- }else if(species === "animal"){
- m += ``;
+ if(variant === "raptor") m += ``;
+ }else if(species === "monster"){
+ if(variant === "cyclops"){
+ m += ``;
+ m += ``;
+ }else if(variant === "alien"){
+ for(const x of [34,50,66]) m += ``;
+ m += ``;
+ }else{
+ const leftSize = variant === "slime" ? 8.5 : 10;
+ const rightSize = variant === "slime" ? 8.5 : 8.2;
+ m += ``;
+ m += ``;
+ if(variant === "slime"){
+ m += ``;
+ }else{
+ m += ``;
+ }
+ }
}else{
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
+ if(variant === "cat"){
+ m += ``;
+ m += ``;
+ }else if(variant === "dog"){
+ m += ``;
+ m += ``;
+ }else if(variant === "rabbit"){
+ m += ``;
+ m += ``;
+ }else{
+ m += ``;
+ m += ``;
+ }
}
+ m += ``;
return m;
}
-
const AVATAR_HAIR_MARKUP = {
none: { back: () => "", front: () => "" },
tuft: {
@@ -285,6 +383,30 @@ const AVATAR_HEAD_ACC_MARKUP = {
`,
};
+/* Oogaccessoires volgen afwijkende oogindelingen van cycloop en alien. */
+function avatarHeadAccessoryMarkup(c){
+ if(c.species === "monster" && c.speciesVariant === "cyclops"){
+ if(c.accessoryHead === "glasses") return `
+
+ `;
+ if(c.accessoryHead === "sunglasses") return `
+
+ `;
+ if(c.accessoryHead === "eyepatch") return `
+
+ `;
+ }
+ if(c.species === "monster" && c.speciesVariant === "alien"){
+ if(c.accessoryHead === "glasses") return `
+
+
+ `;
+ if(c.accessoryHead === "sunglasses") return `
+
+ `;
+ }
+ return AVATAR_HEAD_ACC_MARKUP[c.accessoryHead]();
+}
const AVATAR_BODY_ACC_MARKUP = {
none: { back: () => "", front: () => "" },
scarf: {
@@ -368,16 +490,16 @@ function renderAvatarFace(config, size){
/* Extra ruimte voorkomt afsnijden van hoofddeksels, haar en voetzolen. */
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("class", "avatar-face " + (size || "md") + " av-species-" + c.species + " av-variant-" + c.speciesVariant);
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 += avatarHairLayer(hair.back(c.hairColor), c.species, "back");
- markup += avatarBodyMarkup(c.species, c.bodyColor);
+ markup += avatarBodyMarkup(c.species, c.bodyColor, c.speciesVariant);
if(!avatarHairSuppressed(c)) markup += avatarHairLayer(hair.front(c.hairColor), c.species, "front");
- markup += AVATAR_HEAD_ACC_MARKUP[c.accessoryHead]();
+ markup += avatarHeadAccessoryMarkup(c);
markup += bodyAccessory.front();
svg.innerHTML = markup;
return svg;
@@ -404,11 +526,19 @@ function buildAvatarEditor(container, initialConfig, onChange){
const b = document.createElement("button");
b.type = "button";
b.dataset.species = id;
- b.addEventListener("click", ()=>{ config.species = id; update(); });
+ b.addEventListener("click", ()=>{ config.species = id; config.speciesVariant = AVATAR_VARIANTS[id][0]; update(); });
speciesRow.appendChild(b);
});
controls.appendChild(speciesRow);
+ const variantRow = document.createElement("div"); variantRow.className = "avatar-row";
+ const variantField = document.createElement("label");
+ const variantSpan = document.createElement("span");
+ const variantSel = document.createElement("select");
+ variantSel.addEventListener("change", ()=>{ config.speciesVariant = variantSel.value; update(); });
+ variantField.append(variantSpan, variantSel);
+ variantRow.appendChild(variantField);
+ controls.appendChild(variantRow);
const row1 = document.createElement("div"); row1.className = "avatar-row";
const bodyColorField = document.createElement("label");
const bodyColorSpan = document.createElement("span");
@@ -454,6 +584,13 @@ function buildAvatarEditor(container, initialConfig, onChange){
b.textContent = T("avatarSpecies_" + b.dataset.species);
b.classList.toggle("active", b.dataset.species === config.species);
});
+ const variantIds = AVATAR_VARIANTS[config.species];
+ if([...variantSel.options].map(o=>o.value).join(",") !== variantIds.join(",")){
+ variantSel.innerHTML = "";
+ variantIds.forEach(id=>variantSel.appendChild(new Option("", id)));
+ }
+ variantSpan.textContent = T("avatarVariant");
+ [...variantSel.options].forEach(o=>o.textContent = T("avatarVariant_" + o.value));
bodyColorSpan.textContent = T("avatarBodyColor");
hairColorSpan.textContent = T("avatarHairColor");
hairSpan.textContent = T("avatarHairStyle");
@@ -464,12 +601,15 @@ function buildAvatarEditor(container, initialConfig, onChange){
[...bodyAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value));
}
function update(){
+ const variants = AVATAR_VARIANTS[config.species];
+ if(!variants.includes(config.speciesVariant)) config.speciesVariant = variants[0];
+ syncLabels();
bodyColorInput.value = config.bodyColor;
hairColorInput.value = config.hairColor;
+ variantSel.value = config.speciesVariant;
hairSel.value = config.hairStyle;
headAccSel.value = config.accessoryHead;
bodyAccSel.value = config.accessoryBody;
- syncLabels();
preview.innerHTML = "";
preview.appendChild(renderAvatarFace(config, "lg"));
if(onChange) onChange({ ...config });
@@ -518,6 +658,7 @@ async function saveAvatar(config){
visible: ()=>!!currentUser,
activate(){ showSettingsDom("avatar"); build(); },
keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"),
+ ...Object.values(AVATAR_VARIANTS).flat().map(id=>T("avatarVariant_" + id)),
"personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"],
});
})();
diff --git a/public/js/core.js b/public/js/core.js
index e6cdcb5..3a35fc0 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.49-beta";
+const VERSION = "0.4.50-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@@ -162,9 +162,12 @@ const I18N = {
"brePattern_4-4":"4 tellen in, 4 uit", "brePattern_4-7-8":"4 in, 7 vast, 8 uit", "brePattern_4-4-4-4":"4 in, 4 vast, 4 uit, 4 vast",
wg_compliments:"Complimentenmachine", wg_compliments_d:"Trek een willekeurig compliment om iemand blij te maken.",
complimentsDraw:"Trek een compliment",
- wg_avatar:"Mijn personage", wg_avatar_d:"Stel je eigen personage samen: soort, kleuren, haar en accessoires.",
+ wg_avatar:"Mijn personage", wg_avatar_d:"Kies een dinosaurus, monster of dier en daarna de variant, kleuren, haar en accessoires.",
stAvatar:"Mijn personage", stAvatarSub:"Stel je eigen personage samen.",
avatarAlt:"Mijn personage", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Dier",
+ avatarVariant:"Soortvariant", avatarVariant_rex:"T-rex", avatarVariant_triceratops:"Triceratops", avatarVariant_stegosaurus:"Stegosaurus", avatarVariant_raptor:"Velociraptor",
+ avatarVariant_horned:"Gehoornd monster", avatarVariant_cyclops:"Cycloop", avatarVariant_slime:"Slijmmonster", avatarVariant_alien:"Ruimtemonster",
+ avatarVariant_cat:"Kat", avatarVariant_dog:"Hond", avatarVariant_rabbit:"Konijn", avatarVariant_bear:"Beer",
avatarBodyColor:"Lichaamskleur", avatarHairColor:"Haarkleur", avatarHairStyle:"Haarstijl",
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",
@@ -549,9 +552,12 @@ const I18N = {
"brePattern_4-4":"4 counts in, 4 out", "brePattern_4-7-8":"4 in, 7 hold, 8 out", "brePattern_4-4-4-4":"4 in, 4 hold, 4 out, 4 hold",
wg_compliments:"Compliment machine", wg_compliments_d:"Draw a random compliment to make someone's day.",
complimentsDraw:"Draw a compliment",
- wg_avatar:"My character", wg_avatar_d:"Build your own character: species, colours, hair and accessories.",
+ wg_avatar:"My character", wg_avatar_d:"Choose a dinosaur, monster or animal, then its variant, colours, hair and accessories.",
stAvatar:"My character", stAvatarSub:"Build your own character.",
avatarAlt:"My character", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Animal",
+ avatarVariant:"Creature variant", avatarVariant_rex:"T. rex", avatarVariant_triceratops:"Triceratops", avatarVariant_stegosaurus:"Stegosaurus", avatarVariant_raptor:"Velociraptor",
+ avatarVariant_horned:"Horned monster", avatarVariant_cyclops:"Cyclops", avatarVariant_slime:"Slime monster", avatarVariant_alien:"Alien monster",
+ avatarVariant_cat:"Cat", avatarVariant_dog:"Dog", avatarVariant_rabbit:"Rabbit", avatarVariant_bear:"Bear",
avatarBodyColor:"Body colour", avatarHairColor:"Hair colour", avatarHairStyle:"Hair style",
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",
diff --git a/src/api.js b/src/api.js
index 02756a8..bf790a0 100644
--- a/src/api.js
+++ b/src/api.js
@@ -195,6 +195,11 @@ 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_VARIANTS = {
+ dino: ['rex', 'triceratops', 'stegosaurus', 'raptor'],
+ monster: ['horned', 'cyclops', 'slime', 'alien'],
+ animal: ['cat', 'dog', 'rabbit', 'bear'],
+ };
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'];
@@ -203,13 +208,16 @@ export default async function api(app) {
need(req, reply);
const b = req.body ?? {};
if (!AVATAR_SPECIES.includes(b.species)) return fail(reply, 400, 'ongeldige soort');
+ const variantOptions = AVATAR_VARIANTS[b.species];
+ const speciesVariant = b.speciesVariant ?? variantOptions[0];
+ if (!variantOptions.includes(speciesVariant)) return fail(reply, 400, 'ongeldige soortvariant');
if (!isHexColor(b.bodyColor)) return fail(reply, 400, 'ongeldige lichaamskleur');
if (!AVATAR_HAIR.includes(b.hairStyle)) return fail(reply, 400, 'ongeldige haarstijl');
if (!isHexColor(b.hairColor)) return fail(reply, 400, 'ongeldige haarkleur');
if (!AVATAR_HEAD_ACC.includes(b.accessoryHead)) return fail(reply, 400, 'ongeldig hoofdaccessoire');
if (!AVATAR_BODY_ACC.includes(b.accessoryBody)) return fail(reply, 400, 'ongeldig lichaamsaccessoire');
const avatar = {
- species: b.species, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor,
+ species: b.species, speciesVariant, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor,
accessoryHead: b.accessoryHead, accessoryBody: b.accessoryBody,
};
await pool.query('UPDATE users SET avatar_data = $1 WHERE id = $2', [avatar, req.user.id]);
diff --git a/test/avatar.test.js b/test/avatar.test.js
index 4400b4c..8c50713 100644
--- a/test/avatar.test.js
+++ b/test/avatar.test.js
@@ -20,13 +20,14 @@ async function loadAvatarRenderer(){
},
};
runInNewContext(source.slice(0,cutoff)+
- ';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};',
+ ';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};',
context);
return context.__avatar;
}
const base={
species:'dino',
+ speciesVariant:'rex',
bodyColor:'#3fae6a',
hairStyle:'none',
hairColor:'#5b3a29',
@@ -34,30 +35,63 @@ const base={
accessoryBody:'none',
};
-test('avatars combineren een grote kop met een compact soortspecifiek lijf',async()=>{
+test("iedere categorie heeft vier anatomisch herkenbare avatarvarianten",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-'+species,'av-head-large','av-eye']){
- assert.ok(markup.includes(part),species+' mist '+part);
+ const variants={
+ dino:["rex","triceratops","stegosaurus","raptor"],
+ monster:["horned","cyclops","slime","alien"],
+ animal:["cat","dog","rabbit","bear"],
+ };
+ for(const [species,ids] of Object.entries(variants)){
+ assert.deepEqual(Array.from(api.variants[species]),ids);
+ const bodies=[];
+ for(const id of ids){
+ const markup=api.avatarBodyMarkup(species,base.bodyColor,id);
+ bodies.push(markup);
+ assert.ok(markup.includes("av-creature-"+id),species+"/"+id+" mist variantklasse");
+ assert.equal((markup.match(/av-arm av-arm-/g)||[]).length,2,species+"/"+id+" mist twee armen");
+ assert.equal((markup.match(/av-leg av-leg-/g)||[]).length,2,species+"/"+id+" mist twee benen");
+ for(const part of ["av-neck","av-torso-"+species,"av-head-large","av-eye"]){
+ assert.ok(markup.includes(part),species+"/"+id+" mist "+part);
+ }
+ assert.ok(markup.indexOf("av-arm")api.avatarBodyMarkup(species,base.bodyColor,id);
+ assert.ok(markup("dino","rex").includes("av-tail-rex"));
+ assert.ok(markup("dino","triceratops").includes("av-frill-triceratops"));
+ assert.ok(markup("dino","triceratops").includes("av-nose-horn"));
+ assert.ok(markup("dino","stegosaurus").includes("av-dino-plates"));
+ assert.ok(markup("dino","raptor").includes("av-feather-crest-raptor"));
+ assert.ok(markup("dino","raptor").includes("av-feather-chest"));
+
+ assert.ok(markup("monster","horned").includes("av-horn-left"));
+ assert.equal((markup("monster","cyclops").match(/class="av-part av-eye/g)||[]).length,1);
+ assert.ok(markup("monster","cyclops").includes("av-cyclops-eye"));
+ assert.ok(markup("monster","slime").includes("av-slime-drop"));
+ assert.equal((markup("monster","alien").match(/av-alien-eye/g)||[]).length,3);
+ assert.ok(markup("monster","alien").includes("av-antenna-tip"));
+
+ assert.ok(markup("animal","cat").includes("av-whiskers"));
+ assert.ok(markup("animal","dog").includes("av-ear-dog"));
+ assert.ok(markup("animal","dog").includes("av-tongue"));
+ assert.ok(markup("animal","rabbit").includes("av-ear-rabbit"));
+ assert.ok(markup("animal","rabbit").includes("av-rabbit-teeth"));
+ assert.ok(markup("animal","bear").includes("av-ear-bear"));
+ assert.ok(markup("animal","bear").includes("av-muzzle-bear"));
+
+ assert.equal(api.sanitize({species:"dino"}).speciesVariant,"rex");
+ assert.equal(api.sanitize({species:"monster"}).speciesVariant,"horned");
+ assert.equal(api.sanitize({species:"animal"}).speciesVariant,"cat");
+ assert.equal(api.sanitize({species:"animal",speciesVariant:"rabbit"}).speciesVariant,"rabbit");
+ assert.equal(api.sanitize({species:"monster",speciesVariant:"rabbit"}).speciesVariant,"horned");
+});
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"]);
@@ -128,6 +162,14 @@ test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async
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 cyclopsGlasses=api.renderAvatarFace({...base,species:"monster",speciesVariant:"cyclops",accessoryHead:"glasses"},"lg").innerHTML;
+ const cyclopsPatch=api.renderAvatarFace({...base,species:"monster",speciesVariant:"cyclops",accessoryHead:"eyepatch"},"lg").innerHTML;
+ const alienGlasses=api.renderAvatarFace({...base,species:"monster",speciesVariant:"alien",accessoryHead:"glasses"},"lg").innerHTML;
+ const alienShades=api.renderAvatarFace({...base,species:"monster",speciesVariant:"alien",accessoryHead:"sunglasses"},"lg").innerHTML;
+ assert.ok(cyclopsGlasses.includes("av-glasses-cyclops"),"cycloopbril hoort rond het ene oog");
+ assert.ok(cyclopsPatch.includes("av-eyepatch-cyclops"),"cycloopooglap hoort voor het ene oog");
+ assert.ok(alienGlasses.includes("av-glasses-alien"),"alienbril hoort rond drie ogen");
+ assert.ok(alienShades.includes("av-sunglasses-alien"),"alienzonnebril hoort alle drie ogen af te dekken");
const cape=api.renderAvatarFace({...base,accessoryBody:'cape'},'lg').innerHTML;
assert.ok(cape.indexOf('av-cape"'){
+test("alle 12960 variant-, haar- en accessoirecombinaties renderen volledig",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,
- hairStyle:hairId,
- accessoryHead:headId,
- accessoryBody:bodyId,
- },'sm');
- 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-large'));
- combinations++;
+ for(const speciesId of ["dino","monster","animal"]){
+ for(const variantId of api.variants[speciesId]){
+ 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,
+ speciesVariant:variantId,
+ hairStyle:hairId,
+ accessoryHead:headId,
+ accessoryBody:bodyId,
+ },"sm");
+ 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-large"));
+ assert.ok(svg.innerHTML.includes("av-creature-"+variantId));
+ combinations++;
+ }
}
}
}
}
- assert.equal(combinations,3240);
+ assert.equal(combinations,12960);
});
-
-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'),
+test("client, API en beide talen bevatten dezelfde avataropties",async()=>{
+ const [apiSource,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'];
+ const hair=["tuft","wavy","bob","braids"];
+ const accessories=["beanie","wizardhat","eyepatch","flower","bowtie","belt","satchel","wings"];
+ const variants=["rex","triceratops","stegosaurus","raptor","horned","cyclops","slime","alien","cat","dog","rabbit","bear"];
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');
+ assert.ok(apiSource.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');
+ assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie");
+ assert.equal(core.split("avatarAcc_"+id+":").length-1,2,id+" mist een vertaling");
}
+ for(const id of variants){
+ assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-variantvalidatie");
+ assert.equal(core.split("avatarVariant_"+id+":").length-1,2,id+" mist een variantvertaling");
+ }
+ assert.ok(apiSource.includes("speciesVariant"),"API slaat de soortvariant niet op");
+ assert.equal(core.split("avatarVariant:").length-1,2,"variantveld mist een label");
});
diff --git a/test/save-system.test.js b/test/save-system.test.js
index ea4d7be..f63a031 100644
--- a/test/save-system.test.js
+++ b/test/save-system.test.js
@@ -38,6 +38,39 @@ function makeApp({ dataRev = 3, updateSucceeds = true } = {}) {
const sessionCookie = { teach_session: 'x'.repeat(64) };
+const avatarPayload = {
+ species: "animal", speciesVariant: "rabbit", bodyColor: "#aabbcc",
+ hairStyle: "wavy", hairColor: "#553311", accessoryHead: "glasses", accessoryBody: "scarf",
+};
+
+test("avatar-API bewaart een geldige variant binnen de gekozen categorie", async () => {
+ const { app, calls } = await makeApp();
+ const res = await app.inject({ method: "PUT", url: "/api/me/avatar", cookies: sessionCookie, payload: avatarPayload });
+ assert.equal(res.statusCode, 200, res.body);
+ assert.equal(res.json().avatar.speciesVariant, "rabbit");
+ const update = calls.find((c) => c.sql.startsWith("UPDATE users SET avatar_data"));
+ assert.equal(update.params[0].species, "animal");
+ assert.equal(update.params[0].speciesVariant, "rabbit");
+ await app.close();
+});
+
+test("avatar-API geeft oudere clients een standaardvariant", async () => {
+ const { app } = await makeApp();
+ const { speciesVariant, ...legacyPayload } = avatarPayload;
+ const res = await app.inject({ method: "PUT", url: "/api/me/avatar", cookies: sessionCookie, payload: legacyPayload });
+ assert.equal(res.statusCode, 200, res.body);
+ assert.equal(res.json().avatar.speciesVariant, "cat");
+ await app.close();
+});
+
+test("avatar-API weigert een variant uit een andere categorie", async () => {
+ const { app, calls } = await makeApp();
+ const res = await app.inject({ method: "PUT", url: "/api/me/avatar", cookies: sessionCookie, payload: { ...avatarPayload, speciesVariant: "rex" } });
+ assert.equal(res.statusCode, 400, res.body);
+ assert.equal(res.json().error, "ongeldige soortvariant");
+ assert.equal(calls.some((c) => c.sql.startsWith("UPDATE users SET avatar_data")),false);
+ await app.close();
+});
test('GET /me/data geeft data én rev terug', async () => {
const { app } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie });