【译】基于 Rust 用 Bevy 实现节奏大师游戏

  • Rhythm game in Rust using Bevy 译文(基于 Rust 用 Bevy 实现节奏大师游戏)
  • 原文链接:lmao
  • 原文作者:Guillem Caballero Coll
  • 译文来自:RustMagazine 2021 期刊
  • 译者:suhanyujie
  • ps:水平有限,翻译不当之处,还请指正。
  • 标签:Rust, Bevy, game, Rhythm game

2021/2/8 - 77 min read

介绍

在这个教程中,我们基于 Rust 使用 Bevy 引擎实现一个节奏大师游戏。目的是展现如何用 Bevy 实现一些东西,特别是一些更高级的功能,如着色器,状态,和音频。

If you want to see the final code before diving in, you can find the repository here, and here's a video of how the game works:

如果你想在进入学习之前看看最终的代码,你可以在这里找到仓库,并且下面是一个游戏视频:

视频资源

这款游戏很简单:箭头飞过屏幕,玩家必须在正确的时间内按下正确的方向键才能让箭头消失。如果玩家成功地做到了这一点,他们将获得积分。否则,箭头会旋转着掉下来。箭头会有不同的速度,每个箭头颜色不同。游戏还有一个选择歌曲的菜单,以及一个简单的地图制作器来帮助创建歌曲地图。

Bevy

Bevy 是一个数据驱动的游戏引擎。它使用起来非常简单,令人愉悦。它使用 ECS 来管理游戏实体及其行为。

Bevy 有一个很受欢迎的社区,所以如果你对本教程有任何疑问,可以查阅 Bevy book,浏览[示例]](bevy/examples at main · bevyengine/bevy · GitHub),或者加入官方的 Discord 进行提问。

如果你发现教程中存在错误,请在这里开一个 Issue,我会修正它。

前期准备

在本教程中,你需要熟悉 Rust。你不必成为专家,我们不会使用任何的黑魔法。虽然不是必须的,但强烈建议你去了解一下 ECS 的工作原理。

如果你想阅读一些更简单的教程,我建议你阅读基于 Rust,使用 Bevy 实现贪吃蛇,或者 Bevy 实现国际象棋教程,可以详细了解基础知识。

此外,我们将在本教程中使用着色器和 GLSL。这两种知识不是必须的,因为我会提供要使用的代码,但了解 GLSL 会使你可以修改更多的东西,并让游戏真正属于你自己的。

如果你之前从未使用过着色器,可以参考下面这些推荐链接开始学习:

创建一个项目

和往常一样,我们使用 cargo new bevy_rhythm && cd bevy_rhythm 创建一个空 Rust 项目。你现在可以打开该 crate 项目。并用你喜欢的编辑器打开 Cargo.toml,把 bevy 加入到依赖项中:

[package]
name = "bevy_rhythm"
version = "0.1.0"
authors = ["You <your@emailhere.com>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
bevy = "0.4"

快速编译

我建议你启用快速编译,以确保开发过程不会太烦躁。以下是我们需要准备的:

  • 1.LLD 链接器:普通链接器会有点慢,所以我们把其换成 LLD 链接器进行加速:
    • Ubuntu: sudo apt-get install lld
    • Arch: sudo pacman -S lld
    • Windows: cargo install -f cargo-binutils and rustup component add llvm-tools-preview
    • MacOS: brew install michaeleisel/zld/zld
  • 2.为该项目启用 Rust 的 nightly 版本:rustup 工具链安装 nightly 版,并且在项目目录中设置 rustup 为 nightly 进行启用。
  • 3.把这个文件的内容拷贝到 bevy_rhythm/.cargo/config 中。

以上就是所有要准备的事情了,现在运行游戏来编译所有的库。编译完成后,你应该在命令行中看到 Hello, world!

注意:如果你看到游戏性能很差,或者看到加载资源很慢,你可以用 cargo run --release 的编译模式下运行。编译时间可能会稍长一些,但游戏运行会更加流畅!

开始

任何 Bevy 游戏的第一步都是增加小段示例代码来启动应用的。打开 main.rs,并将已有的 main 函数替换为下面的内容:

use bevy::{input::system::exit_on_esc_system, prelude::*};

fn main() {
    App::build()
        // 抗锯齿设置 samples 为 4
        .add_resource(Msaa { samples: 4 })
        // 设置 WindowDescriptor 资源修改标题和窗口大小
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_plugins(DefaultPlugins)
        .add_system(exit_on_esc_system.system())
        .run();
}

如果你使用 cargo run 运行程序,你会看到一个空白窗口:

这一步设置 Bevy App,添加默认插件。这将包括转换、输入、窗口等游戏运行所需的元素。如果你不需要这些功能, Bevy 是模块化的,你可以选择只开启你需要的功能。我们要新增这些插件,所以需要使用 add_pluginsDefaultPlugins

我们还添加了两个资源:MsaaWindowDescriptor,分别用于配置 anti-aliasing,以及窗口大小和标题。最后,我们添加了 Bevy 的 exit_on_esc_system,它的作用是按下 esc 键时关闭游戏。

Bevy 中的 ECS

下面是 ECS 如何在 Bevy 中工作的介绍。如果你已经知道它是如何工作的,可以跳过本节。这和我们的游戏无关,我将使用 Bevy book 中的例子来说明它是如何运作的。你不需要复制这里的代码,只需读懂它即可。

Bevy 的 ECS 是 hecs 的一个分支版本。它使用 Rust 结构体作为组件,不需要添加宏或其他复杂的东西。例如:

// 有两个字段的结构体组件
struct Position { 
    x: f32,
    y: f32
}

// 元组组件
struct Name(String);

// 我们甚至可以使用标记组件
struct Person;

Systems are just normal Rust functions, that have access to Querys:

这个“系统”中可以使用正常的 Rust 函数,访问 Querys

fn set_names(mut query: Query<(&Position, &mut Name), With<Person>>) {
    for (pos, mut name) in query.iter_mut() {
        name.0 = format!("position: ({}, {})", pos.x, pos.y);
    }
}

一次查询可以访问组件中所有实体。在前面的示例中,query 参数允许我们迭代包括 Person 组件在内以及 PositionName 等组件实体。因为我们用 &mut Name 替代 &Name,所以可以对实体进行修改。如果对 &Name 类型的该值进行修改,Rust 会报错。

有时候我们想要只在游戏开始时运行一次的机制。我们可以通过“启动系统”来做到这一点。“启动系统”和“普通系统”完全一样,唯一的区别是我们将如何把它加到游戏中,这会在后面进行详细讲解。下面是一个使用 Commands 生成一些实体的“启动系统”:

fn setup(commands: &mut Commands) {
    commands
        .spawn((Position { x: 1., y: 2. }, Name("Entity 1".to_string())))
        .spawn((Position { x: 3., y: 9. }, Name("Entity 2".to_string())));
}

Bevy 也有资源的概念,它可以保存全局数据。例如,内置的 Time 资源给我们提供游戏中的当前时间。为了在“系统”中使用这类资源,我们需要用到 Res

fn change_position(mut query: Query<&mut Position>, time: Res<Time>) {
    for mut pos in query.iter_mut() {
        pos.x = time.seconds_since_startup() as f32;
    }
}

我们自定义资源也很简单:

// 一个简单的资源
struct Scoreboard {
    score: usize,
}

// 另一个资源,它实现了 Default trait
#[derive(Default)]
struct OtherScore(f32);

我们有两种方法初始化资源:第一种是使用 .add_resource 并提供我们需要的结构体,另一种是实现了 DefaultFromResources.init_resource

下面我们如何把它们加到游戏中:

fn main() {
    App::build()
        // 新增资源的第一种方法
        .add_resource(Scoreboard { score: 7 })
        // 第二种方法,通过 Default 的初始化加载资源
        .init_resource::<OtherScore>()

        // 增加“启动系统”,游戏启动时只会运行一次
        .add_startup_system(setup.system())
        // 增加一个“普通系统”,每一帧都会运行一次
        .add_system(set_names.system())
        .add_system(change_position.system())
        .run();
}

Bevy 还有一个很酷的东西是插件,我们在上一节使用 DefaultPlugins 时看到了。插件可以让我们将一些特性包装在一起,这可以让我们很容易地启用和禁用它,插件也提供了组织功能,这也是我们在这篇教程中自定义插件的主要目的。

如果有些东西不清楚,不用担心,我们会在后面更详细地解释所有内容。

增加系统设置

每个游戏都需要一个相机来渲染对象,所以我们将从如何添加一个生成相机的“启动系统”开始。因为这是一款 2D 游戏,所以我们要使用 Camera2dBundle

use bevy::{input::system::exit_on_esc_system, prelude::*};

fn main() {
    App::build()
        // 设定[抗锯齿](https://cn.bing.com/search?q=%E7%BB%98%E5%88%B6+%E6%8A%97%E9%94%AF%E9%BD%BF&qs=n&form=QBRE&sp=-1&pq=%E7%BB%98%E5%88%B6+%E6%8A%97%E9%94%AF%E9%BD%BF),samples 参数值为 4
        .add_resource(Msaa { samples: 4 })
        // 设定 WindowDescriptor 资源,定义我们需要的标题和窗口大小
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_startup_system(setup.system()) // <--- New
        .add_plugins(DefaultPlugins)
        .add_system(exit_on_esc_system.system())
        .run();
}

fn setup(commands: &mut Commands) {
    commands.spawn(Camera2dBundle::default());
}

bundle 是组件的集合。在本例中,Camera2dBundle 将创建一个包含 CameraOrthographicProjectionVisibleEntitiesTransformGlobalTransform 的 实体。其中大部分是我们玩游戏时不需要用到的,所以我们使用抽象的 Camera2dBundle 添加组件。

注意:我们还可以使用一个元组代替 bundle 来添加所有组件:

fn setup(commands: &mut Commands) {
    commands.spawn((Camera::default(), OrthographicProjection::default(), VisibleEntities::default(), Transform::default(), GlobalTransform::default()));
}

这段代码实际上还不能运行,因为我们还需要在 camera 和投影组件中设置一些字段,但我觉得它明确地体现了使用 bundle 和元组来添加结构是很相似的。

加载精灵

在这部分中,我们会添加一些“精灵”,让它们四处移动。为此,我们需要创建一个 assets 目录,我们将存储一些图像字体文件。目录中有两个子文件夹,图像和字体。你可以点击前面提到的链接,从 GitHub 仓库下载。

你的资源目录应该如下所示:

assets
├── fonts
│   └── FiraSans-Bold.ttf
└── images
    ├── arrow_blue.png
    ├── arrow_border.png
    ├── arrow_green.png
    └── arrow_red.png

我们将使用带颜色的箭头来表示不同速度的箭头,并使用带边框的箭头来标记目标区域。

有了这些静态资源,我们就可以开始编写一些游戏动画了。我们将创建一个 arrows.rs 文件,它将包含生成,移动,清除箭头等相关操作。首先要做的是为“箭头精灵”保留资源,这样我们就不必在每次创建箭头时重新加载它们:

use bevy::prelude::*;

/// 为箭头保留材料和资源
struct ArrowMaterialResource {
    red_texture: Handle<ColorMaterial>,
    blue_texture: Handle<ColorMaterial>,
    green_texture: Handle<ColorMaterial>,
    border_texture: Handle<ColorMaterial>,
}
impl FromResources for ArrowMaterialResource {
    fn from_resources(resources: &Resources) -> Self {
        let mut materials = resources.get_mut::<Assets<ColorMaterial>>().unwrap();
        let asset_server = resources.get::<AssetServer>().unwrap();

        let red_handle = asset_server.load("images/arrow_red.png");
        let blue_handle = asset_server.load("images/arrow_blue.png");
        let green_handle = asset_server.load("images/arrow_green.png");
        let border_handle = asset_server.load("images/arrow_border.png");
        ArrowMaterialResource {
            red_texture: materials.add(red_handle.into()),
            blue_texture: materials.add(blue_handle.into()),
            green_texture: materials.add(green_handle.into()),
            border_texture: materials.add(border_handle.into()),
        }
    }
}

通过实现 FromResources trait,在我们调用 .init_resource::<ArrowMaterialResource>() 时,Bevy 会管理并初始化资源,在进程中加载图片。

如你所看到的,实际的资源加载是 Handle<ColorMaterial> 而不是 ColorMaterials。这样,当我们创建箭头实例时,我们可以使用对应的 handle,并且它们将复用已存在的资源,而不是每个都各自独有一份。

生成并移动箭头

我们接下来要做的是生成箭头并在屏幕上移动它们。我们从实现每秒生成一个箭头的“系统”开始。箭头会包含一个名为 Arrow 的空(结构体)组件:

/// 箭头组件
struct Arrow;

/// 跟踪何时生成新箭头
struct SpawnTimer(Timer);

/// 生成箭头
fn spawn_arrows(
    commands: &mut Commands,
    materials: Res<ArrowMaterialResource>,
    time: Res<Time>,
    mut timer: ResMut<SpawnTimer>,
) {
    if !timer.0.tick(time.delta_seconds()).just_finished() {
        return;
    }

    let transform = Transform::from_translation(Vec3::new(-400., 0., 1.));
    commands
        .spawn(SpriteBundle {
            material: materials.red_texture.clone(),
            sprite: Sprite::new(Vec2::new(140., 140.)),
            transform,
            ..Default::default()
        })
        .with(Arrow);
}

在这个系统中,我们使用了 Timer,这是 Bevy 中执行每隔 x 秒重复操作的最佳方式。我们使用 newtype 模式进行封装,这样我们能够把 SpawnTimer 与其他的定时器区分开。我们需要使用形如 .add_resource(SpawnTimer(Timer::from_seconds(1.0, true))) 的调用方式进行初始化,调用稍后会进行。将 true 作为参数值传递表示计时器结束时会再次重复执行。

要使用计时器,我们必须手动调用它的 tick 方法,入参 time 是距离上次调用所间隔的时间差,然后我们可以使用 just_finished 来查看定时器是否完成。实际上我们所做的是提前检查定时器是否完成来确保 spawn_arrows 系统每秒只运行一次。

系统的其余部分将创建一个 Transform 组件,我们将其添加到箭头组件中,它会返回 SpriteBundle 从而生成箭头,并给箭头实体一个来自 ArrowMaterialResource 的红色纹理。我们使用 Commands 中的 with 方法添加了 Arrow 组件。这样,我们创建的实体将拥有所有的 SpriteBundleArrow 组件。

注意:这个系统只是临时的,并且它会被在某个特定时间内生成箭头的东西所覆盖。

现在,我们生成的那些箭头就在那了,我们需要用另一个系统让它们向右移动:

/// 箭头前移
fn move_arrows(time: Res<Time>, mut query: Query<(&mut Transform, &Arrow)>) {
    for (mut transform, _arrow) in query.iter_mut() {
        transform.translation.x += time.delta_seconds() * 200.;
    }
}

move_arrows 使用 Query 来获取所有带有 TransformArrow 组件的实体,并通过增加 x 坐标值来将它们向右移动一点点。我们还使用了 Time::delta_seconds() 来根据当前帧到上一帧的时间来增加距离。

我们把这些 ArrowMaterialResourceSpawnTimer 等系统连接到一个插件中:

pub struct ArrowsPlugin;
impl Plugin for ArrowsPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app
            // 初始化资源
            .init_resource::<ArrowMaterialResource>()
            .add_resource(SpawnTimer(Timer::from_seconds(1.0, true)))
            // 增加 system
            .add_system(spawn_arrows.system())
            .add_system(move_arrows.system());
    }
}

我们现在可以将 main.rs 改为如下内容:

use bevy::{input::system::exit_on_esc_system, prelude::*};

mod arrows;
use arrows::ArrowsPlugin;

fn main() {
    App::build()
        // Set antialiasing to use 4 samples
        .add_resource(Msaa { samples: 4 })
        // Set WindowDescriptor Resource to change title and size
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_startup_system(setup.system())
        .add_system(exit_on_esc_system.system())
        .add_plugins(DefaultPlugins)
        .add_plugin(ArrowsPlugin) // <--- New
        .run();
}

fn setup(commands: &mut Commands) {
    commands.spawn(Camera2dBundle::default());
}

我们需要做的只是增加 .add_plugin(ArrowsPlugin),这样所有的系统和资源就被正确地集成在 arrows.rs 中。

如果你运行程序,你会看到箭头在屏幕上飞舞:

视频资源

类型和常量

我们在上一节中对一些值硬编码了。因此我们需要重新使用它们,我们要新建一个小模块来保存我们的常量。创建一个名为 consts.rs 的文件,并添加以下内容:

/// 箭头移动的速度
pub const BASE_SPEED: f32 = 200.;

/// 箭头生成时的 X 坐标值,应该在屏幕之外
pub const SPAWN_POSITION: f32 = -400.;

/// 箭头应该被正确点击时的 X 坐标值
pub const TARGET_POSITION: f32 = 200.;

/// 点击箭头时的容错间隔
pub const THRESHOLD: f32 = 20.;

/// 箭头从刷出到目标区域的总距离
pub const DISTANCE: f32 = TARGET_POSITION - SPAWN_POSITION;

其中一些常数稍后才会用到。在 main.rs 中增加 mod consts,以导入模块使其可用。我们可以在 arrows.rs 中的 spawn_arrowsmove_arrows 替换掉对应硬编码的值。

use crate::consts::*;

fn spawn_arrows(
    commands: &mut Commands,
    materials: Res<ArrowMaterialResource>,
    time: Res<Time>,
    mut timer: ResMut<SpawnTimer>,
) {
    if !timer.0.tick(time.delta_seconds()).just_finished() {
        return;
    }

    let transform = Transform::from_translation(Vec3::new(SPAWN_POSITION, 0., 1.));
    commands
        .spawn(SpriteBundle {
            material: materials.red_texture.clone(),
            sprite: Sprite::new(Vec2::new(140., 140.)),
            transform,
            ..Default::default()
        })
        .with(Arrow);
}

/// 箭头前移
fn move_arrows(time: Res<Time>, mut query: Query<(&mut Transform, &Arrow)>) {
    for (mut transform, _arrow) in query.iter_mut() {
        transform.translation.x += time.delta_seconds() * BASE_SPEED;
    }
}

现在我们的箭头在屏幕上移动,但他们都面向相同的方向、相同的速度移动,且颜色相同。为了能够区分它们,我们将创建两个不同的枚举,一个用于表示方向(上、下、左、右),一个表示速度(慢、中、快)。

注意:我们把它叫做 Directions 而非 Direction,因为后者是一个 Bevy 枚举。通过给它取一个稍微不一样的名字,防止混淆带来的麻烦。

让我们创建一个 types.rs 文件,并把上面提到的枚举值放于其中:

use crate::consts::*;
use bevy::input::{keyboard::KeyCode, Input};
use core::f32::consts::PI;

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Directions {
    Up,
    Down,
    Left,
    Right,
}
impl Directions {
    /// 检查相应的方向键是否被按下
    pub fn key_just_pressed(&self, input: &Input<KeyCode>) -> bool {
        let keys = match self {
            Directions::Up => [KeyCode::Up, KeyCode::D],
            Directions::Down => [KeyCode::Down, KeyCode::F],
            Directions::Left => [KeyCode::Left, KeyCode::J],
            Directions::Right => [KeyCode::Right, KeyCode::K],
        };

        keys.iter().any(|code| input.just_pressed(*code))
    }

    /// 返回此方向的箭头的旋转角度
    pub fn rotation(&self) -> f32 {
        match self {
            Directions::Up => PI * 0.5,
            Directions::Down => -PI * 0.5,
            Directions::Left => PI,
            Directions::Right => 0.,
        }
    }

    /// 返回此方向的箭头的 y 坐标值
    pub fn y(&self) -> f32 {
        match self {
            Directions::Up => 150.,
            Directions::Down => 50.,
            Directions::Left => -50.,
            Directions::Right => -150.,
        }
    }
}

首先,我们添加 Directions 枚举。并且已经实现了三种不同的方法。

key_just_pressed,用于检查被按下的方向键。我已经决定增加 D, F, J, K 作为可能的键,因为我键盘上的方向键比较小。如果你是 FPS 玩家,你可以使用 W, S, A, D,或者 VIM 世界的 K, J, H, L 来替代它们。

注意:如果你不太习惯使用迭代器,下面是用传统的方法实现 key_just_pressed

/// 检查与方向对应的按键是否被按下
pub fn key_just_pressed(&self, input: &Input<KeyCode>) -> bool {
    match self {
        Up => input.just_pressed(KeyCode::Up) || input.just_pressed(KeyCode::D),
        Down => input.just_pressed(KeyCode::Down) || input.just_pressed(KeyCode::F),
        Left => input.just_pressed(KeyCode::Left) || input.just_pressed(KeyCode::J),
        Right => input.just_pressed(KeyCode::Right) || input.just_pressed(KeyCode::K),
    }
}

rotation 表示我们需要将“箭头精灵”旋转多少度以将其指向正确的方向。y 表示箭头的 y 坐标值。我决定把箭头的顺序调整为 Up, Down, Left, Right,但如果你喜欢其他顺序,你可以自己修改。

#[derive(Copy, Clone, Debug)]
pub enum Speed {
    Slow,
    Medium,
    Fast,
}
impl Speed {
    /// 返回箭头移动的实际速度
    pub fn value(&self) -> f32 {
        BASE_SPEED * self.multiplier()
    }
    /// Speed 乘数
    pub fn multiplier(&self) -> f32 {
        match self {
            Speed::Slow => 1.,
            Speed::Medium => 1.2,
            Speed::Fast => 1.5,
        }
    }
}

接下来,我们添加了 Speed 枚举。我们实现了两个方法:一个是乘法,它表示箭头应该相对于 BASE_SPEED 所移动的距离;另一个是 value,它是执行乘法运算得到的值。

这是一部分代码,我不希望特别复杂!接下来要添加的类型是 ArrowTimeSongConfig。前者记录何时生成一个箭头,以及它的方向和速度。第二个将保存所有箭头实体的列表:

#[derive(Clone, Copy, Debug)]
/// 跟踪记录箭头应该在什么时候生成,以及箭头的速度和方向。
pub struct ArrowTime {
    pub spawn_time: f64,
    pub speed: Speed,
    
  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值