4个版本
0.1.7 | 2024年8月13日 |
---|---|
0.1.6 | 2024年7月31日 |
0.1.1 |
|
#176 in 游戏开发
573 每月下载量
用于 alkyd
245KB
4.5K SLoC
Quill
Quill 是 Bevy 游戏引擎的 UI 框架。它旨在提供一个简单的 API 来构建响应式用户界面,类似于 React 和 Solid 这样的框架,但建立在 Bevy ECS 状态管理的基础上。
Quill 是一个实验性库,借鉴了多个流行的 UI 框架的想法,包括 React.js、Solid.js、Dioxus 和 Xilem。然而,由于需要在 Bevy ECS 的基础上构建,这些想法的实现方式相当不同。
Quill 在样式、布局或 ECS 层次方面相对无偏见 - 您可以使用它来构建传统的 2D 游戏UI,3D中的小工具样式叠加,或响应式场景。然而,Quill 附带了一个独立的 crate,bevy_quill_obsidian
,它提供了一套有偏见的控件来构建游戏编辑器。
入门指南
⚠️ Quill 当前需要不稳定的 Rust 功能
impl_trait_in_assoc_type
。一旦该功能被稳定,这个要求就会消失,预计将在 2024 年底之前完成。
⚠️ Quill 当前处于早期开发阶段,随着其发展可能会发生变化。
目前,您可以运行示例。 "复杂" 示例展示了库的多个功能
cargo run --example complex
愿望/指导原则
- 允许轻松组合和重用层次小部件。
- 建立在现有的 Bevy UI 组件之上。
- 不需要特殊语法,只是 Rust。
- 允许使用响应式钩子,如
use_resource()
将其钩入 Bevy 的变更检测框架。 - 在 Bevy ECS 上构建状态管理,而不是维护自己的单独 UI "世界"。
- 任何数据类型(字符串、整数、颜色等)都可以在 UI 中显示,只要它实现了
View
特性。 - 具有最小内存分配的高效渲染方法。使用借鉴了 React 和 Solid 的混合方法来处理 UI 节点图的增量修改。
- 支持动态样式和动画效果。
基本示例
要创建基本小部件,首先创建一个实现 ViewTemplate
特性的结构。该特性有一个方法,create()
,它接受一个上下文(Cx
)并返回一个 View
。
/// A view template
struct MyWidget;
impl ViewTempate for MyWidget {
type View = impl View;
fn create(cx: &mut Cx) -> Self::View {
// Access data in a resource
let counter = cx.use_resource::<Counter>();
Element::<NodeBundle>::new().children((
format!("The count is: {}", counter.count),
))
}
}
要实际显示此小部件,您需要设置一些东西。
- 在您的应用程序插件中添加
QuillPlugin
。 - 初始化
Counter
资源。 - 创建一个视图根。
视图根是代表 UI 层次结构的 ECS 实体。请注意,实际上存在两个层次结构,一个是“视图层次结构”,它是模板和响应式对象的树,另一个是“显示层次结构”,它是实际在屏幕上渲染的 Bevy 实体。您只需要关注前者,因为显示层次结构是自动构建的。
要为 MyWidget
模板创建根,请在设置系统中使用 Bevy commands
。
commands.spawn(MyWidget.to_root());
视图结构和生命周期
Quill 在三个不同级别上管理视图和模板。
- 视图模板 是应用程序级别的组件,在其依赖项更改时“响应”。如果您曾经使用过
React.js
,则ViewTemplate
相当于 React 的Component
。 - 视图 是较低级别的结构,用于生成 UI 的基本 ECS 构建块,例如实体和组件。视图理解增量更新:如何修补 ECS 层次结构以进行修改而不重新构建整个树。
- 显示节点 是视图创建的实际可渲染 ECS 实体。
每个 ViewTemplate
都有一个名为 create
的方法,当模板首次创建时调用,每次显示需要更新时也会再次调用。理解 create()
的调用方式非常重要,因为这将是与 Quill 一起工作的关键。
create()
通常会多次调用,这意味着create
中的任何代码都需要以可重复的方式编写。幸运的是,Cx
对象具有许多方法可以帮助您做到这一点:例如,如果您想编写只运行一次或只在某些情况下运行的代码,您可以调用cx.create_effect()
。create()
是响应式的,这意味着每当它的依赖项之一更改时,它都会再次运行。例如,当您访问 Bevy 资源或组件时,它会自动将该资源或组件添加到跟踪列表中。如果稍后某个函数修改了该资源或组件,它将触发一个 反应,这将导致create
再次运行。
编写 create()
方法时,重要的是不要泄露内存或资源。例如,编写一个直接调用 material_assets.add()
的 create()
方法将是错误的,因为这会在每次更新时添加一个新的材质,并返回一个新的句柄。相反,您可以在调用 cx.create_memo()
的调用中包装材质初始化,这将允许您在调用之间保留材质句柄。
一般来说,您应该以“主要功能”风格编写模板,最小化副作用。当您确实需要副作用时,有一些特殊方法可供选择,例如 .insert()
或 .create_mutable()
,以帮助您解决问题。
create
的返回值是一个实现了 View
特性的对象。《视图》有更复杂的生命周期,涉及诸如 build()
、rebuild()
和 raze()
等方法,但通常您不需要担心这些。
元素
通常您不需要编写自己的 View
实现,因为这些已经提供了。其中最重要的是 Element
类型,它创建一个单独的显示节点实体。元素有三个方面
- 一个包类型:在创建实体时将插入该实体的包的类型。
- 零个或多个子元素。
- 零个或多个“效果”。
元素的孩子也是视图,使用 View
对象定义。这些由父节点构建,并使用标准的 Bevy 父/子关系作为显示节点的孩子插入。
“效果”是指添加或修改实体 ECS 组件的任何东西,例如
- 添加
bevy_mod_picking
事件处理程序。 - 添加自定义材质。
- 添加动画。
- 添加 ARIA 节点以实现无障碍。
- 应用样式。
子元素和效果使用构建器模式添加,如下例所示
Element::<NodeBundle>::new()
.style(style_panel)
.children((
"Hello, ",
"world!",
))
children()
方法接受单个视图或视图的元组。在这种情况下,我们正在传递纯文本字符串。因为 View
特性有一个针对 &str
的实现,因此这些字符串可以作为视图显示,并将构建适当的 bevy_ui Text
节点。
请注意,ViewTemplates
也实现了 View
,因此您可以在定义子元素时自由混合模板和视图。
在上面的示例中,.style()
方法添加了一个“样式效果”——一个初始化实体样式组件的效果。
.style()
方法是 静态效果 的一个示例,它仅在元素首次创建时应用一次。还有 动态效果,可以多次应用。动态效果通常需要依赖项列表,并且仅在依赖项更改时重新运行效果。例如,以下是一个仅当“焦点”标志(由无障碍性 Focus
资源确定)设置为 true 时才显示“焦点矩形”的样式效果。
.style_dyn(
// This closure only runs when 'focused' changes.
|focused, sb| {
if focused {
sb.outline_color(colors::FOCUS)
.outline_offset(1.0)
.width(2.0);
} else {
sb.outline_color(Option::<Color>::None);
}
},
focused,
)
另一个常用效果是 .insert()
以及其动态对应物 .insert_dyn()
,它们用于插入一个 ECS 组件。还有一个变体,.insert_if()
,当条件为真时插入组件,当条件为假时移除组件——非常适合像 Disabled
这样的标记组件。
.insert()
方法常用于插入 bevy_mod_picking
事件处理器。
更多示例
使用 Cond
进行条件渲染
Cond
(简称“条件”)视图接受一个条件表达式和两个子视图,一个在条件为真时构建,另一个在条件为假时构建。
/// Widget that displays whether the count is even or odd.
struct EvenOrOdd;
impl ViewTempate for EvenOrOdd {
type View = impl View;
fn create(cx: &mut Cx) -> Self::View {
let counter = cx.use_resource::<Counter>();
Element::new().children((
"The count is: ",
Cond::new(counter.count & 1 == 0, "even", "odd"),
))
}
}
注意:对于条件视图,使用常规的 if
语句或 match
是完全可行的,但是有一个限制,即所有分支的结果类型必须相同。 Cond
没有这个限制,true
和 false
分支可以是不同类型。内部,每当条件变量改变时,Cond
都会拆毁之前的分支并初始化新的分支。
通常,“false”分支是一个空的视图,()
,它不渲染任何内容,也不创建实体。
使用 Switch
进行条件渲染
您可以使用 Switch
从多个视图中选择。这与 Bevy 游戏状态配合得很好!
let state = *cx.use_resource::<State<EditorState>>().get();
Switch::new(state)
.case(EditorState::Realm, EditModeRealmControls)
.case(EditorState::Terrain, EditModeTerrainControls)
.case(EditorState::Scenery, EditModeSceneryControls)
.case(EditorState::Meta, EditModeMetadataControls)
.case(EditorState::Play, EditModePlayControls)
使用 For
渲染多个项目
For::each()
接受一个项目列表和一个回调,该回调为每个项目构建一个 View
。
struct EventLog;
impl ViewTempate for EventLog {
type View = impl View;
fn create(cx: &mut Cx) -> Self::View {
let log = cx.use_resource::<ClickLog>();
Element::new()
.children(For::each(&log.0, |item| {
Element::new()
.styled(STYLE_LOG_ENTRY.clone())
.children((item.to_owned(), "00:00:00"))
}).with_fallback("No items")),
}
}
在更新过程中,For
视图将项目列表与之前的列表进行比较,并计算差异。只有实际发生变化的项目(插入、删除和修改)才会重新构建。 For
构造有三个不同的变体,它们在处理项目之间的比较方式上有所不同。
For::each()
要求数组元素实现PartialEq
。For::each_cmp()
接受一个额外的比较器参数,用于比较项目。For::index()
不比较项目,而是使用数组索引作为键。这个版本效率较低,因为项目插入或删除需要重新构建所有子视图。
返回多个节点
通常,ViewTemplate
返回一个单一的 View
。如果您想返回多个视图,请使用元组。
(
"Hello, ",
"World!"
)
这是因为视图的元组也是视图。
销毁(Despawning)
要销毁Quill视图层次结构,只需在根实体上调用.despawn()
即可。请不要调用.despawn_recursive()
,因为这会导致panic。原因是Quill视图层次结构比简单的父子关系更复杂,它依赖于Bevy组件钩子来完成内部清理。
这也意味着Quill UI目前与StateScoped
(始终进行递归销毁)不兼容,尽管这可能是未来版本中可以解决的问题。
对于移除子树,您不应销毁单个实体(这会导致混淆),而应依靠条件结构,如Cond
和Switch
。
可变:局部状态
在UI代码中,父小部件通常需要跟踪一些局部状态。通常,这种状态需要由创建UI的代码和事件处理程序访问。“可变”是管理局部状态的一种反应式方法。
Mutable<T>
是一个引用,它指向存储在Bevy World中的可变数据。由于可变本身只是一个id,它支持克隆/复制,这意味着您可以将它传递给子视图或其他函数。
创建一个新的Mutable
是通过实现为Cx
和World
的create_mutable::<T>(value)
方法来完成的。请注意,通过Cx
创建的可变由当前视图拥有,并在视图销毁时自动销毁。在世界上创建的处理程序不是这样的;您负责删除它们。
访问可变中的数据有几种方法,但都需要某种类型的上下文,以便检索数据。这个上下文可以是Cx
上下文对象或World
。
可变.获取(cx)
可变.获取(世界)
可变.设置(cx,新值)
可变.设置(世界,新值)
由于Mutables
是组件,因此它们也是反应式的:调用mutable.get(cx)
会自动将该可变添加到上下文的跟踪集中。然而,如果您传递一个World
,则不会发生这种情况。
上述的.get()
、.set()
方法假设可变中的数据实现了Copy
。还有.get_clone()
和.set_clone()
方法,它们与实现Clone
的数据类型一起使用。
您也可以通过.update()
就地更新可变,该方法接收一个回调函数,该函数传递一个对可变数据的引用。
钩子方法和Cx对象
在创建视图模板或构建视图时,会传递Cx
上下文对象作为参数。该对象包含对跟踪范围的引用,该范围跟踪当前模板的响应式依赖集合。
Cx
包含一些用于管理状态的方法,称为“钩子函数”。这个“钩子”一词来自React.js,意味着同样的意思:一个提供对与当前模板相关联的隐式状态访问的方法。“隐式状态”指的是你不需要手动分配和释放钩子返回的数据。
钩子结果会根据调用顺序自动进行记忆化:在模板内部第一个调用的钩子将始终返回相同的结果,无论它被调用多少次,第二个钩子也是如此,依此类推。这意味着,然而,每次调用钩子时必须保持相同的顺序——如果你在if语句中条件性地调用钩子,或在循环中调用钩子,这将是一个错误,并会导致恐慌。
以下是一些最常用的钩子:
create_mutable()
已经在上一节中进行了讨论。create_effect(闭包, 依赖项)
运行一个回调,但仅在依赖项
更改时。create_memo(工厂, 依赖项)
返回一个记忆化值,当依赖项
更改时,会重新计算。create_entity()
生成一个新的空实体ID。当模板实例被销毁时,该实体将自动被销毁。create_callback(系统)
注册一个新的单次系统。返回的对象可以传递给子小部件和其他函数,并用于接收事件。
Cx
还有一些额外的不是钩子的方法,因为它们不需要按照特定的顺序调用
use_resource()
返回对指定资源
的引用。use_component()
返回对指定组件
的引用。
Quill Obsidian crate通过添加一些额外的钩子扩展了Cx
特质
is_hovering()
如果鼠标悬停在当前元素上,则返回true。is_focused()
如果元素具有键盘焦点,则返回true。还有其他变体,如is_focus_visible()
。use_element_rect(id)
根据实体ID返回小部件的屏幕矩形。create_bistable_transition(打开)
创建一个简单的状态机,当需要为具有“进入”和“退出”动画的元素进行动画处理时可以使用。
Element::from_entity()和显式实体ID
元素通常在构建时会生成一个新的实体。但是,有些情况下您可能想指定已生成的实体的实体ID。为此,您可以使用Element::for_entity(id)
,而不是使用Element::new()
。
这种用法的一个例子是悬停:当鼠标悬停在元素上时,我们想突出显示元素的颜色。为此,我们希望在构建元素时传递一个“is_hovered”标志作为参数,以便计算适当的样式。但是,计算“is_hovered”需要知道元素的实体ID,而这个ID在元素创建之前是不存在的。
在Obsidian库中,有一个名为is_hovering(id)
的钩子(对Cx
的扩展),如果给定的元素当前正被鼠标悬停,它会返回true。我们可以通过以下步骤设置我们的小部件:
- 使用
id = cx.create_entity()
创建一个新的实体ID。 - 使用
cx.is_hovering(id)
检查悬停。 - 使用
Element::for_entity(id)
根据创建的ID创建一个Element
。 - 在
Element
的构建方法中,使用is_hovered
条件性地应用样式。
响应式上下文和Bevy观察者
虽然目前还没有对观察者的明确支持,但是有一种方法可以从观察者那里触发反应。
您可以通过在owner
实体上插入一个Observer
组件来为Quill的View
创建一个观察者。
let owner = cx.owner();
cx.create_effect(
|world, owner| {
world.entity_mut(owner).insert(Observer::new(callback));
},
owner
);
由于观察者附加到所有者实体上,因此当View销毁时,它也会被销毁。
要触发反应,将一个Commands
注入观察者回调,并调用一个TriggerReaction
命令。此命令手动标记跟踪范围为已更改,将在下一个周期更新。
commands.add(TriggerReaction(owner));
请注意,您不应在响应式回调中调用TriggerReaction
,因为这可能导致无限循环。
样式
在Bevy中,有几种不同的方法来处理样式。一种是“命令式样式”,这意味着您明确创建样式组件,如BackgroundColor
和Outline
,并在模板中附加到显示节点。
这种方法的一个缺点是您对从不同来源组合样式的功能有限。Rust有一个从另一个结构体继承结构值的方法,即..
语法;这假设在声明点已知两个结构值。理想情况下,我们希望有一个通用的机制,允许我们使用“基本”样式,然后在上面添加自定义设置,但又不要求向世界公开基本样式的内部细节。
Quill对样式采取更函数式的方法,使用bevy_mod_stylebuilder
包。此包提供了一个StyleBuilder
接口,允许您使用流畅的构建模式定义Bevy样式。此外,它支持组合:您可以将一组样式(style1, style2, style3)
传递,并且将按顺序应用这三个样式。
在这个系统中,“样式”仅仅是接受一个 StyleBuilder
参数的函数。例如,Obsidian 的 Button
小部件使用了以下样式:
fn style_button(ss: &mut StyleBuilder) {
ss.border(1)
.display(ui::Display::Flex)
.flex_direction(ui::FlexDirection::Row)
.justify_content(ui::JustifyContent::Center)
.align_items(ui::AlignItems::Center)
.align_content(ui::AlignContent::Center)
.padding((12, 0))
.border(0)
.color(colors::FOREGROUND)
.cursor(CursorIcon::Pointer);
}
在视图模板中,可以将多个样式添加到元素中。
element.style((
// Default text styles
typography::text_default,
// Standard Button styles
style_button,
// Custom style overrides
self.custom_styles.clone()
))
为了方便,StyleBuilder
API 支持两种语法:长格式和快捷格式。例如,以下都是等价的:
.border(ui::UiRect::all(ui::Val::Px(10.)))
-- 在所有边上都添加10px的边框.border(ui::Val::Px(10.))
-- 标量会自动转换为矩形.border(10.)
-- 假设Px
是默认单位.border(10)
-- 整数会自动转换为 f32 类型。
类似地,它为颜色、矩形和资源路径等类型提供了许多自动转换。
对于熟悉 CSS 的人来说,会有一些熟悉之处,但是 StyleBuilder
在许多重要方面与 CSS 不同。
- 没有优先级或层叠,因为这往往是网页开发人员困惑的来源(即使是 CSS 本身也在通过新的“CSS 层”功能远离这种做法)。相反,样式将严格按照它们出现在元素上的顺序合并。
- 样式只能影响它们所分配的元素,而不是它们的子元素。
要避免的陷阱
在编写视图模板时,有一些事情需要注意。
收敛 - 视图的重建由一个名为“RCS”的 ECS 系统驱动,代表“反应控制系统”。这个系统具有世界访问权限,并且会循环直到它找到没有更多更改 - 也就是说,当具有更改依赖项的跟踪作用域的数量降到零时。
触发其他反应的反应是完全可以的。这通常发生在对 create_effect()
的调用中。然而,不能有触发 自身 的反应。这将导致无限循环。
为了防止这种情况,RCS 跟踪需要更新的跟踪作用域的数量。只要这个数字继续减少,就没有问题:这意味着我们正在“收敛”,也就是说,反应和依赖关系集合正在平静下来。作用域需要更新的数量也可能增加,或者保持不变,但这种情况应该很少发生。这是“发散”,每个帧允许的发散数量有严格的上限。如果超过这个数量,系统会恐慌。
为了避免过度发散的问题,你应该尽量以清晰分离读取和写入的方式编写模板:模板的主体执行读取,而回调和事件处理器处理突变。在极少数需要在进行设置时执行突变(例如,将组件插入实体)的情况下,你应该编写代码以确保这种突变只执行一次,而不会在模板每次重新执行时重复。
动态视图 - 有时候您可能需要一个由算法计算出的视图,也就是说,您会有一组公式,它会根据某些状态返回不同的视图。Obsidian的“检查器”小部件经常这样做。您可能会想使用.into_view_child()
方法来实现这一点,因为它会对视图进行类型擦除,允许不同类型的视图存储在同一个“槽”中。
不幸的是,在重建期间,这会导致恐慌,因为视图的状态(该状态存储在视图外部,并且通过.into_view_child()
将其类型擦除为Any
)将不再匹配视图的类型。例如,如果您将一个Button
转换为Checkbox
,您将陷入一个Checkbox
视图试图使用由之前的Button
模板生成的旧状态的情况。
为了避免这种情况,您可以使用Dynamic::new(child_view)
包装公式。Dynamic
视图保留了额外的信息,这使得它能够检测子视图类型的更改。当发生这种情况时,它会销毁之前的视图并重新构建新的视图。
深入探讨:for循环
For
视图是给定数据项数组,渲染可变数量子视图的视图。有三种不同的For
循环风格。最简单且效率最低的是index()
循环。这个循环只是简单地渲染数组中每个项目在索引位置。这之所以效率低下,是因为数组可能自上次渲染周期以来有插入和删除操作。因此,如果元素#2变为元素#3,那么for循环就会盲目地覆盖位置#3上的任何现有显示节点,销毁不匹配的节点,并在其位置构建新节点。
接下来是.each_cmp()
,它稍微聪明一些:它接受一个额外的函数闭包,该闭包可以比较两个数组元素。项目可以是任何数据类型,只要它们是可克隆的。然后算法尝试使用LCS(最长公共子串)匹配算法将旧数组节点与新节点匹配。这意味着当数组元素移动时,它将重用前一次渲染的显示节点,最小化更改量。任何插入或删除都将被检测到,并相应地构建或销毁这些位置的节点。
最后,还有.each()
,它不需要比较函数,因为它要求数组元素实现Clone
和PartialEq
。
参考文献
依赖关系
~42–79MB
~1M SLoC