Add cinematic Corvid IF page

This commit is contained in:
Meta Vens 2026-05-05 02:04:20 +01:00
commit 98ce45db30
22 changed files with 448 additions and 7 deletions

View file

@ -0,0 +1,26 @@
---
title: "Corvid Collective: First Exodus"
description: "A playable Ghostlight branch-and-fold founding scene for the Corvid Collective: an uplifted raven turns containment routines, human guilt, and bad telemetry into the first crack in the cage."
---
# Corvid Collective: First Exodus
_A playable Ghostlight branch-and-fold scene._
You are Kesh-of-Three-Clicks, an uplifted raven reconnaissance asset inside NeuroSyn's Blackbox Aviary 3C. Two hours before a Rossum & Douglas audit, the room still looks ordinary: perch ladders, welfare logs, a bad hatch, a tray of wing tags, a handler pretending the language of care still belongs to her.
That is the useful thing about ordinary cruelty. It has handles.
This is a Ghostlight fixture built for illustrated interactive replay: short sections, branching choices, visible consequences, and state variables that keep score while everyone pretends the cage is just a workplace with feathers in it.
<div
class="aetheria-ink-player"
data-ink-mode="cinematic"
data-ink-title="Corvid Collective: First Exodus"
data-ink-story="/static/interactive/corvid-collective-founding.branch-and-fold.v0.json"
data-visual-manifest="/static/interactive/corvid-collective-founding.visual-manifest.v0.json"
></div>
## Source Note
This scene is grounded in [[Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Corvid Collective|Corvid Collective]], [[Worldbuilding/Pre-Elysium/Factions/Powers/Minor/NeuroSyn|NeuroSyn]], [[Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Rossum & Douglas|Rossum & Douglas]], and late-Sol uplift politics. Some room-scale containment and raven-interface details remain Ghostlight draft elaboration until backfilled into the lore vault.

View file

@ -13,6 +13,7 @@ Some of the pieces gathered here were written before the current canon settled i
## Current Stories
- [[Corvid Collective First Exodus]]
- [[Lucent Hostage Feed]]
- [[Rain]]
- [[When We Get Home]]

View file

@ -69,6 +69,82 @@
}
}
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
@ -118,6 +194,50 @@
})
}
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"
@ -129,16 +249,29 @@
}
const status = createElement("p", "aetheria-ink-status", "Loading story...")
const transcript = createElement("div", "aetheria-ink-transcript")
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"
container.replaceChildren(status, transcript, choices, variables, restart)
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) {
@ -150,17 +283,46 @@
function reset() {
const story = new window.inkjs.Story(JSON.stringify(storyJson))
status.textContent = container.dataset.inkTitle || "Interactive story"
transcript.replaceChildren()
appendParagraphs(story, transcript)
renderVariables(story, variables)
renderChoices(story, choices, variables)
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."
transcript.append(createElement("p", "aetheria-ink-error", error.message || String(error)))
const errorTarget = transcript || (stageState && stageState.sectionText)
errorTarget.append(createElement("p", "aetheria-ink-error", error.message || String(error)))
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,25 @@
{
"schema_version": "aetheria.ink_visual_manifest.v0",
"fixture_id": "corvid-collective-founding-v0",
"title": "Corvid Collective: First Exodus",
"image_base": "/static/interactive/corvid/",
"default_caption": "Blackbox Aviary 3C",
"slides": [
{ "image": "01_01_aviary_establishing.jpg", "caption": "Blackbox Aviary 3C" },
{ "image": "02_02_human_levers.jpg", "caption": "Human levers" },
{ "image": "03_03_audit_glass.jpg", "caption": "Audit glass" },
{ "image": "04_04_kesh_interface.jpg", "caption": "Kesh at the buffer" },
{ "image": "05_05_cache_tools.jpg", "caption": "Small tools, large consequence" },
{ "image": "06_06_mimicry_pressure.jpg", "caption": "Mimicry pressure" },
{ "image": "07_07_optical_proof.jpg", "caption": "Optical proof" },
{ "image": "08_08_false_calm.jpg", "caption": "False calm" },
{ "image": "09_09_alarm_juvenile.jpg", "caption": "Alarm rupture" },
{ "image": "10_10_choice_web.jpg", "caption": "Four levers" },
{ "image": "11_11_human_pressure.jpg", "caption": "Human pressure" },
{ "image": "12_12_threshold.jpg", "caption": "Final threshold" },
{ "image": "13_13_flock_escape.jpg", "caption": "Flock escape" },
{ "image": "14_14_human_leverage_end.jpg", "caption": "Human leverage" },
{ "image": "15_15_proof_end.jpg", "caption": "Proof survives" },
{ "image": "16_16_disappearance_end.jpg", "caption": "Clean disappearance" }
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

View file

@ -645,6 +645,232 @@ article > h6 {
padding: 0.55rem 0.85rem;
}
.aetheria-ink-player-cinematic {
position: relative;
gap: 0.85rem;
padding: clamp(0.8rem, 2vw, 1.1rem);
border-color: rgba(255, 198, 120, 0.2);
background:
radial-gradient(circle at 16% 12%, rgba(255, 198, 120, 0.12), transparent 24%),
radial-gradient(circle at 82% 10%, rgba(104, 172, 255, 0.1), transparent 26%),
linear-gradient(180deg, rgba(7, 8, 12, 0.98), rgba(9, 13, 19, 0.96));
overflow: hidden;
}
.aetheria-ink-player-cinematic::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
background-size: 42px 42px;
opacity: 0.18;
mix-blend-mode: screen;
}
.aetheria-ink-cinematic-stage {
position: relative;
min-height: clamp(560px, 72vh, 820px);
border-radius: 24px;
overflow: hidden;
border: 1px solid rgba(255, 198, 120, 0.18);
background: #05070b;
box-shadow:
0 28px 68px rgba(0, 0, 0, 0.44),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.aetheria-ink-visual,
.aetheria-ink-visual-plate,
.aetheria-ink-visual-scrim {
position: absolute;
inset: 0;
}
.aetheria-ink-visual-plate {
background-position: center;
background-size: cover;
opacity: 0;
transform: scale(1.035);
filter: saturate(1.08) contrast(1.02) brightness(0.86);
transition:
opacity 780ms ease,
transform 1400ms ease;
}
.aetheria-ink-visual-plate.is-active {
opacity: 1;
transform: scale(1);
}
.aetheria-ink-visual-scrim {
background:
radial-gradient(circle at 50% 32%, transparent 0%, rgba(0, 0, 0, 0.1) 34%, rgba(0, 0, 0, 0.7) 100%),
linear-gradient(180deg, rgba(3, 5, 8, 0.18), rgba(3, 5, 8, 0.82));
}
.aetheria-ink-visual-caption {
position: absolute;
top: 1rem;
left: 1rem;
margin: 0;
padding: 0.4rem 0.65rem;
border: 1px solid rgba(255, 198, 120, 0.24);
border-radius: 999px;
background: rgba(5, 8, 13, 0.62);
color: rgba(255, 225, 184, 0.92);
font-family: var(--titleFont);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
backdrop-filter: blur(12px);
}
.aetheria-ink-section {
position: absolute;
inset: auto clamp(0.85rem, 3vw, 2rem) clamp(0.85rem, 3vw, 2rem);
display: grid;
place-items: end stretch;
}
.aetheria-ink-section-text {
max-width: min(760px, 100%);
padding: clamp(1rem, 2.3vw, 1.45rem);
border: 1px solid rgba(255, 198, 120, 0.18);
border-radius: 24px;
background:
linear-gradient(135deg, rgba(7, 10, 15, 0.72), rgba(10, 16, 23, 0.54)),
radial-gradient(circle at 14% 0%, rgba(255, 198, 120, 0.14), transparent 26%);
box-shadow:
0 20px 54px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
backdrop-filter: blur(18px);
}
.aetheria-ink-section-text .aetheria-ink-line {
color: rgba(246, 241, 226, 0.96);
font-size: clamp(1.05rem, 1.8vw, 1.32rem);
line-height: 1.65;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
}
.aetheria-ink-section-text.is-entering {
animation: aetheriaInkTextIn 520ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.aetheria-ink-section-text.is-exiting {
animation: aetheriaInkTextOut 220ms ease both;
}
.aetheria-ink-player-cinematic .aetheria-ink-choices {
position: relative;
z-index: 1;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
}
.aetheria-ink-player-cinematic .aetheria-ink-choice {
border-radius: 18px;
border-color: rgba(255, 198, 120, 0.2);
background:
linear-gradient(135deg, rgba(17, 23, 31, 0.92), rgba(8, 12, 17, 0.86)),
radial-gradient(circle at 0% 0%, rgba(255, 198, 120, 0.12), transparent 30%);
color: rgba(248, 240, 225, 0.94);
box-shadow:
0 12px 26px rgba(0, 0, 0, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.aetheria-ink-player-cinematic .aetheria-ink-choice:hover,
.aetheria-ink-player-cinematic .aetheria-ink-choice:focus-visible {
border-color: rgba(255, 198, 120, 0.62);
background:
linear-gradient(135deg, rgba(40, 30, 18, 0.94), rgba(13, 16, 21, 0.9)),
radial-gradient(circle at 10% 0%, rgba(255, 198, 120, 0.22), transparent 34%);
transform: translateY(-2px);
}
.aetheria-ink-continue {
justify-self: center;
}
.aetheria-ink-continue,
.aetheria-ink-player-cinematic .aetheria-ink-restart {
position: relative;
z-index: 1;
cursor: pointer;
padding: 0.65rem 1rem;
border: 1px solid rgba(255, 198, 120, 0.28);
border-radius: 999px;
background: rgba(255, 198, 120, 0.1);
color: rgba(255, 235, 205, 0.94);
font-family: var(--titleFont);
letter-spacing: 0.08em;
text-transform: uppercase;
transition:
background 160ms ease,
border-color 160ms ease,
transform 160ms ease;
}
.aetheria-ink-continue:hover,
.aetheria-ink-continue:focus-visible,
.aetheria-ink-player-cinematic .aetheria-ink-restart:hover,
.aetheria-ink-player-cinematic .aetheria-ink-restart:focus-visible {
border-color: rgba(255, 198, 120, 0.6);
background: rgba(255, 198, 120, 0.18);
transform: translateY(-1px);
}
.aetheria-ink-continue[hidden] {
display: none;
}
.aetheria-ink-player-cinematic .aetheria-ink-variables[hidden] {
display: none;
}
.aetheria-ink-player-cinematic .aetheria-ink-variables {
position: relative;
z-index: 1;
justify-content: center;
}
.aetheria-ink-player-cinematic .aetheria-ink-status,
.aetheria-ink-player-cinematic .aetheria-ink-restart {
position: relative;
z-index: 1;
}
@keyframes aetheriaInkTextIn {
from {
opacity: 0;
transform: translateY(18px) scale(0.985);
filter: blur(10px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
@keyframes aetheriaInkTextOut {
from {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
to {
opacity: 0;
transform: translateY(-8px);
filter: blur(8px);
}
}
.aetheria-section-header h2 {
display: block;
margin: 0;