AetheriaLore/Aetheria/static/interactive/aetheria-ink-player.js
2026-05-05 02:04:20 +01:00

335 lines
11 KiB
JavaScript

(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()
})()