NarraLeaf

脚本

把一个槽位的逻辑写成项目自己拥有的 TypeScript 文件——这些文件放在哪里、脚本从哪里进入,以及它能触及什么

页面、控件或剧情行的逻辑是一份图层列表,每一层要么是蓝图——画布上的一张图,要么是脚本——项目自己拥有的一个 TypeScript 文件。列表里的每一层都会运行,所以同一个位置上脚本可以和图并存,两者都会响应同一个事件

脚本不是蓝图的一种,这两个词之间也从不互相修饰。蓝图是一张图,在 Studio 的画布上编辑;脚本是磁盘上的一个文件,在作者自选的编辑器里编辑。画布旁边的图层列表会标明每一层装的是其中哪一种

文件放在哪里

<project>/scripts/ 是项目中唯一一个不归 Studio 所有的目录。在这个目录里,磁盘说了算:Studio 只读取和监视,从不持有一份副本再写回去。在这个目录之外,Studio 说了算

Studio 在这个目录里只占用两个名字,并在它们旁边生成一个文件

scripts/ 中的名字归属是什么
.narraleaf/Studio生成的类型声明
node_modules/作者由作者自己安装
tsconfig.jsonStudio生成的文件,带有「请勿编辑」的头注释
package.json作者只读取其中的依赖清单,从不写入
其余一切作者由作者自行安排:一个脚本就是 scripts/title.ts,想要分目录就是 scripts/menus/title.ts

Studio 从不运行包管理器

一次安装会执行各依赖的 postinstall 脚本,而「构建过程不执行任何第三方代码」是这项功能不会放弃的一条保证。Studio 只打包磁盘上已有的东西——esbuild 读取这些字节,并不执行它们——缺失的依赖会作为一条诊断报出,并写明该运行哪条命令

TypeScript 不是强制的

.ts.js 都是脚本源文件。esbuild 会剥掉类型,从不检查它们,所以一个 .js 文件就是一个放弃了类型检查的脚本。两者只靠扩展名区分,除此之外没有任何区别

添加与移除脚本

在蓝图编辑器中,图层列表上方的新建会添加一层,并问它是其中哪一种。选脚本会列出 scripts/ 下的全部文件,第一项是新建脚本…,其余每一项都标明已经被什么运行——两层共用一个文件是一种正当的安排,不是错误

**新建脚本…**会写出一个起始文件,以它所填的槽位命名——一个叫 Key art 的控件得到 scripts/key-art.ts——此后再也不会重写这个文件。选一个已经在那里的文件,则完全不写任何文件

图层可以从槽位上移除。文件会留在磁盘上——Studio 只写过它一次,从那一刻起它就归项目所有——随后它出现在脚本分区里,标为没有任何逻辑使用的文件

脚本图层也可以改指向 scripts/ 下的另一个文件,入口是该行上的改用其他文件。在作者自己的编辑器里改过名的文件,就是这样重新接上的

编辑

选中一个脚本图层,图层画布的位置显示的就是它的源码,只读。Studio 没有自己的编辑器:在磁盘所有的文件上再放一个编辑器,等于放了第二个写入方

在编辑器中打开会打开整个 scripts/ 目录,并在其中选中该文件,每一种目标都是如此——已检测到的编辑器、文件管理器,或系统关联程序。打开的是目录而不是文件,因为类型是从脚本旁边的 tsconfig.json.narraleaf/ 解析出来的,只打开单个文件一个也解析不到

在哪里找到它们

资产面板脚本分区列出 scripts/ 下的全部源文件、每个文件由哪段逻辑运行,以及哪些文件没有任何逻辑使用

它不是一个资产分类。脚本没有 id、没有元数据,也不进入任何资产集

脚本从哪里进入

脚本通过它导出的函数进入。导出名由事件按一条规则得出:mouseClick 对应 onMouseClick

当前位置会调用哪些名称,取决于它所处的位置,而每个起始文件的头两行都列出了这些名称

逻辑所在位置它会调用的导出名包括
项目onAppBootonGameReadyonKeyDownonKeyUponPreferenceChangedonFullscreenChangedonWindowFocusChangedonWindowCloseRequestedonAction
页面onSurfaceInitonSurfaceUnmountonBeforeSurfaceExitonAfterSurfaceEnteronBroadcastonElementClick
控件onInitonUnmountonFlushonMouseClickonMouseEnteronMouseLeave,以及该类型的其余事件

导出名如果不在当前位置会调用的名单里,它就只是永远不会被调用

剧情行

剧情行是例外:它通过默认导出进入脚本,因为一行没有事件

动作可以 await。内联值和分支条件是在故事无法等待的地方求值的,所以它们返回一个值而不是等待一个值;从这两者中返回一个 promise 会被拒绝并报出诊断,而不是被渲染出来

类型

类型来自脚本旁边生成的声明文件。它们从 @narraleaf/script 导入,并且用 import type,因此构建过程从不去解析任何包

上下文类型交给谁
GlobalCtx项目脚本
SurfaceCtx页面脚本
WidgetCtx<W>控件脚本
ComponentWidgetCtx<W>组件内的控件脚本
StoryCtx剧情动作
StorySyncCtx内联值或分支条件

每个处理函数各自写明自己的类型

import type { WidgetCtx, ScriptEvent } from "@narraleaf/script";

export function onMouseClick(ctx: WidgetCtx<"nl.button">, event: ScriptEvent<"mouseClick">): void {
    ctx.host.devtools.log("info", "clicked");
}

脚本能做什么

脚本能做什么,就是它所在的槽位能做什么。上下文给出的能力,和等价的那张图被给予的完全相同,不会多出任何一项

  • 页面脚本和控件脚本拿到宿主 API、它自己的 vars 存储、一个 AbortSignal,以及——当槽位位于某个界面上时——broadcast 和界面进出场状态的读取器
  • 剧情脚本拿到故事自己的场景变量、存档变量、应用持久化,以及写一行日志用的 ctx.devtools。这一档没有 ctx.host,能触及的成员都直接挂在上下文上。除此之外什么也没有:没有导航、没有游戏控制、也接触不到控件

Blueprint Value

Blueprint Value 只支持蓝图。值绑定在任何一个依赖变化时都会重新求值,而且必须交回一个值,所以那个槽位不提供脚本

出问题的时候

编译失败的文件,以及没有导出当前位置会调用的任何名称的文件,都会在开发模式中报出,且报在出问题的那个文件上

开发模式的蓝图列表会写明某个槽位运行的是哪个文件、模块是否已加载,并把该文件导出了什么与当前位置实际调用什么并排列出。这就是「为什么什么都没发生」的全部答案,因为编译失败和一个拼成 onClik 的处理函数,从别处是分辨不出来的

脚本和其他逻辑一样会被编译进构建产物。它们身上没有任何东西是仅限开发期的

本页目录