Surface
界面——两种 Surface、导航栈、进出场生命周期,以及游戏舞台如何由它们拼成
一个 Surface 就是一块界面:一个根元素、一份设计尺寸,以及底下的元素树。游戏里的每一块界面都是 Surface——标题菜单、设置面板、剧情说话用的对话框。项目把它们全部放在同一份 UI 文档里,运行时用两种方式呈现:叠在导航栈上,或者挂进正在运行的游戏舞台的某个插槽
两种 Surface
| 类型 | host | 出现在哪里 | 什么时候用它 |
|---|---|---|---|
appSurface | app | 作为导航栈上的一个图层 | 你需要一块玩家会打开、也会离开的界面:标题、设置、存档、画廊 |
stageSurface | player | 挂在运行中游戏舞台的某个插槽里 | 你需要替换掉运行中游戏自带界面的一部分:对话框、选项菜单、通知堆栈 |
没有第三种。两者的记录都带有 id、name、designSize、rootElementId,以及可选的 settings(背景色与页面动画);stageSurface 另外带一个指明插槽的 mount,并且创建时背景是透明的——它压在舞台之上
Studio 的 UI 管理器把它们标为 Page 和 游戏界面,节点名沿用了这套叫法——Go Page 打开的是一个 appSurface
设计尺寸
designSize 属于每个 Surface,而不属于项目。它就是你编排时使用的坐标系:布局按设计像素书写,运行时把整棵树缩放到窗口,并用黑边保持宽高比
- 当前活动的 Surface 的设计尺寸决定视口方框。入口 Surface 的设计尺寸同时也是游戏窗口的初始大小
- 创建 Page 时会让你填画布尺寸,默认值取自文档中已有的 Surface。创建游戏界面则没有尺寸字段——它直接采用项目的设计尺寸,因为它必须和舞台共用同一套坐标系
NarraLeaf 舞台是按游戏会话挂载那一刻处于活动状态的那个 Surface 的设计尺寸建立的——既不取自项目设置,也不取自填在各插槽里的游戏界面 surface。玩家按下 Start Game 时所在的那个 Page 是什么设计尺寸,舞台就是什么设计尺寸
导航栈
运行时维护一个图层栈。图层是某个 Surface 的一个实例,而不是 Surface 本身:它带着自己的 props、自己的运行时 scope、自己的呈现方式。同一个 Surface 打开两次,就得到两个互不相干的图层
- 启动时栈被重置为单个图层——宿主指定的入口 Surface,或文档中第一个
appSurface - 打开即入栈。
Go Page(切换到 Page)把选中的 Page 压入栈,并带上你接到它props输入上的值。Show Layer同样是入栈,并交回一个指代这一次显示的句柄——Hide Layer、Wait For Layer与Is Layer Mounted寻址的就是它。除此之外没有任何东西能指代某一层:叠放次序就是挂载次序,任何界面都无法被写成依赖于自己在第几层 - 四种关闭方式。
Go back只弹出一层。把Go Page的 Page 选择器设为None会清空整个栈。Clear Page只摘掉运行中的游戏叠上去的那些覆盖页,在游戏之外什么也不做。Hide Layer关掉句柄指代的那一层,Close This Layer则关掉当前蓝图所在的那一层,并可以带上一个结果给等着它的人 - 栈底是拆不掉的。 只剩一个图层时关闭会被拒绝,所以入口 Surface 永远垫在最下面,在栈底调用
Go back是空操作而不是错误。签名见 App 节点
呈现方式
每个图层要么是 appPage,要么是 gameOverlay。这由运行时决定,不由作者决定:
- 游戏尚未运行时,
Go Page打开的是appPage图层。透出来的底下是上一个 Page Start Game(开始游戏)和Load Save(读取存档)会隐藏当前栈上的所有图层,并显出游戏舞台。栈仍然持有它们,只是它们不再渲染- 栈被隐藏期间,
Go Page打开的是gameOverlay图层,画在舞台之上。这类图层底下是游戏,而不是你来时的那些 Page——暂停菜单、游戏内存档界面、剧情中途调出的设置,就是这么回事 Quit Game(退出游戏)拆掉会话,并把它的返回 Page 重新以appPage打开——它和其余 Game 节点放在一起。Quit Application(退出应用)则结束运行时;在开发模式中它停止的是开发模式会话,而不是关掉 Studio,并且它刻意不触发窗口关闭事件——那个事件只属于玩家自己关窗口
Is Game Overlay 读的就是这个标记。它锁定在图层实例上,所以一个正在退出的暂停菜单在整段退出动画里都保持 overlay 的答案,哪怕会话已经在被清理。每个插槽 Surface 都回答 true;嵌在 overlay 里的 Page 则继承持有它的那个图层的答案
当底下那层正是被游戏隐藏的图层之一时,关闭一个 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 节点
过渡期间,指针输入和键盘输入并不是被同等拦下的。点击会一直被挡到进入动画结束,并在 Before Surface Exit 时再次被丢弃;而按键从预绘制结束那一刻起,会一路穿过两段动画继续送达。想在过渡中忽略按键的图,必须自己用 Is Surface Transitioning 分支
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
params 是值抵达 Page 的通路:
- Frame 的
params属性就是被嵌入 Page 的 props。该 Page 内部的Get Page Props(读取 Page props)读的就是它 - 对导航栈上的图层来说,props 就是
Go Page传进来的东西。同一个节点,同一次读取——正因如此,一个 Page 不需要知道自己身处何处也能在两种位置上工作 - 非对象的值会规范成
{},取不存在的字段读作null Set 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 的子节点挂载,位于舞台之上。它只在舞台可见时渲染,并且它的外壳刻意是点击穿透的——空白处必须仍然能推进剧情。放一个全屏可交互容器进去,就会把这些点击吃掉
每个插槽 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 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 的设计坐标输出坐标;而控件版只对自己那个元素触发。嵌在 Frame 里的 Page 会先收到自己的 Surface 点击,父 Surface 才会收到
与控件级蓝图的关系:
widgetMain蓝图属于单个控件实例,随它挂载、随它卸载。Init和Unmount是它的生命周期;Surface 的则是Surface Init和Surface Unmount- 键盘事件 Head 会派发给全局蓝图、当前活动的 Surface 蓝图,以及每一个声明了该 Head 的已挂载控件。三处都放同一个 Head,三处都会执行
- Element 节点是 Surface 蓝图操作自己并不拥有的控件的方式——这正是 Self / Element 之分存在的全部理由