剧本(.story)是本引擎的"编排层":只描述剧情流程与分支,战斗数值、AI 等机制一律通过宿主 API 调用。运行位置:stories/ 目录(路径监狱限制,客户端 hello 时传相对 stories/ 的路径,越出报 E1010)。

快速验证三件套:

npx tsx scripts/check-story.ts stories/<你的剧本>   # 静态校验(推荐写作时随手跑)
pnpm lint                                           # 全量剧本静态检查
pnpm play                                           # 无头试跑

1. 语句总览#

语句 语法 说明
场景标签 === 标签名 === 流程锚点;-> 跳转、call、选项目标都指向它
对话 说话人: 台词 每句生成稳定 locId(sha1 前 16 位),可被 locales/zh-CN.json 覆盖翻译
选项 * [条件] 选项文本 -> 标签 [条件] 可省略;条件为假时该选项对客户端隐藏
赋值 set 变量 = 表达式 变量名任意 Unicode 标识(如 勇气);使用未赋值变量报运行错误
条件 if 表达式 … [elif 表达式 …] [else …] end 条件必须产出布尔值;块内行缩进四格
循环 while 表达式 … end 迭代上限 LOOP_BUDGET(默认 10000),超出报 E2001
调用 call 标签 / ret 子程序(栈深 256);call 必须与 ret 配对
跳转 -> 标签 无条件跳转;它是"执行流落到哪里"的唯一显式方式
等待 wait 秒数 或 @wait(秒数) 两种写法等价,由调度器(60Hz)恢复
等事件 await 事件名 [args…] / await "事件名"(...) 挂起会话直到同名事件到达
宿主调用 @api名(参数…) 调用宿主 API;表达式里也可直接 api名(参数) 取返回值
发事件 emit("事件名", 参数…) 无脚本内 on 接收者时转交客户端/插件(E4001 警告)
事件声明 on 事件名(参数…): … end 声明事件处理段(处理完自动返回主流程)
插件指令 !指令名(参数…) 插件注册的新句式(语法扩展槽),见 §9
注释 # 任意内容 行注释

关键字集合:set if elif else while end call ret wait await emit on and or not true false。除此之外的标识符都可作为变量/标签/说话人名。

词法硬规则(新手最容易踩):

  1. 每一行要么是语句,要么是"说话人行",要么是 旁白: 文本。 叙述文本没有 旁白: 前缀就是词法错误(E0001 非法字符),整文件拒绝编译。
  2. 说话人名只能是纯标识:汉字/字母/数字/下划线。不能带 ·、空格、标点(如 环使·低语: 会直接 E0001)。需要复合名就用 环使: 或 旁白:。
  3. 引号内文本随便写;引号外不要用全角符号充当语法成分。

2. 表达式#

  • 字面量:数字(3 / 0.5)、字符串("钥匙")、布尔(true / false)
  • 运算符优先级(高→低):-(取负)、not → * / % → + - → > < >= <= → == != → and → or;括号可改变优先级(嵌套深度上限 200)
  • 逻辑连接词只有 and / or / not 关键字——没有 &&、||,写了就是词法/解析错误
  • 类型规则(强类型,不做隐式转换):
  • +:任一侧为字符串则拼接,否则两侧必须都是数字
  • - * / %:仅数字,除数为 0 报错(E7001 族)
  • > < >= <=:仅数字(E7002 族)
  • == !=:严格相等
  • and / or:仅布尔,短路求值
  • 函数调用:只能调用宿主注册表中 serverSide 且有返回值 的 API
  • undefined 是雷区:调用返回 any 的 API(如 mem_get)拿到"键不存在 → undefined"再参与比较,会抛 E7005(表达式产出非标量值)——见 §11 陷阱 1
set 勇气 = 3
if 勇气 >= 3 and has_item("钥匙")
    旁白: 你推开大门。
end
set 全名 = "林" + "·" + "拾遗"

3. 内置宿主 API#

API 签名 执行端 说明
sfx(name) → void 客户端 播放音效
bgm(name, fade?) → void 客户端 切换背景音乐
anim(name, target?) → void 客户端 播放动画
open_ui(name, params?) → void 客户端 打开界面
wait(sec) → void VM 等待秒数(同 wait 语句)
spawn(type, x, y) → str 服务端 生成实体到 world.entities
destroy(id) → void 服务端 移除实体
give_item(id, count?) / remove_item(id, count?) → void 服务端 物品增减(world.items)
has_item(id) → bool 服务端 是否持有物品
get_stat(name) / set_stat(name, value) → num / void 服务端 属性读写(world.stats)
foe_add(name, hp, atk, def?, spd?, crit?) → num 服务端 向战斗遭遇缓冲添加敌人
foe_clear() → void 服务端 清空遭遇缓冲
start_combat(seed?) → str 服务端 用缓冲敌人推演回合制战斗(敌方由行为树 AI 行动),战报写入 world.combats[id].result 并 emit combat_start
combat_result(id, field) → any 服务端 读取战报字段:"outcome"(win/lose)、"rounds"、"seed"
path_find(width, height, blocked, sx, sy, gx, gy) → str 服务端 A* 寻路,blocked 格式 "x,y;x,y";返回 "x,y>x,y>...",不可达空串
path_cost(…) → num 服务端 路径代价(不可达 -1)
can_see(…) → bool 服务端 Bresenham 视线检测
storage_set(k, v) / storage_get(k) → void / any 服务端 会话级键值暂存(world.storage,随存档保存)

clientSide API 转发到客户端执行,客户端以 host_result 回复(超时 HOST_TIMEOUT_MS 默认 5000ms,按默认值继续)。

4. 变量与存档#

  • 变量存在 VM 状态里,随存档整体保存(纯数据:字符串/数字/布尔)
  • world(物品/属性/实体/战斗/暂存)同样随存档保存
  • 不要把函数、插件对象等非纯数据塞进变量,否则存档时报 E5002
  • 存档绑定 programHash:剧本改动后旧存档不可直接读档(走迁移链或重开)

5. 事件模型#

  • 脚本内 emit("e"):优先查本剧本 on e 处理段;没有则转发给客户端 + 插件
  • await e:挂起,直到 client_event / 脚本 emit / 宿主 emit 派发同名事件
  • 事件名建议小写点分段(combat_start、area_entered)
  • 每个 await 必须有对应的 emit 方或 on 声明,否则静态检查报 E4002(error)
  • 完整示例见 stories/puzzle.story

6. 常见模式#

状态机关卡:

=== gate ===
if progress >= 3
    -> solved
end
* [progress == 0] 把风之石按进凹槽 -> slot_wind
* [progress == 1] 把火之石按进凹槽 -> slot_fire

回合战斗(机制在宿主、流程在剧本):

=== boss_fight ===
@foe_add("收表人", 130, 17, 5, 10)
set cid = start_combat(29)
if combat_result(cid, "outcome") == "win"
    -> boss_win
else
    -> boss_death
end

死亡回档(用插件 API 计数并跳回落点):

=== trap_death ===
旁白: 灯,灭了。
set 死亡 = loop_die("钟楼踏板")
-> loop_back

场景事件:

on enter(area="room"):
    if get_stat("hp") < 10
        旁白: 你感觉一阵眩晕。
    end
end

7. 静态检查与校验器#

scripts/check-story.ts(推荐写作时随手跑):

npx tsx scripts/check-story.ts stories/isekai.story
# [OK] stories/isekai.story
#   节点数: 3363
#   指令数: 13304
#   编译 error: 0, warning: 0

它做四件事:recover:false 严格解析(任何词法/语法错误直接报行号)→ 编译(0 error / 0 warning)→ 标签查重 → 全部跳转目标可达性检查。

pnpm lint 的静态检查码:

码 级别 含义
E1001 error 未定义标签(跳转/选项/call 目标不存在)
E1002 error 重复标签
E1003 warning 不可达代码(跳转/返回之后的语句)
E1004 error 所有选项条件恒为假 → 死锁
E1005 warning while 缺少迭代上限声明
E1008 warning 重复事件声明
E4001 warning emit/await 的事件无接收者
E4002 error await 的事件永远不会被 emit
E1010 error 剧本路径越出 stories/ 目录

运行时错误(E2001 预算耗尽、E7005 非标量值等)全表见《协议与错误码参考》。

8. rpg-kit 插件 API 速查(物品/装备/商店/任务)#

状态全部存 world.storage,随会话存档自动持久化;@ 前缀调用:

API 返回 说明
kit_define_item(id, name, atk?, def?, price?) void 定义物品(atk/def = 装备属性;无属性即消耗品)
kit_give(id, count?) / kit_take(id, count?) bool 入包 / 出包(不足返回 false)
kit_has(id, count?) bool 是否持有(含装备中)
kit_count(id) num 持有数量
kit_equip(id) bool 装备(原槽位回背包,聚合 atk/def)
kit_atk() / kit_def() num 含装备加成后的攻/防
kit_list() str 行囊清单
kit_item_name(id) str 物品显示名
kit_gold() / kit_gold_add(n) num 金币查询 / 变动
kit_shop_define(shopId, itemId, price?) void 上架商品
kit_shop_list(shopId) str 商品清单
kit_shop_buy(shopId, itemId) bool 购买
kit_shop_sell(shopId, itemId, price?) bool 出售(缺省六折)
kit_quest_define(id, name, reqCount?, rewardGold?) void 定义任务
kit_quest_accept(id) bool 接取(状态 1)
kit_quest_progress(id, delta?) void 推进进度(≥ reqCount 后状态 2)
kit_quest_state(id) num 0 未接 / 1 进行中 / 2 可交付 / 3 已完成
kit_quest_complete(id) bool 交付(发奖金)

完整示例见 stories/legend.story。

9. 插件指令(! 语法)#

插件用 ctx.script.registerDirective(name, handler) 注册新句式,剧本里以 !指令名(参数…) 调用:

!rpg.roll(2)             # 掷两个骰子,结果写入会话变量 last_rolls / last_roll
set 点数 = last_roll
if last_roll >= 8
    -> win
end
!rpg.record("赌坊")       # 无参数形式也合法
  • 指令名点分小写段(rpg.roll);参数是普通表达式,逗号分隔,括号可省略
  • 指令是语句级(无返回值);要返回值就同时注册宿主 API,set x = api(...)
  • 未注册的指令运行时报 E3002;handler 抛错报 E3003 并计入插件健康度
  • 参考实现:plugins/rpg-commands + stories/dice.story

10. 本地机制插件 API(isekai-core / isekai-codex)#

这两个插件属于本地内容(未随仓库分发),服务于《晨星之环》。API 设计约定:状态全进 world.storage;领域校验在插件内闭环(返回 false / -1,不向剧本抛异常)。

isekai-core(时间 / 因果之尘 / 溯回 / 记忆 / 羁绊 / 契印):

API 返回 说明
time_day() / time_part() num / str 第几日 / 时段(拂晓~深夜)
time_pass(hours) void 时间推进,跨日自动进位,emit time_passed
dust() / dust_add(n) / dust_spend(n) num 因果之尘查询 / 增加 / 消耗(不足返回 -1)
loop_count() num 溯回(死亡回档)次数
loop_die(reason) num 溯回 +1、尘 +3,emit isekai_loop;返回新次数
mem_set(k, v) / mem_get(k) / mem_has(k) void / any / bool 跨回环保留的"既视感"存储;get 未命中返回 undefined(见陷阱 1)
bond_of(name) / bond_add(name, n) / bond_at_least(name, n) num / num / bool 羁绊读写与判定
checkpoint_set(id) / checkpoint_get() void / str 溯回落点标签(剧本自行 -> 过去)

isekai-codex(探索度 / 图鉴 / 线索板):

API 返回 说明
place_visit(id) num 地点首访登记(返回 visit 次数),emit place_visited
place_seen(id) / place_count(id) bool / num 是否去过 / 去过几次
clue_add(id) bool 添加线索(幂等去重,重复添加返回 false),emit clue_gained
clue_has(id) / clue_count() bool / num 线索判定 / 线索总数
beast_meet(id, name, desc) bool 渊兽图鉴登记(已收录返回 false)

11. 大型剧本实战陷阱清单#

以下十二条全部来自《晨星之环》(3363 节点)开发中真实踩过的坑。写超过 500 节点的剧本前,把这节读两遍。

1. mem_get 未初始化 = 运行时挂起。 mem_get("k") 在键不存在时返回 undefined,拿去参与 >= 比较会抛 E7005,会话进入错误挂起态——而且静态检查查不出来。规则:所有用作条件/计数的键,在剧本第一个节点统一 @mem_set("k", 0) 初始化。

2. 门控出口的"钥匙"必须在门外获得。 典型死锁:枢纽出口选项 [clue_has("X")] -> 终局,而 X 只在"终局分支内部"才会被添加——这个出口永远不可见,剧情被锁死。写完每个带条件的出口,自查一遍钥匙的授予点在不在门内。

3. 枢纽的"最后一个选项"决定兜底行为。 引擎对"所有选项都走过"的玩家/冒烟器来说,走的是最后一个可见选项。所以自由枢纽的最后一个选项必须指向前进方向(下一章/终局),而不是绕回枢纽自己——否则兜底就是死循环。一次性剧情事件(如"夜里传来闷响")不要挂在枢纽被自动执行的位置,做成显式选项。

4. 顺序步骤用 == 精确门控,不要 >=。 序列推进类选项(第 1 盏灯 → 第 2 盏灯…)用 [mem_get("k") == n]。用 >= 时,已完成步骤的选项仍然可见,节点会被重复触发,既是剧情 bug 也是循环源。

5. 结局节点必须在文件物理末尾。 会话只有两种结束方式:执行流落到文件最后一条指令(VM 发 end),或显式结束。多结局剧本的正确结构:所有结局节点汇聚到最后一个公共收尾节点(如 === story_end ===),且它后面没有任何其他节点。放在文件中间的"结局"节点不带跳转会直接掉进下一个节点继续执行。

6. 台词/叙述行的词法三忌:裸叙述(忘 旁白:)、说话人带 ·、表达式里写 &&/||。三者都是整文件编译失败,check-story 会给出行号。

7. clue_add 幂等,mem_get + 1 不幂等。 线索可安全重复添加(去重);但 set n = mem_get("n") + 1 这类累加在节点被重复执行时会无限涨。凡是有"计数器"语义的,先想清楚它的节点会不会被重入。

8. 条件选项全灭 = 运行时死锁。 编译期只查"恒为假",运行期因状态导致的"当前一个选项都不可见"会抛 CHOICE_DEADLOCK。枢纽至少保留一个无条件选项(通常是"离开/回头")。

9. 战斗三件套要在死亡分支闭环。 foe_add → start_combat → combat_result 的 else 分支(失败)要么跳死亡回档,要么给补给重试——别让失败分支悬空或回不了战场。

10. 跳转目标一律先定义后引用。 check-story 的 E1001 会在你拼章时立刻暴露缺尾("未定义标签 ch3_open")——这是故意的:每一章写完先拼后验,带缺口跑一遍校验器当"章节清单"用。

11. 大剧本按章拆文件写,拼装后整体校验。 每章一个 .story 片段文件,cat 进主文件后跑 check-story + 冒烟,再进入下一章。单文件一万行没法人肉排错。

12. 冒烟策略要配得上剧本结构。 "永远选第一个"会在自由枢纽打转;"优先未走过的选项、全走过选最后一个"要求枢纽兜底符合陷阱 3。scripts/smoke-isekai.ts 是现成参考实现。

12. 写作约定建议#

  • 标签名用小写下划线(pick_lock);同一剧本内只用一套前缀(如全用 c3_),避免两章各写一套名
  • 说话人名建册管理:动笔前列一张"说话人名 → 身份"表,防止 环使·低语 这类名字写进去才报错
  • 每个场景 === xxx === 一个职责;-> 串流程、call 复用子程序
  • 台词改动无需关心 locId(按"路径+类型+序号"稳定生成);但插入/删除台词会移动序号,已生成翻译时需同步 locales/zh-CN.json
  • 循环体必须有退出条件;纯 while true 会在 10000 次后被熔断
  • 台词是字面文本,不支持 {变量} 插值;动态内容用宿主 API 写 world 后由客户端渲染