335 lines
11 KiB
JavaScript
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()
|
|
})()
|