ttrpg-tools/src/yarn-spinner/compile/compiler.ts

183 lines
8.5 KiB
TypeScript
Raw Normal View History

2026-03-02 16:17:08 +08:00
import type { YarnDocument, Statement, Line, Option } from "../model/ast";
import type { IRProgram, IRNode, IRNodeGroup, IRInstruction } from "./ir";
export interface CompileOptions {
generateOnceIds?: (ctx: { node: string; index: number }) => string;
}
export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram {
const program: IRProgram = { enums: {}, nodes: {} };
// Store enum definitions
for (const enumDef of doc.enums) {
program.enums[enumDef.name] = enumDef.cases;
}
const genOnce = opts.generateOnceIds ?? ((x) => `${x.node}#once#${x.index}`);
let globalLineCounter = 0;
function ensureLineId(tags?: string[]): string[] | undefined {
const t = tags ? [...tags] : [];
if (!t.some((x) => x.startsWith("line:"))) {
t.push(`line:${(globalLineCounter++).toString(16)}`);
}
return t;
}
// Group nodes by title to handle node groups
const nodesByTitle = new Map<string, typeof doc.nodes>();
for (const node of doc.nodes) {
if (!nodesByTitle.has(node.title)) {
nodesByTitle.set(node.title, []);
}
nodesByTitle.get(node.title)!.push(node);
}
for (const [title, nodesWithSameTitle] of nodesByTitle) {
// If only one node with this title, treat as regular node
if (nodesWithSameTitle.length === 1) {
const node = nodesWithSameTitle[0];
const instructions: IRInstruction[] = [];
let onceCounter = 0;
function emitBlock(stmts: Statement[]): IRInstruction[] {
const block: IRInstruction[] = [];
for (const s of stmts) {
switch (s.type) {
case "Line":
{
const line = s as Line;
block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags), markup: line.markup });
}
break;
case "Command":
block.push({ op: "command", content: s.content });
break;
case "Jump":
block.push({ op: "jump", target: s.target });
break;
case "Detour":
block.push({ op: "detour", target: s.target });
break;
case "OptionGroup": {
// Add #lastline tag to the most recent line, if present
for (let i = block.length - 1; i >= 0; i--) {
const ins = block[i];
if (ins.op === "line") {
const tags = new Set(ins.tags ?? []);
if (![...tags].some((x) => x === "lastline" || x === "#lastline")) {
tags.add("lastline");
}
ins.tags = Array.from(tags);
break;
}
if (ins.op !== "command") break; // stop if non-line non-command before options
}
block.push({
op: "options",
options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, condition: o.condition, block: emitBlock(o.body) })),
});
break;
}
case "If":
block.push({
op: "if",
branches: s.branches.map((b) => ({ condition: b.condition, block: emitBlock(b.body) })),
});
break;
case "Once":
block.push({ op: "once", id: genOnce({ node: node.title, index: onceCounter++ }), block: emitBlock(s.body) });
break;
case "Enum":
// Enums are metadata, skip during compilation (already stored in program.enums)
break;
}
}
return block;
}
instructions.push(...emitBlock(node.body));
const irNode: IRNode = {
title: node.title,
instructions,
when: node.when,
css: (node as any).css,
scene: node.headers.scene?.trim() || undefined
};
program.nodes[node.title] = irNode;
} else {
// Multiple nodes with same title - create node group
const groupNodes: IRNode[] = [];
for (const node of nodesWithSameTitle) {
const instructions: IRInstruction[] = [];
let onceCounter = 0;
function emitBlock(stmts: Statement[]): IRInstruction[] {
const block: IRInstruction[] = [];
for (const s of stmts) {
switch (s.type) {
case "Line":
{
const line = s as Line;
block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags), markup: line.markup });
}
break;
case "Command":
block.push({ op: "command", content: s.content });
break;
case "Jump":
block.push({ op: "jump", target: s.target });
break;
case "Detour":
block.push({ op: "detour", target: s.target });
break;
case "OptionGroup": {
for (let i = block.length - 1; i >= 0; i--) {
const ins = block[i];
if (ins.op === "line") {
const tags = new Set(ins.tags ?? []);
if (![...tags].some((x) => x === "lastline" || x === "#lastline")) {
tags.add("lastline");
}
ins.tags = Array.from(tags);
break;
}
if (ins.op !== "command") break;
}
block.push({
op: "options",
options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, condition: o.condition, block: emitBlock(o.body) })),
});
break;
}
case "If":
block.push({
op: "if",
branches: s.branches.map((b) => ({ condition: b.condition, block: emitBlock(b.body) })),
});
break;
case "Once":
block.push({ op: "once", id: genOnce({ node: node.title, index: onceCounter++ }), block: emitBlock(s.body) });
break;
case "Enum":
break;
}
}
return block;
}
instructions.push(...emitBlock(node.body));
groupNodes.push({
title: node.title,
instructions,
when: node.when,
css: (node as any).css,
scene: node.headers.scene?.trim() || undefined
});
}
const group: IRNodeGroup = {
title,
nodes: groupNodes
};
program.nodes[title] = group;
}
}
return program;
}