Kiny DSL 规范 v0.1.0 草案
状态:草案 (draft),等待逐条审查 文件扩展名:.kin 工具/编辑器名:kiny
0. 概述
Kiny 是一种受 Ink 启发的互动叙事 DSL,定位是"Ink 的更简洁版本"。设计目标:
- 作者优先,不是游戏开发者优先:砍掉 Ink 中所有游戏引擎集成向的高级特性(list、tunnel、thread、external function、ref 参数)
- 中文友好:节点 ID 仍用 ASCII,但允许中文显示名;错误信息和文档双语
- 项目文件夹组织:元数据集中在
kiny.json,故事文件用.kin扩展名 - 跨平台:编辑器和阅读器目标 Windows、Android、Web,后续 iOS、macOS
0.1 Kiny vs Ink 速查
| 特性 | Ink | Kiny v0.1.0 |
|---|---|---|
| 文件扩展名 | .ink | .kin |
| 节点声明 | === name 或 === name === | === 名字 ===(强制三等号对称) |
| 节点名 | ASCII 单词,无空格 | 任意 Unicode,可中文,但无空格 |
| 变量声明 | VAR x = 10 CONST PI = 3.14 ~ temp x | ~ let x = 10 ~ const PI = 3.14(JS 语法) |
| 逻辑语言 | Ink 自有表达式语法 | 直接嵌入 JavaScript |
| 多行逻辑块 | {} | ~~~ ... ~~~ |
| 分支收束 | gather 行 - / - - | 行首 > 层级标记 |
| LIST 多值集合 | 支持 | 不支持 |
Tunnel -> x -> | 支持 | 不支持 |
Thread <- x | 支持 | 不支持 |
| Ref 参数 | ref x | 不支持 |
| 文件包含 | INCLUDE x.ink | 引擎自动扫描项目根,无 INCLUDE |
| 元数据 / 命令 | # 任意字符串 tag,宿主解释 | @命令() 封闭内置命令集 |
| External function | === function external x === | 不支持(引擎自行注入) |
| 文本变体 | {a|b} 变体语法(&/!/~ 前缀) | seq() / cycle() / once() / shuffle() 内置函数 |
1. 项目结构
my-story/
├── kiny.json # 项目元数据(必需)
├── main.kin # 入口故事文件(必需,可改名)
├── chapters/ # 推荐:故事文件分目录
│ ├── opening.kin
│ ├── city.kin
│ └── ending.kin
└── assets/ # 可选:图、音、字体
├── images/
└── audio/
1.1 kiny.json
最小元数据,只保留四个字段:
{
"name": "雾港之夜",
"version": "1.0.0",
"engine": "0.1.0",
"entry": "main.kin"
}
| 字段 | 必需 | 说明 |
|---|---|---|
name | ✅ | 项目名称(可中文) |
version | ✅ | 项目版本,semver 字符串 |
engine | ✅ | 要求的 Kiny 引擎版本,semver 字符串 |
entry | ✅ | 入口故事文件路径 |
变量初始化:所有变量声明都在 .kin 文件里用 ~ let/~ const(详见 §7),kiny.json 不参与变量管理。
1.2 文件发现与命名空间
- 引擎自动递归扫描项目根目录下所有
.kin文件,不需要任何显式声明 - 所有
.kin文件共享同一全局节点命名空间 - 节点名在整个项目内必须唯一
- 子节点名只在所属父节点内唯一(不同父节点可重名)
- 跨父节点跳转子节点用
父节点名.子节点名路径 - 编译器检查重复并报错
- 没有
INCLUDE语句,不需要在文件里声明依赖
文件夹组织(chapters/、scenes/ 等)纯粹是作者的视觉/管理偏好,引擎不关心。
1.3 入口与开场
入口文件(kiny.json 的 entry)中、第一个 === 节点 === 之前的整段内容构成「开场」:
- 开场的作用域为全局,从上到下顺序执行——全局声明(
~ let/~ const)与正文、命令同处一段,按源码顺序生效。 - 故事从开场开始播放;要进入某个具名场景,开场需以显式
-> 场景名收尾。 - 开场执行到底部若没有跳转即触底
END(与节点一致,但纯文本零-knot 文件触底属正常,不告警;详见 §1)。 - 一个零节点的纯文本文件本身就是一个完整开场,合法可跑。
开场没有可书写的节点名——它是引擎据入口文件合成的隐形节点,作者无法 -> 引用它,只能从它跳出去。
2. 节点 (Knot)
节点是故事的最小跳转单位。
=== 雾港开场 ===
雾从港口涌上来,遮住了路灯。
你站在码头边。
=== 客栈 ===
你推开了客栈的门,暖气扑面而来。
规则:
- 声明形式:
=== 名字 ===,左右必须各 3 个等号(不接受 2 个或 4 个) - 名字可使用任意 Unicode 非空白字符,允许中文,但不允许包含空格(任何形式的 whitespace)
- 名字前后的空格只用于分隔
===,不属于名字 - 名字在整个项目内全局唯一(跨所有
.kin文件) - 节点正文从下一行开始,到下个
===或=或文件结束 - 节点之间没有隐式 fallthrough——执行到节点底部如果没有跳转,警告
名字同时充当节点 ID 与显示名。 多词名字推荐用下划线或直接连写:雾港开场、first_class、头等舱。
2.1 子节点 (Stitch)
复杂节点用 = 切分子节点:
=== 火车上 ===
雾从车窗外掠过。
-> 头等舱
= 头等舱
奢华的场景...
-> END
= 三等舱
拥挤的场景...
-> END
规则:
- 子节点声明:
= 名字,单个=开头,无右侧等号(跟 knot 的===视觉区分) - 名字规则同 knot:允许中文、不允许空格
- 子节点名只在所属父节点内唯一——不同父节点可以拥有同名子节点
- 没有默认入口、没有 fall-through:进入父节点后只执行父节点正文(从
===到第一个=之间的内容),到达第一个=时父节点结束,不会自动进入任何子节点 - 子节点正文从下一行开始,到下个
=、下个===或文件结束 - 节点底部、子节点底部如无显式跳转 → 警告
跨父节点跳转子节点的路径:
- 同一父节点内:直接
-> 子节点名 - 跨父节点:必须用
-> 父节点名.子节点名路径
执行示例:
输入:
=== 父节点 ===
AAA
= 子节点1
BBB
= 子节点2
CCC
执行:进入 父节点 → 输出 AAA → 触底无跳转 → 警告(输出仅 AAA)
输入:
=== 父节点 ===
= 子节点1
BBB
= 子节点2
CCC
执行:进入 父节点 → 父节点正文为空 → 触底无跳转 → 警告(无输出)
输入:
=== 父节点 ===
-> 子节点1
= 子节点1
BBB
-> END
执行:进入 父节点 → 跳转 子节点1 → 输出 BBB → 故事结束
2.2 带参节点
节点名后可带参数列表,跳转进入时传入实参,参数即该节点的局部变量:
=== 商店(category, discount) ===
@if {discount > 0}
> 老板朝你笑了笑,"今天的{category}打折!"
@else
> "看看{category}吧。"
* [买下] -> 结账
* [离开] -> 街道
进入时用 -> 名字(实参…) 传参(详见 §4.2):
-> 商店("灯笼", 0.8)
规则:
- 声明
=== 名字(参数1, 参数2, …) ===:节点名可中文,但参数是变量,名字必须英文(ASCII 标识符,同变量规则 §7) - 参数 = 节点局部变量,进入时绑定、离开时销毁,作用域同节点局部(§7.2,含其子节点)
- 带参节点就是普通节点:内部可有文本、选项、跳转、
@if等,与无参节点无异 - 带参节点只能经
-> 名字(实参)进入;不能从外部直接跳进它的子节点(参数无从绑定)。内部子节点之间-> 子节点照常,参数仍可见 - 无参节点照旧
=== 名字 ===/-> 名字,不变
3. 普通文本 (Text)
节点正文里所有非控制行都是普通文本,按顺序输出给读者。
3.1 行与段落
雾从港口涌上来,遮住了路灯。
你站在码头边。
你听见远处传来汽笛声。
规则:
- 每一行非控制行 = 一段输出
- 行末自动换行
- 用
<>抑制换行(见 §10) - 空行会被忽略:源文件中可自由插入空行以提升可读性,对输出完全无影响
- 行首、行末的空白被裁掉
控制行 / 控制块完整清单——一行若以下列符号起首即为控制结构,否则就是普通文本:
| 起首 | 含义 | 章节 |
|---|---|---|
=== | 节点声明(可带参 === 名字(参数) ===) | §2 |
= | 子节点声明 | §2.1 |
* / + | 选项(一次性 / 粘性) | §5 |
> | 分支体层级 | §6 |
-> | 跳转(可带参 -> 名字(args)) | §4 |
~ | 单行 JS 语句 | §7 |
~~~ | 多行 JS 块(起止行) | §7.4 |
@if / @elif / @else | 条件控制 | §8 |
@名字(...) | 引擎内置命令 | §11 |
// / /* */ | 注释 | §13 |
行内控制结构(嵌在文本行内,非行首):
| 标记 | 含义 | 章节 |
|---|---|---|
{ ... } | JS 表达式插值(含三元条件、变体函数) | §7.5 / §9 |
<b> <i> <u> <s> <color=…> <size=…> <br> | 内联富文本(样式 / 换行) | §3.6 |
<> | 粘连(行末) | §10 |
-> 目标 | 行末内联跳转 | §4 |
// ... | 行末注释 | §13 |
[ ... ] / ( ... ) | 仅选项行内:选项文本分隔 / 选项标签 | §5 |
3.2 缩进
普通文本里的缩进没有语义,纯粹是作者的视觉组织手段。下列三种写法等价:
你打开门。
你走进去。
你打开门。
你走进去。
你打开门。
你走进去。
备注:分支体不依赖缩进,而是用行首 > 标层级(详见 §6)。普通文本里的缩进始终无语义。
3.3 转义
用反斜杠 \ 转义,输出其后字符的字面形态。
任意位置都需转义(这些符号在行内随处都有特殊含义):
| 写法 | 输出 | 否则会被当作 |
|---|---|---|
\{ | { | 表达式插值起始 |
\} | } | 表达式插值结束 |
\< | < | 粘连 <> |
\/ | / | 注释 //、/*(如 URL http:\//…) |
\\ | \ | 转义符本身 |
仅作文本行首字符时需转义(这些符号只有在行首才是控制标记):
| 写法 | 输出 |
|---|---|
\= \* \+ \> \~ \@ | 对应字面首字符 |
\-> | 行首字面 -> |
仅选项行内需转义:选项显示文本里要出现字面 [ ] ( ) 时,用 \[ \] \( \)。
3.4 文本中可嵌入的元素
文本行内可嵌入以下元素,其余皆为字面文字:
| 元素 | 写法 | 章节 |
|---|---|---|
| 表达式插值 | { JS 表达式 } | §7.5 |
| 行内条件 | { cond ? "真" : "假" }(三元,属插值) | §7.5 |
| 变体(活文本) | { seq(…) } / { cycle(…) } / { once(…) } / { shuffle(…) } | §9 |
| 富文本样式 / 换行 | <b>…</b> / <color=…>…</color> / <size=…>…</size> / <br> 等 | §3.6 |
| 行末粘连 | <> | §10 |
| 行末跳转 | -> 目标 | §4 |
| 行末注释 | // … | §13 |
@if条件块、@命令、~/~~~逻辑都是行级结构,独占整行,不能内联嵌进文本中间。
3.5 引号与标点
引号和各种标点都是普通字符,没有特殊语法。中英文标点可以混用:
"想要点什么?" 老板问。
'Hello,' he said.
"我累了,"我说,"明天再聊吧。"
Kiny 不强制中英文标点风格,作者自由选择。
3.6 内联富文本
文本行内可用标签为局部文字加样式。标签明确闭合、可嵌套,作用于正文叙述与选项文本两处(同一套解析,渲染一致)。
| 标签 | 含义 | 取值 |
|---|---|---|
<b>…</b> | 粗体 | — |
<i>…</i> | 斜体 | — |
<u>…</u> | 下划线 | — |
<s>…</s> | 删除线 | — |
<color=值>…</color> | 颜色 | #rgb / #rrggbb / 字母构成的 CSS 具名色(如 red) |
<size=倍数>…</size> | 字号 | 正数倍数,相对当前正文字号(如 1.5、0.8),渲染为 em |
<br> | 显式换行 | 自闭合,无闭合标签 |
她说:<b>别回头</b>,然后<color=#c00>消失在<i>雾</i>里</color>。
第一行<br>第二行
这个词<size=1.5>很大</size>。
- 嵌套:标签可层层包裹,样式叠加(
<b><color=red>红粗</color></b>)。同类取值标签嵌套时内层覆盖外层。 - 颜色 / 字号取值受限:
color只接受上述颜色形式、size只接受正数倍数,不接受任意 CSS(杜绝经样式注入)。 - 字面
<:未转义的<仅当其后构成合法标签时才识别为标签;否则按字面输出(兼容历史文本里的裸<,如1 < 2)。要强制字面<用\<。 - 插值不二次解析:
{ ... }求值结果作为纯文本插入,其中的<…>不再当标签处理。 - 行末孤立的
<>仍是粘连(§10),与标签语法不冲突(标签都有非空名)。
容错:未闭合标签自动闭合到该文本段末、错配的闭标签弹到最近的同名开标签、非法颜色 / 字号值不应用样式——运行期均不崩。这些情形同时在校验期产出 error 级诊断(编辑器可标红),供作者修正。
4. 跳转 (Divert)
立即跳到另一节点:
你走出了房间。
-> 走廊
规则:
-> 目标是跳转目标是节点名或子节点名(参见 §2 跨父节点跳转用父节点名.子节点名)- 跳转后该节点剩余内容不再执行
跳转的两种放置位置:
独立成行:
你走出了房间。
-> 走廊
嵌在文本末尾(同一行):
你走出了房间。-> 走廊
两种写法在控制流上完全等价。换行行为(行末跳转后输出是否产生空行)由 §10 粘连规则决定。
4.1 特殊跳转
-> END故事正常结束-> DONE当前线程结束(保留给未来扩展,目前等同 END)
4.2 带参跳转
跳转到带参节点(§2.2)时传入实参:
-> 商店("灯笼", 0.8)
- 实参是 JS 表达式,跳转时求值后绑定到目标节点的参数
- 实参个数必须与目标节点的参数个数一致,否则报错
- 仍是普通跳转——进入目标后继续在那儿执行,不返回调用点
5. 选项 (Choice)
5.1 基本选项
你站在岔路口。
* [走向客栈] -> inn
* [沿码头继续走] -> docks
* [原路返回] -> home
规则:
*开头 = 一次性选项(选过即消失)+开头 = 粘性选项(可重复选)[文本]是选项显示的文字- 后面通常跟
-> target
5.2 选项文本与正文文本
[] 用于分开"选项列表里显示的文字"和"点击后印在正文里的文字"。
* [我累了。] "辛苦你了,"他回答。 -> 休息
读者看到的选项是 我累了。,点击后正文里出现 "辛苦你了,"他回答。,然后跳到 休息 节点。
| 写法 | 选项显示 | 点击后正文显示 |
|---|---|---|
* 文本 -> 目标 | "文本" | "文本" |
* [选项文本] -> 目标 | "选项文本" | (无) |
* [选项文本] 正文文本 -> 目标 | "选项文本" | "正文文本" |
规则:
[ ... ]内的文字只在选项列表中显示]之后的文字只在点击后的正文里显示- 完全省略
[]时,整段同时是选项文本和正文文本
5.3 条件选项
* {gold >= 5} [买下灯笼] -> buy_lantern
* {met_innkeeper > 0} [问问老板] -> ask_innkeeper
* [作罢] -> opening
规则:
{condition}紧跟在*/+之后- 条件为假时该选项不显示
- 条件是 JS 表达式(见 §7)
5.4 后备选项 (Fallback)
当其他选项都全部不可用时自动触发,形式仅为 * -> 目标:无文本、无 []、无 {} 条件。
* {!tried_a} [尝试 A] -> 试A
* {!tried_b} [尝试 B] -> 试B
* -> 没招了
每组选项最多一个 fallback。
5.5 选项标签
给选项打一个标签后,引擎会自动追踪该选项被选过的次数。
* (greet) [问候他] "你好。"
* (ignore) [无视他] 我什么也没说。
"嗯,"他回应。
* {greet} [问他叫什么] -> 问名字
* [道别] -> 离开
规则:
(label)写在*/+之后、[或文本之前- 标签等价于一个自动计数的全局变量,其值是该选项被选过的次数(0 = 未选)
- 标签命名必须使用 ASCII 字符(不允许中文,不允许空格),具体规则与变量一致(详见 §7)
- 标签全局唯一:不可与其他选项标签、
let/const变量重名 - 在后续表达式或条件中用
{label}读取计数值
命名约定:节点名/正文允许中文,但变量名(含选项标签)必须英文。视觉上一眼就能分辨"叙事内容"和"程序逻辑"。
6. 分支体与汇合 (Branching)
选项被选中后执行的"分支体"用行首 > 表层级。> 的数量 = 嵌套层级;层级减少 = 内层分支自动汇合到外层。
6.1 基本写法
=== 路口 ===
"什么事?" 我问。
* [我累了。]
> "辛苦你了。"
* [没什么。]
> "好吧。"
* [假装没听见。]
> 我什么也没做。
我们继续前行。
-> END
*选项行后紧跟的> ...行构成"该选项的分支体"- 三个选项体执行完毕后,遇到无
>的我们继续前行。——自动汇合,所有分支都执行后续内容
6.2 嵌套
=== 餐厅 ===
"你想吃什么?"
* [吃米饭]
> 你点了米饭。
> "需要什么菜?"
> * [青菜]
> > 你点了青菜。
> * [肉]
> > 你点了肉。
> 服务员记下了。
* [吃面]
> 你点了面。
"好嘞,"服务员说。
-> END
- 第一层
>是顶层选项的体 - 第二层
> >是嵌套子选项的体 - 层级从
> >回到>= 内层(子选项)汇合到外层("服务员记下了。") - 层级回到 0(无
>) = 整个选项组结束,所有分支汇合("好嘞,服务员说。")
建议:嵌套不要超过 3 层,可读性会崩。
6.3 语法细节
| 规则 | 说明 |
|---|---|
行首 > 数量 | 决定该行所属层级 |
| 行首空白 | 解析时被裁掉。强烈建议不要前导空白 |
> 之间空白 | 可选,>>> 与 > > > 等价。强烈建议加空格,并与 * 对齐 |
> 之后空白 | 可选,>体 与 > 体 等价。强烈建议加空格,便于阅读 |
行首字面 > | 用 \> 转义 |
6.4 分支体与显式跳转混用
选项可以不带 > 体直接 -> 目标 跳走;也可以有 > 体并在体内显式跳走。两种"跳走"都使该选项不参与汇合:
* [回家] -> 家中 ← 直接跳走,不汇合
* [等等]
> 等了又等。 ← 体结束后自动汇合
* [离开]
> 转身就走。
> -> 街上 ← 体内显式跳走,不汇合
都白等了。 ← 只有"等等"会到这里
6.5 空行
> 体中的空行被忽略(同 §3.1)。下列三种写法等价:
* [选 A]
> 第一段。
> 第二段。
* [选 A]
> 第一段。
> 第二段。
* [选 A]
> 第一段。
>
> 第二段。
7. 变量与表达式
Kiny 的所有逻辑都是 JavaScript。Kiny 不发明自己的表达式语言,而是把 JS 以三种方式嵌入故事文本:
| 写法 | 含义 | 输出 |
|---|---|---|
~ 单行 JS 语句 | 执行一条 JS 语句(声明、赋值、调用) | 无 |
~~~ ... ~~~ | 多行 JS 块 | 无 |
{ JS 表达式 } | 求值并插入文本 | 求值结果转字符串 |
变量名必须 ASCII(字母、数字、下划线,首字符非数字),跟 JS 标识符规则一致。
内置函数名(random、seed_random、turns、turns_since、seq、cycle、once、shuffle,见 §12.1)是保留标识符,不可用作变量名、参数名或选项标签,编译期报错。
7.1 变量声明
用 JS 的 let 和 const:
~ let gold = 10
~ let has_lantern = false
~ let player_name = "无名氏"
~ let player = { name: "无名氏", hp: 100 }
~ const MAX_HP = 100
~ const INTRO_LINE = "雾港之夜"
let声明可变变量,const声明常量- 拼错变量名会立即报错(不会沉默地创造新变量)
7.2 作用域
变量按声明位置划分两个作用域:
全局作用域——文件顶部、任何节点之前的声明:
~ let gold = 10 ← 全局,整个故事都可见
~ const MAX_HP = 100
=== 开场 ===
...
入口文件首个节点前的这段内容即「开场」(见 §1.3):全局声明与开场正文同处一段,按源码顺序执行,声明落全局、后续任意节点可见。
节点作用域——节点内部的声明,跳出节点时失效:
=== 战斗 ===
~ let dice = random(1, 6) ← 只在本节点(含其子节点)可见
{ dice > 3: 你打中了! }
节点(含其所有子节点)等价于一个 JS 函数作用域:进入节点时建立局部作用域,离开节点时销毁。
7.3 赋值与运算
声明后用普通 JS 语法赋值:
~ gold = 5
~ gold += 1
~ gold--
~ player.hp -= 10
~ player.name = "Alice"
~ inventory.push("药水")
完整支持 JS 的赋值/算术/比较/逻辑运算符。
7.4 多行 JS 块
复杂逻辑(多语句、循环、复杂初始化)用 ~~~ ... ~~~:
~~~
let total = 0
for (const item of inventory) {
total += item.price
}
gold = total
~~~
规则:
- 起止
~~~各占一行(不接受~~~~等其他长度) - 块内是任意 JS 代码
- 块内不能嵌入 Kiny 语法(
->跳转、* [选项]、{ ... }插值等) - 副作用(赋值、函数调用)正常生效,但不产生 Kiny 文本输出
~~~块只能出现在节点 / 子节点正文顶层,不能写进选项体或@if分支体内;分支体内需要多行逻辑时,在顶层~~~里定义函数,分支内用单行~ f()调用
7.5 表达式插值
文本中嵌入 { JS 表达式 },求值后插入:
你还剩 {gold} 枚金币。
{player.name} 抬起头。
你的攻击力是 {strength * 2}。
你的状态:{ player.hp > 50 ? "良好" : "虚弱" }。
规则:
{ }在 Kiny 中只有这一种含义:求值一段 JS 表达式,输出其字符串(行内条件用三元、活文本用变体函数,都只是表达式的具体形态,不是另一套语法){ 表达式 }内是任意 JS 表达式(不是语句)- 结果转字符串后插入;
undefined/null输出为空字符串 - 表达式里引用未声明的变量 → 编译时报错
7.6 跨文件共享
所有 .kin 文件的 ~ 与 ~~~ 共享同一全局 JS 作用域:
- 文件顶部的
let/const在所有文件可见 - 同名变量跨文件重复声明 → 报错
- 引擎按文件名字典序合并所有全局声明
7.7 边界
~ -> 节点不允许:跳转独立成行用-> 节点- JS 单行注释
// ...可写在~行末 ~~~块内不能再嵌套~~~(不支持嵌套块)
8. 条件控制 (@if)
条件控制用 @ 前缀的指令行表达。@ 是 Kiny 的控制流命名空间,与 ~(纯 JS 语句)、{}(产出文本的 JS 表达式)互不重叠。
行内的单点条件直接用 JS 三元表达式(§7.5),例如 你的灯笼{has_lantern ? "亮着" : "熄灭"}。本章只讲跨行、包住整段叙事的条件块。
8.1 基本写法
@if {met_innkeeper === 0}
> 这是你第一次见他。
@elif {met_innkeeper < 3}
> 你们算是脸熟了。
@else
> 老朋友似的,他朝你点头。
"想要点什么?"
规则:
@if/@elif/@else是控制流选择器行,行首顶格(与同层选项*处于同一层级)- 条件写在
{ }里,是一段 JS 表达式(§7.5)——凡 Kiny 需要表达式之处,一律用{ } - 选择器行后紧跟的
>行构成该分支的 body,层级与选项体共用同一套>计数(§6) - 没有结束符:当某行回到选择器所在层级、且不是
@elif/@else时,整个条件链结束,所有分支汇合到外层 @elif可出现任意多次(含零次);@else至多一个,且必须是链尾@if必须最先出现,@elif/@else只能紧跟在同层@if/@elif之后
8.2 body 内执行语句
body 里要执行 JS(赋值、调用)就用 > 加 ~:
@if {gold >= 5}
> ~ gold -= 5
> 你接过酒杯,喝了一口。
@else
> 钱不够,你摇了摇头。
多行逻辑用 ~~~ … ~~~(§7.4)。{ } 永远只产出文本,绝不承载语句。
8.3 与选项嵌套
@if 与选项 * 共享同一套 > 层级,可任意互嵌;> 的个数 = 总嵌套深度:
* [冲上去战斗]
> @if {hp === 0}
> > 你还没动手就倒下了。
> > -> game_over
> @else
> > 你举起了剑。
> 周围安静下来。 ← 回到选项体内(if 链已闭合)
我们继续赶路。 ← 回到顶层(选项组也结束了)
建议:嵌套不超过 3 层(同 §6)。
9. 文本变体 (Alternatives)
"活文本"——同一处文字随访问次数变化——由四个内置函数实现,写在 { } 里就是普通 JS 函数调用。引擎按源码位置为每个调用点挂一个独立计数器,自动记录它被经过的次数,作者无需手动维护变量。
变体不引入任何新语法:{ } 始终只是"一段 JS 表达式"(§7.5),这四个函数只是 §12.1 内置函数的一部分。
9.1 seq —— 依次推进,停在最后
随后我听见钟声{ seq("响了", "又响了", "这回听起来很远了") }。
第 1 次到达输出"响了",第 2 次"又响了",第 3 次起固定停在最后一项。
9.2 cycle —— 循环
今天是{ cycle("周一","周二","周三","周四","周五","周六","周日") }。
到达末项后回到第一项,循环往复。
9.3 once —— 用完为空
他笑了。{ once("这是我第一次见他笑。", "这次他笑得更开了。") }
依次输出各项,全部用完后返回空字符串。
9.4 shuffle —— 随机
风吹过。{ shuffle("你打了个寒颤。", "你拉紧了衣领。", "你忽然觉得有什么不对。") }
每次随机返回一项,随机序列受 seed_random 控制以保证可复现(§12.1)。
9.5 组合与嵌套
变体函数就是普通 JS 表达式,可与三元、插值、彼此自由组合:
那只{ cycle("小狗","大狗") }{ has_treat ? "摇了摇尾巴" : "吠了一声" }。
注意:作为函数实参的嵌套变体会被立即求值。如cycle("小狗", shuffle("黑狗","花狗"))中,shuffle(...)每次都会先求值并推进自己的计数器,即便这一轮 cycle 没轮到它。若要"轮到才求值",传惰性函数() => shuffle(...)。嵌套变体本就罕见,一般直接接受立即求值即可。
10. 粘连 (Glue)
Kiny 默认每段文本输出后自动换行(§3.1)。<> 紧贴在一段文本的末尾,取消这段文本之后的换行,让下一段产出的文本直接贴上来——即使中间隔着一次 -> 跳转:
我转身离开<>
-> next_room
=== next_room ===
,头也不回。
输出:我转身离开,头也不回。
10.1 <> 与 -> 的位置
-> 是跳转指令、不产出文本,所以 <> 永远贴在文本一侧,不贴在 -> 上。两种等价写法:
我转身离开<>
-> next_room
我转身离开<> -> next_room
- 跳转独立成行时,
<>落在文本行末尾(->在下一行) - 跳转内联时,
<>夹在文本与->之间:文本<> -> 目标 -> 目标之后不写<>(跳转后本行已无文本可粘,无意义)
10.2 规则
<>紧贴它要粘连的那段文本之后,含义:"这段文本之后不换行,下一段输出紧贴上来"- 粘连跨越
->跳转:会把跳转目标的第一段文本接上来,目标节点一侧无需任何标记 - 缝句一律在源头一侧声明;若某节点被多个入口共享、想统一缝句,需在每个入口各加一个
<> - 没有行首
<>;要输出字面<用\<(§3.3)
11. 内置命令 (Command)
引擎内置命令用 @ 前缀、函数调用形式书写,向宿主(编辑器/阅读器)下达副作用指令(切背景、放音乐等)。命令独占一行,不产出叙事文本。
=== inn ===
@bg_show("assets/tavern_interior.jpg")
@bgm_play("assets/tavern_loop.mp3")
老板抬起头看着你。
"想要点什么?"
11.1 当前命令集
| 命令 | 说明 |
|---|---|
@bg_show(image) | 显示背景图 |
@bg_hide() | 隐藏背景图 |
@bgm_play(audio) | 播放背景音乐 |
@bgm_pause() | 暂停背景音乐 |
@bgm_stop() | 停止背景音乐 |
@clear() | 清除屏幕上已显示的正文文字;无参,保留背景与背景音乐 |
@step_mode(mode) | 正文推进模式:"line" = 逐段(逐行)等读者点击才出下一行,"flow" = 一路流到下一个选项(默认) |
@text_speed(cps) | 打字机出字速度(字 / 秒),默认 80;0 = 整行瞬显 |
@text_fade(ms) | 每字淡入时长(毫秒),默认 300;0 = 无淡入 |
@input(var [, hint]) | 暂停故事,向读者请求一段文本,写入变量 var;hint 作输入框提示(placeholder),可省;空 / 纯空白提交保留 var 原值 |
当前仅这几个,后续按需补充(立绘、语音、情绪、转场等)。
@clear 清的是已显示正文,与场景(背景 @bg_*、音乐 @bgm_*)相互独立——清屏后背景与 BGM 照旧。命令是硬边界,故同一次推进里 @clear 之前的文字先显示、@clear 清空、之后的文字进入清空后的新画面,天然实现「先清屏再显示新文」。
@step_mode / @text_speed / @text_fade 控制正文的呈现节奏:@step_mode("line") 让正文像视觉小说那样点一下出一段(点击进行中的段落会立即整段显示;等待点击时正文下方有呼吸的推进提示三角),"flow" 恢复默认的连续流动;@text_speed / @text_fade 调打字机出字速度与逐字淡入时长,默认 80 字 / 秒、每字淡入 300 毫秒。三者都是有状态设定,直到下次改写或故事重开前持续生效。无障碍:读者系统开启「减弱动态效果」时整行瞬显、推进提示停止呼吸,覆盖上述设定。
@input 是唯一的交互命令——它暂停故事、等读者输入(与选项并列,是故事的另一种交互停顿点),并把读者输入的文本写回变量,供后续正文插值 {var} 或 @if 条件读取。典型用途是让读者为主角起名:
~ let player_name = "旅人"
@input(player_name, "请输入你的名字")
你好,{player_name}。
变量须预声明,其声明值即空提交时的默认——读者留空或只敲空格直接确认,player_name 保持 "旅人"。第一个参数是变量名本身(写入目标),不是求值表达式;第二个参数(提示)是普通表达式,可动态。
11.2 规则
@命令(参数)独占一行,行首顶格(与@if同属@引擎指令命名空间)- 参数是 JS 表达式,引擎求值后随命令传给宿主——动态参也可以,如
@bg_show(currentBg) - 命令是副作用指令,不产出任何叙事文本
- 引擎只认识内置命令集;未知的
@命令(...)报错 - 资源参数是项目根相对路径(如
"assets/harbor_fog.jpg"),宿主据此从项目根解析;assets/只是惯例目录,非硬规则 @input是上述两条的显式例外:它的第一个参数是变量名(写入目标,左值)而非求值表达式(该变量须已声明,否则报错);它也不是纯粹传给宿主的副作用指令,而是暂停故事、等读者输入并把结果写回该变量——引擎内部处理这次交互,而非透传
12. 函数 (Function)
Kiny 不提供自己的函数声明语法。逻辑即 JavaScript,函数就用 JS 定义——写在 ~~~ … ~~~ 块(§7.4)或 ~ 行里,在 { } 中取值、在 ~ 行中调用:
~~~
function describe_health(x) {
if (x === 100) return "健康"
if (x > 75) return "不错"
return "虚弱"
}
function add_gold(amount) {
gold += amount
}
~~~
Monsieur Fogg 看起来{ describe_health(hp) }。
规则:
- 用 JS 的
function或箭头函数声明,return返回值 - 取文本用
{ describe_health(hp) },纯副作用用~ add_gold(10) - 完整支持 JS 函数特性:默认参数、递归、闭包等
- 函数定义跨文件共享同一全局 JS 作用域(§7.6),函数名与变量名全局唯一
- 需要"可复用、带参、含分支的叙事段"用带参节点(§2.2),不是函数
12.1 内置函数
纯数学 / 类型转换直接用 JS(Math.trunc、Math.floor、parseFloat、** 等)。内置函数只保留 JS 拿不到的引擎能力:
| 函数 | 说明 |
|---|---|
random(min, max) | 返回 [min, max] 闭区间随机整数(可复现,受 seed_random 控制) |
seed_random(n) | 设置随机种子 |
turns() | 当前总回合数 |
turns_since("节点名") | 距离上次访问该节点的回合数(未访问返回 -1) |
seq(...items) | 变体:依次返回各项,停在最后一项(§9.1) |
cycle(...items) | 变体:循环返回各项(§9.2) |
once(...items) | 变体:依次返回,用完返回空串(§9.3) |
shuffle(...items) | 变体:随机返回一项,受 seed_random 控制(§9.4) |
变体函数seq/cycle/once/shuffle按源码位置自动维护各自的访问计数,是"活文本"的实现(详见 §9)。
上述内置函数名均为保留标识符,作者不可用作变量名 / 参数名 / 选项标签(§7)。
跟 Ink 的对比:去掉CHOICE_COUNT()、LIST_*系列。CHOICE_COUNT()用得少;LIST_*是 LIST 特性,本规范不支持 LIST。
13. 注释
// 单行注释
/*
多行注释
*/
规则:
//单行注释/* ... */多行注释
14. 完整示例
《雾港之夜》——一篇验明真伪的短篇解谜,集中示范本规范的主要语法面:开局 random 加计数/布尔变量(§8)、知识门控的条件选项 {...}(§6)、@if/@else 分支叙述与分支体内 ~ 语句(§8)、节点循环跳转与多结局分流、once() / shuffle() 变体(§9),以及命令 @bg_show / @bg_hide / @bgm_play(§11)。
接头人真伪由开局~ let imposter = random(0, 1)决定。引擎 PRNG 默认种子固定(§9),故纯.kin单独运行时身份恒定;要让每次游玩随机,由宿主在创建 Story 时注入真随机种子(createStory(program, { start, seed }),见engine-packaging-spec)。
// 雾港之夜 —— main.kin
// 架空工业王国「格兰瑟姆联合王国」· 港口城雾港的一个夜晚。
// 你是以太工程院的人,要把一张魔法机关设计图交给接头人「灰隼」。
// 可风声走漏,来的人未必是真灰隼。先验明真伪,再决定交不交。
// 玩法:开局随机定对方真伪;线索靠收集解锁;四结局 = 身份 × 你的决断。
~ let imposter = random(0, 1) // 1 = 冒充者,0 = 真灰隼(开局随机,由宿主种子驱动)
~ let suspicion = 0 // 已看出的疑点数(用于结局风味)
~ let asked = 0 // 已盘问的核验数;四选三,满 3 个对方警觉、不再受盘问
~ let know_geo = false // 看过地图:格雷斯顿深居内陆、不近海
~ let know_seal = false // 读过手册:认得行会符印遇火泛灰光(真伪都泛,验它无从分辨)
~ let know_compass = false // 聊过天气:雾里以太作怪、罗盘乱转
~ let know_mark = false // 问过老板来历:格雷斯顿人打小打铁、手上带铁匠的印记
-> 码头开场
=== 码头开场 ===
@bg_show("assets/harbor_fog.jpg")
@bgm_play("assets/ambient_fog.mp3")
末班蒸汽船把你卸在雾港的栈桥上,锅炉的余温还贴在背后,转眼就被夜雾吞了。
雾是活的——格兰瑟姆人这么说。码头的煤气灯一盏盏亮着,可光只走出半臂远就被雾喝掉,剩下一圈昏黄悬在半空。
岸边立着几根驱雾的盐铁桅,铁锈味混着海腥,咸得发苦。
雾里不知哪儿传来一声汽笛,{ shuffle("闷在水汽里", "像隔着一床湿棉被", "辨不出是进港还是出港") }。
怀里那只以太蜡封的铜管硌着肋骨——那是以太工程院的心血,一张魔法机关的设计图。今夜你要在码头边的锈锚酒馆,把它交给接头人「灰隼」,格雷斯顿来的信使。
接头暗号你记着:上句「雾里的灯」,下句「替谁亮着」。
这张图太要紧,递错了手,便是亲手替敌人铸了利器——交出去之前,总得把人看准。
-> 出发前
=== 出发前 ===
{ once("进门之前,趁手的东西还能再看一眼。") }
* {!know_geo} [摊开随身的地图]
> 你借灯影摊开那张油布地图。海岸线上一串港埠:铅口、索尔兹比、雾港,挨着浪头排开。
> 铁路从雾港一路往内陆钻,越铺越高,扎进群山,尽头才落到格雷斯顿;再往里是焦炭岭和灰背山,地图边角都快盛不下。
> 你把这些方位在心里默记了一遍,收起地图。
> ~ know_geo = true
> -> 出发前
* {!know_seal} [翻开那本简易魔法手册]
> 手册前几页画着以太术士的随身行头,一样样列着——
> 「行会符印」:认证文书用的火漆印,凑近火苗,纹路里会浮起一层灰光。
> 「以太罗盘」:黄铜怀表式罗盘,跑长途的信使人手一只。
> 「以太蜡」:封口封管用,遇热回软,凉透了比火漆还硬。
> 「灰玻璃片」:隔着它看以太,能看出旁人看不见的纹路。
> 你把这几样的模样记在心里,将手册塞回怀里。
> ~ know_seal = true
> -> 出发前
* [收好东西,推开锈锚酒馆的门] -> 入座
=== 入座 ===
@bg_show("assets/tavern_interior.jpg")
暖气和劣酒味一下子糊在脸上。屋角壁炉噼啪响,墙上挂着一盏以太长明灯。
靠墙的角落里坐着个穿灰呢大衣的男人,膝上搁着一只空木匣——等着装你那只铜管。他抬眼看你。
「雾里的灯,」你低声道。
「替谁亮着。」他接得分毫不差。
暗号是对上了。你在他对面坐下。老板在吧台后擦着杯子。
-> 酒馆闲谈
=== 酒馆闲谈 ===
{ once("递铜管之前,不妨先在桌上消磨片刻。") }
* {!know_compass} [跟老板聊聊这鬼天气]
> 「雾港的雾不寻常。」老板擦着杯子,「水手都说,罗盘一进这片雾就找不着北,指针直打转,古怪得很。」
> ~ know_compass = true
> -> 酒馆闲谈
* {!know_mark} [跟老板攀谈,问问他的来历]
> 「我?格雷斯顿人。」老板摊开两手给你看,掌沿和虎口尽是老茧与暗红的烫疤,「打小在炉边抡锤——那地方长大的孩子,没一双手是干净的。打铁是刻进骨子里的本行,生来就躲不掉。」
> ~ know_mark = true
> -> 酒馆闲谈
* [请对面这位喝一杯]
> 你抬手要了两杯麦酒。他道了声谢,接过去抿了一口,又搁下了——不过是些无关紧要的寒暄。
> -> 酒馆闲谈
* [收起寒暄,开始盘问] -> 盘问
=== 盘问 ===
{ once("你把话头收住,目光落到他身上。") }
@if {asked >= 3}
> 他靠回椅背,神色冷了几分。「问得够多了,先生。」一只手按上桌角的木匣,「交,还是不交——你该拿个主意了。」
* {know_geo && asked < 3} [问他这一路是怎么来雾港的]
> 「从格雷斯顿到雾港,路可不近。你这一路,是怎么过来的?」
> @if {imposter === 1}
> > 「走水路,」他说,「从家门口搭的船,海上颠了两天,才晃到这儿。」
> > ~ suspicion++
> @else
> > 「铁路从山里一路坐下来,到了山脚再换马车,」他说,「打格雷斯顿到海边,足足折腾了一整天。」
> ~ asked++
> -> 盘问
* {know_compass && asked < 3} [问他在这片雾里是怎么寻到路的]
> 「雾港的雾你也领教了。两眼一抹黑的,你是怎么寻到这家店的?」
> @if {imposter === 1}
> > 「靠行会那只罗盘呗。」他拍了拍怀表袋,「针一路指得准,照着走就到了。」
> > ~ suspicion++
> @else
> > 「罗盘?」他短促地笑了一声,「那东西一进雾就跟着了魔似的乱转。我是一路问着道、摸过来的。」
> ~ asked++
> -> 盘问
* {know_mark && asked < 3} [借斟酒的工夫,留意他的手]
> 你不动声色,打量起他搁在桌沿的那只手。
> @if {imposter === 1}
> > 也是双粗手,可茧子结在指节与掌心,是常年攥惯了刀枪磨出来的——半点炉火溅出的烫疤都没有。
> > ~ suspicion++
> @else
> > 掌沿一圈厚茧,虎口几处暗红的旧烫疤,是常年握锤打铁、被火星子燎出来的手。
> ~ asked++
> -> 盘问
* {know_seal && asked < 3} [借桌上的烛火,验那枚符印]
> 你借烛火凑近木匣扣着的行会符印。火苗一近,一层灰光顺着纹路浮了上来,匀匀地泛着。
> ~ asked++
> -> 盘问
* [把铜管推过去——交出设计图] -> 交付
* [借口再想想,起身离座——先不交] -> 拒付
=== 交付 ===
@if {imposter === 1}
> -> 为渊驱鱼
@else
> -> 送达
=== 拒付 ===
@if {imposter === 1}
> -> 雾里识隼
@else
> -> 错付
=== 送达 ===
你把铜管推过桌面。他验过蜡封,郑重地收进木匣,扣上。
「替谁亮着,」他低声把那句下半句又念了一遍,像是说给某个不在场的人听,「这一次,掌灯的人没有白等。」
他起身没入雾里。设计图上路了,去它该去的地方。雾港的夜还长——至少今夜,你交对了人。
-> END
=== 错付 ===
你终究没把铜管递出去。
他看了你良久,眼里掠过一丝你读不懂的东西——失望,或只是疲惫。他默默扣上空匣,起身。
「替谁亮着……今晚怕是没人来接这盏灯了。」他没入门外的雾里,脚步声很快被吞没。
后来你才听说,那确是真灰隼。设计图烂在你手里,那台机关,再没能造出来。有些谨慎,代价是错过。
-> END
=== 为渊驱鱼 ===
你把铜管推过桌面。他接过去,蜡封在他指间一捻就开了——熟练得不像第一回。
@if {suspicion >= 1}
> 那些疑点你不是没看见,可你还是把它推了过去。
他冲你一笑,那笑里没有半分接头人的拘谨。木匣扣上,人已起身,转眼没入雾里。
设计图到了不该到的手里。用不了多久,以太工程院的心血,会变成对着工程院开火的东西。
-> END
=== 雾里识隼 ===
@bg_hide()
你终究没把铜管递出去,只淡淡丢下一句「今夜怕是接不成头了」。
他眯眼盯了你一瞬,忽地起身,把空匣塞进怀里,没等你开口便挤进了人群。门开了一线,雾涌进来,又把他卷了出去。
真正的接头人绝不会丢下接头就走。你攥紧怀里的铜管——它还在你手里。这一次,设计图没有泄露出去。
-> END
*v0.1.0 草案结束。*