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