Publish Sai VN Eve GUI surface
This commit is contained in:
parent
858da906f2
commit
71ce0a74f2
5 changed files with 4027 additions and 0 deletions
|
|
@ -35,6 +35,7 @@ sidebarGroups:
|
|||
data-ink-story="/static/interactive/gamecult-compound/gamecult-compound-tour.ink.json"
|
||||
data-ink-start-path="pitch_intro"
|
||||
data-visual-manifest="/static/interactive/gamecult-compound/gamecult-compound.visual-manifest.json"
|
||||
data-eve-gui="/static/interactive/gamecult-compound/gamecult-compound.eve-gui.json"
|
||||
></div>
|
||||
|
||||
<section class="gamecult-vn-source" aria-label="GameCult public wiki doors and portfolio pitch cards">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -68,6 +68,7 @@ cssclasses:
|
|||
data-ink-title="GameCult Compound Tour"
|
||||
data-ink-story="/static/interactive/gamecult-compound/gamecult-compound-tour.ink.json"
|
||||
data-visual-manifest="/static/interactive/gamecult-compound/gamecult-compound.visual-manifest.json"
|
||||
data-eve-gui="/static/interactive/gamecult-compound/gamecult-compound.eve-gui.json"
|
||||
></div>
|
||||
|
||||
<script src="/static/interactive/gamecult-compound/repo-doc-tree.js" defer></script>
|
||||
|
|
|
|||
342
scripts/generate-vn-eve-surface.mjs
Normal file
342
scripts/generate-vn-eve-surface.mjs
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
|
||||
const surfaces = [
|
||||
{
|
||||
providerId: "gamecult.site.sai.compound",
|
||||
title: "GameCult Compound Sai VN",
|
||||
storyPath: "GameCult/static/interactive/gamecult-compound/gamecult-compound-tour.ink",
|
||||
storyJson: "/static/interactive/gamecult-compound/gamecult-compound-tour.ink.json",
|
||||
manifestPath: "GameCult/static/interactive/gamecult-compound/gamecult-compound.visual-manifest.json",
|
||||
manifestUrl: "/static/interactive/gamecult-compound/gamecult-compound.visual-manifest.json",
|
||||
outputPath: "GameCult/static/interactive/gamecult-compound/gamecult-compound.eve-gui.json",
|
||||
defaultStartPath: "welcome",
|
||||
},
|
||||
]
|
||||
|
||||
function readJson(relativePath) {
|
||||
return JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), "utf8"))
|
||||
}
|
||||
|
||||
function readText(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8")
|
||||
}
|
||||
|
||||
function component(id, kind, props = {}, children = []) {
|
||||
return { id, kind, props, children }
|
||||
}
|
||||
|
||||
function normalizeId(value) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_./:-]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
}
|
||||
|
||||
function parseInk(source) {
|
||||
const knots = []
|
||||
let current
|
||||
|
||||
for (const rawLine of source.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
const knotMatch = line.match(/^===\s+([A-Za-z0-9_./:-]+)\s+===/)
|
||||
if (knotMatch) {
|
||||
current = {
|
||||
id: knotMatch[1],
|
||||
tags: {},
|
||||
choices: [],
|
||||
redirects: [],
|
||||
lines: [],
|
||||
}
|
||||
knots.push(current)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!current || line.length === 0 || line.startsWith("VAR ") || line.startsWith("~ ")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const tagMatch = line.match(/^#\s*([A-Za-z0-9_./:-]+)\s*:\s*(.+)$/)
|
||||
if (tagMatch) {
|
||||
current.tags[tagMatch[1]] = tagMatch[2].trim()
|
||||
continue
|
||||
}
|
||||
|
||||
const choiceMatch = line.match(/^\+\s*\[(.+?)\]\s*->\s*([A-Za-z0-9_./:-]+)/)
|
||||
if (choiceMatch) {
|
||||
current.choices.push({
|
||||
label: choiceMatch[1].trim(),
|
||||
target: choiceMatch[2].trim(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const redirectMatch = line.match(/^->\s*([A-Za-z0-9_./:-]+)/)
|
||||
if (redirectMatch) {
|
||||
current.redirects.push(redirectMatch[1].trim())
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line.startsWith("{") && !line.startsWith("}") && !line.startsWith("-")) {
|
||||
current.lines.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
return knots
|
||||
}
|
||||
|
||||
function domCardChildren(manifest, cardIds) {
|
||||
return cardIds
|
||||
.map((rawId) => rawId.trim())
|
||||
.filter(Boolean)
|
||||
.map((cardId) => {
|
||||
const card = manifest.dom_cards?.[cardId] ?? {}
|
||||
return component(`dom_card.${normalizeId(cardId)}`, "card.dom", {
|
||||
sourceId: cardId,
|
||||
title: card.title ?? cardId,
|
||||
selector: card.selector ?? null,
|
||||
role: card.role ?? "dom source card",
|
||||
binding: {
|
||||
source: "visual_manifest.dom_cards",
|
||||
path: `dom_cards.${cardId}`,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function graphChildren(manifest) {
|
||||
const graph = manifest.adventure_graph ?? {}
|
||||
return [
|
||||
component("graph.adventure", "embed.norn", {
|
||||
engine: {
|
||||
id: graph.solver ?? "norn",
|
||||
contract: "gamecult.norn.surface.v1",
|
||||
web: {
|
||||
wasm: graph.solver_wasm ?? null,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
nodes: graph.nodes ?? [],
|
||||
edges: graph.edges ?? [],
|
||||
},
|
||||
layout: {
|
||||
iterations: graph.iterations ?? null,
|
||||
rankGap: graph.rank_gap ?? null,
|
||||
nodeGap: graph.node_gap ?? null,
|
||||
edgeLength: graph.edge_length ?? null,
|
||||
},
|
||||
interaction: {
|
||||
nodeAction: "vn.goto",
|
||||
},
|
||||
binding: {
|
||||
source: "visual_manifest.adventure_graph",
|
||||
path: "adventure_graph",
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function buildSurface(config) {
|
||||
const sourceUpdatedAt = new Date(
|
||||
Math.max(
|
||||
fs.statSync(path.join(repoRoot, config.storyPath)).mtimeMs,
|
||||
fs.statSync(path.join(repoRoot, config.manifestPath)).mtimeMs,
|
||||
),
|
||||
).toISOString()
|
||||
const manifest = readJson(config.manifestPath)
|
||||
const knots = parseInk(readText(config.storyPath))
|
||||
const knotById = new Map(knots.map((knot) => [knot.id, knot]))
|
||||
const defaultKnot = knotById.get(config.defaultStartPath) ?? knots[0]
|
||||
const sceneOptions = Object.entries(manifest.scenes ?? {}).map(([id, scene]) => ({
|
||||
id,
|
||||
label: scene.caption ?? scene.title ?? id,
|
||||
}))
|
||||
|
||||
const routeItems = knots.map((knot) =>
|
||||
component(`route.${normalizeId(knot.id)}`, "card.route", {
|
||||
title: knot.id,
|
||||
speaker: knot.tags.speaker ?? null,
|
||||
scene: knot.tags.scene ?? null,
|
||||
domCards: (knot.tags.dom ?? "").split(",").map((entry) => entry.trim()).filter(Boolean),
|
||||
choices: knot.choices.map((choice) => choice.label),
|
||||
command: {
|
||||
id: "vn.goto",
|
||||
payload: {
|
||||
knot: knot.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultCards = (defaultKnot?.tags.dom ?? "").split(",").filter(Boolean)
|
||||
const defaultScene = defaultKnot?.tags.scene ?? "compound"
|
||||
const scene = manifest.scenes?.[defaultScene] ?? {}
|
||||
|
||||
const root = component("sai.vn.surface", "surface", {
|
||||
domain: "sai.visual_novel",
|
||||
title: config.title,
|
||||
authority: {
|
||||
story: "Ink owns story flow and choices.",
|
||||
manifest: "Visual manifest owns scene, actor, DOM-card, graph, and space metadata.",
|
||||
provider: "Sai publishes accepted state and command effects.",
|
||||
eve: "Eve lowers this retained CultUI tree and emits command intent.",
|
||||
},
|
||||
}, [
|
||||
component("layout.main", "partition", {
|
||||
orientation: "horizontal",
|
||||
ratio: [0.68, 0.32],
|
||||
}, [
|
||||
component("stage", "vn.stage", {
|
||||
bind: "state.currentScene",
|
||||
story: config.storyJson,
|
||||
manifest: config.manifestUrl,
|
||||
startPath: config.defaultStartPath,
|
||||
scene: defaultScene,
|
||||
background: scene.background ? `${manifest.asset_base ?? ""}${scene.background}` : null,
|
||||
capabilities: [
|
||||
"background",
|
||||
"sprite",
|
||||
"avatar",
|
||||
"dialogue",
|
||||
"choices",
|
||||
"dom_cards",
|
||||
"adventure_graph",
|
||||
"embedded_surfaces",
|
||||
"wiki_exits",
|
||||
"transcript",
|
||||
],
|
||||
}, [
|
||||
component("stage.background", "image.background", {
|
||||
bind: "state.currentScene.background",
|
||||
assetBase: manifest.asset_base ?? null,
|
||||
}),
|
||||
component("stage.sprites", "layer.sprites", {
|
||||
bind: "state.actors",
|
||||
actorCatalog: manifest.actors ?? {},
|
||||
}),
|
||||
component("stage.embedded_surfaces", "layer.embedded-surfaces", {
|
||||
bind: "state.currentScene.embeddedSurfaces",
|
||||
placementContract: "scene placement, perspective, skew, keystone, and chromakey belong to the manifest/style state",
|
||||
}),
|
||||
component("stage.dom_cards", "grid.dom_cards", {
|
||||
bind: "state.currentScene.domCards",
|
||||
}, domCardChildren(manifest, defaultCards)),
|
||||
]),
|
||||
component("side.rail", "stack", {
|
||||
gap: "sm",
|
||||
}, [
|
||||
component("dialogue", "panel.dialogue", {
|
||||
speaker: defaultKnot?.tags.speaker ?? manifest.default_caption ?? "Sai",
|
||||
avatar: defaultKnot?.tags.avatar ?? null,
|
||||
bind: "state.currentLine",
|
||||
}, [
|
||||
component("dialogue.speaker", "text", {
|
||||
role: "speaker",
|
||||
text: defaultKnot?.tags.speaker ?? "Sai",
|
||||
}),
|
||||
component("dialogue.line", "text.dialogue", {
|
||||
text: defaultKnot?.lines[0] ?? "",
|
||||
}),
|
||||
]),
|
||||
component("choices", "rail.actions", {
|
||||
bind: "state.availableChoices",
|
||||
command: "vn.choose",
|
||||
}, (defaultKnot?.choices ?? []).map((choice, index) =>
|
||||
component(`choice.${index}`, "control.button", {
|
||||
label: choice.label,
|
||||
command: {
|
||||
id: "vn.choose",
|
||||
payload: {
|
||||
index,
|
||||
target: choice.target,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)),
|
||||
component("navigation", "toolbar", {}, [
|
||||
component("nav.back", "control.button", { label: "Back", command: { id: "vn.back" } }),
|
||||
component("nav.restart", "control.button", { label: "Restart", command: { id: "vn.restart" } }),
|
||||
component("nav.transcript", "control.button", { label: "Transcript", command: { id: "vn.toggle_transcript" } }),
|
||||
component("nav.wiki", "control.button", { label: "Open canonical page", command: { id: "vn.open_exit" } }),
|
||||
]),
|
||||
component("scene.select", "control.select", {
|
||||
label: "Scene",
|
||||
bind: "state.currentScene.id",
|
||||
options: sceneOptions,
|
||||
command: "vn.set_scene",
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
component("knowledge", "partition", {
|
||||
orientation: "horizontal",
|
||||
ratio: [0.5, 0.5],
|
||||
}, [
|
||||
component("route.map", "panel", {
|
||||
title: "Adventure Graph",
|
||||
}, graphChildren(manifest)),
|
||||
component("route.tree", "tree", {
|
||||
title: "Ink Routes",
|
||||
bind: "story.knots",
|
||||
}, routeItems),
|
||||
]),
|
||||
])
|
||||
|
||||
return {
|
||||
type: "surface-state",
|
||||
schema: "gamecult.eve.surface.v1",
|
||||
providerId: config.providerId,
|
||||
providerKind: "sai.visual_novel",
|
||||
title: config.title,
|
||||
version: 1,
|
||||
updatedAt: sourceUpdatedAt,
|
||||
provenance: {
|
||||
generatedBy: "scripts/generate-vn-eve-surface.mjs",
|
||||
story: config.storyPath,
|
||||
storyJson: config.storyJson,
|
||||
visualManifest: config.manifestPath,
|
||||
},
|
||||
surface: {
|
||||
root,
|
||||
styles: {
|
||||
tokens: {
|
||||
colorBackground: "#07111a",
|
||||
colorPanel: "rgba(7, 17, 26, 0.82)",
|
||||
colorAccent: "#ffb63f",
|
||||
colorSignal: "#59b7ff",
|
||||
radius: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
commands: [
|
||||
{ id: "vn.choose", payload: { index: "number", target: "string" } },
|
||||
{ id: "vn.goto", payload: { knot: "string" } },
|
||||
{ id: "vn.back", payload: {} },
|
||||
{ id: "vn.restart", payload: {} },
|
||||
{ id: "vn.set_scene", payload: { scene: "string" } },
|
||||
{ id: "vn.toggle_transcript", payload: {} },
|
||||
{ id: "vn.open_exit", payload: { href: "string?" } },
|
||||
{ id: "vn.open_dom_card", payload: { cardId: "string" } },
|
||||
],
|
||||
stateSchema: {
|
||||
currentKnot: "string",
|
||||
currentScene: "scene derived from Ink tags and manifest.scenes",
|
||||
currentLine: "dialogue line emitted by Ink",
|
||||
availableChoices: "Ink choice list",
|
||||
actors: "manifest actors plus Ink sprite/avatar tags",
|
||||
domCards: "manifest dom_cards selected by Ink # dom tag",
|
||||
transcript: "ordered emitted lines and choices",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for (const config of surfaces) {
|
||||
const surface = buildSurface(config)
|
||||
const outputPath = path.join(repoRoot, config.outputPath)
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(surface, null, 2)}\n`, "utf8")
|
||||
console.log(`Generated ${config.outputPath}`)
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ if (-not (Test-Path $sharedQuartzRoot)) {
|
|||
|
||||
$buildScript = Join-Path $sharedQuartzRoot "scripts\build-site.mjs"
|
||||
$docTreeScript = Join-Path $repoRoot "scripts\generate-vn-repo-doc-tree.mjs"
|
||||
$eveSurfaceScript = Join-Path $repoRoot "scripts\generate-vn-eve-surface.mjs"
|
||||
|
||||
if (-not (Test-Path $buildScript)) {
|
||||
throw "GameCult-Quartz build script was not found at '$buildScript'."
|
||||
|
|
@ -46,6 +47,10 @@ if (Test-Path $docTreeScript) {
|
|||
& $node $docTreeScript
|
||||
}
|
||||
|
||||
if (Test-Path $eveSurfaceScript) {
|
||||
& $node $eveSurfaceScript
|
||||
}
|
||||
|
||||
$scriptArgs = @(
|
||||
$buildScript,
|
||||
$Command,
|
||||
|
|
|
|||
Loading…
Reference in a new issue