diff --git a/VERSION b/VERSION
index 5fbcef1..2cadb92 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.45-beta
+0.4.46-beta
diff --git a/public/css/teach.css b/public/css/teach.css
index ab19372..2a812aa 100644
--- a/public/css/teach.css
+++ b/public/css/teach.css
@@ -1779,8 +1779,8 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000;
.bd-candle::before{content:""; position:absolute; width:8px; height:12px; left:-1.5px; top:-12px; border-radius:70% 30% 60% 40%; background:linear-gradient(#fff7a0,var(--orange)); box-shadow:0 0 8px var(--orange);}
.bd-age-badge{position:absolute; right:8%; top:20%; z-index:4; min-width:58px; height:58px; padding:0 10px; border-radius:20px; display:grid; place-items:center; color:#fff; font-size:29px; font-weight:900; transform:rotate(7deg); box-shadow:0 5px 12px rgba(0,0,0,.2); border:3px solid #fff;}
.bd-plate{width:min(94%,420px); height:30px; border-radius:50%; margin-top:-12px; box-shadow:0 8px 12px rgba(29,52,97,.22); z-index:1;}
-/* =========== Personage (avatar): gelaagde div's + CSS custom properties,
- zelfde aanpak als de taart hierboven (--cake/--icing), geen SVG/afbeeldingen. ==== */
+/* =========== Personage (avatar): schaalbare SVG met anatomische ankerpunten
+ en vaste voor-/achterlagen voor haar en accessoires. ====================== */
.avatar-face{display:inline-block; flex:none; overflow:visible;}
.avatar-face.sm{width:24px; height:24px;}
.avatar-face.md{width:44px; height:44px;}
diff --git a/public/js/avatar.js b/public/js/avatar.js
index af23903..7a7590c 100644
--- a/public/js/avatar.js
+++ b/public/js/avatar.js
@@ -1,6 +1,6 @@
/* teach - personage (avatar): eigen figuur samenstellen (soort, kleuren, haar,
accessoires), gedeeld tussen de Instellingen-sectie "Mijn personage" en het
- avatar-widget. Getekend als één SVG (viewBox 0 0 100 100, tekenvolgorde =
+ avatar-widget. Getekend als één SVG rond vaste anatomische ankers; de tekenvolgorde =
laagvolgorde - geen z-index-gepuzzel zoals bij de eerdere CSS-laagjesversie)
zodat elk onderdeel z'n vaste plek op het silhouet overlapt in plaats van
los ernaast te zweven. */
@@ -39,70 +39,82 @@ function avatarShade(hex, amt){
return "#" + [r,g,b].map(v=>clamp(v).toString(16).padStart(2,"0")).join("");
}
-/* per-soort silhouet: elk onderdeel overlapt het vorige ruim (poten/armen/
- staart tot ver ín het lijf, rugstekels/hoorns pas ná de kop getekend) zodat
- niets los oogt. Volgorde = tekenvolgorde = zichtbare laagvolgorde. */
+/* 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. */
function avatarBodyMarkup(species, bodyColor){
- const dark = avatarShade(bodyColor, -0.35);
+ const outline = avatarShade(bodyColor, -0.48);
+ const dark = avatarShade(bodyColor, -0.24);
const light = avatarShade(bodyColor, 0.4);
+ const highlight = avatarShade(bodyColor, 0.7);
let m = "";
+
+ /* Onderdelen achter het lijf: soortdetails, benen en doorlopende armen. */
if(species === "dino"){
- m += ``;
- }
- m += ``;
- m += ``;
- m += ``;
- m += ``;
- m += ``;
- m += ``;
- if(species === "animal"){
- m += ``;
- m += ``;
- m += ``;
- m += ``;
- }
- if(species === "monster"){
- const darker = avatarShade(bodyColor, -0.5);
- m += ``;
- m += ``;
- }
- m += ``;
- if(species === "dino"){
- m += ``;
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
}
if(species === "animal"){
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
}
if(species === "monster"){
- m += ``;
- m += ``;
- 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 += ``;
+
+ if(species === "dino"){
+ m += ``;
+ m += ``;
+ m += ``;
+ }
+ if(species === "animal"){
+ m += ``;
+ m += ``;
+ }
+ if(species === "monster"){
+ m += ``;
+ m += ``;
+ m += ``;
}else{
- m += ``;
- m += ``;
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
+ m += ``;
}
+ m += ``;
if(species === "dino"){
- m += ``;
- /* rugstekels als klein randje bovenop de kop i.p.v. voor de hals - een
- rij spitse vormen op de borst/hals oogde als een vreemde kraag; dit
- zit anatomisch dichter bij de rug en is ook stiller op klein formaat. */
- const plate = avatarShade(bodyColor, -0.45);
- m += ``;
- m += ``;
+ m += ``;
}
if(species === "animal"){
- m += ``;
+ m += ``;
}
if(species === "monster"){
- m += ``;
- m += ``;
- m += ``;
+ m += ``;
+ m += ``;
}
return m;
}
@@ -131,69 +143,89 @@ const AVATAR_HAIR_MARKUP = {
const AVATAR_HEAD_ACC_MARKUP = {
none: () => "",
cap: () => `
-
- `,
+
+
+ `,
crown: () => `
-
+
+
-
+
`,
glasses: () => `
-
-
+
+
`,
- bow: () => `
-
- `,
sunglasses: () => `
-
+
-
-
-
- `,
+
+ `,
+ bow: () => `
+
+ `,
headphones: () => `
-
-
-
-
- `,
+
+
+
+
+ `,
partyhat: () => `
-
-
+
+
- `,
+ `,
};
const AVATAR_BODY_ACC_MARKUP = {
- none: () => "",
- scarf: () => `
-
- `,
- cape: () => `
-
- `,
- badge: () => ``,
- necklace: () => `
-
- `,
- backpack: () => `
-
-
-
- `,
+ none: { back: () => "", front: () => "" },
+ scarf: {
+ back: () => "",
+ front: () => `
+
+
+ `,
+ },
+ cape: {
+ back: () => `
+
+ `,
+ front: () => `
+
+
+ `,
+ },
+ badge: {
+ back: () => "",
+ front: () => `
+
+ `,
+ },
+ necklace: {
+ back: () => "",
+ front: () => `
+
+ `,
+ },
+ backpack: {
+ back: () => `
+
+ `,
+ front: () => `
+
+ `,
+ },
};
-/* een pet/kroon dekt het hele bovenhoofd af - loshangend haar eronder tonen
- gaf rommelige, half-verstopte plukjes rond de rand (zie roadmap-memory).
- Een paardenstaart mag wel blijven: die hangt naar opzij, niet over de kop. */
+/* Alleen een pet bedekt het bovenhoofd volledig. Een kroon rust juist op het
+ haar; een paardenstaart blijft naast de pet zichtbaar. */
function avatarHairSuppressed(c){
- return (c.accessoryHead === "cap" || c.accessoryHead === "crown") && c.hairStyle !== "ponytail";
+ return c.accessoryHead === "cap" && c.hairStyle !== "ponytail";
}
/* bouwt de SVG-weergave van een personage; config==null → neutrale
@@ -203,16 +235,18 @@ function avatarHairSuppressed(c){
function renderAvatarFace(config, size){
const c = avatarSanitize(config);
const svg = document.createElementNS(AVATAR_SVG_NS, "svg");
- svg.setAttribute("viewBox", "0 0 100 100");
+ /* Extra ruimte voorkomt afsnijden van hoofddeksels, haar en voetzolen. */
+ svg.setAttribute("viewBox", "-6 -12 112 124");
+ 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"));
- let markup = "";
- if(c.accessoryBody === "cape") markup += AVATAR_BODY_ACC_MARKUP.cape();
+ const bodyAccessory = AVATAR_BODY_ACC_MARKUP[c.accessoryBody];
+ let markup = bodyAccessory.back();
markup += avatarBodyMarkup(c.species, c.bodyColor);
if(!avatarHairSuppressed(c)) markup += AVATAR_HAIR_MARKUP[c.hairStyle](c.hairColor);
markup += AVATAR_HEAD_ACC_MARKUP[c.accessoryHead]();
- if(c.accessoryBody !== "cape") markup += AVATAR_BODY_ACC_MARKUP[c.accessoryBody]();
+ markup += bodyAccessory.front();
svg.innerHTML = markup;
return svg;
}
diff --git a/public/js/core.js b/public/js/core.js
index 8718269..db242af 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.45-beta";
+const VERSION = "0.4.46-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
diff --git a/test/avatar.test.js b/test/avatar.test.js
new file mode 100644
index 0000000..26b4d5c
--- /dev/null
+++ b/test/avatar.test.js
@@ -0,0 +1,114 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {runInNewContext} from 'node:vm';
+
+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={
+ 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};',
+ context);
+ return {api:context.__avatar,created};
+}
+
+const base={
+ species:'dino',
+ bodyColor:'#3fae6a',
+ hairStyle:'none',
+ hairColor:'#5b3a29',
+ accessoryHead:'none',
+ accessoryBody:'none',
+};
+
+test('avatars hebben verbonden en herkenbare lichaamsdelen per soort',async()=>{
+ const {api}=await loadAvatarRenderer();
+ for(const species of ['dino','monster','animal']){
+ const markup=api.avatarBodyMarkup(species,base.bodyColor);
+ 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']){
+ 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']){
+ assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag');
+ assert.equal(typeof api.bodyAccessories[id].front,'function',id+' mist voorlaag');
+ }
+
+ const cape=api.renderAvatarFace({...base,accessoryBody:'cape'},'lg').innerHTML;
+ assert.ok(cape.indexOf('av-cape\"')cape.indexOf('av-head\"'));
+
+ const backpack=api.renderAvatarFace({...base,accessoryBody:'backpack'},'lg').innerHTML;
+ assert.ok(backpack.indexOf('av-backpack\"')backpack.indexOf('av-head\"'));
+
+ for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace']]){
+ 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');
+ }
+});
+
+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){
+ const svg=api.renderAvatarFace({
+ ...base,
+ species:speciesId,
+ hairStyle:hairId,
+ accessoryHead:headId,
+ accessoryBody:bodyId,
+ },'sm');
+ assert.equal(svg.attributes.viewBox,'-6 -12 112 124');
+ assert.equal(svg.attributes.preserveAspectRatio,'xMidYMid meet');
+ assert.doesNotMatch(svg.innerHTML,/undefined|NaN/);
+ assert.ok(svg.innerHTML.includes('av-head'));
+ }
+ }
+ }
+ }
+
+ 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');
+});