DOCUMENTATION · 剧情格式
剧情格式
剧情是一个 JSON 文件,放在 public/stories/ 下。引擎核心不理解任何具体剧情内容, 只按 type 分派节点处理器,因此换剧情文件即可换内容。
- 文件位置:
public/stories/<name>.json,通过路径/stories/<name>.json访问。 - 登记方式:在
public/stories/index.json追加条目(页面顶部下拉框的数据源)。 - 热替换:修改剧情文件后刷新页面即生效;页面「重新开始」按钮带时间戳绕过缓存。
顶层字段
{
"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" } }
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | ✅ | 剧情唯一 id,非空 |
title | string | ➖ | 展示标题;缺省时用 id |
start | string | ✅ | 起始节点 id,必须存在于 nodes 中 |
nodes | object | ✅ | 节点表,键为节点 id,值为节点对象 |
plugins | string[] | ➖ | 本剧情依赖的插件 manifest 路径(相对或绝对 URL) |
description | string | ➖ | 说明 |
version | string | ➖ | 版本 |
author | string | ➖ | 作者 |
world | object | ➖ | 世界节拍声明:{ "intervalMs": 500, "timeScale": 2 }。intervalMs 为基准节拍间隔(毫秒),timeScale 为倍速(实际间隔 = intervalMs / timeScale)。加载剧情时由 Runtime 应用到 WorldLoop;非法值(非正数)会被忽略。「这个世界多快」是内容的一部分,不必写死在宿主代码里 |
persistent | boolean | ➖ | 可选,为 true 时表示该剧情是「持续运转」型:剧情本身不会结束,世界循环(WorldLoop)持续驱动。这是元数据标记,供 UI / 文档 / 运维理解剧情形态;引擎侧的实际「永不结束」由节点图保证(典型写法是 hold 节点 next 指向自身) |
编辑器提示:文件里加
"$schema": "/story.schema.json"即可获得字段补全与即时校验 (schema 在public/story.schema.json)。注意 JSON Schema 表达不了「节点引用必须存在」这类 跨字段约束 —— 那由npm run story:lint与StoryEngine.validateStory负责。
校验规则(StoryEngine.validateStory)
剧情在加载时会被严格校验,先校验后落地,非法剧情不会破坏当前正在运行的剧情:
- 根节点必须是 JSON 对象;
id/start必须是非空字符串;nodes必须是对象。 start必须指向nodes中已存在的节点。- 每个节点必须是对象,且
type必须是非空字符串。 next若存在,必须是字符串或null;若为字符串,必须指向已存在的节点。- 全量引用检查:
next/target(jump)/else与branches[].next(if)/options[].next(choice) 都会被校验,指向不存在的节点时加载即报错,并指出出错的字段路径(例如节点 "x" 的 options[1].next 指向不存在的节点 "y")。 插件注册的自定义节点类型会退化为扫描通用引用字段(next/target/else)。
违反任一条都会抛出 StoryFormatError,页面显示错误横幅并在日志中记录。
节点通用字段
| 字段 | 类型 | 说明 |
|---|---|---|
type | string | 必填,节点类型。内置类型见下表;也可以是插件注册的自定义类型 |
next | string | 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"
}| 字段 | 类型 | 说明 |
|---|---|---|
speaker | string | 说话人,可选;缺省时不显示说话人 |
text | string | 对白文本,支持 {{ 表达式 }} 插值 |
auto | boolean | 为 true 时不等待玩家点击继续,直接进入下一节点(缺省 false) |
delayMs | number | 配合 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"
}| 选项字段 | 类型 | 说明 |
|---|---|---|
text | string | 必填,选项文案,支持插值 |
next | string | 选中后跳转的节点 |
condition | string | 条件表达式;不满足则该选项被隐藏 |
effects | EffectSpec[] | 选中后落地的效果数组 |
set | object | 选中后写入的状态(键为状态路径,等价于一批 set 效果) |
| 节点字段 | 说明 |
|---|---|
options | 选项数组;非数组或为空时视为没有选项 |
next | 回退目标:当选中项没有 next(或所有选项都不可见)时使用 |
行为细节:
- 条件不满足的选项不会渲染(玩家看不到)。
- 若没有任何可用选项,会记录警告、广播
story:error,并走节点的next(缺省则结束)。 - 选中后会先落地该选项的
effects,再落地set,最后才跳转。 - 广播
story:choice(含index/text/option)。 index是选项在原始options数组中的下标,因此隐藏选项不会打乱定位。
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"
}| 字段 | 类型 | 说明 |
|---|---|---|
values | object | 批量写入:键为状态路径,值为目标值 |
key / path | string | 单键写入的目标路径 |
value | any | 单键写入的值(当没有 expr 时使用) |
expr | string | 单键写入:表达式求值结果作为值(优先于 value) |
effects | EffectSpec[] | 直接给出效果数组(支持所有内置效果类型) |
三种写法都会按 values → key/path → effects 的顺序依次落地(都会执行,不是互斥)。
没有任何可写入内容时会记录一条警告。
if:条件分支
{
"type": "if",
"branches": [
{ "condition": "var.gold >= 50", "next": "richEnd" },
{ "condition": "var.gold >= 10", "next": "normalEnd" }
],
"else": "poorEnd"
}| 字段 | 类型 | 说明 |
|---|---|---|
branches | array | 分支数组,按顺序取第一个成立的分支 |
branches[].condition | string | boolean | 条件表达式。true 或省略(undefined)视为无条件命中 |
branches[].next | string | 命中后跳转的节点(缺省视为结束) |
else | string | 所有分支都不成立时跳转的节点 |
next | string | 回退目标:当没有 else 时使用 |
命中时广播 story:branch(含 condition / next)。
wait:等待
{ "type": "wait", "ms": 600, "label": "……", "next": "emitIt" }| 字段 | 类型 | 说明 |
|---|---|---|
ms | number | 等待毫秒数,缺省 1000。会被裁剪到 [0, 600000] |
duration | number | ms 的别名 |
label | string | 等待期间显示的提示文案,缺省为「等待 N 毫秒…」 |
UI 会显示一个进度条。等待期间不阻塞其它系统(await 而非忙碌等待)。
emit:广播事件
{ "type": "emit", "event": "demo:minimal", "payload": { "hello": "world" }, "next": "jumpAhead" }| 字段 | 类型 | 说明 |
|---|---|---|
event | string | 事件名,必填(name 是别名) |
payload | any | 事件负载,缺省 {} |
以 { source: 'engine' } 广播。插件可以用 ctx.events.on('demo:minimal', ...) 监听。
plugin:调用插件命令
{
"type": "plugin",
"plugin": "com.example.hello",
"command": "greet",
"args": { "name": "{{ actor.hero.name }}" },
"next": "end"
}| 字段 | 类型 | 说明 |
|---|---|---|
command | string | 必填,命令名(name 是别名) |
plugin | string | 可选,限定由哪个插件处理。填错(命令实际由别的插件注册)会报错 |
args | object | 传给命令的参数,会递归做插值 |
silent | boolean | 为 true 时不等待玩家确认插件返回的 message |
next | string | 命令返回里没有 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"
}| 字段 | 类型 | 说明 |
|---|---|---|
component | string | 必填,组件名(ui 是别名)。可用完整名 com.example.hello:greeter 或唯一短名 greeter |
props | object | 传给组件的属性,会递归做插值 |
wait | boolean | 为 true 时等待玩家点击「继续 ▸」后才进入下一节点 |
组件挂载到独立容器中,只有组件的渲染函数被调用;组件抛错只会显示在该容器内,不影响剧情。 找不到组件时记录警告并跳过该节点。
observe:被动监听世界事件
这是「世界模拟器」的核心节点:剧情不再主动推进,而是被动「听」世界说话。
剧情加载时,引擎会武装(armObservers)全部 observe 节点,把它们登记为对应世界事件的候选观察者。
世界事件(由插件 ctx.world.publish / WASM host_emit / 剧情节点的 emit 发出)到达时:
- 只响应当前剧情(换剧情后旧订阅自动撤销);
- 用
condition过滤候选; - 在满足条件的候选中取
priority最高者,把执行位置挪到其next(并唤醒在途的 UI 等待)。
{
"type": "observe",
"event": "alert:emergency",
"condition": "var.alertLevel > 5",
"priority": 30,
"next": "narration_alert"
}| 字段 | 类型 | 说明 |
|---|---|---|
event | string | 必填,要监听的世界事件名(空串的节点会被忽略并告警) |
condition | string | 可选,条件表达式;不满足时该候选被过滤 |
priority | number | 可选,优先级(默认 0),同一事件多观察者时取最高 |
next | string | 必填,事件触发后跳转的节点 id(指向不存在的节点会被忽略并告警) |
行为细节:
- 若剧情通过
next/jump直接走进一个observe节点(作为普通途经点),会静默按next继续,不阻塞、不输出。 - 事件到达时若没有候选满足(或条件不满足),剧情保持原状。
- 触发跳转会广播
story:observe(含event/nodeId/next/payload)。 match:用事件 payload 求值的附加条件,表达式里用args.*引用 payload (例:"match": "args.dir == 'n'",或是"match": "args.hp < 20 && flag.raging"—— 可与状态混用)。 于是同一个事件能按 payload 分流到不同节点,不必再写一串observe → if分派链。once: true:该 observe 只响应一次,响应后本次剧情加载内作废(换剧情重新武装时恢复)。cooldownMs:响应后这段时间内不再响应(节流高频世界事件,避免一次事件风暴把剧情推着跑)。- 同一事件有多个候选时取
priority最高者;条件不满足 / 已作废 / 冷却中的候选会被跳过。 - 典型用法:剧情用
hold节点(say且next指向自身)悬停,让世界事件随时「插话」;配合顶层persistent: true即可构成永不结束、持续旁白的「世界直播」剧情。
完整示例见 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": "演示剧情到此结束。" }| 字段 | 类型 | 说明 |
|---|---|---|
text | string | 可选的结束摘要,支持插值,会显示在结束界面 |
副作用:标记剧情结束、渲染结束界面、广播 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 为假,其余为真(包括空对象、空数组)。
比较与运算细节
==/!=:null与undefined视为相等;同类型直接比较; 否则尝试转数字比较("10" == 10为真);再否则按字符串比较。>>=<<=:两侧都可转数字时按数字比较,否则按字符串字典序比较。+:任一侧是字符串则做字符串拼接,否则做数值加法。/:除数为0会抛错(条件按false处理)。
示例
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 }}。" }{{ 表达式 }}会被替换成表达式结果(用str的规则转字符串)。支持
\{{转义为字面量{{。在
args/props中,整串就是一个表达式时保留原始类型(数字 / 布尔 / 对象),便于传参:{ "args": { "amount": "{{ var.gold }}", "hero": "{{ actor.hero }}" } }插值出错时回退为原文并记录警告,不会中断剧情。
错误降级策略
| 场景 | 行为 |
|---|---|
| 条件表达式出错 | 按 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 |
- 省略命名空间时默认落在
var:gold等价于var.gold。 actor.hero.hp会自动创建中间对象(set时)。- 不能整体覆盖命名空间(如
set('flag', ...)会报错),必须指定更具体的路径。 - 状态栏实时显示全部内容;每次写入广播
state:change。
常见写法:
{ "type": "set", "values": { "flag.hasKey": true, "var.gold": 5, "actor.hero.name": "旅行者" } }{ "type": "set", "key": "var.gold", "expr": "num(var.gold) + 10" }如何调用插件命令
三步:
剧情文件顶层
plugins声明插件 manifest:"plugins": ["/plugins/hello-js/manifest.json", "/plugins/hello-wasm/manifest.json"]插件在
init注册命令(JS:ctx.commands.register('greet', ...);WASM:在plugin_init的返回里声明commands)。剧情用
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" }- 处理器收到节点对象与受权限校验的
api(含api.nodeId/api.node)。 - 返回
{ next?: string | null; stop?: boolean };不返回则使用节点自身的next。 - 若节点类型既不是内置、也没有插件注册,抛
未知节点类型 "xxx"(内置类型:say, choice, ...;已注册的插件节点:...)。
完整剧情示例
仓库内置三个可直接运行的剧情:
- public/stories/demo.json:演示
set→say→choice→ JS 命令greet→ WASM 命令wasm_greet→ 插件自定义节点note→if→end,并挂载插件 UI 组件。 - public/stories/minimal.json:只用内置节点,验证引擎可脱离插件运行。
- public/stories/world.json:世界模拟器演示。剧情不主动推进,而是被动「听」世界说话:
WASM 插件每拍递减物种数量,归零时发布灭绝事件;剧情用
observe节点听到该事件后转向旁白, JS 插件同时用ctx.world.subscribe弹出世界警示 —— JSON / JS / WASM 三者共享同一套 EventBus 与 StateStore。
下面是 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)), 这是刻意演示「未定义状态不报错」。
热替换
- 修改剧情后刷新页面即生效,无需重启 dev server。
- 页面顶部「重新开始」按钮走
Runtime.restart():暂停 → 清空状态 → 带cacheBust重新拉取剧情文件 → 重新加载插件 → 再次开始。适合在调试时反复验证同一段剧情。 - 切换顶部下拉框里的剧情同样走
cacheBust加载。 - 修改插件文件(
index.js/plugin.wasm/manifest.json)后同样是刷新页面生效;plugin.wasm需要先重新npm run build:wasm。
引擎的运行时保护
- 死循环保护:主循环有
maxSteps(默认100000),超出抛剧情执行超过 N 步,疑似死循环(当前节点 "xxx")。 - 缺失 next 目标:若节点的
next指向不存在的节点,抛StoryRuntimeError(加载时也会校验)。 - 错误不中断页面:主循环内异常会被捕获,转成
story:error+ UI 错误横幅,页面不会白屏。 - 效果容错:
applyEffects逐条try/catch,单条失败不影响整批,未注册的效果类型只记录警告。
派生状态:derived(可选顶层字段)
{
"id": "shop", "start": "open",
"derived": { "var.总价": "var.单价 * var.数量" },
"nodes": { "open": { "type": "end", "text": "完" } }
}- 引擎在加载时校验:路径要以
flag. / var. / item. / actor.开头 ✓、表达式能解析 ✓、 不自己依赖自己、彼此不成环 ✗ —— 坏了就当场报错 ✓,不会拖到运行时变成"某个数值莫名不对" ✗。 - 任何状态变化之后都会重算 ✓(包括插件直接写的状态 ✓),所以剧本里读
var.总价就行。 - 派生项不能被直接写 ✗:剧本里写它会报错(写了也会被下一次重算覆盖,报错更诚实 ✓)。
- 表达式里可以写中文路径 ✓(
var.单价✓,见 CHANGELOG 里那条修复)。