DOCUMENTATION · 剧情格式

剧情格式

剧情是一个 JSON 文件,放在 public/stories/ 下。引擎核心不理解任何具体剧情内容, 只按 type 分派节点处理器,因此换剧情文件即可换内容。

同步自 docs/STORY_FORMAT.md(641 行) · commit b2b4124 · 读源文件 ↗

▶ 在工作台打开:完整示例剧情锈蚀带 · 仓库——11 节点 3 结局,点开即玩,可改、可导出、可分享

顶层字段

{
  "id": "demo",
  "title": "演示剧情",
  "version": "1.0.0",
  "author": "your-name",
  "description": "剧情简介",
  "start": "start",
  "plugins": ["/plugins/hello-js/manifest.json"],
  "nodes": { "start": { "type": "say", "text": "……", "next": "end" }, "end": { "type": "end" } }
}
字段类型必填说明
idstring✅剧情唯一 id,非空
titlestring➖展示标题;缺省时用 id
startstring✅起始节点 id,必须存在于 nodes 中
nodesobject✅节点表,键为节点 id,值为节点对象
pluginsstring[]➖本剧情依赖的插件 manifest 路径(相对或绝对 URL)
descriptionstring➖说明
versionstring➖版本
authorstring➖作者
worldobject➖世界节拍声明:{ "intervalMs": 500, "timeScale": 2 }。intervalMs 为基准节拍间隔(毫秒),timeScale 为倍速(实际间隔 = intervalMs / timeScale)。加载剧情时由 Runtime 应用到 WorldLoop;非法值(非正数)会被忽略。「这个世界多快」是内容的一部分,不必写死在宿主代码里
persistentboolean➖可选,为 true 时表示该剧情是「持续运转」型:剧情本身不会结束,世界循环(WorldLoop)持续驱动。这是元数据标记,供 UI / 文档 / 运维理解剧情形态;引擎侧的实际「永不结束」由节点图保证(典型写法是 hold 节点 next 指向自身)

编辑器提示:文件里加 "$schema": "/story.schema.json" 即可获得字段补全与即时校验 (schema 在 public/story.schema.json)。注意 JSON Schema 表达不了「节点引用必须存在」这类 跨字段约束 —— 那由 npm run story:lint 与 StoryEngine.validateStory 负责。

校验规则(StoryEngine.validateStory)

剧情在加载时会被严格校验,先校验后落地,非法剧情不会破坏当前正在运行的剧情:

违反任一条都会抛出 StoryFormatError,页面显示错误横幅并在日志中记录。

节点通用字段

字段类型说明
typestring必填,节点类型。内置类型见下表;也可以是插件注册的自定义类型
nextstring | null节点执行后默认跳转的目标。null 或缺省表示剧情自然结束

若处理器返回了 next(例如 jump、if、choice、插件命令返回的 next),则优先使用返回值, 否则使用节点的 next 字段。


节点类型总览

类型作用主要字段
say显示一句对白,等待玩家继续speaker / text / auto / delayMs
choice显示选项,等待玩家选择options(text / next / condition / effects / set)/ timeoutMs / timeoutNext
jump显式跳转到指定节点target(或 next)
set写入状态key/path + value/expr,或 values,或 effects
if条件分支branches(condition / next)/ else / next
wait等待若干毫秒ms(或 duration)/ label / skippable
emit广播事件event(或 name)/ payload
plugin调用插件命令plugin / command / args / silent
ui挂载插件注册的 UI 组件component(或 ui)/ props / wait
observe被动监听世界事件,事件到达时跳转event / condition / priority / next / once / cooldownMs / match
end结束剧情text(作为结束摘要)

节点类型名称是大小写敏感的。内置类型不可被插件覆盖:同名时内置处理器优先。


say:显示对白

{
  "type": "say",
  "speaker": "旁白",
  "text": "你来到一个岔路口。背包里有 {{ var.gold }} 枚金币。",
  "next": "choice1"
}
字段类型说明
speakerstring说话人,可选;缺省时不显示说话人
textstring对白文本,支持 {{ 表达式 }} 插值
autoboolean为 true 时不等待玩家点击继续,直接进入下一节点(缺省 false)
delayMsnumber配合 auto 用:推进前先停这么多毫秒(别名 delay,上限 600000)。用来给旁白留呼吸感

副作用:广播 story:say;非 auto 时会阻塞主循环直到玩家点击「继续 ▸」或按空格 / 回车。


choice:分支选择

{
  "type": "choice",
  "options": [
    { "text": "去森林", "next": "forest" },
    { "text": "回村庄", "next": "village", "condition": "flag.hasKey" },
    {
      "text": "购买火把(10 金币)",
      "condition": "var.gold >= 10",
      "set": { "var.gold": 0 },
      "effects": [{ "type": "addItem", "item": "torch", "count": 1 }],
      "next": "forest"
    }
  ],
  "next": "end"
}
选项字段类型说明
textstring必填,选项文案,支持插值
nextstring选中后跳转的节点
conditionstring条件表达式;不满足则该选项被隐藏
effectsEffectSpec[]选中后落地的效果数组
setobject选中后写入的状态(键为状态路径,等价于一批 set 效果)
节点字段说明
options选项数组;非数组或为空时视为没有选项
next回退目标:当选中项没有 next(或所有选项都不可见)时使用

行为细节:


jump:显式跳转

{ "type": "jump", "target": "finalCheck" }
字段说明
target目标节点 id,优先于 next
next备选目标(当 target 缺省时使用)

target 与 next 都不存在时抛 StoryRuntimeError。广播 story:jump。


set:写入状态

支持三种写法,可组合使用:

{
  "type": "set",
  "values": { "actor.hero.name": "旅行者", "var.gold": 5, "flag.hasKey": true },
  "next": "intro"
}
{ "type": "set", "key": "var.gold", "expr": "num(var.gold) + 10", "next": "tip" }
{
  "type": "set",
  "effects": [
    { "type": "setFlag", "flag": "hasKey", "value": true },
    { "type": "addItem", "item": "torch", "count": 1 }
  ],
  "next": "next"
}
字段类型说明
valuesobject批量写入:键为状态路径,值为目标值
key / pathstring单键写入的目标路径
valueany单键写入的值(当没有 expr 时使用)
exprstring单键写入:表达式求值结果作为值(优先于 value)
effectsEffectSpec[]直接给出效果数组(支持所有内置效果类型)

三种写法都会按 values → key/path → effects 的顺序依次落地(都会执行,不是互斥)。 没有任何可写入内容时会记录一条警告。


if:条件分支

{
  "type": "if",
  "branches": [
    { "condition": "var.gold >= 50", "next": "richEnd" },
    { "condition": "var.gold >= 10", "next": "normalEnd" }
  ],
  "else": "poorEnd"
}
字段类型说明
branchesarray分支数组,按顺序取第一个成立的分支
branches[].conditionstring | boolean条件表达式。true 或省略(undefined)视为无条件命中
branches[].nextstring命中后跳转的节点(缺省视为结束)
elsestring所有分支都不成立时跳转的节点
nextstring回退目标:当没有 else 时使用

命中时广播 story:branch(含 condition / next)。


wait:等待

{ "type": "wait", "ms": 600, "label": "……", "next": "emitIt" }
字段类型说明
msnumber等待毫秒数,缺省 1000。会被裁剪到 [0, 600000]
durationnumberms 的别名
labelstring等待期间显示的提示文案,缺省为「等待 N 毫秒…」

UI 会显示一个进度条。等待期间不阻塞其它系统(await 而非忙碌等待)。


emit:广播事件

{ "type": "emit", "event": "demo:minimal", "payload": { "hello": "world" }, "next": "jumpAhead" }
字段类型说明
eventstring事件名,必填(name 是别名)
payloadany事件负载,缺省 {}

以 { source: 'engine' } 广播。插件可以用 ctx.events.on('demo:minimal', ...) 监听。


plugin:调用插件命令

{
  "type": "plugin",
  "plugin": "com.example.hello",
  "command": "greet",
  "args": { "name": "{{ actor.hero.name }}" },
  "next": "end"
}
字段类型说明
commandstring必填,命令名(name 是别名)
pluginstring可选,限定由哪个插件处理。填错(命令实际由别的插件注册)会报错
argsobject传给命令的参数,会递归做插值
silentboolean为 true 时不等待玩家确认插件返回的 message
nextstring命令返回里没有 next 时使用

命令执行的返回值如何被引擎消化:

返回字段引擎行为
message以对白形式渲染(说话人为 plugin 字段或 插件),非 silent 时等待玩家继续;广播 story:say
effects由引擎 applyEffects 落地(内置类型 + 插件自定义类型)
next覆盖节点的 next(可为 null 表示结束)
stop为 true 时终止主循环
data / ok供命令之间传递数据,引擎不直接消费

命令不存在会抛 COMMAND_NOT_FOUND(页面显示错误横幅,日志给出可用命令列表)。


ui:挂载插件 UI 组件

{
  "type": "ui",
  "component": "greeter",
  "props": { "title": "旅店老板", "note": "由 JS 插件注册" },
  "wait": true,
  "next": "intro"
}
字段类型说明
componentstring必填,组件名(ui 是别名)。可用完整名 com.example.hello:greeter 或唯一短名 greeter
propsobject传给组件的属性,会递归做插值
waitboolean为 true 时等待玩家点击「继续 ▸」后才进入下一节点

组件挂载到独立容器中,只有组件的渲染函数被调用;组件抛错只会显示在该容器内,不影响剧情。 找不到组件时记录警告并跳过该节点。


observe:被动监听世界事件

这是「世界模拟器」的核心节点:剧情不再主动推进,而是被动「听」世界说话。 剧情加载时,引擎会武装(armObservers)全部 observe 节点,把它们登记为对应世界事件的候选观察者。 世界事件(由插件 ctx.world.publish / WASM host_emit / 剧情节点的 emit 发出)到达时:

  1. 只响应当前剧情(换剧情后旧订阅自动撤销);
  2. 用 condition 过滤候选;
  3. 在满足条件的候选中取 priority 最高者,把执行位置挪到其 next(并唤醒在途的 UI 等待)。
{
  "type": "observe",
  "event": "alert:emergency",
  "condition": "var.alertLevel > 5",
  "priority": 30,
  "next": "narration_alert"
}
字段类型说明
eventstring必填,要监听的世界事件名(空串的节点会被忽略并告警)
conditionstring可选,条件表达式;不满足时该候选被过滤
prioritynumber可选,优先级(默认 0),同一事件多观察者时取最高
nextstring必填,事件触发后跳转的节点 id(指向不存在的节点会被忽略并告警)

行为细节:

完整示例见 world.json(世界模拟器演示:剧情不主动推进,observe 监听 WASM 发布的世界事件后插话)。

限时选择:choice 写 "timeoutMs": 8000(+ 可选 "timeoutNext": "太慢了")就是限时选择 —— 引擎把时限交给 UI,UI 超时时返回哨兵值 CHOICE_TIMEOUT(-1),剧情走 timeoutNext (缺省走节点的 next)。超时也会被记进回放素材(index: -1)。

可跳过的等待:wait 写 "skippable": true 后,进度条与玩家的「继续」谁先到算谁, 并广播 story:waitSkipped。

意图通道:intent:<名字>

observe 除了听世界事件,也是接收玩家操作意图的唯一入口 —— 插件用 ctx.intents.send('move', { dir: 'n' }) 把意图发出来,引擎受理后广播 intent:move:

{
  "type": "observe",
  "event": "intent:move",
  "match": "args.dir == 'n'",
  "priority": 10,
  "next": "moved_north"
}

引擎只在「剧情空闲(停在 say / wait)」且「确实有这个 observe 在监听」时才受理;其余情况 ctx.intents.send() 返回 false,面板据此给玩家反馈。所以剧情侧仍然只需要 observe, 不需要新的节点类型。详见 PLUGIN_JS.md 的 ctx.intents。

事件名只是示例,引擎不关心语义;实际事件名与结构由插件自定义。


end:结束剧情

{ "type": "end", "text": "演示剧情到此结束。" }
字段类型说明
textstring可选的结束摘要,支持插值,会显示在结束界面

副作用:标记剧情结束、渲染结束界面、广播 story:end,并终止主循环。

另一个「自然结束」的途径:节点没有后继(next 缺省或为 null,且处理器未返回 next)。


条件表达式 DSL

条件(choice.options[].condition、if.branches[].condition)与插值({{ ... }})都使用同一套 DSL。 实现是手写的词法分析 + 递归下降语法分析,绝不使用 eval / new Function, 因此剧情文件无法执行任意脚本。详见 src/runtime/Expression.ts。

语法

类别支持内容
字面量数字 12 3.14 1e3、字符串 "文本" '文本'、true、false、null
状态引用flag.hasKey、var.gold、item.torch、actor.hero.hp;省略命名空间时默认 var
算术+ - * /
比较== != > >= < <=
逻辑! && `
分组( ... )
一元负号-x

内置函数

函数说明
has(path)路径是否存在(参数必须是字符串字面量,如 has('flag.hasKey'))
defined(path)路径存在且值不为 null / 空串
len(x)字符串 / 数组长度,对象返回键数量,其它返回 0
num(x)转数字,失败返回 0
str(x)转字符串
bool(x)转布尔

表达式里的引用是路径(如 flag.hasKey),而函数参数若是路径名需要加引号(如 has('flag.hasKey'))。 调用未定义的函数会抛 ExpressionError。

真值判断

false / 0 / NaN / '' / null / undefined 为假,其余为真(包括空对象、空数组)。

比较与运算细节

示例

flag.hasKey && item.torch >= 1
!(actor.hero.hp < 30) || has('item.potion')
var.gold + 5 > 10
len(actor.hero.buffs) > 0
num(var.gold) >= 50

文本插值

{ "text": "你有 {{ var.gold }} 金币,来自 {{ actor.hero.name }}。" }

错误降级策略

场景行为
条件表达式出错按 false(保守:条件不成立),记录警告 + 广播 story:error
插值表达式出错回退保留原文 {{ ... }},记录警告
set 的 expr 出错写入 undefined,记录警告

以上均不会中断剧情执行。


变量、标志、物品、角色状态

状态分四个命名空间,路径用点号分隔:

命名空间用途示例
var.*普通变量(数字 / 字符串 / 对象等)var.gold、var.lastNote
flag.*布尔标志flag.hasKey、flag.wasGreeted
item.*物品数量(数字)item.torch = 2
actor.*角色状态(对象)actor.hero.name、actor.hero.hp

常见写法:

{ "type": "set", "values": { "flag.hasKey": true, "var.gold": 5, "actor.hero.name": "旅行者" } }
{ "type": "set", "key": "var.gold", "expr": "num(var.gold) + 10" }

如何调用插件命令

三步:

  1. 剧情文件顶层 plugins 声明插件 manifest:

    "plugins": ["/plugins/hello-js/manifest.json", "/plugins/hello-wasm/manifest.json"]
  2. 插件在 init 注册命令(JS:ctx.commands.register('greet', ...);WASM:在 plugin_init 的返回里声明 commands)。

  3. 剧情用 plugin 节点调用:

    { "type": "plugin", "plugin": "com.example.hello", "command": "greet", "args": { "name": "旅行者" }, "next": "end" }

插件返回的 message 会以对白渲染,effects 由引擎落地,next / stop 影响流程。 参见 PLUGIN_JS.md 与 PLUGIN_WASM.md。


如何注册自定义节点类型

插件可以注册全新的节点类型,剧情中直接写该 type 即可(无需改动引擎):

// JS 插件 init 中(需要 nodes:register 权限)
ctx.nodes.register('note', (node, api) => {
  api.state.set('var.lastNote', String(node.text ?? ''))
  return { next: typeof node.next === 'string' ? node.next : null }
})
{ "type": "note", "text": "森林的低语渐渐平息。", "next": "forestEnd" }

完整剧情示例

仓库内置三个可直接运行的剧情:

下面是 minimal.json 的内容,可作为你的起点:

{
  "id": "minimal",
  "title": "最小剧情(无插件)",
  "version": "1.0.0",
  "description": "只使用内置节点类型,证明引擎核心不依赖任何插件。",
  "start": "hello",
  "nodes": {
    "hello": { "type": "say", "speaker": "旁白", "text": "这里没有任何插件,只有引擎内置节点。", "next": "askName" },
    "askName": { "type": "say", "speaker": "系统", "text": "当前金币:{{ var.gold }}", "next": "addGold" },
    "addGold": { "type": "set", "key": "var.gold", "expr": "num(var.gold) + 10", "next": "tip" },
    "tip": { "type": "wait", "ms": 600, "label": "……", "next": "emitIt" },
    "emitIt": { "type": "emit", "event": "demo:minimal", "payload": { "hello": "world" }, "next": "jumpAhead" },
    "jumpAhead": { "type": "jump", "target": "finalCheck" },
    "finalCheck": { "type": "if", "branches": [{ "condition": "var.gold >= 10", "next": "richEnd" }], "else": "poorEnd" },
    "richEnd": { "type": "say", "speaker": "系统", "text": "金币足够:{{ var.gold }}", "next": "end" },
    "poorEnd": { "type": "say", "speaker": "系统", "text": "金币不足。", "next": "end" },
    "end": { "type": "end" }
  }
}

注意 askName 里引用了尚未设置的 var.gold,{{ var.gold }} 会渲染成空串(stringifyValue(undefined)), 这是刻意演示「未定义状态不报错」。


热替换


引擎的运行时保护

派生状态:derived(可选顶层字段)

{
  "id": "shop", "start": "open",
  "derived": { "var.总价": "var.单价 * var.数量" },
  "nodes": { "open": { "type": "end", "text": "完" } }
}