diff --git a/dist/eve.js b/dist/eve.js index d602144..7d855b6 100644 --- a/dist/eve.js +++ b/dist/eve.js @@ -37,6 +37,22 @@ }); } + function scenePlacement(input = {}) { + return compact({ + space: input.space || "scene", + anchor: input.anchor, + slot: input.slot, + mode: input.mode || input.fit || "overlay", + quad: input.quad, + transform: input.transform, + zIndex: input.zIndex, + opacity: input.opacity, + chromaKey: input.chromaKey, + occlusion: input.occlusion, + lighting: input.lighting, + }); + } + function styleTokens(input = {}) { return { themeId: input.themeId || "sai.vn.default", @@ -81,7 +97,7 @@ }; } - function graphComponent(graph = {}) { + function nornComponent(graph = {}) { if (!graph.nodes?.length) return null; const rawXs = graph.nodes.map((node) => node.viewX ?? node.x ?? 0); const rawYs = graph.nodes.map((node) => node.viewY ?? node.y ?? 0); @@ -98,31 +114,76 @@ return component( "sai.graph", - "graph", + "embed.norn", { label: graph.label || "Ink knot graph", - layout: graph.layout || "norn.2d", - nodes: graph.nodes.map((node) => - compact({ - id: node.id, - label: node.label || node.id, - target: node.target || node.knot || node.id, - x: normalizeX(node.viewX ?? node.x ?? 0), - y: normalizeY(node.viewY ?? node.y ?? 0), - rank: node.rank, - order: node.order, - current: Boolean(node.current), - visited: Boolean(node.visited), - available: Boolean(node.available), - weight: node.weight, - }), - ), - edges: graph.edges || [], + engine: { + id: "norn", + contract: "gamecult.norn.surface.v1", + web: { + wasm: graph.solver_wasm || graph.solverWasm, + package: "@gamecult/norn-viewer", + }, + native: { + rustCrate: "norn-rs", + direct2dAdapter: "fensalir.norn.direct2d", + }, + }, + layout: { + mode: graph.layout || "norn.2d", + iterations: graph.iterations, + rankGap: graph.rank_gap || graph.rankGap, + nodeGap: graph.node_gap || graph.nodeGap, + edgeLength: graph.edge_length || graph.edgeLength, + }, + graph: { + nodes: graph.nodes.map((node) => + compact({ + id: node.id, + label: node.label || node.id, + target: node.target || node.knot || node.id, + x: normalizeX(node.viewX ?? node.x ?? 0), + y: normalizeY(node.viewY ?? node.y ?? 0), + rank: node.rank, + order: node.order, + current: Boolean(node.current), + visited: Boolean(node.visited), + available: Boolean(node.available), + weight: node.weight, + }), + ), + edges: graph.edges || [], + }, + interaction: { + nodeAction: "story.jump", + focusFollowsStory: true, + rendererMustUseNorn: true, + }, + placement: scenePlacement(graph.placement || graph.diegetic), }, [], ); } + function texComponents(items = []) { + return items.map((item, index) => + component(`sai.tex.${item.id || index}`, "embed.tex", { + label: item.label, + source: item.source || item.tex || item.latex, + sourceUri: item.sourceUri, + format: item.format || "latex", + display: item.display || "block", + macros: item.macros, + renderer: { + web: item.webRenderer || "katex", + native: item.nativeRenderer || "tex-renderer", + direct2d: item.direct2dRenderer || "tex-to-directwrite", + }, + placement: scenePlacement(item.placement || item.diegetic), + }), + ); + } + function spriteComponents(sprites = []) { return sprites.map((sprite, index) => component(`sai.sprite.${sprite.actorName || index}`, "image.sprite", { @@ -186,8 +247,9 @@ const speaker = input.speaker || metadata.speaker || "Void"; const line = input.line || ""; const title = input.title || scene.label || "Sai Visual Novel"; - const graph = graphComponent(input.graph); + const norn = nornComponent(input.graph || input.norn); const variables = variableComponents(input.variables || []); + const tex = texComponents(input.tex || input.texSurfaces || []); const stageChildren = [ component("sai.background", "image.background", { @@ -196,7 +258,8 @@ fit: "cover", pixelArt: style.tokens.pixelArt, }), - graph, + norn, + component("sai.embeds", "layer.embedded-surfaces", {}, tex), component("sai.sprites", "layer.sprites", {}, spriteComponents(input.sprites)), component("sai.cards", "layer.cards", {}, domCardComponents(input.domCards)), component( diff --git a/docs/eve.md b/docs/eve.md index 92391be..3f1c429 100644 --- a/docs/eve.md +++ b/docs/eve.md @@ -41,12 +41,31 @@ const surface = window.SaiEve.createVisualNovelSurface({ graph: { label: "GameCult Ink Knot Graph", layout: "norn.2d", + solver_wasm: "/static/interactive/norn/norn-rs.wasm", + placement: { + space: "scene", + anchor: "whiteboard", + mode: "keystone", + quad: [[0.16, 0.18], [0.58, 0.13], [0.62, 0.50], [0.14, 0.56]] + }, nodes: [ { id: "hub", label: "Hub", target: "hub", x: 0, y: 0, current: true }, { id: "eve", label: "Eve", target: "eve", x: 1, y: 0 }, ], edges: [{ source: "hub", target: "eve" }], }, + tex: [ + { + id: "bifrost-log-power", + label: "Bifrost voting weight", + source: "\\\\mathrm{votes}(p)=1+\\\\lfloor\\\\log_b(1+p)\\\\rfloor", + placement: { + space: "scene", + anchor: "whiteboard", + mode: "keystone" + } + } + ], choices: [ { text: "What is Eve trying to turn the web into?", targetPath: "eve" }, ], @@ -73,13 +92,45 @@ without each renderer inventing its own theme protocol. A renderer may expose native color pickers, segmented controls, or accessibility settings, but the accepted style state belongs to the provider and comes back as surface tokens. +## Embedded Scene Surfaces + +The point of the Eve module is not to flatten Sai into a dashboard. A VN scene +can contain portable interactive surfaces that are diegetically placed inside +the scene. The graph map is one of those surfaces. + +`embed.norn` declares that a renderer must embed a Norn graph runtime, not draw +a decorative graph lookalike. Web can use Norn WASM and `@gamecult/norn-viewer`; +native targets can use `norn-rs` directly or through a platform adapter. + +`embed.tex` declares TeX/LaTeX content as a portable rich math/layout surface. +Web renderers may use KaTeX or another TeX path; native renderers should lower +the same source into their local text/vector stack. + +Both kinds accept `placement`: + +```json +{ + "space": "scene", + "anchor": "whiteboard", + "mode": "keystone", + "quad": [[0.16, 0.18], [0.58, 0.13], [0.62, 0.50], [0.14, 0.56]], + "chromaKey": { "color": "#00ff00", "tolerance": 0.12 }, + "occlusion": "sprite-mask:whiteboard-hand" +} +``` + +That is the hook for character-pointing-at-whiteboard shots, chromakey panels in +sprite assets, and perspective/skew/keystone fitting onto scene props. + ## Component Kinds The first VN surface uses: - `vn.stage` - `image.background` -- `graph` +- `embed.norn` +- `embed.tex` +- `layer.embedded-surfaces` - `layer.sprites` - `image.sprite` - `layer.cards` diff --git a/src/eve.js b/src/eve.js index d602144..7d855b6 100644 --- a/src/eve.js +++ b/src/eve.js @@ -37,6 +37,22 @@ }); } + function scenePlacement(input = {}) { + return compact({ + space: input.space || "scene", + anchor: input.anchor, + slot: input.slot, + mode: input.mode || input.fit || "overlay", + quad: input.quad, + transform: input.transform, + zIndex: input.zIndex, + opacity: input.opacity, + chromaKey: input.chromaKey, + occlusion: input.occlusion, + lighting: input.lighting, + }); + } + function styleTokens(input = {}) { return { themeId: input.themeId || "sai.vn.default", @@ -81,7 +97,7 @@ }; } - function graphComponent(graph = {}) { + function nornComponent(graph = {}) { if (!graph.nodes?.length) return null; const rawXs = graph.nodes.map((node) => node.viewX ?? node.x ?? 0); const rawYs = graph.nodes.map((node) => node.viewY ?? node.y ?? 0); @@ -98,31 +114,76 @@ return component( "sai.graph", - "graph", + "embed.norn", { label: graph.label || "Ink knot graph", - layout: graph.layout || "norn.2d", - nodes: graph.nodes.map((node) => - compact({ - id: node.id, - label: node.label || node.id, - target: node.target || node.knot || node.id, - x: normalizeX(node.viewX ?? node.x ?? 0), - y: normalizeY(node.viewY ?? node.y ?? 0), - rank: node.rank, - order: node.order, - current: Boolean(node.current), - visited: Boolean(node.visited), - available: Boolean(node.available), - weight: node.weight, - }), - ), - edges: graph.edges || [], + engine: { + id: "norn", + contract: "gamecult.norn.surface.v1", + web: { + wasm: graph.solver_wasm || graph.solverWasm, + package: "@gamecult/norn-viewer", + }, + native: { + rustCrate: "norn-rs", + direct2dAdapter: "fensalir.norn.direct2d", + }, + }, + layout: { + mode: graph.layout || "norn.2d", + iterations: graph.iterations, + rankGap: graph.rank_gap || graph.rankGap, + nodeGap: graph.node_gap || graph.nodeGap, + edgeLength: graph.edge_length || graph.edgeLength, + }, + graph: { + nodes: graph.nodes.map((node) => + compact({ + id: node.id, + label: node.label || node.id, + target: node.target || node.knot || node.id, + x: normalizeX(node.viewX ?? node.x ?? 0), + y: normalizeY(node.viewY ?? node.y ?? 0), + rank: node.rank, + order: node.order, + current: Boolean(node.current), + visited: Boolean(node.visited), + available: Boolean(node.available), + weight: node.weight, + }), + ), + edges: graph.edges || [], + }, + interaction: { + nodeAction: "story.jump", + focusFollowsStory: true, + rendererMustUseNorn: true, + }, + placement: scenePlacement(graph.placement || graph.diegetic), }, [], ); } + function texComponents(items = []) { + return items.map((item, index) => + component(`sai.tex.${item.id || index}`, "embed.tex", { + label: item.label, + source: item.source || item.tex || item.latex, + sourceUri: item.sourceUri, + format: item.format || "latex", + display: item.display || "block", + macros: item.macros, + renderer: { + web: item.webRenderer || "katex", + native: item.nativeRenderer || "tex-renderer", + direct2d: item.direct2dRenderer || "tex-to-directwrite", + }, + placement: scenePlacement(item.placement || item.diegetic), + }), + ); + } + function spriteComponents(sprites = []) { return sprites.map((sprite, index) => component(`sai.sprite.${sprite.actorName || index}`, "image.sprite", { @@ -186,8 +247,9 @@ const speaker = input.speaker || metadata.speaker || "Void"; const line = input.line || ""; const title = input.title || scene.label || "Sai Visual Novel"; - const graph = graphComponent(input.graph); + const norn = nornComponent(input.graph || input.norn); const variables = variableComponents(input.variables || []); + const tex = texComponents(input.tex || input.texSurfaces || []); const stageChildren = [ component("sai.background", "image.background", { @@ -196,7 +258,8 @@ fit: "cover", pixelArt: style.tokens.pixelArt, }), - graph, + norn, + component("sai.embeds", "layer.embedded-surfaces", {}, tex), component("sai.sprites", "layer.sprites", {}, spriteComponents(input.sprites)), component("sai.cards", "layer.cards", {}, domCardComponents(input.domCards)), component(