From a226a9516cdef636d5d287d8defa40b85c9025c7 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 3 Mar 2026 11:36:23 +0800 Subject: [PATCH] feat: yarn return --- src/yarn-spinner/compile/compiler.ts | 180 ++++++++++----------------- src/yarn-spinner/compile/ir.ts | 1 + src/yarn-spinner/model/ast.ts | 6 + src/yarn-spinner/parse/parser.ts | 2 + src/yarn-spinner/runtime/runner.ts | 15 +-- 5 files changed, 84 insertions(+), 120 deletions(-) diff --git a/src/yarn-spinner/compile/compiler.ts b/src/yarn-spinner/compile/compiler.ts index a8fce24..dc537e1 100644 --- a/src/yarn-spinner/compile/compiler.ts +++ b/src/yarn-spinner/compile/compiler.ts @@ -1,4 +1,4 @@ -import type { YarnDocument, Statement, Line, Option } from "../model/ast"; +import type {YarnDocument, Statement, Line, Option, YarnNode} from "../model/ast"; import type { IRProgram, IRNode, IRNodeGroup, IRInstruction } from "./ir"; export interface CompileOptions { @@ -31,68 +31,74 @@ export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram nodesByTitle.get(node.title)!.push(node); } + let onceCounter = 0; + function emitBlock(stmts: Statement[], node: YarnNode): 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 "Return": + block.push({ op: "return" }); + 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, node) })), + }); + break; + } + case "If": + block.push({ + op: "if", + branches: s.branches.map((b) => ({ condition: b.condition, block: emitBlock(b.body, node) })), + }); + break; + case "Once": + block.push({ op: "once", id: genOnce({ node: node.title, index: onceCounter++ }), block: emitBlock(s.body, node) }); + break; + case "Enum": + // Enums are metadata, skip during compilation (already stored in program.enums) + break; + } + } + return block; + } + 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)); + + onceCounter = 0; + instructions.push(...emitBlock(node.body, node)); const irNode: IRNode = { title: node.title, instructions, @@ -106,61 +112,9 @@ export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram 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)); + + onceCounter = 0; + instructions.push(...emitBlock(node.body, node)); groupNodes.push({ title: node.title, instructions, diff --git a/src/yarn-spinner/compile/ir.ts b/src/yarn-spinner/compile/ir.ts index 38c8183..f6e9c12 100644 --- a/src/yarn-spinner/compile/ir.ts +++ b/src/yarn-spinner/compile/ir.ts @@ -22,6 +22,7 @@ export type IRInstruction = | { op: "command"; content: string } | { op: "jump"; target: string } | { op: "detour"; target: string } + | { op: "return"; } | { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; markup?: MarkupParseResult; condition?: string; block: IRInstruction[] }> } | { op: "if"; branches: Array<{ condition: string | null; block: IRInstruction[] }> } | { op: "once"; id: string; block: IRInstruction[] }; diff --git a/src/yarn-spinner/model/ast.ts b/src/yarn-spinner/model/ast.ts index ecbd1d3..2a8dd79 100644 --- a/src/yarn-spinner/model/ast.ts +++ b/src/yarn-spinner/model/ast.ts @@ -34,6 +34,7 @@ export type Statement = | OnceBlock | Jump | Detour + | Return | EnumBlock; import type { MarkupParseResult } from "../markup/types.js"; @@ -61,6 +62,11 @@ export interface Detour { target: string; } +export interface Return { + type: "Return"; + target: string; +} + export interface OptionGroup { type: "OptionGroup"; options: Option[]; diff --git a/src/yarn-spinner/parse/parser.ts b/src/yarn-spinner/parse/parser.ts index 4faa91f..75ab37b 100644 --- a/src/yarn-spinner/parse/parser.ts +++ b/src/yarn-spinner/parse/parser.ts @@ -13,6 +13,7 @@ import type { OnceBlock, Jump, Detour, + Return, EnumBlock, } from "../model/ast"; @@ -174,6 +175,7 @@ class Parser { const cmd = this.take("COMMAND").text; if (cmd.startsWith("jump ")) return { type: "Jump", target: cmd.slice(5).trim() } as Jump; if (cmd.startsWith("detour ")) return { type: "Detour", target: cmd.slice(7).trim() } as Detour; + if (cmd.startsWith("return")) return { type: "Return" } as Return; if (cmd.startsWith("if ")) return this.parseIfCommandBlock(cmd); if (cmd === "once") return this.parseOnceBlock(); if (cmd.startsWith("enum ")) { diff --git a/src/yarn-spinner/runtime/runner.ts b/src/yarn-spinner/runtime/runner.ts index d20f835..a60237c 100644 --- a/src/yarn-spinner/runtime/runner.ts +++ b/src/yarn-spinner/runtime/runner.ts @@ -122,13 +122,6 @@ export class YarnRunner { this.step(); } - /** - * Get the current node title (may resolve to a node group). - */ - getCurrentNodeTitle(): string { - return this.nodeTitle; - } - /** * Resolve a node title to an actual node (handling node groups). */ @@ -498,6 +491,14 @@ export class YarnRunner { // resolveNode will handle node groups continue; } + case "return": { + const top = this.callStack.pop(); + if(!top) throw new Error("No call stack to return to"); + this.nodeTitle = top.title; + this.ip = top.ip; + this.currentNodeIndex = -1; // Reset node index for new resolution + continue; + } case "options": { const available = this.filterOptions(ins.options); if (available.length === 0) {