把「玩法」还给插件,把「可能」还给剧情
引擎与剧情彻底解耦:换一个 JSON 文件就是换一段内容,装一个插件就是长出一种玩法。
-
01
双形态插件体系
JS 插件(ES Module 动态
import())与 Rust WASM 插件(裸 C ABI,不用 wasm-bindgen)统一管理,注册命令、条件、效果、自定义节点与 UI 组件。 -
02
安全表达式 DSL
手写词法 + 递归下降语法分析,绝不使用 eval / new Function。标识符规则支持 Unicode ——
var.金币 >= 10可以直接写。 -
03
21 项权限 + 运行期审计
manifest 声明权限,宿主对每一次能力调用做校验;支持作用域限定(如
state:write:var.arena)。每次尝试都留痕 —— 拒绝也记。 -
04
存档全家桶
localStorage 多槽位 + 节点级自动存档;带信封的存档文件可导出 / 导入;逐级版本迁移、老档可读、拿错文件明确报错,多端冲突能比较出「分叉」。
-
05
世界模拟器
WorldLoop以固定节拍广播world:tick;剧情写一个observe节点就能被动听世界说话 —— 世界持续运转,剧情永不结束。 -
06
实时帧循环
固定步长逻辑(帧率无关)+ 渲染帧插值,没有订阅就不烧帧。性能看门狗实测帧耗时、自动降档带防抖,
?perf=1挂只读浮层。 -
07
给编剧的剧本编辑器
表单 / 文本 / JSON 三种模式;结构体检(断头路、无结局、条件写错)、一键试读、撤销重做、可打印剧本 HTML 导出,产物一定过引擎校验。
-
08
库形态 + iframe 嵌入
import { Runtime } from 'plugin-story-engine'直接用(克隆即用、免构建);或 iframe + postMessage 桥,父页不 import 引擎也能驱动剧情。 -
09
确定性与回放
种子化随机(同种子必然复现)、稳定运行指纹、无头回放 CLI —— 在另一台宿主跑同一条命令比对指纹,跨宿主一致性可被验证。
-
10
错误隔离与熔断
单个插件崩溃 / 超时 / 连续失败自动熔断,都拖不垮主引擎与其它插件;切剧情后的「漏网定时器」能被
leakReport()点名。 -
11
认领式输入 + 触屏
插件「认领」键盘与指针,引擎独占捕获层仲裁(优先级 + 作用域);触屏控件把手指翻译成相同键码,插件零改动支持移动端。
-
12
可观测性
状态来源追踪(
explain('var.金币')→ 谁写的、哪一步)、每插件开销归因、诊断包一键导出 —— 玩家说卡住了,让他导出,你就能远程复现。
微内核 + 受检插件 + 纯数据剧情
引擎按 type 分派节点处理器;插件拿不到引擎内部对象,只能通过经过权限校验的 ctx 说活。
静态权限清单是「许诺」,运行期 AuditLog 是「证据」—— 插件拿不到引擎内部对象,每一次能力调用都被宿主校验。
引擎听得懂的十一种句子
内置节点不可被插件覆盖;插件还能注册自己的节点类型,与内置节点平权分派。
say显示一句对白,等待玩家继续;支持 speaker、auto、delayMschoice显示选项等玩家选;选项可带条件与效果,支持限时jump显式跳转到指定节点set写入状态:var / flag / item / actor 四个命名空间if条件分支,条件即安全表达式(认中文标识符)wait等待若干毫秒;可标记为可跳过emit广播事件给所有监听者(插件 / 宿主)plugin调用插件命令,结果可渲染、可落地效果ui挂载插件注册的 UI 组件,可选等待玩家确认observe被动听世界事件,事件到达时跳转 —— 世界模拟器的核心end结束剧情,带一句结束摘要……你的插件用 ctx.nodes.register() 注册自定义节点类型
五分钟跑起来
Node.js ≥ 18、npm ≥ 9 即可;只有构建 WASM 插件才需要 Rust。
从源码跑起来
# 克隆仓库(含构建好的库产物,离线也能用) git clone https://gitcode.com/rincjiyu/rne.git cd rne && npm install npm run dev # http://localhost:5173,开箱即玩 npm test # 439 个用例全绿 npm run create-plugin -- com.example.demo # 生成能跑的「插件+剧情+测试」四件套
- 五个页面入口:引擎演示、剧本编辑器、嵌入演示、展示页…
- 推送前自动跑类型检查 + 全量测试(pre-push 钩子)
当库引入(免构建)
# dist-lib 随仓库提交:克隆即用,无需构建、离线也行 npm install git+https://gitcode.com/rincjiyu/rne.git#main # 或打成 tarball 离线分发 npm run prepack && npm pack # → plugin-story-engine-1.5.0.tgz // ── 然后 ────────────────────────────── import { mount } from 'plugin-story-engine' const app = mount('#app', { story: '/stories/demo.json' })
- ESM 产物 145 KB(gzip 约 50 KB)+ 43 份类型声明
- 构建产物已在 node 里实测跑通整条剧情链路
六种姿势,用你想用的方式
从「一行挂载就能玩」到「用 Rust 写一个插件」,同一套内核。
想要界面就直接用。mount 是可选入口 —— 不用它的人一行都不受影响;自带存档 / 读档 / 导出导入 / 亮暗主题 / 移动端屏幕控件 / 打字机。
main.ts
import { mount } from 'plugin-story-engine' // 挂上就能玩:存档 / 读档 / 导出导入 / 亮暗主题 / 打字机,全都自带 const app = mount('#app', { story: '/stories/demo.json' }) // 需要收摊时干净卸载 await app.destroy()
做产品级界面?自己实现 RuntimeUi。引擎不认任何具体 UI,给它一个实现接口的东西就行 —— 官方默认 UI 有意不提供扩展点,守住「引擎不含 UI」这条线。
my-ui.ts
import { Runtime, type RuntimeUi } from 'plugin-story-engine' // 引擎不认任何具体界面:给它一个实现 RuntimeUi 的东西就行(你的是你自己的 UI) const runtime = new Runtime({ ui: myUi }) await runtime.loadStory('/stories/demo.json', { autoStart: true }) runtime.state.get('var.gold') // 读状态 runtime.events.on('story:say', …) // 听事件
把引擎塞进别人的页面。父页不需要 import 引擎运行时,只认协议:channel 命名空间、命令白名单、请求-答复配对、来源校验 —— 协议刻意做得很窄。
host-page.ts
import { createEmbedHost, parentSurface } from 'plugin-story-engine' const host = createEmbedHost({ surface: parentSurface(iframe, location.origin) }) await host.ready() // 等子页就绪 await host.load('/stories/demo.json') await host.start() host.on('story:say', ({ payload }) => console.log(payload))
玩法能力全靠插件长出来。manifest 声明权限(缺了拒绝加载),init 里通过受检的 ctx 注册能力;跑 npm run create-plugin -- com.example.x 一条命令生成能跑的四件套。
public/plugins/shop/index.js
// manifest.json 声明权限 —— 宿主对每一次能力调用做校验,拒绝也留痕 export default { init(ctx) { // 剧情 JSON 里用 { "type": "plugin", "command": "giveGold" } 调用 ctx.commands.register('giveGold', (args, api) => { api.state.set('var.gold', (Number(api.state.get('var.gold', 0)) || 0) + args.n) return { ok: true, message: '金币 +1' } }) // 还能注册:条件 / 效果 / 自定义节点 / UI 组件 / 事件监听 / 表达式函数 }, snapshot() { return local }, // 随存档走 restore(data) { local = data } // 读档恢复 }
剧情是纯数据。加载时全量校验引用(指向不存在的节点会当场报错并指出字段路径);非法剧情不会破坏正在运行的内容。文件里加 $schema 还能获得字段补全。
public/stories/rust-gate.json
{
"id": "rust-gate", "title": "锈蚀之门", "start": "start",
"nodes": {
"start": { "type": "say", "speaker": "铁颚", "text": "进去,或者滚。", "next": "ask" },
"ask": {
"type": "choice", "text": "怎么办?",
"options": [
{ "text": "拿钥匙进去", "next": "hall" },
{ "text": "空手进去", "condition": "flag.brave == true", "next": "hall" }
]
},
"hall": {
"type": "if",
"branches": [{ "condition": "item.gold >= 10", "next": "good" }],
"else": "poor"
},
"good": { "type": "end", "text": "好结局:你活着出来了。" },
"poor": { "type": "end", "text": "穷得连门都进不去。" }
}
}
编剧不需要看 JSON。编辑器的文本模式实时解析、解析失败只报错不动内容;「文本是真源,模型是它的产物」—— 往返幂等被测试钉死。完整语法见 docs/SCRIPT_TEXT.md。
锈蚀带.txt
# 锈蚀带 作者:编剧甲 ## 门厅(内景 · 锈蚀的门厅 · 夜) 铁门被推开,灰尘落下来。 铁颚(低声):进去,或者滚。 选择:怎么办? - 拿钥匙 -> 里间 - 空手进去(如果 flag.brave == true)-> 里间 - 就此离开 如果 item.gold >= 10 -> 富人区 否则 -> 贫民窟 结局:好结局 你活着出来了。
编剧写剧本,
不用知道引擎的存在
编辑器把引擎概念翻译成编剧的词汇:场、对白、选择、条件分支、跳转、结局。写坏了也不会丢东西 —— 解析不通过只报错(带行号),不动你写的内容。
-
一
结构体检当场点名
断头路、没人进的场、一个结局都没有、选项点了莫名结束、条件写错 —— 顶栏一行直接报出来。
-
二
一键试读
复用引擎本身试读全剧,还能「从这一场开始」;改编完马上能看到玩家眼中的样子。
-
三
两种交付稿
可打印剧本 HTML(A4、人物名居中、对白缩进 → Ctrl+P 存 PDF 给导演)与增强版 Markdown(进仓库、能 diff)。
-
四
三种模式看同一份剧情
表单 / 文本 / JSON 能看能改引擎真正加载的那份;含插件的剧情往返保真,工程节点原样保留。
同一套内核,撑得住三种讲故事的姿势
内置示例剧情风格完全不同 —— 它们的作用不是好看,而是证明引擎抽象。全部在 node 里被确定性跑通。
未寄出的信
纯对白与分歧:三封信、四个结局,只用内置节点。文本插值 {{ var.readCount }} 直接写在台词里。
潮池
剧情不主动推进 —— 插件在 world:tick 上演化生态,潮池自己长起来或塌掉,剧情用 observe 被动接住。
轨道
固定步长物理 + 输入认领 + 受管画布。引擎侧复杂(物理/帧/输入/画布),玩家侧只有两个键。
归途
结束时告诉你「你没走的路」与「因为你」;NPC 跨周目记得你。两个插件都不需要引擎改任何东西。
两种开场
同一场两个版本按玩家分流(一个玩家只分一次、可复现)—— 用来测「哪一版读者走得更远」。
世界模拟器演示
WASM 模拟物种、JS 弹警示、JSON observe 转向 —— 三种形态的插件在同一个世界里平权协作。
先玩再读 —— 两分钟上手
不装环境,不注册。页面里跑的就是引擎本体:255KB 的 rne.js。
文档读这里 —— 15 篇全在站内
与仓库 docs/*.md 逐字同步(CI 自动构建,新文档自动收录),读完即可上手;文档与代码的一致性由 23 个检查器用例兜底。
总览 · 阅读路线按目的选择入口:写剧情、写插件、嵌入 UI、部署上线剧情格式剧情 JSON、11 种节点、表达式 DSL、四个状态命名空间文本剧本语法给编剧的写作格式:幕、对白、选择、条件分支(编辑器同源)JS 插件开发manifest、ctx 全部 21 个能力、生命周期、状态读写竞态WASM 插件开发Rust + wasm32 裸 C ABI:导出、宿主 imports、内存约定权限模型21 项细粒度权限:按能力授予,越权即被拦截架构总览四层结构、插件生命周期、错误隔离三道防线更新日志每个版本的能力、修复与破坏性变更FAQ按现象排查:加载失败、剧情不更新、observe 不触发、错误码
其余 6 篇(事件总线 · WASM ABI · 跨域嵌入 · 前端集成 · 构建 · 测试)在文档总览;全部源文件在 GitCode docs/。元规则:src/ 下每个 .ts 都必须在文档里被提到 —— 由文档一致性检查器强制执行,文档不会烂在实现后面。
几个高频问题
一定要写插件才能用吗?
minimal.json 只用内置节点(say / choice / jump / set / if / wait / emit / plugin / ui / observe / end)。什么时候想要新玩法,什么时候再装插件。表达式安全吗?支持中文吗?
var.单价 * var.数量、flag.门开了 都能直接写 —— 这是面向中文的引擎应有的样子。存档会不会丢?
为什么 WASM 插件只支持 Rust?
wasm32-unknown-unknown,直接走 C ABI(alloc / dealloc),不使用 wasm-bindgen —— 依赖面更小、ABI 更可测(UTF-8 JSON 跨边界通信)。精确约定见 docs/ABI.md,宿主能力(wasm / simd / 线程)可用 ctx.capabilities 显式探测降级。怎么接入我自己的 UI / 项目?
mount() 一行挂载用默认界面;② 实现 RuntimeUi 接口 + new Runtime({ ui })(见 docs/FRONTEND.md);③ iframe + postMessage 嵌入,父页完全不 import 引擎。安装走 git 直装或 tarball:npm install git+https://gitcode.com/rincjiyu/rne.git#main。能部署到哪里?要注意什么?
下一个剧情,由你开头。
克隆仓库、写一段 JSON、装一个插件 —— 三步之后,浏览器里就是你的世界。Apache-2.0 开源,随时可以开始。