Surface
界面:两种 Surface、导航栈、进出场生命周期,以及游戏舞台如何由它们拼成
一个 Surface 就是一块界面:一个根元素、一份设计尺寸,以及底下的元素树。游戏里的每一块界面都是 Surface——标题菜单、设置面板、剧情说话用的对话框。项目把它们全部放在同一份 UI 文档里,运行时用两种方式呈现:叠在导航栈上,或者挂进正在运行的游戏舞台的某个插槽
屏幕上的叠放次序,以及开始游戏、打开页面、退出游戏时它如何变化,在运行时的界面中逐步画出。本页是这些规则的参考
两种 Surface
| 类型 | host | 出现在哪里 | 什么时候用它 |
|---|---|---|---|
appSurface | app | 作为页面栈上的一个页面,或作为页面之上的叠加页面 | 你需要一块玩家会打开、也会离开的界面:标题、设置、存档、画廊 |
stageSurface | player | 挂在运行中游戏舞台的某个插槽里 | 你需要替换掉运行中游戏自带界面的一部分:对话框、选项菜单、通知堆栈 |
没有第三种。两者的记录都带有 id、name、designSize、rootElementId、可选的 settings(背景色、背景图与页面动画),以及可选的 actions(它回应的项目输入意图);stageSurface 另外带一个指明插槽的 mount,并且创建时背景是透明的——它压在舞台之上
Studio 的 UI 管理器把它们标为 Page 和 游戏界面,节点名沿用了这套叫法——Go Page 打开的是一个 appSurface
设计尺寸
designSize 属于每个 Surface,而不属于项目。它就是你编排时使用的坐标系:布局按设计像素书写,运行时把整棵树缩放到窗口,并用黑边保持宽高比
- 当前活动的 Surface 的设计尺寸决定视口方框。入口 Surface 的设计尺寸同时也是游戏窗口的初始大小
- 创建 Page 时会让你填画布尺寸,默认值取自文档中已有的 Surface。创建游戏界面则没有尺寸字段——它直接采用项目的设计尺寸,因为它必须和舞台共用同一套坐标系
NarraLeaf 舞台是按游戏会话挂载那一刻处于活动状态的那个 Surface 的设计尺寸建立的——既不取自项目设置,也不取自填在各插槽里的游戏界面 surface。玩家按下 Start Game 时所在的那个 Page 是什么设计尺寸,舞台就是什么设计尺寸
导航栈
导航由两个栈组成:Go Page 打开的页面,与 Show Layer 显示的叠加页面。叠加页面总是画在所有页面之上。两个栈上的每一项都是某个 Surface 的一个实例,而不是 Surface 本身:它带着自己的 props 和自己的运行时 scope。同一个 Surface 打开两次,就得到两个互不相干的实例
- 启动时页面栈只有一个页面:入口页面
- 打开即入栈。
Go Page(前往页面)把选中的 Page 压入页面栈,并带上它的输入传入的参数和props,新页面取代屏幕上原来的页面。Replace Page(替换页面)以同样的方式打开页面,然后把被它替换的页面移出栈,所以Go back和设为None的Go Page都不会回到那个页面。Show Layer(叠加页面)压入叠加页面栈,并交回一个指代这一次显示的句柄:Hide Layer、Wait For Layer与Is Layer Mounted寻址的就是它。除此之外没有任何东西能指代某个叠加页面:叠放次序就是挂载次序,任何界面都无法被写成依赖于自己在第几层。叠加页面位于所有页面之上,包括在它之后打开的页面 - 模态叠加页面。 模态的叠加页面会让它下面的一切都不再接收输入,并持有键盘。非模态的叠加页面不影响它下面的页面。声明同一分组的叠加页面排队而不叠放,并且显示某个叠加页面的那个 Surface 关闭时,该叠加页面也随之关闭
- 五种关闭方式。
Go back(返回上一页)在最上面的叠加页面可关闭时关掉它,否则只弹出一个页面。把Go Page的 Page 选择器设为None会把页面栈清空到只剩第一个页面。Clear Page(关闭全部页面)只关闭运行中的游戏里打开的那些页面,在游戏之外什么也不做。Hide Layer关掉句柄指代的那个叠加页面,Close This Layer则关掉当前蓝图所在的那个叠加页面,并可以带上一个结果给等着它的人 - 栈底保持不动。
Go back、设为None的Go Page和Clear Page都不会关闭页面栈上的第一个页面。游戏启动时它是入口页面,Quit Game之后是Quit Game打开的页面。在它显示于屏幕上时运行Replace Page,该节点打开的页面会取代它成为第一个页面,启动画面就是这样把游戏交给标题页面的。在栈底调用Go back是空操作而不是错误。签名见 App 节点
呈现方式
每个页面要么是 appPage,要么是 gameOverlay。这由运行时决定,不由作者决定:
- 游戏尚未运行时,
Go Page打开的是appPage页面。屏幕上只画最上面的页面,下面的页面留在栈上,供Go back返回 Start Game(开始游戏)和Load Save(读取存档)会隐藏当前栈上的所有页面、关闭所有叠加页面,并显出游戏舞台。栈仍然持有这些页面,只是它们不再渲染- 栈被隐藏期间,
Go Page打开的是gameOverlay页面,画在舞台之上。这类页面底下是游戏,而不是你来时的那些页面:暂停菜单、游戏内存档界面、剧情中途调出的设置都属于这一类。这类页面显示在屏幕上时,dialog、nvl、choice和onStage插槽的 Surface 会淡出(仍保持挂载),并在它关闭时淡入回来;notification插槽保持显示。叠加页面不会隐藏它们 - 叠加页面在显示时以同样的方式打上标记:栈被隐藏期间用
Show Layer显示的是gameOverlay叠加页面,其他时候显示的是appPage叠加页面 gameOverlay页面或叠加页面把自己的背景画成略微透明,让场景透出来。它上面的控件保持原有的不透明度Quit Game(退出游戏)拆掉会话,把它的返回 Page 重新以appPage打开,并关闭它下面的所有页面。它和其余 Game 节点放在一起。Quit Application(退出应用)则结束运行时;在开发模式中它停止的是开发模式会话,而不是关掉 Studio,并且它刻意不触发窗口关闭事件,那个事件只属于玩家自己关窗口
在页面或叠加页面自己的蓝图里,Is Game Overlay 读的就是这个标记。它锁定在实例上,所以一个正在退出的暂停菜单在整段退出动画里都保持 overlay 的答案,哪怕会话已经在被清理。每个插槽 Surface 都回答 true;嵌在 overlay 里的 Page 则继承持有它的那个页面的答案。应用逻辑不属于任何页面,在那里该节点回答的是运行中的游戏是否被页面或模态叠加页面盖住,见 Is Game Overlay
当底下那个页面正是被游戏隐藏的页面之一时,关闭一个 gameOverlay 只会播放退出动画并显出舞台。没有东西进场,因为底下没有东西在渲染
生命周期
一个页面或叠加页面,按顺序:
- 它以隐藏状态挂载并绘制一次。控件的
Init(元素初始化)在这一趟隐藏绘制中执行,因此首帧的改动会在任何东西可见之前落定 - 预绘制结束 →
Surface Init(当前 Surface 初始化)。它按运行时 scope 只触发一次;再次打开同一个 Surface 是一个新 scope,所以会再次触发 - 进入动画播放
After Surface Enter(进入动画结束后)——在动画结束之后。没有动画、或处于 reduced motion 时,它紧跟预绘制触发。每次进场恰好一次,无论内部是哪条路径报告了完成Before Surface Exit(退出动画开始前)——在退出动画开始之前- 退出动画播放,随后卸载 →
Surface Unmount(当前 Surface 卸载)
三个纯节点把同一件事报成状态,而不是报成边沿:
| Node | true 的时段 |
|---|---|
Is Surface Entering | 从挂载起,到进入动画结束为止 |
Is Surface Exiting | 从 Before Surface Exit 起,到卸载为止 |
Is Surface Transitioning | 上面两者之一成立时 |
状态在对应事件 Head 执行之前就已经翻转。在 After Surface Enter 内部,Is Surface Entering 已经读到 false;在 Before Surface Exit 内部,Is Surface Exiting 已经读到 true。用它们问此刻正在发生什么;用事件 Head 问什么时候发生的。在完全没有动画层的环境里,三者都读作 false
Before Surface Exit 和 After Surface Enter 也会送达已挂载控件的私有蓝图——只送给那一刻还活着的那些。Surface Init 和 Surface Unmount 不会;它们属于 Surface。控件自己的那一对是 Init 和 Unmount。全部事件 Head 列在 Events 节点
页面或叠加页面从显示出来的那一刻起、贯穿进入动画都接收指针和键盘输入,并在退出动画开始时两者都停止接收。想在进场期间忽略输入的图,必须自己用 Is Surface Entering 分支。播放退出动画期间,没有其他页面或叠加页面接收的点击会被丢弃,不会落到游戏舞台上,见事件冒泡
Surface、Page 与 Frame
nl.frame 是一个把另一个 app Surface 渲染进当前 Surface 的控件。它显示的东西叫 Page
Page 是一种角色,不是另一种文档类型。同一个 appSurface 可以在这里作为页面或叠加页面,在那里作为某个 Frame 里的 Page。Frame 也不是 iframe:目标是通过同一套 renderer、在同一份文档中渲染的
- 目标必须是另一个 app Surface。把 Frame 指向自己、指向游戏界面、指向缺失的 Surface,或者指向任何会绕回持有该 Frame 的那个 Surface 的目标,都会被拒绝,Frame 显示占位提示而不是让渲染崩掉
- 每个 Frame 实例拥有自己的运行时 scope,由父 scope、Frame 元素和目标共同推导。两个显示同一个 Page 的 Frame 不共享 surface state、不共享控件 locals、也不共享生命周期。被嵌入的 Page 在 Frame 首次挂载它时触发自己的
Surface Init,在目标变化或 Frame 消失时触发Surface Unmount
值通过 Page 在属性中声明的参数抵达 Page,见页面参数:
- 对页面和叠加页面来说,值由
Go Page、Replace Page、Show Layer或Show Confirm传入。前三个节点为所选页面的每个参数各长出一个输入 - Frame 的
params属性就是被嵌入 Page 的 props。它的属性面板为目标 Page 的每个参数各显示一个字段 - 在 Page 内部,文本或按钮在页面参数中显示文字参数,List 显示列表参数,
Get Page Param读取任意参数。未传入的参数读取默认值。无论作为页面、叠加页面还是在 Frame 中,同一个页面都以相同方式读取参数 Get Page Props读取整个对象:已声明的参数及其默认值,以及Page props输入或 Frame 传入的其他字段。非对象的值会规范成{},取不存在的字段读作nullSet Frame Page(切换 Frame 的 Page)切换目标,并可以在同一次调用里写入 params;没有传入参数且 props 输入不接线时,原有 params 保持不变。参见 Frame 节点- 子级用
Emit Page Event(发送 Page 事件)回话,由 Frame 元素自身蓝图上的Page Event事件 Head 接收。单独显示的页面或叠加页面没有父 Frame,在那里发送谁也收不到
舞台插槽
剧情运行期间,舞台归 NarraLeaf 所有,Studio 把你的游戏界面 surface 交给它。一共五个插槽,每个插槽一个生效的游戏界面
| Slot | 接管什么 | 在这里做什么 |
|---|---|---|
dialog | 对话框 | 说话人名与一个 Sentence,外加推进当前行的接线 |
choice | 选项菜单 | 一个基于 { text, index, disabled } 的 Choice List,把 Item Click 接到 Select Choice |
notification | 通知堆栈 | 一个基于 { id, message } 的 Notification List |
nvl | 全屏 NVL 模式对话 | 一个基于 { nametag, isActive, index } 的 NVL List,外加 NVL Texts |
onStage | 什么都不接管——它是额外的一层 | 压在运行中舞台上的 HUD |
空着的插槽会继续使用 NarraLeaf 自带的内置组件,所以删掉你的 Dialog surface 并不会让游戏没有对话框。插槽选择器会禁用已被占用的插槽;万一某份文档里同一个插槽有两个 Surface,运行时按文档顺序取第一个并发出警告
onStage 是这张表里的例外。NarraLeaf 没有给它留注入点,所以它作为 Player 的子节点挂载,位于舞台之上。它只在舞台可见时渲染,并且它的外壳刻意是点击穿透的——空白处必须仍然能推进剧情。放一个全屏可交互容器进去,就会把这些点击吃掉
notification 插槽只用于显示:它上面没有任何东西接收点击,落在通知上的点击会交给它下面的东西,它的 Surface 蓝图也收不到按键和输入意图
每个插槽 Surface 都跑在自己的运行时 scope 里,由游戏会话、插槽和 Surface 三者作键,并且 Is Game Overlay 在它们内部一律为 true
Surface 蓝图
每个 Surface 有一份蓝图,宿主为 surfaceMain。它的存活时长恰好等于挂载它的那个页面、叠加页面或 Frame 实例:把一个 Surface 打开两次,它的蓝图就跑两遍,locals 分开、surface state 也分开
它能触及什么:
- 自己的元素。
Element字面量存的是{ surfaceId, elementId, elementType },从构造上就限定在同一个 Surface 内。没有任何节点能伸进另一个 Surface 的树 - 自己的 surface state,以实例的运行时 scope 作键,因此同一个 Surface 的两个实例永不冲突。作用域与生命周期见变量
- 自己的参数和 props,通过
Get Page Param和Get Page Props - 一切本来就不按 Surface 划分的东西:持久变量、全局状态、存档、正在运行的游戏
它的事件 Head:Surface Init、Surface Unmount、Before Surface Exit、After Surface Enter、Mouse Click(鼠标点击)、Right Click(鼠标右键点击)、On Action、键盘事件 Head、Game Preference 变化事件 Head、全屏变化与窗口焦点变化、窗口关闭请求、广播接收 Head,以及两个绑定元素的 Head
Mouse Click 和 Right Click 在这里的行为和在控件上不同:在 Surface 上,它们对落在该 Surface 上的任意点击触发,并按该 Surface 的设计坐标输出坐标;而控件版对自己那个元素以及它内部的元素触发。Page 只在绘有它的元素的地方接收点击,所以点在 Page 空白处的点击不属于这个 Page。显示在 Frame 里的 Page 在点击落在它的元素上时,以自己的设计坐标执行自己的 Surface Mouse Click 与 Right Click,随后持有该 Frame 的 Surface 也会收到这次点击。点在被显示 Page 空白处的点击只有持有该 Frame 的 Surface 收到
与控件级蓝图的关系:
widgetMain蓝图属于单个控件实例,随它挂载、随它卸载。Init和Unmount是它的生命周期;Surface 的则是Surface Init和Surface Unmount- 键盘事件 Head 先派发给应用逻辑,再派发给持有键盘的 Surface 以及它上面每一个声明了该 Head 的控件。三处都放同一个 Head,三处都会执行。哪个 Surface 持有键盘,见键盘
- Element 节点是 Surface 蓝图操作自己并不拥有的控件的方式——这正是 Self / Element 之分存在的全部理由