「译」基于 Rust 用 Bevy 实现节奏大师游戏 您所在的位置:网站首页 节奏大师4399下载安装 「译」基于 Rust 用 Bevy 实现节奏大师游戏

「译」基于 Rust 用 Bevy 实现节奏大师游戏

2023-12-19 08:31| 来源: 网络整理| 查看: 265

「译」基于 Rust 用 Bevy 实现节奏大师游戏

译者:suhanyujie/ 后期编辑:张汉东

原文链接:https://caballerocoll.com/blog/bevy-rhythm-game/

本文相关:

Rust中文社群线上学习室 | 使用 Bevy 实现节奏大师游戏 ⁣>

01 录屏 02 录屏⁣

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR 话题群:https://applink.feishu.cn/TeLD868w 正文

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

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

视频资源

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

Bevy

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

Bevy 有一个很受欢迎的社区,所以如果你对本教程有任何疑问,可以查阅 Bevy book,浏览[示例]](https://github.com/bevyengine/bevy/tree/master/examples),或者加入官方的 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 加入到依赖项中:

#![allow(unused)] fn main() { [package] name = "bevy_rhythm" version = "0.1.0" authors = ["You "] 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_plugins 和 DefaultPlugins。

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

Bevy 中的 ECS

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

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

#![allow(unused)] fn main() { // 有两个字段的结构体组件 struct Position { x: f32, y: f32 } // 元组组件 struct Name(String); // 我们甚至可以使用标记组件 struct Person; }

Systems are just normal Rust functions, that have access to Querys: >这个“系统”中可以使用正常的 Rust 函数,访问 Querys:

#![allow(unused)] fn main() { fn set_names(mut query: Query) { 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 生成一些实体的“启动系统”:

#![allow(unused)] fn main() { 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:

#![allow(unused)] fn main() { fn change_position(mut query: Query, time: Res) { for mut pos in query.iter_mut() { pos.x = time.seconds_since_startup() as f32; } } }

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

#![allow(unused)] fn main() { // 一个简单的资源 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::() // 增加“启动系统”,游戏启动时只会运行一次 .add_startup_system(setup.system()) // 增加一个“普通系统”,每一帧都会运行一次 .add_system(set_names.system()) .add_system(change_position.system()) .run(); }

Another cool thing Bevy has are Plugins, which we've already seen when we used DefaultPlugins in the previous section. Plugins allow us to wrap features that belong together, which then let's us enable and disable them together easily. Plugins also provide organization, which is the main purpose we'll be creating our own in this tutorial. >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()) // Self { let mut materials = resources.get_mut::().unwrap(); let asset_server = resources.get::().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::() 时,Bevy 会管理并初始化资源,在进程中加载图片。

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

生成并移动箭头

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

#![allow(unused)] fn main() { /// 箭头组件 struct Arrow; /// 跟踪何时生成新箭头 struct SpawnTimer(Timer); /// 生成箭头 fn spawn_arrows( commands: &mut Commands, materials: Res, time: Res, mut timer: ResMut, ) { 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 组件。

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

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

#![allow(unused)] fn main() { /// 箭头前移 fn move_arrows(time: Res, mut query: Query) { 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 等系统连接到一个插件中:

#![allow(unused)] fn main() { pub struct ArrowsPlugin; impl Plugin for ArrowsPlugin { fn build(&self, app: &mut AppBuilder) { app // 初始化资源 .init_resource::() .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) // 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:

#![allow(unused)] fn main() { /// 检查与方向对应的按键是否被按下 pub fn key_just_pressed(&self, input: &Input) -> 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,但如果你喜欢其他顺序,你可以自己修改。

#![allow(unused)] fn main() { #[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。前者记录何时生成一个箭头,以及它的方向和速度。第二个将保存所有箭头实体的列表:

#![allow(unused)] fn main() { #[derive(Clone, Copy, Debug)] /// 跟踪记录箭头应该在什么时候生成,以及箭头的速度和方向。 pub struct ArrowTime { pub spawn_time: f64, pub speed: Speed, pub direction: Directions, } #[derive(Debug)] pub struct SongConfig { pub arrows: Vec, } }

我们的 ArrowTime 有个问题。在内部,我们需要知道箭头什么时候生成,但在生成它时,我们希望指定应该在什么时候点击它。因为每个箭头都有不同的速度,所以仅仅减去几秒是不够的。为了解决这个问题,我们要创建一个 new 函数,包含 click_time,speed 和 direction,并设置相应的 spawn_time:

#![allow(unused)] fn main() { impl ArrowTime { fn new(click_time: f64, speed: Speed, direction: Directions) -> Self { let speed_value = speed.value(); Self { spawn_time: click_time - (DISTANCE / speed_value) as f64, speed, direction, } } } }

为了进行测试,我们将创建一个函数,它返回硬编码的 SongConfig,其中包含了不同的速度和方向的箭头:

#![allow(unused)] fn main() { pub fn load_config() -> SongConfig { SongConfig { arrows: vec![ ArrowTime::new(1., Speed::Slow, Directions::Up), ArrowTime::new(2., Speed::Slow, Directions::Down), ArrowTime::new(3., Speed::Slow, Directions::Left), ArrowTime::new(4., Speed::Medium, Directions::Up), ArrowTime::new(5., Speed::Fast, Directions::Right), ], } } }

最后,我们可以进入 main.rs 并将 setup 系统修改成下方所示:

#![allow(unused)] fn main() { mod types; fn setup(commands: &mut Commands) { let config = types::load_config(); commands .spawn(Camera2dBundle::default()) .insert_resource(config); } }

注意:我们使用 insert_resource 替代 add_resource 或 init_resource,因为后者是 AppBuilder,前者是用在 Commands 中。

如果我们现在运行游戏,没有任何变化,但仍然是能运行的,这很棒!我们进入 arrows.rs 文件,修改它使它能根据 SongConfig 中的列表生成箭头。

定时生成箭头

现在我们有了一个要生成的箭头列表,我们可以删除所有定时器的内容,并修改 spawn_arrows 系统来检查每一帧刷出的箭头。

我们可以想到的第一个实现是循环遍历 SongConfig 中的所有箭头,并检查哪些箭头应该在当前帧中生成。这是可行的,但我们会在每一帧都循环遍历一个可能会很大的数组。我们硬编码的只有 5 个箭头,这不成问题,但一整首歌的情况下,箭头可能会超过 1000 个,就算电脑很快,玩家也不希望游戏让它们的 CPU “热”起来。

相反,我们将假设 SongConfig 中的箭头是有序的。我们需要在歌曲开始前将它们进行排序,这很简单。了解了这一点,我们只能先检查列表中的第一个箭头,如果它应该被生成出来,我们也会检查下一个箭头,一次类推,直到我们到达那个不需要再生成的箭头为止。由于箭头是有序的,如果一个箭头不需要生成,那么其后的箭头也无需生成。在这之后,我们需要移除列表中已经被生成的箭头。

我们还需要给 Arrow 新增 Speed 和 Directions 字段:

#![allow(unused)] fn main() { // 在顶部 use crate::types::*; /// “精灵实体”上的组件 struct Arrow { speed: Speed, direction: Directions, } /// 生成箭头 fn spawn_arrows( commands: &mut Commands, mut song_config: ResMut, materials: Res, time: Res, ) { // 我们得到了从启动到当前的时间(secs)以及到最后一次迭代的时间(secs_last),这样我们就可以检查是否有箭头应该在这个窗口中生成。 // 歌曲在启动后 3 秒开始,所以减去 3 秒。 let secs = time.seconds_since_startup() - 3.; let secs_last = secs - time.delta_seconds_f64(); // 计数器用于计算列表中产生和删除箭头数量 let mut remove_counter = 0; for arrow in &song_config.arrows { // 列表是有序的,所以我们遍历检查直到第一个不满足条件为止 // 检查箭头是否应该在当前帧和下一帧之间的时间点生成 if secs_last < arrow.spawn_time && arrow.spawn_time < secs { remove_counter += 1; // 根据速度得到与之匹配的箭头素材(纹理) let material = match arrow.speed { Speed::Slow => materials.red_texture.clone(), Speed::Medium => materials.blue_texture.clone(), Speed::Fast => materials.green_texture.clone(), }; let mut transform = Transform::from_translation(Vec3::new(SPAWN_POSITION, arrow.direction.y(), 1.)); // 按一定的方向旋转箭头 transform.rotate(Quat::from_rotation_z(arrow.direction.rotation())); commands .spawn(SpriteBundle { material, sprite: Sprite::new(Vec2::new(140., 140.)), transform, ..Default::default() }) .with(Arrow { speed: arrow.speed, direction: arrow.direction, }); } else { break; } } // 移除列表中生成的箭头 for _ in 0..remove_counter { song_config.arrows.remove(0); } } }

上面这段代码,我们来分析一下它。

在“系统”开始时,我们先获取游戏已经开始多久了,以及“系统”最后一次运行的时间点。我们使用 delta_seconds_f64 来获取,它返回自最后一次游戏更新以来的时间。有了这两个值,我们就能知道该生成哪个箭头。因为 Bevy 不会每纳秒都更新(不代表所有的游戏引擎),所以如果只是简单地检查 spawn_time 是否等于当前时间会导致我们跳过需要处理的箭头。例如,我们可能有一个箭头,它刷出的时间被设为 3.0。Bevy 可以在 2.99 时运行这个“系统”,然后 3.01 时运行一次。由于箭头被指定为在 3.0 时生成,它就与运行“系统”的时间不匹配,导致它永远不会生成。

我们换个方法,在“系统”开始时检查当前时间和最后结束时的时间,对于上面的举例,在第二次运行该“系统”时,就会有 secs = 3.01 以及 secs_last = 2.99,因为我们的箭头产生的时间超过 secs_last,但小于下一帧的 secs,所以能够生成。大功告成!

有了这个,我们可以对 move_arrows 做一下小修改,让它兼顾速度的影响,可以使用我们之前创建的 Speed::value() 方法:

#![allow(unused)] fn main() { /// 把箭头向前移动 fn move_arrows(time: Res, mut query: Query) { for (mut transform, arrow) in query.iter_mut() { transform.translation.x += time.delta_seconds() * arrow.speed.value(); } } }

很酷,现在每个箭头都显示了正确的颜色,并以相应的速度移动:

视频资源

增加目标区域箭头

现在我们将使用 border_texture 去创造目标箭头,以便玩家能够知道何时应该按下按键。为此,我们将创建另一个“启动系统”,setup_target_arrows 以及一个标记组件,TargetArrow:

#![allow(unused)] fn main() { struct TargetArrow; fn setup_target_arrows(commands: &mut Commands, materials: Res) { use Directions::*; let directions = [Up, Down, Left, Right]; for direction in directions.iter() { let mut transform = Transform::from_translation(Vec3::new(TARGET_POSITION, direction.y(), 1.)); transform.rotate(Quat::from_rotation_z(direction.rotation())); commands .spawn(SpriteBundle { material: materials.border_texture.clone(), sprite: Sprite::new(Vec2::new(140., 140.)), transform, ..Default::default() }) .with(TargetArrow); } } }

为了创建四个箭头,我们创建了一个有四个方向值的数组,然后循环调用 border_texture 和空的 TargetArrow 组件。

不要忘记在 ArrowsPlugin 中添加 setup_target_arrows 作为“启动系统”:

#![allow(unused)] fn main() { pub struct ArrowsPlugin; impl Plugin for ArrowsPlugin { fn build(&self, app: &mut AppBuilder) { app.init_resource::() .add_startup_system(setup_target_arrows.system()) .add_system(spawn_arrows.system()) .add_system(move_arrows.system()); } } }

好了,我们现在把“目标区域箭头”准备好了。

视频资源

按键按下时清除箭头

现在我们有了目标箭头,我们接下来要实现一个“系统”,它的作用是,当箭头刷出时,并且如果在特定的阈值内,用户点击了正确的操作键,箭头就会消失。我们将创建一个名为 despawn_arrows 的新“系统”:

#![allow(unused)] fn main() { /// 用户在箭头到达尽头前按下正确的按键,箭头消失。 fn despawn_arrows( commands: &mut Commands, query: Query, keyboard_input: Res, ) { for (entity, transform, arrow) in query.iter() { let pos = transform.translation.x; // 检查按下按键时,是否是在特定的阈值内 if (TARGET_POSITION - THRESHOLD..=TARGET_POSITION + THRESHOLD).contains(&pos) && arrow.direction.key_just_pressed(&keyboard_input) { commands.despawn(entity); } // 当箭头离开屏幕时,箭头消失 if pos >= 2. * TARGET_POSITION { commands.despawn(entity); } } } }

我们使用 Query 来查询所有实现了 Transform 和 Arrow 的实体。我们在查询中添加了 Entity,这样可以访问实体的“id”,然后我们可以在 Commands::despawn() 中根据它来消除实体。然后我们循环所有箭头,并检查 x 坐标值是否在点击的阈值内,如果是,则消除箭头。还有第二个检查,当箭头被错过离开屏幕时,它在最后也会被消除。它是在 x 坐标值大于等于 2. * TARGET_POSITION 时消除。

记得用 .add_system(despawn_arrows.system()) 将“系统”添加到 ArrowsPlugin 中,这样,运行游戏时,当我们斜着看的时候,也可以将其视为一种游戏!

增加基础 UI

在这一节中,我们将实现一些基本的 UI,目前只是显示了歌曲中的当前时间。我们会把它保存在 ui.rs 中:

#![allow(unused)] fn main() { use bevy::prelude::*; fn setup_ui( commands: &mut Commands, asset_server: ResMut, mut color_materials: ResMut, ) { let font = asset_server.load("fonts/FiraSans-Bold.ttf"); let material = color_materials.add(Color::NONE.into()); commands // 时间文本节点 .spawn(NodeBundle { style: Style { position_type: PositionType::Absolute, position: Rect { left: Val::Px(10.), top: Val::Px(10.), ..Default::default() }, ..Default::default() }, material: material.clone(), ..Default::default() }) .with_children(|parent| { parent .spawn(TextBundle { text: Text { value: "Time: 0.0".to_string(), font: font.clone(), style: TextStyle { font_size: 40.0, color: Color::rgb(0.9, 0.9, 0.9), ..Default::default() }, }, ..Default::default() }) .with(TimeText); }); } struct TimeText; }

在这个系统中,我们使用了父子关系模式(parenting),使得子实体可以相对于父实体进行转换。当我们把子实体加到父实体中后,给它一个合适的命名 with_children,它的参数是一个闭包,闭包接受一个类似于 Commands 的结构体类型 ChildBuilder 参数。在这个例子中,我创建了一个 NodeBundle 作为父实体,并将 TextBundle 作为子实体添加到其中。我们使用类似于 css 风格的 Style 组件让父节点坐落在屏幕的左上角。我们给文本实体增加了 TimeText 标记组件,这样我们就可以查询它,并且可以在任意帧中修改它。

现在,我们可以添加一个“系统”,它可以在每一帧中更新文本:

#![allow(unused)] fn main() { fn update_time_text(time: Res, mut query: Query) { // 歌曲在实时启动 3 秒后开始 let secs = time.seconds_since_startup() - 3.; // 在歌曲开始播放前不做任何处理 if secs < 0. { return; } for (mut text, _marker) in query.iter_mut() { text.value = format!("Time: {:.2}", secs); } } }

该系统使用内置的 Time 资源,以及具有 Text 和 TimeText 的组件的实体查询。之后,我们只需要循环遍历它们并更新文本值。在实际情况中,应该只有一个实体能匹配上查询,所以我们可以只需获取第一个实体并完成此次操作,但无论如何我还是倾向于使用循环。这样,如果将来我们决定创建多个“系统”,我们就不必修改其中的代码了。

我们通过创建一个插件来完成该代码文件的编写:

#![allow(unused)] fn main() { pub struct UIPlugin; impl Plugin for UIPlugin { fn build(&self, app: &mut AppBuilder) { app.add_startup_system(setup_ui.system()) .add_system(update_time_text.system()); } } }

现在,进入 main.rs,把 CameraUiBundle 加到 setup “系统”中,并导入插件:

use bevy::{input::system::exit_on_esc_system, prelude::*}; mod arrows; use arrows::ArrowsPlugin; mod consts; mod types; mod ui; use ui::UIPlugin; 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) .add_plugin(UIPlugin) // usize { self.score } pub fn corrects(&self) -> usize { self.corrects } pub fn fails(&self) -> usize { self.fails } } }

ScoreResource 是一个简单的结构体,它有三个 usize 类型的私有字段。我们没有将字段设计成公有,而是设计成成员属性的 getter 和 setter。通过这种方式,增加合适的箭头数量的唯一方法是通过 increase_correct,它也能增加积分,我们需要保证有了这个方法后不会又编写另一个类似功能的方法。在这款游戏中,我们不需要这样,因为我们只需在一个地方增加分数,但对于其他更大的项目而言,这种做法更让我们有信心维护,它不会造成意料之外的漏洞。

我们把这个资源添加到 main.rs,并加上下面的引入代码:

#![allow(unused)] fn main() { mod score; use score::ScoreResource; }

使用下面的代码替换 main 函数:

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() }) .init_resource::() // = 2. * TARGET_POSITION { commands.despawn(entity); // 新代码 score.increase_fails(); } } } }

改动很简单,我们增加 mut score: ResMut 作为系统的参数,以便我们可以编辑得分,我们添加了一个 increase_correct 方法,它会帮助我们增加积分,并且还有一个 increase_fails 方法,用于表示箭头离开屏幕消失时,积分增加失败。

现在,拥有一个得分系统很不错,但如果玩家无法看到自己的表现,那就没啥价值了!我们需要在 UI 模板中加一些东西,以显示分数:

#![allow(unused)] fn main() { use crate::ScoreResource; // 新代码 struct ScoreText; fn update_score_text(score: ChangedRes, mut query: Query) { for (mut text, _marker) in query.iter_mut() { text.value = format!( "Score: {}. Corrects: {}. Fails: {}", score.score(), score.corrects(), score.fails() ); } } pub struct UIPlugin; impl Plugin for UIPlugin { fn build(&self, app: &mut AppBuilder) { app.add_startup_system(setup_ui.system()) .add_system(update_time_text.system()) .add_system(update_score_text.system()); // Self { let speed_value = arrow.speed.value(); Self { spawn_time: arrow.click_time - (DISTANCE / speed_value) as f64, speed: arrow.speed, direction: arrow.direction, } } } }

让我们在 SongConfig 加几个字段,用来保存名称和音频:

#![allow(unused)] fn main() { pub struct SongConfig { pub name: String, pub song_audio: Handle, pub arrows: Vec, } }

我们用 Handle 保存音频,当我们把 SongConfigToml 转换为 SongConfig 时,我们会使用 AssetServer 加载它。

最后,我们将修改 load_config 来从文件中加载 SongConfig:

#![allow(unused)] fn main() { pub fn load_config(path: &str, asset_server: &AssetServer) -> SongConfig { // 打开文件并读取内容 let mut file = File::open(format!("assets/songs/{}", path)).expect("Couldn't open file"); let mut contents = String::new(); file.read_to_string(&mut contents) .expect("Couldn't read file into String"); // 使用 toml 和 Serde 进行解析 let parsed: SongConfigToml = toml::from_str(&contents).expect("Couldn't parse into SongConfigToml"); // 处理箭头 let mut arrows = parsed .arrows .iter() .map(|arr| ArrowTime::new(arr)) .collect::(); // 根据 spawn_time 对箭头排序 arrows.sort_by(|a, b| a.spawn_time.partial_cmp(&b.spawn_time).unwrap()); // 加载音频歌曲,并进行处理 let song_audio = asset_server.load(&*format!("songs/{}", parsed.filename)); SongConfig { name: parsed.name, song_audio, arrows, } } }

只有几行代码,但是很直接:先打开文件并读取文件的内容,使用 toml 库中的 from_str 方法解析文件内容,然后修改 ArrowTimeTomls 数组为 ArrowTimes 数组,我们使用 AssetServer::load 加载歌曲音频,然后返回新构建的 SongConfig。

注意:AssetServer::load 将在 assets 文件夹中搜索文件。File::open 不会从根目录开始查找,所以我们需要手动地将 assets 加到路径前缀中。

我们还需要修改 main.rs 中的 setup “系统”,修改 load_config 的调用方式,把 AssetServer 作为参数:

#![allow(unused)] fn main() { fn setup(commands: &mut Commands, asset_server: Res) { let config = types::load_config("test.toml", &asset_server); commands .spawn(Camera2dBundle::default()) .spawn(CameraUiBundle::default()) .insert_resource(config); } }

我们将在 assets 中创建一个 songs 文件夹,可以在其中保存所有的歌曲文件和对应的音频。现在,我们将创建一个名为 test.toml 的占位文件。你可以随意修改 arrows 以获得更详细的内容,现在只做一些简单测试:

name = "Test song" filename = "audio.mp3" arrows = [ { click_time = 1.00, speed = "Slow", direction = "Up" }, { click_time = 3.00, speed = "Slow", direction = "Down" }, { click_time = 5.00, speed = "Fast", direction = "Left" }, { click_time = 5.00, speed = "Slow", direction = "Right" }, { click_time = 7.00, speed = "Slow", direction = "Up" }, { click_time = 8.00, speed = "Medium", direction = "Up" }, { click_time = 9.00, speed = "Slow", direction = "Left" }, { click_time = 10.00, speed = "Slow", direction = "Right" }, { click_time = 10.50, speed = "Medium", direction = "Right" }, { click_time = 11.00, speed = "Slow", direction = "Up" }, { click_time = 11.00, speed = "Slow", direction = "Down" }, ]

现在,(合法地)下载你最喜欢的歌曲,将其放在 assets/songs 中,并将其命名为 audio.mp3。

你的 assets 目录应该如下方所示:

assets ├── fonts │ └── FiraSans-Bold.ttf ├── images │ ├── arrow_blue.png │ ├── arrow_border.png │ ├── arrow_green.png │ └── arrow_red.png └── songs ├── audio.mp3 └── test.toml

现在运行游戏,应该和上一节没有太大不同,只是你得到的箭头是根据外部文件配置加载的!如果你问我的话,我觉得相当酷 :)。

播放音频

你可能注意到,在上一节中,我们做了一些加载歌曲的逻辑,但当我们玩游戏时,歌曲还是不能播放。现在,我们来实现播放!为此,我新建了一个文件,audio.rs,其中只含有一个“系统”:

#![allow(unused)] fn main() { audio.rs use crate::types::SongConfig; use bevy::prelude::*; fn start_song(audio: Res, time: Res, config: Res) { // 歌曲将在实时的 3 秒后开始播放 let secs = time.seconds_since_startup(); let secs_last = secs - time.delta_seconds_f64(); if secs_last = 0.02 { // 一旦箭头穿过目标区域,则开始下落 transform.translation.y -= time.delta_seconds() * distance_after_target * 2.; // 根据箭头地距离改变下降因子(比例) let scale = ((100. - distance_after_target / 3.) / 100.).max(0.2); transform.scale = Vec3::splat(scale); // 根据距离和速度旋转箭头 transform.rotate(Quat::from_rotation_z( -distance_after_target * arrow.speed.multiplier() / 460., )); } } } }

这是一串充满魔力的数字和公式,我在经过多次不同的尝试得出的结论。我建议你试试其它内容!

我们将其分析一下:首先,我们使用一个随着箭头移动而减小的公式来获得一个比例。然后,使用 max 来确保比例至少为 0.2。之后,我们使用 Transform::rotate 来旋转箭头。对于旋转,我们使用 Speed::multiplier,如果箭头的速度更快,就会旋转地更快。下面是所有这些效果组合在一起的样子:

视频资源

太酷了!再次强调,你可以随时即兴发挥,添加其他逻辑,让它更加酷炫。游戏有一半的乐趣来自于制作你喜欢的花哨特效!

着色器背景

接下来我们要做的是替换灰色背景。选择之一是使用 ClearColor 资源,以静态颜色作为背景。这里是一个使用示例。这种方式很简单,我们只需要在 main 函数中加上 .add_resource(ClearColor(Color::rgb(0.5, 0.5, 0.9))),缺点是只能将背景改为一个平面颜色,我们希望看到更加生动的内容。着色器可以帮助我们!

我们将在所有元素下面制作一个窗口大小的精灵,我们将添加着色器材料。这样我们会有一个背景,也就是设置一个着色器作为背景。

当我们用着色器添加一些其他东西时,我们创建一个名为 shaders 的文件夹,用于存放相关文件。我们先打开 shaders/mod.rs:

#![allow(unused)] fn main() { use bevy::{ prelude::*, reflect::TypeUuid, render::{ pipeline::{PipelineDescriptor, RenderPipeline}, render_graph::{base, RenderGraph}, renderer::RenderResources, shader::{ShaderStage, ShaderStages}, }, window::WindowResized, }; mod background; use background::*; }

现在,我们只添加了一些导入,声明了 background 模块,接下来就创建这个模块:

#![allow(unused)] fn main() { use super::*; pub struct Background; pub fn setup_background( commands: &mut Commands, mut pipelines: ResMut, mut shaders: ResMut, window: Res, ) { // 创建一个新的着色器管道 let pipeline_handle = pipelines.add(PipelineDescriptor::default_config(ShaderStages { vertex: shaders.add(Shader::from_glsl( ShaderStage::Vertex, include_str!("background.vert"), )), fragment: Some(shaders.add(Shader::from_glsl( ShaderStage::Fragment, include_str!("background.frag"), ))), })); commands .spawn(SpriteBundle { render_pipelines: RenderPipelines::from_pipelines(vec![RenderPipeline::new( pipeline_handle, )]), transform: Transform::from_scale(Vec3::new( window.width + 10., window.height + 10., 1., )), ..Default::default() }) .with(Background); } }

在这个文件中,我们添加了一个“启动系统”,它首先创建了 PipelineDescriptor,其中包含顶点和 fragment 着色器。这些都是用 include_str 宏从文件中添加进来的。然后我们会创建一个带有 RenderPipelines 组件的 SpriteBundle,并将我们创建的管道描述符传入。最后,我们添加了一个 Background 标记组件。

我们正在使用 WindowDescriptor 资源来得到屏幕宽度和高度,这样就可以进行正确的转换。如果玩家将窗口变大,会出现一个小问题,因为我们的背景大小不变,导致后面的灰色背景被显示出来!为了解决这个问题,我们添加另一个“系统”:

#![allow(unused)] fn main() { /// 当窗口大小变化时,背景大小跟着改变 pub fn update_background_size( mut event_reader: Local, events: Res, mut background: Query, ) { for event in event_reader.iter(&events) { for (mut transform, _) in background.iter_mut() { transform.scale = Vec3::new(event.width, event.height, 1.); } } } }

它监听 WindowResized 事件,该事件在每次调整窗口大小时会提供新的窗口宽高。

正如你注意到的,在 Bevy 中有一种易于使用且优雅的模式。事件也不例外。要使用一个事件,我们需要添加一个 Event 资源和一个 Local 作为参数。然后我们就可以通过事件资源来使用 EventReader::iter,该事件资源将给我们提供需要处理的事件。

实际使用着色器时是使用 Rust 的 include_str 宏添加的,它将以字符串的形式添加文件内容。首先,我们创建 background.vert:

#version 450 layout(location = 0) in vec3 Vertex_Position; layout(location = 1) in vec3 Vertex_Normal; layout(location = 2) in vec2 Vertex_Uv; layout(location = 1) out vec2 v_Uv; layout(set = 0, binding = 0) uniform Camera { mat4 ViewProj; }; layout(set = 1, binding = 0) uniform Transform { mat4 Model; }; void main() { v_Uv = Vertex_Uv; gl_Position = ViewProj * Model * vec4(Vertex_Position, 1.0); }

我们在这里只需做一件特殊的事是添加 v_Uv(纹理的 uv 坐标)作为输出,这样,我们就可以在 fragment 着色器中使用它,现在我们在 background.frag 中创建它:

// shaders/background.frag #version 450 layout(location = 0) in vec4 v_Position; layout(location = 1) in vec2 v_Uv; layout(location = 0) out vec4 o_Target; void main() { o_Target = vec4(v_Uv, 0.1, 1.0); }

在这个着色器中,我们只返回基于背景的 uv 坐标的简单颜色。

我们现在需要注册这些创建的“系统”。我们在 shaders/mod.rs 中添加 ShaderPlugin:

#![allow(unused)] fn main() { // shaders/mod.rs pub struct ShadersPlugin; impl Plugin for ShadersPlugin { fn build(&self, app: &mut AppBuilder) { app.add_startup_system(setup_background.system()) .add_system(update_background_size.system()); } } }

现在我们可以在 main.rs 中导入它:

mod shaders; use shaders::ShadersPlugin; 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() }) .init_resource::() .add_startup_system(setup.system()) .add_system(exit_on_esc_system.system()) .add_plugins(DefaultPlugins) .add_plugin(ArrowsPlugin) .add_plugin(UIPlugin) .add_plugin(AudioPlugin) .add_plugin(ShadersPlugin) //


【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有