【譯】基於 Rust 用 Bevy 實現節奏大師游戲


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 <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,
    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

現在運行游戲,應該和上一節沒有太大不同,只是你得到的箭頭是根據外部文件配置加載的!如果你問我的話,我覺得相當酷 😃。

播放音頻

你可能注意到,在上一節中,我們做了一些加載歌曲的邏輯,但當我們玩游戲時,歌曲還是不能播放。現在,我們來實現播放!為此,我新建了一個文件,audio.rs,其中只含有一個“系統”:

audio.rs
use crate::types::SongConfig;
use bevy::prelude::*;

fn start_song(audio: Res<Audio>, time: Res<Time>, config: Res<SongConfig>) {
    // 歌曲將在實時的 3 秒后開始播放
    let secs = time.seconds_since_startup();
    let secs_last = secs - time.delta_seconds_f64();

    if secs_last <= 3. && 3. <= secs {
        audio.play(config.song_audio.clone());
    }
}

pub struct AudioPlugin;
impl Plugin for AudioPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_system(start_song.system());
    }
}

start_song 使用 Audio 資源,在進入游戲 3 秒后開始播放歌曲。如你所看到的,我們使用了與“生成箭頭”相同的方法。

注意:我們本可以復用 Timer,但當我們制作一個菜單來選擇歌曲時,會帶來一定的復雜度。何況嘗試使用定時器重寫,是個很不錯的練習方式!

main.rs 中,我們添加以下內容:

// main.rs
mod audio;
use audio::AudioPlugin;

main 函數中,在所有插件加載的最后,添加 .add_plugin(AudioPlugin)。現在運行游戲應該會讓歌曲播放了,因為計時器在運行!

至此,我們完成了游戲核心實現。你可以自由地在此基礎上構建你自己地東西,但我建議你再往后看看,因為我們將致力於讓游戲更加✨漂亮✨。

美化失敗的箭頭

首先,我們可以改進失敗箭頭的外觀。目前,它們只是飛向遠處。我們希望給玩家一些暗示,提醒他們那個箭頭失敗了。

我們要做的是讓箭頭在穿過目標區域后,“脫離”那條線。為了實現這一點,我們在 arrows.rs 中的 move_arrows 函數中加點東西:

/// 箭頭前移
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();

        // 新加代碼
        let distance_after_target = transform.translation.x - (TARGET_POSITION + THRESHOLD);
        if distance_after_target >= 0.02 {
            transform.translation.y -= time.delta_seconds() * distance_after_target * 2.;
        }
    }
}

我們所做的是獲取目標到目標區域箭頭符號的 x 坐標距離差,如果是正的,意味着它已經移動到目標區域外,我們就在它的 y 坐標減去一點,這樣它就會下降。通過 time.delta_seconds() * distance_after_target,我們讓每一幀的下降因子變大,這會讓箭頭以弧線的形式下降。2. 只是一個特定的常量,使弧線更好看(我覺得是),你可以根據你自己的意願調整它!

效果見下方鏈接的視頻:

視頻資源

很好,我們再給它加點效果。我們讓箭頭在下降時收縮並旋轉:

/// 箭頭前移
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();

        let distance_after_target = transform.translation.x - (TARGET_POSITION + THRESHOLD);
        if distance_after_target >= 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

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 模塊,接下來就創建這個模塊:

use super::*;

pub struct Background;
pub fn setup_background(
    commands: &mut Commands,
    mut pipelines: ResMut<Assets<PipelineDescriptor>>,
    mut shaders: ResMut<Assets<Shader>>,
    window: Res<WindowDescriptor>,
) {
    // 創建一個新的着色器管道
    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 資源來得到屏幕寬度和高度,這樣就可以進行正確的轉換。如果玩家將窗口變大,會出現一個小問題,因為我們的背景大小不變,導致后面的灰色背景被顯示出來!為了解決這個問題,我們添加另一個“系統”:

/// 當窗口大小變化時,背景大小跟着改變
pub fn update_background_size(
    mut event_reader: Local<EventReader<WindowResized>>,
    events: Res<Events<WindowResized>>,
    mut background: Query<(&mut Transform, &Background)>,
) {
    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<T> 資源和一個 Local<EventReader<T>> 作為參數。然后我們就可以通過事件資源來使用 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

// 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::<ScoreResource>()
        .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) // <--- New
        .run();
}

運行游戲你可以看到下方鏈接視頻中展示的效果:

視頻資源

使用時間着色器

繼續,我們會有一些奇特的場景,酷!理想情況下,我們希望游戲背景隨着時間有一些變化。

Bevy 沒有(至少現在沒有)添加時間和分辨率到着色器中作為輸入,所以我們將不得不手動添加它們。希望這點能在 Bevy 中盡快得到改善。

我們再次打開 shaders/mod.rs文件,並增加以下代碼:

#[derive(RenderResources, Default, TypeUuid)]
#[uuid = "0320b9b8-b3a3-4baa-8bfa-c94008177b17"]
/// 將資源傳遞給着色器
pub struct ShaderInputs {
    time: f32,
    resolution: Vec2,
}

/// 在每一幀中,更新 ShaderInputs 中的時間
fn update_time(time: Res<Time>, mut nodes: Query<&mut ShaderInputs>) {
    let time = time.seconds_since_startup();
    for mut node in nodes.iter_mut() {
        node.time = time as f32;
    }
}

/// 如果窗口大小發生改變,更新 ShaderInputs 的分辨率
fn update_resolution(
    mut event_reader: Local<EventReader<WindowResized>>,
    events: Res<Events<WindowResized>>,
    mut background: Query<&mut ShaderInputs>,
) {
    for event in event_reader.iter(&events) {
        for mut node in background.iter_mut() {
            node.resolution = Vec2::new(event.width / event.height, 1.);
        }
    }
}

/// 在渲染圖形時,添加 ShaderInputs 作為一個 edge
fn setup_render_graph(mut render_graph: ResMut<RenderGraph>) {
    render_graph.add_system_node("inputs", RenderResourcesNode::<ShaderInputs>::new(true));
    render_graph
        .add_node_edge("inputs", base::node::MAIN_PASS)
        .unwrap();
}

我們正在創建一個新的 ShaderInputs 結構體,將其作為渲染圖形邊添加到 setup_render_graph 中,並將其加到“啟動系統”中。update_timeupdate_resolution 是兩個負責更新 ShaderInputs 組件值的系統。注意在 update_resolution 中我們是通過監聽 WindowResized 事件來實現,而非更新每一幀。

現在,用以下代碼替換 ShaderPlugin 中的實現,添加所有系統和資源:

pub struct ShadersPlugin;
impl Plugin for ShadersPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_asset::<ShaderInputs>() // <--- 新代碼
            .add_startup_system(setup_render_graph.system()) // <--- 新代碼
            .add_system(update_time.system()) // <--- 新代碼
            .add_system(update_resolution.system()) // <--- 新代碼
            .add_startup_system(setup_background.system())
            .add_system(update_background_size.system());
    }
}

我們現在要向之前創建的背景實體添加 ShaderInputs 組件,提供初始值:

// shaders/background.rs
pub fn setup_background(
    commands: &mut Commands,
    mut pipelines: ResMut<Assets<PipelineDescriptor>>,
    mut shaders: ResMut<Assets<Shader>>,
    window: Res<WindowDescriptor>,
) {
    // 創建新的着色器管道
    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)
        // New
        .with(ShaderInputs {
            time: 0.,
            resolution: Vec2::new(window.width / window.height, 1.),
        });
}

這些值在添加一些東西后,現在可以在着色器上使用了:

// 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;

// New
layout(set = 2, binding = 0) uniform ShaderInputs_time {
    float time;
};
// New
layout(set = 2, binding = 1) uniform ShaderInputs_resolution {
    vec2 resolution;
};

void main() {
    o_Target = vec4(v_Uv, abs(sin(time)), 1.0);
}

基本上,我們必須對 ShaderInputs 的每個字段增加 uniform,它包含 binding 對應增加的值,以及形如 ShaderInputs_$name 的名字,其中的 $name 是字段名。現在我們可以使用着色器內部的變量了!

現在看起來應該如下方鏈接視頻所示:

視頻資源

就個人而言,我選擇了以下配置的着色器作為背景:

#version 450

#define TWO_PI 6.28318530718

layout(location = 0) in vec4 v_Position;
layout(location = 1) in vec2 v_Uv;
layout(location = 0) out vec4 o_Target;

layout(set = 2, binding = 0) uniform ShaderInputs_time {
    float time;
};
layout(set = 2, binding = 1) uniform ShaderInputs_resolution {
    vec2 resolution;
};

vec3 hsb2rgb(in vec3 c){
    vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0),
                             6.0)-3.0)-1.0,
                     0.0,
                     1.0 );
    rgb = rgb*rgb*(3.0-2.0*rgb);
    return c.z * mix( vec3(1.0), rgb, c.y);
}

float wave_sin(in float x) {
    float amplitude = 0.5;
    float frequency = 1.0;
    float y = sin(x * frequency);
    float t = 0.01*(-time*50.0);
    y += sin(x * frequency * 2.1 + t)*4.5;
    y += sin(x * frequency * 1.72 + t*1.121)*4.0;
    y += sin(x * frequency * 2.221 + t*0.437)*5.0;
    y += sin(x * frequency * 3.1122+ t*4.269)*2.5;
    y *= amplitude*0.06;
    return y;
}
float wave_cos(in float x) {
    float amplitude = 0.5;
    float frequency = 2.0;
    float y = cos(x * frequency);
    float t = 0.01*(-time*30.0);
    y += cos(x * frequency * 2.1 + t)*4.5;
    y += cos(x * frequency * 1.72 + t*1.121)*4.0;
    y += cos(x * frequency * 2.221 + t*0.437)*5.0;
    y += cos(x * frequency * 3.1122+ t*4.269)*2.5;
    y *= amplitude*0.06;
    return y;
}
vec2 wave(in vec2 v) {
    return vec2(wave_sin(v.x), wave_cos(v.y));
}

void main() {
    vec2 uv = wave(v_Uv);
    vec3 color = hsb2rgb(vec3(uv.x + sin(uv.y), 0.7, 1.0));

    o_Target = vec4(color,1.0);
}

它移動周圍的顏色,產生好看的波浪,效果如下方鏈接視頻所示:

視頻資源

現在輪到你玩它了,找到你喜歡的東西。如果你不太理解着色器,你可以嘗試對上面的着色器做一些小修改,你也可以去 Shadertoy 查找一些資料。例如,下面是一個 shader 配置,它由 Danilo Guanabara 轉換自 Shadertoy:

// shaders/background.frag
#version 450

// Creation, by Silexars (Danilo Guanabara)
// From https://www.shadertoy.com/view/XsXXDn

layout(location = 0) in vec4 v_Position;
layout(location = 1) in vec2 v_Uv;
layout(location = 0) out vec4 o_Target;

layout(set = 2, binding = 0) uniform ShaderInputs_time {
    float time;
};
layout(set = 2, binding = 1) uniform ShaderInputs_resolution {
    vec2 resolution;
};

void main() {
    vec3 c;
    vec2 r = resolution;
    float l,z=time;
    for(int i=0;i<3;i++) {
        vec2 uv,p = v_Uv; // / r;
        uv = p;
        p -= 0.5;
        p.x *= r.x/r.y;
        z += 0.07;
        l = length(p);
        uv += p/l*(sin(z)+1.)*abs(sin(l*9.0-z*2.0));
        c[i] = (0.01)/length(abs(mod(uv,1.0)-0.5));
    }
    o_Target = vec4(c/l,time);
}

效果如下方鏈接視頻所示:

視頻資源

美化點擊動畫

我們之前已經為失敗的箭頭添加了有趣動畫,但當成功命中箭頭時,我們啥也沒做。它就這樣消失了,這有點讓人失望。我們將這一點進行改進。

我們將有四個不同的“精靈”,每個精靈在每個目標區域箭頭下都有一個着色器。然后,每當正確命中箭頭時,相應的精靈下的着色器就會啟動動畫,動畫持續一段時間后,再消失。

注意:這個如果用技術實現會比較復雜,但這樣可以展示很多東西。實現這一點有個捷徑是在每次正確點擊箭頭時創建一個精靈,然后幾秒鍾后刪除掉。

打開 shaders/target_arrows.rs 文件。我們為這些精靈添加一個組件(我把它叫做“普通目標箭頭”),它只是指示目標箭頭的方向和位置:

pub struct TargetArrowSparkle {
    direction: Directions,
}

我們再添加另一條邊到渲染圖中,並將另一個結構體作為參數傳遞給着色器。這將保留最近一次正確命中箭頭的時間,以及對應得分:

// shaders/target_arrows.rs
#[derive(RenderResources, TypeUuid)]
#[uuid = "c9400817-b3a3-4baa-8bfa-0320b9b87b17"]
pub struct TimeSinceLastCorrect {
    last_time: f32,
    points: f32,
}

請注意,當我們向目標箭頭添加 TimeSinceLastCorrect 組件時,每個組件都有自己的值,這些值是不共享的,所以我們需要單獨設定它們。

現在,我們添加一個“啟動系統”用於創建精靈:

// shaders/target_arrows.rs
use super::*;
use crate::consts::*;
use crate::types::Directions::{self, *};

pub fn setup_target_arrows(
    commands: &mut Commands,
    mut pipelines: ResMut<Assets<PipelineDescriptor>>,
    mut shaders: ResMut<Assets<Shader>>,
    mut render_graph: ResMut<RenderGraph>,
    window: Res<WindowDescriptor>,
) {
    // 創建一個新的着色器管道
    let pipeline_handle = pipelines.add(PipelineDescriptor::default_config(ShaderStages {
        vertex: shaders.add(Shader::from_glsl(
            ShaderStage::Vertex,
            include_str!("target_arrows.vert"),
        )),
        fragment: Some(shaders.add(Shader::from_glsl(
            ShaderStage::Fragment,
            include_str!("target_arrows.frag"),
        ))),
    }));

    // 把 TimeSinceLastCorrect 加到渲染圖中
    render_graph.add_system_node(
        "last_time",
        RenderResourcesNode::<TimeSinceLastCorrect>::new(true),
    );
    render_graph
        .add_node_edge("last_time", base::node::MAIN_PASS)
        .unwrap();

    let directions = [Up, Down, Left, Right];
    for direction in directions.iter() {
        // z 值不同,所以它們不會重疊
        let z = match direction {
            Up => 0.3,
            Down => 0.4,
            Left => 0.5,
            Right => 0.6,
        };

        let mut transform =
            Transform::from_translation(Vec3::new(TARGET_POSITION, direction.y(), z));
        transform.scale = Vec3::new(300., 300., 1.);
        commands
            .spawn(SpriteBundle {
                render_pipelines: RenderPipelines::from_pipelines(vec![RenderPipeline::new(
                    pipeline_handle.clone(),
                )]),
                transform,
                visible: Visible {
                    is_transparent: true,
                    ..Default::default()
                },
                ..Default::default()
            })
            .with(TargetArrowSparkle {
                direction: *direction,
            })
            .with(TimeSinceLastCorrect {
                last_time: 3.,
                points: 0.5,
            })
            .with(ShaderInputs {
                time: 0.,
                resolution: Vec2::new(window.width / window.height, 1.),
            });
    }
}

這個系統就像是 setup_target_arrowssetup_render_graphsetup_background 的混合體。我們首先創建一個 PipelineDescriptor,然后添加 TimeSinceLastCorrect 作為渲染圖的邊,最后我們創建一個存放所有方向的數組,然后迭代它,創建 4 個精靈組,並添加 TargetArrowSparkleTimeSinceLastCorrectShaderInputs 組件。

我們把 last_time 設為 3 秒進行測試。這樣,當時間達到三秒時,動畫就開始了。當我們設置好所有內容后,我們會將其更改為負值,因為我們希望箭頭在被正確點擊時觸發。

我們還需要為這個着色器創建新文件:

#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);
}

vertex 着色器的實現基本上和 shaders/background.vert 一樣。更有趣的是 shaders/target_arrows.frag

# shaders/target_arrows.frag
#version 450

#define TWO_PI 6.28318530718

layout(location = 0) in vec4 v_Position;
layout(location = 1) in vec2 v_Uv;
layout(location = 0) out vec4 o_Target;

layout(set = 2, binding = 0) uniform ShaderInputs_time {
    float time;
};
layout(set = 2, binding = 1) uniform ShaderInputs_resolution {
    vec2 resolution;
};
layout(set = 3, binding = 0) uniform TimeSinceLastCorrect_last_time {
    float last_time;
};
layout(set = 3, binding = 1) uniform TimeSinceLastCorrect_points {
    float points;
};

float interval(in float a, in float b, in float val) {
    return step(a, val) * smoothstep(1.0 - b - 0.1, 1.0 - b, 1. - val);
}

float circle(in vec2 uv, in float _radius){
    vec2 dist = uv - vec2(0.5);
    return 1.0 - smoothstep(_radius - (_radius * 0.01),
                            _radius + (_radius * 0.01),
                            dot(dist, dist) * 4.0);
}

float smoothcircle(in vec2 _st, in float s){
    vec2 dist = _st-vec2(0.5);
    return 4. * dot(dist,dist) / (s);
}

void main() {
    // 0. when the circle shouldn't be shown
    float alpha = interval(last_time, last_time + 0.6, time);

    // Circle radius
    float radius = time - last_time;
    // 0. for not in circle, 1. for circle
    // float circle = circle(v_Uv, radius) * (1. - circle(v_Uv, radius - 0.1));
    float circle = smoothcircle(v_Uv, radius) * smoothcircle(v_Uv, radius) * circle(v_Uv, radius);

    // rgb(92, 175, 29);
    vec3 colorMin = vec3(0.36078431373,0.6862745098,0.1137254902);
    // rgb(255, 255, 6);
    vec3 colorMax = vec3(1.,1.,0.02352941176);

    // Get color according to points
    vec3 color = mix(colorMin, colorMax, points);

    o_Target = vec4(color * circle, circle * alpha);
}

這個着色器有點復雜,但簡而言之,它的作用是創建一個半徑隨時間增加的圓。圓圈在 last_time 后存在 0.6 秒。我們把值設為 3 來添加 TimeSinceLastCorrect,並且和 ShaderInputs 一樣,每個字段的綁定值都會增加。圓形的顏色根據點的不同而有所變化。

我們還需要把 setup_target_arrows 加到 ShaderPlugin 中:

// shaders/mod.rs
mod target_arrows;
use target_arrows::*;

pub struct ShadersPlugin;
impl Plugin for ShadersPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_asset::<ShaderInputs>()
            .add_asset::<TimeSinceLastCorrect>()
            .add_startup_system(setup_render_graph.system())
            .add_system(update_time.system())
            .add_system(update_resolution.system())
            .add_startup_system(setup_background.system())
            .add_system(update_background_size.system())
            .add_startup_system(setup_target_arrows.system()); // <--- New
    }
}

現在運行游戲,將看到如下面鏈接視頻所展示的效果:

視頻資源

如你所看到的,就在歌曲開始后,第 3 秒時,所有的圓圈開始變大,約過半秒后它們就消失了。太好了,這意味這着色器和定時器都正常工作了!我們仍然缺少一些東西來更新一些值,所以我們添加一個“系統”,用於當箭頭被正確的按下時,更新 last_time 值。在此之前,我們使其默認值為負的:

// shaders/target_arrows.rs
.with(TimeSinceLastCorrect {
    last_time: -10.,
    points: 0.,
})

現在如果你運行這個游戲,圓圈就不會出現了。

之前,我們已經看到了如何偵聽事件,但我們仍然沒有看到硬幣的另一面。我們現在就准備探索一下。我們將創建一個正確點擊箭頭時發生的事件。我們在 arrows.rs 文件中的 despawn_arrows 中產生這個事件:

// arrows.rs
/// 事件結構體
pub struct CorrectArrowEvent {
    pub direction: Directions,
    pub points: usize,
}

/// 當他們到達目標區域時,正確點擊按鈕,箭頭就會消失
fn despawn_arrows(
    commands: &mut Commands,
    query: Query<(Entity, &Transform, &Arrow)>,
    keyboard_input: Res<Input<KeyCode>>,
    mut score: ResMut<ScoreResource>,
    mut correct_arrow_events: ResMut<Events<CorrectArrowEvent>>,
) {
    for (entity, transform, arrow) in query.iter() {
        let pos = transform.translation.x;

        // Check if arrow is inside clicking threshold
        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);

            // 新代碼
            
            // 發送事件
            correct_arrow_events.send(CorrectArrowEvent {
                direction: arrow.direction,
                points,
            });
        }

        // 當箭頭離開屏幕時消除它們
        if pos >= 2. * TARGET_POSITION {
            commands.despawn(entity);
            score.increase_fails();
        }
    }
}

我們首先要做的是創建一個新的 CorrectArrowEvent 結構體,它用來表示我們的事件。對於 despawn_arrows,我們添加了 ResMut<Events<CorrectArrowEvent>> 參數,這樣我們就能通過 send 方法發送事件。為了發送一個事件,我們需要傳入一個 CorrectArrowEvent 結構體,它攜帶箭頭的方向以及玩家的得分。

現在我們需要把 .init_resource::<Events<CorrectArrowEvent>>() 添加到 ArrowsPlugin,我們已經准備好了。很簡單,對吧?

現在我們要在 shaders/target_arrows.rs 中添加一個“系統”,它負責更新“目標區域箭頭”中的 last_time

// shaders/target_arrows.rs
pub fn correct_arrow_event_listener(
    time: Res<Time>,
    mut correct_event_reader: Local<EventReader<CorrectArrowEvent>>,
    correct_events: Res<Events<CorrectArrowEvent>>,
    mut query: Query<(&TargetArrowSparkle, &mut TimeSinceLastCorrect)>,
) {
    for event in correct_event_reader.iter(&correct_events) {
        for (arrow, mut last_correct) in query.iter_mut() {
            if arrow.direction == event.direction {
                last_correct.last_time = time.seconds_since_startup() as f32;
                last_correct.points = event.points as f32 / 100.;
            }
        }
    }
}

它通過監聽事件,尋找與目標方向相關的箭頭精靈,並更新其中的 last_timepoints 值。

把最后一個“系統”加到 ShaderPlugin.add_system(correct_arrow_event_listener.system())。現在如果你運行游戲,當你正確點擊箭頭時,就會看到圓圈效果:

視頻資源

這就是這個游戲中我們要做的所有着色工作。和以往一樣,你可以隨便修改代碼,添加更多效果,進行實驗!

增加狀態

在下一節,我們將制作一個非常簡單的歌曲選擇菜單。為此,我們將在一些狀態值上下手,這就需要修改一些地方。為了創建一個狀態,我們需要新建一個新的枚舉,並將其包裝成 State 的資源加到游戲代碼中。然后,我們可以使用 on_state_updateon_state_enteron_state_exit 等方法為每個系統分配特定的狀態。

我們開始吧。首先,打開 consts.rs,添加 state 枚舉:

/// Stage for our systems
pub const APP_STATE_STAGE: &str = "app_state_stage";

/// States
#[derive(Clone, PartialEq, Eq, Hash)]
pub enum AppState {
    Menu,
    Game,
    MakeMap,
}

AppState 將代表我們游戲的三個模式:歌曲選擇菜單,游戲和(尚未實現的)地圖制作模式。

我們,還添加了一個字符串用於表示我們的系統的階段。現在我們進入 main.rs 中,添加 State 以及更新后的新階段兩個資源:

// main.rs
use crate::consts::*;

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_resource(State::new(AppState::Menu)) // <--- 新代碼
        .add_stage_after( // <--- 新代碼
            stage::UPDATE,
            APP_STATE_STAGE,
            StateStage::<AppState>::default(),
        )
        .init_resource::<ScoreResource>()
        .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)
        .run();
}

現在游戲不會有任何變化,因為我們的“系統”仍然以普通的方式加入。為了改變這一點,我們將從修改 arrows.rs 中的 ArrowsPlugin 入手:

// arrows.rs
pub struct ArrowsPlugin;
impl Plugin for ArrowsPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ArrowMaterialResource>()
            .init_resource::<Events<CorrectArrowEvent>>()
            .on_state_enter(
                APP_STATE_STAGE,
                AppState::Game,
                setup_target_arrows.system(),
            )
            .on_state_update(APP_STATE_STAGE, AppState::Game, spawn_arrows.system())
            .on_state_update(APP_STATE_STAGE, AppState::Game, move_arrows.system())
            .on_state_update(APP_STATE_STAGE, AppState::Game, despawn_arrows.system());
    }
}

我們必須把 add_startup_system替換為 on_stage_enter,將 add_system 替換為 on_stage_update。對於這些函數,我們必須傳入“系統”運行的階段和狀態。因為我們想要所有這些運行在 Game 狀態,就是我們使用的那個。

現在我們看看 ui.rs

// ui.rs
use crate::consts::*;

pub struct UIPlugin;
impl Plugin for UIPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.on_state_enter(APP_STATE_STAGE, AppState::Game, setup_ui.system())
            .on_state_update(APP_STATE_STAGE, AppState::Game, update_time_text.system())
            .on_state_update(APP_STATE_STAGE, AppState::Game, update_score_text.system());
    }
}

audio.rs 中的代碼:

// audio.rs
use crate::consts::*;

pub struct AudioPlugin;
impl Plugin for AudioPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.on_state_update(APP_STATE_STAGE, AppState::Game, start_song.system());
    }
}

我們已經修改了所有與 Game 狀態相關的“系統”,所以如果你現在運行游戲,除了看到動畫背景外,什么也不會發生,因為我們要從 Menu 開始,但是我們還沒有相關的“系統”。

添加基礎菜單

我們現在將制作一個帶有按鈕的菜單,它可以讓我們選擇一首歌曲或進入游戲地圖制作模式。我們將它保存在一個新的文件 menu.rs 中。我們新建一個資源來保存對應的素材:

use crate::consts::*;
use bevy::prelude::*;

struct ButtonMaterials {
    none: Handle<ColorMaterial>,
    normal: Handle<ColorMaterial>,
    hovered: Handle<ColorMaterial>,
    pressed: Handle<ColorMaterial>,
    font: Handle<Font>,
}

impl FromResources for ButtonMaterials {
    fn from_resources(resources: &Resources) -> Self {
        let mut materials = resources.get_mut::<Assets<ColorMaterial>>().unwrap();
        let asset_server = resources.get_mut::<AssetServer>().unwrap();

        ButtonMaterials {
            none: materials.add(Color::NONE.into()),
            normal: materials.add(Color::rgb(0.15, 0.15, 0.15).into()),
            hovered: materials.add(Color::rgb(0.25, 0.25, 0.25).into()),
            pressed: materials.add(Color::rgb(0.35, 0.75, 0.35).into()),
            font: asset_server.load("fonts/FiraSans-Bold.ttf"),
        }
    }
}

這看起來很標准。接下來,我們將創建一個“系統”來構建菜單元素。

// menu.rs
struct MenuUI;
fn setup_menu(commands: &mut Commands, button_materials: Res<ButtonMaterials>) {
    commands
        .spawn(NodeBundle {
            style: Style {
                size: Size::new(Val::Percent(100.), Val::Percent(100.)),
                display: Display::Flex,
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::FlexStart,
                justify_content: JustifyContent::FlexStart,
                ..Default::default()
            },
            material: button_materials.none.clone(),
            ..Default::default()
        })
        .with(MenuUI)
        .with_children(|parent| {
            // 生成新按鈕
            parent
                .spawn(ButtonBundle {
                    style: Style {
                        size: Size::new(Val::Px(350.0), Val::Px(65.0)),
                        margin: Rect::all(Val::Auto),
                        justify_content: JustifyContent::Center,
                        align_items: AlignItems::Center,
                        ..Default::default()
                    },
                    material: button_materials.normal.clone(),
                    ..Default::default()
                })
                .with_children(|parent| {
                    parent.spawn(TextBundle {
                        text: Text {
                            value: "Play".to_string(),
                            font: button_materials.font.clone(),
                            style: TextStyle {
                                font_size: 20.0,
                                color: Color::rgb(0.9, 0.9, 0.9),
                                ..Default::default()
                            },
                        },
                        ..Default::default()
                    });
                });
        });
}

這看起來非常類似於 ui.rs 中的 setup_ui。但結構類似於 NodeBundle > ButtonBundle > TextBundle

我們還要創建一個刪除所有按鈕的系統,這樣我們就可以在離開菜單時運行它。如果不這樣做,菜單按鈕會一直停留在游戲屏幕上。

// menu.rs
fn despawn_menu(commands: &mut Commands, query: Query<(Entity, &MenuUI)>) {
    for (entity, _) in query.iter() {
        commands.despawn_recursive(entity);
    }
}

給這個系統實現插件:

// menu.rs
pub struct MenuPlugin;
impl Plugin for MenuPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ButtonMaterials>()
            .on_state_enter(APP_STATE_STAGE, AppState::Menu, setup_menu.system())
            .on_state_exit(APP_STATE_STAGE, AppState::Menu, despawn_menu.system());
    }
}

把它添加到 main.rs 中,導入它並在 main 函數中增加 .add_plugin(MenuPlugin) 調用:

// main.rs
mod menu;
use menu::MenuPlugin;

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_resource(State::new(AppState::Menu))
        .add_stage_after(
            stage::UPDATE,
            APP_STATE_STAGE,
            StateStage::<AppState>::default(),
        )
        .init_resource::<ScoreResource>()
        .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)
        .add_plugin(MenuPlugin) // <--- 新代碼
        .run();
}


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

我們還要更改 setup,不再是 SongConfig 資源,因為我們會在玩家點擊按鈕選擇歌曲時添加它。

現在運行游戲會顯示下面這樣的按鈕:

目前,單擊按鈕並將鼠標懸停在按鈕上會發現按鈕什么也沒有干,所以我們需要讓菜單能根據需要有所反應。首先,我們將添加一個系統,根據按鈕的交互改變顏色:

// menu.rs
fn button_color_system(
    button_materials: Res<ButtonMaterials>,
    mut query: Query<
        (&Interaction, &mut Handle<ColorMaterial>),
        (Mutated<Interaction>, With<Button>),
    >,
) {
    for (interaction, mut material) in query.iter_mut() {
        match *interaction {
            Interaction::Clicked => {
                *material = button_materials.pressed.clone();
            }
            Interaction::Hovered => {
                *material = button_materials.hovered.clone();
            }
            Interaction::None => {
                *material = button_materials.normal.clone();
            }
        }
    }
}

這里我們使用的是 Interaction 組件,它和 ButtonBundle 一起。它有三個不同的變體,ClickedHoveredNone。分別表示:單機按鈕,懸停在按鈕上,不做任何事。我們將匹配按鈕的所有可能的值,從而做出不同的反應。將 MenuPlugin 加到游戲中,運行游戲,觀察鼠標懸停、點擊或移開時按鈕的顏色是如何變化的。

視頻資源

優化菜單

我們還需要兩個東西:在文件夾中顯示歌曲列表菜單,以及正式開始游戲的按鈕。我們從第一點開始,在 menu.rs 中增加一個方法:

// menu.rs
use std::fs::read_dir;

pub fn get_songs() -> Vec<String> {
    let paths = read_dir("assets/songs").unwrap();

    let mut vec = vec![];
    for path in paths {
        let path = path.unwrap().path();

        if "toml" == path.as_path().extension().unwrap() {
            vec.push(
                path.as_path()
                    .file_stem()
                    .unwrap()
                    .to_str()
                    .unwrap()
                    .to_string(),
            );
        }
    }
    vec
}

這個函數使用 read_dir 獲取 songs 目錄中的文件,並將 .toml 后綴文件路徑追加到數組中。

現在我們可以從 setup_menu 內部調用這個函數,來為 get_songs 得到的每個文件增加按鈕。首先,我們創建一個枚舉組件加到按鈕中:

// menu.rs
enum MenuButton {
    MakeMap,
    PlaySong(String),
}
impl MenuButton {
    fn name(&self) -> String {
        match self {
            Self::MakeMap => "Make map".to_string(),
            Self::PlaySong(song) => format!("Play song: {}", song),
        }
    }
}

枚舉的第一個變體 MakeMap 用於進入地圖制作模式(如果實現了)。另一個變體 PlaySong 用於開始特定的歌曲游戲。

// menu.rs
fn setup_menu(commands: &mut Commands, button_materials: Res<ButtonMaterials>) {
    // 制作按鈕列表
    let mut buttons: Vec<MenuButton> = get_songs()
        .iter()
        .map(|name| MenuButton::PlaySong(name.clone()))
        .collect();
    buttons.push(MenuButton::MakeMap);

    commands
        .spawn(NodeBundle {
            style: Style {
                size: Size::new(Val::Percent(100.), Val::Percent(100.)),
                display: Display::Flex,
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::FlexStart,
                justify_content: JustifyContent::FlexStart,
                ..Default::default()
            },
            material: button_materials.none.clone(),
            ..Default::default()
        })
        .with(MenuUI)
        .with_children(|parent| {
            // 將所有按鈕以子按鈕的方式加入
            for button in buttons {
                // 生成新按鈕
                parent
                    .spawn(ButtonBundle {
                        style: Style {
                            size: Size::new(Val::Px(350.0), Val::Px(65.0)),
                            margin: Rect::all(Val::Auto),
                            justify_content: JustifyContent::Center,
                            align_items: AlignItems::Center,
                            ..Default::default()
                        },
                        material: button_materials.normal.clone(),
                        ..Default::default()
                    })
                    .with_children(|parent| {
                        parent.spawn(TextBundle {
                            text: Text {
                                value: button.name(),
                                font: button_materials.font.clone(),
                                style: TextStyle {
                                    font_size: 20.0,
                                    color: Color::rgb(0.9, 0.9, 0.9),
                                    ..Default::default()
                                },
                            },
                            ..Default::default()
                        });
                    })
                    .with(button);
            }
        });
}

我們已替換了 with_children 的內容,來循環遍歷按鈕列表,從而創建按鈕。

注意:我們設置按鈕的方式有點菜,所以如果你有很多按鈕顯示的話,它會看起來很奇怪!添加一個滾動條或者其他改善方式就留給讀者作為練習了。

效果如下圖所示:

現在我們要讓按鈕可用。為此,我們添加另一個“系統”來監聽點擊事件:

// menu.rs
use crate::types::load_config;

fn button_press_system(
    commands: &mut Commands,
    asset_server: Res<AssetServer>,
    query: Query<(&Interaction, &MenuButton), (Mutated<Interaction>, With<Button>)>,
    mut state: ResMut<State<AppState>>,
) {
    for (interaction, button) in query.iter() {
        // 在這一幀中檢測按鈕是否被點擊
        if *interaction == Interaction::Clicked {
            match button {
                // 如果地圖制作按鈕被點擊,改變模式
                MenuButton::MakeMap => state
                    .set_next(AppState::MakeMap)
                    .expect("Couldn't switch state to MakeMap"),
                // 如果它是一個播放歌曲按鈕,加載對應配置,插入資源,然后改變態模式
                MenuButton::PlaySong(song) => {
                    let config = load_config(&*format!("{}.toml", song), &asset_server);
                    commands.insert_resource(config);
                    state
                        .set_next(AppState::Game)
                        .expect("Couldn't switch state to Game")
                }
            };
        }
    }
}

在這個系統中,我們循環遍歷每個按鈕,並檢查它們是否處於點擊狀態。如果是,我們會匹配按鈕的類型,執行相應的邏輯。對於 MakeMap,我們只需使用 set_next 改變狀態。對於 PlaySong,用我們創建的 SongConfig 函數來加載選定歌曲的 SongConfig,在將狀態更改為 Game 之前,我們使用 insert_resource 添加歌曲。

最后,我們應該把這個系統添加到 MenuPlugin,設置成 Menu 狀態更新時運行:

// menu.rs
pub struct MenuPlugin;
impl Plugin for MenuPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ButtonMaterials>()
            .on_state_enter(APP_STATE_STAGE, AppState::Menu, setup_menu.system())
            .on_state_update(
                APP_STATE_STAGE,
                AppState::Menu,
                button_color_system.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::Menu,
                button_press_system.system(),
            )
            .on_state_exit(APP_STATE_STAGE, AppState::Menu, despawn_menu.system());
    
}

現在運行游戲,我們會看到按鈕正常工作,開始游戲:

視頻資源

但有個大問題!當我們開始游戲時,時間在跑了,箭頭卻沒有顯示!因為我們使用 time_since_startup 來檢查何時生成箭頭,當我們進入 Game 狀態時,值已經過了第一個箭頭的生成時間,所以不會出現,其它箭頭也不會出現。為了解決這個問題,我們將在后面制作一個包裝器,這樣我們就可以在進入 Game 模式時重置它。

時間系統封裝

我們的時間包裝器非常類似於 Bevy 的時間資源實現,不同的是它需要在我們進入 GameMakeMap 狀態時重置時間系統。復制所有代碼只是為了改善一些糟糕的東西,但這會讓我們在未來做其他工作時帶來方便,比如暫停。這也是一個了解 Bevy 源碼的好機會。

此外,通過同時擁有一個正常的時間資源和我們自己包裝的版本,可以讓我們使用正常的時間資源,以及其他需要控制時間的場景。例如,我們要繼續為游戲背景使用正常時間,因為我們希望它在所有狀態下都能工作。

打開一個新文件, time.rs

use crate::consts::*;
use bevy::{
    prelude::*,
    utils::{Duration, Instant},
};

pub struct ControlledTime {
    delta: Duration,
    last_update: Option<Instant>,
    delta_seconds_f64: f64,
    delta_seconds: f32,
    seconds_since_startup: f64,
    startup: Instant,
}
impl Default for ControlledTime {
    fn default() -> Self {
        Self {
            delta: Duration::from_secs(0),
            last_update: None,
            startup: Instant::now(),
            delta_seconds_f64: 0.0,
            seconds_since_startup: 0.0,
            delta_seconds: 0.0,
        }
    }
}

這里我們添加了一個與 Bevy 的 time 相同的結構體,使用相同的 Default 實現,我們將其稱為 ControlledTime

現在,添加我們想要的方法,它來自於這個資源,此外我們還會添加一個 reset_time 函數,它將時間設置為 0:

// time.rs
impl ControlledTime {
    pub fn reset_time(&mut self) {
        self.startup = Instant::now();
        self.seconds_since_startup = 0.0;
    }

    pub fn update(&mut self) {
        let now = Instant::now();
        self.update_with_instant(now);
    }

    pub fn update_with_instant(&mut self, instant: Instant) {
        if let Some(last_update) = self.last_update {
            self.delta = instant - last_update;
            self.delta_seconds_f64 = self.delta.as_secs_f64();
            self.delta_seconds = self.delta.as_secs_f32();
        }

        let duration_since_startup = instant - self.startup;
        self.seconds_since_startup = duration_since_startup.as_secs_f64();
        self.last_update = Some(instant);
    }

    /// 當前標記和最后一次標記的時間差是 [`f32`] 秒
    #[inline]
    pub fn delta_seconds(&self) -> f32 {
        self.delta_seconds
    }

    /// 當前標記和最后一次標記的時間差是 [`f64`] 秒
    #[inline]
    pub fn delta_seconds_f64(&self) -> f64 {
        self.delta_seconds_f64
    }

    /// 啟動后的時間,以秒為單位
    #[inline]
    pub fn seconds_since_startup(&self) -> f64 {
        self.seconds_since_startup
    }
}

考慮到這一點,我們需要一個能夠更新時間的“系統”:

// time.rs
pub fn update_time(mut time: ResMut<ControlledTime>) {
    time.update();
}

並且有一個系統對時間進行重置

// time.rs
pub fn reset_time_when_entering_game(mut time: ResMut<ControlledTime>) {
    time.reset_time();
}

我們還會添加一個插件來把它們放在一起:

// time.rs
pub struct TimePlugin;
impl Plugin for TimePlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ControlledTime>()
            .on_state_update(APP_STATE_STAGE, AppState::Game, update_time.system())
            .on_state_update(APP_STATE_STAGE, AppState::MakeMap, update_time.system())
            .on_state_enter(
                APP_STATE_STAGE,
                AppState::Game,
                reset_time_when_entering_game.system(),
            )
            .on_state_enter(
                APP_STATE_STAGE,
                AppState::MakeMap,
                reset_time_when_entering_game.system(),
            );
    }
}

我們在 GameMapMaker 執行期間設置了 update_time,並且 reset_time_when_entering_game 在這兩種模式下都會執行。

跟其它插件一樣,我們在 main.rs 中添加:

// main.rs
mod time;
use time::TimePlugin;

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_resource(State::new(AppState::Menu))
        .add_stage_after(
            stage::UPDATE,
            APP_STATE_STAGE,
            StateStage::<AppState>::default(),
        )
        .init_resource::<ScoreResource>()
        .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)
        .add_plugin(MenuPlugin)
        .add_plugin(TimePlugin) // <--- New
        .run();
}

我們需要做的最后一件事就是用 ControlledTime 代替 Time

首先是 ui.rs,我們只需改變 update_time_text 中的 time 參數:

// ui.rs
use crate::time::ControlledTime;

fn update_time_text(time: Res<ControlledTime>, mut query: Query<(&mut Text, &TimeText)>) {
    [...]
}

audio.rs 文件也一樣,將 Time 替換為 ControlledTime

// audio.rs
use crate::time::ControlledTime;

fn start_song(audio: Res<Audio>, time: Res<ControlledTime>, config: Res<SongConfig>) {
    [...]
}

最后是 arrows.rs 文件,要修改的地方多一些:

// main.rs
use crate::time::ControlledTime;

/// Spawns arrows
fn spawn_arrows(
    commands: &mut Commands,
    mut song_config: ResMut<SongConfig>,
    materials: Res<ArrowMaterialResource>,
    time: Res<ControlledTime>,
) {
    [...]
}

/// Moves the arrows forward
fn move_arrows(time: Res<ControlledTime>, mut query: Query<(&mut Transform, &Arrow)>) {
    [...]
}

現在運行游戲,可以看到菜單和游戲正常工作了:

視頻資源

太棒了!

添加簡單的地圖制作模式

在本節中,我們添加一個場景模式來幫助我們給歌曲創建地圖。我們想要的是當歌曲播放時,我們何時按下按鍵,並將它們保存到一個文件中。

我們打開一個新文件 map_maker.rs,我們從添加資源和“系統”開始:

use crate::time::ControlledTime;
use crate::consts::*;
use crate::types::{
    ArrowTimeToml,
    Directions::{self, *},
    Speed,
};
use bevy::{
    app::AppExit,
    input::{keyboard::KeyCode, Input},
    prelude::*,
};
use serde_derive::Serialize;
use std::fs::File;
use std::io::prelude::*;

#[derive(Serialize, Debug, Default)]
/// 跟蹤按鍵被按下的時間
struct Presses {
    arrows: Vec<ArrowTimeToml>,
}

/// 保存被按下的鍵
fn save_key_presses(
    time: Res<ControlledTime>,
    keyboard_input: Res<Input<KeyCode>>,
    mut presses: ResMut<Presses>,
) {
    let directions = [Up, Down, Left, Right];
    for direction in directions.iter() {
        if direction.key_just_pressed(&keyboard_input) {
            presses.arrows.push(ArrowTimeToml {
                click_time: time.seconds_since_startup(),
                speed: Speed::Slow,
                direction: *direction,
            });
        }
    }
}

我們大量添加需要增加的東西,我們創建 Presses 資源,它保存了一個 ArrowTimeToml 列表,以及一個當方向鍵被按下時添加到該列表的“系統”,並循環所有方向的按鍵。

我們還需要一個系統來監聽 AppExit 事件,並將 ArrowTimeToml 列表保存到文件中:

// map_maker.rs
fn save_to_file_on_exit(
    mut event_reader: Local<EventReader<AppExit>>,
    events: Res<Events<AppExit>>,
    presses: Res<Presses>,
) {
    for _event in event_reader.iter(&events) {
        let text = toml::to_string(&*presses).expect("Couldn't convert to toml text");

        let mut file = File::create("map.toml").expect("Couldn't open map.toml");
        file.write_all(text.as_bytes())
            .expect("Couldn't write to map.toml");
    }
}

我們得做點什么來提高這個模式的易用性。當玩家按下一個按鍵時,相應的方向會有箭頭出現在屏幕上。我們將添加兩個系統,一個生成箭頭,一個切換箭頭的可見性:

// map_maker.rs
struct MapMakerArrow(Directions);

/// Creates map maker arrows
fn setup_map_maker_arrows(
    commands: &mut Commands,
    mut materials: ResMut<Assets<ColorMaterial>>,
    asset_server: ResMut<AssetServer>,
) {
    let border_handle = materials.add(asset_server.load("images/arrow_border.png").into());

    let directions = [Up, Down, Left, Right];
    for direction in directions.iter() {
        let y = match direction {
            Up => 150.,
            Down => 50.,
            Left => -50.,
            Right => -150.,
        };

        let mut transform = Transform::from_translation(Vec3::new(0., y, 1.));
        transform.rotate(Quat::from_rotation_z(direction.rotation()));
        commands
            .spawn(SpriteBundle {
                material: border_handle.clone(),
                sprite: Sprite::new(Vec2::new(140., 140.)),
                transform,
                ..Default::default()
            })
            .with(MapMakerArrow(*direction));
    }
}

/// 根據是否按下對應的鍵來切換可見性
fn toggle_map_maker_arrows(
    mut query: Query<(&mut Visible, &MapMakerArrow)>,
    keyboard_input: Res<Input<KeyCode>>,
) {
    for (mut visible, arrow) in query.iter_mut() {
        visible.is_visible = arrow.0.key_pressed(&keyboard_input);
    }
}

第一個“系統”非常類似於 spawn_target_arrows,它只是創建精靈,並添加我們剛剛聲明的 MapMakerArrow 組件。第二個系統是 toggle_map_maker_arrows,根據箭頭對應的方向鍵是否被按下來設置箭頭的可見性。我們通過設置精靈的 Visible 中的 is_visible 字段來做到這一點。

這里有一個問題,我們目前給 Directions 聲明的 key_just_pressed 方法使用了 just_pressed,這只會在按鍵被按下的第一幀時才會生效。我們希望玩家按下按鍵,箭頭就立即顯示,所以我們添加了另一種 pressed 方法,它可以實現我們想要的:

// types.rs
impl Directions {
    [Other methods...]

    /// 檢查是否按下與當前方向相同的方向鍵
    pub fn key_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.pressed(*code))
    }
}

這樣,我們的 toggle_map_maker_arrows 系統就可以正常工作了!我們還要給所有的歌曲地圖實現一個插件:

// map_maker.rs
pub struct MapMakerPlugin;
impl Plugin for MapMakerPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<Presses>()
            .on_state_enter(
                APP_STATE_STAGE,
                AppState::MakeMap,
                setup_map_maker_arrows.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                toggle_map_maker_arrows.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                save_key_presses.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                save_to_file_on_exit.system(),
            );
    }
}

要想讓它運行起來,我們還需要在 main.rs 中加上“系統”的調用代碼:

// main.rs
mod map_maker;
use map_maker::MapMakerPlugin;

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_resource(State::new(AppState::Menu))
        .add_stage_after(
            stage::UPDATE,
            APP_STATE_STAGE,
            StateStage::<AppState>::default(),
        )
        .init_resource::<ScoreResource>()
        .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)
        .add_plugin(MenuPlugin)
        .add_plugin(TimePlugin)
        .add_plugin(MapMakerPlugin) // <--- 新增代碼
        .run();
}

現在,我們可以運行游戲來看看地圖制作模式是否能正常工作:

視頻資源

請記住,在游戲終端中使用 ESC 鍵退出,而不是 Ctrl+C 鍵,這樣才能保存文件成功。

這是我們得到的一個文件示例:

[[arrows]]
click_time = 1.04939044
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 1.658164574
speed = "Slow"
direction = "Down"

[[arrows]]
click_time = 2.191576505
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 2.558483463
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 2.858588189
speed = "Slow"
direction = "Left"

[[arrows]]
click_time = 3.4904190330000002
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 3.9252477949999998
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 4.240984206
speed = "Slow"
direction = "Left"

[[arrows]]
click_time = 4.62353972
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 4.97381796
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 5.308837329
speed = "Slow"
direction = "Left"

現在我們可以將它添加到 assets/songs 目錄下,添加 namefilename 字段,這樣就有了歌曲的工作地圖!

我們需要做的最后一件事是在地圖制作模式下播放歌曲,否則它就顯得有點雞肋。我們簡單實現一下,並且給使用的歌曲路徑硬編碼,這樣可以讓教程簡短一些(如果還算短的話)。我們將使用路徑 assets/map_maker_song.mp3 中的歌曲。玩家必須在地圖制作器中修改文件路徑來更換歌曲。每個人都可以實現一些自己的“系統”,以更容易地選擇地圖制作器中使用的歌曲。

在地圖制作器中播放歌曲

為了讓音樂進入地圖制作器,我們先要添加一個資源來保存 Handle<AudioSource>。我們要為該資源實現 FromResources,這樣可以在開始時就加載它,當把它加載到地圖制作器中時,它就准備好可以玩了:

struct MapMakerAudio(Handle<AudioSource>);
impl FromResources for MapMakerAudio {
    fn from_resources(resources: &Resources) -> Self {
        let asset_server = resources.get_mut::<AssetServer>().unwrap();
        let audio = asset_server.load("map_maker_song.mp3");
        Self(audio)
    }
}

這一次我決定使用一個元組結構體來處理資源,因為我們只有一個字段。FromResources 實現了靜態資源服務器,它可以加載音頻資源。

在那之后,我們要創建一個新“系統”來進行播放音頻,我們將把它設置為進入 MakeMap 的狀態時執行:

// map_maker.rs
fn start_song(audio: Res<Audio>, map_maker_audio: Res<MapMakerAudio>) {
    audio.play(map_maker_audio.0.clone());
}

我們要做的最后一件事是將這兩個資源加到插件中:

// map_maker.rs
pub struct MapMakerPlugin;
impl Plugin for MapMakerPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<Presses>()
            .init_resource::<MapMakerAudio>() // <--- 新代碼
            .on_state_enter(APP_STATE_STAGE, AppState::MakeMap, start_song.system()) // <--- 新代碼
            .on_state_enter(
                APP_STATE_STAGE,
                AppState::MakeMap,
                setup_map_maker_arrows.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                toggle_map_maker_arrows.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                save_key_presses.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                save_to_file_on_exit.system(),
            );
    }
}

找一個音頻文件,並將其放到 assets/map_maker_song.mp3 中,如果你運行游戲,進入地圖制作模式時,應該可以聽到音頻播放了!

至此,我們的游戲教程就結束了。和往常一樣,你可以隨意嘗試,修改一些東西,讓它成為你的東西!如果你有任何的改進,請在 Twitter 標記我,這樣我就能看到了!

下一步

如果你還沒想好要做什么樣的二次開發,以下提供一些可以嘗試的想法:

  • 1.添加必須在特定的時間內保持狀態的箭頭。
  • 2.改進地圖制作器,增加選擇歌曲的功能。
  • 3.給游戲增加一個游戲結束畫面。
  • 4.增加一種歌曲播放完后,回到菜單的方式
  • 5.創建一個可以改變點擊閾值的“系統”,可以讓玩家在困難模式時選擇簡單模式,玩家很輕松則切換到困難模式。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM