剧本(.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。除此之外的标识符都可作为变量/标签/说话人名。
词法硬规则(新手最容易踩):
- 每一行要么是语句,要么是"说话人行",要么是
旁白: 文本。 叙述文本没有旁白:前缀就是词法错误(E0001 非法字符),整文件拒绝编译。 - 说话人名只能是纯标识:汉字/字母/数字/下划线。不能带
·、空格、标点(如环使·低语:会直接 E0001)。需要复合名就用环使:或旁白:。 - 引号内文本随便写;引号外不要用全角符号充当语法成分。
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 后由客户端渲染