Skip to content

Latest commit

 

History

History
740 lines (558 loc) · 22 KB

File metadata and controls

740 lines (558 loc) · 22 KB

Writing With Ink++

Wrote by BL.BlueLighting

这是一本 Ink++ v2.0 的编码指南,由 BL.BlueLighting 撰写。

本指南以 code/Sample.(language).inkpp 为准,本教程不会实时更新,请见谅。

本指南将教你如何从零写出 Ink++ 交互小说。读完第一部分,你就能写出一个完整的小故事;第二部分覆盖进阶特性。


目录

Part 1 — 基础

  1. Hello world
  2. 基础流程
  3. 输出与输入
  4. 选择
  5. 变量
  6. 表达式与运算符
  7. 条件与循环

Part 2 — 进阶

  1. 函数与集合
  2. 模块与导入
  3. 本地化 i18n
  4. 图片与音频
  5. 存档
  6. 调试
  7. 外部代码
  8. Ink 兼容层(toinkpp)
  9. 写作技巧与常见错误

Part 1 — 基础

1. Hello world

最小的 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

2. 基础流程

2.1 段落

一个故事由多个**段落(segment)**组成。段落是跳转的单位,用来组织故事与章节:

@segment Example {

}

2.2 跳转

用 -> 跳转到另一个段落。跳转不返回(等同 goto),目标必须在文件里存在:

@segment Hello {
    "Hello World!".
    -> Next
}

@segment Next {
    "I'm next segment.".
    -> @entry/end
}

也可以跳转到系统入口 @entry/start / @entry/end。

2.3 子 segment(嵌套段落)

段落体内可以再定义段落(子 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 等块内不允许)。

2.4 开头与结尾

每个故事必须有一个 @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()
}

3. 输出与输入

输入与输出只由两个符号控制:. 与 >>。

3.1 输出

任何表达式末尾加 . 即可输出,点号作用于整个表达式:

"Hello World!".           // 字符串
name.                     // 变量
"Count: " + i.            // 整个拼接表达式
todo.                     // 输出 "To be continued..."

3.2 输出 HTML

输出文本支持 HTML 标签,不经过转译——在 Web 播放器与 VS Code 预览中会按富文本渲染:

"<strong>注意!</strong> 前方有危险。".
"<em>他低声说道</em>".
  • 整行 <h1>…</h1> / <h2>…</h2> 会渲染到页面顶部的标题区(常驻显示)。
  • 渲染时会自动删除 <script> 块,无法通过输出注入脚本。
  • CLI 环境原样打印标签文本。

3.3 输入

>> name : string

name 是变量名,string 是类型。输入会按类型校验,不合法会要求重新输入:

@segment AskName {
    "Hello!".
    "What's your name?".
    >> name : string
    "Greetings! " + name + ".".
}

4. 选择

4.1 choice 选项块

用 choice 向玩家展示选项,玩家选中后执行对应代码块:

choice -> result {
    "去森林" {
        "你走向黑暗的森林".
        result = "forest"
    }
    "去城堡" {
        "你走向雄伟的城堡".
        result = "castle"
    }
}

if (result == "forest") then -> ForestScene
if (result == "castle") then -> CastleScene
  • choice 会隐式声明结果变量(类型 string),不要重复声明。
  • 在选项块内可以直接 -> 跳转。

模板:

choice -> (结果变量) {
    "<选项名称>" {
        // 代码块
    }
    ...
}

4.2 choice-go 单行快捷选择

只有一个选项、选中后直接跳转时,用 choice-go 一行搞定:

choice-go "继续" -> Next
choice-go "返回标题" -> @entry/start
choice-go "结束" -> @entry/end

目标与 -> 相同:必须是已定义的段落,或 @entry/start / @entry/end(编译期校验)。

5. 变量

5.1 类型

六种类型(全部小写):

类型 说明
int 整数
bool 布尔值
float 浮点数
string 文本
list 可变列表
map Key:Value 映射

5.2 声明与赋值

int gold = 50
bool hasKey = false
string playerName = "Hero"

赋值可以跨段落、跨文件。两遍检查:变量可以先使用后声明(编译器先收集所有声明,再检查用法):

@segment A {
    "金币: " + gold.     // 编译通过;运行时需保证 gold 已被赋值
    -> B
}
@segment B {
    int gold = 50
    -> @entry/end
}

编译期只检查"声明过";读取尚未赋值的变量是运行时错误。

5.3 隐式转换

类型检查是严格的。唯一允许的隐式转换是 int → float:

float health = 100     // OK:100 拓宽为 100.0
health = 1.5           // OK
playerHealth = 1.5     // 错误:float 不能缩窄为 int
gold = "100"           // 错误:不能把 string 赋给 int

5.4 列表与映射

list inventory = []
inventory/add("rusty sword")      // 成员访问用 '/'
"背包: " + inventory[0].           // 索引读取
inventory[0] = "生锈的铁剑"        // 索引赋值
"件数: " + inventory/length().     // 长度

map questLog = {}
questLog["main"] = "找到宝石"
"任务: " + questLog["main"].
  • list 是真正的可变列表,不是 Ink 的 const。
  • 字面量要求元素同质:[1, "two"] 是编译错误。
  • 索引越界、读取不存在的键是运行时错误。

6. 表达式与运算符

支持大多数常见运算符(按优先级从低到高):

||    &&
== !=    < > <= >=
+ -      * / %
! -(一元)    ( )

赋值运算符:= += -= *= /=。

Warning

  • 除法运算的结果恒定为 float。
  • + 两侧任意一边是字符串时做拼接。
  • 比较仅限同类型(数值之间可以互相比较)。

7. 条件与循环

7.1 if 与 then

// 标准写法
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".
}

7.2 for 与 while

for (int i = 0; i < 5; i += 1) {
    "Count: " + i.
}

while (health > 0) {
    "Fighting...".
    health -= 10
}
  • for 循环变量是全局声明的。
  • 条件写法与 C++ 相同,只是 ++ 写作 += 1。

7.3 break 与 return

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,仅循环内部合法(含前置检查)。

7.4 then 的完整语义

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 写法。


Part 2 — 进阶

8. 函数与集合

8.1 定义函数

@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 <表达式> 返回函数值;函数执行到末尾未返回时同样返回默认值

8.2 调用函数

int x = add(2, 3)        // 表达式调用,可取返回值
"直接: " + add(10, 5).   // 输出返回值
-> log("跳转式调用")      // 语句调用:丢弃返回值
>> log("输入式调用")     // 同上
  • 函数参数按值传递;函数内可访问全局变量。
  • 支持递归;参数名会遮蔽同名全局变量,不会泄漏到外部。

8.3 集合 collection(类)

@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;调用集合外的函数名会报错。

8.4 continue / restart / reboot / return

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 带值时是函数返回。

9. 模块与导入

大故事可以拆成多个文件。

8.1 导入本地文件 @import

@import ./rooms        // 相对当前文件,可省略 .inkpp
@import ./config.inkpp // 带扩展名也可以
  • 写在顶层,路径相对当前文件所在目录。
  • 被导入文件的段落、变量、i18n、资源、外部代码会合并进当前故事。
  • 支持嵌套导入;循环导入会报错;重复导入只生效一次。
  • CLI 直接可用;Web 播放器 / 插件预览没有文件系统,@import 会提示改用 @import-system。

8.2 导入内置模块 @import-system

@import-system console   // 内置模块,可省略 .inkpp
@import-system random
  • 内置模块位于项目 builtin/ 目录(console、random、gamekit),引擎内嵌一份作为 Web/预览环境的兜底。
  • 内置模块内部可以使用 @javascript / @python 代码,不受 allowUnsafeCode 开关限制(内置模块是受信任的)。
  • 内置模块可提供预置段落、变量与初始化代码,供故事复用。

8.3 编写自己的内置模块

在项目 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/float
    

    js/ 的返回值是动态类型(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 标记)跳过入口检查。

10. 本地化 i18n

@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/插件里切换),默认取表中第一种语言。
  • 缺键时回退到第一种语言;完全没有该键则原样输出键名。

11. 图片与音频

// 顶层预加载(可选,但未预加载就使用会有编译警告)
@preload music "background.ogg"
@preload image "scene1.png"

@segment MediaTest {
    @show image "scene1.png"     // 非阻塞显示图片
    "你看到了一幅美丽的风景画".
    @play music "background.ogg" // 播放音乐
    @stop music                  // 停止
}

媒体事件通过引擎的 media 回调交给宿主处理:CLI 打印一行提示,Web 播放器真实显示/播放(素材内嵌 base64),也可以在你的宿主里接真实资源。

12. 存档

所有声明过的变量默认进入存档,不需要任何额外声明:

@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) 供宿主自行触发自动存档。

13. 调试

->debugger       // 断点标记
  • 只在开发模式生效(CLI --dev、Web/插件的 dev 开关);非开发模式完全跳过。
  • 触发时暂停执行:CLI 按回车继续,插件预览弹"继续执行"按钮。
  • 编译器本身会给出大量警告(不阻止运行):不可达段落、未使用的变量、i18n 缺键、未预加载的媒体文件。写作时保持零警告是好习惯。

14. 外部代码

@javascript {
    console.log("Hello from JS");   // Web / CLI 都可用
}

@python {
    print("Hello from Python")      // 仅 CLI 环境;Web 编译报错
}
  • 出于安全,默认禁止:需要在引擎初始化时开启 allowUnsafeCode: true。
  • 块体是原样捕获的代码(可以有自己的大括号、字符串和注释),在故事开始时按顺序执行一次。

15. Ink 兼容层(toinkpp)

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): ... 注释并在转换时列出警告,请人工处理。

16. 写作技巧与常见错误

组织故事:把每个场景/节点写成一个段落,用 -> 连接;分支用 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++!