• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    迪恩网络公众号

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

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

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,浏览[示例]](https://github.com/bevyengine/bevy/tree/master/examples),或者加入官方的 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 <[email protected]>"]
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,
    pub direction: Directions,
}

#[derive(Debug)]
pub struct SongConfig {
    pub arrows: Vec<ArrowTime>,
}

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

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,其中包含了不同的速度和方向的箭头:

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 系统修改成下方所示:

mod types;

fn setup(commands: &mut Commands) {
    let config = types::load_config();

    commands
        .spawn(Camera2dBundle::default())
        .insert_resource(config);
}

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

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

定时生成箭头

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

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

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

我们还需要给 Arrow 新增 SpeedDirections 字段:

// 在顶部
use crate::types::*;

/// “精灵实体”上的组件
struct Arrow {
    speed: Speed,
    direction: Directions,
}

/// 生成箭头
fn spawn_arrows(
    commands: &mut Commands,
    mut song_config: ResMut<SongConfig>,
    materials: Res<ArrowMaterialResource>,
    time: Res<Time>,
) {
    // 我们得到了从启动到当前的时间(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() 方法:

/// 把箭头向前移动
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() * arrow.speed.value();
    }
}

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

视频资源

增加目标区域箭头

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

struct TargetArrow;

fn setup_target_arrows(commands: &mut Commands, materials: Res<ArrowMaterialResource>) {
    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 作为“启动系统”:

pub struct ArrowsPlugin;
impl Plugin for ArrowsPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ArrowMaterialResource>()
            .add_startup_system(setup_target_arrows.system())
            .add_system(spawn_arrows.system())
            .add_system(move_arrows.system());
    }
}

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

视频资源

按键按下时清除箭头

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

/// 用户在箭头到达尽头前按下正确的按键,箭头消失。
fn despawn_arrows(
    commands: &mut Commands,
    query: Query<(Entity, &Transform, &Arrow)>,
    keyboard_input: Res<Input<KeyCode>>,
) {
    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 来查询所有实现了 TransformArrow 的实体。我们在查询中添加了 Entity,这样可以访问实体的“id”,然后我们可以在 Commands::despawn() 中根据它来消除实体。然后我们循环所有箭头,并检查 x 坐标值是否在点击的阈值内,如果是,则消除箭头。还有第二个检查,当箭头被错过离开屏幕时,它在最后也会被消除。它是在 x 坐标值大于等于 2. * TARGET_POSITION 时消除。

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

增加基础 UI

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

use bevy::prelude::*;

fn setup_ui(
    commands: &mut Commands,
    asset_server: ResMut<AssetServer>,
    mut color_materials: ResMut<Assets<ColorMaterial>>,
) {
    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 标记组件,这样我们就可以查询它,并且可以在任意帧中修改它。

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

fn update_time_text(time: Res<Time>, mut query: Query<(&mut Text, &TimeText)>) {
    // 歌曲在实时启动 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 资源,以及具有 TextTimeText 的组件的实体查询。之后,我们只需要循环遍历它们并更新文本值。在实际情况中,应该只有一个实体能匹配上查询,所以我们可以只需获取第一个实体并完成此次操作,但无论如何我还是倾向于使用循环。这样,如果将来我们决定创建多个“系统”,我们就不必修改其中的代码了。

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

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) // <--- 新代码
        .run();
}

fn setup(commands: &mut Commands) {
    let config = types::load_config();

    commands
        .spawn(Camera2dBundle::default())
        .spawn(CameraUiBundle::default()) // <--- 新代码
        .insert_resource(config);
}

CameraUiBundleCamera2dBundle 很类似,但对于 UI 元素。如果不显式地添加它,文本就不会显示。因为我们之前已经添加了它,现在可以运行游戏,在屏幕上可以看到华丽地文字:

视频资源

增加得分

在本节中,我们将创建得分系统,以便于玩家能过够在每次玩耍后看到自己的表现。为此,我们打开另一个文件 score.rs。在其中,我们将创建一个新的资源来记录分数以及正确的箭头和失败的箭头数量:

use crate::consts::*;

#[derive(Default)]
pub struct ScoreResource {
    corrects: usize,
    fails: usize,

    score: usize,
}

impl ScoreResource {
    /// 增加合适的次数值以及得分
    pub fn increase_correct(&mut self, distance: f32) -> usize {
        self.corrects += 1;

        // 根据按下的按键的及时性获取一个 0 到 1 的值
        let score_multiplier = (THRESHOLD - distance.abs()) / THRESHOLD;
        // 最少增加 10 分,最多不超过 100 分。
        let points = (score_multiplier * 100.).min(100.).max(10.) as usize;
        self.score += points;

        points
    }

    /// 统计失败的次数
    pub fn increase_fails(&mut self) {
        self.fails += 1;
    }

    // Getters

    pub fn score(&self) -> 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,并加上下面的引入代码:

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::<ScoreResource>() // <--- New
        .add_startup_system(setup.system())
        .add_system(exit_on_esc_system.system())
        .add_plugins(DefaultPlugins)
        .add_plugin(ArrowsPlugin)
        .add_plugin(UIPlugin)
        .run();
}

完成之后,我们就能使用“系统”上的资源了。也就是说,我们对 arrows.rs 文件中的 despawn_arrows 系统做一些调整,这样,当箭头消失时,就会触发调用增加积分方法:

use crate::ScoreResource;

/// 当它们到达终点时,正确点击了按钮,就会消除箭头
fn despawn_arrows(
    commands: &mut Commands,
    query: Query<(Entity, &Transform, &Arrow)>,
    keyboard_input: Res<Input<KeyCode>>,
    
    // 新代码
    mut score: ResMut<ScoreResource>,
) {
    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);

            // 新代码
            let _points = score.increase_correct(TARGET_POSITION - pos);
        }

        // 离开屏幕时,箭头消失
        if pos >= 2. * TARGET_POSITION {
            commands.despawn(entity);

            // 新代码
            score.increase_fails();
        }
    }
}

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

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

use crate::ScoreResource;

// 新代码
struct ScoreText;
fn update_score_text(score: ChangedRes<ScoreResource>, mut query: Query<(&mut Text, &ScoreText)>) {
    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()); // <--- 新代码
    }
}

update_score_text 中,我们使用 ChangedRes,而非普通的 Res。它们的区别在于后者会在每一帧都会运行一次,而 ChangedRes 只会在资源发生改变时才会运行。这很酷,因为分数不会再每一帧里都发生变化,所以这样可以节省一些开销,只需在需要时才更新文本。然后,它在具有 ScoreText 组件的实体上设置文本值(和 TimeText 一样,应该只有一个,但为什么要限制)。

我们还要修改 setup_ui 中的一些东西,在第二次产生 NodeBundleTextBundle 时,使用 ScoreText 组件:

fn setup_ui(
    commands: &mut Commands,
    asset_server: ResMut<AssetServer>,
    mut color_materials: ResMut<Assets<ColorMaterial>>,
) {
    let font = asset_server.load("fonts/FiraSans-Bold.ttf");
    let material = color_materials.add(Color::NONE.into());

    commands
        // Time 文本节点
        .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.8, 0.8, 0.8),
                            ..Default::default()
                        },
                    },
                    ..Default::default()
                })
                .with(TimeText);
        })
        
        // 新代码
        .spawn(NodeBundle {
            style: Style {
                position_type: PositionType::Absolute,
                position: Rect {
                    left: Val::Px(10.),
                    bottom: Val::Px(10.),
                    ..Default::default()
                },
                ..Default::default()
            },
            material,
            ..Default::default()
        })
        .with_children(|parent| {
            parent
                .spawn(TextBundle {
                    text: Text {
                        value: "Score: 0. Corrects: 0. Fails: 0".to_string(),
                        font,
                        style: TextStyle {
                            font_size: 40.0,
                            color: Color::rgb(0.8, 0.8, 0.8),
                            ..Default::default()
                        },
                    },
                    ..Default::default()
                })
                .with(ScoreText);
        });
}

我已经打算把这个文本设置在屏幕的左下角,但如果你想练习,你可以尝试把它设置在左上角时间文本的下面。

试试吧!运行游戏,看看我们的成果如何:

视频资源

你可以随心所欲地为 UI 增减东西!我们在这里所做的是比较基础地展示文本。

从配置文件中加载数据

目前我们游戏中的箭头是硬编码的。目前这一切都还好,但我们希望玩家能创作自己的歌曲。我们不会通过制作自定义文件格式或任何花哨的东西使配置复杂化,所以我们将通过 TOMLserde 库,来使用经过试用和测试的 TOML 格式。这两个 crate 将帮助我们非常容易地实现 SongConfig 结构的 TOML 序列化和反序列化。

Cargo.toml 文件加入以下内容:

toml = "0.5.8"
serde = "1.0.118"
serde_derive = "1.0.118"

我们现在可以编辑 types.rs 文件,并且导入准备好的类型和反序列化格式,向 DirectionsSpeed 类型中增加 DeserializeSerialize trait 实现声明:

use bevy::prelude::*;

use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::prelude::*;

#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Serialize)]
pub enum Directions {
    Up,
    Down,
    Left,
    Right,
}
#[derive(Copy, Clone, Debug, Deserialize, Serialize)]
pub enum Speed {
    Slow,
    Medium,
    Fast,
}

现在,我们有个小问题。我们的 ArrowTime 结构体有 spawn_time 字段,但是我们想在 TOML 文件中写入点击时间,所以我们不能直接在 Serde 中使用 ArrowTimeSongConfig。我们会通过创建两个新结构体来解决这个问题,ArrowTimeTomlSongConfigToml,它们对应的数据将会被包含在 TOML 文件中:

#[derive(Deserialize, Debug)]
struct SongConfigToml {
    pub name: String,
    pub filename: String,
    pub arrows: Vec<ArrowTimeToml>,
}

#[derive(Deserialize, Serialize, Debug)]
pub struct ArrowTimeToml {
    pub click_time: f64,
    pub speed: Speed,
    pub direction: Directions,
}

name 字段用于存储歌曲的名称,filename 是音频文件的路径,arrowsArrowTimeTomls 列表。ArrowTimeTomlArrowTime 的字段大部分一样,不同的是前者有 click_time,后者没有,取而代之的是 spawn_time

我们也会把 ArrowTime::new 的入参改为 ArrowTimeToml 类型:

impl ArrowTime {
    fn new(arrow: &ArrowTimeToml) -> 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 加几个字段,用来保存名称和音频:

pub struct SongConfig {
    pub name: String,
    pub song_audio: Handle<AudioSource>,
    pub arrows: Vec<ArrowTime>,
}

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

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

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::<Vec<ArrowTime>>();
    // 根据 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 作为参数:

fn setup(commands: &mut Commands, asset_server: Res<AssetServer>) {
    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

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


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
热门推荐
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap