342 lines
11 KiB
JavaScript
342 lines
11 KiB
JavaScript
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}`)
|
|
}
|