(function () { let inkRuntimePromise = null function createElement(tag, className, text) { const element = document.createElement(tag) if (className) element.className = className if (text !== undefined) element.textContent = text return element } function ensureInkRuntime() { if (window.inkjs && window.inkjs.Story) return Promise.resolve() if (inkRuntimePromise) return inkRuntimePromise inkRuntimePromise = new Promise((resolve, reject) => { const existing = document.querySelector('script[data-aetheria-ink-runtime="true"]') if (existing) { existing.addEventListener("load", resolve, { once: true }) existing.addEventListener("error", () => reject(new Error("inkjs runtime failed to load")), { once: true, }) return } const script = document.createElement("script") script.src = "/static/interactive/ink.js" script.defer = true script.dataset.aetheriaInkRuntime = "true" script.addEventListener("load", resolve, { once: true }) script.addEventListener("error", () => reject(new Error("inkjs runtime failed to load")), { once: true, }) document.head.appendChild(script) }) return inkRuntimePromise } function formatVariableValue(value) { if (value === true) return "true" if (value === false) return "false" if (value === null || value === undefined) return "unset" return String(value) } function collectVariables(story) { const state = story.variablesState || {} return Object.keys(state) .filter((name) => !name.startsWith("_") && name !== "$" && typeof state[name] !== "function") .sort() .map((name) => ({ name, value: state[name], })) } function renderVariables(story, target) { const variables = collectVariables(story) target.replaceChildren() if (variables.length === 0) return for (const variable of variables) { const chip = createElement("span", "aetheria-ink-variable") chip.append(createElement("span", "aetheria-ink-variable-name", variable.name)) chip.append( createElement("span", "aetheria-ink-variable-value", formatVariableValue(variable.value)), ) target.append(chip) } } async function loadVisualManifest(container) { const manifestSrc = container.dataset.visualManifest if (!manifestSrc) return null const response = await fetch(manifestSrc) if (!response.ok) { throw new Error(`could not fetch ${manifestSrc}: ${response.status}`) } return response.json() } function resolveSlideUrl(manifest, slide) { if (!manifest || !slide) return "" const base = manifest.image_base || "" return `${base}${slide.image || ""}` } function createCinematicStage() { const stage = createElement("div", "aetheria-ink-cinematic-stage") const visual = createElement("div", "aetheria-ink-visual") const plateA = createElement("div", "aetheria-ink-visual-plate is-active") const plateB = createElement("div", "aetheria-ink-visual-plate") const scrim = createElement("div", "aetheria-ink-visual-scrim") const caption = createElement("p", "aetheria-ink-visual-caption") const section = createElement("div", "aetheria-ink-section") const sectionText = createElement("div", "aetheria-ink-section-text") visual.append(plateA, plateB, scrim, caption) section.append(sectionText) stage.append(visual, section) return { stage, plateA, plateB, caption, sectionText, activePlate: plateA, inactivePlate: plateB, slideIndex: -1, } } function setCinematicSlide(stageState, manifest, index) { if (!stageState || !manifest || !Array.isArray(manifest.slides) || manifest.slides.length === 0) { return } const clamped = Math.min(Math.max(index, 0), manifest.slides.length - 1) if (stageState.slideIndex === clamped) return const slide = manifest.slides[clamped] const nextPlate = stageState.inactivePlate nextPlate.style.backgroundImage = `url("${resolveSlideUrl(manifest, slide)}")` nextPlate.classList.add("is-active") stageState.activePlate.classList.remove("is-active") const previous = stageState.activePlate stageState.activePlate = nextPlate stageState.inactivePlate = previous stageState.slideIndex = clamped stageState.caption.textContent = slide.caption || manifest.default_caption || "" } function renderCinematicText(stageState, text) { stageState.sectionText.replaceChildren() const paragraph = createElement("p", "aetheria-ink-line", text) stageState.sectionText.append(paragraph) stageState.sectionText.classList.remove("is-entering") // Force a reflow so repeated sections replay the transition. void stageState.sectionText.offsetWidth stageState.sectionText.classList.add("is-entering") } function appendParagraphs(story, transcript) { let advanced = false let safety = 0 while (story.canContinue && safety < 1000) { const text = story.Continue().trim() safety += 1 if (text.length > 0) { transcript.append(createElement("p", "aetheria-ink-line", text)) advanced = true } } if (safety >= 1000) { transcript.append( createElement( "p", "aetheria-ink-error", "The story did not settle before the safety limit. Something in the Ink is looping.", ), ) } if (advanced) { transcript.scrollTop = transcript.scrollHeight } } function renderChoices(story, choices, variables) { choices.replaceChildren() if (story.currentChoices.length === 0) { choices.append(createElement("p", "aetheria-ink-end", "End of branch. The feed stops blinking.")) return } story.currentChoices.forEach((choice, index) => { const button = createElement("button", "aetheria-ink-choice", choice.text) button.type = "button" button.addEventListener("click", () => { story.ChooseChoiceIndex(index) appendParagraphs(story, choices.closest(".aetheria-ink-player").querySelector(".aetheria-ink-transcript")) renderVariables(story, variables) renderChoices(story, choices, variables) }) choices.append(button) }) } function renderCinematicStep(story, state) { const { choices, variables, stageState, manifest, continueButton } = state choices.replaceChildren() continueButton.hidden = true if (story.canContinue) { const text = story.Continue().trim() if (text.length > 0) { setCinematicSlide(stageState, manifest, stageState.slideIndex + 1) renderCinematicText(stageState, text) stageState.stage.scrollIntoView({ behavior: "smooth", block: "center" }) } renderVariables(story, variables) if (story.canContinue) { continueButton.hidden = false continueButton.textContent = "Continue" return } } renderVariables(story, variables) if (story.currentChoices.length === 0) { choices.append(createElement("p", "aetheria-ink-end", "End of branch. The cage pretends it was always empty.")) return } story.currentChoices.forEach((choice, index) => { const button = createElement("button", "aetheria-ink-choice", choice.text) button.type = "button" button.addEventListener("click", () => { stageState.sectionText.classList.add("is-exiting") window.setTimeout(() => { story.ChooseChoiceIndex(index) stageState.sectionText.classList.remove("is-exiting") renderCinematicStep(story, state) }, 180) }) choices.append(button) }) } async function initialisePlayer(container) { if (container.dataset.inkInitialised === "true") return container.dataset.inkInitialised = "true" const storySrc = container.dataset.inkStory if (!storySrc) { container.textContent = "Ink player is missing data-ink-story." return } const status = createElement("p", "aetheria-ink-status", "Loading story...") const isCinematic = container.dataset.inkMode === "cinematic" const showVariables = !isCinematic || container.dataset.showVariables === "true" const transcript = isCinematic ? null : createElement("div", "aetheria-ink-transcript") const stageState = isCinematic ? createCinematicStage() : null const choices = createElement("div", "aetheria-ink-choices") const variables = createElement("div", "aetheria-ink-variables") const continueButton = createElement("button", "aetheria-ink-continue", "Continue") const restart = createElement("button", "aetheria-ink-restart", "Restart") continueButton.type = "button" continueButton.hidden = true restart.type = "button" if (isCinematic) { container.classList.add("aetheria-ink-player-cinematic") variables.hidden = !showVariables container.replaceChildren(status, stageState.stage, choices, variables, continueButton, restart) } else { container.replaceChildren(status, transcript, choices, variables, restart) } try { await ensureInkRuntime() const visualManifest = await loadVisualManifest(container) const response = await fetch(storySrc) if (!response.ok) { throw new Error(`could not fetch ${storySrc}: ${response.status}`) } const storyJson = await response.json() function reset() { const story = new window.inkjs.Story(JSON.stringify(storyJson)) status.textContent = container.dataset.inkTitle || "Interactive story" choices.replaceChildren() variables.replaceChildren() if (isCinematic) { stageState.slideIndex = -1 stageState.caption.textContent = "" stageState.sectionText.replaceChildren() continueButton.onclick = () => { stageState.sectionText.classList.add("is-exiting") window.setTimeout(() => { stageState.sectionText.classList.remove("is-exiting") renderCinematicStep(story, { choices, variables, stageState, manifest: visualManifest, continueButton, }) }, 180) } renderCinematicStep(story, { choices, variables, stageState, manifest: visualManifest, continueButton, }) } else { transcript.replaceChildren() appendParagraphs(story, transcript) renderVariables(story, variables) renderChoices(story, choices, variables) } } restart.addEventListener("click", reset) reset() } catch (error) { status.textContent = "Ink player failed to load." const errorTarget = transcript || (stageState && stageState.sectionText) errorTarget.append(createElement("p", "aetheria-ink-error", error.message || String(error))) } } function initialiseAllPlayers() { document.querySelectorAll(".aetheria-ink-player").forEach(initialisePlayer) } document.addEventListener("nav", initialiseAllPlayers) if (document.readyState !== "loading") initialiseAllPlayers() })()