const UI = (function () {
const $ = function (id) { return document.getElementById(id); };
const screens = {
welcome: $("screen-welcome"),
map: $("screen-map"),
level: $("screen-level"),
finale: $("screen-finale")
};
const topbar = $("topbar");
const homeBtn = $("home-btn");
const progressLabel = $("progress-label");
const progressFill = $("progress-fill");
const muteBtn = $("mute-btn");
const revealOverlay = $("reveal-overlay");
const confirmOverlay = $("confirm-overlay");
const toastNode = $("toast");
const LEVEL_ICONS = { 1: "🌱", 2: "✨", 3: "⭐", 4: "🍰", 5: "💐", 6: "🃏", 7: "🍂" };
const SOUNDS = {
click: [{ f: 520, d: 0.08, g: 0.025 }],
seed: [{ f: 392, d: 0.1 }, { f: 523, d: 0.14, at: 0.08 }],
water: [{ f: 660, d: 0.12 }, { f: 880, d: 0.18, at: 0.1 }],
catch: [{ f: 784, d: 0.1 }, { f: 1046, d: 0.2, at: 0.08 }],
star: [{ f: 880, d: 0.1 }, { f: 1174, d: 0.2, at: 0.09 }],
place: [{ f: 523, d: 0.08 }, { f: 659, d: 0.13, at: 0.07 }],
nope: [{ f: 330, d: 0.12, g: 0.022 }],
chime: [{ f: 659, d: 0.12 }, { f: 988, d: 0.22, at: 0.1 }],
complete: [
{ f: 523, d: 0.16 },
{ f: 659, d: 0.16, at: 0.12 },
{ f: 784, d: 0.16, at: 0.24 },
{ f: 1046, d: 0.34, at: 0.36 }
],
reveal: [
{ f: 659, d: 0.16 },
{ f: 784, d: 0.16, at: 0.11 },
{ f: 988, d: 0.2, at: 0.22 },
{ f: 1318, d: 0.38, at: 0.34 }
]
};
let state = { unlocked: 1, completed: [], revealed: [] };
let currentScreen = "welcome";
let muted = Storage.loadMuted();
let audioContext = null;
let toastTimer = 0;
let revealCallback = null;
let revealSteamCallback = null;
let revealLastFocus = null;
let confirmCallback = null;
let confirmLastFocus = null;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (character) {
return {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
}[character];
});
}
function ensureAudio() {
if (!window.AudioContext && !window.webkitAudioContext) return null;
if (!audioContext) {
const Ctor = window.AudioContext || window.webkitAudioContext;
audioContext = new Ctor();
}
if (audioContext.state === "suspended") {
audioContext.resume().catch(function () {});
}
return audioContext;
}
function play(name) {
if (muted) return;
const context = ensureAudio();
if (!context) return;
const parts = SOUNDS[name] || SOUNDS.click;
parts.forEach(function (part) {
const start = context.currentTime + (part.at || 0);
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(part.f, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(part.g || 0.035, start + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, start + part.d);
oscillator.connect(gain);
gain.connect(context.destination);
oscillator.start(start);
oscillator.stop(start + part.d + 0.06);
});
}
function updateMuteButton() {
muteBtn.textContent = muted ? "Geluid uit" : "Geluid aan";
muteBtn.setAttribute("aria-pressed", muted ? "true" : "false");
muteBtn.setAttribute(
"aria-label",
muted ? "Geluid staat uit. Zet geluid aan." : "Geluid staat aan. Zet geluid uit."
);
}
function toggleMute() {
muted = !muted;
Storage.saveMuted(muted);
updateMuteButton();
if (!muted) play("chime");
}
function updateProgress() {
const completed = state.completed.length;
const total = totalLevels();
progressLabel.textContent = completed + " / " + total + " cadeaus";
progressFill.style.width = (completed / total) * 100 + "%";
}
function totalLevels() {
return Levels.count || (GAME_CONFIG.games ? GAME_CONFIG.games.length : 5);
}
function setState(nextState) {
state = nextState;
updateProgress();
const startBtn = $("start-btn");
if (startBtn) {
startBtn.textContent = state.completed.length > 0 ? "Verder in de tuin" : "Begin in de tuin";
}
}
function showScreen(name) {
Object.keys(screens).forEach(function (key) {
screens[key].hidden = key !== name;
});
currentScreen = name;
topbar.hidden = name === "welcome";
homeBtn.hidden = name !== "level";
updateProgress();
window.scrollTo(0, 0);
}
function setHint(message) {
const node = $("level-hint");
node.textContent = message || "";
}
function showLevel(id) {
const info = Levels.meta[id];
$("level-eyebrow").textContent = "Tuintaak " + id + " van " + totalLevels();
$("level-title").textContent = info.title;
$("level-instruction").textContent = info.instruction;
setHint("");
showScreen("level");
}
function getCoverUrl(game) {
if (!game) return "";
if (game.image) return game.image;
if (game.appId) {
return "https://cdn.cloudflare.steamstatic.com/steam/apps/" + game.appId + "/header.jpg";
}
return "";
}
function getActivationUrl(key) {
return "https://store.steampowered.com/account/registerkey?key=" + encodeURIComponent(key);
}
function copyKey() {
const input = $("reveal-key-value");
const value = input.value.trim();
if (!value) return;
const button = $("reveal-key-copy");
const done = function () {
button.textContent = "Gekopieerd";
toast("Steam-key gekopieerd.");
window.setTimeout(function () { button.textContent = "Kopieer"; }, 1800);
};
const fallback = function () {
input.focus();
input.select();
let succeeded = false;
try {
succeeded = document.execCommand("copy");
} catch (error) {
succeeded = false;
}
if (succeeded) {
done();
} else {
button.textContent = "Selecteer";
toast("De key is geselecteerd. Kopieer hem met Ctrl+C.");
}
};
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
navigator.clipboard.writeText(value).then(done).catch(fallback);
} else {
fallback();
}
}
function coverArt(game, className) {
const wrap = el("span", className);
wrap.style.setProperty("--accent", (game && game.accent) || "#d9c2e8");
wrap.append(el("span", "cover-fallback", (game && game.title) || "Cadeau"));
const url = getCoverUrl(game);
if (url) {
const img = document.createElement("img");
img.alt = "";
img.loading = "lazy";
img.src = url;
img.addEventListener("load", function () { wrap.classList.add("has-image"); });
img.addEventListener("error", function () { img.remove(); });
wrap.append(img);
}
return wrap;
}
function renderMap() {
const path = $("garden-path");
path.innerHTML = "";
const total = totalLevels();
for (let level = 1; level <= total; level += 1) {
const completed = state.completed.indexOf(level) !== -1;
const revealed = state.revealed.indexOf(level - 1) !== -1;
const unlocked = level <= state.unlocked || completed;
const isCurrent = !completed && level === state.unlocked;
const node = el("button", "map-node");
node.type = "button";
node.disabled = !unlocked;
node.classList.toggle("completed", completed);
node.classList.toggle("current", isCurrent);
node.classList.toggle("locked", !unlocked);
let sub = "Nog even";
if (completed && revealed) sub = "Cadeau geopend";
else if (completed) sub = "Cadeau wacht";
else if (isCurrent) sub = "Speel nu";
node.innerHTML =
'' +
'' + LEVEL_ICONS[level] + "" +
(revealed ? '🎁' : "") +
(!unlocked ? '🔒' : "") +
"" +
'' +
'Level ' + level + "" +
'' + sub + "" +
"";
node.setAttribute(
"aria-label",
"Level " + level + ", " + Levels.meta[level].title + ", " + sub + (unlocked ? "" : ", nog vergrendeld")
);
node.addEventListener("click", function () { App.startLevel(level); });
path.append(node);
}
renderShelf();
$("finale-btn").hidden = state.completed.length !== total || state.revealed.length !== total;
updateProgress();
}
function renderShelf() {
const grid = $("gift-shelf");
grid.innerHTML = "";
const games = GAME_CONFIG.games || [];
const total = totalLevels();
for (let index = 0; index < total; index += 1) {
const game = games[index];
const revealed = state.revealed.indexOf(index) !== -1 && Boolean(game);
if (!revealed) {
const card = el("div", "shelf-card locked");
card.innerHTML =
'🎁' +
'Cadeau ' + (index + 1) + "" +
'Nog verborgen';
card.setAttribute("aria-label", "Cadeau " + (index + 1) + ", nog verborgen");
grid.append(card);
continue;
}
const card = el("button", "shelf-card");
card.type = "button";
card.append(coverArt(game, "shelf-art"));
card.append(el("span", "shelf-title", game.title));
card.setAttribute("aria-label", "Bekijk cadeau: " + game.title);
card.addEventListener("click", function () {
showReveal(index, null, { review: true });
});
grid.append(card);
}
}
function showReveal(index, onContinue, options) {
const opts = options || {};
const review = Boolean(opts.review);
const game = (GAME_CONFIG.games && GAME_CONFIG.games[index]) || {
title: "Een geheim cadeau",
note: "Dit cadeau is nog niet ingevuld in de configuratie.",
accent: "#d9c2e8"
};
revealLastFocus = document.activeElement;
$("reveal-eyebrow").textContent = review ? "Jouw cadeau" : "Cadeau ontgrendeld";
$("reveal-title").textContent = game.title;
$("reveal-note").textContent = game.note || "Speciaal voor jou uitgekozen.";
const key = typeof game.key === "string" ? game.key.trim() : "";
const keyBox = $("reveal-key");
const keyValue = $("reveal-key-value");
keyBox.hidden = !key;
keyValue.value = key;
$("reveal-key-copy").textContent = "Kopieer";
const art = $("reveal-art");
art.innerHTML = "";
art.classList.remove("has-image");
art.style.setProperty("--accent", game.accent || "#d9c2e8");
art.append(el("span", "cover-fallback", game.title));
const coverUrl = getCoverUrl(game);
if (coverUrl) {
const img = document.createElement("img");
img.className = "cover-img";
img.alt = "";
img.src = coverUrl;
img.addEventListener("load", function () { art.classList.add("has-image"); });
img.addEventListener("error", function () { img.remove(); });
art.append(img);
}
const steamLink = $("reveal-steam");
const browserLink = $("reveal-browser");
if (key) {
steamLink.hidden = false;
steamLink.href = getActivationUrl(key);
steamLink.target = "_blank";
steamLink.textContent = "Activeer op Steam";
const shopUrl = game.url || (game.appId
? "https://store.steampowered.com/app/" + game.appId + "/"
: "");
if (shopUrl) {
browserLink.hidden = false;
browserLink.href = shopUrl;
browserLink.textContent = "of bekijk het spel in de winkel";
} else {
browserLink.hidden = true;
}
} else if (game.appId) {
steamLink.hidden = false;
steamLink.href = "steam://store/" + game.appId;
steamLink.target = "_self";
steamLink.textContent = "Open in Steam";
browserLink.hidden = false;
browserLink.href = "https://store.steampowered.com/app/" + game.appId + "/";
browserLink.textContent = "of bekijk in de browser";
} else if (game.url) {
steamLink.hidden = false;
steamLink.href = game.url;
steamLink.target = "_blank";
steamLink.textContent = review ? "Bekijk het spel" : "Open het spel";
browserLink.hidden = true;
} else {
steamLink.hidden = true;
browserLink.hidden = true;
}
const continueBtn = $("reveal-continue");
continueBtn.textContent = review ? "Sluiten" : (index === totalLevels() - 1 ? "Naar de finale" : "Verder");
revealCallback = !review && typeof onContinue === "function" ? onContinue : null;
revealSteamCallback = !review && typeof opts.onSteamOpen === "function" ? opts.onSteamOpen : null;
revealOverlay.hidden = false;
document.body.classList.add("overlay-open");
play(review ? "click" : "reveal");
continueBtn.focus();
}
function closeReveal(triggerCallback) {
if (revealOverlay.hidden) return;
revealOverlay.hidden = true;
document.body.classList.remove("overlay-open");
const callback = revealCallback;
revealCallback = null;
revealSteamCallback = null;
if (revealLastFocus && typeof revealLastFocus.focus === "function") {
revealLastFocus.focus();
}
if (triggerCallback && callback) callback();
}
function showConfirm(onYes) {
confirmLastFocus = document.activeElement;
confirmCallback = typeof onYes === "function" ? onYes : null;
confirmOverlay.hidden = false;
document.body.classList.add("overlay-open");
$("confirm-no").focus();
}
function closeConfirm() {
if (confirmOverlay.hidden) return;
confirmOverlay.hidden = true;
if (revealOverlay.hidden) document.body.classList.remove("overlay-open");
confirmCallback = null;
if (confirmLastFocus && typeof confirmLastFocus.focus === "function") {
confirmLastFocus.focus();
}
}
function toast(message) {
toastNode.textContent = message;
toastNode.hidden = false;
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(function () {
toastNode.hidden = true;
}, 2400);
}
function renderFinaleGifts() {
const grid = $("finale-gifts");
grid.innerHTML = "";
const games = GAME_CONFIG.games || [];
games.forEach(function (game, index) {
const card = el("button", "finale-card");
card.type = "button";
card.append(coverArt(game, "finale-art"));
card.append(el("span", "finale-card-title", game.title));
card.setAttribute("aria-label", "Bekijk cadeau: " + game.title);
card.addEventListener("click", function () {
showReveal(index, null, { review: true });
});
grid.append(card);
});
}
function showFinale() {
$("finale-title").textContent = GAME_CONFIG.finaleTitle;
$("finale-message").textContent = GAME_CONFIG.finalMessage;
$("finale-signature").textContent = GAME_CONFIG.finaleSignature || ("Liefs, " + GAME_CONFIG.yourName);
renderFinaleGifts();
showScreen("finale");
play("reveal");
const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) spawnConfetti();
const title = $("finale-title");
title.setAttribute("tabindex", "-1");
title.focus();
}
function spawnConfetti() {
const old = document.querySelector(".confetti-layer");
if (old) old.remove();
const layer = el("div", "confetti-layer");
layer.setAttribute("aria-hidden", "true");
const colors = ["#f4a7b9", "#f6d98a", "#bfe3d0", "#cdbde8", "#f29b8b", "#fff7ef"];
for (let i = 0; i < 42; i += 1) {
const piece = el("span", "confetti");
piece.style.left = (Math.random() * 100).toFixed(1) + "%";
piece.style.background = colors[i % colors.length];
piece.style.width = (6 + Math.random() * 7).toFixed(1) + "px";
piece.style.height = (9 + Math.random() * 9).toFixed(1) + "px";
piece.style.animationDelay = (Math.random() * 1.8).toFixed(2) + "s";
piece.style.animationDuration = (3.4 + Math.random() * 2.6).toFixed(2) + "s";
piece.style.setProperty("--drift", (Math.random() * 160 - 80).toFixed(0) + "px");
layer.append(piece);
}
screens.finale.append(layer);
window.setTimeout(function () { layer.remove(); }, 8000);
}
function showConfigError(messages) {
const banner = el("div", "config-warning");
banner.setAttribute("role", "alert");
banner.textContent = "Let op: " + messages.join(" ");
const inner = document.querySelector(".welcome-inner");
inner.insertBefore(banner, $("start-btn"));
if (window.console && console.error) {
console.error("Configuratieprobleem:", messages);
}
}
function init() {
document.title = "Voor " + GAME_CONFIG.herName + " — een tuin vol cadeautjes";
$("welcome-title").textContent = GAME_CONFIG.welcomeTitle || "Voor";
$("welcome-name").textContent = GAME_CONFIG.herName;
$("welcome-text").textContent = GAME_CONFIG.welcomeText;
$("finale-title").textContent = GAME_CONFIG.finaleTitle;
$("finale-message").textContent = GAME_CONFIG.finalMessage;
$("finale-signature").textContent = GAME_CONFIG.finaleSignature || ("Liefs, " + GAME_CONFIG.yourName);
updateMuteButton();
$("start-btn").addEventListener("click", function () { App.goToMap(); });
homeBtn.addEventListener("click", function () { App.goHome(); });
muteBtn.addEventListener("click", toggleMute);
$("reset-btn").addEventListener("click", function () {
showConfirm(function () { App.resetGame(); });
});
$("finale-btn").addEventListener("click", showFinale);
$("finale-back").addEventListener("click", function () { App.goToMap(); });
$("reveal-continue").addEventListener("click", function () { closeReveal(true); });
$("reveal-key-copy").addEventListener("click", copyKey);
$("reveal-key-value").addEventListener("focus", function () { this.select(); });
[$("reveal-steam"), $("reveal-browser")].forEach(function (link) {
link.addEventListener("click", function () {
if (revealSteamCallback) revealSteamCallback();
});
});
$("confirm-no").addEventListener("click", closeConfirm);
$("confirm-yes").addEventListener("click", function () {
const callback = confirmCallback;
closeConfirm();
if (callback) callback();
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") return;
if (!confirmOverlay.hidden) {
closeConfirm();
return;
}
if (!revealOverlay.hidden) {
closeReveal(true);
}
});
document.addEventListener("pointerdown", function () {
ensureAudio();
}, { once: true });
updateProgress();
}
return {
init: init,
play: play,
setState: setState,
renderMap: renderMap,
showScreen: showScreen,
showLevel: showLevel,
setHint: setHint,
showReveal: showReveal,
showFinale: showFinale,
showConfigError: showConfigError,
toast: toast,
getScreen: function () { return currentScreen; }
};
})();