4个版本

0.1.7 2024年8月13日
0.1.6 2024年7月31日
0.1.1 2023年11月27日

#176 in 游戏开发

Download history • Rust 包仓库 128/week @ 2024-07-08 • Rust 包仓库 24/week @ 2024-07-15 • Rust 包仓库 158/week @ 2024-07-22 • Rust 包仓库 226/week @ 2024-07-29 • Rust 包仓库 18/week @ 2024-08-05 • Rust 包仓库 167/week @ 2024-08-12 • Rust 包仓库

573 每月下载量
用于 alkyd

MIT/Apache

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 没有这个限制,truefalse 分支可以是不同类型。内部,每当条件变量改变时,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(始终进行递归销毁)不兼容,尽管这可能是未来版本中可以解决的问题。

对于移除子树,您不应销毁单个实体(这会导致混淆),而应依靠条件结构,如CondSwitch

可变:局部状态

在UI代码中,父小部件通常需要跟踪一些局部状态。通常,这种状态需要由创建UI的代码和事件处理程序访问。“可变”是管理局部状态的一种反应式方法。

Mutable<T>是一个引用,它指向存储在Bevy World中的可变数据。由于可变本身只是一个id,它支持克隆/复制,这意味着您可以将它传递给子视图或其他函数。

创建一个新的Mutable是通过实现为CxWorldcreate_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中,有几种不同的方法来处理样式。一种是“命令式样式”,这意味着您明确创建样式组件,如BackgroundColorOutline,并在模板中附加到显示节点。

这种方法的一个缺点是您对从不同来源组合样式的功能有限。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(),它不需要比较函数,因为它要求数组元素实现ClonePartialEq

参考文献

依赖关系

~42–79MB
~1M SLoC