Compare commits
4 Commits
a226a9516c
...
f1aa536c4d
| Author | SHA1 | Date |
|---|---|---|
|
|
f1aa536c4d | |
|
|
e13ead2309 | |
|
|
8d5b15ad51 | |
|
|
82876d47c0 |
|
|
@ -21,7 +21,8 @@ title: 节点名称
|
||||||
```
|
```
|
||||||
|
|
||||||
对于`随机`遭遇而言,使用多个`title`相同的节点。
|
对于`随机`遭遇而言,使用多个`title`相同的节点。
|
||||||
可以使用`when: `来为节点触发添加条件。
|
使用`when: `来为节点触发添加条件。
|
||||||
|
如果没有条件,则使用`when: always`。
|
||||||
若需要节点只触发一次,可以结合`变量`。
|
若需要节点只触发一次,可以结合`变量`。
|
||||||
|
|
||||||
```yarn-spinner
|
```yarn-spinner
|
||||||
|
|
@ -43,30 +44,29 @@ when: $villager_encountered == false
|
||||||
|
|
||||||
随机遭遇可以使用`<<return>>`来返回主线。
|
随机遭遇可以使用`<<return>>`来返回主线。
|
||||||
|
|
||||||
## 休息与睡觉
|
在玩家可以反复尝试的遭遇中,在结尾添加`<<jump 节点名称>>```,跳转到自身的开头。
|
||||||
|
|
||||||
在合适的地方可以休息。如果时间在晚0点到早6点之间,可以睡觉。
|
|
||||||
|
|
||||||
## 命令和函数
|
## 命令和函数
|
||||||
|
|
||||||
使用命令读写时间。不要创建变量。
|
使用命令读写时间。不要创建变量。
|
||||||
- ```<<time_pass>>```:时间流逝,若休息则使用`<<time_pass true>>`。若在夜间休息则休息至第二天6点,否则休息1小时。
|
- `<<add_hour>>``<<add_minute>>``<<add_day>>`:触发时间流逝。
|
||||||
- ```<<if time_of_day() <= 6>>```:读取24小时制当前时间。午夜为0点。
|
- `$time_day``$time_hour``$time_minute`:读取24小时制当前时间。
|
||||||
- ```<<if time_of_game() >= 72>>```:总共流逝的时间。
|
- `$hour_total`:总共流逝的时间。
|
||||||
|
|
||||||
使用以下命令操作角色属性:
|
使用以下命令操作角色属性:
|
||||||
- ```<<damage str 4>>```:造成角色属性损伤。若可耐受,则使用```<<damage str 4 true>>```。
|
- ```<<damage str 4>>```:造成角色属性损伤。
|
||||||
- ```<<heal str 4>>```:解除属性创伤,并恢复角色属性损伤。
|
- ```<<heal str 4>>```:解除属性创伤,并恢复角色属性损伤。
|
||||||
- ```<<buff str 4 4>>```:施加属性修改值,持续一定小时数。
|
|
||||||
- ```<<set $result to check("nodeId:checkId", "wis")>> ```:检定角色属性。成功返回正值代表成功进度,失败返回负值代表失败进度。
|
- ```<<set $result to check("nodeId:checkId", "wis")>> ```:检定角色属性。成功返回正值代表成功进度,失败返回负值代表失败进度。
|
||||||
- ```<<if $result >= 3>>```:处理检定成功进度。
|
- ```<<if $result >= 3>>```:处理检定成功进度。
|
||||||
- ```<<if $result <= -2>>```:处理检定失败进度。
|
- ```<<if $result <= -2>>```:处理检定失败进度。
|
||||||
- ```<<if $result > 0>>```:如果既没有成功也没有失败,暗示玩家应该继续尝试。
|
- ```<<if $result > 0>>```:如果既没有达成成功进度也没有达成失败进度,暗示玩家应该继续尝试。
|
||||||
|
- 检定后总是在文本中描述玩家当前的进度(但不要描述难度和机会)
|
||||||
|
|
||||||
使用以下命令操作角色物品:
|
直接使用变量来记录玩家物品。没有背包限制。
|
||||||
- ```<<add_item light_armor 1>>```:添加物品。
|
- ```<<set $item_light_armor to $item_light_armor + 1>>```:添加物品。
|
||||||
- ```<<if consume_item("gold", 100)>>```:消耗物品。
|
- ```<<if $item_gold >= 100)>>```:检查物品。
|
||||||
- ```<<if has_item("gold", 100)>>```:检查物品。
|
|
||||||
|
直接使用变量来记录势力计划、地点、事件的进度。
|
||||||
|
|
||||||
## 文件分割
|
## 文件分割
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@ import { customElement, noShadowDOM } from 'solid-element';
|
||||||
import { For, Show, createEffect } from 'solid-js';
|
import { For, Show, createEffect } from 'solid-js';
|
||||||
import type {TextResult, RuntimeResult, OptionsResult} from '../yarn-spinner/runtime/results';
|
import type {TextResult, RuntimeResult, OptionsResult} from '../yarn-spinner/runtime/results';
|
||||||
import { createYarnStore } from './stores/yarnStore';
|
import { createYarnStore } from './stores/yarnStore';
|
||||||
import {RunnerOptions} from "../yarn-spinner/runtime/runner";
|
|
||||||
|
|
||||||
customElement<RunnerOptions>('md-yarn-spinner', {startAt: 'start'}, (props, { element }) => {
|
customElement<{start: string}>('md-yarn-spinner', {start: 'start'}, (props, { element }) => {
|
||||||
noShadowDOM();
|
noShadowDOM();
|
||||||
|
|
||||||
let historyContainer: HTMLDivElement | undefined;
|
let historyContainer: HTMLDivElement | undefined;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
type IRunner = {
|
||||||
|
getVariable(key: string): unknown;
|
||||||
|
setVariable(key: string, value: unknown): void;
|
||||||
|
setSmartVariable(name: string, expression: string): void;
|
||||||
|
}
|
||||||
|
export function getTtrpgFunctions(runner: IRunner){
|
||||||
|
/* time related */
|
||||||
|
function ensure_time(){
|
||||||
|
if(!runner.getVariable("time_day")){
|
||||||
|
runner.setVariable("time_day", 1);
|
||||||
|
runner.setVariable("time_hour", 0);
|
||||||
|
runner.setVariable("time_minute", 0);
|
||||||
|
runner.setSmartVariable("hour_total", "$time_day * 24 + $time_hour");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// midnight - 6am is night time
|
||||||
|
function is_night(){
|
||||||
|
ensure_time();
|
||||||
|
const hour = runner.getVariable("time_hour") as number;
|
||||||
|
return hour < 6;
|
||||||
|
}
|
||||||
|
function add_minute(delta: number){
|
||||||
|
ensure_time();
|
||||||
|
const min = (runner.getVariable("time_minute") as number) + delta;
|
||||||
|
runner.setVariable("time_minute", min % 60);
|
||||||
|
if(min >= 60) add_hour(Math.floor(min / 60));
|
||||||
|
}
|
||||||
|
function add_hour(delta: number){
|
||||||
|
ensure_time();
|
||||||
|
const hour = (runner.getVariable("time_hour") as number) + delta;
|
||||||
|
runner.setVariable("time_hour", hour % 24);
|
||||||
|
if(hour >= 24) add_day(Math.floor(hour / 24));
|
||||||
|
}
|
||||||
|
function add_day(delta: number){
|
||||||
|
ensure_time();
|
||||||
|
const day = (runner.getVariable("time_day") as number) + delta;
|
||||||
|
runner.setVariable("time_day", day);
|
||||||
|
}
|
||||||
|
/* stat related */
|
||||||
|
function ensure_stat(stat: string){
|
||||||
|
if(runner.getVariable(stat) === undefined){
|
||||||
|
const statBase = `${stat}_base`;
|
||||||
|
const statMod = `${stat}_mod`;
|
||||||
|
const statDamage = `${stat}_damage`;
|
||||||
|
const statWound = `${stat}_wound`;
|
||||||
|
runner.setVariable(statBase, 10);
|
||||||
|
runner.setVariable(statMod, 0);
|
||||||
|
runner.setVariable(statDamage, 0);
|
||||||
|
runner.setVariable(statWound, false);
|
||||||
|
runner.setSmartVariable(stat, `$${statBase} - $${statDamage} + $${statMod}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function get_stat(stat: string){
|
||||||
|
ensure_stat(stat);
|
||||||
|
return runner.getVariable(stat) as number || 0;
|
||||||
|
}
|
||||||
|
function damage(stat: string, amount: number){
|
||||||
|
const current = get_stat(stat);
|
||||||
|
if(amount * 2 >= current)
|
||||||
|
runner.setVariable(`${stat}_wound`, true);
|
||||||
|
const damage = get_stat(`${stat}_damage`);
|
||||||
|
runner.setVariable(`${stat}_damage`, damage + amount);
|
||||||
|
}
|
||||||
|
function heal(stat: string, amount = 0){
|
||||||
|
ensure_stat(stat);
|
||||||
|
runner.setVariable(`${stat}_wound`, false);
|
||||||
|
const damage = get_stat(`${stat}_damage`);
|
||||||
|
runner.setVariable(`${stat}_damage`, Math.max(0, damage - amount));
|
||||||
|
}
|
||||||
|
function check(id: string, stat: string): number {
|
||||||
|
const statVal = get_stat(stat);
|
||||||
|
const pass = Math.ceil(Math.random() * 20) <= statVal;
|
||||||
|
const key = `${id}_${pass ? 'pass' : 'fail'}`;
|
||||||
|
const progress = runner.getVariable(key) as number || 0;
|
||||||
|
const newProgress = progress + Math.ceil(Math.random() * 4);
|
||||||
|
runner.setVariable(key, newProgress);
|
||||||
|
console.log(`check ${stat}(${statVal}) ${pass ? 'pass' : 'fail'}, now ${newProgress}`);
|
||||||
|
return pass ? newProgress : -newProgress;
|
||||||
|
}
|
||||||
|
function rollStat(){
|
||||||
|
const index = Math.floor(Math.random() * 6);
|
||||||
|
return ['str', 'dex', 'con', 'int', 'wis', 'cha'][index];
|
||||||
|
}
|
||||||
|
function fatigue(){
|
||||||
|
damage(rollStat(), 1);
|
||||||
|
}
|
||||||
|
function regen(){
|
||||||
|
let stat = rollStat();
|
||||||
|
if(runner.getVariable(`${stat}_wound`) || get_stat(`${stat}_damage`) <= 0) {
|
||||||
|
stat = rollStat();
|
||||||
|
}
|
||||||
|
if(runner.getVariable(`${stat}_wound`) || get_stat(`${stat}_damage`) <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
heal(stat, 1);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
commands: {
|
||||||
|
add_minute,
|
||||||
|
add_hour,
|
||||||
|
add_day,
|
||||||
|
damage,
|
||||||
|
heal,
|
||||||
|
fatigue,
|
||||||
|
regen
|
||||||
|
} as Record<string, (...args: any[]) =>any>,
|
||||||
|
functions: {
|
||||||
|
check
|
||||||
|
} as Record<string, (...args: any[]) =>any>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import {compile, parseYarn, YarnRunner} from "../../yarn-spinner";
|
||||||
import {loadElementSrc, resolvePath} from "../utils/path";
|
import {loadElementSrc, resolvePath} from "../utils/path";
|
||||||
import {createStore} from "solid-js/store";
|
import {createStore} from "solid-js/store";
|
||||||
import {RunnerOptions} from "../../yarn-spinner/runtime/runner";
|
import {RunnerOptions} from "../../yarn-spinner/runtime/runner";
|
||||||
|
import {getTtrpgFunctions} from "./ttrpgRunner";
|
||||||
|
|
||||||
type YarnSpinnerStore = {
|
type YarnSpinnerStore = {
|
||||||
dialogueHistory: (RuntimeResult | OptionsResult['options'][0])[],
|
dialogueHistory: (RuntimeResult | OptionsResult['options'][0])[],
|
||||||
|
|
@ -12,7 +13,7 @@ type YarnSpinnerStore = {
|
||||||
runnerInstance: YarnRunner | null,
|
runnerInstance: YarnRunner | null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createYarnStore(element: HTMLElement, props: RunnerOptions){
|
export function createYarnStore(element: HTMLElement, props: {start: string}){
|
||||||
const [store, setStore] = createStore<YarnSpinnerStore>({
|
const [store, setStore] = createStore<YarnSpinnerStore>({
|
||||||
dialogueHistory: [],
|
dialogueHistory: [],
|
||||||
currentOptions: null,
|
currentOptions: null,
|
||||||
|
|
@ -38,7 +39,13 @@ export function createYarnStore(element: HTMLElement, props: RunnerOptions){
|
||||||
const ast = parseYarn(content);
|
const ast = parseYarn(content);
|
||||||
const program = compile(ast);
|
const program = compile(ast);
|
||||||
|
|
||||||
return new YarnRunner(program, props);
|
const runner = new YarnRunner(program, {
|
||||||
|
startAt: props.start,
|
||||||
|
});
|
||||||
|
const {commands, functions} = getTtrpgFunctions(runner);
|
||||||
|
runner.registerFunctions(functions);
|
||||||
|
runner.registerCommands(commands);
|
||||||
|
return runner;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize YarnRunner:', error);
|
console.error('Failed to initialize YarnRunner:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export interface ParsedCommand {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a command string like "command_name arg1 arg2" or "set variable value"
|
* Parse a command string like "command_name arg1 arg2" or "set variable value"
|
||||||
|
* Supports quoted strings with spaces, nested parentheses in expressions.
|
||||||
*/
|
*/
|
||||||
export function parseCommand(content: string): ParsedCommand {
|
export function parseCommand(content: string): ParsedCommand {
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
|
|
@ -24,15 +25,14 @@ export function parseCommand(content: string): ParsedCommand {
|
||||||
let current = "";
|
let current = "";
|
||||||
let inQuotes = false;
|
let inQuotes = false;
|
||||||
let quoteChar = "";
|
let quoteChar = "";
|
||||||
|
let parenDepth = 0;
|
||||||
|
|
||||||
for (let i = 0; i < trimmed.length; i++) {
|
for (let i = 0; i < trimmed.length; i++) {
|
||||||
const char = trimmed[i];
|
const char = trimmed[i];
|
||||||
|
|
||||||
if ((char === '"' || char === "'") && !inQuotes) {
|
// Handle quote toggling (only when not inside parentheses)
|
||||||
// If we have accumulated non-quoted content (e.g. a function name and "(")
|
if ((char === '"' || char === "'") && !inQuotes && parenDepth === 0) {
|
||||||
// push it as its own part before entering quoted mode. This prevents the
|
// Push accumulated non-quoted content as a part
|
||||||
// surrounding text from being merged into the quoted content when we
|
|
||||||
// later push the quoted value.
|
|
||||||
if (current.trim()) {
|
if (current.trim()) {
|
||||||
parts.push(current.trim());
|
parts.push(current.trim());
|
||||||
current = "";
|
current = "";
|
||||||
|
|
@ -43,17 +43,23 @@ export function parseCommand(content: string): ParsedCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (char === quoteChar && inQuotes) {
|
if (char === quoteChar && inQuotes) {
|
||||||
inQuotes = false;
|
// End of quoted string - preserve quotes in the output
|
||||||
// Preserve the surrounding quotes in the parsed part so callers that
|
|
||||||
// reassemble the expression (e.g. declare handlers) keep string literals
|
|
||||||
// intact instead of losing quote characters.
|
|
||||||
parts.push(quoteChar + current + quoteChar);
|
parts.push(quoteChar + current + quoteChar);
|
||||||
quoteChar = "";
|
quoteChar = "";
|
||||||
current = "";
|
current = "";
|
||||||
|
inQuotes = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (char === " " && !inQuotes) {
|
// Track parenthesis depth to avoid splitting inside expressions
|
||||||
|
if (char === "(" && !inQuotes) {
|
||||||
|
parenDepth++;
|
||||||
|
} else if (char === ")" && !inQuotes) {
|
||||||
|
parenDepth = Math.max(0, parenDepth - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on spaces only when not in quotes and not in parentheses
|
||||||
|
if (char === " " && !inQuotes && parenDepth === 0) {
|
||||||
if (current.trim()) {
|
if (current.trim()) {
|
||||||
parts.push(current.trim());
|
parts.push(current.trim());
|
||||||
current = "";
|
current = "";
|
||||||
|
|
@ -64,6 +70,7 @@ export function parseCommand(content: string): ParsedCommand {
|
||||||
current += char;
|
current += char;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push any remaining content
|
||||||
if (current.trim()) {
|
if (current.trim()) {
|
||||||
parts.push(current.trim());
|
parts.push(current.trim());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -320,47 +320,72 @@ export class ExpressionEvaluator {
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
let current = "";
|
let current = "";
|
||||||
let lastOp: "&&" | "||" | null = null;
|
let lastOp: "&&" | "||" | null = null;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
for (const char of expr) {
|
while (i < expr.length) {
|
||||||
if (char === "(") depth++;
|
const char = expr[i];
|
||||||
else if (char === ")") depth--;
|
|
||||||
else if (depth === 0 && expr.includes(char === "&" ? "&&" : char === "|" ? "||" : "")) {
|
if (char === "(") {
|
||||||
// Check for && or ||
|
depth++;
|
||||||
const remaining = expr.slice(expr.indexOf(char));
|
current += char;
|
||||||
if (remaining.startsWith("&&")) {
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ")") {
|
||||||
|
depth--;
|
||||||
|
current += char;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for && or || at current position (only at depth 0)
|
||||||
|
if (depth === 0) {
|
||||||
|
if (expr.slice(i, i + 2) === "&&") {
|
||||||
if (current.trim()) {
|
if (current.trim()) {
|
||||||
parts.push({ expr: current.trim(), op: lastOp });
|
parts.push({ expr: current.trim(), op: lastOp });
|
||||||
current = "";
|
current = "";
|
||||||
}
|
}
|
||||||
lastOp = "&&";
|
lastOp = "&&";
|
||||||
// skip &&
|
i += 2;
|
||||||
continue;
|
continue;
|
||||||
} else if (remaining.startsWith("||")) {
|
}
|
||||||
|
if (expr.slice(i, i + 2) === "||") {
|
||||||
if (current.trim()) {
|
if (current.trim()) {
|
||||||
parts.push({ expr: current.trim(), op: lastOp });
|
parts.push({ expr: current.trim(), op: lastOp });
|
||||||
current = "";
|
current = "";
|
||||||
}
|
}
|
||||||
lastOp = "||";
|
lastOp = "||";
|
||||||
// skip ||
|
i += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
current += char;
|
current += char;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.trim()) {
|
||||||
|
parts.push({ expr: current.trim(), op: lastOp });
|
||||||
}
|
}
|
||||||
if (current.trim()) parts.push({ expr: current.trim(), op: lastOp });
|
|
||||||
|
|
||||||
// Simple case: single expression
|
// Simple case: single expression
|
||||||
if (parts.length === 0) return !!this.evaluateExpression(expr);
|
if (parts.length <= 1) {
|
||||||
|
return !!this.evaluateExpression(expr);
|
||||||
|
}
|
||||||
|
|
||||||
// Evaluate parts (supports &&, ||, ^ as xor)
|
// Evaluate parts with short-circuit logic
|
||||||
let result = this.evaluateExpression(parts[0].expr);
|
let result = this.evaluateExpression(parts[0].expr);
|
||||||
for (let i = 1; i < parts.length; i++) {
|
for (let i = 1; i < parts.length; i++) {
|
||||||
const part = parts[i];
|
const part = parts[i];
|
||||||
const val = this.evaluateExpression(part.expr);
|
|
||||||
if (part.op === "&&") {
|
if (part.op === "&&") {
|
||||||
result = result && val;
|
// Short-circuit: if result is false, no need to evaluate further
|
||||||
|
if (!result) return false;
|
||||||
|
result = result && this.evaluateExpression(part.expr);
|
||||||
} else if (part.op === "||") {
|
} else if (part.op === "||") {
|
||||||
result = result || val;
|
// Short-circuit: if result is true, no need to evaluate further
|
||||||
|
if (result) return true;
|
||||||
|
result = result || this.evaluateExpression(part.expr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,9 +483,33 @@ export class ExpressionEvaluator {
|
||||||
if (a === b) return true;
|
if (a === b) return true;
|
||||||
if (a == null || b == null) return a === b;
|
if (a == null || b == null) return a === b;
|
||||||
if (typeof a !== typeof b) return false;
|
if (typeof a !== typeof b) return false;
|
||||||
|
|
||||||
if (typeof a === "object") {
|
if (typeof a === "object") {
|
||||||
return JSON.stringify(a) === JSON.stringify(b);
|
// Handle arrays
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (!this.deepEquals(a[i], b[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Handle plain objects
|
||||||
|
if (!Array.isArray(a) && !Array.isArray(b)) {
|
||||||
|
const keysA = Object.keys(a);
|
||||||
|
const keysB = Object.keys(b);
|
||||||
|
if (keysA.length !== keysB.length) return false;
|
||||||
|
for (const key of keysA) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
||||||
|
if (!this.deepEquals((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Mixed types (array vs object)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -499,4 +548,11 @@ export class ExpressionEvaluator {
|
||||||
getVariable(name: string): unknown {
|
getVariable(name: string): unknown {
|
||||||
return this.variables[name];
|
return this.variables[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerFunctions(functions: Record<string, (...args: unknown[]) => unknown>){
|
||||||
|
this.functions = {
|
||||||
|
...this.functions,
|
||||||
|
...functions
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import type { MarkupParseResult } from "../markup/types";
|
import type { MarkupParseResult } from "../markup/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result emitted when the dialogue produces text/dialogue.
|
||||||
|
*/
|
||||||
export type TextResult = {
|
export type TextResult = {
|
||||||
type: "text";
|
type: "text";
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -10,6 +14,9 @@ export type TextResult = {
|
||||||
isDialogueEnd: boolean;
|
isDialogueEnd: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result emitted when the dialogue presents options to the user.
|
||||||
|
*/
|
||||||
export type OptionsResult = {
|
export type OptionsResult = {
|
||||||
type: "options";
|
type: "options";
|
||||||
options: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }[];
|
options: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }[];
|
||||||
|
|
@ -18,11 +25,17 @@ export type OptionsResult = {
|
||||||
isDialogueEnd: boolean;
|
isDialogueEnd: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result emitted when the dialogue executes a command.
|
||||||
|
*/
|
||||||
export type CommandResult = {
|
export type CommandResult = {
|
||||||
type: "command";
|
type: "command";
|
||||||
command: string;
|
command: string;
|
||||||
isDialogueEnd: boolean;
|
isDialogueEnd: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type of all possible runtime results emitted by the YarnRunner.
|
||||||
|
*/
|
||||||
export type RuntimeResult = TextResult | OptionsResult | CommandResult;
|
export type RuntimeResult = TextResult | OptionsResult | CommandResult;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,14 @@ export interface RunnerOptions {
|
||||||
handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
||||||
commandHandler?: CommandHandler;
|
commandHandler?: CommandHandler;
|
||||||
onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
|
onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
|
||||||
|
/**
|
||||||
|
* If true, each runner instance maintains its own once-seen state.
|
||||||
|
* If false (default), all runners share global once-seen state.
|
||||||
|
*/
|
||||||
|
isolated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global shared state for once-seen tracking (default behavior)
|
||||||
const globalOnceSeen = new Set<string>();
|
const globalOnceSeen = new Set<string>();
|
||||||
const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index"
|
const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index"
|
||||||
|
|
||||||
|
|
@ -25,27 +31,27 @@ type CompiledOption = {
|
||||||
block: IRInstruction[];
|
block: IRInstruction[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CallStackFrame =
|
||||||
|
| { kind: "detour"; title: string; ip: number }
|
||||||
|
| { kind: "block"; title: string; ip: number; block: IRInstruction[]; idx: number };
|
||||||
|
|
||||||
export class YarnRunner {
|
export class YarnRunner {
|
||||||
private readonly program: IRProgram;
|
private readonly program: IRProgram;
|
||||||
private readonly variables: Record<string, unknown>;
|
private readonly variables: Record<string, unknown>;
|
||||||
private readonly functions: Record<string, (...args: unknown[]) => unknown>;
|
|
||||||
private readonly handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
private readonly handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
||||||
private readonly commandHandler: CommandHandler;
|
private readonly commandHandler: CommandHandler;
|
||||||
private readonly evaluator: ExpressionEvaluator;
|
private readonly evaluator: ExpressionEvaluator;
|
||||||
private readonly onceSeen = globalOnceSeen;
|
private readonly onceSeen: Set<string>;
|
||||||
|
private readonly nodeGroupOnceSeen: Set<string>;
|
||||||
private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
|
private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
|
||||||
private storyEnded = false;
|
private storyEnded = false;
|
||||||
private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
|
|
||||||
private readonly visitCounts: Record<string, number> = {};
|
private readonly visitCounts: Record<string, number> = {};
|
||||||
private pendingOptions: CompiledOption[] | null = null;
|
private pendingOptions: CompiledOption[] | null = null;
|
||||||
|
|
||||||
private nodeTitle: string;
|
private nodeTitle: string;
|
||||||
private ip = 0; // instruction pointer within node
|
private ip = 0; // instruction pointer within node
|
||||||
private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node)
|
private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node)
|
||||||
private callStack: Array<
|
private callStack: CallStackFrame[] = [];
|
||||||
| ({ title: string; ip: number } & { kind: "detour" })
|
|
||||||
| ({ title: string; ip: number; block: IRInstruction[]; idx: number } & { kind: "block" })
|
|
||||||
> = [];
|
|
||||||
|
|
||||||
currentResult: RuntimeResult | null = null;
|
currentResult: RuntimeResult | null = null;
|
||||||
history: RuntimeResult[] = [];
|
history: RuntimeResult[] = [];
|
||||||
|
|
@ -59,7 +65,15 @@ export class YarnRunner {
|
||||||
this.variables[normalizedKey] = value;
|
this.variables[normalizedKey] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.functions = {
|
// Use isolated state if requested, otherwise share global state
|
||||||
|
if (opts.isolated) {
|
||||||
|
this.onceSeen = new Set<string>();
|
||||||
|
this.nodeGroupOnceSeen = new Set<string>();
|
||||||
|
} else {
|
||||||
|
this.onceSeen = globalOnceSeen;
|
||||||
|
this.nodeGroupOnceSeen = globalNodeGroupOnceSeen;
|
||||||
|
}
|
||||||
|
let functions = {
|
||||||
// Default conversion helpers
|
// Default conversion helpers
|
||||||
string: (v: unknown) => String(v ?? ""),
|
string: (v: unknown) => String(v ?? ""),
|
||||||
number: (v: unknown) => Number(v),
|
number: (v: unknown) => Number(v),
|
||||||
|
|
@ -115,12 +129,25 @@ export class YarnRunner {
|
||||||
} as Record<string, (...args: unknown[]) => unknown>;
|
} as Record<string, (...args: unknown[]) => unknown>;
|
||||||
this.handleCommand = opts.handleCommand;
|
this.handleCommand = opts.handleCommand;
|
||||||
this.onStoryEnd = opts.onStoryEnd;
|
this.onStoryEnd = opts.onStoryEnd;
|
||||||
this.evaluator = new ExpressionEvaluator(this.variables, this.functions, this.program.enums);
|
this.evaluator = new ExpressionEvaluator(this.variables, functions, this.program.enums);
|
||||||
this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
|
this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
|
||||||
this.nodeTitle = opts.startAt;
|
this.nodeTitle = opts.startAt;
|
||||||
|
|
||||||
this.step();
|
this.step();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public registerFunctions(functions: Record<string, (...args: unknown[]) => unknown>){
|
||||||
|
this.evaluator.registerFunctions(functions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerCommands(commands: Record<string, (args: unknown[], evaluator?: ExpressionEvaluator) => void | Promise<void>>) {
|
||||||
|
for(const key in commands){
|
||||||
|
this.commandHandler.register(key, (args, evaluator) => {
|
||||||
|
if(!evaluator) return;
|
||||||
|
commands[key].call(this, args.map(arg => evaluator.evaluateExpression(arg)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a node title to an actual node (handling node groups).
|
* Resolve a node title to an actual node (handling node groups).
|
||||||
|
|
@ -217,6 +244,9 @@ export class YarnRunner {
|
||||||
this.step();
|
this.step();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate variables in text and update markup segments accordingly.
|
||||||
|
*/
|
||||||
private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } {
|
private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } {
|
||||||
const evaluateExpression = (expr: string): string => {
|
const evaluateExpression = (expr: string): string => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -235,6 +265,17 @@ export class YarnRunner {
|
||||||
return { text: interpolated };
|
return { text: interpolated };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.interpolateWithMarkup(text, markup, evaluateExpression);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate text while preserving and updating markup segments.
|
||||||
|
*/
|
||||||
|
private interpolateWithMarkup(
|
||||||
|
text: string,
|
||||||
|
markup: MarkupParseResult,
|
||||||
|
evaluateExpression: (expr: string) => string
|
||||||
|
): { text: string; markup?: MarkupParseResult } {
|
||||||
const segments = markup.segments.filter((segment) => !segment.selfClosing);
|
const segments = markup.segments.filter((segment) => !segment.selfClosing);
|
||||||
const getWrappersAt = (index: number): MarkupWrapper[] => {
|
const getWrappersAt = (index: number): MarkupWrapper[] => {
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
|
|
@ -263,29 +304,6 @@ export class YarnRunner {
|
||||||
const newSegments: MarkupSegment[] = [];
|
const newSegments: MarkupSegment[] = [];
|
||||||
let currentSegment: MarkupSegment | null = null;
|
let currentSegment: MarkupSegment | null = null;
|
||||||
|
|
||||||
const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
|
|
||||||
if (a.length !== b.length) return false;
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
const wa = a[i];
|
|
||||||
const wb = b[i];
|
|
||||||
if (wa.name !== wb.name || wa.type !== wb.type) return false;
|
|
||||||
const keysA = Object.keys(wa.properties);
|
|
||||||
const keysB = Object.keys(wb.properties);
|
|
||||||
if (keysA.length !== keysB.length) return false;
|
|
||||||
for (const key of keysA) {
|
|
||||||
if (wa.properties[key] !== wb.properties[key]) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const flushSegment = () => {
|
|
||||||
if (currentSegment) {
|
|
||||||
newSegments.push(currentSegment);
|
|
||||||
currentSegment = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendCharWithWrappers = (char: string, wrappers: MarkupWrapper[]) => {
|
const appendCharWithWrappers = (char: string, wrappers: MarkupWrapper[]) => {
|
||||||
const index = resultChars.length;
|
const index = resultChars.length;
|
||||||
resultChars.push(char);
|
resultChars.push(char);
|
||||||
|
|
@ -294,17 +312,18 @@ export class YarnRunner {
|
||||||
type: wrapper.type,
|
type: wrapper.type,
|
||||||
properties: { ...wrapper.properties },
|
properties: { ...wrapper.properties },
|
||||||
}));
|
}));
|
||||||
if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappersCopy)) {
|
if (currentSegment && this.wrappersEqual(currentSegment.wrappers, wrappersCopy)) {
|
||||||
currentSegment.end = index + 1;
|
currentSegment.end = index + 1;
|
||||||
} else {
|
} else {
|
||||||
flushSegment();
|
this.flushSegment(currentSegment, newSegments);
|
||||||
currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy };
|
currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => {
|
const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
flushSegment();
|
this.flushSegment(currentSegment, newSegments);
|
||||||
|
currentSegment = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const ch of value) {
|
for (const ch of value) {
|
||||||
|
|
@ -333,12 +352,34 @@ export class YarnRunner {
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
flushSegment();
|
this.flushSegment(currentSegment, newSegments);
|
||||||
const interpolatedText = resultChars.join('');
|
const interpolatedText = resultChars.join('');
|
||||||
const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments });
|
const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments });
|
||||||
return { text: interpolatedText, markup: normalizedMarkup };
|
return { text: interpolatedText, markup: normalizedMarkup };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private wrappersEqual(a: MarkupWrapper[], b: MarkupWrapper[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
const wa = a[i];
|
||||||
|
const wb = b[i];
|
||||||
|
if (wa.name !== wb.name || wa.type !== wb.type) return false;
|
||||||
|
const keysA = Object.keys(wa.properties);
|
||||||
|
const keysB = Object.keys(wb.properties);
|
||||||
|
if (keysA.length !== keysB.length) return false;
|
||||||
|
for (const key of keysA) {
|
||||||
|
if (wa.properties[key] !== wb.properties[key]) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushSegment(segment: MarkupSegment | null, segments: MarkupSegment[]): void {
|
||||||
|
if (segment) {
|
||||||
|
segments.push(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined {
|
private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined {
|
||||||
if (!result) return undefined;
|
if (!result) return undefined;
|
||||||
if (result.segments.length === 0) {
|
if (result.segments.length === 0) {
|
||||||
|
|
@ -384,13 +425,12 @@ export class YarnRunner {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "command": {
|
case "command": {
|
||||||
try {
|
const parsed = parseCommand(ins.content);
|
||||||
const parsed = parseCommand(ins.content);
|
// Execute command handler (errors are caught internally)
|
||||||
this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
|
this.commandHandler.execute(parsed, this.evaluator).catch((err) => {
|
||||||
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
console.warn(`Command execution error: ${err}`);
|
||||||
} catch {
|
});
|
||||||
if (this.handleCommand) this.handleCommand(ins.content);
|
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
||||||
}
|
|
||||||
this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
|
this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -463,13 +503,12 @@ export class YarnRunner {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "command": {
|
case "command": {
|
||||||
try {
|
const parsed = parseCommand(ins.content);
|
||||||
const parsed = parseCommand(ins.content);
|
// Execute command handler (errors are caught internally)
|
||||||
this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
|
this.commandHandler.execute(parsed, this.evaluator).catch((err) => {
|
||||||
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
console.warn(`Command execution error: ${err}`);
|
||||||
} catch {
|
});
|
||||||
if (this.handleCommand) this.handleCommand(ins.content);
|
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
||||||
}
|
|
||||||
this.emit({ type: "command", command: ins.content, isDialogueEnd: this.lookaheadIsEnd() });
|
this.emit({ type: "command", command: ins.content, isDialogueEnd: this.lookaheadIsEnd() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -493,7 +532,10 @@ export class YarnRunner {
|
||||||
}
|
}
|
||||||
case "return": {
|
case "return": {
|
||||||
const top = this.callStack.pop();
|
const top = this.callStack.pop();
|
||||||
if(!top) throw new Error("No call stack to return to");
|
if (!top) {
|
||||||
|
console.warn("Return called with empty call stack");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
this.nodeTitle = top.title;
|
this.nodeTitle = top.title;
|
||||||
this.ip = top.ip;
|
this.ip = top.ip;
|
||||||
this.currentNodeIndex = -1; // Reset node index for new resolution
|
this.currentNodeIndex = -1; // Reset node index for new resolution
|
||||||
|
|
@ -548,7 +590,8 @@ export class YarnRunner {
|
||||||
if (this.evaluator.evaluate(option.condition)) {
|
if (this.evaluator.evaluate(option.condition)) {
|
||||||
available.push(option);
|
available.push(option);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn(`Option condition evaluation error: ${err}`);
|
||||||
// Treat errors as false conditions
|
// Treat errors as false conditions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -556,15 +599,15 @@ export class YarnRunner {
|
||||||
}
|
}
|
||||||
|
|
||||||
private lookaheadIsEnd(): boolean {
|
private lookaheadIsEnd(): boolean {
|
||||||
// Check if current node has more emit-worthy instructions
|
// Check if current node has more emit-worthy instructions ahead
|
||||||
const node = this.resolveNode(this.nodeTitle);
|
const node = this.resolveNode(this.nodeTitle);
|
||||||
for (let k = this.ip; k < node.instructions.length; k++) {
|
for (let k = this.ip; k < node.instructions.length; k++) {
|
||||||
const op = node.instructions[k]?.op;
|
const op = node.instructions[k]?.op;
|
||||||
if (!op) break;
|
if (!op) break;
|
||||||
|
// These instructions produce output or control flow changes
|
||||||
if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false;
|
if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false;
|
||||||
if (op === "jump" || op === "detour") return false;
|
if (op === "jump" || op === "detour") return false;
|
||||||
}
|
}
|
||||||
// Node is ending - mark as end (will trigger detour return if callStack exists)
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -599,6 +642,8 @@ export class YarnRunner {
|
||||||
* Get variable value.
|
* Get variable value.
|
||||||
*/
|
*/
|
||||||
getVariable(name: string): unknown {
|
getVariable(name: string): unknown {
|
||||||
|
if(this.evaluator.isSmartVariable(name))
|
||||||
|
return this.evaluator.evaluateExpression(`$${name}`);
|
||||||
return this.variables[name];
|
return this.variables[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -609,5 +654,9 @@ export class YarnRunner {
|
||||||
this.variables[name] = value;
|
this.variables[name] = value;
|
||||||
this.evaluator.setVariable(name, value);
|
this.evaluator.setVariable(name, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSmartVariable(name: string, expression: string): void {
|
||||||
|
this.evaluator.setSmartVariable(name, expression);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue