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