diff --git a/Dockers/inez-game/.dockerignore b/Dockers/inez-game/.dockerignore new file mode 100644 index 0000000..015c251 --- /dev/null +++ b/Dockers/inez-game/.dockerignore @@ -0,0 +1,5 @@ +README.md +Dockerfile +.dockerignore +.git +.gitignore diff --git a/Dockers/inez-game/Dockerfile b/Dockers/inez-game/Dockerfile new file mode 100644 index 0000000..4aaf6c6 --- /dev/null +++ b/Dockers/inez-game/Dockerfile @@ -0,0 +1,10 @@ +FROM nginx:1.27-alpine + +COPY index.html /usr/share/nginx/html/ +COPY css /usr/share/nginx/html/css/ +COPY js /usr/share/nginx/html/js/ + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://127.0.0.1/ || exit 1 diff --git a/Dockers/inez-game/README.md b/Dockers/inez-game/README.md new file mode 100644 index 0000000..cf5588b --- /dev/null +++ b/Dockers/inez-game/README.md @@ -0,0 +1,108 @@ +# Een tuin vol cadeautjes + +Een klein cozy HTML5-spel voor Inez' verjaardag. Ze speelt zeven rustige tuintaken en opent na elk level een cadeau: de titel van een Steam-game met een persoonlijke zin, een link naar de winkel en optioneel een Steam-key die ze direct kan activeren. + +Het spel is één zelfstandige map zonder build-stap. Het werkt lokaal (`file://`) en op elke statische hosting. + +## Snel starten + +Open `index.html` in een browser. Meer is niet nodig. + +Voor een lokale server (handig bij testen): + +```bash +python3 -m http.server 8000 +``` + +Ga daarna naar . + +## De cadeaus aanpassen + +Open `js/config.js`. Bovenaan staan de namen en teksten: + +- `herName` en `yourName` +- `welcomeText` +- `finalMessage` en `finaleSignature` + +Daaronder staan precies zeven games in `games`. Elk cadeau heeft: + +| Veld | Betekenis | +| --- | --- | +| `title` | Naam die in het spel verschijnt | +| `appId` | Steam-appnummer; staat in de winkel-URL, bijvoorbeeld `413150` in `store.steampowered.com/app/413150/` | +| `url` | Alternatief als je geen `appId` hebt | +| `key` | Optioneel; de Steam-key die na het level wordt getoond | +| `note` | Korte persoonlijke zin | +| `image` | Optioneel; laat leeg om de Steam-cover te gebruiken | +| `accent` | Kleur van de kaart als de cover niet laadt | + +De volgorde in `games` hoort bij de levels: level 1 opent `games[0]`, level 2 opent `games[1]`, enzovoort. + +De zeven tuintaken zijn: zaaien en water geven, vuurvliegjes vangen, sterrenbeelden verbinden, taart versieren, boeket schikken, tuinmemory en herfstblaadjes naar de mand slepen. + +Zonder `key` opent de knop de Steam-winkel. Met een `key` wordt de knop "Activeer op Steam" en opent hij de activatiepagina van Steam met de key al ingevuld. Daarnaast staat de key in een vak met een kopieerknop. + +## Een Steam-key toevoegen + +Vul de key in bij de juiste game in `js/config.js`: + +```js +{ + title: "Stardew Valley", + appId: 413150, + url: "", + key: "ABCDE-FGHIJ-KLMNO", + note: "Een plek om te wortelen.", + image: "", + accent: "#f3c2a6" +} +``` + +Na het level ziet ze de key, kan ze hem kopiΓ«ren en via "Activeer op Steam" meteen naar de activatiepagina gaan. Na inloggen en activeren staat de game in haar Steam-bibliotheek. + +Let op: + +- Een key staat leesbaar in `config.js` en in de pagina. Zet keys alleen in een lokale versie en host die map niet openbaar. +- De activeerlink bevat de key in de URL, dus die kan in de browsergeschiedenis terechtkomen. +- Steam geeft geen keys uit voor games die je rechtstreeks via Steam cadeau doet. Gebruik alleen keys die je als key hebt gekocht bij een betrouwbare aanbieder. + +## Op de Steam Deck + +1. Zet de hele map op de Deck (bijvoorbeeld via USB, Syncthing of een gedeelde map). +2. Open Desktop Mode en start Firefox of Chrome. +3. Open `index.html` vanuit de bestandsbeheerder, of host de map en open de URL. +4. Optioneel: voeg de pagina als snelkoppeling toe aan Steam. In de browser kan dat via het menu; kies daarna in Steam "Toevoegen als niet-Steam-game". + +Het spel is gemaakt voor 1280Γ—800 en werkt met touchscreen, trackpad en toetsenbord. Er is geen tijdsdruk of faalstaat. + +## Online zetten + +Upload de map zoals hij is naar statische hosting, bijvoorbeeld GitHub Pages, Gitea Pages, Netlify of Cloudflare Pages. Er is geen servercode en er zijn geen externe scripts nodig. + +Zet geen Steam-keys in een online gehoste versie: iedereen die de pagina opent kan ze uitlezen. Gebruik dan alleen titels en links, of houd de versie met keys lokaal. + +De Steam-covers worden van de Steam-CDN geladen. Zonder internet werkt het spel volledig, maar dan zie je de gestileerde kaarten in plaats van de covers. Wil je echt alles offline, zet dan lokale afbeeldingen in de map en vul het pad in bij `image`. + +## Voortgang + +De voortgang wordt opgeslagen in `localStorage` van de browser. Dat betekent: + +- sluiten en heropenen bewaart de tuin; +- een andere browser of privacymodus begint opnieuw; +- met "Opnieuw beginnen" onderaan het tuinpad wis je alles. + +## Bestanden + +``` +index.html de schermen +css/styles.css alle vormgeving en animaties +js/config.js namen, teksten en de zeven games +js/storage.js voortgang bewaren +js/levels.js de zeven mini-games +js/ui.js schermen, onthullingen, finale en geluid +js/app.js spelverloop +``` + +## Cadeaus versturen + +Het spel onthult de titel, de link en eventueel de key. Met een key kan ze de game zelf op haar account activeren. Zonder key verstuur je de game via Steam, bijvoorbeeld als cadeau of met een key die je buiten het spel om geeft. diff --git a/Dockers/inez-game/css/styles.css b/Dockers/inez-game/css/styles.css new file mode 100644 index 0000000..27bdb7e --- /dev/null +++ b/Dockers/inez-game/css/styles.css @@ -0,0 +1,2314 @@ +:root { + --cream: #fdf8f1; + --cream-2: #f8ecdd; + --pink: #f3b6c6; + --pink-deep: #d97f98; + --pink-soft: #fbdfe7; + --mint: #bfe2cf; + --mint-deep: #5f9c7e; + --lavender: #cdbde8; + --yellow: #f6d98a; + --ink: #463b44; + --ink-soft: #7a6a73; + --brown: #8a6552; + --white: #fffdf9; + --shadow: 0 18px 40px -24px rgba(90, 60, 75, 0.45); + --focus: #b45d7a; +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: + radial-gradient(1100px 620px at 50% -12%, #fff7ea 0%, rgba(255, 247, 234, 0) 62%), + linear-gradient(180deg, #fdf3ea 0%, #fbe9ee 46%, #ebf4e7 100%); + font-family: "Avenir Next", "Trebuchet MS", "Segoe UI", system-ui, sans-serif; + font-size: 16px; + line-height: 1.5; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3 { + font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ink); +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +button { + font: inherit; + color: inherit; + cursor: pointer; +} + +button:disabled { + cursor: default; +} + +button:focus-visible, +a:focus-visible, +[tabindex="-1"]:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} + +[hidden] { + display: none !important; +} + +body.overlay-open { + overflow: hidden; +} + + +.sky-decor { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} + +.sun { + position: absolute; + top: -80px; + right: -60px; + width: 320px; + height: 320px; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 228, 160, 0.75) 0%, rgba(255, 228, 160, 0) 68%); +} + +.cloud { + position: absolute; + width: 170px; + height: 54px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.6); + box-shadow: 0 12px 24px -18px rgba(120, 90, 100, 0.5); + animation: drift 24s ease-in-out infinite alternate; +} + +.cloud::before, +.cloud::after { + content: ""; + position: absolute; + border-radius: 50%; + background: inherit; +} + +.cloud::before { + width: 78px; + height: 78px; + top: -36px; + left: 26px; +} + +.cloud::after { + width: 56px; + height: 56px; + top: -22px; + right: 28px; +} + +.cloud-a { + top: 12%; + left: 5%; +} + +.cloud-b { + top: 22%; + right: 8%; + transform: scale(0.72); + animation-delay: -7s; + opacity: 0.8; +} + +.cloud-c { + top: 52%; + left: 74%; + transform: scale(0.5); + animation-delay: -14s; + opacity: 0.55; +} + +#app { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +main { + flex: 1; + display: flex; + flex-direction: column; +} + +.screen { + width: min(1120px, calc(100% - 40px)); + margin: 0 auto; +} + + +.topbar { + position: relative; + z-index: 20; + width: min(1120px, calc(100% - 32px)); + margin: 14px auto 0; + padding: 8px 10px; + display: flex; + align-items: center; + gap: 14px; + border-radius: 999px; + background: rgba(255, 253, 249, 0.72); + border: 1px solid rgba(255, 255, 255, 0.9); + box-shadow: 0 12px 30px -24px rgba(70, 50, 60, 0.55); + backdrop-filter: blur(14px); +} + +.topbar-center { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + min-width: 0; +} + +.progress-label { + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-soft); + white-space: nowrap; +} + +.progress-track { + width: min(320px, 100%); + height: 6px; + border-radius: 99px; + background: rgba(70, 50, 60, 0.1); + overflow: hidden; +} + +#progress-fill { + height: 100%; + width: 0; + border-radius: 99px; + background: linear-gradient(90deg, var(--pink), var(--lavender)); + transition: width 0.5s ease; +} + + +.pill-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 8px 16px; + border-radius: 999px; + border: 1px solid rgba(90, 60, 75, 0.14); + background: rgba(255, 255, 255, 0.6); + font-size: 0.86rem; + font-weight: 700; + color: var(--ink); + transition: background 0.18s ease, transform 0.18s ease; + white-space: nowrap; +} + +.pill-btn:hover { + background: #fff; + transform: translateY(-1px); +} + +.pill-btn.soft { + background: var(--mint); + border-color: transparent; +} + +.primary-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 52px; + padding: 14px 28px; + border: none; + border-radius: 999px; + background: linear-gradient(135deg, #e58da5, #c96f8d); + color: #fff; + font-size: 1rem; + font-weight: 700; + text-decoration: none; + box-shadow: 0 12px 24px -14px rgba(190, 90, 120, 0.85); + transition: transform 0.16s ease, box-shadow 0.2s ease; +} + +.primary-btn:hover { + transform: translateY(-2px); + box-shadow: 0 16px 28px -14px rgba(190, 90, 120, 0.9); +} + +.primary-btn:active { + transform: translateY(1px) scale(0.99); +} + +.secondary-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 50px; + padding: 12px 24px; + border-radius: 999px; + border: 2px solid rgba(217, 127, 152, 0.55); + background: #fff; + color: var(--ink); + font-size: 0.98rem; + font-weight: 700; + text-decoration: none; + transition: background 0.18s ease, transform 0.18s ease; +} + +.secondary-btn:hover { + background: var(--pink-soft); + transform: translateY(-1px); +} + +.text-btn { + border: none; + background: none; + padding: 8px; + color: var(--ink-soft); + font-size: 0.88rem; + text-decoration: underline; + text-underline-offset: 4px; +} + +.text-btn.danger { + color: #b8566f; +} + +.text-link { + color: var(--ink-soft); + font-size: 0.86rem; + text-decoration: underline; + text-underline-offset: 4px; +} + + +.welcome { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 44px 16px 140px; +} + +.welcome-inner { + position: relative; + z-index: 2; + max-width: 680px; + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + text-align: center; +} + +.eyebrow { + margin: 0; + color: #a27486; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.welcome h1 { + margin: 0; + font-size: clamp(3.2rem, 9vw, 6.4rem); + line-height: 0.96; + text-shadow: 0 12px 30px rgba(190, 120, 140, 0.12); +} + +.welcome h1 #welcome-name { + color: #c96f8d; +} + +.welcome-text { + max-width: 34rem; + margin: 0; + color: var(--ink-soft); + font-size: 1.04rem; + line-height: 1.7; +} + +.welcome-hint { + margin: 0; + color: #9a8892; + font-size: 0.82rem; +} + +.config-warning { + max-width: 34rem; + padding: 12px 16px; + border-radius: 14px; + background: #fff1d6; + border: 1px solid #eccb8a; + color: #7a5a22; + font-size: 0.88rem; + line-height: 1.5; +} + +.welcome-flowers { + position: absolute; + left: 0; + right: 0; + bottom: -96px; + display: flex; + justify-content: center; + gap: clamp(8px, 5vw, 52px); + font-size: clamp(2rem, 6vw, 4rem); + pointer-events: none; +} + +.wf { + display: inline-block; + filter: drop-shadow(0 10px 10px rgba(90, 60, 75, 0.14)); + animation: sway 5.5s ease-in-out infinite; +} + +.wf-2 { + animation-delay: -1.2s; + transform: translateY(-14px); +} + +.wf-3 { + animation-delay: -2.4s; + opacity: 0.85; +} + +.wf-4 { + animation-delay: -0.7s; + transform: translateY(-6px); +} + +.wf-5 { + animation-delay: -3.1s; +} + +.wf-6 { + animation-delay: -1.8s; + transform: translateY(-18px); + opacity: 0.8; +} + + +.map-screen { + padding: 26px 0 70px; +} + +.map-heading { + text-align: center; + margin-bottom: 8px; +} + +.map-heading h2 { + margin: 6px 0 8px; + font-size: clamp(1.9rem, 4vw, 2.8rem); +} + +.map-intro { + max-width: 520px; + margin: 0 auto; + color: var(--ink-soft); + font-size: 0.96rem; +} + +.garden-path { + position: relative; + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 8px; + padding: 46px 0 28px; +} + +.garden-path::before { + content: ""; + position: absolute; + left: 9%; + right: 9%; + top: 50%; + border-top: 4px dashed rgba(122, 106, 115, 0.26); + z-index: 0; +} + +.map-node { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; + padding: 0; + border: none; + background: none; + text-align: center; +} + +.map-node:nth-child(even) { + transform: translateY(12px); +} + +.map-node:nth-child(odd) { + transform: translateY(-8px); +} + +.map-node.locked { + cursor: not-allowed; + opacity: 0.55; +} + +.node-circle { + position: relative; + display: grid; + place-items: center; + width: clamp(74px, 9vw, 102px); + height: clamp(74px, 9vw, 102px); + border-radius: 50%; + border: 3px solid rgba(217, 127, 152, 0.32); + background: radial-gradient(circle at 35% 30%, #fff, #f6e7d6); + box-shadow: 0 14px 24px -18px rgba(70, 50, 60, 0.6); + font-size: clamp(1.7rem, 3vw, 2.3rem); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.map-node:not(.locked):hover .node-circle { + transform: translateY(-3px); +} + +.map-node.current .node-circle { + border-color: var(--pink-deep); + background: radial-gradient(circle at 35% 30%, #fff8dc, #f8d9a8); + box-shadow: 0 0 0 8px rgba(246, 217, 138, 0.24), 0 18px 30px -18px rgba(190, 120, 90, 0.75); + animation: bob 3.4s ease-in-out infinite; +} + +.map-node.completed .node-circle { + border-color: var(--mint-deep); + background: radial-gradient(circle at 35% 30%, #f4fff8, #c9e8d8); +} + +.node-icon { + line-height: 1; +} + +.node-gift { + position: absolute; + right: -4px; + bottom: -2px; + font-size: 1.4rem; + filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.16)); +} + +.node-lock { + position: absolute; + right: -2px; + bottom: -2px; + font-size: 1.15rem; +} + +.node-text { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.node-label { + font-size: 0.94rem; + font-weight: 700; +} + +.node-sub { + font-size: 0.76rem; + color: var(--ink-soft); +} + + +.shelf { + margin-top: 30px; + padding: 18px; + border-radius: 24px; + background: rgba(255, 253, 249, 0.65); + border: 1px solid rgba(255, 255, 255, 0.92); + box-shadow: var(--shadow); +} + +.shelf-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.shelf-head h3 { + margin: 0; + font-size: 1.3rem; +} + +.shelf-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.shelf-card { + display: flex; + flex-direction: column; + min-height: 150px; + padding: 0; + overflow: hidden; + border: none; + border-radius: 16px; + background: #fff; + color: var(--ink); + text-align: left; + box-shadow: 0 10px 20px -18px rgba(70, 50, 60, 0.75); + transition: transform 0.18s ease, box-shadow 0.2s ease; +} + +button.shelf-card:hover { + transform: translateY(-3px); + box-shadow: 0 16px 26px -18px rgba(70, 50, 60, 0.7); +} + +.shelf-art { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + background: linear-gradient(135deg, var(--accent, #d9c2e8), #fff7ef); +} + +.shelf-art img, +.reveal-art img, +.finale-art img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.35s ease; +} + +.has-image img { + opacity: 1; +} + +.has-image .cover-fallback { + opacity: 0; +} + +.cover-fallback { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 12px; + color: rgba(70, 50, 60, 0.72); + font-family: Georgia, "Iowan Old Style", serif; + font-size: 0.95rem; + font-weight: 700; + text-align: center; + transition: opacity 0.3s ease; +} + +.shelf-title { + display: block; + padding: 9px 11px 2px; + font-size: 0.88rem; + font-weight: 700; + line-height: 1.25; +} + +.shelf-sub { + display: block; + padding: 0 11px 10px; + color: var(--ink-soft); + font-size: 0.72rem; +} + +.shelf-card.locked { + align-items: center; + justify-content: center; + gap: 2px; + text-align: center; + background: repeating-linear-gradient(45deg, #fbf1e5, #fbf1e5 10px, #f7e9da 10px, #f7e9da 20px); +} + +.shelf-card.locked .gift-box { + font-size: 2rem; + filter: grayscale(0.15); +} + +.shelf-card.locked .shelf-title, +.shelf-card.locked .shelf-sub { + padding: 0; +} + +.map-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 18px; + padding: 0 6px; +} + +.map-footer-note { + color: #9a8892; + font-size: 0.78rem; + font-style: italic; +} + + +.level-screen { + padding: 20px 0 60px; +} + +.level-head { + margin: 4px 0 16px; + text-align: center; +} + +.level-head h2 { + margin: 6px 0; + font-size: clamp(1.8rem, 4vw, 2.6rem); +} + +.level-instruction { + max-width: 620px; + margin: 0 auto; + color: var(--ink-soft); + font-size: 0.96rem; +} + +.stage { + position: relative; + width: 100%; + min-height: clamp(380px, 54vh, 520px); + overflow: hidden; + border-radius: 30px; + background: #f6eee2; + box-shadow: var(--shadow), inset 0 0 0 1px rgba(255, 255, 255, 0.55); +} + +.level-hint { + min-height: 1.6em; + max-width: 640px; + margin: 14px auto 0; + color: var(--ink-soft); + font-size: 0.96rem; + text-align: center; +} + + +.stage-garden { + background: linear-gradient(180deg, #eaf7ee 0%, #d6efdf 52%, #bfe0c6 100%); +} + +.garden-scene { + position: relative; + display: flex; + flex-direction: column; + min-height: clamp(380px, 54vh, 520px); + padding: 22px; +} + +.garden-sun { + position: absolute; + top: -46px; + left: -34px; + width: 210px; + height: 210px; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 236, 170, 0.9) 0%, rgba(255, 236, 170, 0) 68%); + pointer-events: none; +} + +.tool-row { + position: relative; + z-index: 3; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.tool-label { + margin-right: 4px; + color: var(--ink-soft); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.tool-btn { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 46px; + padding: 8px 16px; + border: 2px solid transparent; + border-radius: 999px; + background: rgba(255, 255, 255, 0.66); + box-shadow: 0 8px 16px -14px rgba(70, 50, 60, 0.7); + font-size: 0.88rem; + font-weight: 700; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease; +} + +.tool-btn span:first-child { + font-size: 1.15rem; +} + +.tool-btn:hover { + background: #fff; + transform: translateY(-1px); +} + +.tool-btn.selected { + border-color: var(--pink-deep); + background: #fff; + box-shadow: 0 0 0 4px rgba(243, 182, 198, 0.25); +} + +.pot-row { + position: relative; + z-index: 2; + display: flex; + align-items: flex-end; + justify-content: center; + gap: clamp(4px, 3vw, 34px); + margin-top: auto; + padding-bottom: 6px; +} + +.pot { + position: relative; + display: flex; + align-items: flex-end; + justify-content: center; + width: clamp(58px, 8.5vw, 96px); + height: clamp(122px, 19vh, 172px); + padding: 0; + border: none; + background: none; +} + +.pot-body { + position: relative; + z-index: 2; + width: 100%; + height: 46%; + border-radius: 6px 6px 22px 22px; + background: linear-gradient(180deg, #cf946f, #b47655); + box-shadow: inset -8px 0 12px rgba(0, 0, 0, 0.08), inset 8px 0 12px rgba(255, 255, 255, 0.13); +} + +.pot-rim { + position: absolute; + top: -6px; + left: -6px; + right: -6px; + height: 18px; + border-radius: 12px; + background: linear-gradient(180deg, #dda37c, #c78a64); +} + +.pot-plant { + position: absolute; + z-index: 1; + bottom: 42%; + left: 0; + display: flex; + align-items: flex-end; + justify-content: center; + width: 100%; + height: 64%; + transform: scale(0.08); + transform-origin: bottom center; + transition: transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.pot.seeded .pot-plant { + transform: scale(0.5); +} + +.pot.watered .pot-plant { + transform: scale(0.72); +} + +.pot.bloomed .pot-plant { + transform: scale(1); +} + +.stem { + position: absolute; + bottom: 0; + width: 5px; + height: 70%; + border-radius: 6px; + background: linear-gradient(180deg, #7bbf89, #4f9463); +} + +.leaf { + position: absolute; + bottom: 34%; + width: 28%; + height: 12%; + border-radius: 60% 10% 60% 10%; + background: #7bbf89; +} + +.leaf-l { + left: 20%; + transform: rotate(22deg); +} + +.leaf-r { + right: 20%; + transform: scaleX(-1) rotate(22deg); +} + +.bloom { + position: absolute; + top: -8px; + left: 50%; + width: min(140%, 76px); + aspect-ratio: 1; + border-radius: 50%; + background: + radial-gradient(circle at 50% 50%, #f9e08a 0 18%, rgba(249, 224, 138, 0) 19%), + repeating-conic-gradient(from 0deg, var(--bloom, #f4a7b9) 0 30deg, var(--bloom-2, #f9cfd8) 30deg 60deg); + box-shadow: 0 6px 18px -10px rgba(70, 50, 60, 0.4); + opacity: 0; + transform: translateX(-50%) scale(0.2) rotate(-30deg); + transition: opacity 0.5s ease, transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.pot.bloomed .bloom { + opacity: 1; + transform: translateX(-50%) scale(1) rotate(0deg); +} + +.pot.watering .pot-body::after { + content: "πŸ’§"; + position: absolute; + top: -74px; + left: 50%; + font-size: 1.25rem; + transform: translateX(-50%); + animation: drop 0.55s ease-in; +} + + +.stage-fireflies { + background: radial-gradient(120% 90% at 50% -10%, #5b4d7d 0%, #332d56 46%, #1d1b33 100%); +} + +.firefly-scene { + position: relative; + min-height: clamp(380px, 54vh, 520px); + overflow: hidden; +} + +.tiny-star { + position: absolute; + width: 3px; + height: 3px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.78); + animation: twinkle 3.6s ease-in-out infinite; +} + +.scene-counter { + position: absolute; + top: 18px; + left: 50%; + z-index: 3; + margin: 0; + padding: 8px 18px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.12); + color: #fdf4dd; + font-size: 0.82rem; + letter-spacing: 0.04em; + transform: translateX(-50%); +} + +.jar { + position: absolute; + left: 50%; + bottom: 20px; + z-index: 2; + width: clamp(96px, 12vw, 140px); + height: clamp(110px, 15vh, 150px); + border: 3px solid rgba(255, 255, 255, 0.45); + border-radius: 18px 18px 34px 34px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.28)); + box-shadow: inset 0 0 30px rgba(255, 240, 180, 0.14), 0 16px 30px -20px #000; + transform: translateX(-50%); +} + +.jar-lid { + position: absolute; + top: -14px; + left: 50%; + width: 72%; + height: 20px; + border-radius: 8px; + background: linear-gradient(180deg, #c8b9a4, #9c8b76); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25); + transform: translateX(-50%); +} + +.jar-glow { + position: absolute; + inset: 18% 16% 12%; + border-radius: 40%; + background: radial-gradient(circle, rgba(255, 232, 150, 0.4), rgba(255, 232, 150, 0) 70%); + opacity: 0; + transition: opacity 0.6s ease; +} + +.jar.has-light .jar-glow { + opacity: 1; + animation: jar-glow 2.8s ease-in-out infinite; +} + +.jar-dots { + position: absolute; + inset: 16%; +} + +.glow-dot { + position: absolute; + width: 9px; + height: 9px; + border-radius: 50%; + background: #ffedb0; + box-shadow: 0 0 12px 5px rgba(255, 230, 140, 0.65); + animation: pulse 2.2s ease-in-out infinite; +} + +.jar-label { + position: absolute; + bottom: 8px; + left: 50%; + color: rgba(255, 255, 255, 0.55); + font-size: 0.72rem; + transform: translateX(-50%); +} + +.firefly { + position: absolute; + z-index: 2; + display: grid; + place-items: center; + width: 54px; + height: 54px; + padding: 0; + border: none; + background: none; + transform: translate(-50%, -50%); +} + +.firefly::before { + content: ""; + width: 11px; + height: 11px; + border-radius: 50%; + background: #fff3bd; + box-shadow: 0 0 16px 7px rgba(255, 232, 150, 0.55); + animation: pulse 2.4s ease-in-out infinite; +} + +.firefly:hover::before { + box-shadow: 0 0 22px 10px rgba(255, 240, 170, 0.82); +} + +.firefly.caught { + opacity: 0; + transform: translate(-50%, -50%) scale(0.4); + transition: left 0.45s ease, top 0.45s ease, opacity 0.45s ease, transform 0.45s ease; + pointer-events: none; +} + + +.stage-stars { + background: radial-gradient(circle at 50% 10%, #33406f 0%, #1c2444 55%, #11162a 100%); +} + +.starfield { + position: relative; + min-height: clamp(380px, 54vh, 520px); + overflow: hidden; +} + +.constellation-lines { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + pointer-events: none; +} + +.star-line { + stroke: #ffe6a7; + stroke-width: 3; + stroke-linecap: round; + vector-effect: non-scaling-stroke; + opacity: 0; + filter: drop-shadow(0 0 6px rgba(255, 220, 140, 0.8)); + animation: line-in 0.55s ease forwards; +} + +.star { + position: absolute; + z-index: 2; + display: grid; + place-items: center; + width: 48px; + height: 48px; + padding: 0; + border: none; + background: none; + transform: translate(-50%, -50%); +} + +.star::before { + content: ""; + width: 13px; + height: 13px; + border-radius: 50%; + background: #fff9db; + box-shadow: 0 0 10px 4px rgba(255, 246, 200, 0.55); + transition: box-shadow 0.3s ease, background 0.3s ease; +} + +.star-num { + position: absolute; + top: -15px; + color: rgba(255, 255, 255, 0.58); + font-size: 0.68rem; + letter-spacing: 0.05em; +} + +.star.next::before { + box-shadow: 0 0 16px 8px rgba(255, 232, 150, 0.7); + animation: pulse 2s ease-in-out infinite; +} + +.star.connected::before { + background: #ffe9a6; + box-shadow: 0 0 18px 8px rgba(255, 220, 130, 0.8); +} + +.star.wrong { + animation: nudge 0.5s ease; +} + +.constellation-done .star::before { + box-shadow: 0 0 24px 12px rgba(255, 220, 130, 0.85); +} + + +.stage-cake { + background: radial-gradient(120% 100% at 50% 0%, #fff8f3 0%, #fbe4e6 60%, #f7d8dd 100%); +} + +.cake-scene { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: clamp(420px, 56vh, 540px); + padding: 18px; +} + +.cake { + position: relative; + flex: 0 0 auto; + width: min(380px, 74vw); + height: 240px; + margin-top: 8px; +} + +.cake-art { + position: absolute; + inset: 0; +} + +.cake-plate { + position: absolute; + left: 4%; + right: 4%; + bottom: 0; + height: 22px; + border-radius: 50%; + background: linear-gradient(180deg, #fff, #e6d8d2); + box-shadow: 0 8px 20px -10px rgba(90, 60, 75, 0.4); +} + +.cake-body { + position: absolute; + left: 9%; + right: 9%; + bottom: 12px; + height: 160px; + border-radius: 14px 14px 18px 18px; + background: linear-gradient( + 180deg, + #f9d7c3 0%, + #f3b9a3 21%, + #fff1e2 22%, + #f7ddc7 47%, + #f0b7a0 48%, + #fff1e2 50%, + #f7ddc7 77%, + #eaa58d 79% + ); + box-shadow: inset 0 -12px 24px rgba(160, 90, 70, 0.12); +} + +.cake-frosting { + position: absolute; + top: 0; + left: -2%; + right: -2%; + height: 40px; + border-radius: 50% 50% 0 0 / 90% 90% 0 0; + background: linear-gradient(180deg, #fffdf9, #fdeee6); + box-shadow: 0 6px 12px rgba(180, 120, 110, 0.12); +} + +.cake-drip { + position: absolute; + top: 24px; + width: 18px; + height: 28px; + border-radius: 0 0 50% 50%; + background: #fffdf9; +} + +.drip-1 { + left: 16%; +} + +.drip-2 { + left: 47%; + height: 36px; +} + +.drip-3 { + right: 17%; + height: 22px; +} + +.candle { + position: absolute; + top: -26px; + left: 50%; + z-index: 3; + width: 14px; + height: 40px; + transform: translateX(-50%); +} + +.candle-stick { + position: absolute; + bottom: 0; + left: 50%; + width: 10px; + height: 32px; + border-radius: 4px; + background: repeating-linear-gradient(180deg, #fff 0 6px, #f3b6c6 6px 10px); + transform: translateX(-50%); +} + +.flame { + position: absolute; + top: -8px; + left: 50%; + width: 14px; + height: 20px; + border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%; + background: radial-gradient(circle at 50% 65%, #fff3b0, #f6a23c 70%); + box-shadow: 0 0 20px 8px rgba(255, 190, 80, 0.55); + opacity: 0; + transform: translateX(-50%) scale(0); + transition: opacity 0.5s ease, transform 0.5s ease; +} + +.cake-done .flame { + opacity: 1; + transform: translateX(-50%) scale(1); + animation: flicker 1.2s ease-in-out infinite; +} + +.cake-slots { + position: absolute; + inset: 0; + z-index: 2; +} + +.cake-slot { + position: absolute; + display: grid; + place-items: center; + width: 52px; + height: 52px; + padding: 0; + border: 2px dashed rgba(180, 120, 120, 0.38); + border-radius: 50%; + background: rgba(255, 255, 255, 0.55); + font-size: 1.5rem; + transform: translate(-50%, -50%); + transition: transform 0.15s ease, background 0.2s ease; +} + +.cake-slot .slot-ghost { + opacity: 0.2; + filter: grayscale(0.4); +} + +.cake-slot:hover { + background: rgba(255, 255, 255, 0.88); + transform: translate(-50%, -50%) scale(1.06); +} + +.cake-slot.filled { + border-style: solid; + background: #fffdf9; + box-shadow: 0 4px 10px -6px rgba(90, 60, 75, 0.4); +} + +.cake-slot.filled .slot-topping { + animation: pop 0.3s ease; +} + +.tray-label { + margin: 2px 0 0; + color: var(--ink-soft); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.topping-tray { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + max-width: 700px; + padding: 0 8px; +} + +.topping { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 64px; + padding: 8px 10px; + border: 2px solid transparent; + border-radius: 16px; + background: rgba(255, 255, 255, 0.75); + box-shadow: 0 6px 12px -12px rgba(90, 60, 75, 0.5); + touch-action: none; + transition: transform 0.15s ease, background 0.18s ease, border-color 0.18s ease; +} + +.topping:hover { + background: #fff; + transform: translateY(-2px); +} + +.topping.selected { + border-color: var(--pink-deep); + background: #fff; + box-shadow: 0 0 0 4px rgba(243, 182, 198, 0.28); +} + +.topping.used { + opacity: 0.35; + filter: grayscale(0.5); +} + +.topping-emoji { + font-size: 1.5rem; + line-height: 1; +} + +.topping-name { + color: var(--ink-soft); + font-size: 0.68rem; +} + + +.stage-bouquet { + background: linear-gradient(180deg, #f2fbf4 0%, #e3f2e6 60%, #d8ecdd 100%); +} + +.bouquet-scene { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-areas: + "pattern pattern" + "vase garden"; + gap: 12px 24px; + align-items: start; + min-height: clamp(420px, 56vh, 540px); + padding: 16px 22px 22px; +} + +.pattern-panel { + grid-area: pattern; + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + padding: 8px 14px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.62); +} + +.panel-label { + margin: 0; + color: var(--ink-soft); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.pattern-row { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.pattern-flower { + display: grid; + place-items: center; + width: 44px; + height: 44px; +} + +.flower-svg { + display: block; + width: 100%; + height: 100%; + filter: drop-shadow(0 2px 3px rgba(90, 60, 75, 0.15)); +} + +.vase-panel { + grid-area: vase; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.bouquet-holder { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.vase-slots { + z-index: 2; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 2px; + max-width: 310px; + margin-bottom: -10px; +} + +.vase-slot { + display: grid; + place-items: center; + width: 54px; + height: 54px; + padding: 0; + border: 2px dashed rgba(95, 156, 126, 0.4); + border-radius: 50%; + background: rgba(255, 255, 255, 0.55); +} + +.vase-slot .slot-flower, +.vase-slot .slot-flower-ghost { + display: block; + width: 72%; + height: 72%; +} + +.slot-flower-ghost { + opacity: 0.22; + filter: grayscale(0.45); +} + +.vase-slot.filled { + border-style: solid; + border-color: var(--mint-deep); + background: #fffdf9; +} + +.vase-slot.filled .slot-flower { + animation: pop 0.35s ease; +} + +.vase { + position: relative; + z-index: 1; + width: 180px; + height: 130px; + margin-top: -6px; +} + +.vase-body { + position: absolute; + inset: 10px 0 0; + border-radius: 12px 12px 46% 46% / 12px 12px 60% 60%; + background: linear-gradient(180deg, #b9dcd0, #7fb8a1); + box-shadow: inset -14px 0 24px rgba(0, 0, 0, 0.08), inset 12px 0 20px rgba(255, 255, 255, 0.28); +} + +.vase-rim { + position: absolute; + top: 0; + left: 50%; + z-index: 2; + width: 70%; + height: 22px; + border-radius: 10px; + background: linear-gradient(180deg, #cce7dd, #a3cdbd); + transform: translateX(-50%); +} + +.garden-panel { + grid-area: garden; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.garden-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + width: min(260px, 100%); +} + +.garden-flower { + width: 100%; + aspect-ratio: 1; + padding: 8px; + border: 2px solid transparent; + border-radius: 16px; + background: rgba(255, 255, 255, 0.55); + transition: transform 0.15s ease, background 0.18s ease, border-color 0.18s ease; +} + +.garden-flower:hover { + background: #fff; + transform: translateY(-2px); +} + +.garden-flower.selected { + border-color: var(--pink-deep); + background: #fff; + box-shadow: 0 0 0 4px rgba(243, 182, 198, 0.25); +} + +.garden-flower.used { + opacity: 0.25; + filter: grayscale(0.4); +} + +.bouquet-done .vase { + animation: bob 2s ease-in-out infinite; +} + +.stage-memory { + background: radial-gradient(120% 100% at 50% 0%, #f2fbf4 0%, #dfefe4 55%, #cfe4d6 100%); +} + +.memory-scene { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + min-height: clamp(400px, 56vh, 540px); + padding: 20px; +} + +.memory-counter { + position: static; + color: var(--ink-soft); + background: rgba(255, 255, 255, 0.72); + border-color: rgba(95, 156, 126, 0.28); + transform: none; +} + +.memory-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 10px; + width: min(820px, 100%); +} + +.memory-card { + position: relative; + aspect-ratio: 4 / 5; + padding: 0; + border: none; + background: none; + perspective: 800px; +} + +.memory-inner { + position: absolute; + inset: 0; + transform-style: preserve-3d; + transition: transform 0.45s ease; +} + +.memory-card.flipped .memory-inner { + transform: rotateY(180deg); +} + +.memory-face { + position: absolute; + inset: 0; + display: grid; + place-items: center; + border-radius: 16px; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} + +.memory-back { + border: 2px solid rgba(255, 255, 255, 0.6); + background: + radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0) 45%), + linear-gradient(145deg, #9fc7ad, #6fa98a); + box-shadow: 0 10px 18px -14px rgba(70, 50, 60, 0.7); + color: rgba(255, 255, 255, 0.82); + font-size: 1.7rem; +} + +.memory-front { + border: 2px solid rgba(95, 156, 126, 0.35); + background: #fffdf9; + transform: rotateY(180deg); + box-shadow: 0 10px 18px -16px rgba(70, 50, 60, 0.5); +} + +.memory-front .flower-svg { + width: 74%; + height: 74%; +} + +.memory-card.matched .memory-front { + border-color: var(--yellow); + box-shadow: 0 0 0 5px rgba(246, 217, 138, 0.32); +} + +.memory-card.matched { + animation: card-match 0.4s ease; +} + +.stage-leaves { + background: + radial-gradient(120% 100% at 50% 0%, #fff8ea 0%, #fbe8cb 55%, #eed6ad 100%); +} + +.leaves-scene { + position: relative; + min-height: clamp(400px, 56vh, 540px); + overflow: hidden; +} + +.leaves-counter { + color: var(--ink-soft); + background: rgba(255, 255, 255, 0.75); + border-color: rgba(150, 110, 70, 0.28); +} + +.leaf { + position: absolute; + z-index: 2; + display: grid; + place-items: center; + width: 62px; + height: 62px; + padding: 0; + border: none; + background: none; + font-size: 2.2rem; + transform: translate(-50%, -50%) rotate(var(--rot, 0deg)); + filter: drop-shadow(0 5px 5px rgba(120, 80, 40, 0.25)); + cursor: grab; + touch-action: none; + transition: left 0.35s ease, top 0.35s ease, opacity 0.35s ease, transform 0.35s ease; +} + +.leaf span { + pointer-events: none; +} + +.leaf:hover { + transform: translate(-50%, -50%) rotate(var(--rot, 0deg)) scale(1.08); +} + +.leaf.dragging { + z-index: 6; + cursor: grabbing; + transform: translate(-50%, -50%) scale(1.18) rotate(0deg); + transition: none; +} + +.leaf.collected { + z-index: 1; + pointer-events: none; +} + +.leaf-basket { + position: absolute; + bottom: 12px; + left: 50%; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + width: 170px; + height: 132px; + padding-bottom: 8px; + border: 2px dashed rgba(150, 110, 70, 0.4); + border-radius: 26px; + background: rgba(255, 255, 255, 0.3); + transform: translateX(-50%); +} + +.basket-emoji { + font-size: 3rem; + line-height: 1; + filter: drop-shadow(0 6px 6px rgba(120, 80, 40, 0.22)); +} + +.basket-label { + color: #8a6a45; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.leaf-basket.basket-full { + border-color: var(--yellow); + background: rgba(255, 248, 216, 0.6); + animation: bob 0.4s ease; +} + + +.finale-screen { + position: relative; + flex: 1; + padding: 30px 0 70px; +} + +.finale-inner { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + max-width: 900px; + margin: 0 auto; + text-align: center; +} + +.finale-inner h2 { + margin: 0; + font-size: clamp(2.4rem, 6vw, 4rem); +} + +.finale-message { + max-width: 640px; + margin: 0; + color: var(--ink-soft); + font-size: 1.04rem; + line-height: 1.75; +} + +.finale-gifts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + width: 100%; + margin: 10px 0; +} + +.finale-card { + display: flex; + flex-direction: column; + padding: 0; + border: none; + border-radius: 16px; + background: #fff; + color: var(--ink); + overflow: hidden; + box-shadow: 0 10px 20px -18px rgba(70, 50, 60, 0.75); + text-align: left; + transition: transform 0.18s ease, box-shadow 0.2s ease; +} + +.finale-card:hover { + transform: translateY(-3px); + box-shadow: 0 16px 26px -18px rgba(70, 50, 60, 0.7); +} + +.finale-art { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + background: linear-gradient(135deg, var(--accent, #d9c2e8), #fff7ef); +} + +.finale-card-title { + padding: 7px 8px 9px; + font-size: 0.8rem; + font-weight: 700; +} + +.finale-signature { + margin: 0; + color: #a27486; + font-family: Georgia, "Iowan Old Style", serif; + font-size: 1.1rem; + font-style: italic; +} + +.confetti-layer { + position: fixed; + inset: 0; + z-index: 40; + overflow: hidden; + pointer-events: none; +} + +.confetti { + position: absolute; + top: -24px; + border-radius: 2px; + opacity: 0.9; + animation-name: fall; + animation-timing-function: linear; + animation-fill-mode: forwards; +} + + +.overlay { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 20px; + background: rgba(58, 42, 52, 0.45); + backdrop-filter: blur(6px); + animation: fade-in 0.25s ease; +} + +.overlay-card { + position: relative; + width: min(520px, 100%); + max-height: calc(100vh - 40px); + padding: 26px; + overflow: auto; + border-radius: 28px; + background: linear-gradient(180deg, #fffdf9, #fdf3ec); + box-shadow: 0 30px 80px -30px rgba(40, 20, 30, 0.6); + text-align: center; + animation: rise 0.35s cubic-bezier(0.2, 0.8, 0.3, 1); +} + +.reveal-card .eyebrow { + color: var(--pink-deep); +} + +.reveal-sparkles { + position: absolute; + top: -18px; + left: 50%; + font-size: 1.6rem; + filter: drop-shadow(0 4px 6px rgba(120, 70, 90, 0.25)); + transform: translateX(-50%); +} + +.reveal-art { + position: relative; + width: 100%; + aspect-ratio: 2.14 / 1; + max-height: 230px; + margin: 14px 0 16px; + overflow: hidden; + border-radius: 18px; + background: linear-gradient(135deg, var(--accent, #d9c2e8), #fff7ef); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6); +} + +.reveal-art .cover-fallback { + font-size: 1.3rem; +} + +.reveal-note { + max-width: 400px; + margin: 0 auto 18px; + color: var(--ink-soft); + line-height: 1.65; +} + +.key-box { + margin: 0 0 18px; + padding: 14px 16px; + border: 1px dashed rgba(176, 133, 69, 0.45); + border-radius: 18px; + background: #fff6e3; + text-align: left; +} + +.key-box-label { + margin: 0 0 8px; + color: #8a6a34; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.key-row { + display: flex; + gap: 8px; +} + +.key-value { + flex: 1; + min-width: 0; + padding: 12px 14px; + border: 1px solid rgba(176, 133, 69, 0.35); + border-radius: 10px; + background: #fffdf9; + color: var(--ink); + font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + font-size: 0.98rem; + letter-spacing: 0.05em; +} + +.key-copy { + flex: 0 0 auto; + min-height: 44px; + background: var(--yellow); + border-color: transparent; +} + +.key-help { + margin: 8px 0 0; + color: #8a7351; + font-size: 0.76rem; + line-height: 1.5; +} + +.reveal-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.reveal-actions .primary-btn, +.reveal-actions .secondary-btn { + width: 100%; +} + +.confirm-card p { + color: var(--ink-soft); + line-height: 1.65; +} + +.confirm-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-top: 18px; +} + +.toast { + position: fixed; + left: 50%; + bottom: 26px; + z-index: 80; + max-width: calc(100vw - 40px); + padding: 12px 20px; + border-radius: 999px; + background: rgba(70, 59, 68, 0.94); + color: #fff; + font-size: 0.88rem; + text-align: center; + box-shadow: 0 12px 30px -16px #000; + transform: translateX(-50%); + animation: rise 0.25s ease; +} + + +.wrong { + animation: nudge 0.5s ease; +} + + +@keyframes drift { + from { transform: translateX(0); } + to { transform: translateX(28px); } +} + +@keyframes sway { + 0%, 100% { transform: translateY(0) rotate(-2deg); } + 50% { transform: translateY(-9px) rotate(2deg); } +} + +@keyframes bob { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-7px); } +} + +@keyframes pulse { + 0%, 100% { opacity: 0.55; transform: scale(0.9); } + 50% { opacity: 1; transform: scale(1.15); } +} + +@keyframes twinkle { + 0%, 100% { opacity: 0.25; } + 50% { opacity: 1; } +} + +@keyframes jar-glow { + 0%, 100% { opacity: 0.7; transform: scale(0.98); } + 50% { opacity: 1; transform: scale(1.03); } +} + +@keyframes drop { + from { opacity: 0; transform: translate(-50%, -14px) scale(0.7); } + 35% { opacity: 1; } + to { opacity: 0; transform: translate(-50%, 26px) scale(1); } +} + +@keyframes line-in { + from { opacity: 0; stroke-dasharray: 140; stroke-dashoffset: 140; } + to { opacity: 1; stroke-dasharray: 140; stroke-dashoffset: 0; } +} + +@keyframes nudge { + 0%, 100% { box-shadow: 0 0 0 0 rgba(200, 80, 110, 0); } + 30% { box-shadow: 0 0 0 7px rgba(200, 80, 110, 0.24); } + 65% { box-shadow: 0 0 0 3px rgba(200, 80, 110, 0.12); } +} + +@keyframes pop { + 0% { transform: scale(0.4); opacity: 0; } + 70% { transform: scale(1.12); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } +} + +@keyframes card-match { + 0% { transform: scale(1); } + 45% { transform: scale(1.06); } + 100% { transform: scale(1); } +} + +@keyframes flicker { + 0%, 100% { transform: translateX(-50%) scale(1); } + 40% { transform: translateX(-50%) scale(0.94) rotate(-2deg); } + 70% { transform: translateX(-50%) scale(1.05) rotate(2deg); } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes rise { + from { opacity: 0; transform: translateY(14px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes fall { + to { + transform: translate(var(--drift, 0), 110vh) rotate(720deg); + opacity: 0; + } +} + + +@media (max-width: 760px) { + .topbar { + width: calc(100% - 20px); + gap: 8px; + padding: 8px; + } + + .progress-label { + font-size: 0.66rem; + } + + .pill-btn { + min-height: 36px; + padding: 7px 12px; + font-size: 0.78rem; + } + + .screen { + width: calc(100% - 24px); + } + + .welcome { + padding-bottom: 150px; + } + + .welcome h1 { + font-size: clamp(2.8rem, 12vw, 4.6rem); + } + + .garden-path { + grid-auto-flow: row; + grid-auto-columns: auto; + gap: 10px; + padding: 10px 0 20px; + } + + .garden-path::before { + top: 26px; + bottom: 26px; + left: 39px; + right: auto; + border-top: none; + border-left: 4px dashed rgba(122, 106, 115, 0.26); + } + + .map-node, + .map-node:nth-child(even), + .map-node:nth-child(odd) { + flex-direction: row; + justify-content: flex-start; + gap: 14px; + padding: 4px 0; + text-align: left; + transform: none; + } + + .node-circle { + flex: 0 0 auto; + width: 78px; + height: 78px; + } + + .node-label { + font-size: 1rem; + } + + .node-text { + align-items: flex-start; + } + + .shelf-grid { + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + } + + .map-footer { + flex-direction: column; + gap: 4px; + text-align: center; + } + + .bouquet-scene { + grid-template-columns: 1fr; + grid-template-areas: + "pattern" + "vase" + "garden"; + gap: 10px; + } + + .cake { + width: min(340px, 88vw); + } + + .memory-grid { + grid-template-columns: repeat(4, 1fr); + } + + .memory-scene { + padding: 14px; + } + + .leaf { + width: 54px; + height: 54px; + font-size: 1.9rem; + } + + .leaf-basket { + width: 140px; + height: 112px; + } + + .cake-slot { + width: 46px; + height: 46px; + font-size: 1.35rem; + } + + .topping { + min-width: 58px; + padding: 7px 8px; + } + + .stage { + border-radius: 24px; + } + + .overlay-card { + padding: 22px 18px; + } + + .cloud-c { + display: none; + } +} + +@media (max-width: 520px) { + .key-row { + flex-direction: column; + } + + .key-copy { + width: 100%; + } + + .tool-row { + justify-content: center; + } + + .tool-label { + width: 100%; + margin: 0 0 2px; + text-align: center; + } + + .pot-row { + gap: 2px; + } + + .stage { + min-height: 460px; + } + + .shelf-grid { + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + } + + .vase-slots { + max-width: 280px; + } + + .vase-slot { + width: 50px; + height: 50px; + } + + .vase { + width: 150px; + height: 112px; + } + + .pattern-flower { + width: 38px; + height: 38px; + } + + .finale-card-title { + font-size: 0.72rem; + } + + .welcome-flowers { + gap: 6px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + + .confetti-layer { + display: none; + } +} diff --git a/Dockers/inez-game/index.html b/Dockers/inez-game/index.html new file mode 100644 index 0000000..280c3fd --- /dev/null +++ b/Dockers/inez-game/index.html @@ -0,0 +1,138 @@ + + + + + + Voor Inez β€” een tuin vol cadeautjes + + + + + + +
+ + +
+
+
+

Een verjaardagscadeau

+

Voor Inez

+

+ Zeven kleine tuintaken staan klaar. Bij elke taak die je afmaakt, opent er een cadeautje. + Neem de tijd β€” er is geen haast en je kunt niets verkeerd doen. +

+ +

Tip: je kunt stoppen wanneer je wilt. De tuin onthoudt waar je was.

+ +
+
+ + + + + + +
+
+ + + + + + + + + + + + + + diff --git a/Dockers/inez-game/js/app.js b/Dockers/inez-game/js/app.js new file mode 100644 index 0000000..6e6fa49 --- /dev/null +++ b/Dockers/inez-game/js/app.js @@ -0,0 +1,148 @@ +const App = (function () { + let state = Storage.load(); + let currentLevelId = null; + let levelHandle = null; + let levelFinishing = false; + + function validateConfig() { + const errors = []; + const games = GAME_CONFIG.games; + const expected = Levels.count; + + if (!Array.isArray(games) || games.length !== expected) { + errors.push("er moeten precies " + expected + " games in GAME_CONFIG.games staan."); + } else { + games.forEach(function (game, index) { + if (!game || !game.title) { + errors.push("game " + (index + 1) + " heeft geen titel."); + return; + } + if (!game.appId && !game.url && !game.key) { + errors.push("game " + (index + 1) + " heeft geen appId, url of key."); + } + }); + } + + if (!GAME_CONFIG.herName) errors.push("de naam van de jarige ontbreekt."); + return errors; + } + + function init() { + UI.init(); + UI.setState(state); + UI.renderMap(); + UI.showScreen("welcome"); + + const errors = validateConfig(); + if (errors.length > 0) { + UI.showConfigError(errors); + } + } + + function destroyLevel() { + if (levelHandle) { + levelHandle.destroy(); + levelHandle = null; + } + currentLevelId = null; + levelFinishing = false; + UI.setHint(""); + } + + function goToMap() { + destroyLevel(); + UI.renderMap(); + UI.showScreen("map"); + } + + function goHome() { + if (currentLevelId !== null) { + destroyLevel(); + UI.renderMap(); + UI.toast("Je kunt dit level later opnieuw doen."); + } + UI.showScreen("map"); + } + + function startLevel(id) { + if (id < 1 || id > Levels.count) return; + + const completed = state.completed.indexOf(id) !== -1; + if (id > state.unlocked && !completed) { + UI.toast("Dit level is nog niet open."); + return; + } + + destroyLevel(); + currentLevelId = id; + UI.showLevel(id); + + levelHandle = Levels.mount(id, document.getElementById("level-stage"), { + hint: function (message) { UI.setHint(message); }, + onComplete: function () { finishLevel(id); } + }); + } + + function finishLevel(id) { + if (currentLevelId !== id || levelFinishing) return; + levelFinishing = true; + + const firstTime = state.completed.indexOf(id) === -1; + if (firstTime) { + state.completed.push(id); + state.completed.sort(function (a, b) { return a - b; }); + state.unlocked = Math.max(state.unlocked, Math.min(Levels.count, id + 1)); + Storage.save(state); + UI.setState(state); + } + + const gameIndex = id - 1; + + if (state.revealed.indexOf(gameIndex) === -1) { + const markRevealed = function () { + if (state.revealed.indexOf(gameIndex) !== -1) return; + state.revealed.push(gameIndex); + state.revealed.sort(function (a, b) { return a - b; }); + Storage.save(state); + UI.setState(state); + }; + + UI.showReveal(gameIndex, function () { + markRevealed(); + destroyLevel(); + UI.renderMap(); + + if (state.completed.length === Levels.count && state.revealed.length === Levels.count) { + UI.showFinale(); + } else { + UI.showScreen("map"); + } + }, { onSteamOpen: markRevealed }); + return; + } + + destroyLevel(); + UI.renderMap(); + UI.toast(firstTime ? "Level voltooid!" : "Mooi opnieuw gedaan!"); + UI.showScreen("map"); + } + + function resetGame() { + state = Storage.reset(); + destroyLevel(); + UI.setState(state); + UI.renderMap(); + UI.showScreen("welcome"); + UI.toast("De tuin is opnieuw begonnen."); + } + + return { + init: init, + goToMap: goToMap, + goHome: goHome, + startLevel: startLevel, + resetGame: resetGame + }; +})(); + +App.init(); diff --git a/Dockers/inez-game/js/config.js b/Dockers/inez-game/js/config.js new file mode 100644 index 0000000..ca7abce --- /dev/null +++ b/Dockers/inez-game/js/config.js @@ -0,0 +1,92 @@ +const GAME_CONFIG = { + herName: "Inez", + yourName: "Bram", + + welcomeTitle: "Voor", + welcomeText: + "Jij denkt toch niet dat je je cadeautjes gewoon zomaar krijgt? Er is vanalles te doen in de tuin en jij moet daar eerst bij helpen. ", + + finaleTitle: "De tuin staat vol", + finalMessage: + "Lieve bbi, gefeliciteerd met je verjaardag! Het werk in de tuin is nu gedaan," + + "dus nu mag je lekker gaan genieten van je cadeautjes. ", + finaleSignature: "Liefs, Bram", + + // Vervang hieronder de zeven voorbeeldgames door de echte cadeaus. + // title: de naam die in het spel verschijnt + // appId: het Steam-appnummer (te vinden in de winkel-URL) + // url: alternatief als je geen appId hebt + // key: optioneel; de Steam-key die na het level wordt getoond + // note: een korte persoonlijke zin + // image: optioneel; laat leeg om de Steam-cover automatisch te gebruiken + // accent: kleur van de kaart als de cover niet laadt + // + // LET OP: een key staat leesbaar in dit bestand en in de pagina. + // Gebruik keys alleen in een lokale versie en zet dit bestand niet openbaar online. + // Steam geeft geen keys uit voor games die je rechtstreeks via Steam cadeau doet. + games: [ + { + title: "Locomoto", + appId: 2328650, + url: "", + key: "TGLWT-VGV5Z-QE2HN", + note: "Rij mee met je eigen gezellige trein, verzorg je passagiers en maak je wagonnetjes mooier met spulletjes die je onderweg vindt. Een zachte reis met lo-fi muziek op de achtergrond.", + image: "", + accent: "#f3c2a6" + }, + { + title: "Winter Burrow", + appId: 3008740, + url: "", + key: "2Q2EM-BE6G9-FTDPB", + note: "Een klein muisje keert terug naar het holletje van zijn jeugd om alles weer warm en knus te maken. Verzamel, brei, bak en maak van je holletje weer een echt thuis.", + image: "", + accent: "#b7d7e8" + }, + { + title: "Subnautica", + appId: 264710, + url: "", + key: "FC6J5-HAHNP-GYNW8", + note: "Duik in een kleurrijke oceaan vol koraal, vreemde vissen en verborgen plekjes. Bouw je eigen onderwaterhuis en laat je verrassen door wat er allemaal onder het wateroppervlak leeft.", + image: "", + accent: "#f2d59a" + }, + { + title: "Unpacking", + appId: 1135690, + url: "", + key: "XBRLT-GC2IQ-WIBZM", + note: "Pak dozen uit en geef elk spulletje een eigen plekje. Een rustig spel vol kleine herinneringen β€” alsof je zelf een nieuw hoofdstuk inricht.", + image: "", + accent: "#d8c2e8" + }, + { + title: "Coffee Talk Tokyo", + appId: 3161220, + url: "", + key: "ACKBW-2QLKE-HF3JJ", + note: "Schuif aan in een knus nachtcafΓ© in Tokio, zet de gekste drankjes in elkaar en luister naar de verhalen van je gasten. Mensen, yōkai en heel veel warme gesprekken.", + image: "", + accent: "#b9ddc4" + }, + { + title: "Tinykin", + appId: 1599020, + url: "", + key: "237T0-7W28Q-JDBRA", + note: "Verken een reusachtig huis door de ogen van een piepklein jongetje en verzamel honderden Tinykin. Met hun hulp vind je de weg terug naar huis β€” en naar je normale formaat.", + image: "", + accent: "#e8c7b8" + }, + { + title: "Critter Cove", + appId: 1631470, + url: "", + key: "Y3PX9-TGQ5I-36455", + note: "Bouw een kleurrijk kuststadje op, zeil naar eilandjes en haal nieuwe dierenvrienden in huis. Vang vis, verbouw groenten en maak van Critter Cove weer een plek waar iedereen wil blijven.", + image: "", + accent: "#c9dfb8" + } + ] +}; diff --git a/Dockers/inez-game/js/levels.js b/Dockers/inez-game/js/levels.js new file mode 100644 index 0000000..3a1577b --- /dev/null +++ b/Dockers/inez-game/js/levels.js @@ -0,0 +1,1058 @@ +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 += ''; + } + petals += ''; + } else if (type === "madeliefje") { + for (let i = 0; i < 7; i += 1) { + petals += ''; + } + } else { + petals = + '' + + '' + + ''; + } + + return ''; + } + + + 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 = "🌱Zaadje"; + seedBtn.setAttribute("aria-pressed", "true"); + const waterBtn = el("button", "tool-btn"); + waterBtn.type = "button"; + waterBtn.innerHTML = "πŸ’§Gieter"; + 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 = + '" + + ''; + + 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 = + '' + + '' + + '' + + '0'; + 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 = '"; + 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 = + '' + + '' + + '' + + '' + + '' + + '' + + "" + + ''; + + 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 = '"; + 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 = '"; + 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 = '' + def.name + ""; + 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 = '

Het voorbeeld

'; + 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 = ''; + holder.append(slotsRow, vase); + vasePanel.append(vaseLabel, holder); + + const gardenPanel = el("div", "garden-panel"); + gardenPanel.innerHTML = '

Pluk uit de tuin

'; + 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 = '' + flowerSVG(flower.type, flower.color) + ""; + 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 = + '' + + '' + + '" + + ""; + + 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 = + '' + + 'De mand'; + 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 = '"; + + 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 + }; +})(); diff --git a/Dockers/inez-game/js/storage.js b/Dockers/inez-game/js/storage.js new file mode 100644 index 0000000..ef45244 --- /dev/null +++ b/Dockers/inez-game/js/storage.js @@ -0,0 +1,117 @@ +const Storage = (function () { + const STATE_KEY = "inez-unlock-game-v1"; + const MUTE_KEY = "inez-unlock-muted"; + + function defaults() { + return { unlocked: 1, completed: [], revealed: [] }; + } + + function levelCount() { + if (typeof Levels !== "undefined" && Levels.count) return Levels.count; + return GAME_CONFIG.games ? GAME_CONFIG.games.length : 5; + } + + function safeGet(key) { + try { + return window.localStorage.getItem(key); + } catch (error) { + return null; + } + } + + function safeSet(key, value) { + try { + window.localStorage.setItem(key, value); + } catch (error) { + } + } + + function load() { + const raw = safeGet(STATE_KEY); + if (!raw) return defaults(); + + try { + const parsed = JSON.parse(raw); + return sanitize(parsed); + } catch (error) { + return defaults(); + } + } + + function sanitize(value) { + const state = defaults(); + const total = levelCount(); + if (!value || typeof value !== "object") return state; + + const unlocked = Number(value.unlocked); + if (Number.isFinite(unlocked)) { + state.unlocked = Math.min(total, Math.max(1, Math.floor(unlocked))); + } + + if (Array.isArray(value.completed)) { + state.completed = uniqueLevels(value.completed); + } + if (Array.isArray(value.revealed)) { + state.revealed = uniqueGames(value.revealed); + } + + const highest = state.completed.length > 0 ? state.completed[state.completed.length - 1] : 0; + state.unlocked = Math.max(state.unlocked, Math.min(total, highest + 1)); + if (state.completed.length === total) state.unlocked = total; + return state; + } + + function uniqueLevels(values) { + const seen = new Set(); + const result = []; + const total = levelCount(); + values.forEach(function (value) { + const number = Math.floor(Number(value)); + if (number >= 1 && number <= total && !seen.has(number)) { + seen.add(number); + result.push(number); + } + }); + return result.sort(function (a, b) { return a - b; }); + } + + function uniqueGames(values) { + const seen = new Set(); + const result = []; + values.forEach(function (value) { + const number = Math.floor(Number(value)); + const count = GAME_CONFIG.games ? GAME_CONFIG.games.length : 5; + if (number >= 0 && number < count && !seen.has(number)) { + seen.add(number); + result.push(number); + } + }); + return result.sort(function (a, b) { return a - b; }); + } + + function save(state) { + safeSet(STATE_KEY, JSON.stringify(state)); + } + + function reset() { + const state = defaults(); + save(state); + return state; + } + + function loadMuted() { + return safeGet(MUTE_KEY) === "1"; + } + + function saveMuted(muted) { + safeSet(MUTE_KEY, muted ? "1" : "0"); + } + + return { + load: load, + save: save, + reset: reset, + loadMuted: loadMuted, + saveMuted: saveMuted + }; +})(); diff --git a/Dockers/inez-game/js/ui.js b/Dockers/inez-game/js/ui.js new file mode 100644 index 0000000..6925d06 --- /dev/null +++ b/Dockers/inez-game/js/ui.js @@ -0,0 +1,575 @@ +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; } + }; +})();