1059 lines
34 KiB
JavaScript
1059 lines
34 KiB
JavaScript
const Levels = (function () {
|
|
const meta = {
|
|
1: {
|
|
title: "Zaaien & water geven",
|
|
instruction: "Plant vijf zaadjes en geef ze water. Kies je gereedschap en klik op de potten."
|
|
},
|
|
2: {
|
|
title: "Vuurvliegjes vangen",
|
|
instruction: "Tik op de gloeiende vuurvliegjes en vang ze alle acht in de jampot."
|
|
},
|
|
3: {
|
|
title: "Sterrenbeelden verbinden",
|
|
instruction: "Verbind de sterren op volgorde. Een verkeerde ster doet geen pijn."
|
|
},
|
|
4: {
|
|
title: "Taart versieren",
|
|
instruction: "Kies een topping en leg hem op het juiste silhouet. Als alle plekjes gevuld zijn, gaat het kaarsje aan."
|
|
},
|
|
5: {
|
|
title: "Boeket schikken",
|
|
instruction: "Kijk naar het voorbeeld en pluk de juiste bloemen. Kleur én soort moeten kloppen."
|
|
},
|
|
6: {
|
|
title: "Tuinmemory",
|
|
instruction: "Draai twee kaartjes om. Vind alle zes bloemenparen — een foutje doet geen pijn."
|
|
},
|
|
7: {
|
|
title: "Herfstblaadjes",
|
|
instruction: "Sleep de acht blaadjes naar de mand met je vinger of het trackpad."
|
|
}
|
|
};
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function sound(name) {
|
|
if (window.UI && typeof window.UI.play === "function") window.UI.play(name);
|
|
}
|
|
|
|
function wiggle(node) {
|
|
if (!node) return;
|
|
node.classList.remove("wrong");
|
|
void node.offsetWidth;
|
|
node.classList.add("wrong");
|
|
window.setTimeout(function () {
|
|
node.classList.remove("wrong");
|
|
}, 520);
|
|
}
|
|
|
|
function shuffle(items) {
|
|
const result = items.slice();
|
|
for (let i = result.length - 1; i > 0; i -= 1) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
const tmp = result[i];
|
|
result[i] = result[j];
|
|
result[j] = tmp;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function makeTimers() {
|
|
const timers = [];
|
|
return {
|
|
later: function (fn, ms) {
|
|
const id = window.setTimeout(fn, ms);
|
|
timers.push(id);
|
|
return id;
|
|
},
|
|
clear: function () {
|
|
timers.forEach(function (id) { window.clearTimeout(id); });
|
|
timers.length = 0;
|
|
}
|
|
};
|
|
}
|
|
|
|
const FLOWER_TYPES = {
|
|
tulp: { name: "tulp" },
|
|
roos: { name: "roos" },
|
|
madeliefje: { name: "madeliefje" }
|
|
};
|
|
|
|
const FLOWER_COLORS = {
|
|
roze: { name: "roze", hex: "#f1a7bc" },
|
|
geel: { name: "geel", hex: "#f2cf72" },
|
|
lavendel: { name: "lavendel", hex: "#bfaee6" },
|
|
wit: { name: "wit", hex: "#fffaf2" },
|
|
koraal: { name: "koraal", hex: "#f09a86" }
|
|
};
|
|
|
|
function flowerLabel(type, color) {
|
|
return FLOWER_COLORS[color].name + " " + FLOWER_TYPES[type].name;
|
|
}
|
|
|
|
function flowerSVG(type, color) {
|
|
const hex = FLOWER_COLORS[color].hex;
|
|
let petals = "";
|
|
|
|
if (type === "roos") {
|
|
for (let i = 0; i < 8; i += 1) {
|
|
petals += '<ellipse cx="24" cy="13.6" rx="6.3" ry="8.7" fill="' + hex +
|
|
'" transform="rotate(' + (i * 45) + ' 24 24)"/>';
|
|
}
|
|
petals += '<circle cx="24" cy="24" r="7.4" fill="' + hex + '" opacity=".9"/>';
|
|
} else if (type === "madeliefje") {
|
|
for (let i = 0; i < 7; i += 1) {
|
|
petals += '<ellipse cx="24" cy="13.8" rx="3.6" ry="8.6" fill="' + hex +
|
|
'" transform="rotate(' + (i * (360 / 7)).toFixed(2) + ' 24 24)"/>';
|
|
}
|
|
} else {
|
|
petals =
|
|
'<ellipse cx="17.5" cy="17" rx="7" ry="10" fill="' + hex + '" transform="rotate(-18 17.5 17)"/>' +
|
|
'<ellipse cx="24" cy="14" rx="7.4" ry="11" fill="' + hex + '"/>' +
|
|
'<ellipse cx="30.5" cy="17" rx="7" ry="10" fill="' + hex + '" transform="rotate(18 30.5 17)"/>';
|
|
}
|
|
|
|
return '<svg class="flower-svg" viewBox="0 0 48 48" aria-hidden="true" focusable="false">' +
|
|
petals +
|
|
'<circle cx="24" cy="24" r="4.7" fill="#f6d36f"/>' +
|
|
'<circle cx="24" cy="24" r="2.2" fill="#e3ae45" opacity=".8"/>' +
|
|
'</svg>';
|
|
}
|
|
|
|
|
|
function buildGarden(stage, ctx) {
|
|
stage.classList.add("stage-garden");
|
|
const timers = makeTimers();
|
|
|
|
const scene = el("div", "garden-scene");
|
|
const toolbar = el("div", "tool-row");
|
|
const label = el("span", "tool-label", "Gereedschap");
|
|
const seedBtn = el("button", "tool-btn");
|
|
seedBtn.type = "button";
|
|
seedBtn.innerHTML = "<span aria-hidden=\"true\">🌱</span><span>Zaadje</span>";
|
|
seedBtn.setAttribute("aria-pressed", "true");
|
|
const waterBtn = el("button", "tool-btn");
|
|
waterBtn.type = "button";
|
|
waterBtn.innerHTML = "<span aria-hidden=\"true\">💧</span><span>Gieter</span>";
|
|
waterBtn.setAttribute("aria-pressed", "false");
|
|
toolbar.append(label, seedBtn, waterBtn);
|
|
|
|
const bed = el("div", "pot-row");
|
|
const colors = [
|
|
["#f4a7b9", "#f9cfd8"],
|
|
["#f2ce7a", "#f8e6b4"],
|
|
["#bea9e6", "#dcd0f2"],
|
|
["#f29b8b", "#f8c9bf"],
|
|
["#fff7ef", "#f3e6d8"]
|
|
];
|
|
|
|
let tool = "seed";
|
|
let seeds = 0;
|
|
let watered = 0;
|
|
let done = false;
|
|
const pots = [];
|
|
|
|
function selectTool(next) {
|
|
tool = next;
|
|
seedBtn.classList.toggle("selected", tool === "seed");
|
|
waterBtn.classList.toggle("selected", tool === "water");
|
|
seedBtn.setAttribute("aria-pressed", tool === "seed" ? "true" : "false");
|
|
waterBtn.setAttribute("aria-pressed", tool === "water" ? "true" : "false");
|
|
}
|
|
|
|
function onPot(pot, index) {
|
|
if (done) return;
|
|
|
|
if (pot.watered) {
|
|
ctx.hint("Deze bloem bloeit al. Kijk eens hoe mooi.");
|
|
return;
|
|
}
|
|
|
|
if (tool === "seed") {
|
|
if (pot.seeded) {
|
|
ctx.hint("Hier ligt al een zaadje. Pak de gieter om water te geven.");
|
|
return;
|
|
}
|
|
pot.seeded = true;
|
|
pot.node.classList.add("seeded");
|
|
pot.node.setAttribute("aria-label", "Bloempot " + (index + 1) + ", zaadje geplant");
|
|
seeds += 1;
|
|
sound("seed");
|
|
ctx.hint("Zaadje " + seeds + " van 5 geplant.");
|
|
if (seeds === 5) {
|
|
selectTool("water");
|
|
ctx.hint("Alle zaadjes liggen klaar. Geef ze nu water met de gieter.");
|
|
sound("chime");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!pot.seeded) {
|
|
wiggle(pot.node);
|
|
ctx.hint("Eerst een zaadje planten. Pak het zaadje.");
|
|
return;
|
|
}
|
|
|
|
pot.watered = true;
|
|
pot.node.classList.add("watering");
|
|
pot.node.classList.add("watered");
|
|
pot.node.setAttribute("aria-label", "Bloempot " + (index + 1) + ", bloem groeit");
|
|
timers.later(function () { pot.node.classList.remove("watering"); }, 550);
|
|
timers.later(function () { pot.node.classList.add("bloomed"); }, 420);
|
|
watered += 1;
|
|
sound("water");
|
|
ctx.hint("Water gegeven! " + watered + " van 5 bloemen.");
|
|
|
|
if (watered === 5) {
|
|
done = true;
|
|
ctx.hint("Wat een mooie bloementuin! Alles staat in bloei.");
|
|
sound("complete");
|
|
timers.later(ctx.onComplete, 1200);
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const node = el("button", "pot");
|
|
node.type = "button";
|
|
node.setAttribute("aria-label", "Bloempot " + (i + 1));
|
|
node.style.setProperty("--bloom", colors[i][0]);
|
|
node.style.setProperty("--bloom-2", colors[i][1]);
|
|
node.innerHTML =
|
|
'<span class="pot-plant" aria-hidden="true">' +
|
|
'<span class="stem"></span>' +
|
|
'<span class="leaf leaf-l"></span>' +
|
|
'<span class="leaf leaf-r"></span>' +
|
|
'<span class="bloom"></span>' +
|
|
"</span>" +
|
|
'<span class="pot-body" aria-hidden="true"><span class="pot-rim"></span></span>';
|
|
|
|
const pot = { node: node, seeded: false, watered: false };
|
|
pots.push(pot);
|
|
node.addEventListener("click", function () { onPot(pot, i); });
|
|
bed.append(node);
|
|
}
|
|
|
|
seedBtn.addEventListener("click", function () {
|
|
selectTool("seed");
|
|
ctx.hint("Kies een pot en plant een zaadje.");
|
|
});
|
|
waterBtn.addEventListener("click", function () {
|
|
selectTool("water");
|
|
ctx.hint("Kies een pot met een zaadje en geef water.");
|
|
});
|
|
|
|
const sun = el("span", "garden-sun");
|
|
sun.setAttribute("aria-hidden", "true");
|
|
scene.append(sun, toolbar, bed);
|
|
stage.append(scene);
|
|
ctx.hint("Begin met een zaadje: klik op een pot.");
|
|
|
|
return {
|
|
destroy: function () {
|
|
timers.clear();
|
|
pots.length = 0;
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
function buildFireflies(stage, ctx) {
|
|
stage.classList.add("stage-fireflies");
|
|
const timers = makeTimers();
|
|
const scene = el("div", "firefly-scene");
|
|
|
|
for (let i = 0; i < 18; i += 1) {
|
|
const star = el("span", "tiny-star");
|
|
star.style.left = (4 + Math.random() * 92).toFixed(1) + "%";
|
|
star.style.top = (4 + Math.random() * 60).toFixed(1) + "%";
|
|
star.style.animationDelay = (Math.random() * 4).toFixed(2) + "s";
|
|
scene.append(star);
|
|
}
|
|
|
|
const counter = el("p", "scene-counter", "0 van 8 vuurvliegjes");
|
|
const jar = el("div", "jar");
|
|
jar.innerHTML =
|
|
'<span class="jar-lid" aria-hidden="true"></span>' +
|
|
'<span class="jar-glow" aria-hidden="true"></span>' +
|
|
'<span class="jar-dots" aria-hidden="true"></span>' +
|
|
'<span class="jar-label">0</span>';
|
|
const jarDots = jar.querySelector(".jar-dots");
|
|
const jarLabel = jar.querySelector(".jar-label");
|
|
scene.append(counter, jar);
|
|
|
|
const total = 8;
|
|
const flies = [];
|
|
let caught = 0;
|
|
let done = false;
|
|
let running = true;
|
|
let raf = 0;
|
|
const start = performance.now();
|
|
|
|
function frame(now) {
|
|
if (!running) return;
|
|
const t = (now - start) / 1000;
|
|
flies.forEach(function (fly) {
|
|
if (fly.caught) return;
|
|
const x = fly.x + Math.sin(t * fly.sx + fly.phase) * fly.ampX;
|
|
const y = fly.y + Math.cos(t * fly.sy + fly.phase) * fly.ampY;
|
|
fly.node.style.left = x.toFixed(2) + "%";
|
|
fly.node.style.top = y.toFixed(2) + "%";
|
|
});
|
|
raf = window.requestAnimationFrame(frame);
|
|
}
|
|
|
|
function catchFly(fly) {
|
|
if (fly.caught || done) return;
|
|
fly.caught = true;
|
|
caught += 1;
|
|
fly.node.classList.add("caught");
|
|
fly.node.disabled = true;
|
|
fly.node.style.left = "50%";
|
|
fly.node.style.top = "82%";
|
|
sound("catch");
|
|
counter.textContent = caught + " van " + total + " vuurvliegjes";
|
|
ctx.hint(caught === total ? "Alle vuurvliegjes zitten in de pot!" : "Gevangen! Nog " + (total - caught) + " te gaan.");
|
|
|
|
timers.later(function () {
|
|
fly.node.hidden = true;
|
|
const dot = el("span", "glow-dot");
|
|
dot.style.left = (16 + Math.random() * 68).toFixed(1) + "%";
|
|
dot.style.top = (16 + Math.random() * 62).toFixed(1) + "%";
|
|
dot.style.animationDelay = (Math.random() * 1.4).toFixed(2) + "s";
|
|
jarDots.append(dot);
|
|
jar.classList.add("has-light");
|
|
jarLabel.textContent = String(caught);
|
|
}, 430);
|
|
|
|
if (caught === total) {
|
|
done = true;
|
|
sound("complete");
|
|
timers.later(function () {
|
|
running = false;
|
|
window.cancelAnimationFrame(raf);
|
|
ctx.onComplete();
|
|
}, 1150);
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < total; i += 1) {
|
|
const node = el("button", "firefly");
|
|
node.type = "button";
|
|
node.setAttribute("aria-label", "Vuurvliegje " + (i + 1));
|
|
const fly = {
|
|
node: node,
|
|
x: 8 + Math.random() * 84,
|
|
y: 10 + Math.random() * 46,
|
|
phase: Math.random() * Math.PI * 2,
|
|
sx: 0.45 + Math.random() * 0.8,
|
|
sy: 0.4 + Math.random() * 0.7,
|
|
ampX: 2.5 + Math.random() * 4.5,
|
|
ampY: 1.5 + Math.random() * 3.5,
|
|
caught: false
|
|
};
|
|
node.style.left = fly.x.toFixed(1) + "%";
|
|
node.style.top = fly.y.toFixed(1) + "%";
|
|
node.addEventListener("click", function () { catchFly(fly); });
|
|
flies.push(fly);
|
|
scene.append(node);
|
|
}
|
|
|
|
stage.append(scene);
|
|
raf = window.requestAnimationFrame(frame);
|
|
ctx.hint("Tik op een vuurvliegje om het te vangen.");
|
|
|
|
return {
|
|
destroy: function () {
|
|
running = false;
|
|
window.cancelAnimationFrame(raf);
|
|
timers.clear();
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
function buildStars(stage, ctx) {
|
|
stage.classList.add("stage-stars");
|
|
const timers = makeTimers();
|
|
const scene = el("div", "starfield");
|
|
const svgNS = "http://www.w3.org/2000/svg";
|
|
|
|
for (let i = 0; i < 22; i += 1) {
|
|
const star = el("span", "tiny-star");
|
|
star.style.left = (3 + Math.random() * 94).toFixed(1) + "%";
|
|
star.style.top = (3 + Math.random() * 90).toFixed(1) + "%";
|
|
star.style.animationDelay = (Math.random() * 4).toFixed(2) + "s";
|
|
scene.append(star);
|
|
}
|
|
|
|
const svg = document.createElementNS(svgNS, "svg");
|
|
svg.setAttribute("class", "constellation-lines");
|
|
svg.setAttribute("viewBox", "0 0 100 100");
|
|
svg.setAttribute("preserveAspectRatio", "none");
|
|
svg.setAttribute("aria-hidden", "true");
|
|
scene.append(svg);
|
|
|
|
const points = [
|
|
{ x: 30, y: 22 },
|
|
{ x: 46, y: 15 },
|
|
{ x: 62, y: 22 },
|
|
{ x: 67, y: 45 },
|
|
{ x: 46, y: 55 },
|
|
{ x: 25, y: 45 },
|
|
{ x: 46, y: 75 }
|
|
];
|
|
const starNodes = [];
|
|
let next = 0;
|
|
let done = false;
|
|
|
|
function drawLine(a, b) {
|
|
const line = document.createElementNS(svgNS, "line");
|
|
line.setAttribute("x1", a.x);
|
|
line.setAttribute("y1", a.y);
|
|
line.setAttribute("x2", b.x);
|
|
line.setAttribute("y2", b.y);
|
|
line.setAttribute("class", "star-line");
|
|
svg.append(line);
|
|
}
|
|
|
|
function onStar(index) {
|
|
if (done) return;
|
|
|
|
if (index === next) {
|
|
starNodes[index].classList.add("connected");
|
|
starNodes[index].classList.remove("next");
|
|
starNodes[index].setAttribute("aria-label", "Ster " + (index + 1) + ", verbonden");
|
|
sound("star");
|
|
|
|
if (next > 0) drawLine(points[next - 1], points[next]);
|
|
next += 1;
|
|
|
|
if (next < points.length) {
|
|
starNodes[next].classList.add("next");
|
|
ctx.hint("Mooi. Verbind nu ster " + (next + 1) + ".");
|
|
} else {
|
|
done = true;
|
|
scene.classList.add("constellation-done");
|
|
ctx.hint("Een sterrenkat! Hij kijkt tevreden naar je.");
|
|
sound("complete");
|
|
timers.later(ctx.onComplete, 1200);
|
|
}
|
|
return;
|
|
}
|
|
|
|
wiggle(starNodes[index]);
|
|
sound("nope");
|
|
ctx.hint("Bijna! Verbind de sterren op volgorde. Nu is ster " + (next + 1) + " aan de beurt.");
|
|
}
|
|
|
|
points.forEach(function (point, index) {
|
|
const node = el("button", "star");
|
|
node.type = "button";
|
|
node.style.left = point.x + "%";
|
|
node.style.top = point.y + "%";
|
|
node.setAttribute("aria-label", "Ster " + (index + 1));
|
|
node.innerHTML = '<span class="star-num" aria-hidden="true">' + (index + 1) + "</span>";
|
|
node.addEventListener("click", function () { onStar(index); });
|
|
starNodes.push(node);
|
|
scene.append(node);
|
|
});
|
|
|
|
starNodes[0].classList.add("next");
|
|
stage.append(scene);
|
|
ctx.hint("Begin bij ster 1 en volg de nummers.");
|
|
|
|
return {
|
|
destroy: function () {
|
|
timers.clear();
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
function buildCake(stage, ctx) {
|
|
stage.classList.add("stage-cake");
|
|
const timers = makeTimers();
|
|
const scene = el("div", "cake-scene");
|
|
|
|
const cake = el("div", "cake");
|
|
const art = el("div", "cake-art");
|
|
art.setAttribute("aria-hidden", "true");
|
|
art.innerHTML =
|
|
'<span class="cake-plate"></span>' +
|
|
'<span class="cake-body">' +
|
|
'<span class="cake-frosting"></span>' +
|
|
'<span class="cake-drip drip-1"></span>' +
|
|
'<span class="cake-drip drip-2"></span>' +
|
|
'<span class="cake-drip drip-3"></span>' +
|
|
"</span>" +
|
|
'<span class="candle"><span class="candle-stick"></span><span class="flame"></span></span>';
|
|
|
|
const slotDefs = [
|
|
{ type: "aardbei", emoji: "🍓", left: 25, top: 30 },
|
|
{ type: "bloem", emoji: "🌼", left: 50, top: 25 },
|
|
{ type: "room", emoji: "🍦", left: 75, top: 30 },
|
|
{ type: "blad", emoji: "🍃", left: 30, top: 56 },
|
|
{ type: "ster", emoji: "⭐", left: 50, top: 60 },
|
|
{ type: "kers", emoji: "🍒", left: 70, top: 56 }
|
|
];
|
|
|
|
const slotRow = el("div", "cake-slots");
|
|
const slots = slotDefs.map(function (def, index) {
|
|
const node = el("button", "cake-slot");
|
|
node.type = "button";
|
|
node.dataset.slot = String(index);
|
|
node.style.left = def.left + "%";
|
|
node.style.top = def.top + "%";
|
|
node.setAttribute("aria-label", "Plek voor " + def.type);
|
|
node.innerHTML = '<span class="slot-ghost" aria-hidden="true">' + def.emoji + "</span>";
|
|
slotRow.append(node);
|
|
return { def: def, node: node, filled: false };
|
|
});
|
|
|
|
cake.append(art, slotRow);
|
|
|
|
const trayLabel = el("p", "tray-label", "Kies een topping");
|
|
const tray = el("div", "topping-tray");
|
|
const toppingDefs = [
|
|
{ type: "aardbei", emoji: "🍓", name: "Aardbei" },
|
|
{ type: "bloem", emoji: "🌼", name: "Bloemetje" },
|
|
{ type: "room", emoji: "🍦", name: "Slagroom" },
|
|
{ type: "blad", emoji: "🍃", name: "Blaadje" },
|
|
{ type: "ster", emoji: "⭐", name: "Sterretje" },
|
|
{ type: "kers", emoji: "🍒", name: "Kers" }
|
|
];
|
|
|
|
const toppings = {};
|
|
let selected = null;
|
|
let filledCount = 0;
|
|
let done = false;
|
|
|
|
function selectTopping(type) {
|
|
if (done || toppings[type].used) return;
|
|
selected = type;
|
|
Object.keys(toppings).forEach(function (key) {
|
|
toppings[key].node.classList.toggle("selected", key === type);
|
|
});
|
|
sound("click");
|
|
ctx.hint("Kies een plekje op de taart voor de " + toppings[type].name.toLowerCase() + ".");
|
|
}
|
|
|
|
function place(index) {
|
|
const slot = slots[index];
|
|
if (!slot || slot.filled || done) return;
|
|
|
|
if (!selected) {
|
|
ctx.hint("Kies eerst een topping uit het dienblad.");
|
|
return;
|
|
}
|
|
|
|
if (selected !== slot.def.type) {
|
|
wiggle(slot.node);
|
|
sound("nope");
|
|
ctx.hint("Deze topping past niet op dat plekje. Kijk naar het silhouet.");
|
|
return;
|
|
}
|
|
|
|
slot.filled = true;
|
|
slot.node.classList.add("filled");
|
|
slot.node.innerHTML = '<span class="slot-topping" aria-hidden="true">' + slot.def.emoji + "</span>";
|
|
slot.node.setAttribute("aria-label", slot.def.type + " geplaatst");
|
|
slot.node.disabled = true;
|
|
toppings[selected].used = true;
|
|
toppings[selected].node.classList.add("used");
|
|
toppings[selected].node.classList.remove("selected");
|
|
toppings[selected].node.disabled = true;
|
|
selected = null;
|
|
filledCount += 1;
|
|
sound("place");
|
|
|
|
if (filledCount === 6) {
|
|
done = true;
|
|
cake.classList.add("cake-done");
|
|
ctx.hint("De taart is klaar. Het kaarsje brandt — maak een wens.");
|
|
sound("complete");
|
|
timers.later(ctx.onComplete, 1300);
|
|
} else {
|
|
ctx.hint("Mooi! Nog " + (6 - filledCount) + " plekjes te vullen.");
|
|
}
|
|
}
|
|
|
|
toppingDefs.forEach(function (def) {
|
|
const node = el("button", "topping");
|
|
node.type = "button";
|
|
node.dataset.topping = def.type;
|
|
node.setAttribute("aria-label", def.name);
|
|
node.innerHTML = '<span class="topping-emoji" aria-hidden="true">' + def.emoji +
|
|
'</span><span class="topping-name">' + def.name + "</span>";
|
|
node.addEventListener("pointerdown", function (event) {
|
|
if (node.disabled) return;
|
|
selectTopping(def.type);
|
|
drag = { x: event.clientX, y: event.clientY };
|
|
});
|
|
toppings[def.type] = { node: node, name: def.name, used: false };
|
|
tray.append(node);
|
|
});
|
|
|
|
slots.forEach(function (slot, index) {
|
|
slot.node.addEventListener("click", function () { place(index); });
|
|
});
|
|
|
|
let drag = null;
|
|
function onPointerUp(event) {
|
|
if (!drag) return;
|
|
const moved = Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 8;
|
|
drag = null;
|
|
if (!moved) return;
|
|
const target = document.elementFromPoint(event.clientX, event.clientY);
|
|
const slotEl = target && target.closest ? target.closest(".cake-slot") : null;
|
|
if (slotEl) place(Number(slotEl.dataset.slot));
|
|
}
|
|
scene.addEventListener("pointerup", onPointerUp);
|
|
|
|
scene.append(cake, trayLabel, tray);
|
|
stage.append(scene);
|
|
ctx.hint("Kies een topping en tik op het bijbehorende silhouet.");
|
|
|
|
return {
|
|
destroy: function () {
|
|
timers.clear();
|
|
scene.removeEventListener("pointerup", onPointerUp);
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
function buildBouquet(stage, ctx) {
|
|
stage.classList.add("stage-bouquet");
|
|
const timers = makeTimers();
|
|
const scene = el("div", "bouquet-scene");
|
|
|
|
const pattern = [
|
|
{ type: "tulp", color: "roze" },
|
|
{ type: "roos", color: "geel" },
|
|
{ type: "madeliefje", color: "lavendel" },
|
|
{ type: "tulp", color: "wit" },
|
|
{ type: "roos", color: "koraal" }
|
|
];
|
|
|
|
const extras = [
|
|
{ type: "roos", color: "roze" },
|
|
{ type: "madeliefje", color: "geel" },
|
|
{ type: "tulp", color: "lavendel" },
|
|
{ type: "madeliefje", color: "koraal" }
|
|
];
|
|
|
|
function flowerKey(flower) {
|
|
return flower.type + "-" + flower.color;
|
|
}
|
|
|
|
const patternPanel = el("div", "pattern-panel");
|
|
patternPanel.innerHTML = '<p class="panel-label">Het voorbeeld</p>';
|
|
const patternRow = el("div", "pattern-row");
|
|
pattern.forEach(function (flower) {
|
|
const item = el("span", "pattern-flower");
|
|
item.setAttribute("role", "img");
|
|
item.setAttribute("aria-label", flowerLabel(flower.type, flower.color));
|
|
item.innerHTML = flowerSVG(flower.type, flower.color);
|
|
patternRow.append(item);
|
|
});
|
|
patternPanel.append(patternRow);
|
|
|
|
const vasePanel = el("div", "vase-panel");
|
|
const vaseLabel = el("p", "panel-label", "Jouw boeket");
|
|
const holder = el("div", "bouquet-holder");
|
|
const slotsRow = el("div", "vase-slots");
|
|
const slots = pattern.map(function (required, index) {
|
|
const node = el("button", "vase-slot");
|
|
node.type = "button";
|
|
node.dataset.slot = String(index);
|
|
node.setAttribute("aria-label", "Plek voor " + flowerLabel(required.type, required.color));
|
|
const ghost = el("span", "slot-flower slot-flower-ghost");
|
|
ghost.innerHTML = flowerSVG(required.type, required.color);
|
|
node.append(ghost);
|
|
slotsRow.append(node);
|
|
return { required: required, node: node, filled: false };
|
|
});
|
|
const vase = el("div", "vase");
|
|
vase.setAttribute("aria-hidden", "true");
|
|
vase.innerHTML = '<span class="vase-rim"></span><span class="vase-body"></span>';
|
|
holder.append(slotsRow, vase);
|
|
vasePanel.append(vaseLabel, holder);
|
|
|
|
const gardenPanel = el("div", "garden-panel");
|
|
gardenPanel.innerHTML = '<p class="panel-label">Pluk uit de tuin</p>';
|
|
const gardenGrid = el("div", "garden-grid");
|
|
const gardenFlowers = shuffle(pattern.concat(extras));
|
|
const gardenByKey = {};
|
|
const gardenButtons = [];
|
|
|
|
pattern.concat(extras).forEach(function (flower) {
|
|
gardenByKey[flowerKey(flower)] = flower;
|
|
});
|
|
|
|
let selectedKey = null;
|
|
let placed = 0;
|
|
let done = false;
|
|
|
|
function selectFlower(key, node) {
|
|
if (done || node.disabled) return;
|
|
selectedKey = key;
|
|
gardenButtons.forEach(function (button) {
|
|
button.classList.toggle("selected", button.dataset.flower === key);
|
|
});
|
|
sound("click");
|
|
ctx.hint("Mooi, een " + flowerLabel(gardenByKey[key].type, gardenByKey[key].color) + ". Kies een plekje in de vaas.");
|
|
}
|
|
|
|
function place(index) {
|
|
const slot = slots[index];
|
|
if (!slot || slot.filled || done) return;
|
|
|
|
if (!selectedKey) {
|
|
ctx.hint("Kies eerst een bloem uit de tuin.");
|
|
return;
|
|
}
|
|
|
|
const flower = gardenByKey[selectedKey];
|
|
if (flower.type !== slot.required.type || flower.color !== slot.required.color) {
|
|
wiggle(slot.node);
|
|
sound("nope");
|
|
ctx.hint("Deze bloem lijkt niet op dat plekje. Kijk naar kleur én soort.");
|
|
return;
|
|
}
|
|
|
|
slot.filled = true;
|
|
slot.node.classList.add("filled");
|
|
slot.node.innerHTML = '<span class="slot-flower">' + flowerSVG(flower.type, flower.color) + "</span>";
|
|
slot.node.setAttribute("aria-label", flowerLabel(flower.type, flower.color) + " geplaatst");
|
|
slot.node.disabled = true;
|
|
|
|
const button = gardenButtons.find(function (item) {
|
|
return item.dataset.flower === selectedKey;
|
|
});
|
|
if (button) {
|
|
button.disabled = true;
|
|
button.classList.add("used");
|
|
button.classList.remove("selected");
|
|
}
|
|
|
|
selectedKey = null;
|
|
placed += 1;
|
|
sound("place");
|
|
|
|
if (placed === 5) {
|
|
done = true;
|
|
scene.classList.add("bouquet-done");
|
|
ctx.hint("Een prachtig boeket — precies zoals het voorbeeld.");
|
|
sound("complete");
|
|
timers.later(ctx.onComplete, 1300);
|
|
} else {
|
|
ctx.hint("Prachtig. Nog " + (5 - placed) + " bloemen te plukken.");
|
|
}
|
|
}
|
|
|
|
gardenFlowers.forEach(function (flower) {
|
|
const key = flowerKey(flower);
|
|
const node = el("button", "garden-flower");
|
|
node.type = "button";
|
|
node.dataset.flower = key;
|
|
node.setAttribute("aria-label", flowerLabel(flower.type, flower.color));
|
|
node.innerHTML = flowerSVG(flower.type, flower.color);
|
|
node.addEventListener("click", function () { selectFlower(key, node); });
|
|
gardenButtons.push(node);
|
|
gardenGrid.append(node);
|
|
});
|
|
|
|
slots.forEach(function (slot, index) {
|
|
slot.node.addEventListener("click", function () { place(index); });
|
|
});
|
|
|
|
gardenPanel.append(gardenGrid);
|
|
scene.append(patternPanel, vasePanel, gardenPanel);
|
|
stage.append(scene);
|
|
ctx.hint("Kies een bloem die ook in het voorbeeld staat.");
|
|
|
|
return {
|
|
destroy: function () {
|
|
timers.clear();
|
|
}
|
|
};
|
|
}
|
|
|
|
function buildMemory(stage, ctx) {
|
|
stage.classList.add("stage-memory");
|
|
const timers = makeTimers();
|
|
const scene = el("div", "memory-scene");
|
|
const counter = el("p", "scene-counter memory-counter", "0 van 6 paren");
|
|
const grid = el("div", "memory-grid");
|
|
|
|
const pairs = [
|
|
{ type: "tulp", color: "roze" },
|
|
{ type: "roos", color: "geel" },
|
|
{ type: "madeliefje", color: "lavendel" },
|
|
{ type: "tulp", color: "wit" },
|
|
{ type: "roos", color: "koraal" },
|
|
{ type: "madeliefje", color: "roze" }
|
|
];
|
|
|
|
const deck = shuffle(pairs.concat(pairs));
|
|
const cards = [];
|
|
let first = null;
|
|
let locked = false;
|
|
let matched = 0;
|
|
let done = false;
|
|
|
|
function flip(card) {
|
|
if (done || locked || card.matched || card === first) return;
|
|
|
|
card.node.classList.add("flipped");
|
|
card.node.setAttribute("aria-label", flowerLabel(card.flower.type, card.flower.color) + ", open");
|
|
sound("click");
|
|
|
|
if (!first) {
|
|
first = card;
|
|
return;
|
|
}
|
|
|
|
const second = card;
|
|
|
|
if (first.key === second.key) {
|
|
first.matched = true;
|
|
second.matched = true;
|
|
first.node.classList.add("matched");
|
|
second.node.classList.add("matched");
|
|
first.node.disabled = true;
|
|
second.node.disabled = true;
|
|
first.node.setAttribute("aria-label", flowerLabel(first.flower.type, first.flower.color) + ", gevonden");
|
|
second.node.setAttribute("aria-label", flowerLabel(second.flower.type, second.flower.color) + ", gevonden");
|
|
matched += 1;
|
|
counter.textContent = matched + " van 6 paren";
|
|
sound("place");
|
|
first = null;
|
|
ctx.hint(matched === 6 ? "Alle paren gevonden. Wat een lieve tuin." : "Een paar! Nog " + (6 - matched) + " te gaan.");
|
|
|
|
if (matched === 6) {
|
|
done = true;
|
|
sound("complete");
|
|
timers.later(ctx.onComplete, 1150);
|
|
}
|
|
return;
|
|
}
|
|
|
|
locked = true;
|
|
sound("nope");
|
|
ctx.hint("Bijna — deze twee horen niet bij elkaar.");
|
|
|
|
const a = first;
|
|
const b = second;
|
|
timers.later(function () {
|
|
[a, b].forEach(function (card) {
|
|
if (!card) return;
|
|
card.node.classList.remove("flipped");
|
|
card.node.setAttribute("aria-label", "Omgedraaide kaart");
|
|
});
|
|
first = null;
|
|
locked = false;
|
|
ctx.hint("Probeer het nog eens rustig.");
|
|
}, 950);
|
|
}
|
|
|
|
deck.forEach(function (flower, index) {
|
|
const node = el("button", "memory-card");
|
|
node.type = "button";
|
|
node.setAttribute("aria-label", "Omgedraaide kaart");
|
|
node.innerHTML =
|
|
'<span class="memory-inner">' +
|
|
'<span class="memory-face memory-back" aria-hidden="true">🌿</span>' +
|
|
'<span class="memory-face memory-front" aria-hidden="true">' + flowerSVG(flower.type, flower.color) + "</span>" +
|
|
"</span>";
|
|
|
|
const card = {
|
|
node: node,
|
|
flower: flower,
|
|
key: flower.type + "-" + flower.color,
|
|
matched: false
|
|
};
|
|
node.dataset.key = card.key;
|
|
node.addEventListener("click", function () { flip(card); });
|
|
cards.push(card);
|
|
grid.append(node);
|
|
});
|
|
|
|
scene.append(counter, grid);
|
|
stage.append(scene);
|
|
ctx.hint("Draai twee kaartjes om en zoek dezelfde bloemen.");
|
|
|
|
return {
|
|
destroy: function () {
|
|
timers.clear();
|
|
}
|
|
};
|
|
}
|
|
|
|
function buildLeaves(stage, ctx) {
|
|
stage.classList.add("stage-leaves");
|
|
const timers = makeTimers();
|
|
const scene = el("div", "leaves-scene");
|
|
const counter = el("p", "scene-counter leaves-counter", "0 van 8 blaadjes");
|
|
const basket = el("div", "leaf-basket");
|
|
basket.innerHTML =
|
|
'<span class="basket-emoji" aria-hidden="true">🧺</span>' +
|
|
'<span class="basket-label">De mand</span>';
|
|
scene.append(counter, basket);
|
|
|
|
const spots = [
|
|
[12, 32], [30, 22], [50, 32], [70, 24],
|
|
[18, 60], [40, 52], [62, 62], [83, 54]
|
|
];
|
|
const emojis = ["🍂", "🍁", "🍃", "🍂", "🍁", "🍃", "🍂", "🍁"];
|
|
const leaves = [];
|
|
let collected = 0;
|
|
let done = false;
|
|
let dragging = null;
|
|
|
|
function collect(leaf) {
|
|
if (leaf.collected || done) return;
|
|
|
|
leaf.collected = true;
|
|
collected += 1;
|
|
leaf.node.classList.add("collected");
|
|
leaf.node.disabled = true;
|
|
|
|
const sceneRect = scene.getBoundingClientRect();
|
|
const basketRect = basket.getBoundingClientRect();
|
|
const basketX = ((basketRect.left + basketRect.width / 2) - sceneRect.left) / sceneRect.width * 100;
|
|
const basketY = ((basketRect.top + basketRect.height / 2) - sceneRect.top) / sceneRect.height * 100;
|
|
leaf.node.style.left = basketX + "%";
|
|
leaf.node.style.top = basketY + "%";
|
|
leaf.node.style.transform = "translate(-50%, -50%) scale(0.4) rotate(20deg)";
|
|
leaf.node.style.opacity = "0";
|
|
|
|
counter.textContent = collected + " van 8 blaadjes";
|
|
basket.classList.add("basket-full");
|
|
timers.later(function () { basket.classList.remove("basket-full"); }, 400);
|
|
sound("place");
|
|
ctx.hint(collected === 8 ? "De mand zit vol herfstblaadjes!" : "Mooi, nog " + (8 - collected) + " blaadjes.");
|
|
|
|
if (collected === 8) {
|
|
done = true;
|
|
sound("complete");
|
|
timers.later(ctx.onComplete, 1150);
|
|
}
|
|
}
|
|
|
|
function startDrag(event, leaf) {
|
|
if (done || leaf.collected) return;
|
|
event.preventDefault();
|
|
dragging = leaf;
|
|
leaf.node.classList.add("dragging");
|
|
if (leaf.node.setPointerCapture) {
|
|
leaf.node.setPointerCapture(event.pointerId);
|
|
}
|
|
}
|
|
|
|
function moveDrag(event, leaf) {
|
|
if (dragging !== leaf || leaf.collected) return;
|
|
const rect = scene.getBoundingClientRect();
|
|
const x = ((event.clientX - rect.left) / rect.width) * 100;
|
|
const y = ((event.clientY - rect.top) / rect.height) * 100;
|
|
leaf.node.style.left = Math.max(4, Math.min(96, x)) + "%";
|
|
leaf.node.style.top = Math.max(6, Math.min(94, y)) + "%";
|
|
}
|
|
|
|
function endDrag(event, leaf) {
|
|
if (dragging !== leaf) return;
|
|
dragging = null;
|
|
leaf.node.classList.remove("dragging");
|
|
if (leaf.collected) return;
|
|
|
|
const basketRect = basket.getBoundingClientRect();
|
|
const inBasket = event.clientX >= basketRect.left &&
|
|
event.clientX <= basketRect.right &&
|
|
event.clientY >= basketRect.top &&
|
|
event.clientY <= basketRect.bottom;
|
|
|
|
if (inBasket) {
|
|
collect(leaf);
|
|
} else {
|
|
ctx.hint("Sleep het blaadje in de mand.");
|
|
}
|
|
}
|
|
|
|
spots.forEach(function (spot, index) {
|
|
const node = el("button", "leaf");
|
|
node.type = "button";
|
|
node.style.left = spot[0] + "%";
|
|
node.style.top = spot[1] + "%";
|
|
node.style.setProperty("--rot", (Math.random() * 50 - 25).toFixed(0) + "deg");
|
|
node.setAttribute("aria-label", "Herfstblad " + (index + 1));
|
|
node.innerHTML = '<span aria-hidden="true">' + emojis[index] + "</span>";
|
|
|
|
const leaf = { node: node, collected: false };
|
|
node.addEventListener("pointerdown", function (event) { startDrag(event, leaf); });
|
|
node.addEventListener("pointermove", function (event) { moveDrag(event, leaf); });
|
|
node.addEventListener("pointerup", function (event) { endDrag(event, leaf); });
|
|
node.addEventListener("pointercancel", function (event) { endDrag(event, leaf); });
|
|
node.addEventListener("keydown", function (event) {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
collect(leaf);
|
|
}
|
|
});
|
|
|
|
leaves.push(leaf);
|
|
scene.append(node);
|
|
});
|
|
|
|
stage.append(scene);
|
|
ctx.hint("Sleep de blaadjes één voor één naar de mand.");
|
|
|
|
return {
|
|
destroy: function () {
|
|
timers.clear();
|
|
}
|
|
};
|
|
}
|
|
|
|
const builders = {
|
|
1: buildGarden,
|
|
2: buildFireflies,
|
|
3: buildStars,
|
|
4: buildCake,
|
|
5: buildBouquet,
|
|
6: buildMemory,
|
|
7: buildLeaves
|
|
};
|
|
|
|
function mount(id, stage, ctx) {
|
|
stage.className = "stage";
|
|
stage.innerHTML = "";
|
|
const builder = builders[id];
|
|
if (!builder) {
|
|
stage.textContent = "Dit level kon niet worden gevonden.";
|
|
return { destroy: function () {} };
|
|
}
|
|
const api = builder(stage, ctx) || {};
|
|
return {
|
|
destroy: function () {
|
|
try {
|
|
if (typeof api.destroy === "function") api.destroy();
|
|
} finally {
|
|
stage.innerHTML = "";
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
return {
|
|
mount: mount,
|
|
meta: meta,
|
|
count: Object.keys(meta).length,
|
|
flowerSVG: flowerSVG
|
|
};
|
|
})();
|