183 lines
8.5 KiB
TypeScript
183 lines
8.5 KiB
TypeScript
|
|
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;
|
|||
|
|
}
|
|||
|
|
|