演出命令
画面与声音、正文里的图与线、呈现节奏、固定区域、向读者提问——按用途分组的全部内置命令。
命令用 @ 开头、写成函数调用的样子,独占一行、顶格。它们不产出正文,而是向阅读页下达指令:换背景、放音乐、插一张图、改推进节奏。
=== 酒馆 ===
@bg_show("assets/tavern.webp")
@bgm_play("assets/tavern_loop.mp3")
老板抬起头看着你。
参数是 JS 表达式,所以可以传变量(@bg_show(current_bg))。资源路径相对项目根(assets/ 只是惯例,不是硬规则)。
下面按用途分组。
场景:背景与音乐
| 命令 | 做什么 |
|---|---|
@bg_show(路径) |
显示全屏背景图 |
@bg_hide() |
隐藏背景图 |
@bgm_play(路径) |
播放背景音乐(循环) |
@bgm_pause() |
暂停 |
@bgm_stop() |
停止 |
@sfx(路径) |
放一次性音效(区别于循环的 bgm) |
@bg_show("assets/港口_夜.webp")
@bgm_play("assets/潮声.mp3")
雾从港口涌上来。
@sfx("assets/汽笛.mp3")
远处传来一声汽笛。
背景是始终垫在底下的一层,不随正文滚动。它和下面的正文插图是两回事。
正文流:插图与分割线
| 命令 | 做什么 |
|---|---|
@img(路径 [, 替代文字] [, 类名]) |
在正文流里插一张图 |
@divider([类名]) |
在正文流里插一条分割线 |
@clear() |
清除已显示的正文(保留背景与音乐) |
她推开门。
@img("assets/tavern.webp", "昏暗的酒馆内景")
炉火还没灭。
第一幕结束。
@divider()
第二幕开始。
插图与分割线是正文流里的一条内容:随正文往下滚、留在阅读历史里、line 模式下各占一次点击、@clear 时随正文一起清掉。
- 替代文字:图片的 alt。省略就按装饰图处理(屏幕阅读器跳过);加载失败时浏览器显示它。建议写。
- 类名:交给作品 CSS 的样式钩子,渲染时加
kin-前缀。规则同行内的<class=名>。 - 每张插图恒带
kin-illustration、每条分割线恒带kin-divider——尺寸、边框、间距归作品 CSS,播放层只保证「不溢出阅读列、块级居中」这条底线。 - 没有
@img_hide:插图是流里的一条内容、不是可开关的显示层。要清屏用@clear。
@clear清的是正文,和场景相互独立——清屏后背景与 BGM 照旧。命令是硬边界,所以同一次推进里@clear之前的文字先显示、清空、之后的文字进入新画面,天然就是「先清屏再显示新文」。
节奏:怎么显示、显示多快
| 命令 | 做什么 | 默认 |
|---|---|---|
@step_mode(模式) |
"line" 逐段等点击 / "flow" 一路流到选项 |
"flow" |
@text_speed(字每秒) |
打字机出字速度,0 = 整行瞬显 |
80 |
@text_fade(毫秒) |
每个字淡入的时长,0 = 无淡入 |
300 |
@scroll_mode(模式) |
"auto" 视角跟到最新 / "manual" 视角归读者 |
"auto" |
@sleep(毫秒) |
行与行之间插一段定时停顿 | — |
这四个设定都是有状态的:改一次,直到下次改写或故事重开都持续生效。
@step_mode("line") 让正文像视觉小说那样点一下出一段(等待时正文下方有呼吸的推进三角;点击进行中的段落会让它立即整段显示)。"flow" 恢复默认的连续流动。
@scroll_mode 管的是另一件事——阅读视角。 默认 "auto" 下新内容进来就把视角带到最新处;"manual" 下播放层不主动滚动,正文在读者眼前一行行长出来,读者自己往下读。
@scroll_mode("manual")
@step_mode("flow")
(一整段长信……)
@scroll_mode("auto")
长独白、整段书信、大段场景描写用 manual,读者才能从这段的开头读起;快节奏对话切回 auto。manual 期间下方还有没看到的内容时,正文底部会浮一个可点的向下箭头。
manual只作用于flow。line模式下视角恒跟到最新——那里每一次推进都出自读者点击,新行落在屏幕外就成了「点了没反应」。两个设定各管一件事:@step_mode管推进节奏,@scroll_mode管阅读视角,而「自动推进」正是「视角被带着走」的前提。
@sleep(毫秒) 在行与行、命令与命令之间插一段停顿,用于演出编排:
门,缓缓开了。
@sleep(1500)
里面什么都没有。
@bg_show("assets/night.webp")
@sleep(800)
@bgm_play("assets/theme.mp3")
停顿不可跳过——那是作者钦定的节奏,读者点击无效。它不是暂停点:等待中没有交互、不产生存档语义,读档与重放零等待。
想让停顿落在句子中间(前半句停住、后半句续在同一行),用行内的
<pause>/<pause=毫秒>,见正文写作。@sleep只管行与行之间。无障碍:读者系统开了「减弱动态效果」时整行瞬显、推进三角停止呼吸,覆盖上面的速度设定;但停顿仍然保留——它是叙事节奏,不是动效。
固定区域 @panel
给阅读页加一块独立于正文流的区域,适合 RPG 状态栏、章节指示:
~ let hp = 80
~ let gold = 12
~ let chapter = 1
-> 港口
=== 港口 ===
@panel("left", "<b>状态</b><br>HP: {hp}<br>金币: {gold}")
@panel("bottom", "第 {chapter} 章 · 雾港")
雾从港口涌上来。
* [花掉五枚] -> 花钱
=== 花钱 ===
~ gold -= 5
你买了一盏灯笼。
-> END
运行它、点一下选项:左边的金币数自己变了——不需要重新登记面板。
- 槽位四选一:
"left"/"right"(侧边栏,宽屏贴一侧、窄屏折成横条)、"bottom"(底部条)、"after"(正文后固定栏,随正文滚动、在选项之前)。槽位必须是字符串字面量。 - 模板是字符串,支持
{表达式}插值与全部富文本标签。空模板""= 清空并隐藏该槽。 - 活绑定:引擎在每次推进与每个暂停点重估模板,结果变了才刷新。所以模板表达式必须纯读取——每步都会求值,有副作用就会反复执行。
- 模板里的变量应该是全局变量。模板登记后长期驻留、每步重估,而节点局部变量出了那个节点就不可见了——届时重估会抛「未定义」,若别处恰好有同名局部变量还会静默读到那一个。把状态栏变量声明在开场里即可。
- 面板是显示区:没有打字机揭示、没有交互元素(按钮 / 链接不做,交互仍走选项与输入框)。默认无装饰,要加底色边框就覆盖作品 CSS 的
--kiny-panel-*变量,或直接以.panel-left等为选择器写。
多行模板
模板里的换行符等价于 <br>,所以整张状态栏可以在 ~~~ 块里用反引号写成多行,一个 <br> 都不用写:
~~~
function status_panel() {
return `<b>状态</b>
HP: {hp} / {hp_max}
金币: {gold}`
}
~~~
=== 港口 ===
@panel("left", status_panel())
注意插值写的是 {hp}(Kin 的插值标记),不是 JS 的 ${hp}——函数返回的是模板文本,@panel 登记那一刻调一次函数拿到它,其中的 {} 由引擎在每个事件边界求值,所以这样登记的仍然是活模板。
代价:引擎只对字符串字面量模板做静态检查,函数返回的模板文本里那些 {} 的变量名拼错要到运行期才暴露。
模板是快照,不是响应式绑定
对同一个槽位而言,@panel 登记那一刻选中的那条模板会一直用到下次对该槽 @panel 为止。模板内的 {插值} 每步重估(活的),但选中哪条模板不会自动重选。
要让面板随状态换一套内容,两种写法都对:
(a) 条件写进模板内部——一次登记,长期生效。适合内容形状固定、只是文案随状态变:
@panel("left", "{at_home ? '在家' : '外出'}|HP {hp}")
(b) @if 包多条 @panel——适合两套模板结构差别大的情况。此时必须保证这段代码每回合都被路由经过,即所有分支最终都汇流回这个状态检查节点:
~ let at_home = true
~ let hp = 10
~ let stamina = 5
-> 状态检查
=== 状态检查 ===
@if {at_home}
> @panel("left", "在家|HP {hp}")
@else
> @panel("left", "外出|HP {hp}|体力 {stamina}")
-> 主循环
=== 主循环 ===
你要做什么?
+ [出门]
> ~ at_home = false
> 你走出家门。
> -> 状态检查
+ [回家]
> ~ at_home = true
> 你回到家。
> -> 状态检查
* [睡了] -> END
反例是某个分支直接跳向目标节点、绕开状态检查:
=== 主循环 ===
你要做什么?
+ [探索]
> ~ at_home = false
> -> 探索 ← 绕开了状态检查,那两条 @panel 没再执行
=== 探索 ===
你在野外走了很久。
-> END
at_home 已经改了,但被绕开的 @panel 从未重新登记,面板就停在上一条模板上(一直显示「在家」)。这类问题静态查不出来——某个节点是否每回合可达取决于运行期路径,动态跳转更是无从判定。只能靠「所有分支汇流回一个状态检查节点」的结构来保证。
向读者提问 @input
@input 是唯一的交互命令:它暂停故事、等读者输入一段文本,写回变量。
~ let player_name = "旅人"
@input(player_name, "请输入你的名字")
你好,{player_name}。
- 变量必须预先声明,它的声明值就是空提交时的默认——读者留空或只敲空格直接确认,
player_name保持「旅人」。 - 第一个参数是变量名本身(写入目标),不是表达式。这是命令里唯一的例外。第二个参数(输入框提示)是普通表达式,可以动态。
- 它和选项并列,是故事的另一种交互停顿点。
几条通则
- 命令独占一行、顶格(与
@if同属@这个指令命名空间)。 - 参数是 JS 表达式,引擎求值后传给阅读页。
- 命令不产出任何正文。
- 引擎只认识内置命令集,写一个不存在的
@命令(...)会报错。
接下来
下一篇讲怎么让台词一眼看出是谁在说——角色与台词着色。