Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1

Open
bes-r wants to merge 192 commits from bes-r/avatar-realism into main AGit
5 changed files with 714 additions and 24 deletions
Showing only changes of commit 3ad7c80bad - Show all commits

View file

@ -1 +1 @@
0.3.52-beta 0.3.54-beta

View file

@ -1454,7 +1454,7 @@ body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2
.place-grid{display:grid;grid-template-columns:repeat(6,1fr);width:100%;border:1px solid var(--line);border-radius:14px;overflow:hidden}.place-grid div{text-align:center;border-right:1px solid var(--line)}.place-grid b{display:block;padding:8px;background:var(--surface-2);font-size:12px}.place-grid output{display:block;padding:18px 4px;font-size:clamp(25px,5vw,48px);font-weight:900;color:var(--blue)} .place-grid{display:grid;grid-template-columns:repeat(6,1fr);width:100%;border:1px solid var(--line);border-radius:14px;overflow:hidden}.place-grid div{text-align:center;border-right:1px solid var(--line)}.place-grid b{display:block;padding:8px;background:var(--surface-2);font-size:12px}.place-grid output{display:block;padding:18px 4px;font-size:clamp(25px,5vw,48px);font-weight:900;color:var(--blue)}
.money-app{width:100%;min-height:100%;align-self:flex-start;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)} .money-app{width:100%;min-height:100%;align-self:flex-start;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)}
.money-top{position:sticky;top:-16px;z-index:6;width:100%;display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;padding:8px 4px;background:var(--surface);border-bottom:1px solid var(--line)}.money-level-control{display:flex;align-items:center;gap:6px}.money-level-control>span{color:var(--muted);font-size:12px;font-weight:900}.money-levels button{min-width:31px;padding:6px 9px;white-space:nowrap}.money-mode{width:36px;padding:0;font-size:18px}.money-score{margin-left:auto;padding:6px 10px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:900} .money-top{position:sticky;top:-16px;z-index:6;width:100%;display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;padding:8px 4px;background:var(--surface);border-bottom:1px solid var(--line)}.money-level-control{display:flex;align-items:center;gap:6px}.money-level-control>span{color:var(--muted);font-size:12px;font-weight:900}.money-levels button{min-width:31px;padding:6px 9px;white-space:nowrap}.money-mode{width:36px;padding:0;font-size:18px}.money-score{margin-left:auto;padding:6px 10px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:900}
.money-task-card{width:min(100%,650px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);box-shadow:var(--shadow-1)}.money-task-card h3{margin:0;text-align:center;font-size:clamp(19px,3vw,25px)} .money-task-card{width:min(100%,650px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);box-shadow:var(--shadow-1)}.money-task-card h3{margin:0;text-align:center;font-size:clamp(19px,3vw,25px)}.money-level-hint{margin:-7px 0 0;text-align:center;color:var(--muted);font-size:12px;font-weight:800}
.money-target{width:100%;min-height:98px;display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;padding:12px;border-radius:16px;background:var(--surface-2)}.money-target>span:first-child{flex-basis:100%;color:var(--muted);font-size:12px;font-weight:900;text-transform:uppercase;letter-spacing:.5px}.money-assignment{display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;text-align:center}.money-task-amount{font-size:clamp(45px,10vw,72px);line-height:1;font-weight:950;color:var(--green)}.money-task-words{flex-basis:100%;margin:0;font-size:clamp(21px,4vw,31px);line-height:1.15;font-weight:950;color:var(--purple)}.money-assignment.compact .money-task-amount{font-size:clamp(38px,8vw,58px)}.money-assignment.compact .money-task-words{font-size:clamp(19px,4vw,27px)} .money-target{width:100%;min-height:98px;display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;padding:12px;border-radius:16px;background:var(--surface-2)}.money-target>span:first-child{flex-basis:100%;color:var(--muted);font-size:12px;font-weight:900;text-transform:uppercase;letter-spacing:.5px}.money-assignment{display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;text-align:center}.money-task-amount{font-size:clamp(45px,10vw,72px);line-height:1;font-weight:950;color:var(--green)}.money-task-words{flex-basis:100%;margin:0;font-size:clamp(21px,4vw,31px);line-height:1.15;font-weight:950;color:var(--purple)}.money-assignment.compact .money-task-amount{font-size:clamp(38px,8vw,58px)}.money-assignment.compact .money-task-words{font-size:clamp(19px,4vw,27px)}
.money-wallet{width:100%;min-height:112px;padding:10px;border:3px dashed var(--line);border-radius:18px;background:linear-gradient(145deg,var(--surface-2),var(--surface));transition:border-color .15s,background .15s,transform .15s}.money-wallet>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}.money-wallet output{font-size:24px;font-weight:950;color:var(--green)}.money-wallet>p{margin:22px auto;color:var(--muted);font-weight:800;text-align:center}.money-wallet.droptarget{border-color:var(--green);background:#e4f6eb;transform:scale(1.01)}.money-wallet.money-remove-target{border-color:var(--red);background:#fff0ed;box-shadow:0 0 0 4px rgba(190,55,45,.1)} .money-wallet{width:100%;min-height:112px;padding:10px;border:3px dashed var(--line);border-radius:18px;background:linear-gradient(145deg,var(--surface-2),var(--surface));transition:border-color .15s,background .15s,transform .15s}.money-wallet>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}.money-wallet output{font-size:24px;font-weight:950;color:var(--green)}.money-wallet>p{margin:22px auto;color:var(--muted);font-weight:800;text-align:center}.money-wallet.droptarget{border-color:var(--green);background:#e4f6eb;transform:scale(1.01)}.money-wallet.money-remove-target{border-color:var(--red);background:#fff0ed;box-shadow:0 0 0 4px rgba(190,55,45,.1)}
.money-pile{display:flex;align-items:center;justify-content:center;gap:7px;flex-wrap:wrap;min-height:58px} .money-pile{display:flex;align-items:center;justify-content:center;gap:7px;flex-wrap:wrap;min-height:58px}
@ -1463,11 +1463,11 @@ body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2
.money-piece.note{height:43px;border-radius:2px}.money-piece.note .money-piece-fallback{border-radius:2px}.money-piece.note[data-value="500"]{width:82px;height:42px}.money-piece.note[data-value="1000"]{width:86px;height:45px}.money-piece.note[data-value="2000"]{width:89px;height:48px}.money-piece.note[data-value="5000"]{width:93px;height:51px}.money-drag-clone{opacity:.98}.money-drag-clone.from-wallet{opacity:.92;filter:drop-shadow(0 14px 18px rgba(160,45,45,.3))} .money-piece.note{height:43px;border-radius:2px}.money-piece.note .money-piece-fallback{border-radius:2px}.money-piece.note[data-value="500"]{width:82px;height:42px}.money-piece.note[data-value="1000"]{width:86px;height:45px}.money-piece.note[data-value="2000"]{width:89px;height:48px}.money-piece.note[data-value="5000"]{width:93px;height:51px}.money-drag-clone{opacity:.98}.money-drag-clone.from-wallet{opacity:.92;filter:drop-shadow(0 14px 18px rgba(160,45,45,.3))}
.money-tray-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}.money-tray-wrap>b{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.4px}.money-cash-tray{width:100%;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;padding:11px;border:1px solid var(--line);border-radius:16px;background:var(--surface-2)} .money-tray-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}.money-tray-wrap>b{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.4px}.money-cash-tray{width:100%;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;padding:11px;border:1px solid var(--line);border-radius:16px;background:var(--surface-2)}
.money-count-task{width:100%;min-height:128px;display:grid;place-items:center;padding:14px;border-radius:17px;background:linear-gradient(145deg,#eef7e9,var(--surface-2));border:1px solid var(--line)}.money-count-task .money-piece{transform:rotate(var(--money-tilt,0deg))} .money-count-task{width:100%;min-height:128px;display:grid;place-items:center;padding:14px;border-radius:17px;background:linear-gradient(145deg,#eef7e9,var(--surface-2));border:1px solid var(--line)}.money-count-task .money-piece{transform:rotate(var(--money-tilt,0deg))}
.money-shop-task{width:100%;display:grid;grid-template-columns:minmax(0,1fr) minmax(150px,.8fr);gap:12px}.money-product,.money-paid{min-height:150px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;padding:12px;border:1px solid var(--line);border-radius:17px;background:var(--surface-2)}.money-product-icon{font-size:clamp(52px,8vw,70px);line-height:1;filter:drop-shadow(0 5px 5px rgba(30,45,70,.2))}.money-product>b,.money-paid>b{color:var(--muted);font-size:12px;text-transform:uppercase}.money-product .money-task-amount{font-size:37px}.money-product .money-task-words{font-size:19px}.money-paid>output{font-size:31px;font-weight:950;color:var(--blue)} .money-shop-task{width:100%;display:grid;grid-template-columns:minmax(0,1fr) minmax(150px,.8fr);gap:12px}.money-product,.money-paid{min-height:150px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;padding:12px;border:1px solid var(--line);border-radius:17px;background:var(--surface-2)}.money-product-icon{font-size:clamp(52px,8vw,70px);line-height:1;filter:drop-shadow(0 5px 5px rgba(30,45,70,.2))}.money-product-name{max-width:100%;font-size:17px;font-weight:950;text-align:center}.money-product>b,.money-paid>b{color:var(--muted);font-size:12px;text-transform:uppercase}.money-product .money-task-amount{font-size:37px}.money-product .money-task-words{font-size:19px}.money-paid>output{font-size:31px;font-weight:950;color:var(--blue)}
.money-digit-answer,.money-type-answer{width:min(100%,300px);display:flex;flex-direction:column;align-items:center;gap:10px}.money-digit-answer>p,.money-type-answer>p{margin:0;color:var(--muted);font-weight:800;text-align:center}.money-answer-slots{display:flex;align-items:center;justify-content:center;gap:6px}.money-answer-slots>strong{font-size:29px}.money-digit-slot.slot.sm{width:43px;height:64px;padding-top:1px}.money-digit-slot.slot.sm .ghost{height:24px;font-size:20px}.money-digit-slot.slot.sm .slot-dots{margin-bottom:3px}.money-digit-slot.filled{padding:0}.money-digit-slot.fixed{border-style:solid;background:var(--surface-2);opacity:.72}.money-digit-block.blok{width:39px;height:58px;min-height:58px;padding-top:2px;cursor:default}.money-digit-block.blok .bl{height:28px;font-size:25px;color:var(--purple)}.money-digit-block.blok .blok-dots{margin-bottom:4px}.money-digit-tray.lg-tray{display:grid;grid-template-columns:repeat(5,39px);gap:7px;min-height:0;padding:11px 14px}.money-digit-tray .money-drag-digit{cursor:grab}.money-digit-tray .money-drag-digit:active{cursor:grabbing}.money-digit-slot.droptarget{border-color:var(--green);background:#e3f6ea;transform:scale(1.06)} .money-digit-answer,.money-type-answer{width:min(100%,300px);display:flex;flex-direction:column;align-items:center;gap:10px}.money-digit-answer>p,.money-type-answer>p{margin:0;color:var(--muted);font-weight:800;text-align:center}.money-answer-slots{display:flex;align-items:center;justify-content:center;gap:6px}.money-answer-slots>strong{font-size:29px}.money-digit-slot.slot.sm{width:43px;height:64px;padding-top:1px}.money-digit-slot.slot.sm .ghost{height:24px;font-size:20px}.money-digit-slot.slot.sm .slot-dots{margin-bottom:3px}.money-digit-slot.filled{padding:0}.money-digit-slot.fixed{border-style:solid;background:var(--surface-2);opacity:.72}.money-digit-block.blok{width:39px;height:58px;min-height:58px;padding-top:2px;cursor:default}.money-digit-block.blok .bl{height:28px;font-size:25px;color:var(--purple)}.money-digit-block.blok .blok-dots{margin-bottom:4px}.money-digit-tray.lg-tray{display:grid;grid-template-columns:repeat(5,39px);gap:7px;min-height:0;padding:11px 14px}.money-digit-tray .money-drag-digit{cursor:grab}.money-digit-tray .money-drag-digit:active{cursor:grabbing}.money-digit-slot.droptarget{border-color:var(--green);background:#e3f6ea;transform:scale(1.06)}
.money-type-display{min-width:190px;padding:9px 14px;border:3px dashed var(--line);border-radius:14px;background:var(--surface-2);font-size:34px;font-weight:950;letter-spacing:2px;color:var(--green)}.money-keypad{width:100%;display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.money-keypad .tbtn{min-width:0;padding:0;font-size:20px} .money-type-display{min-width:190px;padding:9px 14px;border:3px dashed var(--line);border-radius:14px;background:var(--surface-2);font-size:34px;font-weight:950;letter-spacing:2px;color:var(--green)}.money-keypad{width:100%;display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.money-keypad .tbtn{min-width:0;padding:0;font-size:20px}
.money-feedback{min-height:24px;margin:0;text-align:center;font-weight:900}.money-feedback.ok{color:var(--green)}.money-feedback.bad{color:var(--red)}.money-actions{display:flex;justify-content:center;gap:8px;flex-wrap:wrap}.money-actions .tbtn:not(.ghost){background:var(--green);color:#fff} .money-feedback{min-height:24px;margin:0;text-align:center;font-weight:900}.money-feedback.ok{color:var(--green)}.money-feedback.bad{color:var(--red)}.money-actions{display:flex;justify-content:center;gap:8px;flex-wrap:wrap}.money-actions .tbtn:not(.ghost){background:var(--green);color:#fff}
@media(max-width:520px){.money-task-card{gap:9px;padding:10px}.money-level-control>span{display:none}.money-top{top:-10px;gap:5px}.money-shop-task{grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);gap:7px}.money-product,.money-paid{min-height:112px;padding:8px 6px;gap:4px}.money-product-icon{font-size:46px}.money-product .money-task-amount{font-size:32px}.money-product .money-task-words{font-size:16px}.money-paid>output{font-size:26px}.money-cash-tray{gap:6px;padding:9px}} @media(max-width:520px){.money-task-card{gap:9px;padding:10px}.money-level-control>span{display:none}.money-top{top:-10px;gap:5px}.money-shop-task{grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);gap:7px}.money-product,.money-paid{min-height:112px;padding:8px 6px;gap:4px}.money-product-icon{font-size:46px}.money-product-name{font-size:14px}.money-product .money-task-amount{font-size:32px}.money-product .money-task-words{font-size:16px}.money-paid>output{font-size:26px}.money-cash-tray{gap:6px;padding:9px}}
.wordtypes{display:flex;gap:9px;flex-wrap:wrap;justify-content:center}.wordtypes button{border:0;border-radius:10px;padding:10px 13px;font:inherit;font-weight:900;background:var(--surface-2)}.wordtypes button[data-type=noun]{background:#dce9ff;color:#174c9b}.wordtypes button[data-type=verb]{background:#dcf7e7;color:#176b3a}.wordtypes button[data-type=adjective]{background:#fff0cf;color:#855b00}.wordtypes p{flex-basis:100%;text-align:center;color:var(--muted);font-size:12px} .wordtypes{display:flex;gap:9px;flex-wrap:wrap;justify-content:center}.wordtypes button{border:0;border-radius:10px;padding:10px 13px;font:inherit;font-weight:900;background:var(--surface-2)}.wordtypes button[data-type=noun]{background:#dce9ff;color:#174c9b}.wordtypes button[data-type=verb]{background:#dcf7e7;color:#176b3a}.wordtypes button[data-type=adjective]{background:#fff0cf;color:#855b00}.wordtypes p{flex-basis:100%;text-align:center;color:var(--muted);font-size:12px}
.schedule{width:min(100%,450px);display:flex;flex-direction:column;gap:7px}.schedule>div{display:flex;align-items:center;gap:12px;padding:9px;background:var(--surface-2);border-radius:10px}.schedule time{font-weight:900;color:var(--blue)}.schedule span{flex:1;font-weight:800}.schedule .tbtn{height:30px;min-height:30px;padding:0 9px}.mood{display:flex;gap:8px;flex-wrap:wrap}.mood .tbtn{font-size:36px;height:auto;padding:12px}.mood span{display:block;font-size:14px}.poll{width:min(100%,440px);display:flex;flex-direction:column;gap:8px}.poll h3{text-align:center}.poll .tbtn{position:relative;overflow:hidden;text-align:left}.poll .tbtn i{position:absolute;inset:0 auto 0 0;background:var(--accent-soft);z-index:0}.poll .tbtn span{position:relative;float:right;font-weight:900}.poll .tbtn{isolation:isolate} .schedule{width:min(100%,450px);display:flex;flex-direction:column;gap:7px}.schedule>div{display:flex;align-items:center;gap:12px;padding:9px;background:var(--surface-2);border-radius:10px}.schedule time{font-weight:900;color:var(--blue)}.schedule span{flex:1;font-weight:800}.schedule .tbtn{height:30px;min-height:30px;padding:0 9px}.mood{display:flex;gap:8px;flex-wrap:wrap}.mood .tbtn{font-size:36px;height:auto;padding:12px}.mood span{display:block;font-size:14px}.poll{width:min(100%,440px);display:flex;flex-direction:column;gap:8px}.poll h3{text-align:center}.poll .tbtn{position:relative;overflow:hidden;text-align:left}.poll .tbtn i{position:absolute;inset:0 auto 0 0;background:var(--accent-soft);z-index:0}.poll .tbtn span{position:relative;float:right;font-weight:900}.poll .tbtn{isolation:isolate}
@media(max-width:600px){.gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.gcard{min-height:120px;padding:13px 8px}.gcard .gd{display:none}.widget .wdup,.widget .wpreview{display:none}.edu-stage{padding:10px}.place-grid b{font-size:9px}.place-grid output{padding:12px 2px}} @media(max-width:600px){.gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.gcard{min-height:120px;padding:13px 8px}.gcard .gd{display:none}.widget .wdup,.widget .wpreview{display:none}.edu-stage{padding:10px}.place-grid b{font-size:9px}.place-grid output{padding:12px 2px}}

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* 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. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.3.52-beta"; const VERSION = "0.3.54-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -121,6 +121,7 @@ const I18N = {
wg_money:"Geldrekenen", wg_money_d:"Oefen bedragen maken, geld tellen en wisselgeld op drie niveaus.", wg_money:"Geldrekenen", wg_money_d:"Oefen bedragen maken, geld tellen en wisselgeld op drie niveaus.",
moneyLearningStyle:"Opdracht tonen", moneyStyleAmount:"Als bedrag", moneyStyleWords:"In woorden", moneyStyleBoth:"Bedrag en woorden", moneyLearningStyle:"Opdracht tonen", moneyStyleAmount:"Als bedrag", moneyStyleWords:"In woorden", moneyStyleBoth:"Bedrag en woorden",
moneyGame:"Spel", moneyGameMake:"Bedrag maken", moneyGameCount:"Geld tellen", moneyGameChange:"Wisselgeld", moneyGame:"Spel", moneyGameMake:"Bedrag maken", moneyGameCount:"Geld tellen", moneyGameChange:"Wisselgeld",
moneyCategory:"Koopcategorie", moneyCategoryAll:"Alle producten", moneyCategoryFood:"Eten en drinken", moneyCategorySchool:"Schoolspullen", moneyCategoryPlay:"Spelen en sport",
moneyLevel:"Niveau", moneyLevel1Hint:"Niveau 1: hele euros tot € 9", moneyLevel2Hint:"Niveau 2: tot € 10 in stappen van 5 cent", moneyLevel3Hint:"Niveau 3: tot € 50 met alle centen, munten en biljetten", moneyLevel:"Niveau", moneyLevel1Hint:"Niveau 1: hele euros tot € 9", moneyLevel2Hint:"Niveau 2: tot € 10 in stappen van 5 cent", moneyLevel3Hint:"Niveau 3: tot € 50 met alle centen, munten en biljetten",
moneyScore:"Score: {score} · reeks: {streak}", moneyMakeTitle:"Maak het gevraagde bedrag", moneyCountTitle:"Hoeveel geld zie je?", moneyChangeTitle:"Bereken het wisselgeld", moneyScore:"Score: {score} · reeks: {streak}", moneyMakeTitle:"Maak het gevraagde bedrag", moneyCountTitle:"Hoeveel geld zie je?", moneyChangeTitle:"Bereken het wisselgeld",
moneyYourTask:"Jouw opdracht", moneyPrice:"Prijs", moneyPaid:"Betaald", moneyWallet:"Jouw geld", moneyWalletHint:"Sleep geld hierheen of tik op een munt of biljet. Sleep geplaatst geld naar buiten om het weg te halen.", moneyRemove:"Sleep naar buiten om weg te halen", moneyTray:"Munten en biljetten", moneyYourTask:"Jouw opdracht", moneyPrice:"Prijs", moneyPaid:"Betaald", moneyWallet:"Jouw geld", moneyWalletHint:"Sleep geld hierheen of tik op een munt of biljet. Sleep geplaatst geld naar buiten om het weg te halen.", moneyRemove:"Sleep naar buiten om weg te halen", moneyTray:"Munten en biljetten",
@ -328,6 +329,7 @@ const I18N = {
wg_money:"Money", wg_money_d:"Practise making amounts, counting money and change at three levels.", wg_money:"Money", wg_money_d:"Practise making amounts, counting money and change at three levels.",
moneyLearningStyle:"Show task as", moneyStyleAmount:"As an amount", moneyStyleWords:"In words", moneyStyleBoth:"Amount and words", moneyLearningStyle:"Show task as", moneyStyleAmount:"As an amount", moneyStyleWords:"In words", moneyStyleBoth:"Amount and words",
moneyGame:"Game", moneyGameMake:"Make an amount", moneyGameCount:"Count money", moneyGameChange:"Change", moneyGame:"Game", moneyGameMake:"Make an amount", moneyGameCount:"Count money", moneyGameChange:"Change",
moneyCategory:"Shopping category", moneyCategoryAll:"All products", moneyCategoryFood:"Food and drinks", moneyCategorySchool:"School supplies", moneyCategoryPlay:"Play and sports",
moneyLevel:"Level", moneyLevel1Hint:"Level 1: whole euros up to €9", moneyLevel2Hint:"Level 2: up to €10 in five-cent steps", moneyLevel3Hint:"Level 3: up to €50 with all cents, coins and notes", moneyLevel:"Level", moneyLevel1Hint:"Level 1: whole euros up to €9", moneyLevel2Hint:"Level 2: up to €10 in five-cent steps", moneyLevel3Hint:"Level 3: up to €50 with all cents, coins and notes",
moneyScore:"Score: {score} · streak: {streak}", moneyMakeTitle:"Make the requested amount", moneyCountTitle:"How much money do you see?", moneyChangeTitle:"Work out the change", moneyScore:"Score: {score} · streak: {streak}", moneyMakeTitle:"Make the requested amount", moneyCountTitle:"How much money do you see?", moneyChangeTitle:"Work out the change",
moneyYourTask:"Your task", moneyPrice:"Price", moneyPaid:"Paid", moneyWallet:"Your money", moneyWalletHint:"Drag money here or tap a coin or note. Drag placed money outside to remove it.", moneyRemove:"Drag outside to remove", moneyTray:"Coins and notes", moneyYourTask:"Your task", moneyPrice:"Price", moneyPaid:"Paid", moneyWallet:"Your money", moneyWalletHint:"Drag money here or tap a coin or note. Drag placed money outside to remove it.", moneyRemove:"Drag outside to remove", moneyTray:"Coins and notes",

View file

@ -1,3 +1,642 @@
const MONEY_LEVELS=Object.freeze({
"1": {
"min": 100,
"max": 900,
"step": 100,
"denominations": [
200,
100
],
"payments": [
500,
1000
],
"fixedIndices": [
0,
2,
3
],
"emptyDigits": [
0,
null,
0,
0
]
},
"2": {
"min": 50,
"max": 995,
"step": 5,
"denominations": [
500,
200,
100,
50,
20,
10,
5
],
"payments": [
500,
1000,
2000
],
"fixedIndices": [
0
],
"emptyDigits": [
0,
null,
null,
null
]
},
"3": {
"min": 1,
"max": 4999,
"step": 1,
"denominations": [
2000,
1000,
500,
200,
100,
50,
20,
10,
5,
2,
1
],
"payments": [
100,
200,
500,
1000,
2000,
5000
],
"fixedIndices": [],
"emptyDigits": [
null,
null,
null,
null
]
}
});
const MONEY_PRODUCTS=Object.freeze([
{
"id": "apple",
"icon": "🍎",
"category": "food",
"nl": "Appel",
"en": "Apple",
"prices": {
"1": [
100
],
"2": [
75,
100
],
"3": [
69,
79,
99
]
}
},
{
"id": "bananas",
"icon": "🍌",
"category": "food",
"nl": "Tros bananen",
"en": "Bunch of bananas",
"prices": {
"1": [
100,
200
],
"2": [
100,
150
],
"3": [
99,
145,
149
]
}
},
{
"id": "juice",
"icon": "🧃",
"category": "food",
"nl": "Pakje sap",
"en": "Juice box",
"prices": {
"1": [
200,
300
],
"2": [
200,
250
],
"3": [
199,
229,
259
]
}
},
{
"id": "sandwich",
"icon": "🥪",
"category": "food",
"nl": "Broodje",
"en": "Sandwich",
"prices": {
"1": [
400,
500
],
"2": [
350,
450,
550
],
"3": [
349,
449,
549
]
}
},
{
"id": "yogurt",
"icon": "🥣",
"category": "food",
"nl": "Yoghurt",
"en": "Yogurt",
"prices": {
"1": [
100,
200
],
"2": [
100,
150,
200
],
"3": [
99,
159,
189
]
}
},
{
"id": "cookie",
"icon": "🍪",
"category": "food",
"nl": "Koek",
"en": "Cookie",
"prices": {
"1": [
100,
200
],
"2": [
100,
150,
250
],
"3": [
99,
149,
249
]
}
},
{
"id": "water",
"icon": "💧",
"category": "food",
"nl": "Flesje water",
"en": "Bottle of water",
"prices": {
"1": [
100,
200
],
"2": [
100,
150,
200
],
"3": [
99,
149,
199
]
}
},
{
"id": "icecream",
"icon": "🍦",
"category": "food",
"nl": "IJsje",
"en": "Ice cream",
"prices": {
"1": [
200,
300
],
"2": [
200,
250,
350
],
"3": [
199,
249,
349
]
}
},
{
"id": "pencil",
"icon": "✏️",
"category": "school",
"nl": "Potlood",
"en": "Pencil",
"prices": {
"1": [
100
],
"2": [
50,
100
],
"3": [
49,
79,
99
]
}
},
{
"id": "eraser",
"icon": "🧽",
"category": "school",
"nl": "Gum",
"en": "Eraser",
"prices": {
"1": [
100
],
"2": [
50,
100
],
"3": [
49,
69,
99
]
}
},
{
"id": "ruler",
"icon": "📏",
"category": "school",
"nl": "Liniaal",
"en": "Ruler",
"prices": {
"1": [
100,
200
],
"2": [
100,
150,
200
],
"3": [
99,
149,
199
]
}
},
{
"id": "notebook",
"icon": "📓",
"category": "school",
"nl": "Schrift",
"en": "Notebook",
"prices": {
"1": [
300,
400
],
"2": [
250,
350,
450
],
"3": [
249,
349,
449
]
}
},
{
"id": "book",
"icon": "📘",
"category": "school",
"nl": "Boek",
"en": "Book",
"prices": {
"1": [
700,
900
],
"2": [
695,
895,
995
],
"3": [
699,
899,
1299
]
}
},
{
"id": "markers",
"icon": "🖍️",
"category": "school",
"nl": "Stiften",
"en": "Markers",
"prices": {
"1": [
500,
700
],
"2": [
495,
695,
895
],
"3": [
499,
699,
899
]
}
},
{
"id": "scissors",
"icon": "✂️",
"category": "school",
"nl": "Schaar",
"en": "Scissors",
"prices": {
"1": [
300,
400
],
"2": [
250,
350,
450
],
"3": [
249,
349,
449
]
}
},
{
"id": "backpack",
"icon": "🎒",
"category": "school",
"nl": "Rugzak",
"en": "Backpack",
"prices": {
"3": [
2499,
2999,
3499
]
}
},
{
"id": "calculator",
"icon": "🧮",
"category": "school",
"nl": "Rekenmachine",
"en": "Calculator",
"prices": {
"1": [
900
],
"2": [
895,
995
],
"3": [
899,
1299,
1799
]
}
},
{
"id": "ball",
"icon": "⚽",
"category": "play",
"nl": "Bal",
"en": "Ball",
"prices": {
"1": [
600,
900
],
"2": [
600,
900
],
"3": [
599,
899,
999
]
}
},
{
"id": "teddy",
"icon": "🧸",
"category": "play",
"nl": "Knuffel",
"en": "Teddy bear",
"prices": {
"1": [
900
],
"2": [
995
],
"3": [
999,
1499,
1899
]
}
},
{
"id": "puzzle",
"icon": "🧩",
"category": "play",
"nl": "Puzzel",
"en": "Puzzle",
"prices": {
"1": [
800,
900
],
"2": [
795,
995
],
"3": [
799,
999,
1499
]
}
},
{
"id": "rope",
"icon": "➰",
"category": "play",
"nl": "Springtouw",
"en": "Skipping rope",
"prices": {
"1": [
300,
600
],
"2": [
300,
600
],
"3": [
299,
599,
699
]
}
},
{
"id": "kite",
"icon": "🪁",
"category": "play",
"nl": "Vlieger",
"en": "Kite",
"prices": {
"1": [
700,
900
],
"2": [
695,
895
],
"3": [
699,
999,
1299
]
}
},
{
"id": "blocks",
"icon": "🧱",
"category": "play",
"nl": "Bouwblokken",
"en": "Building blocks",
"prices": {
"1": [
900
],
"2": [
895,
995
],
"3": [
999,
1499,
1999
]
}
},
{
"id": "boardgame",
"icon": "🎲",
"category": "play",
"nl": "Spel",
"en": "Game",
"prices": {
"1": [
900
],
"2": [
995
],
"3": [
999,
1499,
2499
]
}
},
{
"id": "skateboard",
"icon": "🛹",
"category": "play",
"nl": "Skateboard",
"en": "Skateboard",
"prices": {
"3": [
2999,
3999,
4999
]
}
}
]);
const moneyPriceFitsLevel=(price,level)=>{const rule=MONEY_LEVELS[level];return Number.isInteger(price)&&price>=rule.min&&price<=rule.max&&(price-rule.min)%rule.step===0};
function createMoneyTask(level,game,category='all',previousCents=null,previousProductId=null,random=Math.random){
const rule=MONEY_LEVELS[level],rnd=max=>Math.floor(random()*max),randomAmount=()=>rule.min+rnd(Math.floor((rule.max-rule.min)/rule.step)+1)*rule.step;
let targetCents,productId=previousProductId;
if(game==='change'){
const pool=MONEY_PRODUCTS.filter(item=>(category==='all'||item.category===category)&&(item.prices[level]||[]).some(price=>moneyPriceFitsLevel(price,level)));
const pick=()=>{const product=pool[rnd(pool.length)],prices=product.prices[level].filter(price=>moneyPriceFitsLevel(price,level));return{product,price:prices[rnd(prices.length)]}};
let offer=pick();for(let attempt=0;attempt<8&&offer.product.id===previousProductId&&offer.price===previousCents;attempt++)offer=pick();
targetCents=offer.price;productId=offer.product.id;
}else{targetCents=randomAmount();if(targetCents===previousCents)targetCents=randomAmount()}
const paidCents=rule.payments.find(value=>value>targetCents)||rule.payments[rule.payments.length-1];
return{targetCents,paidCents,productId};
}
/* Acht compacte educatieve en klaswidgets. Alle invoer blijft tekstContent-safe. */ /* Acht compacte educatieve en klaswidgets. Alle invoer blijft tekstContent-safe. */
function learningShell(root, settings, stage){ function learningShell(root, settings, stage){
root.innerHTML='<div class="edu"><details class="edu-settings"><summary>⚙ <span></span></summary><div class="edu-controls"></div></details><div class="edu-stage"></div></div>'; root.innerHTML='<div class="edu"><details class="edu-settings"><summary>⚙ <span></span></summary><div class="edu-controls"></div></details><div class="edu-stage"></div></div>';
@ -46,22 +685,21 @@ function mountPlacevalue(root,init){
const draw=()=>{S.number=Math.max(0,Math.min(999999,+input.value||0));const digits=String(S.number).padStart(6,'0').split('');stage.className='place-grid';stage.innerHTML='';['HD','TD','D','H','T','E'].forEach((x,i)=>{const d=document.createElement('div');const b=document.createElement('b');b.textContent=x;const o=document.createElement('output');o.textContent=digits[i];d.append(b,o);stage.append(d)})};input.oninput=draw;learningShell(root,[field(T('placeNumber'),input),btn('🎲',()=>{input.value=Math.floor(Math.random()*999999);draw()})],stage);draw();return{getState:()=>({...S})}; const draw=()=>{S.number=Math.max(0,Math.min(999999,+input.value||0));const digits=String(S.number).padStart(6,'0').split('');stage.className='place-grid';stage.innerHTML='';['HD','TD','D','H','T','E'].forEach((x,i)=>{const d=document.createElement('div');const b=document.createElement('b');b.textContent=x;const o=document.createElement('output');o.textContent=digits[i];d.append(b,o);stage.append(d)})};input.oninput=draw;learningShell(root,[field(T('placeNumber'),input),btn('🎲',()=>{input.value=Math.floor(Math.random()*999999);draw()})],stage);draw();return{getState:()=>({...S})};
} }
function mountMoney(root,init){ function mountMoney(root,init){
const legacy=init||{},S=Object.assign({level:1,style:'both',game:'make',amode:'blocks',targetCents:375,paidCents:500,selected:[],given:[],input:'',answerDigits:[0,null,0,0],score:0,streak:0,solved:false,feedback:'',product:'🧃'},legacy); const legacy=init||{},S=Object.assign({level:1,style:'both',game:'make',category:'all',productId:'juice',amode:'blocks',targetCents:375,paidCents:500,selected:[],given:[],input:'',answerDigits:[0,null,0,0],score:0,streak:0,solved:false,feedback:''},legacy);
if(legacy.targetCents==null&&Number.isFinite(legacy.target))S.targetCents=Math.round(legacy.target*100); if(legacy.targetCents==null&&Number.isFinite(legacy.target))S.targetCents=Math.round(legacy.target*100);
S.level=Math.max(1,Math.min(3,+S.level||1));S.style=['amount','words','both'].includes(S.style)?S.style:'both';S.game=['make','count','change'].includes(S.game)?S.game:'make';S.amode=S.amode==='type'?'type':'blocks';S.selected=Array.isArray(S.selected)?S.selected.map(Number).filter(Number.isFinite):[];S.given=Array.isArray(S.given)?S.given.map(Number).filter(Number.isFinite):[]; S.level=Math.max(1,Math.min(3,+S.level||1));S.style=['amount','words','both'].includes(S.style)?S.style:'both';S.game=['make','count','change'].includes(S.game)?S.game:'make';S.category=['all','food','school','play'].includes(S.category)?S.category:'all';S.amode=S.amode==='type'?'type':'blocks';S.selected=Array.isArray(S.selected)?S.selected.map(Number).filter(Number.isFinite):[];S.given=Array.isArray(S.given)?S.given.map(Number).filter(Number.isFinite):[];
const style=document.createElement('select'),game=document.createElement('select'),stage=document.createElement('div'),add=(el,items)=>items.forEach(([value,key])=>el.add(new Option(T(key),value))); const style=document.createElement('select'),game=document.createElement('select'),category=document.createElement('select'),stage=document.createElement('div'),add=(el,items)=>items.forEach(([value,key])=>el.add(new Option(T(key),value)));
add(style,[['amount','moneyStyleAmount'],['words','moneyStyleWords'],['both','moneyStyleBoth']]);add(game,[['make','moneyGameMake'],['count','moneyGameCount'],['change','moneyGameChange']]);style.value=S.style;game.value=S.game; add(style,[['amount','moneyStyleAmount'],['words','moneyStyleWords'],['both','moneyStyleBoth']]);add(game,[['make','moneyGameMake'],['count','moneyGameCount'],['change','moneyGameChange']]);add(category,[['all','moneyCategoryAll'],['food','moneyCategoryFood'],['school','moneyCategorySchool'],['play','moneyCategoryPlay']]);style.value=S.style;game.value=S.game;category.value=S.category;
const styleSetting=field(T('moneyLearningStyle'),style),gameSetting=field(T('moneyGame'),game),syncSettings=()=>{styleSetting.hidden=S.game==='count'}; const styleSetting=field(T('moneyLearningStyle'),style),gameSetting=field(T('moneyGame'),game),categorySetting=field(T('moneyCategory'),category),syncSettings=()=>{styleSetting.hidden=S.game==='count';categorySetting.hidden=S.game!=='change'};
const rnd=max=>Math.floor(Math.random()*max),format=cents=>{const value=(Math.max(0,cents)/100).toFixed(2);return '€ '+(LANG==='nl'?value.replace('.',','):value)}; const format=cents=>{const value=(Math.max(0,cents)/100).toFixed(2);return '€ '+(LANG==='nl'?value.replace('.',','):value)};
const nlSmall=['nul','een','twee','drie','vier','vijf','zes','zeven','acht','negen','tien','elf','twaalf','dertien','veertien','vijftien','zestien','zeventien','achttien','negentien'],nlTens=['','','twintig','dertig','veertig','vijftig','zestig','zeventig','tachtig','negentig']; const nlSmall=['nul','een','twee','drie','vier','vijf','zes','zeven','acht','negen','tien','elf','twaalf','dertien','veertien','vijftien','zestien','zeventien','achttien','negentien'],nlTens=['','','twintig','dertig','veertig','vijftig','zestig','zeventig','tachtig','negentig'];
const enSmall=['zero','one','two','three','four','five','six','seven','eight','nine','ten','eleven','twelve','thirteen','fourteen','fifteen','sixteen','seventeen','eighteen','nineteen'],enTens=['','','twenty','thirty','forty','fifty','sixty','seventy','eighty','ninety']; const enSmall=['zero','one','two','three','four','five','six','seven','eight','nine','ten','eleven','twelve','thirteen','fourteen','fifteen','sixteen','seventeen','eighteen','nineteen'],enTens=['','','twenty','thirty','forty','fifty','sixty','seventy','eighty','ninety'];
const numberWords=value=>{const n=Math.max(0,Math.min(99,value));if(LANG==='nl'){if(n<20)return nlSmall[n];const ones=n%10;return ones?nlSmall[ones]+nlTens[Math.floor(n/10)]:nlTens[Math.floor(n/10)]}if(n<20)return enSmall[n];const ones=n%10;return enTens[Math.floor(n/10)]+(ones?'-'+enSmall[ones]:'')}; const numberWords=value=>{const n=Math.max(0,Math.min(99,value));if(LANG==='nl'){if(n<20)return nlSmall[n];const ones=n%10;return ones?nlSmall[ones]+nlTens[Math.floor(n/10)]:nlTens[Math.floor(n/10)]}if(n<20)return enSmall[n];const ones=n%10;return enTens[Math.floor(n/10)]+(ones?'-'+enSmall[ones]:'')};
const amountWords=cents=>{const euros=Math.floor(cents/100),rest=cents%100;if(LANG==='nl')return numberWords(euros)+' euro'+(rest?' en '+numberWords(rest)+' cent':'');return numberWords(euros)+' euro'+(euros===1?'':'s')+(rest?' and '+numberWords(rest)+' cent'+(rest===1?'':'s'):'')}; const amountWords=cents=>{const euros=Math.floor(cents/100),rest=cents%100;if(LANG==='nl')return numberWords(euros)+' euro'+(rest?' en '+numberWords(rest)+' cent':'');return numberWords(euros)+' euro'+(euros===1?'':'s')+(rest?' and '+numberWords(rest)+' cent'+(rest===1?'':'s'):'')};
const denominations=()=>S.level===1?[200,100]:S.level===2?[500,200,100,50,20,10,5]:[2000,1000,500,200,100,50,20,10,5,2,1]; const levelRule=()=>MONEY_LEVELS[S.level],denominations=()=>levelRule().denominations;
const randomTarget=()=>S.level===1?(1+rnd(9))*100:S.level===2?(10+rnd(190))*5:1+rnd(4999);
const paymentFor=price=>{const options=S.level===1?[500,1000]:S.level===2?[500,1000,2000]:[100,200,500,1000,2000,5000];return options.find(value=>value>price)||Math.ceil((price+1)/5000)*5000};
const breakdown=amount=>{const result=[];let rest=amount;denominations().forEach(value=>{while(rest>=value&&result.length<24){result.push(value);rest-=value}});return result.sort(()=>Math.random()-.5)}; const breakdown=amount=>{const result=[];let rest=amount;denominations().forEach(value=>{while(rest>=value&&result.length<24){result.push(value);rest-=value}});return result.sort(()=>Math.random()-.5)};
const fixedIndices=()=>S.level===1?[0,2,3]:S.level===2?[0]:[],emptyDigits=()=>S.level===1?[0,null,0,0]:S.level===2?[0,null,null,null]:[null,null,null,null]; const fixedIndices=()=>levelRule().fixedIndices,emptyDigits=()=>[...levelRule().emptyDigits],currentProduct=()=>MONEY_PRODUCTS.find(item=>item.id===S.productId)||MONEY_PRODUCTS[0];
const legacyProduct=MONEY_PRODUCTS.find(item=>item.icon===legacy.product);if(!MONEY_PRODUCTS.some(item=>item.id===S.productId))S.productId=legacyProduct?.id||'juice';delete S.product;
const piece=(value,interactive=false)=>{const el=document.createElement(interactive?'button':'span');if(interactive)el.type='button';const type=value>=500?'note':'coin',extension=value>=500?'jpg':'png';el.className='money-piece '+type;el.dataset.value=value;const image=document.createElement('img'),fallback=document.createElement('span');image.className='money-piece-image';image.src=`/img/money/${type}-${value}.${extension}`;image.alt='';image.draggable=false;image.decoding='async';image.setAttribute('aria-hidden','true');image.onerror=()=>el.classList.add('image-missing');fallback.className='money-piece-fallback';fallback.textContent=value>=100?'€'+value/100:value+'c';el.append(image,fallback);el.setAttribute('aria-label',format(value));return el}; const piece=(value,interactive=false)=>{const el=document.createElement(interactive?'button':'span');if(interactive)el.type='button';const type=value>=500?'note':'coin',extension=value>=500?'jpg':'png';el.className='money-piece '+type;el.dataset.value=value;const image=document.createElement('img'),fallback=document.createElement('span');image.className='money-piece-image';image.src=`/img/money/${type}-${value}.${extension}`;image.alt='';image.draggable=false;image.decoding='async';image.setAttribute('aria-hidden','true');image.onerror=()=>el.classList.add('image-missing');fallback.className='money-piece-fallback';fallback.textContent=value>=100?'€'+value/100:value+'c';el.append(image,fallback);el.setAttribute('aria-label',format(value));return el};
const pile=(values,interactive=false,wallet=null)=>{const wrap=document.createElement('div');wrap.className='money-pile'+(interactive?' interactive':' readonly');values.forEach((value,index)=>{const el=piece(value,interactive);if(interactive){el.title=T('moneyRemove');el.onpointerdown=e=>{if(S.solved||(e.pointerType==='mouse'&&e.button!==0))return;e.preventDefault();const startX=e.clientX,startY=e.clientY,clone=el.cloneNode(true);clone.id='dragClone';clone.classList.add('money-drag-clone','from-wallet');document.body.append(clone);const moveTo=ev=>{const zoom=VZ();clone.style.left=ev.clientX/zoom+'px';clone.style.top=ev.clientY/zoom+'px'},over=ev=>{const r=wallet.getBoundingClientRect();return ev.clientX>=r.left&&ev.clientX<=r.right&&ev.clientY>=r.top&&ev.clientY<=r.bottom},hasMoved=ev=>Math.hypot(ev.clientX-startX,ev.clientY-startY)>=6;moveTo(e);const move=ev=>{moveTo(ev);wallet.classList.toggle('money-remove-target',hasMoved(ev)&&!over(ev))},clean=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);document.removeEventListener('pointercancel',cancel);wallet.classList.remove('money-remove-target');clone.remove()},remove=()=>{S.selected.splice(index,1);S.feedback='';draw()},up=ev=>{const moved=hasMoved(ev),outside=!over(ev);clean();if(!moved||(moved&&outside))remove()},cancel=()=>clean();document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);document.addEventListener('pointercancel',cancel)};el.onkeydown=e=>{if((e.key==='Enter'||e.key===' '||e.key==='Delete'||e.key==='Backspace')&&!S.solved){e.preventDefault();S.selected.splice(index,1);S.feedback='';draw()}}}wrap.append(el)});return wrap}; const pile=(values,interactive=false,wallet=null)=>{const wrap=document.createElement('div');wrap.className='money-pile'+(interactive?' interactive':' readonly');values.forEach((value,index)=>{const el=piece(value,interactive);if(interactive){el.title=T('moneyRemove');el.onpointerdown=e=>{if(S.solved||(e.pointerType==='mouse'&&e.button!==0))return;e.preventDefault();const startX=e.clientX,startY=e.clientY,clone=el.cloneNode(true);clone.id='dragClone';clone.classList.add('money-drag-clone','from-wallet');document.body.append(clone);const moveTo=ev=>{const zoom=VZ();clone.style.left=ev.clientX/zoom+'px';clone.style.top=ev.clientY/zoom+'px'},over=ev=>{const r=wallet.getBoundingClientRect();return ev.clientX>=r.left&&ev.clientX<=r.right&&ev.clientY>=r.top&&ev.clientY<=r.bottom},hasMoved=ev=>Math.hypot(ev.clientX-startX,ev.clientY-startY)>=6;moveTo(e);const move=ev=>{moveTo(ev);wallet.classList.toggle('money-remove-target',hasMoved(ev)&&!over(ev))},clean=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);document.removeEventListener('pointercancel',cancel);wallet.classList.remove('money-remove-target');clone.remove()},remove=()=>{S.selected.splice(index,1);S.feedback='';draw()},up=ev=>{const moved=hasMoved(ev),outside=!over(ev);clean();if(!moved||(moved&&outside))remove()},cancel=()=>clean();document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);document.addEventListener('pointercancel',cancel)};el.onkeydown=e=>{if((e.key==='Enter'||e.key===' '||e.key==='Delete'||e.key==='Backspace')&&!S.solved){e.preventDefault();S.selected.splice(index,1);S.feedback='';draw()}}}wrap.append(el)});return wrap};
const amountDisplay=(cents,compact=false)=>{const wrap=document.createElement('div');wrap.className='money-assignment'+(compact?' compact':'');if(['amount','both'].includes(S.style)){const output=document.createElement('output');output.className='money-task-amount';output.textContent=format(cents);wrap.append(output)}if(['words','both'].includes(S.style)){const words=document.createElement('p');words.className='money-task-words';words.textContent=amountWords(cents);wrap.append(words)}return wrap}; const amountDisplay=(cents,compact=false)=>{const wrap=document.createElement('div');wrap.className='money-assignment'+(compact?' compact':'');if(['amount','both'].includes(S.style)){const output=document.createElement('output');output.className='money-task-amount';output.textContent=format(cents);wrap.append(output)}if(['words','both'].includes(S.style)){const words=document.createElement('p');words.className='money-task-words';words.textContent=amountWords(cents);wrap.append(words)}return wrap};
@ -78,15 +716,15 @@ function mountMoney(root,init){
const check=()=>{const value=S.game==='make'?S.selected.reduce((sum,item)=>sum+item,0):answerValue();if(value===null){S.feedback='incomplete';sndBad();draw();return}setFeedback(value===expected())}; const check=()=>{const value=S.game==='make'?S.selected.reduce((sum,item)=>sum+item,0):answerValue();if(value===null){S.feedback='incomplete';sndBad();draw();return}setFeedback(value===expected())};
const feedbackText=()=>{if(S.feedback==='good')return T('moneyCorrect');if(S.feedback==='incomplete')return T('moneyIncomplete');if(S.feedback==='bad'&&S.game==='make'){const total=S.selected.reduce((sum,item)=>sum+item,0),difference=S.targetCents-total;return T(difference>0?'moneyNeed':'moneyTooMuch').replace('{amount}',format(Math.abs(difference)))}return T('moneyTryAgain')}; const feedbackText=()=>{if(S.feedback==='good')return T('moneyCorrect');if(S.feedback==='incomplete')return T('moneyIncomplete');if(S.feedback==='bad'&&S.game==='make'){const total=S.selected.reduce((sum,item)=>sum+item,0),difference=S.targetCents-total;return T(difference>0?'moneyNeed':'moneyTooMuch').replace('{amount}',format(Math.abs(difference)))}return T('moneyTryAgain')};
const resetAnswer=()=>{S.selected=[];S.input='';S.answerDigits=emptyDigits();S.feedback='';S.solved=false;draw()}; const resetAnswer=()=>{S.selected=[];S.input='';S.answerDigits=emptyDigits();S.feedback='';S.solved=false;draw()};
const newTask=()=>{let target=randomTarget();if(target===S.targetCents)target=randomTarget();S.targetCents=target;S.paidCents=paymentFor(target);S.selected=[];S.given=breakdown(target);S.input='';S.answerDigits=emptyDigits();S.solved=false;S.feedback='';S.product=['🍎','🧃','📘','🧸','⚽','✏️'][rnd(6)];draw()}; const newTask=()=>{const task=createMoneyTask(S.level,S.game,S.category,S.targetCents,S.productId);S.targetCents=task.targetCents;S.paidCents=task.paidCents;S.productId=task.productId;S.selected=[];S.given=breakdown(task.targetCents);S.input='';S.answerDigits=emptyDigits();S.solved=false;S.feedback='';draw()};
const chooseLevel=value=>{S.level=value;S.score=0;S.streak=0;newTask()},toggleMode=()=>{S.amode=S.amode==='blocks'?'type':'blocks';S.input='';S.answerDigits=emptyDigits();S.feedback='';draw()}; const chooseLevel=value=>{S.level=value;S.score=0;S.streak=0;newTask()},toggleMode=()=>{S.amode=S.amode==='blocks'?'type':'blocks';S.input='';S.answerDigits=emptyDigits();S.feedback='';draw()};
const draw=()=>{S.style=style.value;S.game=game.value;stage.className='money-app';stage.innerHTML='';const top=document.createElement('div');top.className='money-top';const levelControl=document.createElement('div');levelControl.className='money-level-control';const levelLabel=document.createElement('span');levelLabel.textContent=T('moneyLevel');const levels=document.createElement('div');levels.className='lg-levels money-levels';[1,2,3].forEach(value=>{const button=document.createElement('button');button.type='button';button.textContent=T('lvl'+value);button.title=T('moneyLevel'+value+'Hint');button.classList.toggle('active',S.level===value);button.onclick=()=>chooseLevel(value);levels.append(button)});levelControl.append(levelLabel,levels);top.append(levelControl);if(S.game!=='make'){const mode=btn(S.amode==='blocks'?'🧱':'⌨️',toggleMode);mode.classList.add('money-mode');mode.title=T('amode');top.append(mode)}const score=document.createElement('span');score.className='money-score';score.textContent=T('moneyScore').replace('{score}',S.score).replace('{streak}',S.streak);top.append(score);stage.append(top); const draw=()=>{S.style=style.value;S.game=game.value;stage.className='money-app';stage.innerHTML='';const top=document.createElement('div');top.className='money-top';const levelControl=document.createElement('div');levelControl.className='money-level-control';const levelLabel=document.createElement('span');levelLabel.textContent=T('moneyLevel');const levels=document.createElement('div');levels.className='lg-levels money-levels';[1,2,3].forEach(value=>{const button=document.createElement('button');button.type='button';button.textContent=T('lvl'+value);button.title=T('moneyLevel'+value+'Hint');button.classList.toggle('active',S.level===value);button.onclick=()=>chooseLevel(value);levels.append(button)});levelControl.append(levelLabel,levels);top.append(levelControl);if(S.game!=='make'){const mode=btn(S.amode==='blocks'?'🧱':'⌨️',toggleMode);mode.classList.add('money-mode');mode.title=T('amode');top.append(mode)}const score=document.createElement('span');score.className='money-score';score.textContent=T('moneyScore').replace('{score}',S.score).replace('{streak}',S.streak);top.append(score);stage.append(top);
const card=document.createElement('section');card.className='money-task-card';const title=document.createElement('h3');title.textContent=T(S.game==='make'?'moneyMakeTitle':S.game==='count'?'moneyCountTitle':'moneyChangeTitle');card.append(title); const card=document.createElement('section');card.className='money-task-card';const title=document.createElement('h3'),levelHint=document.createElement('p');title.textContent=T(S.game==='make'?'moneyMakeTitle':S.game==='count'?'moneyCountTitle':'moneyChangeTitle');levelHint.className='money-level-hint';levelHint.textContent=T('moneyLevel'+S.level+'Hint');card.append(title,levelHint);
if(S.game==='make'){const target=document.createElement('div');target.className='money-target';const label=document.createElement('span');label.textContent=T('moneyYourTask');target.append(label,amountDisplay(S.targetCents,true));card.append(target);const wallet=document.createElement('div');wallet.className='money-wallet';const walletHead=document.createElement('div');const walletLabel=document.createElement('b');walletLabel.textContent=T('moneyWallet');const total=document.createElement('output');total.textContent=format(S.selected.reduce((sum,item)=>sum+item,0));walletHead.append(walletLabel,total);wallet.append(walletHead);if(S.selected.length)wallet.append(pile(S.selected,true,wallet));else{const hint=document.createElement('p');hint.textContent=T('moneyWalletHint');wallet.append(hint)}card.append(wallet,moneyTray(wallet))} if(S.game==='make'){const target=document.createElement('div');target.className='money-target';const label=document.createElement('span');label.textContent=T('moneyYourTask');target.append(label,amountDisplay(S.targetCents,true));card.append(target);const wallet=document.createElement('div');wallet.className='money-wallet';const walletHead=document.createElement('div');const walletLabel=document.createElement('b');walletLabel.textContent=T('moneyWallet');const total=document.createElement('output');total.textContent=format(S.selected.reduce((sum,item)=>sum+item,0));walletHead.append(walletLabel,total);wallet.append(walletHead);if(S.selected.length)wallet.append(pile(S.selected,true,wallet));else{const hint=document.createElement('p');hint.textContent=T('moneyWalletHint');wallet.append(hint)}card.append(wallet,moneyTray(wallet))}
if(S.game==='count'){const target=document.createElement('div');target.className='money-count-task';target.append(pile(S.given));card.append(target,S.amode==='blocks'?digitAnswer():typeAnswer())} if(S.game==='count'){const target=document.createElement('div');target.className='money-count-task';target.append(pile(S.given));card.append(target,S.amode==='blocks'?digitAnswer():typeAnswer())}
if(S.game==='change'){const shop=document.createElement('div');shop.className='money-shop-task';const product=document.createElement('div');product.className='money-product';const emoji=document.createElement('span');emoji.className='money-product-icon';emoji.textContent=S.product;const priceLabel=document.createElement('b');priceLabel.textContent=T('moneyPrice');product.append(emoji,priceLabel,amountDisplay(S.targetCents,true));const paid=document.createElement('div');paid.className='money-paid';const paidLabel=document.createElement('b');paidLabel.textContent=T('moneyPaid');const paidAmount=document.createElement('output');paidAmount.textContent=format(S.paidCents);paid.append(paidLabel,paidAmount,pile([S.paidCents]));shop.append(product,paid);card.append(shop,S.amode==='blocks'?digitAnswer():typeAnswer())} if(S.game==='change'){const item=currentProduct(),shop=document.createElement('div');shop.className='money-shop-task';const product=document.createElement('div');product.className='money-product';const emoji=document.createElement('span'),productName=document.createElement('strong');emoji.className='money-product-icon';emoji.textContent=item.icon;productName.className='money-product-name';productName.textContent=LANG==='nl'?item.nl:item.en;const priceLabel=document.createElement('b');priceLabel.textContent=T('moneyPrice');product.append(emoji,productName,priceLabel,amountDisplay(S.targetCents,true));const paid=document.createElement('div');paid.className='money-paid';const paidLabel=document.createElement('b');paidLabel.textContent=T('moneyPaid');const paidAmount=document.createElement('output');paidAmount.textContent=format(S.paidCents);paid.append(paidLabel,paidAmount,pile([S.paidCents]));shop.append(product,paid);card.append(shop,S.amode==='blocks'?digitAnswer():typeAnswer())}
const feedback=document.createElement('p');feedback.className='money-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');feedback.textContent=S.feedback?feedbackText():'';card.append(feedback);const actions=document.createElement('div');actions.className='money-actions';if(!S.solved)actions.append(btn(T('numberlineCheck'),check,''));if(!S.solved)actions.append(btn(T('moneyClear'),resetAnswer,'ghost'));actions.append(btn(T('moneyNextTask'),newTask,'ghost'));card.append(actions);stage.append(card)}; const feedback=document.createElement('p');feedback.className='money-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');feedback.textContent=S.feedback?feedbackText():'';card.append(feedback);const actions=document.createElement('div');actions.className='money-actions';if(!S.solved)actions.append(btn(T('numberlineCheck'),check,''));if(!S.solved)actions.append(btn(T('moneyClear'),resetAnswer,'ghost'));actions.append(btn(T('moneyNextTask'),newTask,'ghost'));card.append(actions);stage.append(card)};
style.onchange=draw;game.onchange=()=>{S.game=game.value;syncSettings();newTask()};learningShell(root,[styleSetting,gameSetting],stage);syncSettings();newTask();return{getState:()=>({...S,selected:[...S.selected],given:[...S.given],answerDigits:[...S.answerDigits]})}; style.onchange=draw;game.onchange=()=>{S.game=game.value;syncSettings();newTask()};category.onchange=()=>{S.category=category.value;newTask()};learningShell(root,[styleSetting,gameSetting,categorySetting],stage);syncSettings();newTask();return{getState:()=>({...S,selected:[...S.selected],given:[...S.given],answerDigits:[...S.answerDigits]})};
} }
function mountWordtypes(root,init){ function mountWordtypes(root,init){

View file

@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises'; import {readFile} from 'node:fs/promises';
import {runInNewContext} from 'node:vm';
const ids=['birthday','numberline','sentence']; const ids=['birthday','numberline','sentence'];
const learningIds=['fractions','clock','placevalue','money','wordtypes','schedule','mood','poll']; const learningIds=['fractions','clock','placevalue','money','wordtypes','schedule','mood','poll'];
@ -106,14 +107,18 @@ test('geldrekenen biedt drie niveaus, leerwijzen, spellen en directe bediening',
const [source,css,core,board]=await Promise.all([readFile('public/js/widgets/learning.js','utf8'),readFile('public/css/teach.css','utf8'),readFile('public/js/core.js','utf8'),readFile('public/js/board.js','utf8')]); const [source,css,core,board]=await Promise.all([readFile('public/js/widgets/learning.js','utf8'),readFile('public/css/teach.css','utf8'),readFile('public/js/core.js','utf8'),readFile('public/js/board.js','utf8')]);
const moneySource=source.slice(source.indexOf('function mountMoney'),source.indexOf('function mountWordtypes')); const moneySource=source.slice(source.indexOf('function mountMoney'),source.indexOf('function mountWordtypes'));
for(const value of ["['make','moneyGameMake']","['count','moneyGameCount']","['change','moneyGameChange']","['amount','moneyStyleAmount']","['words','moneyStyleWords']","['both','moneyStyleBoth']","S.amode==='blocks'?'🧱':'⌨️'"])assert.ok(moneySource.includes(value),value); for(const value of ["['make','moneyGameMake']","['count','moneyGameCount']","['change','moneyGameChange']","['amount','moneyStyleAmount']","['words','moneyStyleWords']","['both','moneyStyleBoth']","S.amode==='blocks'?'🧱':'⌨️'"])assert.ok(moneySource.includes(value),value);
assert.ok(moneySource.includes("S.level===1?(1+rnd(9))*100:S.level===2?(10+rnd(190))*5:1+rnd(4999)")); assert.ok(moneySource.includes("const levelRule=()=>MONEY_LEVELS[S.level]"));
assert.ok(moneySource.includes("S.level===1?[200,100]:S.level===2?[500,200,100,50,20,10,5]")); assert.ok(source.includes("function createMoneyTask(level,game,category='all'"));
for(const feature of ['money-wallet','money-cash-tray','money-answer-slots','money-digit-tray','money-keypad','dragClone','droptarget','getBoundingClientRect','amountWords','paymentFor','money-piece-image','money-product-icon'])assert.ok(moneySource.includes(feature),feature); assert.ok(moneySource.includes("denominations=()=>levelRule().denominations"));
assert.ok(moneySource.includes("learningShell(root,[styleSetting,gameSetting],stage)"),'alleen voorkeuren staan onder instellingen'); assert.ok(moneySource.includes("S.game==='change'"));
for(const feature of ['money-wallet','money-cash-tray','money-answer-slots','money-digit-tray','money-keypad','dragClone','droptarget','getBoundingClientRect','amountWords','money-piece-image','money-product-icon','money-product-name','money-level-hint'])assert.ok(moneySource.includes(feature),feature);
assert.ok(moneySource.includes("learningShell(root,[styleSetting,gameSetting,categorySetting],stage)"),'alleen voorkeuren staan onder instellingen');
assert.ok(moneySource.includes("style.onchange=draw")); assert.ok(moneySource.includes("style.onchange=draw"));
assert.ok(moneySource.includes("game.onchange=()=>{S.game=game.value;syncSettings();newTask()}")); assert.ok(moneySource.includes("game.onchange=()=>{S.game=game.value;syncSettings();newTask()}"));
assert.ok(moneySource.includes("category.onchange=()=>{S.category=category.value;newTask()}"));
assert.ok(moneySource.includes("categorySetting.hidden=S.game!=='change'"));
assert.doesNotMatch(moneySource,/learningShell\(root,\[[^\]]*(?:numberlineCheck|numberlineNew|moneyClear|moneyNextTask)/); assert.doesNotMatch(moneySource,/learningShell\(root,\[[^\]]*(?:numberlineCheck|numberlineNew|moneyClear|moneyNextTask)/);
for(const key of ['moneyLearningStyle','moneyStyleAmount','moneyStyleWords','moneyStyleBoth','moneyGame','moneyGameMake','moneyGameCount','moneyGameChange','moneyLevel','moneyLevel1Hint','moneyLevel2Hint','moneyLevel3Hint','moneyScore','moneyMakeTitle','moneyCountTitle','moneyChangeTitle','moneyWallet','moneyTray','moneyBuildHint','moneyTypeHint','moneyCorrect','moneyNextTask','moneyRemove'])assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key); for(const key of ['moneyLearningStyle','moneyStyleAmount','moneyStyleWords','moneyStyleBoth','moneyGame','moneyGameMake','moneyGameCount','moneyGameChange','moneyLevel','moneyLevel1Hint','moneyLevel2Hint','moneyLevel3Hint','moneyScore','moneyMakeTitle','moneyCountTitle','moneyChangeTitle','moneyWallet','moneyTray','moneyBuildHint','moneyTypeHint','moneyCorrect','moneyNextTask','moneyRemove','moneyCategory','moneyCategoryAll','moneyCategoryFood','moneyCategorySchool','moneyCategoryPlay'])assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
assert.match(css,/money-app\{[^}]*align-self:flex-start[^}]*justify-content:flex-start/); assert.match(css,/money-app\{[^}]*align-self:flex-start[^}]*justify-content:flex-start/);
assert.match(css,/money-wallet\.droptarget/); assert.match(css,/money-wallet\.droptarget/);
assert.match(css,/money-wallet\.money-remove-target/); assert.match(css,/money-wallet\.money-remove-target/);
@ -137,3 +142,48 @@ test('geldrekenen biedt drie niveaus, leerwijzen, spellen en directe bediening',
} }
assert.match(board,/id:"money"[^\n]*w:580, h:560, minW:380, minH:360/); assert.match(board,/id:"money"[^\n]*w:580, h:560, minW:380, minH:360/);
}); });
test('elk geldspel gebruikt geldige niveaugrenzen en realistische productcategorieën', async()=>{
const source=await readFile('public/js/widgets/learning.js','utf8'),context={};
runInNewContext(source+';globalThis.__moneyData={levels:MONEY_LEVELS,products:MONEY_PRODUCTS,createTask:createMoneyTask}',context);
const data=JSON.parse(JSON.stringify({levels:context.__moneyData.levels,products:context.__moneyData.products})),createTask=context.__moneyData.createTask,categories=['food','school','play'],games=['make','count','change'];
assert.equal(Object.keys(data.levels).length,3);
assert.ok(data.products.length>=24);
assert.equal(new Set(data.products.map(item=>item.id)).size,data.products.length);
for(const level of [1,2,3]){
const rule=data.levels[level];
assert.ok(rule.min<rule.max);
assert.ok(rule.payments.some(value=>value>rule.max),'niveau '+level+' heeft passend betaalgeld');
for(const category of categories){
const pool=data.products.filter(item=>item.category===category&&Array.isArray(item.prices[level])&&item.prices[level].length);
assert.ok(pool.length>=5,category+' heeft producten op niveau '+level);
}
for(const item of data.products){
for(const price of item.prices[level]||[]){
assert.ok(price>=rule.min&&price<=rule.max,item.id+' valt binnen niveau '+level);
assert.equal((price-rule.min)%rule.step,0,item.id+' volgt de stapgrootte van niveau '+level);
}
}
for(const game of games){
const taskCategories=game==='change'?categories:['all'];
for(const category of taskCategories){
for(let sample=0;sample<30;sample++){
const task=createTask(level,game,category,null,null);
assert.ok(task.targetCents>=rule.min&&task.targetCents<=rule.max,game+' niveau '+level);
assert.equal((task.targetCents-rule.min)%rule.step,0,game+' stapgrootte niveau '+level);
assert.ok(task.paidCents>task.targetCents,game+' betaalgeld niveau '+level);
if(game==='change'){
const product=data.products.find(item=>item.id===task.productId);
assert.equal(product.category,category);
assert.ok(product.prices[level].includes(task.targetCents));
}
}
}
}
}
for(const item of data.products){
assert.ok(item.icon&&item.nl&&item.en);
assert.ok(categories.includes(item.category));
}
});