- 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 会使你可以修改更多的东西,并让游戏真正属于你自己的。
如果你之前从未使用过着色器,可以参考下面这些推荐链接开始学习:
- Shadertoy 入门:介绍并使用 Shadertoy。
- Unity 着色器编码入门 —— 一款即兴的在线课程:介绍在 Unity 中使用着色器。非 Unity 官方指定的大部分资料都在这儿。
- Unity 教程:着色器的实用介绍 —— 第一部分:与上面类似。
创建一个项目
和往常一样,我们使用 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
- Ubuntu:
- 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
运行程序,你会看到一个空白窗口:
![](https://img-blog.csdnimg.cn/img_convert/7a2b02c6e22e6232d93ef908b095680d.png)
这一步设置 Bevy App
,添加默认插件。这将包括转换、输入、窗口等游戏运行所需的元素。如果你不需要这些功能, Bevy 是模块化的,你可以选择只开启你需要的功能。我们要新增这些插件,所以需要使用 add_plugins
和 DefaultPlugins
。
我们还添加了两个资源:Msaa
和 WindowDescriptor
,分别用于配置 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
组件在内以及 Position
和 Name
等组件实体。因为我们用 &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
并提供我们需要的结构体,另一种是实现了 Default
和 FromResources
的 .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
将创建一个包含 Camera
,OrthographicProjection
,VisibleEntities
,Transform
和 GlobalTransform
的 实体。其中大部分是我们玩游戏时不需要用到的,所以我们使用抽象的 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
组件。这样,我们创建的实体将拥有所有的 SpriteBundle
和 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() * 200.;
}
}
move_arrows
使用 Query
来获取所有带有 Transform
和 Arrow
组件的实体,并通过增加 x 坐标值来将它们向右移动一点点。我们还使用了 Time::delta_seconds()
来根据当前帧到上一帧的时间来增加距离。
我们把这些 ArrowMaterialResource
和 SpawnTimer
等系统连接到一个插件中:
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_arrows
和 move_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
,它是执行乘法运算得到的值。
这是一部分代码,我不希望特别复杂!接下来要添加的类型是 ArrowTime
和 SongConfig
。前者记录何时生成一个箭头,以及它的方向和速度。第二个将保存所有箭头实体的列表:
#[derive(Clone, Copy, Debug)]
/// 跟踪记录箭头应该在什么时候生成,以及箭头的速度和方向。
pub struct ArrowTime {
pub spawn_time: f64,
pub speed: Speed,