diff --git a/.gitignore b/.gitignore index ad7e950..17656f6 100755 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .venv/ output/ .org-timestamps/ -backup/ +backups/ __pycache__/ *~ .syncthing*.tmp diff --git a/Makefile b/Makefile index d071fa7..44e01dc 100755 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean norm test author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help +.PHONY: all clean norm test test-elisp author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help VENV := .venv PY := $(VENV)/bin/python @@ -25,6 +25,9 @@ search: $(VENV) test: $(MAKE) -C "$(AUTHOR_SERVICE_DIR)" test +test-elisp: + emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit + $(VENV): @echo "Generating virtual environment" python3 -m venv $(VENV) @@ -80,6 +83,7 @@ help: @echo " make clean-venv - Remove venv directory" @echo " make norm - Move backup files" @echo " make test - Run authoring server tests" + @echo " make test-elisp - Run Emacs Lisp build tests" @echo " make author - Install and start the authoring UI service" @echo " make author-install - Install and enable the authoring UI service" @echo " make author-stop - Stop the authoring UI service" diff --git a/README.md b/README.md index 5b4e04b..972e623 100755 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ This repository contains code for the website https://zainezq.com, which serves The site is written using Emacs and Org-mode, and exported using the `org-publish` functionality. See the `build-site.el` script for details on how the site is built and published. +Asset structure and frontend boundaries are documented in `docs/assets-architecture.md`. + ## Local authoring UI The authoring UI now lives in `/home/zaine/master-folder/org-platform/authoring-service`. diff --git a/assets/New Document.rnote b/assets/New Document.rnote deleted file mode 100755 index fcb8fc0..0000000 Binary files a/assets/New Document.rnote and /dev/null differ diff --git a/assets/avatars/aphy.jpeg b/assets/avatars/aphy.jpeg deleted file mode 100644 index e1da5d4..0000000 Binary files a/assets/avatars/aphy.jpeg and /dev/null differ diff --git a/assets/avatars/future-z.jpeg b/assets/avatars/future-z.jpeg deleted file mode 100644 index 5511e9c..0000000 Binary files a/assets/avatars/future-z.jpeg and /dev/null differ diff --git a/assets/avatars/lima.jpg b/assets/avatars/lima.jpg deleted file mode 100644 index a41ee8d..0000000 Binary files a/assets/avatars/lima.jpg and /dev/null differ diff --git a/assets/avatars/sensei-chi.jpeg b/assets/avatars/sensei-chi.jpeg deleted file mode 100644 index e1132e2..0000000 Binary files a/assets/avatars/sensei-chi.jpeg and /dev/null differ diff --git a/assets/avatars/young-z.jpeg b/assets/avatars/young-z.jpeg deleted file mode 100644 index bf11e49..0000000 Binary files a/assets/avatars/young-z.jpeg and /dev/null differ diff --git a/assets/avatars/z.jpeg b/assets/avatars/z.jpeg deleted file mode 100644 index ea4b0eb..0000000 Binary files a/assets/avatars/z.jpeg and /dev/null differ diff --git a/assets/emacs-calibre.rnote b/assets/emacs-calibre.rnote deleted file mode 100755 index 5a66610..0000000 Binary files a/assets/emacs-calibre.rnote and /dev/null differ diff --git a/assets/scripts/application/theme-switcher.js b/assets/scripts/application/theme-switcher.js new file mode 100644 index 0000000..71979f3 --- /dev/null +++ b/assets/scripts/application/theme-switcher.js @@ -0,0 +1,32 @@ +/* Function for setting the theme */ +(function(){ + const root = document.documentElement; + const storageKey = "theme"; + const themes = ["light", "dark", "dark-academia"]; + const labels = { + "light": "Light", + "dark": "Dark", + "dark-academia": "Academia" + }; + const saved = localStorage.getItem(storageKey); + if (themes.includes(saved)) { + root.setAttribute("data-theme", saved); + } + const btn = document.getElementById("theme-toggle"); + if (!btn) return; + const updateButton = () => { + const current = root.getAttribute("data-theme"); + const label = labels[current] || "Auto"; + btn.textContent = `Theme: ${label}`; + btn.setAttribute("aria-label", `Current theme: ${label}. Switch theme.`); + }; + updateButton(); + btn.addEventListener("click", () => { + const current = root.getAttribute("data-theme"); + const index = themes.indexOf(current); + const target = themes[index === -1 ? 1 : (index + 1) % themes.length]; + root.setAttribute("data-theme", target); + localStorage.setItem(storageKey, target); + updateButton(); + }); +})(); diff --git a/assets/scripts/ash-below-lake.js b/assets/scripts/ash-below-lake.js index 5aa74cd..cd3483c 100644 --- a/assets/scripts/ash-below-lake.js +++ b/assets/scripts/ash-below-lake.js @@ -1,1008 +1,7 @@ (function () { - const slot = "ash-below-the-lake"; - const fallbackKey = "play:rpg:ash-below-the-lake"; - const tile = 32; - const cols = 30; - const rows = 15; - const worldH = tile * rows; - const uiY = worldH; - const actions = ["Confront", "Listen", "Forget"]; - const areas = { - harbor: { - name: "Harbor Row", - act: "Act 1", - track: "harbor", - floor: ["#222c36", "#263541", "#304650"], - accent: "#e0a451", - walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 15], [29, 0, 1, 15], [3, 3, 5, 2], [12, 2, 4, 3], [20, 3, 6, 2], [5, 11, 7, 1], [17, 10, 5, 1]]), - exits: [{ x: 28, y: 7, to: "red", px: 1, py: 7 }], - props: [{ kind: "lake", x: 1, y: 11, w: 28, h: 3 }, { kind: "stall", x: 9, y: 5 }, { kind: "flowers", x: 23, y: 9 }], - start: { x: 3, y: 8 } - }, - red: { - name: "The Red Streets", - act: "Act 1", - track: "red", - floor: ["#2f1f2c", "#442237", "#5b283f"], - accent: "#db4f62", - walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 6], [0, 9, 1, 6], [29, 0, 1, 15], [4, 3, 4, 3], [12, 4, 3, 8], [20, 2, 5, 3], [22, 9, 4, 2]]), - exits: [{ x: 0, y: 7, to: "harbor", px: 28, py: 7 }, { x: 28, y: 12, to: "roots", px: 1, py: 12 }], - props: [{ kind: "sign", x: 9, y: 3 }, { kind: "stage", x: 17, y: 6 }, { kind: "poster", x: 26, y: 4 }], - start: { x: 2, y: 7 } - }, - roots: { - name: "The Roots", - act: "Act 2", - track: "roots", - floor: ["#182725", "#203832", "#2e493e"], - accent: "#66c7a2", - walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 11], [0, 13, 1, 2], [29, 0, 1, 15], [5, 2, 2, 10], [11, 5, 7, 1], [15, 8, 2, 5], [22, 3, 3, 9]]), - exits: [{ x: 0, y: 12, to: "red", px: 28, py: 12 }, { x: 28, y: 4, to: "chapel", px: 1, py: 4 }], - props: [{ kind: "archive", x: 8, y: 8 }, { kind: "root", x: 18, y: 3 }, { kind: "home", x: 25, y: 11 }], - start: { x: 2, y: 12 } - }, - chapel: { - name: "The Drowned Chapel", - act: "Act 3", - track: "chapel", - floor: ["#172232", "#1c3043", "#263b4f"], - accent: "#8bd3ff", - walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 3], [0, 6, 1, 9], [29, 0, 1, 15], [6, 4, 3, 7], [13, 2, 4, 3], [19, 4, 3, 7], [10, 12, 10, 1]]), - exits: [{ x: 0, y: 4, to: "roots", px: 28, py: 4 }, { x: 28, y: 10, to: "shore", px: 1, py: 10 }], - props: [{ kind: "pew", x: 10, y: 7 }, { kind: "pew", x: 16, y: 7 }, { kind: "glass", x: 14, y: 5 }], - start: { x: 2, y: 4 } - }, - shore: { - name: "The Hollow Shore", - act: "Final Act", - track: "shore", - floor: ["#111827", "#1b2440", "#211c33"], - accent: "#f1f5f9", - walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 9], [0, 12, 1, 3], [29, 0, 1, 15], [7, 3, 3, 2], [16, 2, 2, 10], [22, 7, 4, 1]]), - exits: [{ x: 0, y: 10, to: "chapel", px: 28, py: 10 }], - props: [{ kind: "lake", x: 2, y: 10, w: 27, h: 4 }, { kind: "flowers", x: 5, y: 6 }, { kind: "rift", x: 24, y: 4 }], - start: { x: 2, y: 10 } - } - }; - const characters = { - ilyas: { name: "Ilyas", colors: ["#3d2b2f", "#e3b27f", "#334b7a", "#f2f0e6"] }, - mira: { name: "Mira", colors: ["#21191e", "#d98d70", "#b73550", "#f6d36f"] }, - ferryman: { name: "Ferryman", colors: ["#151719", "#dad9cd", "#56616d", "#0b0d10"] }, - cinder: { name: "Brother Cinder", colors: ["#1d1512", "#d59667", "#6b2e2f", "#f4c35c"] }, - sibling: { name: "Your Sibling", colors: ["#1d2531", "#bec8d8", "#52616f", "#dbeafe"] }, - vendor: { name: "Maribel", colors: ["#2a2023", "#d5a06f", "#3d7b65", "#ffd166"] }, - performer: { name: "Velvet Noon", colors: ["#2a1724", "#f2bd93", "#8f244d", "#ffe6a8"] }, - archivist: { name: "Memory Archivist", colors: ["#151f1d", "#b9ddc9", "#386b5c", "#dff7eb"] } - }; - const npcs = { - mira: { character: "mira", area: "harbor", x: 10, y: 8 }, - ferryman: { character: "ferryman", area: "harbor", x: 25, y: 10 }, - vendor: { character: "vendor", area: "harbor", x: 9, y: 6 }, - performer: { character: "performer", area: "red", x: 17, y: 8 }, - archivist: { character: "archivist", area: "roots", x: 9, y: 9 }, - cinder: { character: "cinder", area: "chapel", x: 15, y: 6 }, - sibling: { character: "sibling", area: "shore", x: 24, y: 8 } - }; - const items = { - letter: { name: "Wet Letter", area: "harbor", x: 5, y: 10 }, - bell: { name: "Brass Bell", area: "red", x: 27, y: 5 }, - photo: { name: "Blurred Photo", area: "roots", x: 13, y: 9 }, - cinderKey: { name: "Chapel Key", area: "chapel", x: 22, y: 12 } - }; - const encounters = { - first_erased: { - title: "First Erased Person", - area: "red", - x: 6, - y: 9, - hp: 18, - text: "A person-shaped absence blocks the alley. Their outline mouths a name no one else can hear.", - bullets: "drift" - }, - root_husk: { - title: "Root Husk", - area: "roots", - x: 21, - y: 9, - hp: 24, - text: "A house nobody remembers learned to walk. It wants its rooms back.", - bullets: "rain" - }, - chapel_memory: { - title: "Choir of Ash", - area: "chapel", - x: 11, - y: 10, - hp: 30, - text: "Voices rise from the drowned pews. Every note sounds like an apology.", - bullets: "spiral" - } - }; - - window.AshBelowLakeRpg = function init(root) { - const canvas = root.querySelector("[data-rpg-canvas]"); - const ctx = canvas.getContext("2d"); - const els = { - speaker: root.querySelector("[data-rpg-speaker]"), - line: root.querySelector("[data-rpg-line]"), - area: root.querySelector("[data-rpg-room]"), - route: root.querySelector("[data-rpg-route]"), - resolve: root.querySelector("[data-rpg-hearts]"), - journal: root.querySelector("[data-rpg-quests]"), - inventory: root.querySelector("[data-rpg-inventory]"), - status: root.querySelector("[data-rpg-status]") - }; - const ui = buildUi(root); - const audio = makeAudio(); - let state = freshState(); - let keys = new Set(); - let last = performance.now(); - let saveBusy = false; - let backendAvailable = true; - - bind(); - loadLocal(); - requestAnimationFrame(frame); - - function freshState() { - return { - started: false, - mode: "title", - area: "harbor", - player: { x: 3, y: 8, facing: "down", step: 0 }, - route: "Undecided", - resolve: 20, - toldName: null, - flags: {}, - inventory: [], - memories: [ - { id: "letter", text: "Your sibling's letter says: Don't let the lake remember your name.", stable: true }, - { id: "town", text: "Morrow's End smiles like a town in a postcard someone tried to burn.", stable: true } - ], - forgets: 0, - listens: 0, - confronts: 0, - companion: false, - battle: null, - ending: null, - dialogue: { speaker: "The Letter", line: "Don't let the lake remember your name." } - }; - } - - function buildUi(rootEl) { - const panel = rootEl.querySelector(".rpg-panel"); - const actionsWrap = document.createElement("div"); - actionsWrap.className = "ash-choicebar"; - actionsWrap.innerHTML = actions.map((name) => ``).join(""); - panel.insertBefore(actionsWrap, panel.querySelector(".rpg-list")); - const meter = document.createElement("div"); - meter.className = "ash-memory-meter"; - meter.innerHTML = "Lake Memory"; - panel.insertBefore(meter, actionsWrap); - const nameBox = document.createElement("form"); - nameBox.className = "ash-name-form"; - nameBox.innerHTML = ""; - panel.insertBefore(nameBox, meter); - return { - actionButtons: Array.from(actionsWrap.querySelectorAll("button")), - memoryFill: meter.querySelector("[data-ash-memory-fill]"), - nameForm: nameBox, - nameInput: nameBox.querySelector("[data-ash-name]") - }; - } - - function bind() { - root.querySelector("[data-rpg-start]").addEventListener("click", start); - root.querySelector("[data-rpg-save]").addEventListener("click", save); - root.querySelector("[data-rpg-load]").addEventListener("click", () => load(true)); - root.querySelector("[data-rpg-reset]").addEventListener("click", newGame); - root.querySelector("[data-rpg-sound]").addEventListener("click", () => { - audio.start(); - audio.setTrack(state.started ? areas[state.area].track : "title"); - els.status.textContent = audio.supported() ? "Sound on." : "This browser does not expose Web Audio."; - }); - root.querySelector("[data-rpg-act]").addEventListener("click", act); - root.querySelectorAll("[data-rpg-move]").forEach((button) => { - button.addEventListener("click", () => move(button.dataset.rpgMove)); - }); - ui.actionButtons.forEach((button) => button.addEventListener("click", () => chooseBattle(button.dataset.ashAction))); - ui.nameForm.addEventListener("submit", (event) => { - event.preventDefault(); - tellName(ui.nameInput.value.trim() || "Ilyas"); - }); - window.addEventListener("keydown", (event) => { - if (!root.isConnected || ["INPUT", "TEXTAREA"].includes(event.target.tagName)) return; - if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "w", "a", "s", "d", " ", "Enter", "Escape"].includes(event.key)) event.preventDefault(); - keys.add(event.key.toLowerCase()); - audio.start(); - if (event.key === " " || event.key === "Enter") act(); - if (event.key === "Escape" && state.mode === "battle") leaveBattle("You step back from the memory and let it breathe."); - }); - window.addEventListener("keyup", (event) => keys.delete(event.key.toLowerCase())); - } - - function start() { - audio.start(); - state.started = true; - state.mode = "world"; - state.dialogue = { speaker: "Ilyas", line: "The letter is damp, but the ink has not run. Morrow's End waits beyond the harbor lamps." }; - audio.setTrack(areas[state.area].track); - save(); - } - - async function newGame() { - state = freshState(); - localStorage.removeItem(fallbackKey); - if (backendAvailable) { - try { - const response = await fetch(`/api/play/rpg/save/${slot}`, { method: "DELETE" }); - if (response.status === 404) els.status.textContent = "New game started locally. There was no backend save to clear."; - } catch (_error) { - backendAvailable = false; - } - } - audio.setTrack("title"); - if (!els.status.textContent.includes("backend save to clear")) { - els.status.textContent = backendAvailable ? "New game started and local save cleared." : "New game started locally. Backend save endpoint is not reachable."; - } - renderHud(); - } - - async function save() { - localStorage.setItem(fallbackKey, JSON.stringify(state)); - if (saveBusy || !backendAvailable) { - els.status.textContent = backendAvailable ? "Save already running." : "Saved locally. Backend save endpoint is not available."; - return; - } - saveBusy = true; - try { - const response = await fetch(`/api/play/rpg/save/${slot}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ payload: state }) - }); - if (response.status === 404) { - backendAvailable = false; - els.status.textContent = "Saved locally. Backend save endpoint is not deployed yet."; - } else { - els.status.textContent = response.ok ? "Saved to backend." : "Saved locally. Backend rejected the save."; - } - } catch (_error) { - backendAvailable = false; - els.status.textContent = "Saved locally. Backend save API was not reachable."; - } finally { - saveBusy = false; - } - } - - async function load(allowBackend) { - if (loadLocal("Loaded local save.")) return; - if (!allowBackend || !backendAvailable) { - els.status.textContent = backendAvailable ? "No local save found." : "No local save found. Backend save endpoint is not available."; - return; - } - try { - const response = await fetch(`/api/play/rpg/save/${slot}`); - if (response.status === 404) { - els.status.textContent = "No save file exists yet. Press Save after starting a game to create one."; - return; - } - if (!response.ok) { - els.status.textContent = "No local save found. Backend did not return a save."; - return; - } - const data = await response.json(); - state = normalize(data.payload); - localStorage.setItem(fallbackKey, JSON.stringify(state)); - els.status.textContent = "Loaded from backend."; - audio.setTrack(state.started ? areas[state.area].track : "title"); - } catch (_error) { - backendAvailable = false; - els.status.textContent = "No local save found. Backend save API was not reachable."; - } - } - - function loadLocal(message) { - const local = localStorage.getItem(fallbackKey); - if (local) { - state = normalize(JSON.parse(local)); - if (message) els.status.textContent = message; - audio.setTrack(state.started ? areas[state.area].track : "title"); - return true; - } - audio.setTrack(state.started ? areas[state.area].track : "title"); - return false; - } - - function normalize(candidate) { - const base = freshState(); - const next = Object.assign(base, candidate || {}); - next.player = Object.assign(base.player, (candidate && candidate.player) || {}); - next.flags = Object.assign({}, (candidate && candidate.flags) || {}); - next.inventory = Array.isArray(next.inventory) ? next.inventory.filter((id) => items[id]) : []; - next.memories = Array.isArray(next.memories) ? next.memories : base.memories; - next.area = areas[next.area] ? next.area : "harbor"; - next.dialogue = Object.assign(base.dialogue, (candidate && candidate.dialogue) || {}); - return next; - } - - function frame(now) { - const dt = Math.min(48, now - last); - last = now; - update(dt); - draw(now); - renderHud(); - requestAnimationFrame(frame); - } - - function update(dt) { - if (state.mode !== "world") return; - const dir = keyDirection(); - if (!dir) return; - state.player.step += dt; - if (state.player.step < 115) return; - state.player.step = 0; - move(dir); - } - - function keyDirection() { - if (keys.has("arrowup") || keys.has("w")) return "up"; - if (keys.has("arrowdown") || keys.has("s")) return "down"; - if (keys.has("arrowleft") || keys.has("a")) return "left"; - if (keys.has("arrowright") || keys.has("d")) return "right"; - return null; - } - - function move(direction) { - if (state.mode !== "world" || !state.started || state.ending) return; - const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[direction]; - state.player.facing = direction; - const nx = state.player.x + delta[0]; - const ny = state.player.y + delta[1]; - const area = areas[state.area]; - const exit = area.exits.find((item) => item.x === nx && item.y === ny); - if (exit && canEnter(exit.to)) { - state.area = exit.to; - state.player.x = exit.px; - state.player.y = exit.py; - audio.setTrack(areas[state.area].track); - remember(`Entered ${areas[state.area].name}.`, true); - say("Morrow's End", areaEnterLine(state.area)); - save(); - return; - } - if (area.walls.has(`${nx},${ny}`) || npcAt(nx, ny) || nx < 0 || nx >= cols || ny < 0 || ny >= rows) return; - state.player.x = nx; - state.player.y = ny; - const found = itemAt(nx, ny); - if (found) take(found); - const encounter = encounterAt(nx, ny); - if (encounter && !state.flags[`encounter_${encounter}`]) startBattle(encounter); - } - - function canEnter(to) { - if (to === "chapel" && !state.flags.rootsTruth && state.forgets < 2) { - say("Ferryman", "The chapel is under the lake. It only opens for truth, or for forgetting."); - return false; - } - if (to === "shore" && !state.flags.cinderMet) { - say("Brother Cinder", "The shore is not a place. It is a decision. Hear me first."); - return false; - } - return true; - } - - function act() { - audio.start(); - if (!state.started) return start(); - if (state.mode === "battle") return chooseBattle("listen"); - if (state.ending) return; - const front = inFront(); - const npc = npcAt(front.x, front.y) || npcAt(state.player.x, state.player.y); - if (npc) return talk(npc); - const found = itemAt(state.player.x, state.player.y); - if (found) return take(found); - const special = propAt(front.x, front.y) || propAt(state.player.x, state.player.y); - if (special) return inspectProp(special); - if (state.area === "shore" && state.player.x > 22 && state.player.y > 8) return finalChoice(); - say("Ilyas", "The town holds its breath. Somewhere, water knocks against stone."); - } - - function inFront() { - const d = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[state.player.facing] || [0, 1]; - return { x: state.player.x + d[0], y: state.player.y + d[1] }; - } - - function npcAt(x, y) { - return Object.keys(npcs).find((id) => { - const npc = npcs[id]; - return npc.area === state.area && npc.x === x && npc.y === y && !forgottenNpc(id); - }); - } - - function itemAt(x, y) { - return Object.keys(items).find((id) => { - const item = items[id]; - return item.area === state.area && item.x === x && item.y === y && !state.inventory.includes(id); - }); - } - - function encounterAt(x, y) { - return Object.keys(encounters).find((id) => { - const e = encounters[id]; - return e.area === state.area && e.x === x && e.y === y; - }); - } - - function propAt(x, y) { - return areas[state.area].props.find((prop) => x >= prop.x && x < prop.x + (prop.w || 1) && y >= prop.y && y < prop.y + (prop.h || 1)); - } - - function take(id) { - state.inventory.push(id); - audio.sfx("item"); - if (id === "letter") remember("The wet letter refuses to dry.", true); - if (id === "photo") remember("A photo shows you beside someone whose face keeps changing.", true); - if (id === "cinderKey") state.flags.chapelKey = true; - say("Found", `${items[id].name} added to your bag.`); - save(); - } - - function talk(id) { - audio.sfx("talk"); - if (id === "mira") { - state.companion = true; - state.flags.metMira = true; - remember("Mira remembers what other people lose.", true); - say("Mira", state.forgets > 1 ? "You keep looking through people like windows. Don't do that to me, okay?" : "You look lost. Good. Everyone interesting does."); - } - if (id === "ferryman") { - remember("The Ferryman knows where you have been before you arrive.", state.forgets < 2); - say("Ferryman", state.toldName ? `${state.toldName} is a fine name to keep away from water.` : "Names are hooks. I would not throw yours into the lake."); - } - if (id === "vendor") say("Maribel", "Today's special: one jar of fog, two apologies, and a fish that predicts Thursday badly."); - if (id === "performer") { - remember("A performer advertised a show called YOUR MISSING YEARS.", state.forgets < 3); - say("Velvet Noon", "Clap if you remember me. Clap twice if you are pretending."); - } - if (id === "archivist") { - state.flags.rootsTruth = true; - remember("The lake feeds on suffering, but calls it mercy.", true); - say("Memory Archivist", "The archives are homes with the people removed. The lake calls that tidying."); - } - if (id === "cinder") { - state.flags.cinderMet = true; - remember("Brother Cinder believes forgetting pain is mercy.", true); - say("Brother Cinder", "I have seen grief make animals of gentle people. The lake did not begin as a hunger. It began as a kindness."); - } - if (id === "sibling") { - remember("Your sibling entered the lake willingly.", true); - say("Your Sibling", "I gave it my name because I was tired of carrying it. Please don't give it yours."); - finalChoice(); - } - save(); - } - - function inspectProp(prop) { - audio.sfx("talk"); - if (prop.kind === "lake") say("Black Lake", state.toldName ? `It almost says ${state.toldName}. Almost.` : "It reflects nothing. Not clouds. Not you."); - if (prop.kind === "stall") say("Market Sign", "Fresh eels. Older than regret. Discount if they remember your birthday."); - if (prop.kind === "flowers") say("Glowing Flowers", "They lean toward the water like they are listening."); - if (prop.kind === "sign") say("Advertisement", "TONIGHT: The Amazing Vanishing Audience. Refunds unavailable after erasure."); - if (prop.kind === "stage") say("Stage", "The curtain twitches though there is no wind indoors."); - if (prop.kind === "poster") say("Poster", "A smiling face has been scratched out. The scratch is smiling too."); - if (prop.kind === "archive") { - state.flags.rootsTruth = true; - remember("Some memory shelves are empty because nobody dared open them.", true); - say("Memory Archive", "A drawer opens by itself. Inside: houses, birthdays, funeral songs, a ticket stub from a train that never arrived."); - } - if (prop.kind === "root") say("Roots", "They pulse slowly under the town, carrying lake-water like blood."); - if (prop.kind === "home") say("Abandoned Home", "Every room is furnished except for the idea of a family."); - if (prop.kind === "pew") say("Drowned Pew", "The wood is wet. The prayer book is dry."); - if (prop.kind === "glass") say("Sunken Window", "Saints in blue glass lower their eyes toward the lake."); - if (prop.kind === "rift") finalChoice(); - save(); - } - - function tellName(name) { - state.toldName = name.slice(0, 18); - remember(`You told Morrow's End your name is ${state.toldName}.`, false); - say("Morrow's End", `The town repeats ${state.toldName} very softly. Too softly.`); - state.route = state.route === "Undecided" ? "Named" : state.route; - save(); - } - - function startBattle(id) { - const e = encounters[id]; - state.mode = "battle"; - state.battle = { - id, - hp: e.hp, - maxHp: e.hp, - soul: { x: 480, y: 382 }, - bullets: [], - t: 0, - grace: 0, - turn: 1, - message: e.text - }; - audio.setTrack("battle"); - audio.sfx("battle"); - say(e.title, e.text); - save(); - } - - function chooseBattle(action) { - if (state.mode !== "battle" || !state.battle) return; - const battle = state.battle; - if (action === "confront") { - battle.hp -= 9; - state.confronts += 1; - state.route = "Confronting"; - battle.message = "You refuse to move. The memory recoils from your shape."; - audio.sfx("hit"); - } - if (action === "listen") { - battle.hp -= 7; - state.listens += 1; - state.route = state.forgets === 0 ? "Carry" : "Listening"; - battle.message = "You listen until the ache becomes words. The attack softens."; - remember(`Listened to ${encounters[battle.id].title}.`, true); - audio.sfx("listen"); - } - if (action === "forget") { - battle.hp -= 18; - state.forgets += 1; - state.route = state.forgets >= 3 ? "Forgotten" : "Silence"; - battle.message = "You let the lake take the hard part. Something else goes with it."; - rewriteMemory(); - audio.sfx("forget"); - } - battle.turn += 1; - if (battle.hp <= 0) resolveBattle(action); - else say(encounters[battle.id].title, battle.message); - save(); - } - - function resolveBattle(action) { - const id = state.battle.id; - state.flags[`encounter_${id}`] = action; - if (id === "root_husk") state.flags.rootsTruth = true; - if (action === "forget" && state.forgets >= 3) return end("forgotten"); - const line = action === "listen" - ? "The memory steps aside, still hurting, but no longer alone." - : action === "forget" - ? "The memory becomes easy to pass. Behind you, a street sign loses a letter." - : "The memory breaks apart like ash under rain."; - leaveBattle(line); - } - - function leaveBattle(line) { - state.mode = "world"; - state.battle = null; - audio.setTrack(areas[state.area].track); - say("Afterimage", line); - save(); - } - - function finalChoice() { - if (state.forgets >= 3) return end("forgotten"); - if (state.forgets === 0 && state.listens >= 3 && state.flags.metMira) return end("rest"); - if (state.route === "Silence" || state.forgets > state.listens) return end("silence"); - end("carry"); - } - - function end(kind) { - state.ending = kind; - state.mode = "ending"; - state.route = kind === "rest" ? "True: Rest" : kind === "carry" ? "Carry" : kind === "silence" ? "Silence" : "Forgotten"; - audio.setTrack(kind === "forgotten" ? "void" : "ending"); - say("Ending", endingLine(kind)); - remember(endingLine(kind), kind !== "forgotten"); - save(); - } - - function endingLine(kind) { - if (kind === "silence") return "Silence: you join the lake. Morrow's End becomes peaceful, clean, and almost empty."; - if (kind === "carry") return "Carry: the lake breaks. Everyone remembers. The town survives with tears in every lit window."; - if (kind === "forgotten") return "Forgotten: the story continues politely around the space where Ilyas used to be."; - return "Rest: the lake is forgiven, not destroyed. Mira laughs once, real and afraid, as the shore finally reflects the sky."; - } - - function rewriteMemory() { - const mutable = state.memories.find((memory) => !memory.stable && !memory.erased) || state.memories.find((memory) => !memory.erased); - if (mutable) { - mutable.text = state.forgets > 2 ? "There was someone here. There was no one here. Both feel true." : mutable.text.replace(/[aeiou]/gi, "_"); - mutable.erased = state.forgets > 2; - } - if (state.forgets === 2) state.flags.vendorForgotten = true; - if (state.forgets >= 3) state.flags.performerForgotten = true; - } - - function forgottenNpc(id) { - return (id === "vendor" && state.flags.vendorForgotten) || (id === "performer" && state.flags.performerForgotten); - } - - function remember(text, stable) { - if (!state.memories.some((memory) => memory.text === text)) state.memories.push({ id: `m${Date.now()}`, text, stable: Boolean(stable) }); - if (state.memories.length > 8) state.memories.splice(2, 1); - } - - function say(speaker, line) { - state.dialogue = { speaker, line }; - } - - function areaEnterLine(area) { - if (area === "red") return "The Red Streets glow warmly, but the laughter arrives half a second late."; - if (area === "roots") return "The Roots smell like wet stone and bedrooms left untouched for years."; - if (area === "chapel") return "The Drowned Chapel rings with a bell under water."; - if (area === "shore") return "The Hollow Shore bends at the edges, as if the world has been folded too many times."; - return "Harbor Row keeps its lamps low and its names lower."; - } - - function draw(now) { - ctx.imageSmoothingEnabled = false; - if (!state.started || state.mode === "title") drawTitle(now); - else drawWorld(now); - if (state.mode === "battle") drawBattle(now); - if (state.mode === "ending") drawEnding(now); - drawVignette(now); - } - - function drawTitle(now) { - const g = ctx.createLinearGradient(0, 0, 0, canvas.height); - g.addColorStop(0, "#05070c"); - g.addColorStop(0.55, "#101b28"); - g.addColorStop(1, "#020306"); - ctx.fillStyle = g; - ctx.fillRect(0, 0, canvas.width, canvas.height); - drawLake(0, 345, 960, 195, now, 0.4); - ctx.fillStyle = "#f8efe0"; - ctx.font = "700 50px Georgia, serif"; - ctx.textAlign = "center"; - ctx.fillText("Ash Below the Lake", 480, 142); - ctx.font = "18px Georgia, serif"; - ctx.fillStyle = "#b9c6d4"; - ctx.fillText("A quiet town. A black lake. A name you should not give away.", 480, 178); - ctx.fillStyle = "#e0a451"; - ctx.font = "700 18px ui-monospace, monospace"; - ctx.fillText("Press Start or Space", 480, 246); - drawSprite("ilyas", 424, 288, 3, now); - drawSprite("mira", 500, 288, 3, now); - } - - function drawWorld(now) { - const area = areas[state.area]; - for (let y = 0; y < rows; y += 1) { - for (let x = 0; x < cols; x += 1) { - const wall = area.walls.has(`${x},${y}`); - const c = area.floor[(x + y) % area.floor.length]; - ctx.fillStyle = wall ? "#0b0d10" : c; - ctx.fillRect(x * tile, y * tile, tile, tile); - if (!wall) { - ctx.fillStyle = (x + y) % 2 === 0 ? "rgba(255,255,255,0.018)" : "rgba(0,0,0,0.035)"; - ctx.fillRect(x * tile, y * tile + 30, tile, 2); - } - } - } - area.exits.forEach((exit) => drawDoor(exit.x, exit.y, area.accent)); - area.props.forEach((prop) => drawProp(prop, now)); - Object.keys(items).forEach((id) => { - const item = items[id]; - if (item.area === state.area && !state.inventory.includes(id)) drawItem(id, item, now); - }); - Object.keys(encounters).forEach((id) => { - const e = encounters[id]; - if (e.area === state.area && !state.flags[`encounter_${id}`]) drawEncounter(e, now); - }); - Object.keys(npcs).forEach((id) => { - const npc = npcs[id]; - if (npc.area === state.area && !forgottenNpc(id)) drawSprite(npc.character, npc.x * tile, npc.y * tile - 12, 2, now); - }); - drawSprite("ilyas", state.player.x * tile, state.player.y * tile - 12, 2, now); - if (state.companion && state.area !== "chapel") { - const offset = state.player.facing === "left" ? 1 : -1; - drawSprite("mira", (state.player.x + offset) * tile, state.player.y * tile - 12, 2, now + 200); - } - drawTopBar(area); - } - - function drawTopBar(area) { - ctx.fillStyle = "rgba(3, 6, 10, 0.82)"; - ctx.fillRect(0, 0, canvas.width, 42); - ctx.fillStyle = area.accent; - ctx.font = "700 14px ui-monospace, monospace"; - ctx.textAlign = "left"; - ctx.fillText(`${area.act} / ${area.name}`, 18, 26); - ctx.textAlign = "right"; - ctx.fillStyle = "#f8efe0"; - ctx.fillText(`Resolve ${state.resolve} Lake ${state.forgets}`, 940, 26); - } - - function drawBattle(now) { - const battle = state.battle; - battle.t += 1; - updateBullets(battle, now); - ctx.fillStyle = "rgba(4, 5, 8, 0.88)"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.strokeStyle = "#f8efe0"; - ctx.lineWidth = 3; - ctx.strokeRect(296, 252, 368, 190); - ctx.fillStyle = "#f8efe0"; - ctx.font = "700 24px Georgia, serif"; - ctx.textAlign = "center"; - ctx.fillText(encounters[battle.id].title, 480, 82); - ctx.font = "16px ui-monospace, monospace"; - ctx.fillText(`Memory ${Math.max(0, battle.hp)} / ${battle.maxHp}`, 480, 116); - drawBattleEnemy(battle.id, now); - ctx.fillStyle = "#f35f5f"; - ctx.beginPath(); - ctx.moveTo(battle.soul.x, battle.soul.y - 8); - ctx.bezierCurveTo(battle.soul.x - 13, battle.soul.y - 17, battle.soul.x - 20, battle.soul.y + 4, battle.soul.x, battle.soul.y + 18); - ctx.bezierCurveTo(battle.soul.x + 20, battle.soul.y + 4, battle.soul.x + 13, battle.soul.y - 17, battle.soul.x, battle.soul.y - 8); - ctx.fill(); - ctx.fillStyle = "#dbeafe"; - battle.bullets.forEach((b) => { - ctx.beginPath(); - ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); - ctx.fill(); - }); - ctx.fillStyle = "#f8efe0"; - ctx.font = "15px Georgia, serif"; - wrapText(battle.message, 148, 480, 660, 22); - } - - function updateBullets(battle) { - const speed = state.forgets > 0 ? 1.15 : 1.55; - const move = keyDirection(); - if (move === "up") battle.soul.y -= 3.3; - if (move === "down") battle.soul.y += 3.3; - if (move === "left") battle.soul.x -= 3.3; - if (move === "right") battle.soul.x += 3.3; - battle.soul.x = clamp(battle.soul.x, 312, 648); - battle.soul.y = clamp(battle.soul.y, 270, 426); - if (battle.t % 26 === 0) { - if (encounters[battle.id].bullets === "rain") battle.bullets.push({ x: 315 + Math.random() * 330, y: 256, vx: 0, vy: speed * 2.4, r: 5 }); - else if (encounters[battle.id].bullets === "spiral") { - const a = battle.t / 14; - battle.bullets.push({ x: 480, y: 346, vx: Math.cos(a) * speed * 2, vy: Math.sin(a) * speed * 2, r: 5 }); - } else battle.bullets.push({ x: Math.random() > 0.5 ? 304 : 656, y: 275 + Math.random() * 130, vx: (Math.random() > 0.5 ? -1 : 1) * speed * 2, vy: Math.sin(battle.t) * 0.6, r: 5 }); - } - battle.bullets.forEach((b) => { - b.x += b.vx; - b.y += b.vy; - if (battle.grace <= 0 && Math.hypot(b.x - battle.soul.x, b.y - battle.soul.y) < b.r + 10) { - state.resolve = Math.max(0, state.resolve - 1); - battle.grace = 24; - audio.sfx("hurt"); - if (state.resolve <= 0) end("forgotten"); - } - }); - battle.grace -= 1; - battle.bullets = battle.bullets.filter((b) => b.x > 280 && b.x < 680 && b.y > 230 && b.y < 455); - } - - function renderHud() { - root.classList.toggle("is-lake-awake", state.forgets > 1); - root.classList.toggle("is-ending", Boolean(state.ending)); - els.speaker.textContent = state.dialogue.speaker; - els.line.textContent = state.dialogue.line; - els.area.textContent = areas[state.area].name; - els.route.textContent = state.route; - els.resolve.textContent = String(state.resolve); - ui.memoryFill.style.width = `${Math.min(100, state.forgets * 34)}%`; - els.inventory.innerHTML = state.inventory.length ? state.inventory.map((id) => `
  • ${items[id].name}
  • `).join("") : "
  • Empty
  • "; - els.journal.innerHTML = state.memories.map((memory) => `
  • ${memory.erased ? "[entry missing]" : memory.text}
  • `).join(""); - ui.actionButtons.forEach((button) => { - button.disabled = state.mode !== "battle"; - }); - } - - function drawProp(prop, now) { - const x = prop.x * tile; - const y = prop.y * tile; - if (prop.kind === "lake") return drawLake(x, y, (prop.w || 1) * tile, (prop.h || 1) * tile, now, 1); - if (prop.kind === "stall") { - ctx.fillStyle = "#7f2d3a"; ctx.fillRect(x, y + 8, 64, 34); - ctx.fillStyle = "#e0a451"; ctx.fillRect(x - 4, y, 72, 12); - } - if (prop.kind === "flowers") for (let i = 0; i < 6; i += 1) { ctx.fillStyle = `rgba(133, 255, 214, ${0.45 + Math.sin(now / 400 + i) * 0.2})`; ctx.fillRect(x + i * 8, y + 12 + (i % 2) * 8, 5, 5); } - if (prop.kind === "sign" || prop.kind === "poster") { ctx.fillStyle = "#12151d"; ctx.fillRect(x, y, 38, 32); ctx.fillStyle = "#f3b15d"; ctx.fillRect(x + 5, y + 6, 28, 3); } - if (prop.kind === "stage") { ctx.fillStyle = "#210d18"; ctx.fillRect(x, y + 10, 96, 46); ctx.fillStyle = "#d64d62"; ctx.fillRect(x + 8, y, 80, 14); } - if (prop.kind === "archive") { ctx.fillStyle = "#4b3a25"; ctx.fillRect(x, y, 58, 52); ctx.fillStyle = "#d8cba8"; for (let i = 0; i < 4; i += 1) ctx.fillRect(x + 8, y + 8 + i * 10, 42, 3); } - if (prop.kind === "root") { ctx.strokeStyle = "#6aa27d"; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(x, y + 28); ctx.bezierCurveTo(x + 26, y - 8, x + 52, y + 48, x + 80, y + 6); ctx.stroke(); } - if (prop.kind === "home") { ctx.fillStyle = "#263332"; ctx.fillRect(x, y, 54, 48); ctx.fillStyle = "#080a0c"; ctx.fillRect(x + 20, y + 18, 14, 30); } - if (prop.kind === "pew") { ctx.fillStyle = "#33251d"; ctx.fillRect(x, y + 10, 76, 20); } - if (prop.kind === "glass") { ctx.fillStyle = "rgba(139,211,255,0.55)"; ctx.fillRect(x, y, 52, 72); ctx.fillStyle = "rgba(255,255,255,0.3)"; ctx.fillRect(x + 23, y, 5, 72); } - if (prop.kind === "rift") { ctx.strokeStyle = `rgba(248,239,224,${0.5 + Math.sin(now / 200) * 0.3})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x + 20, y); ctx.lineTo(x + 5, y + 22); ctx.lineTo(x + 27, y + 40); ctx.lineTo(x + 10, y + 70); ctx.stroke(); } - } - - function drawLake(x, y, w, h, now, alpha) { - const g = ctx.createLinearGradient(0, y, 0, y + h); - g.addColorStop(0, `rgba(7, 18, 30, ${0.7 * alpha})`); - g.addColorStop(1, `rgba(0, 0, 0, ${0.95 * alpha})`); - ctx.fillStyle = g; - ctx.fillRect(x, y, w, h); - ctx.strokeStyle = `rgba(114, 169, 191, ${0.2 * alpha})`; - for (let i = 0; i < 8; i += 1) { - ctx.beginPath(); - const yy = y + 12 + i * 17 + Math.sin(now / 700 + i) * 4; - ctx.moveTo(x, yy); - for (let xx = 0; xx < w; xx += 24) ctx.lineTo(x + xx, yy + Math.sin(now / 350 + xx / 35 + i) * 3); - ctx.stroke(); - } - } - - function drawDoor(x, y, color) { - ctx.fillStyle = color; - ctx.fillRect(x * tile + 7, y * tile + 4, 18, 26); - ctx.fillStyle = "#05070c"; - ctx.fillRect(x * tile + 19, y * tile + 17, 3, 3); - } - - function drawItem(id, item, now) { - const x = item.x * tile; - const y = item.y * tile + Math.sin(now / 220) * 2; - ctx.fillStyle = id === "bell" ? "#d6a24a" : id === "photo" ? "#dbeafe" : id === "cinderKey" ? "#f59e0b" : "#f8efe0"; - ctx.fillRect(x + 10, y + 10, 13, 13); - ctx.fillStyle = "rgba(255,255,255,0.45)"; - ctx.fillRect(x + 13, y + 7, 7, 5); - } - - function drawEncounter(e, now) { - const x = e.x * tile; - const y = e.y * tile; - ctx.fillStyle = `rgba(210, 225, 245, ${0.35 + Math.sin(now / 260) * 0.16})`; - ctx.fillRect(x + 8, y + 4, 16, 27); - ctx.clearRect(x + 12, y + 10, 3, 4); - ctx.clearRect(x + 19, y + 10, 3, 4); - } - - function drawSprite(id, x, y, scale, now) { - const c = characters[id].colors; - const bob = Math.sin(now / 260 + x) * 1.5; - const s = scale; - ctx.fillStyle = "rgba(0,0,0,0.28)"; - ctx.fillRect(x + 5 * s, y + 30 * s, 12 * s, 3 * s); - ctx.fillStyle = c[0]; ctx.fillRect(x + 7 * s, y + bob + 2 * s, 10 * s, 7 * s); - ctx.fillStyle = c[1]; ctx.fillRect(x + 6 * s, y + bob + 8 * s, 12 * s, 10 * s); - ctx.fillStyle = c[2]; ctx.fillRect(x + 5 * s, y + bob + 18 * s, 14 * s, 13 * s); - ctx.fillStyle = c[3]; ctx.fillRect(x + 8 * s, y + bob + 12 * s, 2 * s, 2 * s); ctx.fillRect(x + 14 * s, y + bob + 12 * s, 2 * s, 2 * s); - ctx.fillStyle = c[0]; ctx.fillRect(x + 4 * s, y + bob + 22 * s, 3 * s, 9 * s); ctx.fillRect(x + 17 * s, y + bob + 22 * s, 3 * s, 9 * s); - } - - function drawBattleEnemy(id, now) { - if (id === "first_erased") drawEncounter({ x: 14, y: 4 }, now); - if (id === "root_husk") { ctx.strokeStyle = "#79c89d"; ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(440, 180); ctx.bezierCurveTo(470, 120, 510, 230, 540, 160); ctx.stroke(); } - if (id === "chapel_memory") { ctx.fillStyle = "rgba(139,211,255,0.5)"; for (let i = 0; i < 5; i += 1) ctx.fillRect(420 + i * 28, 150 + Math.sin(now / 300 + i) * 8, 16, 54); } - } - - function drawEnding(now) { - ctx.fillStyle = "rgba(0,0,0,0.72)"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = "#f8efe0"; - ctx.font = "700 42px Georgia, serif"; - ctx.textAlign = "center"; - ctx.fillText(state.route, 480 + Math.sin(now / 120) * (state.ending === "forgotten" ? 3 : 0), 202); - ctx.font = "18px Georgia, serif"; - wrapText(state.dialogue.line, 250, 250, 460, 28); - } - - function drawVignette(now) { - if (state.forgets > 0) { - ctx.fillStyle = `rgba(0,0,0,${0.06 + state.forgets * 0.05 + Math.sin(now / 800) * 0.02})`; - ctx.fillRect(0, 0, canvas.width, canvas.height); - } - const g = ctx.createRadialGradient(480, 245, 120, 480, 245, 560); - g.addColorStop(0, "rgba(0,0,0,0)"); - g.addColorStop(1, "rgba(0,0,0,0.48)"); - ctx.fillStyle = g; - ctx.fillRect(0, 0, canvas.width, canvas.height); - } - - function wrapText(text, x, y, maxWidth, lineHeight) { - ctx.textAlign = "left"; - const words = text.split(" "); - let line = ""; - for (const word of words) { - const test = `${line}${word} `; - if (ctx.measureText(test).width > maxWidth && line) { - ctx.fillText(line, x, y); - line = `${word} `; - y += lineHeight; - } else { - line = test; - } - } - ctx.fillText(line, x, y); - } - }; - - function makeAudio() { - let ctx = null; - let master = null; - let track = "title"; - let timer = null; - const patterns = { - title: [220, 277, 330, 277], - harbor: [262, 330, 392, 330], - red: [196, 233, 294, 349], - roots: [174, 220, 261, 220], - chapel: [147, 196, 247, 294], - shore: [110, 165, 220, 165], - battle: [330, 311, 294, 247], - ending: [262, 392, 440, 523], - void: [110, 98, 82, 73] - }; - function start() { - const Audio = window.AudioContext || window.webkitAudioContext; - if (!Audio) return; - if (ctx) { - if (ctx.state === "suspended") ctx.resume(); - return; - } - ctx = new Audio(); - master = ctx.createGain(); - master.gain.value = 0.14; - master.connect(ctx.destination); - if (ctx.state === "suspended") ctx.resume(); - loop(); - const notes = patterns[track] || patterns.title; - tone(notes[0], 0.22, "triangle", 0.9); - } - function loop() { - clearInterval(timer); - let i = 0; - timer = setInterval(() => { - if (!ctx) return; - const notes = patterns[track] || patterns.title; - tone(notes[i % notes.length], 0.28, track === "battle" ? "sawtooth" : "sine"); - if (track !== "battle") tone(notes[(i + 2) % notes.length] / 2, 0.7, "triangle", 0.45); - i += 1; - }, track === "battle" ? 230 : 520); - } - function tone(freq, dur, type, gainScale) { - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = type; - osc.frequency.value = freq; - gain.gain.setValueAtTime(0, ctx.currentTime); - gain.gain.linearRampToValueAtTime(0.25 * (gainScale || 1), ctx.currentTime + 0.02); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur); - osc.connect(gain); - gain.connect(master); - osc.start(); - osc.stop(ctx.currentTime + dur + 0.03); - } - return { - start, - supported() { - return Boolean(window.AudioContext || window.webkitAudioContext); - }, - setTrack(next) { - track = next; - if (ctx && ctx.state === "suspended") ctx.resume(); - if (ctx) loop(); - if (ctx) tone((patterns[track] || patterns.title)[0], 0.16, track === "battle" ? "sawtooth" : "triangle", 0.65); - }, - sfx(kind) { - if (!ctx) return; - if (ctx.state === "suspended") ctx.resume(); - const map = { item: 660, talk: 440, battle: 130, hit: 180, listen: 523, forget: 96, hurt: 80 }; - tone(map[kind] || 330, kind === "forget" ? 0.8 : 0.18, kind === "forget" ? "sawtooth" : "square", kind === "hurt" ? 1.3 : 0.8); - } - }; - } - - function rects(sources) { - const set = new Set(); - sources.forEach(([x, y, w, h]) => { - for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) set.add(`${xx},${yy}`); - }); - return set; - } - - function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); - } -})(); + "use strict"; + var script = document.createElement("script"); + script.src = "/assets/scripts/pages/ash-below-lake.js"; + script.defer = true; + document.head.appendChild(script); +}()); diff --git a/assets/scripts/bigger-picture.min.js b/assets/scripts/bigger-picture.min.js index 93bcaa7..6db958b 100755 --- a/assets/scripts/bigger-picture.min.js +++ b/assets/scripts/bigger-picture.min.js @@ -1 +1,7 @@ -var BiggerPicture=function(){function t(){}const n=t=>t;function e(t,n){for(const e in n)t[e]=n[e];return t}function o(t){return t()}function r(t){t.forEach(o)}function i(t){return"function"==typeof t}function c(t,n){return t!=t?n==n:t!==n}function s(n,e,o){n.u.t.push(((n,...e)=>{if(null==n)return t;const o=n.subscribe(...e);return o.unsubscribe?()=>o.unsubscribe():o})(e,o))}function u(n){return n&&i(n.destroy)?n.destroy:t}let l=()=>globalThis.performance.now(),a=t=>requestAnimationFrame(t);const p=new Set;function f(t){p.forEach((n=>{n.c(t)||(p.delete(n),n.f())})),0!==p.size&&a(f)}function d(t){let n;return 0===p.size&&a(f),{promise:new Promise((e=>{p.add(n={c:t,f:e})})),abort(){p.delete(n)}}}function m(t,n){t.appendChild(n)}function b(t,n,e){t.insertBefore(n,e||null)}function h(t){t.parentNode.removeChild(t)}function g(t){return document.createElement(t)}function x(){return document.createTextNode("")}function v(t,n,e,o){return t.addEventListener(n,e,o),()=>t.removeEventListener(n,e,o)}function w(t,n,e){null==e?t.removeAttribute(n):t.getAttribute(n)!==e&&t.setAttribute(n,e)}function y(t,n,e,o){null===e?t.style.removeProperty(n):t.style.setProperty(n,e)}function $(t,n,e){t.classList[e?"add":"remove"](n)}let k,_,M=0,S={};function z(t,n,e,o,r,i,c,s=0){const u=16.666/o;let l="{\n";for(let t=0;1>=t;t+=u){const o=n+(e-n)*i(t);l+=100*t+`%{${c(o,1-o)}}\n`}const a=l+`100% {${c(e,1-e)}}\n}`,p=`_bp_${Math.round(1e9*Math.random())}_${s}`;if(!S[p]){if(!k){const t=g("style");document.head.appendChild(t),k=t.sheet}S[p]=1,k.insertRule(`@keyframes ${p} ${a}`,k.cssRules.length)}const f=t.style.animation||"";return t.style.animation=`${f?f+", ":""}${p} ${o}ms linear ${r}ms 1 both`,M+=1,p}function I(t,n){t.style.animation=(t.style.animation||"").split(", ").filter(n?t=>0>t.indexOf(n):t=>-1===t.indexOf("_bp")).join(", "),n&&!--M&&a((()=>{if(M)return;let t=k.cssRules.length;for(;t--;)k.deleteRule(t);S={}}))}function P(t){_=t}const T=[],A=[],N=[],O=[],C=Promise.resolve();let E=0;function R(t){N.push(t)}const j=new Set;let q,F=0;function J(){const t=_;do{for(;T.length>F;){const t=T[F];F++,P(t),B(t.u)}for(P(null),T.length=0,F=0;A.length;)A.pop()();for(let t=0;N.length>t;t+=1){const n=N[t];j.has(n)||(j.add(n),n())}N.length=0}while(T.length);for(;O.length;)O.pop()();E=0,j.clear(),P(t)}function B(t){if(null!==t.l){t.update(),r(t.g);const n=t.v;t.v=[-1],t.l&&t.l.p(t.$,n),t.k.forEach(R)}}function D(){return q||(q=Promise.resolve(),q.then((()=>{q=null}))),q}function K(t,n,e){t.dispatchEvent(((t,n,e=0)=>{const o=document.createEvent("CustomEvent");return o.initCustomEvent(t,e,0,n),o})(`${n?"intro":"outro"}${e}`))}const L=new Set;let W;function X(){W={r:0,c:[],p:W}}function Y(){W.r||r(W.c),W=W.p}function G(t,n){t&&t.i&&(L.delete(t),t.i(n))}function H(t,n,e,o){if(t&&t.o){if(L.has(t))return;L.add(t),W.c.push((()=>{L.delete(t),o&&(e&&t.d(1),o())})),t.o(n)}}const Q={duration:0};function U(e,o,r){let c,s,u=o(e,r),a=0,p=0;function f(){c&&I(e,c)}function m(){const{delay:o=0,duration:r=300,_:i=n,M:m=t,css:b}=u||Q;b&&(c=z(e,0,1,r,o,i,b,p++)),m(0,1);const h=l()+o,g=h+r;s&&s.abort(),a=1,R((()=>K(e,1,"start"))),s=d((t=>{if(a){if(t>=g)return m(1,0),K(e,1,"end"),f(),a=0;if(t>=h){const n=i((t-h)/r);m(n,1-n)}}return a}))}let b=0;return{start(){b||(b=1,I(e),i(u)?(u=u(),D().then(m)):m())},S(){b=0},end(){a&&(f(),a=0)}}}function V(e,o,c){let s,u=o(e,c),a=1;const p=W;function f(){const{delay:o=0,duration:i=300,_:c=n,M:f=t,css:m}=u||Q;m&&(s=z(e,1,0,i,o,c,m));const b=l()+o,h=b+i;R((()=>K(e,0,"start"))),d((t=>{if(a){if(t>=h)return f(0,1),K(e,0,"end"),--p.r||r(p.c),0;if(t>=b){const n=c((t-b)/i);f(1-n,n)}}return a}))}return p.r+=1,i(u)?D().then((()=>{u=u(),f()})):f(),{end(t){t&&u.M&&u.M(1,0),a&&(s&&I(e,s),a=0)}}}function Z(t){t&&t.c()}function tt(t,n,e,c){const{l:s,I:u,t:l,k:a}=t.u;s&&s.m(n,e),c||R((()=>{const n=u.map(o).filter(i);l?l.push(...n):r(n),t.u.I=[]})),a.forEach(R)}function nt(t,n){const e=t.u;null!==e.l&&(r(e.t),e.l&&e.l.d(n),e.t=e.l=null,e.$=[])}function et(n,e,o,i,c,s,u,l=[-1]){const a=_;P(n);const p=n.u={l:null,$:null,P:s,update:t,T:c,bound:{},I:[],t:[],A:[],g:[],k:[],context:new Map(e.context||(a?a.u.context:[])),N:{},v:l,O:0,root:e.target||a.u.root};u&&u(p.root);let f=0;p.$=o?o(n,e.P||{},((t,e,...o)=>{const r=o.length?o[0]:e;return p.$&&c(p.$[t],p.$[t]=r)&&(!p.O&&p.bound[t]&&p.bound[t](r),f&&((t,n)=>{-1===t.u.v[0]&&(T.push(t),E||(E=1,C.then(J)),t.u.v.fill(0)),t.u.v[n/31|0]|=1<{const t=e.indexOf(n);-1!==t&&e.splice(t,1)}}C(t){this.R&&0!==Object.keys(t).length&&(this.u.O=1,this.R(t),this.u.O=0)}}function rt(t){const n=t-1;return n*n*n+1}function it(t,{delay:n=0,duration:e=400,_:o=rt,x:r=0,y:i=0,opacity:c=0}={}){const s=getComputedStyle(t),u=+s.opacity,l="none"===s.transform?"":s.transform,a=u*(1-c);return{delay:n,duration:e,_:o,css(t,n){return`\n\t\t\ttransform: ${l} translate(${(1-t)*r}px, ${(1-t)*i}px);\n\t\t\topacity: ${u-a*n}`}}}const ct=[];function st(n,e=t){let o;const r=new Set;function i(t){if(c(n,t)&&(n=t,o)){const t=!ct.length;for(const t of r)t[1](),ct.push(t,n);if(t){for(let t=0;ct.length>t;t+=2)ct[t][0](ct[t+1]);ct.length=0}}}return{set:i,update(t){i(t(n))},subscribe(c,s=t){const u=[c,s];return r.add(u),1===r.size&&(o=e(i)||t),c(n),()=>{r.delete(u),0===r.size&&(o(),o=null)}}}}function ut(t,n){if(t===n||t!=t)return()=>t;const e=typeof t;if(Array.isArray(t)){const e=n.map(((n,e)=>ut(t[e],n)));return t=>e.map((n=>n(t)))}if("number"===e){const e=n-t;return n=>t+n*e}}function lt(t,o={}){const r=st(t);let i,c=t;function s(s,u){if(null==t)return r.set(t=s),Promise.resolve();c=s;let a=i,p=0,{delay:f=0,duration:m=400,_:b=n,interpolate:h=ut}=e(e({},o),u);if(0===m)return a&&(a.abort(),a=null),r.set(t=c),Promise.resolve();const g=l()+f;let x;return i=d((n=>{if(g>n)return 1;p||(x=h(t,s),"function"==typeof m&&(m=m(t,s)),p=1),a&&(a.abort(),a=null);const e=n-g;return e>m?(r.set(t=s),0):(r.set(t=x(b(e/m))),1)})),i.promise}return{set:s,update(n,e){return s(n(c,t),e)},subscribe:r.subscribe}}const at=st(0),pt=globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches,ft=t=>({_:rt,duration:pt?0:t}),dt=t=>!t.thumb||`url(${t.thumb})`,mt=(t,n)=>{if(n){"string"==typeof n&&(n=JSON.parse(n));for(const e in n)t.setAttribute(e,n[e])}};function bt(t){let n,e,o;return{c(){n=g("div"),n.innerHTML='',w(n,"class","bp-load"),y(n,"background-image",dt(t[0]))},m(t,e){b(t,n,e),o=1},p(t,e){1&e&&y(n,"background-image",dt(t[0]))},i(t){o||(e&&e.end(1),o=1)},o(t){t&&(e=V(n,it,{duration:480})),o=0},d(t){t&&h(n),t&&e&&e.end()}}}function ht(n){let e,o;return{c(){e=g("div"),w(e,"class","bp-load"),y(e,"background-image",dt(n[0]))},m(t,n){b(t,e,n)},p(t,n){1&n&&y(e,"background-image",dt(t[0]))},i(t){o||R((()=>{o=U(e,it,{duration:480}),o.start()}))},o:t,d(t){t&&h(e)}}}function gt(t){let n,e,o=!t[1]&&bt(t),r=t[2]&&ht(t);return{c(){o&&o.c(),n=x(),r&&r.c(),e=x()},m(t,i){o&&o.m(t,i),b(t,n,i),r&&r.m(t,i),b(t,e,i)},p(t,[i]){t[1]?o&&(X(),H(o,1,1,(()=>{o=null})),Y()):o?(o.p(t,i),2&i&&G(o,1)):(o=bt(t),o.c(),G(o,1),o.m(n.parentNode,n)),t[2]?r?(r.p(t,i),4&i&&G(r,1)):(r=ht(t),r.c(),G(r,1),r.m(e.parentNode,e)):r&&(r.d(1),r=null)},i(t){G(o),G(r)},o(t){H(o)},d(t){o&&o.d(t),t&&h(n),r&&r.d(t),t&&h(e)}}}function xt(t,n,e){let o;s(t,at,(t=>e(2,o=t)));let{j:r}=n,{loaded:i}=n;return t.R=t=>{"j"in t&&e(0,r=t.j),"loaded"in t&&e(1,i=t.loaded)},[r,i,o]}class vt extends ot{constructor(t){super(),et(this,t,xt,gt,c,{j:0,loaded:1})}}function wt(t){let n,e,o,i,c,s;return{c(){n=g("img"),w(n,"sizes",e=t[8].sizes||t[1]+"px"),w(n,"alt",t[7].alt)},m(e,o){b(e,n,o),i=1,c||(s=[u(t[21].call(null,n)),v(n,"error",t[27])],c=1)},p(t,o){(!i||2&o[0]&&e!==(e=t[8].sizes||t[1]+"px"))&&w(n,"sizes",e)},i(t){i||(o&&o.end(1),i=1)},o(t){o=V(n,it,{}),i=0},d(t){t&&h(n),t&&o&&o.end(),c=0,r(s)}}}function yt(t){let n,e;return n=new vt({P:{j:t[7],loaded:t[2]}}),{c(){Z(n.u.l)},m(t,o){tt(n,t,o),e=1},p(t,e){const o={};4&e[0]&&(o.loaded=t[2]),n.C(o)},i(t){e||(G(n.u.l,t),e=1)},o(t){H(n.u.l,t),e=0},d(t){nt(n,t)}}}function $t(t){let n,e,o,i,c,s,l=`translate3d(${t[0][0]/-2+t[6][0]}px, ${t[0][1]/-2+t[6][1]}px, 0)`,a=t[2]&&wt(t),p=t[3]&&yt(t);return{c(){n=g("div"),e=g("div"),a&&a.c(),o=x(),p&&p.c(),w(e,"class","bp-img"),y(e,"width",t[0][0]+"px"),y(e,"height",t[0][1]+"px"),$(e,"bp-drag",t[4]),$(e,"bp-canzoom",t[11]>1&&t[12]>t[0][0]),y(e,"background-image",dt(t[7])),y(e,"transform",l),w(n,"class","bp-img-wrap"),$(n,"bp-close",t[5])},m(r,l){b(r,n,l),m(n,e),a&&a.m(e,null),m(e,o),p&&p.m(e,null),i=1,c||(s=[u(t[20].call(null,e)),v(n,"wheel",t[15]),v(n,"pointerdown",t[16]),v(n,"pointermove",t[17]),v(n,"pointerup",t[19]),v(n,"pointercancel",t[18])],c=1)},p(t,r){t[2]?a?(a.p(t,r),4&r[0]&&G(a,1)):(a=wt(t),a.c(),G(a,1),a.m(e,o)):a&&(X(),H(a,1,1,(()=>{a=null})),Y()),t[3]?p?(p.p(t,r),8&r[0]&&G(p,1)):(p=yt(t),p.c(),G(p,1),p.m(e,null)):p&&(X(),H(p,1,1,(()=>{p=null})),Y()),(!i||1&r[0])&&y(e,"width",t[0][0]+"px"),(!i||1&r[0])&&y(e,"height",t[0][1]+"px"),(!i||16&r[0])&&$(e,"bp-drag",t[4]),(!i||6145&r[0])&&$(e,"bp-canzoom",t[11]>1&&t[12]>t[0][0]),65&r[0]&&l!==(l=`translate3d(${t[0][0]/-2+t[6][0]}px, ${t[0][1]/-2+t[6][1]}px, 0)`)&&y(e,"transform",l),(!i||32&r[0])&&$(n,"bp-close",t[5])},i(t){i||(G(a),G(p),i=1)},o(t){H(a),H(p),i=0},d(t){t&&h(n),a&&a.d(),p&&p.d(),c=0,r(s)}}}function kt(t,n,e){let o,r,i,c;s(t,at,(t=>e(26,i=t)));let{P:u}=n,{q:l}=n,{j:a,F:p,J:f,next:d,zoomed:m,container:b}=u;s(t,m,(t=>e(25,o=t)));let h,g,x,v,w,y,$,k,_,M,S,z=a.maxZoom||p.maxZoom||10,I=u.B(a),P=I[0],T=0;const A=+a.width,N=[],O=new Map,C=lt(I,ft(400));s(t,C,(t=>e(0,c=t)));const E=lt([0,0],ft(400));s(t,E,(t=>e(6,r=t)));const R=([t,n],o=c)=>{const r=(o[0]-b.w)/2,i=(o[1]-b.h)/2;return 0>r?t=0:t>r?l?(t=w?r+(t-r)/10:r)>r+20&&e(4,w=f()):t=r:-r>t&&(l?-r-20>(t=w?-r-(-r-t)/10:-r)&&e(4,w=d()):t=-r),0>i?n=0:n>i?n=i:-i>n&&(n=-i),[t,n]};function j(t=z,n){if(i)return;const o=I[0]*z;let s=c[0]+c[0]*t,u=c[1]+c[1]*t;if(t>0)s>o&&(s=o,u=I[1]*z),s>A&&(s=A,u=+a.height);else if(I[0]>s)return C.set(I),E.set([0,0]);let{x:l,y:p,width:f,height:d}=v.getBoundingClientRect();const m=n?n.clientX-l-f/2:0,b=n?n.clientY-p-d/2:0;l=s/f*-m+m,p=u/d*-b+b;const h=[s,u];C.set(h).then((()=>{e(1,P=Math.round(Math.max(P,s)))})),E.set(R([r[0]+l,r[1]+p],h))}Object.defineProperty(a,"zoom",{configurable:1,get(){return o},set(t){return j(t?z:-z)}});const q=t=>O.delete(t.pointerId);return t.R=t=>{"q"in t&&e(23,l=t.q)},t.u.update=()=>{if(16777217&t.u.v[0]&&m.set(c[0]-10>I[0]),117440512&t.u.v[0]&&i&&o&&!p.intro){const t=ft(480);E.set([0,0],t),C.set(I,t),e(5,S=1)}},[c,P,h,g,w,S,r,a,p,m,b,z,A,C,E,t=>{p.inline&&!o||(t.preventDefault(),j(t.deltaY/-300,t))},t=>{2!==t.button&&(t.preventDefault(),e(4,w=1),O.set(t.pointerId,t),$=t.clientX,k=t.clientY,_=r[0],M=r[1])},t=>{if(O.size>1)return e(4,w=0),p.noPinch?.(b.el)||(t=>{const[n,e]=O.set(t.pointerId,t).values(),o=Math.hypot(n.clientX-e.clientX,n.clientY-e.clientY);x=x||{clientX:(n.clientX+e.clientX)/2,clientY:(n.clientY+e.clientY)/2},j(((T||o)-o)/-35,x),T=o})(t);if(!w)return;let n=t.clientX,r=t.clientY;y=N.push({x:n,y:r})>2,n-=$,r-=k,o||(-90>r&&e(4,w=!p.noClose&&u.close()),30>Math.abs(r)&&(n>40&&e(4,w=f()),-40>n&&e(4,w=d()))),o&&y&&!i&&E.set(R([_+n,M+r]),{duration:0})},q,function(t){if(q(t),x&&(e(4,w=T=0),x=O.size?x:null),w){if(e(4,w=0),t.target===this&&!p.noClose)return u.close();if(y){const[t,n,e]=N.slice(-3);Math.hypot(n.x-e.x,n.y-e.y)>5&&E.set(R([r[0]-5*(t.x-e.x),r[1]-5*(t.y-e.y)]))}else p.onImageClick?.(b.el,a)||j(o?-z:z,t);y=0,N.length=0}},t=>{v=t,u.D((()=>{e(24,I=u.B(a)),!p.inline&&l||(C.set(I),E.set([0,0]))})),u.K(a).then((()=>{e(2,h=1),u.L()})),setTimeout((()=>{e(3,g=!h)}),250)},t=>{mt(t,a.attr),t.srcset=a.img},u,l,I,o,i,t=>p.onError?.(b,a,t)]}class _t extends ot{constructor(t){super(),et(this,t,kt,$t,c,{P:22,q:23},null,[-1,-1])}}function Mt(t){let n,e,o,i,c,s;return o=new vt({P:{j:t[2],loaded:t[0]}}),{c(){n=g("div"),e=g("iframe"),Z(o.u.l),w(e,"allow","autoplay; fullscreen"),w(e,"title",t[2].title),w(n,"class","bp-if"),y(n,"width",t[1][0]+"px"),y(n,"height",t[1][1]+"px")},m(r,l){b(r,n,l),m(n,e),tt(o,n,null),i=1,c||(s=[u(t[3].call(null,e)),v(e,"load",t[5])],c=1)},p(t,[e]){const r={};1&e&&(r.loaded=t[0]),o.C(r),(!i||2&e)&&y(n,"width",t[1][0]+"px"),(!i||2&e)&&y(n,"height",t[1][1]+"px")},i(t){i||(G(o.u.l,t),i=1)},o(t){H(o.u.l,t),i=0},d(t){t&&h(n),nt(o),c=0,r(s)}}}function St(t,n,e){let o,r,{P:i}=n;const{j:c}=i,s=()=>e(1,r=i.B(c));return s(),i.D(s),[o,r,c,t=>{mt(t,c.attr),t.src=c.iframe},i,()=>e(0,o=1)]}class zt extends ot{constructor(t){super(),et(this,t,St,Mt,c,{P:4})}}function It(t){let n,e,o,r,i;return e=new vt({P:{j:t[2],loaded:t[0]}}),{c(){n=g("div"),Z(e.u.l),w(n,"class","bp-vid"),y(n,"width",t[1][0]+"px"),y(n,"height",t[1][1]+"px"),y(n,"background-image",dt(t[2]))},m(c,s){b(c,n,s),tt(e,n,null),o=1,r||(i=u(t[3].call(null,n)),r=1)},p(t,[r]){const i={};1&r&&(i.loaded=t[0]),e.C(i),(!o||2&r)&&y(n,"width",t[1][0]+"px"),(!o||2&r)&&y(n,"height",t[1][1]+"px")},i(t){o||(G(e.u.l,t),o=1)},o(t){H(e.u.l,t),o=0},d(t){t&&h(n),nt(e),r=0,i()}}}function Pt(t,n,e){let o,r,{P:i}=n;const{j:c,F:s,container:u}=i,l=()=>e(1,r=i.B(c));return l(),i.D(l),[o,r,c,t=>{let n;const r=(t,e)=>{Array.isArray(e)||(e=JSON.parse(e));for(const o of e){n||(n=document.createElement(o.type?.includes("audio")?"audio":"video"),mt(n,{controls:1,autoplay:1,playsinline:1,tabindex:"0"}),mt(n,c.attr));const e=document.createElement(t);mt(e,o),"source"==t&&(e.onError=t=>s.onError?.(u,c,t)),n.append(e)}};r("source",c.sources),r("track",c.tracks||[]),n.oncanplay=()=>e(0,o=1),t.append(n)},i]}class Tt extends ot{constructor(t){super(),et(this,t,Pt,It,c,{P:4})}}function At(n){let e,o,i,s,l,a,p,f,d,x=n[6].i,y=jt(n),k=n[0].length>1&&qt(n);return{c(){e=g("div"),o=g("div"),y.c(),s=g("div"),l=g("button"),k&&k.c(),w(l,"class","bp-x"),w(l,"title","Close"),w(l,"aria-label","Close"),w(s,"class","bp-controls"),w(e,"class","bp-wrap"),$(e,"bp-zoomed",n[10]),$(e,"bp-inline",n[8]),$(e,"bp-small",n[7]),$(e,"bp-noclose",n[5].noClose)},m(t,r){b(t,e,r),m(e,o),y.m(e,null),m(e,s),m(s,l),k&&k.m(s,null),p=1,f||(d=[v(l,"click",n[1]),u(n[14].call(null,e))],f=1)},p(n,o){64&o[0]&&c(x,x=n[6].i)?(X(),H(y,1,1,t),Y(),y=jt(n),y.c(),G(y,1),y.m(e,s)):y.p(n,o),n[0].length>1?k?k.p(n,o):(k=qt(n),k.c(),k.m(s,null)):k&&(k.d(1),k=null),(!p||1024&o[0])&&$(e,"bp-zoomed",n[10]),(!p||256&o[0])&&$(e,"bp-inline",n[8]),(!p||128&o[0])&&$(e,"bp-small",n[7]),(!p||32&o[0])&&$(e,"bp-noclose",n[5].noClose)},i(t){p||(i&&i.end(1),G(y),a&&a.end(1),p=1)},o(t){t&&(i=V(o,it,{duration:480})),H(y),t&&(a=V(s,it,{})),p=0},d(t){t&&h(e),t&&i&&i.end(),y.d(t),k&&k.d(),t&&a&&a.end(),f=0,r(d)}}}function Nt(n){let e,o=(n[6].html??n[6].element.outerHTML)+"";return{c(){e=g("div"),w(e,"class","bp-html")},m(t,n){b(t,e,n),e.innerHTML=o},p(t,n){64&n[0]&&o!==(o=(t[6].html??t[6].element.outerHTML)+"")&&(e.innerHTML=o)},i:t,o:t,d(t){t&&h(e)}}}function Ot(n){let e,o;return e=new zt({P:{P:n[13]()}}),{c(){Z(e.u.l)},m(t,n){tt(e,t,n),o=1},p:t,i(t){o||(G(e.u.l,t),o=1)},o(t){H(e.u.l,t),o=0},d(t){nt(e,t)}}}function Ct(n){let e,o;return e=new Tt({P:{P:n[13]()}}),{c(){Z(e.u.l)},m(t,n){tt(e,t,n),o=1},p:t,i(t){o||(G(e.u.l,t),o=1)},o(t){H(e.u.l,t),o=0},d(t){nt(e,t)}}}function Et(t){let n,e;return n=new _t({P:{P:t[13](),q:t[7]}}),{c(){Z(n.u.l)},m(t,o){tt(n,t,o),e=1},p(t,e){const o={};128&e[0]&&(o.q=t[7]),n.C(o)},i(t){e||(G(n.u.l,t),e=1)},o(t){H(n.u.l,t),e=0},d(t){nt(n,t)}}}function Rt(t){let n,e,o,r=t[6].caption+"";return{c(){n=g("div"),w(n,"class","bp-cap")},m(t,e){b(t,n,e),n.innerHTML=r,o=1},p(t,e){(!o||64&e[0])&&r!==(r=t[6].caption+"")&&(n.innerHTML=r)},i(t){o||(e&&e.end(1),o=1)},o(t){e=V(n,it,{duration:200}),o=0},d(t){t&&h(n),t&&e&&e.end()}}}function jt(t){let n,e,o,i,c,s,u,l,a;const p=[Et,Ct,Ot,Nt],f=[];function d(t,n){return t[6].img?0:t[6].sources?1:t[6].iframe?2:3}e=d(t),o=f[e]=p[e](t);let m=t[6].caption&&Rt(t);return{c(){n=g("div"),o.c(),m&&m.c(),s=x(),w(n,"class","bp-inner")},m(o,r){b(o,n,r),f[e].m(n,null),m&&m.m(o,r),b(o,s,r),u=1,l||(a=[v(n,"pointerdown",t[20]),v(n,"pointerup",t[21])],l=1)},p(t,r){let i=e;e=d(t),e===i?f[e].p(t,r):(X(),H(f[i],1,1,(()=>{f[i]=null})),Y(),o=f[e],o?o.p(t,r):(o=f[e]=p[e](t),o.c()),G(o,1),o.m(n,null)),t[6].caption?m?(m.p(t,r),64&r[0]&&G(m,1)):(m=Rt(t),m.c(),G(m,1),m.m(s.parentNode,s)):m&&(X(),H(m,1,1,(()=>{m=null})),Y())},i(e){u||(G(o),R((()=>{c&&c.end(1),i=U(n,t[12],1),i.start()})),G(m),u=1)},o(e){H(o),i&&i.S(),c=V(n,t[12],0),H(m),u=0},d(t){t&&h(n),f[e].d(),t&&c&&c.end(),m&&m.d(t),t&&h(s),l=0,r(a)}}}function qt(t){let n,e,o,i,c,s=`${t[4]+1} / ${t[0].length}`;return{c(){n=g("div"),e=g("button"),o=g("button"),w(n,"class","bp-count"),w(e,"class","bp-prev"),w(e,"title","Previous"),w(e,"aria-label","Previous"),w(o,"class","bp-next"),w(o,"title","Next"),w(o,"aria-label","Next")},m(r,u){b(r,n,u),n.innerHTML=s,b(r,e,u),b(r,o,u),i||(c=[v(e,"click",t[2]),v(o,"click",t[3])],i=1)},p(t,e){17&e[0]&&s!==(s=`${t[4]+1} / ${t[0].length}`)&&(n.innerHTML=s)},d(t){t&&h(n),t&&h(e),t&&h(o),i=0,r(c)}}}function Ft(t){let n,e,o=t[0]&&At(t);return{c(){o&&o.c(),n=x()},m(t,r){o&&o.m(t,r),b(t,n,r),e=1},p(t,e){t[0]?o?(o.p(t,e),1&e[0]&&G(o,1)):(o=At(t),o.c(),G(o,1),o.m(n.parentNode,n)):o&&(X(),H(o,1,1,(()=>{o=null})),Y())},i(t){e||(G(o),e=1)},o(t){H(o),e=0},d(t){o&&o.d(t),t&&h(n)}}}function Jt(t,n,e){let o,{items:r}=n,{target:i}=n;const c=document.documentElement;let u,l,a,p,f,d,m,b,h;const g=()=>!h.img&&!h.sources&&!h.iframe;let x;const v=t=>x=t,w={},y=st(0);s(t,y,(t=>e(10,o=t)));const $=()=>{l.onClose?.(w.el,h),at.set(1),e(0,r=null),p?.focus({preventScroll:1})},k=()=>M(u-1),_=()=>M(u+1),M=t=>{m=t-u,e(4,u=S(t))},S=t=>(t+r.length)%r.length,z=t=>{const{key:n,shiftKey:e}=t;if("Escape"===n)!l.noClose&&$();else if("ArrowRight"===n)_();else if("ArrowLeft"===n)k();else if("Tab"===n){const{activeElement:n}=document;if(e||!n.controls){t.preventDefault();const{focusWrap:o=w.el}=l,r=[...o.querySelectorAll("*")].filter((t=>t.tabIndex>=0));let i=r.indexOf(n);i+=r.length+(e?-1:1),r[i%r.length].focus()}}},I=({width:t=1920,height:n=1080})=>{const{scale:e=.99}=l,o=Math.min(1,w.w/t*e,w.h/n*e);return[Math.round(t*o),Math.round(n*o)]},P=()=>{if(r){const t=r[S(u+1)],n=r[S(u-1)];!t.preload&&T(t),!n.preload&&T(n)}},T=t=>{if(t.img){const n=document.createElement("img");return n.sizes=l.sizes||I(t)[0]+"px",n.srcset=t.img,t.preload=1,n.decode().catch((t=>{}))}};return t.R=t=>{"items"in t&&e(0,r=t.items),"target"in t&&e(15,i=t.target)},t.u.update=()=>{786545&t.u.v[0]&&r&&(e(6,h=r[u]),a&&l.onUpdate?.(w.el,h))},[r,$,k,_,u,l,h,f,d,b,o,y,(t,n)=>a&&r?it(t,{x:(m>0?20:-20)*(n?1:-1),duration:250}):(e(18,a=n),l.intro?it(t,{y:n?10:-10}):(t=>{let n;if(g()){const e=t.firstChild.firstChild;n=[e.clientWidth,e.clientHeight]}else n=I(h);const e=(h.element||p).getBoundingClientRect(),o=e.left-(w.w-e.width)/2,r=e.top-(w.h-e.height)/2,i=e.width/n[0],c=e.height/n[1];return{duration:480,_:rt,css:(t,n)=>`transform:translate3d(${o*n}px, ${r*n}px, 0) scale3d(${i+t*(1-i)}, ${c+t*(1-c)}, 1)`}})(t)),()=>({j:h,B:I,K:T,L:P,F:l,J:k,next:_,close:$,D:v,zoomed:y,container:w}),t=>{let n;e(19,w.el=t,w),l.onOpen?.(w.el,h),d||globalThis.addEventListener("keydown",z);const o=new ResizeObserver((t=>{n&&(e(19,w.w=t[0].contentRect.width,w),e(19,w.h=t[0].contentRect.height,w),e(7,f=769>w.w),g()||x?.(),l.onResize?.(w.el,h)),n=1}));return o.observe(t),{destroy(){o.disconnect(),globalThis.removeEventListener("keydown",z),at.set(0),c.classList.remove("bp-lock"),l.onClosed?.()}}},i,t=>{e(5,l=t),e(8,d=l.inline),!d&&c.scrollHeight>c.clientHeight&&c.classList.add("bp-lock"),p=document.activeElement,e(19,w.w=i.offsetWidth,w),e(19,w.h=i===document.body?globalThis.innerHeight:i.clientHeight,w),e(7,f=769>w.w),e(4,u=l.position||0),e(0,r=[]);for(let t=0;(l.items.length||1)>t;t++){let n=l.items[t]||l.items;"dataset"in n?r.push({element:n,i:t,...n.dataset}):(n.i=t,r.push(n),n=n.element),l.el&&l.el===n&&e(4,u=t)}},M,a,w,t=>e(9,b=t.target),function(t){2!==t.button&&t.target===this&&b===this&&!l.noClose&&$()}]}class Bt extends ot{constructor(t){super(),et(this,t,Jt,Ft,c,{items:0,target:15,open:16,close:1,J:2,next:3,setPosition:17},null,[-1,-1])}get items(){return this.u.$[0]}get target(){return this.u.$[15]}get open(){return this.u.$[16]}get close(){return this.u.$[1]}get J(){return this.u.$[2]}get next(){return this.u.$[3]}get setPosition(){return this.u.$[17]}}return t=>new Bt({...t,P:t})}(); +(function () { + "use strict"; + var script = document.createElement("script"); + script.src = "/assets/scripts/vendor/bigger-picture.min.js"; + script.defer = true; + document.head.appendChild(script); +}()); diff --git a/assets/scripts/comments.js b/assets/scripts/comments.js index 25596f3..51fa87d 100755 --- a/assets/scripts/comments.js +++ b/assets/scripts/comments.js @@ -1,196 +1,7 @@ -let pageSlug = null; - -/* ----------------------------- - * DOM builders - * ----------------------------- */ - - -function showReplyForm(container, parentId) { - if (container.querySelector(".reply-form")) return; - - const form = document.createElement("form"); - form.className = "reply-form"; - - form.innerHTML = ` - - - - `; - - form.addEventListener("submit", async e => { - e.preventDefault(); - - const author = form.author.value.trim(); - const content = form.content.value.trim(); - if (!content) return; - - const ok = await postComment(author, content, parentId); - if (ok) loadComments(); - else alert("Failed to post reply"); - }); - - container.appendChild(form); -} - -function createComment(comment) { - const wrapper = document.createElement("div"); - wrapper.className = "comment"; - wrapper.dataset.id = comment.id; - wrapper.id = `comment-${comment.id}`; - - const author = document.createElement("strong"); - author.textContent = comment.author || "Anonymous"; - - const body = document.createElement("p"); - body.textContent = comment.content; - - const time = document.createElement("time"); - time.className = "comment-date"; - time.dateTime = comment.created_at; - time.textContent = new Date(comment.created_at).toLocaleString(); - - const replyBtn = document.createElement("button"); - replyBtn.className = "comment-reply"; - replyBtn.textContent = "Reply"; - replyBtn.onclick = () => - showReplyForm(wrapper, Number(comment.id)); - - wrapper.append(author, body, time, replyBtn); - - if (comment.children?.length) { - const replies = document.createElement("div"); - replies.className = "comment-children"; - - comment.children.forEach(child => { - replies.appendChild(createComment(child)); - }); - - wrapper.appendChild(replies); - } - return wrapper; -} - - - -function buildCommentTree(comments) { - const byId = {}; - const roots = []; - - comments.forEach(comment => { - comment.children = []; - byId[comment.id] = comment; - }); - - comments.forEach(comment => { - if (comment.parent_id) { - const parent = byId[comment.parent_id]; - if (parent) parent.children.push(comment); - } else { - roots.push(comment); - } - }); - - return roots; -} - - -/* ----------------------------- - * Rendering - * ----------------------------- */ - -function renderComments(comments) { - const list = document.getElementById("comments-list"); - list.innerHTML = ""; - - if (!comments.length) { - const empty = document.createElement("p"); - empty.className = "comments-empty"; - empty.textContent = "No comments yet."; - list.appendChild(empty); - return; - } - - const tree = buildCommentTree(comments); - tree.forEach(comment => list.appendChild(createComment(comment))); -} - - -/* ----------------------------- - * API - * ----------------------------- */ - -async function fetchComments() { - const res = await fetch(`/api/comments/${pageSlug}`); - if (!res.ok) { - console.error("Failed to fetch comments"); - return []; - } - return await res.json(); -} - -async function postComment(author, content, parentId = null) { - const res = await fetch("/api/comments", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - page_slug: pageSlug, - author: author || null, - content, - parent_id: parentId - }) - }); - - return res.ok; -} - - - -/* ----------------------------- - * Form handling - * ----------------------------- */ - -function wireCommentForm() { - const form = document.getElementById("comment-form"); - - form.addEventListener("submit", async event => { - event.preventDefault(); - - const authorInput = form.querySelector("input[name='author']"); - const textarea = form.querySelector("textarea[name='content']"); - - const author = authorInput.value.trim(); - const content = textarea.value.trim(); - - if (!content) return; - - const ok = await postComment(author, content); - if (ok) { - textarea.value = ""; - loadComments(); - } else { - alert("Failed to post comment"); - } - }); -} - - -/* ----------------------------- - * Entry point - * ----------------------------- */ - -async function loadComments() { - const comments = await fetchComments(); - renderComments(comments); -} - -function initComments() { - const section = document.getElementById("comments"); - if (!section) return; - - pageSlug = section.dataset.slug; - - wireCommentForm(); - loadComments(); -} - -initComments(); +(function () { + "use strict"; + var script = document.createElement("script"); + script.src = "/assets/scripts/features/comments.js"; + script.defer = true; + document.head.appendChild(script); +}()); diff --git a/assets/scripts/competency-status-board.js b/assets/scripts/competency-status-board.js index baf3db3..483d1e0 100755 --- a/assets/scripts/competency-status-board.js +++ b/assets/scripts/competency-status-board.js @@ -1,240 +1,7 @@ -const STATES = [ - { key: "completed", label: "Completed" }, - { key: "manager_review", label: "Manager Review" }, - { key: "in_progress", label: "In Progress" }, - { key: "not_started", label: "Not Started" }, - { key: "comments", label: "Comments" }, -]; - -const LEVELS = [ - { key: "graduate", label: "Graduate" }, - { key: "engineer", label: "Engineer" }, -]; - -let currentLevel = LEVELS[0].key; - -function isMobile() { - return window.matchMedia("(max-width: 600px)").matches; -} - -function renderLevelTabs() { - const board = document.getElementById("kanban-board"); - if (!board || document.getElementById("level-tabs")) return; - - const nav = document.createElement("div"); - nav.id = "level-tabs"; - - LEVELS.forEach(({ key, label }) => { - const btn = document.createElement("button"); - btn.textContent = label; - btn.dataset.level = key; - btn.className = "level-tab" + (key === currentLevel ? " active" : ""); - btn.addEventListener("click", () => { - currentLevel = key; - document.querySelectorAll(".level-tab").forEach(b => - b.classList.toggle("active", b.dataset.level === key) - ); - loadBoard(); - }); - nav.appendChild(btn); - }); - - board.insertAdjacentElement("beforebegin", nav); - - if (!document.getElementById("level-tab-styles")) { - const style = document.createElement("style"); - style.id = "level-tab-styles"; - style.textContent = ` - #level-tabs { - display: flex; - gap: 0.5em; - margin-bottom: 1em; - } - .level-tab { - padding: 0.4em 1.2em; - border: 1px solid #ccc; - background: none; - cursor: pointer; - border-radius: 4px; - font-size: 0.95em; - } - .level-tab.active { - background: #333; - color: #fff; - border-color: #333; - } - `; - document.head.appendChild(style); - } -} - -function populateMobileControls(items) { - if (!isMobile()) return; - - const panel = document.getElementById("mobile-move-panel"); - const itemSelect = document.getElementById("move-item"); - const fromSelect = document.getElementById("move-from"); - const toSelect = document.getElementById("move-to"); - - itemSelect.innerHTML = ""; - fromSelect.innerHTML = ""; - toSelect.innerHTML = ""; - - items.forEach((item) => { - const opt = document.createElement("option"); - opt.value = item.id; - opt.textContent = item.title; - opt.dataset.state = item.state; - itemSelect.appendChild(opt); - }); - - STATES.forEach((s) => { - fromSelect.appendChild(new Option(s.label, s.key)); - toSelect.appendChild(new Option(s.label, s.key)); - }); - - itemSelect.onchange = () => { - const selected = itemSelect.selectedOptions[0]; - if (selected?.dataset.state) { - fromSelect.value = selected.dataset.state; - } - }; -} - -const moveConfirmBtn = document.getElementById("move-confirm"); -if (moveConfirmBtn) { - moveConfirmBtn.addEventListener("click", async () => { - const itemId = document.getElementById("move-item").value; - const from = document.getElementById("move-from").value; - const to = document.getElementById("move-to").value; - - if (!itemId || !to) { - alert("Select an item and target column"); - return; - } - - if (from === to) { - alert("Item is already in that column"); - return; - } - - const res = await fetch(`/api/competencies/items/${itemId}/state`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state: to }), - }); - - if (res.ok) { - loadBoard(); - } else { - alert("Failed to move competency"); - } - }); -} - -let draggedItemId = null; - -function countByState(items) { - return items.reduce((acc, item) => { - acc[item.state] = (acc[item.state] || 0) + 1; - return acc; - }, {}); -} - -async function updateProgress(items) { - const total = items.length; - const completed = items.filter(i => i.state === "completed").length; - const percent = total === 0 ? 0 : Math.round((completed / total) * 100); - - const fill = document.getElementById("progress-fill"); - const label = document.getElementById("progress-label"); - - if (fill) fill.style.width = `${percent}%`; - if (label) label.textContent = `${percent}% completed`; -} - -function createCard(item) { - const card = document.createElement("div"); - card.className = "kanban-card"; - card.draggable = true; - card.dataset.id = item.id; - card.textContent = item.title; - - card.addEventListener("dragstart", () => { - draggedItemId = item.id; - card.classList.add("dragging"); - }); - - card.addEventListener("dragend", () => { - draggedItemId = null; - card.classList.remove("dragging"); - }); - - return card; -} - -function createColumn(state, items, counts) { - const col = document.createElement("div"); - col.className = "kanban-column"; - col.dataset.state = state.key; - - const header = document.createElement("h3"); - header.innerHTML = ` - ${state.label} - ${counts[state.key] || 0} -`; - - const list = document.createElement("div"); - list.className = "kanban-list"; - - list.addEventListener("dragover", (e) => e.preventDefault()); - - list.addEventListener("drop", async () => { - if (!draggedItemId) return; - - const res = await fetch(`/api/competencies/items/${draggedItemId}/state`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state: state.key }), - }); - - if (res.ok) { - loadBoard(); - } else { - alert("Failed to update state"); - } - }); - - items - .filter((i) => i.state === state.key) - .forEach((i) => list.appendChild(createCard(i))); - - col.appendChild(header); - col.appendChild(list); - - return col; -} - -function renderBoard(items) { - const board = document.getElementById("kanban-board"); - if (!board) return; - board.innerHTML = ""; - - const counts = countByState(items); - - STATES.forEach((state) => { - board.appendChild(createColumn(state, items, counts)); - }); -} - -async function loadBoard() { - const res = await fetch(`/api/competencies/items?group=${currentLevel}`); - const items = await res.json(); - - updateProgress(items); - renderBoard(items); - populateMobileControls(items); -} - -renderLevelTabs(); -loadBoard(); +(function () { + "use strict"; + var script = document.createElement("script"); + script.src = "/assets/scripts/pages/competency-status-board.js"; + script.defer = true; + document.head.appendChild(script); +}()); diff --git a/assets/scripts/features/comments.js b/assets/scripts/features/comments.js new file mode 100644 index 0000000..25596f3 --- /dev/null +++ b/assets/scripts/features/comments.js @@ -0,0 +1,196 @@ +let pageSlug = null; + +/* ----------------------------- + * DOM builders + * ----------------------------- */ + + +function showReplyForm(container, parentId) { + if (container.querySelector(".reply-form")) return; + + const form = document.createElement("form"); + form.className = "reply-form"; + + form.innerHTML = ` + + + + `; + + form.addEventListener("submit", async e => { + e.preventDefault(); + + const author = form.author.value.trim(); + const content = form.content.value.trim(); + if (!content) return; + + const ok = await postComment(author, content, parentId); + if (ok) loadComments(); + else alert("Failed to post reply"); + }); + + container.appendChild(form); +} + +function createComment(comment) { + const wrapper = document.createElement("div"); + wrapper.className = "comment"; + wrapper.dataset.id = comment.id; + wrapper.id = `comment-${comment.id}`; + + const author = document.createElement("strong"); + author.textContent = comment.author || "Anonymous"; + + const body = document.createElement("p"); + body.textContent = comment.content; + + const time = document.createElement("time"); + time.className = "comment-date"; + time.dateTime = comment.created_at; + time.textContent = new Date(comment.created_at).toLocaleString(); + + const replyBtn = document.createElement("button"); + replyBtn.className = "comment-reply"; + replyBtn.textContent = "Reply"; + replyBtn.onclick = () => + showReplyForm(wrapper, Number(comment.id)); + + wrapper.append(author, body, time, replyBtn); + + if (comment.children?.length) { + const replies = document.createElement("div"); + replies.className = "comment-children"; + + comment.children.forEach(child => { + replies.appendChild(createComment(child)); + }); + + wrapper.appendChild(replies); + } + return wrapper; +} + + + +function buildCommentTree(comments) { + const byId = {}; + const roots = []; + + comments.forEach(comment => { + comment.children = []; + byId[comment.id] = comment; + }); + + comments.forEach(comment => { + if (comment.parent_id) { + const parent = byId[comment.parent_id]; + if (parent) parent.children.push(comment); + } else { + roots.push(comment); + } + }); + + return roots; +} + + +/* ----------------------------- + * Rendering + * ----------------------------- */ + +function renderComments(comments) { + const list = document.getElementById("comments-list"); + list.innerHTML = ""; + + if (!comments.length) { + const empty = document.createElement("p"); + empty.className = "comments-empty"; + empty.textContent = "No comments yet."; + list.appendChild(empty); + return; + } + + const tree = buildCommentTree(comments); + tree.forEach(comment => list.appendChild(createComment(comment))); +} + + +/* ----------------------------- + * API + * ----------------------------- */ + +async function fetchComments() { + const res = await fetch(`/api/comments/${pageSlug}`); + if (!res.ok) { + console.error("Failed to fetch comments"); + return []; + } + return await res.json(); +} + +async function postComment(author, content, parentId = null) { + const res = await fetch("/api/comments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + page_slug: pageSlug, + author: author || null, + content, + parent_id: parentId + }) + }); + + return res.ok; +} + + + +/* ----------------------------- + * Form handling + * ----------------------------- */ + +function wireCommentForm() { + const form = document.getElementById("comment-form"); + + form.addEventListener("submit", async event => { + event.preventDefault(); + + const authorInput = form.querySelector("input[name='author']"); + const textarea = form.querySelector("textarea[name='content']"); + + const author = authorInput.value.trim(); + const content = textarea.value.trim(); + + if (!content) return; + + const ok = await postComment(author, content); + if (ok) { + textarea.value = ""; + loadComments(); + } else { + alert("Failed to post comment"); + } + }); +} + + +/* ----------------------------- + * Entry point + * ----------------------------- */ + +async function loadComments() { + const comments = await fetchComments(); + renderComments(comments); +} + +function initComments() { + const section = document.getElementById("comments"); + if (!section) return; + + pageSlug = section.dataset.slug; + + wireCommentForm(); + loadComments(); +} + +initComments(); diff --git a/assets/scripts/features/hidden-details.js b/assets/scripts/features/hidden-details.js new file mode 100644 index 0000000..dd700c0 --- /dev/null +++ b/assets/scripts/features/hidden-details.js @@ -0,0 +1,624 @@ +(function () { + "use strict"; + + /* + * Hidden site details + * ------------------- + * This file adds small hidden interactions across the site: footer notes, + * click targets, search/keyboard secrets, rare messages, and page-specific + * behavior for the hidden /play pages. + * + * How to add your own things: + * - Add new text to EDITABLE CONTENT arrays such as `details`, `poems`, + * `greetings`, or `lore`. + * - Add search-triggered toast messages to `SEARCH_TOASTS`. + * - Add search-triggered page redirects to `SEARCH_ROUTES`. + * - Add typed keyboard phrases to `KEYBOARD_SECRETS` or `LONG_KEYBOARD_SECRETS`. + * - Add a new feature by writing an `addYourFeature(memory)` function and + * adding it to the `FEATURES` list at the bottom of the file. + */ + + // Storage keys. v1 is read once so old visitors keep their hidden progress. + const STORAGE_KEY = "zxh_hidden_details_v2"; + const OLD_STORAGE_KEY = "zxh_hidden_details_v1"; + + // Shared page context. These are captured once so daily random picks stay stable. + const now = new Date(); + const hour = now.getHours(); + const path = window.location.pathname; + const state = readState(); + // ----------------------------- + // EDITABLE CONTENT + // ----------------------------- + // Generated by the hidden narrative authoring page. + // Friendly source of truth: assets/content/hidden-details.json + + const familyLayers = []; + + const details = []; + + const poems = []; + + const greetings = []; + + const nightMessages = []; + + const lore = { + "quotes": [], + "conversations": [], + "journals": [], + "warnings": [], + "dreams": [], + "cassettes": [], + "fakeUsers": [], + "seasonal": {}, + "homepageTakeovers": [], + "roomLinks": [] +}; + + // Exact search text -> toast message. + const SEARCH_TOASTS = {}; + + // Exact search text -> hidden page route. + const SEARCH_ROUTES = {}; + + // Short typed phrases. Stored in sessionStorage as a rolling key chain. + const KEYBOARD_SECRETS = []; + + // Longer typed phrases and character names. + const LONG_KEYBOARD_SECRETS = []; + + + // ----------------------------- + // STATE HELPERS + // ----------------------------- + + function readState() { + let current = {}; + try { + current = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); + } catch (_) {} + if (!current.visits) { + try { + const oldState = JSON.parse(localStorage.getItem(OLD_STORAGE_KEY) || "{}"); + current = { ...oldState, migratedFromV1: true }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(current)); + } catch (_) {} + } + return current; + } + + function writeState(next) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch (_) {} + } + + function pick(list, salt) { + const seed = hash(`${path}|${now.toDateString()}|${salt || ""}`); + return list[seed % list.length]; + } + + function hash(value) { + let h = 2166136261; + for (let i = 0; i < value.length; i += 1) { + h ^= value.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return Math.abs(h >>> 0); + } + + // ----------------------------- + // UI HELPERS + // ----------------------------- + + function hiddenMessageMarkup(message) { + return `${escapeHtml(message)}`; + } + + function setHiddenMessage(el, message) { + delete el.dataset.hiddenCharacter; + el.innerHTML = hiddenMessageMarkup(message); + } + + function toast(message, duration) { + let el = document.querySelector(".hidden-toast"); + if (!el) { + el = document.createElement("div"); + el.className = "hidden-toast"; + el.setAttribute("role", "status"); + document.body.appendChild(el); + } + setHiddenMessage(el, message); + el.classList.add("is-visible"); + window.clearTimeout(el._timer); + el._timer = window.setTimeout(() => el.classList.remove("is-visible"), duration || 5600); + } + + function redirectTo(route) { + window.location.href = route; + } + + function runSecretAction(secret) { + if (secret.route) redirectTo(secret.route); + if (secret.message) toast(secret.message); + if (secret.song) playTinySong(); + } + + function layerForSearch(value) { + if (value.includes("future")) return 4; + if (value.includes("sensei") || value.includes("aphy")) return 3; + return 2; + } + + function rememberVisit() { + const visits = (state.visits || 0) + 1; + const seen = Array.isArray(state.seen) ? state.seen : []; + const pathCounts = { ...(state.pathCounts || {}) }; + pathCounts[path] = (pathCounts[path] || 0) + 1; + const layerVisits = { ...(state.layerVisits || {}) }; + const next = { + ...state, + visits, + lastPath: path, + pathCounts, + layerVisits, + seen: Array.from(new Set([...seen, path])).slice(-100), + firstSeen: state.firstSeen || new Date().toISOString(), + lastSeen: new Date().toISOString() + }; + writeState(next); + return next; + } + + function awardLayer(layer, reason) { + const next = readState(); + next.layerVisits = { ...(next.layerVisits || {}) }; + next.layerVisits[layer] = (next.layerVisits[layer] || 0) + 1; + next.lastLayerReason = reason; + writeState(next); + } + + // ----------------------------- + // GLOBAL FEATURES + // ----------------------------- + + function addFooterNote(memory) { + const footer = document.querySelector("footer"); + if (!footer || !details.length) return; + const note = document.createElement("p"); + note.className = "hidden-footer-note"; + const base = pick(details, "footer"); + setHiddenMessage(note, memory.visits > 4 ? `${base} You have passed through ${memory.visits} times.` : base); + footer.appendChild(note); + } + + function addHomepageGreeting(memory) { + const target = document.querySelector("#db-greeting"); + if (!target || !greetings.length) return; + setHiddenMessage(target, pick(greetings, `greeting-${memory.visits}`)); + const extra = document.createElement("p"); + extra.className = "hidden-greeting"; + setHiddenMessage(extra, ""); + target.closest("div")?.appendChild(extra); + } + + function addWhispers() { + // Event: click one of the tiny "?" marks appended to long paragraphs. + const paragraphs = Array.from(document.querySelectorAll("main p, #content p, article p")).filter((p) => p.textContent.trim().length > 80); + paragraphs.slice(0, 5).forEach((p, index) => { + if ((hash(path + index) + index) % 3 !== 0) return; + const mark = document.createElement("span"); + mark.className = "hidden-whisper"; + mark.tabIndex = 0; + mark.textContent = " ?"; + const source = index % 2 ? poems : details; + if (!source.length) return; + mark.title = pick(source, `whisper-${index}`); + mark.addEventListener("click", () => { + awardLayer(index % 2 ? 2 : 1, "paragraph whisper"); + toast(mark.title); + }); + p.appendChild(mark); + }); + } + + function addCornerObject() { + // Event: click the small fixed button in the bottom-left corner. + const object = document.createElement("button"); + object.className = "hidden-corner-object"; + object.type = "button"; + object.title = "hidden interaction"; + object.textContent = state.visits % 2 ? "*" : "~"; + document.body.appendChild(object); + let clicks = 0; + object.addEventListener("click", () => { + clicks += 1; + awardLayer(clicks > 3 ? 3 : 1, "corner object"); + toast("Hidden interaction recorded."); + if (clicks === 7) window.location.href = "/play/left-behind.html"; + }); + } + + function addLogoSearchAndKeyboardSecrets(memory) { + // Events: + // - Click `.site-brand` repeatedly on the homepage. + // - Type exact words into `#search-input`. + // - Type phrases anywhere on the page. + const nav = document.querySelector(".site-brand"); + if (nav) { + let taps = Number(sessionStorage.getItem("zxh_logo_taps") || 0); + nav.addEventListener("click", (event) => { + if (path === "/" || path === "/index.html") { + event.preventDefault(); + taps += 1; + sessionStorage.setItem("zxh_logo_taps", String(taps)); + awardLayer(taps >= 5 ? 3 : 1, "logo taps"); + toast(taps >= 5 ? "Hidden route available: /play/dream-corridor.html" : "Hidden interaction recorded."); + if (taps >= 8) window.location.href = "/play/dream-corridor.html"; + } + }); + } + + const search = document.querySelector("#search-input"); + if (search) { + search.addEventListener("input", () => { + const value = search.value.trim().toLowerCase(); + if (SEARCH_TOASTS[value]) toast(SEARCH_TOASTS[value]); + if (SEARCH_ROUTES[value]) { + awardLayer(layerForSearch(value), `search ${value}`); + redirectTo(SEARCH_ROUTES[value]); + } + }); + } + + document.addEventListener("keydown", (event) => { + const key = event.key.toLowerCase(); + const chain = `${sessionStorage.getItem("zxh_key_chain") || ""}${key}`.slice(-18); + sessionStorage.setItem("zxh_key_chain", chain); + KEYBOARD_SECRETS + .filter((secret) => chain.endsWith(secret.phrase)) + .forEach(runSecretAction); + }); + + if (memory.seen?.length >= 6 && !state.travellerNoteShown) { + window.setTimeout(() => { + toast("Hidden path recorded."); + writeState({ ...readState(), travellerNoteShown: true }); + }, 1600); + } + } + + function addRareEvents() { + if (hour >= 22 || hour < 5) { + document.body.classList.add("hidden-late-night"); + if (nightMessages.length) window.setTimeout(() => toast(pick(nightMessages, "night"), 2200), 900); + } + const roll = Math.random(); + if (roll < 0.003) { + window.setTimeout(() => showDriftNote("", ""), 1800); + awardLayer(3, "rare sensei appearance"); + } else if (roll < 0.008) { + window.setTimeout(() => toast("Hidden warning."), 2400); + awardLayer(4, "future warning"); + } + } + + function showDriftNote(text, art) { + const panel = document.createElement("aside"); + panel.className = "hidden-drift-note"; + panel.innerHTML = `
    ${hiddenMessageMarkup(text)}
    ${escapeHtml(art)}
    `; + panel.querySelector("button").addEventListener("click", () => panel.remove()); + document.body.appendChild(panel); + } + + function addSecretLinks() { + [ + ["/play/left-behind.html", "left behind"], + ["/play/dream-corridor.html", "dream corridor"], + ["/play/kitchen-light.html", "kitchen light"], + ...lore.roomLinks + ].forEach(([href, label], index) => { + const link = document.createElement("a"); + link.className = "hidden-secret-link"; + link.href = href; + link.textContent = label; + link.style.left = `${index + 1}px`; + document.body.appendChild(link); + }); + } + + function enhanceErrors() { + if (document.title.match(/404|not found/i) || document.body.textContent.match(/404|not found/i)) { + toast("Page not found."); + } + } + + function addContinuity(memory) { + const milestones = { + 64: "Hidden layer unlocked." + }; + if (milestones[memory.visits] && !state[`milestone_${memory.visits}`]) { + const layer = memory.visits >= 31 ? 4 : memory.visits >= 9 ? 3 : 2; + awardLayer(layer, `visit milestone ${memory.visits}`); + window.setTimeout(() => toast(milestones[memory.visits], 7000), 1200); + writeState({ ...readState(), [`milestone_${memory.visits}`]: true }); + } + + const count = memory.pathCounts?.[path] || 0; + if ([3, 7, 12].includes(count)) { + window.setTimeout(() => toast("Page revisit recorded.", 6400), 1700); + } + } + + function addSeasonAndDates(memory) { + const month = now.getMonth(); + const day = now.getDate(); + const season = month < 2 || month === 11 ? "winter" : month < 5 ? "spring" : month < 8 ? "summer" : "autumn"; + if (Math.random() < 0.18 && lore.seasonal[season]) window.setTimeout(() => toast(lore.seasonal[season], 5600), 2600); + const first = memory.firstSeen ? new Date(memory.firstSeen) : null; + if (first && memory.visits > 1 && first.getMonth() === month && first.getDate() === day) { + window.setTimeout(() => toast("Visitor anniversary recorded.", 7000), 1400); + } + } + + function addObjectConstellation() { + // Event: click the small keepsake buttons along the bottom rail. + const rail = document.createElement("div"); + rail.className = "hidden-object-rail"; + rail.setAttribute("aria-label", "Hidden objects"); + const objects = [ + ["ring", "Hidden object recorded."], + ["bot", "Hidden object recorded."], + ["tea", "Hidden object recorded."], + ["crayon", "Hidden object recorded."], + ["clock", "Hidden object recorded."] + ]; + objects.forEach(([name, message]) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `hidden-keepsake hidden-keepsake--${name}`; + button.title = name; + button.textContent = { ring: "o", bot: "#", tea: "u", crayon: "/", clock: ":" }[name]; + button.addEventListener("click", () => { + const next = readState(); + next.keepsakes = Array.from(new Set([...(next.keepsakes || []), name])); + writeState(next); + awardLayer(next.keepsakes.length >= 3 ? 3 : 2, `keepsake ${name}`); + toast(message); + if (next.keepsakes.length >= objects.length) { + window.setTimeout(() => toast("Hidden object set completed."), 1100); + } + }); + rail.appendChild(button); + }); + document.body.appendChild(rail); + } + + function addWeatherWindow(memory) { + // Event: click the small "window" button. This asks for browser geolocation. + if (memory.visits < 3 || !("geolocation" in navigator)) return; + const button = document.createElement("button"); + button.className = "hidden-weather-window"; + button.type = "button"; + button.title = "Check outside weather"; + button.textContent = "window"; + button.addEventListener("click", () => { + toast("Checking outside weather."); + navigator.geolocation.getCurrentPosition((pos) => { + const { latitude, longitude } = pos.coords; + fetch(`https://api.open-meteo.com/v1/forecast?latitude=${latitude.toFixed(3)}&longitude=${longitude.toFixed(3)}¤t=temperature_2m,precipitation&timezone=auto`) + .then((res) => res.json()) + .then((data) => { + const current = data.current || {}; + const rain = Number(current.precipitation || 0) > 0; + const temp = Math.round(Number(current.temperature_2m)); + toast(rain ? `Outside: rain, ${temp}C.` : `Outside: ${temp}C.`); + }) + .catch(() => toast("Weather check failed.")); + }, () => toast("No outside weather today.")); + }); + document.body.appendChild(button); + } + + function addOldWebLayer(memory) { + if (Math.random() < 0.08 || memory.visits > 10) { + const stamp = document.createElement("div"); + stamp.className = "hidden-oldweb-stamp"; + if (!lore.fakeUsers.length) return; + stamp.title = pick(lore.fakeUsers, "fake-user"); + stamp.textContent = "best viewed with patience"; + stamp.addEventListener("click", () => { + awardLayer(1, "old web stamp"); + toast(stamp.title); + }); + document.body.appendChild(stamp); + } + const comments = document.querySelector("#comments"); + if (comments && lore.fakeUsers.length && !comments.querySelector(".hidden-guestbook-line")) { + const line = document.createElement("p"); + line.className = "hidden-guestbook-line"; + setHiddenMessage(line, pick(lore.fakeUsers, "comment-lore")); + comments.appendChild(line); + } + } + + function addSourceRelics() { + const marker = document.createComment("hidden-details"); + document.documentElement.appendChild(marker); + document.querySelectorAll("img[alt=''], img:not([alt])").forEach((img, index) => { + if (index > 2) return; + img.alt = "Decorative image."; + }); + } + + function addLongKeyboardSecrets() { + // Event: type longer hidden phrases anywhere on the page. + document.addEventListener("keydown", (event) => { + const chain = `${sessionStorage.getItem("zxh_second_chain") || ""}${event.key.toLowerCase()}`.slice(-24); + sessionStorage.setItem("zxh_second_chain", chain); + LONG_KEYBOARD_SECRETS + .filter((secret) => chain.endsWith(secret.phrase)) + .forEach(runSecretAction); + }); + } + + function addPatienceRewards(memory) { + // Event: no movement, key presses, scrolling, or clicking for 90 seconds. + if (memory.visits < 2) return; + let idleTimer = null; + const startIdle = () => { + window.clearTimeout(idleTimer); + idleTimer = window.setTimeout(() => { + awardLayer(5, "patience"); + toast("Idle moment recorded.", 7600); + const next = readState(); + next.patientMoments = (next.patientMoments || 0) + 1; + writeState(next); + }, 90000); + }; + ["mousemove", "keydown", "scroll", "click"].forEach((name) => document.addEventListener(name, startIdle, { passive: true })); + startIdle(); + } + + function addRareSecondLayer() { + const roll = Math.random(); + if ((path === "/" || path === "/index.html") && roll < 0.006) { + document.body.classList.add("hidden-home-takeover"); + if (lore.homepageTakeovers.length) window.setTimeout(() => showDriftNote(pick(lore.homepageTakeovers, "takeover"), ""), 600); + } else if (roll < 0.002) { + window.setTimeout(() => showDriftNote("", ""), 1500); + } else if (roll < 0.006) { + if (lore.dreams.length) window.setTimeout(() => toast(pick(lore.dreams, "rare-dream"), 7600), 2000); + } else if (roll < 0.014) { + if (lore.warnings.length) window.setTimeout(() => toast(pick(lore.warnings, "rare-warning"), 6800), 2300); + } + } + + function addFamilyLayerIndex(memory) { + if (!path.includes("/play/family-layer-index")) return; + const target = document.querySelector("[data-family-layer-index]"); + if (!target) return; + const visits = readState().layerVisits || {}; + target.innerHTML = familyLayers.map((line, layer) => { + const count = visits[layer] || 0; + return `
  • ${hiddenMessageMarkup(line)}
    local encounters: ${count}
  • `; + }).join(""); + } + + function addPageSpecificSecrets() { + // Events only used by specific `/play/...` pages. + if (path.includes("/play/cassette-log")) { + document.querySelectorAll("[data-cassette]").forEach((button, index) => { + button.addEventListener("click", () => { + awardLayer(index >= 2 ? 4 : 2, "voice note"); + if (lore.cassettes.length) toast(lore.cassettes[index % lore.cassettes.length], 7600); + playTinySong(); + }); + }); + } + if (path.includes("/play/patience-game")) { + const target = document.querySelector("[data-patience-target]"); + const count = document.querySelector("[data-patience-count]"); + if (target && count) { + let seconds = 0; + setInterval(() => { + seconds += 1; + count.textContent = String(seconds); + if ([30, 90, 180].includes(seconds)) { + setHiddenMessage(target, "Patience milestone recorded."); + } + }, 1000); + } + } + if (path.includes("/play/terminal-cupboard")) { + const input = document.querySelector("[data-cupboard-input]"); + const log = document.querySelector("[data-cupboard-log]"); + const commands = { + help: "commands: layer, exit", + layer: familyLayers.join("\n"), + exit: "Closed." + }; + input?.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + const value = input.value.trim().toLowerCase(); + const line = document.createElement("p"); + setHiddenMessage(line, `> ${value}\n${commands[value] || "Unknown command."}`); + log.appendChild(line); + input.value = ""; + }); + } + } + + function addInvisibleHoverSecrets() { + document.querySelectorAll("h1, h2").forEach((heading, index) => { + if (index > 5) return; + heading.classList.add("hidden-hover-memory"); + const source = [...lore.quotes, ...lore.journals, ...poems]; + if (!source.length) return; + heading.dataset.hiddenMemory = pick(source, `heading-${index}`); + }); + } + + function playTinySong() { + try { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return; + const ctx = new AudioContext(); + const notes = [392, 494, 440, 330, 392]; + notes.forEach((freq, index) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.frequency.value = freq; + osc.type = "sine"; + gain.gain.setValueAtTime(0.0001, ctx.currentTime + index * 0.18); + gain.gain.exponentialRampToValueAtTime(0.05, ctx.currentTime + index * 0.18 + 0.03); + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + index * 0.18 + 0.16); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(ctx.currentTime + index * 0.18); + osc.stop(ctx.currentTime + index * 0.18 + 0.18); + }); + } catch (_) {} + } + + function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }[char])); + } + + // Features run in this order on every page after the DOM is ready. + // Each function receives the current `memory` object. + const FEATURES = [ + addFooterNote, + addHomepageGreeting, + addWhispers, + addCornerObject, + addLogoSearchAndKeyboardSecrets, + addRareEvents, + addSecretLinks, + enhanceErrors, + addContinuity, + addSeasonAndDates, + addObjectConstellation, + addWeatherWindow, + addOldWebLayer, + addSourceRelics, + addLongKeyboardSecrets, + addPatienceRewards, + addRareSecondLayer, + addFamilyLayerIndex, + addPageSpecificSecrets, + addInvisibleHoverSecrets + ]; + + document.addEventListener("DOMContentLoaded", () => { + const memory = rememberVisit(); + FEATURES.forEach((addFeature) => addFeature(memory)); + }); +}()); diff --git a/assets/scripts/gallery-init.js b/assets/scripts/gallery-init.js index c76eb76..99d4d66 100755 --- a/assets/scripts/gallery-init.js +++ b/assets/scripts/gallery-init.js @@ -1,315 +1,7 @@ -document.addEventListener('DOMContentLoaded', () => { - if (typeof window.BiggerPicture !== 'function') { - console.error('[gallery-init] BiggerPicture not found. Check script path.'); - return; - } - const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'm4v', 'ogv']; - - const isVideo = (href) => { - if (!href) return false; - href = href.toLowerCase(); - return VIDEO_EXTENSIONS.some(ext => href.endsWith('.' + ext)); - }; - - // 1b) Convert MP4 links into inline