演出命令

画面与声音、正文里的图与线、呈现节奏、固定区域、向读者提问——按用途分组的全部内置命令。

命令用 @ 开头、写成函数调用的样子,独占一行、顶格。它们不产出正文,而是向阅读页下达指令:换背景、放音乐、插一张图、改推进节奏。

=== 酒馆 ===
@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,读者才能从这段的开头读起;快节奏对话切回 automanual 期间下方还有没看到的内容时,正文底部会浮一个可点的向下箭头。

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 表达式,引擎求值后传给阅读页。
  • 命令不产出任何正文
  • 引擎只认识内置命令集,写一个不存在的 @命令(...) 会报错

接下来

下一篇讲怎么让台词一眼看出是谁在说——角色与台词着色