这是一本 Ink++ v2.0 的编码指南,由 BL.BlueLighting 撰写。
本指南以 code/Sample.(language).inkpp 为准,本教程不会实时更新,请见谅。
本指南将教你如何从零写出 Ink++ 交互小说。读完第一部分,你就能写出一个完整的小故事;第二部分覆盖进阶特性。
Part 1 — 基础
Part 2 — 进阶
最小的 Ink++ 故事:
@entry/start -> Hello
@entry/end -> Hello
@segment Hello {
"Hello World!".
}
逐行解释:
@entry/start -> Hello—— 故事从这里开始:先跳转到段落Hello。@entry/end -> Hello—— 故事结束节点。本例与开始节点相同,合法。@segment Hello { ... }—— 定义一个段落。"Hello World!".—— 输出一行文本。字符串末尾的.就是输出。
运行(CLI):node src/cli.ts run 你的故事.inkpp
一个故事由多个**段落(segment)**组成。段落是跳转的单位,用来组织故事与章节:
@segment Example {
}
用 -> 跳转到另一个段落。跳转不返回(等同 goto),目标必须在文件里存在:
@segment Hello {
"Hello World!".
-> Next
}
@segment Next {
"I'm next segment.".
-> @entry/end
}
也可以跳转到系统入口 @entry/start / @entry/end。
段落体内可以再定义段落(子 segment),用于组织复杂流程。子 segment 只在父作用域内可见:
@segment Main {
"在 Main 里".
-> Sub // 父内短名调用
@segment Sub {
"在 Sub 里".
-> Main/Sub2 // 或全限定名 parent/son
@segment Sub2 {
"在 Sub2 里".
-> End
}
}
}
- 定义:在
@segment/@function/@collection的块内写@segment 名字 { ... }(可多层嵌套)。 - 调用:
-> parent/son(全限定)或父作用域内-> son(短名);子段注册名即完整路径。 - 作用域限制:
- 段内子段:仅父段(及父段的子段)内可调用,外部调用报错。
- 函数内子段:仅该函数内可调用;
return在子段内返回函数值。 - 集合内子段:仅集合内函数可调用;集合函数之间可以互相调用对方定义的对内子段。
@entry/start/@entry/end必须指向顶层段。- 子段只能定义在 segment / collection / function 体内(if、choice 等块内不允许)。
每个故事必须有一个 @entry/start 和一个 @entry/end。它们可以指向同一个段落。
Warning
@entry/end 指向的段落必须调用 >> System/exit() 结束故事。
@entry/start -> Hello
@entry/end -> End
@segment Hello {
"Hello World!".
-> End
}
@segment End {
"Goodbye World!".
>> System/exit()
}
输入与输出只由两个符号控制:. 与 >>。
任何表达式末尾加 . 即可输出,点号作用于整个表达式:
"Hello World!". // 字符串
name. // 变量
"Count: " + i. // 整个拼接表达式
todo. // 输出 "To be continued..."
输出文本支持 HTML 标签,不经过转译——在 Web 播放器与 VS Code 预览中会按富文本渲染:
"<strong>注意!</strong> 前方有危险。".
"<em>他低声说道</em>".
- 整行
<h1>…</h1>/<h2>…</h2>会渲染到页面顶部的标题区(常驻显示)。 - 渲染时会自动删除
<script>块,无法通过输出注入脚本。 - CLI 环境原样打印标签文本。
>> name : string
name 是变量名,string 是类型。输入会按类型校验,不合法会要求重新输入:
@segment AskName {
"Hello!".
"What's your name?".
>> name : string
"Greetings! " + name + ".".
}
用 choice 向玩家展示选项,玩家选中后执行对应代码块:
choice -> result {
"去森林" {
"你走向黑暗的森林".
result = "forest"
}
"去城堡" {
"你走向雄伟的城堡".
result = "castle"
}
}
if (result == "forest") then -> ForestScene
if (result == "castle") then -> CastleScene
choice会隐式声明结果变量(类型string),不要重复声明。- 在选项块内可以直接
->跳转。
模板:
choice -> (结果变量) {
"<选项名称>" {
// 代码块
}
...
}
只有一个选项、选中后直接跳转时,用 choice-go 一行搞定:
choice-go "继续" -> Next
choice-go "返回标题" -> @entry/start
choice-go "结束" -> @entry/end
目标与 -> 相同:必须是已定义的段落,或 @entry/start / @entry/end(编译期校验)。
六种类型(全部小写):
| 类型 | 说明 |
|---|---|
int |
整数 |
bool |
布尔值 |
float |
浮点数 |
string |
文本 |
list |
可变列表 |
map |
Key:Value 映射 |
int gold = 50
bool hasKey = false
string playerName = "Hero"
赋值可以跨段落、跨文件。两遍检查:变量可以先使用后声明(编译器先收集所有声明,再检查用法):
@segment A {
"金币: " + gold. // 编译通过;运行时需保证 gold 已被赋值
-> B
}
@segment B {
int gold = 50
-> @entry/end
}
编译期只检查"声明过";读取尚未赋值的变量是运行时错误。
类型检查是严格的。唯一允许的隐式转换是 int → float:
float health = 100 // OK:100 拓宽为 100.0
health = 1.5 // OK
playerHealth = 1.5 // 错误:float 不能缩窄为 int
gold = "100" // 错误:不能把 string 赋给 int
list inventory = []
inventory/add("rusty sword") // 成员访问用 '/'
"背包: " + inventory[0]. // 索引读取
inventory[0] = "生锈的铁剑" // 索引赋值
"件数: " + inventory/length(). // 长度
map questLog = {}
questLog["main"] = "找到宝石"
"任务: " + questLog["main"].
list是真正的可变列表,不是 Ink 的const。- 字面量要求元素同质:
[1, "two"]是编译错误。- 索引越界、读取不存在的键是运行时错误。
支持大多数常见运算符(按优先级从低到高):
|| &&
== != < > <= >=
+ - * / %
! -(一元) ( )
赋值运算符:= += -= *= /=。
Warning
- 除法运算的结果恒定为
float。 +两侧任意一边是字符串时做拼接。- 比较仅限同类型(数值之间可以互相比较)。
// 标准写法
if (name == "") {
todo.
}
// else / else if 链:条件为假时执行 else 分支
if (score > 10) {
"big".
} else if (score > 5) {
"mid".
} else {
"small".
}
// 单行快捷写法
if (name == "CCF") then -> @entry/end
// 链式 then:条件为真时,各块顺序执行(上限 20 层);后面也可以接 else
if (score > 10) then {
"first if".
} then {
"second".
} else {
"neither".
}
for (int i = 0; i < 5; i += 1) {
"Count: " + i.
}
while (health > 0) {
"Fighting...".
health -= 10
}
- for 循环变量是全局声明的。
- 条件写法与 C++ 相同,只是
++写作+= 1。
while (health > 0) {
health -= 10
if (health <= 0) {
break // 跳出循环
}
}
for (int i = 0; i < 10; i += 1) {
if (i % 2 == 1) {
return // 等价 continue:回到循环头,先执行步进再判断条件
}
"even " + i.
}
return 等价 continue,仅循环内部合法(含前置检查)。
then 在 if、for、while 中通用(上限 20 层):
| 位置 | 语义 |
|---|---|
if (c) then A then B ... |
条件为真时顺序执行 A、B…(A/B 可以是块、单条语句,或带自己 then 链的 if/for/while) |
while (c) then if(...) {...} then {...} ... |
第一个 then 若是 if,它是前置检查:每轮开始前执行;其中 return 回到循环头、跳过本轮循环体 |
while (c) then {...} ... |
第一个 then 不是 if 时,它就是循环体 |
for (...) then {...} ... |
第一个 then 是循环体(每轮重复) |
| 循环体之后的 then | 末尾 then:循环结束后顺序执行一次(此时不在循环内,return/break 不可用) |
// 前置 then + 末尾 then 组合
int n = 0
while (n < 3) then if (n == 1) {
n += 1
return
} then {
"body " + n.
n += 1
} then {
"done".
}
花括号写法(if (c) { }、while (c) { }、for (...) { })不能接 then——需要链式调用时用 then 写法。
@function 定义带参数、返回值的函数(语法类似带参数的段落):
@function add(int a, int b) : int {
return a + b
}
@function greet(string name) : string {
return "Hi " + name
}
- 参数列表:
类型 参数名, ... - 返回类型写在
:后,默认int;只写return(无值)默认返回 0(其他类型返回默认值:false/0.0/""/[]/{}) return <表达式>返回函数值;函数执行到末尾未返回时同样返回默认值
int x = add(2, 3) // 表达式调用,可取返回值
"直接: " + add(10, 5). // 输出返回值
-> log("跳转式调用") // 语句调用:丢弃返回值
>> log("输入式调用") // 同上
- 函数参数按值传递;函数内可访问全局变量。
- 支持递归;参数名会遮蔽同名全局变量,不会泄漏到外部。
@collection 把多个函数编组,像 Ink++ 的"类":
int health = 100 // 限定变量(成员)写在集合外,即顶层
@function getHealth() : int {
if (collection) then {
return health // 集合内运行:返回成员变量
}
return -1
}
@function heal(int amount) : int {
health += amount
return health
}
@collection Player {
getHealth,
heal
}
- 集合调用:
Player/getHealth()——函数内获得collection对象(truthy),if (collection)判断是否在集合内运行。 - 直接调用:
getHealth()也可用(无论是否在集合内)——此时collection为 null。 - 兄弟调用:集合内函数可用
collection/otherFunc()调用集合内其他函数。 - 限定变量:在集合外(顶层)声明的变量,集合函数可读写——把它理解为类的字段。
- 集合中的函数名必须是已定义的
@function;调用集合外的函数名会报错。
for (int i = 0; i < 5; i += 1) then {
if (i % 2 == 1) {
continue // restart / reboot / return(无值)等价:重新开始当前循环
}
"even " + i.
}
continue、restart、reboot、return(无值、循环内)都表示重新开始当前循环(continue 语义);仅循环内合法。return 带值时是函数返回。
大故事可以拆成多个文件。
@import ./rooms // 相对当前文件,可省略 .inkpp
@import ./config.inkpp // 带扩展名也可以
- 写在顶层,路径相对当前文件所在目录。
- 被导入文件的段落、变量、i18n、资源、外部代码会合并进当前故事。
- 支持嵌套导入;循环导入会报错;重复导入只生效一次。
- CLI 直接可用;Web 播放器 / 插件预览没有文件系统,
@import会提示改用@import-system。
@import-system console // 内置模块,可省略 .inkpp
@import-system random
- 内置模块位于项目
builtin/目录(console、random、gamekit),引擎内嵌一份作为 Web/预览环境的兜底。 - 内置模块内部可以使用
@javascript/@python代码,不受 allowUnsafeCode 开关限制(内置模块是受信任的)。 - 内置模块可提供预置段落、变量与初始化代码,供故事复用。
在项目 builtin/ 目录下新建一个 <模块名>.inkpp 文件,故事里 @import-system <模块名> 即可使用。模块文件第一行必须是模块标记 ->builtin_module——它告诉编译器和 VS Code 插件"这是模块文件,无需 @entry/start、@entry/end,也不检查段落可达性",同时让模块内的 @javascript / @python 受信任:
// builtin/mytimer.inkpp — 自定义内置模块
->builtin_module
// 1) 用 @javascript 挂载全局工具(受信任,无需 allowUnsafeCode)
@javascript {
if (!globalThis.inkppTimer) {
globalThis.inkppTimer = {
start() { this.t0 = Date.now(); },
elapsed() { return this.t0 ? Date.now() - this.t0 : 0; },
};
}
}
// 2) 可选:提供预置段落(导入后即可 -> 跳转)
@segment TimerDemo {
"内置模块 mytimer 已加载。".
-> @entry/end
}
// 3) 可选:定义函数 / 集合 / 顶层变量,供故事复用
// 故事里用 js/ 命名空间调用 globalThis 上的函数
@function secondsElapsed() : int {
return js/inkppTimer/elapsed() / 1000
}
要点:
-
模块名 = 文件名(不含
.inkpp),只能由字母、数字、下划线组成。 -
受信任:模块内的
@javascript/@python始终允许执行(@python在 Web 环境仍不可用)。 -
模块里的
@segment是普通段落——不可达是正常的(警告可忽略),模块主要价值在它挂载的全局能力与函数。 -
@javascript里用globalThis.xxx挂载工具,故事用js/命名空间直接调用:js/inkppRandom(1, 10). // 调用 globalThis 上的函数(js/函数名(...)) js/inkppTimer/elapsed(). // 对象方法(js/对象/方法(...)) "timer: " + js/inkppTimer. // 属性取值 map data = js/inkppGetData() // 返回值自动转换:对象→map、数组→list、数字→int/floatjs/的返回值是动态类型(any),赋值给任意类型的变量、参与运算、索引都允许;参数自动从 Ink++ 值转换为 JS 值。 -
CLI 从
builtin/目录读取;Web 端在npm run web:build时自动把builtin/目录下所有模块打包进story.js,浏览器里@import-system直接可用;VS Code 插件默认使用引擎内嵌模块,设置inkpp.builtinDir指定自定义模块目录后,诊断与预览都会使用该目录的模块。 -
在宿主 API(TypeScript)中,
compileStory(source, { importResolver })的 resolver 提供resolveSystem(name)即可自定义模块来源;模块文件可传moduleMode: true(或自动识别->builtin_module标记)跳过入口检查。
@i18n {
"zh-cn": {
"greeting": "你好,世界!",
"farewell": "再见!"
},
"en-us": {
"greeting": "Hello World!",
"farewell": "Goodbye!"
}
}
@segment I18nTest {
i18n("greeting"). // 按当前语言输出
[i18n:farewell]. // 标签语法,等价
}
@i18n表写在顶层,语言代码任意(如zh-cn、en-us)。- 当前语言由引擎选项决定(CLI
--lang;Web/插件里切换),默认取表中第一种语言。 - 缺键时回退到第一种语言;完全没有该键则原样输出键名。
// 顶层预加载(可选,但未预加载就使用会有编译警告)
@preload music "background.ogg"
@preload image "scene1.png"
@segment MediaTest {
@show image "scene1.png" // 非阻塞显示图片
"你看到了一幅美丽的风景画".
@play music "background.ogg" // 播放音乐
@stop music // 停止
}
媒体事件通过引擎的 media 回调交给宿主处理:CLI 打印一行提示,Web 播放器真实显示/播放(素材内嵌 base64),也可以在你的宿主里接真实资源。
所有声明过的变量默认进入存档,不需要任何额外声明:
@segment GameSave {
int gold = 50
string currentScene = "GameSave"
@nosave // 排除出存档(临时变量、缓存等)
int tempCounter = 0
@save "quick_save" // 手动存档(变量快照)
gold = 999
@load "quick_save" // 手动读档(恢复变量)
"读档后金币: " + gold. // 输出 50
}
存档槽的名字是字符串,由宿主决定存哪里:CLI 存 inkpp-saves/*.json,Web 存 localStorage,插件预览存工作区。引擎 API 也提供 engine.save(slot) / engine.load(slot) 供宿主自行触发自动存档。
->debugger // 断点标记
- 只在开发模式生效(CLI
--dev、Web/插件的 dev 开关);非开发模式完全跳过。 - 触发时暂停执行:CLI 按回车继续,插件预览弹"继续执行"按钮。
- 编译器本身会给出大量警告(不阻止运行):不可达段落、未使用的变量、i18n 缺键、未预加载的媒体文件。写作时保持零警告是好习惯。
@javascript {
console.log("Hello from JS"); // Web / CLI 都可用
}
@python {
print("Hello from Python") // 仅 CLI 环境;Web 编译报错
}
- 出于安全,默认禁止:需要在引擎初始化时开启
allowUnsafeCode: true。 - 块体是原样捕获的代码(可以有自己的大括号、字符串和注释),在故事开始时按顺序执行一次。
Ink++ 不是 Ink,但提供了把 Ink 脚本转换为 Ink++ 的命令行工具:
node src/cli.ts toinkpp 故事.ink # 生成 故事.inkpp
node src/cli.ts toinkpp 故事.ink 输出.inkpp # 指定输出文件支持转换的 Ink 特性:
| Ink 语法 | 转换为 |
|---|---|
=== knot === |
@segment knot { ... } |
-> knot / -> END / -> DONE |
-> knot / -> End(生成 End 段) |
* 选项 / * 选项 -> knot / * 选项 [-> knot] |
choice 块 |
= knot =(alternate knot) |
嵌套 @segment knot(子段) |
{ cond: } / - else: 多行条件 |
if (cond) { } else { } |
VAR x = 5 / CONST x = 5 |
类型推断的变量声明(int/float/bool/string) |
~ x = 5 |
x = 5 |
INCLUDE file |
@import ./file |
[b: 文本] / [i: 文本] / {b: ...} |
<b> / <i> HTML 输出 |
{变量} 插值 |
"..." + 变量 + "..." |
// 注释 |
保留 |
不支持的特性(多行 else、织针、标签、EXTERNAL 等)会输出 // TODO(ink): ... 注释并在转换时列出警告,请人工处理。
组织故事:把每个场景/节点写成一个段落,用 -> 连接;分支用 choice + if 跳转。段落名是全局符号,取有意义的名字(ForestScene 比 S1 好)。
存档友好:故事的"进度"用变量表达(currentScene、gold),这样 @save / @load 自动覆盖全部状态;临时量用 @nosave 排除。
类型意识:/ 除法恒为 float,需要整数时用 % 或再赋值回 int;+ 拼接很方便,但别在数值计算里混入字符串。
| 错误 | 原因 |
|---|---|
unknown segment 'X' — did you mean 'Y'? |
跳转目标拼错或还没写 |
cannot assign string to int variable 'x' |
类型不匹配(严格类型) |
variable 'x' is already declared |
重复声明;choice 的结果变量是隐式声明的 |
break is only allowed inside a loop |
break 写在了循环外 |
return is only allowed inside a loop |
return(continue)写在了循环外 |
chained 'then' is limited to 20 blocks |
then 链超过 20 层 |
unknown variable 'x' |
变量没声明过(可以先使用后声明,但不能不声明) |
| 错误 | 原因 |
|---|---|
variable 'x' has no value yet |
读取了还没赋值的变量 |
list index N out of range |
索引越界 |
key 'k' not found in map |
读取了不存在的键 |
division by zero |
除以零 |
save slot 'x' does not exist |
读档时存档不存在 |
"text". 输出
>> name : type 输入(int/bool/float/string)
-> Target 跳转(-> @entry/end、->debugger)
x = v, x += v 赋值
if / else / then / for / while / break / continue / restart / reboot / return
int bool float string list map
@segment @entry/start @entry/end @i18n @preload
@save @load @nosave @show image @play music @stop music
@javascript @python
@function f(int a) : int { return a } @collection C { f }
foo(1) C/foo() -> foo(1) >> foo(1)
@import ./path @import-system 内置模块
System/exit()
Have a good time while writing with Ink++!