← 返回首页 DSL 语法说明

Kiny DSL 语法

用纯文本写互动小说的完整语法规范——节点、文本、跳转、选项、分支、变量、条件、变体与命令。

Kiny DSL 规范 v0.1.0 草案

状态:草案 (draft),等待逐条审查 文件扩展名:.kin 工具/编辑器名:kiny

0. 概述

Kiny 是一种受 Ink 启发的互动叙事 DSL,定位是"Ink 的更简洁版本"。设计目标:

  1. 作者优先,不是游戏开发者优先:砍掉 Ink 中所有游戏引擎集成向的高级特性(list、tunnel、thread、external function、ref 参数)
  2. 中文友好:节点 ID 仍用 ASCII,但允许中文显示名;错误信息和文档双语
  3. 项目文件夹组织:元数据集中在 kiny.json,故事文件用 .kin 扩展名
  4. 跨平台:编辑器和阅读器目标 Windows、Android、Web,后续 iOS、macOS

0.1 Kiny vs Ink 速查

特性InkKiny 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.jsonentry)中、第一个 === 节点 === 之前的整段内容构成「开场」:

  • 开场的作用域为全局,从上到下顺序执行——全局声明(~ 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.50.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 标识符规则一致。

内置函数名(randomseed_randomturnsturns_sinceseqcycleonceshuffle,见 §12.1)是保留标识符,不可用作变量名、参数名或选项标签,编译期报错。

7.1 变量声明

用 JS 的 letconst

~ 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)打字机出字速度(字 / 秒),默认 800 = 整行瞬显
@text_fade(ms)每字淡入时长(毫秒),默认 3000 = 无淡入
@input(var [, hint])暂停故事,向读者请求一段文本,写入变量 varhint 作输入框提示(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.truncMath.floorparseFloat** 等)。内置函数只保留 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 草案结束。*