v1.5.0·Apache-2.0·TypeScript + 原生 DOM·JS / Rust WASM 插件·零运行时依赖

引擎不含剧情,
剧情由你书写。一行就能挂载哟

RNE 是一个运行在浏览器里的插件化剧情运行时。内核不认识任何具体剧情, 只负责解析并执行 JSON 剧本;指令、条件、效果、UI 与事件, 全部由 JS / Rust WASM 插件动态注册。

439 个测试用例守护 · 手写安全表达式解析器 · 绝不使用 eval

适用:视觉小说·文字冒险·互动教学·世界模拟·NPC 跨周目记忆

▶ 右边就是 RNE 正在渲染的剧情 —— 做个选择,走出你的结局

锈蚀带 · 微缩演示 var.gold = 0
▸ node: —
0测试用例全绿
0种内置剧情节点
0项细粒度权限
0个公开 API 导出
0gzip 后库体积
0运行时依赖
FEATURES

把「玩法」还给插件,把「可能」还给剧情

引擎与剧情彻底解耦:换一个 JSON 文件就是换一段内容,装一个插件就是长出一种玩法。

  1. 01

    双形态插件体系

    JS 插件(ES Module 动态 import())与 Rust WASM 插件(裸 C ABI,不用 wasm-bindgen)统一管理,注册命令、条件、效果、自定义节点与 UI 组件。

  2. 02

    安全表达式 DSL

    手写词法 + 递归下降语法分析,绝不使用 eval / new Function。标识符规则支持 Unicode —— var.金币 >= 10 可以直接写。

  3. 03

    21 项权限 + 运行期审计

    manifest 声明权限,宿主对每一次能力调用做校验;支持作用域限定(如 state:write:var.arena)。每次尝试都留痕 —— 拒绝也记。

  4. 04

    存档全家桶

    localStorage 多槽位 + 节点级自动存档;带信封的存档文件可导出 / 导入;逐级版本迁移、老档可读、拿错文件明确报错,多端冲突能比较出「分叉」。

  5. 05

    世界模拟器

    WorldLoop 以固定节拍广播 world:tick;剧情写一个 observe 节点就能被动听世界说话 —— 世界持续运转,剧情永不结束。

  6. 06

    实时帧循环

    固定步长逻辑(帧率无关)+ 渲染帧插值,没有订阅就不烧帧。性能看门狗实测帧耗时、自动降档带防抖,?perf=1 挂只读浮层。

  7. 07

    给编剧的剧本编辑器

    表单 / 文本 / JSON 三种模式;结构体检(断头路、无结局、条件写错)、一键试读、撤销重做、可打印剧本 HTML 导出,产物一定过引擎校验。

  8. 08

    库形态 + iframe 嵌入

    import { Runtime } from 'plugin-story-engine' 直接用(克隆即用、免构建);或 iframe + postMessage 桥,父页不 import 引擎也能驱动剧情。

  9. 09

    确定性与回放

    种子化随机(同种子必然复现)、稳定运行指纹、无头回放 CLI —— 在另一台宿主跑同一条命令比对指纹,跨宿主一致性可被验证。

  10. 10

    错误隔离与熔断

    单个插件崩溃 / 超时 / 连续失败自动熔断,都拖不垮主引擎与其它插件;切剧情后的「漏网定时器」能被 leakReport() 点名。

  11. 11

    认领式输入 + 触屏

    插件「认领」键盘与指针,引擎独占捕获层仲裁(优先级 + 作用域);触屏控件把手指翻译成相同键码,插件零改动支持移动端。

  12. 12

    可观测性

    状态来源追踪(explain('var.金币') → 谁写的、哪一步)、每插件开销归因、诊断包一键导出 —— 玩家说卡住了,让他导出,你就能远程复现。

ARCHITECTURE

微内核 + 受检插件 + 纯数据剧情

引擎按 type 分派节点处理器;插件拿不到引擎内部对象,只能通过经过权限校验的 ctx 说活。

宿主层 · 你的界面引擎不认识任何具体 UI
自己实现 RuntimeUi mount() 一行挂载(可选默认 UI) iframe 父页(只认协议) 剧本编辑器
Runtime 内核 · 53 个公开导出src/index.ts 是唯一契约面
StoryEngine StateStore PluginManager EventBus WorldLoop FrameLoop InputRouter AssetStore RandomSource SaveFile PerfWatchdog ContractRegistry
内容层 · 纯数据 + 双形态插件换文件即换内容
剧情 JSON(11 种节点 + 条件表达式) JS 插件(ES Module) Rust WASM 插件(wasm32 · 裸 C ABI) 文本剧本(编辑器编译为 JSON)

静态权限清单是「许诺」,运行期 AuditLog 是「证据」—— 插件拿不到引擎内部对象,每一次能力调用都被宿主校验。

NODE TYPES

引擎听得懂的十一种句子

内置节点不可被插件覆盖;插件还能注册自己的节点类型,与内置节点平权分派。

  • say显示一句对白,等待玩家继续;支持 speaker、auto、delayMs
  • choice显示选项等玩家选;选项可带条件与效果,支持限时
  • jump显式跳转到指定节点
  • set写入状态:var / flag / item / actor 四个命名空间
  • if条件分支,条件即安全表达式(认中文标识符)
  • wait等待若干毫秒;可标记为可跳过
  • emit广播事件给所有监听者(插件 / 宿主)
  • plugin调用插件命令,结果可渲染、可落地效果
  • ui挂载插件注册的 UI 组件,可选等待玩家确认
  • observe被动听世界事件,事件到达时跳转 —— 世界模拟器的核心
  • end结束剧情,带一句结束摘要
  • ……你的插件用 ctx.nodes.register() 注册自定义节点类型
条件就是表达式: item.gold >= 10 && flag.门开了 || var.单价 * var.数量 < 100  // 手写解析器,无 eval,标识符认中文
QUICK START

五分钟跑起来

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 里实测跑通整条剧情链路
EXAMPLES

六种姿势,用你想用的方式

从「一行挂载就能玩」到「用 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 -> 富人区
否则 -> 贫民窟
结局:好结局
你活着出来了。
FOR WRITERS

编剧写剧本,
不用知道引擎的存在

编辑器把引擎概念翻译成编剧的词汇:场、对白、选择、条件分支、跳转、结局。写坏了也不会丢东西 —— 解析不通过只报错(带行号),不动你写的内容。

  • 一
    结构体检当场点名

    断头路、没人进的场、一个结局都没有、选项点了莫名结束、条件写错 —— 顶栏一行直接报出来。

  • 二
    一键试读

    复用引擎本身试读全剧,还能「从这一场开始」;改编完马上能看到玩家眼中的样子。

  • 三
    两种交付稿

    可打印剧本 HTML(A4、人物名居中、对白缩进 → Ctrl+P 存 PDF 给导演)与增强版 Markdown(进仓库、能 diff)。

  • 四
    三种模式看同一份剧情

    表单 / 文本 / JSON 能看能改引擎真正加载的那份;含插件的剧情往返保真,工程节点原样保留。

剧本编辑器 · 文本模式Ctrl+S 存草稿
锈蚀带
作者:编剧甲
第一幕
门厅(内景 · 锈蚀的门厅 · 夜)
铁门被推开,灰尘落下来。
铁颚(低声):进去,或者滚。
旁白:风从缝里灌进来。
选择:怎么办?
拿钥匙 -> 里间
空手进去 (如果 flag.brave == true)-> 里间
就此离开
结局:好结局
你活着出来了。
SHOWCASE

同一套内核,撑得住三种讲故事的姿势

内置示例剧情风格完全不同 —— 它们的作用不是好看,而是证明引擎抽象。全部在 node 里被确定性跑通。

文学向

未寄出的信

纯对白与分歧:三封信、四个结局,只用内置节点。文本插值 {{ var.readCount }} 直接写在台词里。

涌现向

潮池

剧情不主动推进 —— 插件在 world:tick 上演化生态,潮池自己长起来或塌掉,剧情用 observe 被动接住。

实时向

轨道

固定步长物理 + 输入认领 + 受管画布。引擎侧复杂(物理/帧/输入/画布),玩家侧只有两个键。

叙事插件

归途

结束时告诉你「你没走的路」与「因为你」;NPC 跨周目记得你。两个插件都不需要引擎改任何东西。

A/B 变体

两种开场

同一场两个版本按玩家分流(一个玩家只分一次、可复现)—— 用来测「哪一版读者走得更远」。

世界模拟

世界模拟器演示

WASM 模拟物种、JS 弹警示、JSON observe 转向 —— 三种形态的插件在同一个世界里平权协作。

FAQ

几个高频问题

一定要写插件才能用吗?
不。引擎可以完全脱离插件运行:minimal.json 只用内置节点(say / choice / jump / set / if / wait / emit / plugin / ui / observe / end)。什么时候想要新玩法,什么时候再装插件。
表达式安全吗?支持中文吗?
条件表达式是手写的词法 + 递归下降解析器,绝不使用 eval / new Function。标识符规则允许 Unicode 字母,所以 var.单价 * var.数量、flag.门开了 都能直接写 —— 这是面向中文的引擎应有的样子。
存档会不会丢?
存档在 localStorage 多槽位 + 节点级自动存档。但浏览器会清本地存储(Safari ITP、无痕模式、手动清数据),客户端拦不住 —— 所以引擎提供带信封的存档文件「导出 / 导入」:老版本文件能走迁移器读回,拿错文件会明确报错而不是「导入成功但进度乱了」。
为什么 WASM 插件只支持 Rust?
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。
能部署到哪里?要注意什么?
任意静态托管都可以(Cloudflare Pages / Vercel / GitHub Pages 等)。启用多线程 WASM 或跨域隔离(COOP/COEP)时有静默代价,部署前建议读一遍 docs/CROSS_ORIGIN.md。
觉得不错的话,去点个 Star 呗

下一个剧情,由你开头。

克隆仓库、写一段 JSON、装一个插件 —— 三步之后,浏览器里就是你的世界。Apache-2.0 开源,随时可以开始。