Rust UI 框架:Rust Slint 实现的记忆图块游戏

7,931 阅读12分钟

Slint 1.0 已发布,标志着项目已顺利从开发阶段“毕业”,可正式用于生产环境。

Slint 曾经被称为 SixtyFPS,特点是既快又小,是一个 Rust 编写的综合性 UI 工具包,可用于为各种操作系统和处理器架构开发 UI,包括 Linux、macOS、Windows、WebAssembly、Blackberry QNX 和裸机。它允许开发人员为嵌入式和桌面应用程序创建原生用户界面。其开发团队希望将 Slint 打造成下一代 GUI 工具包,因此从头开始构建项目时就考虑了可扩展性、包容性、工具性和跨平台兼容性。

上一节对 Slint 做了简单介绍,本节将通过一个例子进一步学习 Slint UI 框架。

介绍

本教程将通过 Slint + Rust 实现一个小游戏:图块记忆配对,以一种有趣的方式向你介绍 Slint UI 框架。本例将把.slint语言实现 UI 与 Rust 实现的游戏规则结合起来。

游戏由 16 个矩形方块组成的网格。单击图块会显示下面的图标。我们知道总共有 8 个不同的图标,因此每个图块在网格中的某处都有一个相同图标的图块。目标是找到所有图块的配对。用户可以同时揭开两块图块。如果它们不相同,图标将再次被遮盖。如果两个具有相同图标的图块,那么它们已配对。

游戏在运行中的样子大概是这样:

memory_clip.gif

入门

首先,创建一个新的 cargo 项目:

cargo new slint-memory
cd slint-memory

然后我们Cargo.toml使用以下方式编辑以添加 slint 依赖项cargo add

cargo add slint@1.0.0

最后,我们将Slint 文档 中的 hello world 程序复制到的src/main.rs

fn main() {
    MainWindow::new().unwrap().run().unwrap();
}

slint::slint! {
    export component MainWindow inherits Window {
        Text {
            text: "hello world";
            color: green;
        }
    }
}

我们运行此示例cargo run,将出现一个带有绿色“Hello World”问候语的窗口。

显示 Hello World 的初始教程应用程序的屏幕截图

一个记忆图块

骨架就位后,让我们来看看游戏的第一个元素:记忆图块,它由底层填充色值的矩形背景、图标图像组成。背景矩形的宽和高声明为 64 逻辑像素,并填充了舒缓的蓝色调。这里是px是 Slint 语言中尺寸大小的单位。

代码如下:

fn main() {
    MainWindow::new().unwrap().run().unwrap();
}

slint::slint! {
     component MemoryTile inherits Rectangle {
        width: 64px;
        height: 64px;
        background: #3960D5;

        Image {
            source: @image-url("icons/bus.png");
            width: parent.width;
            height: parent.height;
        }
    }

    export component MainWindow inherits Window {
        MemoryTile {}
    }
}

Rectangle中,我们放置了一个Image元素,该元素使用@image-url()宏加载图标。使用slint!宏时,路径是相对于 Cargo.toml 所在文件夹的。

注:可以下载Zip 存档并将其解压缩,解压icons包含一堆图标。

curl -O https://slint-ui.com/blog/memory-game-tutorial/icons.zip
unzip icons.zip

运行程序,cargo run屏幕上会出现一个窗口,显示蓝色背景上的公交车图标。

第一个磁贴的屏幕截图

设置图块交互样式

接下来,添加一个像盖子一样的窗帘,当点击时它会打开。通过在Image下方定义两个矩形来实现这种样式和交互。TouchArea元素声明一个透明的矩形区域,允许对用户输入(例如鼠标单击或点击)做出反转动作。它可以将回调传递到单击图块的 MainWindow, 在 MainWindow 中,定义 open_curtain 属性来表示反转动作,它可以控制窗帘的显示和隐藏。这两种状态详见下表:

open_curtain 值:falsetrue
Left curtain rectangle通过将宽度width设置为父宽度的一半来填充左半部分宽度为零使矩形不可见
Right curtain rectangle通过将x和宽度设置为父宽度的一半来填充右半部分宽度为零使矩形不可见。x向右移动,以在动画时滑动窗帘打开

为了使我们的 tile 可扩展,硬编码的图标名称被替换为一个图标属性,该属性可以在实例化元素时从外部设置更新。还需要添加了一个已配对的状态属性,当找到一个配对图块时,将颜色设置成绿色+阴影。将slint!宏中的代码替换为以下内容:

component MemoryTile inherits Rectangle {
    callback clicked;
    in property <bool> open_curtain;
    in property <bool> solved;
    in property <image> icon;

    height: 64px;
    width: 64px;
    background: solved ? #34CE57 : #3960D5;
    animate background { duration: 800ms; }

    Image {
        source: icon;
        width: parent.width;
        height: parent.height;
    }

    // Left curtain
    Rectangle {
        background: #193076;
        x: 0px;
        width: open_curtain ? 0px : (parent.width / 2);
        height: parent.height;
        animate width { duration: 250ms; easing: ease-in; }
    }

    // Right curtain
    Rectangle {
        background: #193076;
        x: open_curtain ? parent.width : (parent.width / 2);
        width: open_curtain ? 0px : (parent.width / 2);
        height: parent.height;
        animate width { duration: 250ms; easing: ease-in; }
        animate x { duration: 250ms; easing: ease-in; }
    }

    TouchArea {
        clicked => {
            // Delegate to the user of this element
            // 请注意代码中的`root`指的是组件中最外层的元素。
            root.clicked();
        }
    }
}

export component MainWindow inherits Window {
    MemoryTile {
        icon: @image-url("icons/bus.png");
        clicked => {
            self.open_curtain = !self.open_curtain;
        }
    }
}

运行它会在屏幕上显示一个带有矩形的窗口,单击它会打开并向我们显示公交车图标。随后的点击将关闭并再次打开窗帘。

polishing-the-tile.gif

从单个图块到多个图块

在对单个图块建模之后,然后再看下如何创建的多个图块组成的网格图块。为了让网格成为我们的游戏板,我们需要两个特征:

  1. 数据模型:这应该是一个数组,其中每个元素都描述了图块数据结构,例如图像的 url、图像是否可见以及该图块是否已配对。
  2. 一种使用上述标记代码创建多图块实例的方法。

在 Slint 中,可以使用括号声明一个结构数组,以创建一个数据模型。使用for循环来创建同一元素的多个实例。在.slint中 for..in 循环中声明的数据模型,在数据模型更改时自动更新。我们实例化所有不同的MemoryTile元素,并根据它们的索引将它们放置在一个网格上,并在图块之间留出一点间距。

首先,复制 tile 数据结构定义并将其粘贴到slint!宏内部的顶部:

// Added:
struct TileData {
    image: image,
    image_visible: bool,
    solved: bool,
}

component MemoryTile inherits Rectangle {
....
....
}

接下来,将宏底部的导出组件 MainWindow inherits Window { ... } 部分替换为以下代码片段:

export component MainWindow inherits Window {
    width: 326px;
    height: 326px;

    in property <[TileData]> memory_tiles: [
        { image: @image-url("icons/at.png") },
        { image: @image-url("icons/balance-scale.png") },
        { image: @image-url("icons/bicycle.png") },
        { image: @image-url("icons/bus.png") },
        { image: @image-url("icons/cloud.png") },
        { image: @image-url("icons/cogs.png") },
        { image: @image-url("icons/motorcycle.png") },
        { image: @image-url("icons/video.png") },
    ];
    for tile[i] in memory_tiles : MemoryTile {
        x: mod(i, 4) * 74px;
        y: floor(i / 4) * 74px;
        width: 64px;
        height: 64px;
        icon: tile.image;
        open_curtain: tile.image_visible || tile.solved;
        // propagate the solved status from the model to the tile
        solved: tile.solved;
        clicked => {
            tile.image_visible = !tile.image_visible;
        }
    }
}

定义了一个数组数据memory_tiles,然后用for tile[i] in memory_tiles:遍历数组,并且设置每个图块的属性。

运行它会为我们提供一个显示 8 个图块的窗口,这些图块可以单独打开。

from-one-to-multiple-tiles.gif

在 Rust 处理图块数据

游戏中的方块应该随机放置。我们需要使用命令为随机化添加rand依赖项 :

cargo add rand@0.8

首先需要获取用 .slint 语言声明的图块列表,复制一份,然后随机混合一下打乱它。slint 中对于每个一级属性,都会生成一个 getter 和一个 setter 函数——在我们的例子中是get_memory_tilesset_memory_tiles。由于memory_tiles是.slint中的一个数组,它表示为一个Rc<dyn slint::Model>,我们无法修改 .slint 生成的数据,但可以从中获取图块数组数据,并将其放入VecModel实现Model特征的对象中, VecModel允许我们进行修改,可以用它来替换静态生成的数据。

修改 main 函数如下:

fn main() {
    use slint::Model;

    let main_window = MainWindow::new().unwrap();

    // Fetch the tiles from the model
    let mut tiles: Vec<TileData> = main_window.get_memory_tiles().iter().collect();
    // Duplicate them to ensure that we have pairs
    tiles.extend(tiles.clone());

    // Randomly mix the tiles
    use rand::seq::SliceRandom;
    let mut rng = rand::thread_rng();
    tiles.shuffle(&mut rng);

    // Assign the shuffled Vec to the model property
    let tiles_model = std::rc::Rc::new(slint::VecModel::from(tiles));
    main_window.set_memory_tiles(tiles_model.into());

    main_window.run().unwrap();
}

运行它会在屏幕上显示一个 4 x 4 矩形网格的窗口,单击时可以显示或隐藏图标。现在只缺少最后一个环,即游戏规则。

creating-the-tiles-from-rust.gif

添加游戏规则逻辑

Slint 的一般理念是,.slint实现用户界面,业务逻辑用自己喜欢的编程语言实现(比如 Rust )。游戏规则应强制要求最多两块牌的窗帘打开。如果图块匹配,那么我们认为它们已配对并且它们保持打开状态。否则等一会儿,这样玩家就可以记住图标的位置,然后再次关闭它们。

需要对MainWindow进行两处更改:

  1. 为 MainWindow 添加一种调用 Rust 代码的方法,它应该检查是否已经配对了一对图块。
  2. 添加一个 Rust 代码可以切换的属性,以禁用进一步的方块交互,以防止玩家打开比允许的更多的方块。不允许作弊!

首先,在 MainWindow 中添加检测是否配对的方法和是否是禁用状态的属性:

export component MainWindow inherits Window {
    width: 326px;
    height: 326px;

    callback check_if_pair_solved(); // Added
    in property <bool> disable_tiles; // Added

    in-out property <[TileData]> memory_tiles: [
       { image: @image-url("icons/at.png") },

然后,在 MainWindow 中添加以下逻辑代码:

for tile[i] in memory_tiles : MemoryTile {
    x: mod(i, 4) * 74px;
    y: floor(i / 4) * 74px;
    width: 64px;
    height: 64px;
    icon: tile.image;
    open_curtain: tile.image_visible || tile.solved;
    // propagate the solved status from the model to the tile
    solved: tile.solved;
    clicked => {
        // old: tile.image_visible = !tile.image_visible;
        // new: 
        if (!root.disable_tiles) {
            tile.image_visible = !tile.image_visible;
            root.check_if_pair_solved();
        }
    }
}

在 Rust 方面,可以向回调添加一个处理函数check_if_pair_solved,它将检查是否打开了两个图块,如果它们匹配,则该solved属性在模型中设置为 true。如果它们不匹配,则启动一个计时器,该计时器将在一秒钟后关闭它们。当计时器运行时,禁用所有图块,在此期间不能单击任何内容。

// Assign the shuffled Vec to the model property
let tiles_model = std::rc::Rc::new(slint::VecModel::from(tiles));
main_window.set_memory_tiles(tiles_model.clone().into());

let main_window_weak = main_window.as_weak();
main_window.on_check_if_pair_solved(move || {
    let mut flipped_tiles =
        tiles_model.iter().enumerate().filter(|(_, tile)| tile.image_visible && !tile.solved);

    if let (Some((t1_idx, mut t1)), Some((t2_idx, mut t2))) =
        (flipped_tiles.next(), flipped_tiles.next())
    {
        let is_pair_solved = t1 == t2;
        if is_pair_solved {
            t1.solved = true;
            tiles_model.set_row_data(t1_idx, t1);
            t2.solved = true;
            tiles_model.set_row_data(t2_idx, t2);
        } else {
            let main_window = main_window_weak.unwrap();
            main_window.set_disable_tiles(true);
            let tiles_model = tiles_model.clone();
            slint::Timer::single_shot(std::time::Duration::from_secs(1), move || {
                main_window.set_disable_tiles(false);
                t1.image_visible = false;
                tiles_model.set_row_data(t1_idx, t1);
                t2.image_visible = false;
                tiles_model.set_row_data(t2_idx, t2);
            });
        }
    }
});

main_window.run().unwrap();

完整代码:一个 Slint + Rust 实现的小游戏:记忆图块

[package]
name = "slint-memory"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rand = "0.8"
slint = "1.0.0"

main.rs 中添加代码:

fn main() {
    use slint::Model;

    let main_window = MainWindow::new().unwrap();

    // Fetch the tiles from the model(从 Model中获取图块列表)
    let mut tiles: Vec<TileData> = main_window.get_memory_tiles().iter().collect();
    // 复制一份图块数据,确保整体上是成对的,因为游戏就要上翻牌来配对图块
    tiles.extend(tiles.clone());

    // 随机混合一下,就是打乱所有图标
    use rand::seq::SliceRandom;
    let mut rng = rand::thread_rng();
    tiles.shuffle(&mut rng);

    // 将打乱后的 Vec 分配给模型属性
    let tiles_model = std::rc::Rc::new(slint::VecModel::from(tiles));
    main_window.set_memory_tiles(tiles_model.clone().into());

    // 游戏规则:游戏规则应强制要求最多两块牌的图块打开,
    // 如果图块匹配,那么我们认为它们已解决并且它们保持打开状态。
    // 否则等一会儿,这样玩家就可以记住图标的位置,然后再次关闭它们。
    let main_window_weak = main_window.as_weak();
    // 在 Rust 方面,我们现在可以向回调添加一个处理程序check_if_pair_solved,它将检查是否打开了两个图块
    main_window.on_check_if_pair_solved(move || {
        let mut flipped_tiles =
            tiles_model.iter().enumerate().filter(|(_, tile)| tile.image_visible && !tile.solved);

        if let (Some((t1_idx, mut t1)), Some((t2_idx, mut t2))) =
            (flipped_tiles.next(), flipped_tiles.next())
        {
            let is_pair_solved = t1 == t2;
            if is_pair_solved { // 如果它们2个匹配,则该solved属性在模型中设置为 true
                t1.solved = true; 
                tiles_model.set_row_data(t1_idx, t1);
                t2.solved = true;
                tiles_model.set_row_data(t2_idx, t2);
            } else {    
                // 如果它们不匹配,则启动一个计时器,该计时器将在一秒钟后关闭它们。当计时器运行时,禁用每个图块,因此在此期间不能单击任何内容。
                let main_window = main_window_weak.unwrap();
                main_window.set_disable_tiles(true); // 禁止单击

                let tiles_model = tiles_model.clone();
                slint::Timer::single_shot(std::time::Duration::from_secs(1), move || {
                    main_window.set_disable_tiles(false);
                    t1.image_visible = false;
                    tiles_model.set_row_data(t1_idx, t1);
                    t2.image_visible = false;
                    tiles_model.set_row_data(t2_idx, t2);
                });
            }
        }
    });

    main_window.run().unwrap();

}
// slint!宏构建 slintUI
slint::slint! {
    // Added: tile 数据结构定义并将其粘贴到slint!宏内部的顶部:
    struct TileData {
        image: image,
        image_visible: bool,
        solved: bool,
    }
    component MemoryTile inherits Rectangle {
        callback clicked;
        in property <bool> open_curtain;
        in property <bool> solved;
        in property <image> icon;
    
        height: 64px;
        width: 64px;
        background: solved ? #34CE57 : #3960D5;
        animate background { duration: 800ms; }
    
        Image {
            source: icon;
            width: parent.width;
            height: parent.height;
        }
        // 分成左右两半的原因是点击后图块的关闭动画
        // Left curtain
        Rectangle {
            background: #193076;
            x: 0px;
            width: open_curtain ? 0px : (parent.width / 2);
            height: parent.height;
            animate width { duration: 250ms; easing: ease-in; }
        }
    
        // Right curtain
        Rectangle {
            background: #193076;
            x: open_curtain ? parent.width : (parent.width / 2);
            width: open_curtain ? 0px : (parent.width / 2);
            height: parent.height;
            animate width { duration: 250ms; easing: ease-in; }
            animate x { duration: 250ms; easing: ease-in; }
        }
    
        TouchArea {
            clicked => {
                // Delegate to the user of this element
                root.clicked();
            }
        }
    }
    
    export component MainWindow inherits Window {
        width: 326px;
        height: 326px;

        callback check_if_pair_solved(); // Added 回调函数:检查两个图块是否打开已配对
        in property <bool> disable_tiles; // Added
 
        in property <[TileData]> memory_tiles: [
            { image: @image-url("icons/at.png") },
            { image: @image-url("icons/balance-scale.png") },
            { image: @image-url("icons/bicycle.png") },
            { image: @image-url("icons/bus.png") },
            { image: @image-url("icons/cloud.png") },
            { image: @image-url("icons/cogs.png") },
            { image: @image-url("icons/motorcycle.png") },
            { image: @image-url("icons/video.png") },
        ];
        // 该for tile[i] in memory_tiles:语法声明了一个变量 tile,
        // 其中包含数组中一个元素的数据memory_tiles,以及一个变量i,该变量是图块的索引。
        // 我们使用i索引根据其行和列计算图块的位置,使用模和整数除法创建 4 x 4 网格。
        // 运行它会为我们提供一个显示 8 个图块的窗口,这些图块可以单独打开。
        for tile[i] in memory_tiles : MemoryTile {
            x: mod(i, 4) * 74px + 20px; // 调整坐标,尽量使整体居中
            y: floor(i / 4) * 74px + 20px; // 调整坐标,尽量使整体居中
            width: 64px;
            height: 64px;
            icon: tile.image;
            open_curtain: tile.image_visible || tile.solved;
            // propagate the solved status from the model to the tile
            solved: tile.solved;
            clicked => {
                // old: tile.image_visible = !tile.image_visible;
                // new: 当计时器运行时,禁用每个图块,因此在此期间不能单击任何内容。
                if (!root.disable_tiles) { 
                    tile.image_visible = !tile.image_visible;
                    root.check_if_pair_solved();
                }
            }
        }
    }
}

注:项目用到的图标Icons:github.com/huangbqsky/…,

下载后直接解压到项目icons文件夹即可,也可以自己准备一组小图标替换

执行命令:cargo run,运行效果如下图(注:以上所有程序开发均在vs code下完成,依赖slint官方插件)

截屏2023-05-11 13.18.56.png

游戏只是一个学习 Slint + Rust的示例,非常简单,当然自己可以按照游戏真实情况的继续完善,比如添加更多的组件,显示「开始」、「结束」、「再来一局」等按钮,并添加一些动画,让它更像一个游戏。

总结

Slint 是一个 Rust 编写的综合性 UI 工具包,定义和提供一系列方便构建 UI 的组件,使用了声明式编程来简化 UI 的开发,可用于为桌面和嵌入式设备构建原生用户界面。

在本教程中,我们演示了如何将一些内置的 Slint 元素与 Rust 代码结合起来构建一个小游戏。还有许多我们没有讨论的功能,例如布局、小部件或样式。

建议使用以下链接继续深入学习:

参考