RustのゲームエンジンBevyでスプライトアニメーションをしてみた

[PONOS Advent Calendar 2023](https://qiita.com/advent-calendar/2023/ponos)の18日目の記事です。
昨日は、 @bluenova1221さんの[UnityエンジニアがGodot Engineを触ってみた](https://qiita.com/bluenova1221/items/fec2e25f2b550a4763af)でした。

## はじめに
前回のアドベントカレンダーで、[RustでWebAPIを構築し単純なリクエストデータを返してみる](https://qiita.com/nisei275/items/7daf8542f7a4ab4b0fb8)の記事を書かせていただきましたが、やっぱりRustでゲームアプリを作ってみたい欲が出てきました。
最近では、[Fyrox](https://fyrox.rs/)や[Arete Engine](https://www.areteengine.com/)等、さまざまなRust製エンジンが出てきていますが、2021年の際に触れた[Bevy Engine](https://bevyengine.org/)をアプリケーションの起動だけではなく、2Dゲームの要であるスプライトアニメーションを実際に実装してみようかと思います。
前回の記事は、[RustのゲームエンジンBevyに触れてみる](https://qiita.com/nisei275/items/7daf8542f7a4ab4b0fb8)にありまして、特徴など記述しておりますのでご覧になってはいかがでしょうか。

## 実装
実装環境としてはローカル(macOS)で実施することを前提しています。

| 開発環境 | バージョン |
|:-:|:-:|
| MacOS(Ventura) | 13.5.2 |
| Rust(rustc、cargo) | 1.74.1 |
| Bevy | 0.12.1 |

### 事前準備
開発端末にRustを導入しなくてはいけませんが、導入する方法は[2019年アドベントカレンダーの17日目の記事](https://qiita.com/nisei275/items/a60cb3d3e97aeb429fa7)で記載しておりますので、今回も省略いたします。

#### プロジェクトの作成

```
$ cargo new bevy_test
```

プロジェクトの生成をすると、以下のようなシンプルなディレクトリ構成のプロジェクトが生成されます。

```
├──Cargo.toml
├──README.md
└──src
   └──main.rs
```

### 実装する

まずは、スプライトアニメーションさせるために以下画像を用意しました。止まっている時、左に動いている時、右に動いている時のアニメーションが再生されるように実装します。この画像を読み込みスプライトアニメーションさせる実装をしたいと思いますので、プロジェクトのassetsディレクトリ配下に設置します。

![test_player_atlas.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191987/d14f57ed-71f4-6fc0-1485-09b4e174f7e7.png)

各種追加したコードを記述します。

```toml:Cargo.toml
[package]
name = "bevy test"
version = "0.1.0"
authors = ["XXXXX <XXXXX@email.com>"]
edition = "2021"

[dependencies]
bevy = "0.12.1"
```

```rust:main.rs
use bevy::{
    prelude::*,
    window::{PresentMode, WindowTheme},
};

/// プレイヤー
#[derive(Component, Default, Debug)]
struct Player;

/// プレイヤーアニメーション
#[derive(Component, Default, Debug)]
struct PlayerAnimation {
    idle: SpriteAnimation,
    left_run: SpriteAnimation,
    right_run: SpriteAnimation
}

/// プレイヤー関連のコンボーネントバンドル
#[derive(Bundle, Default)]
struct PlayerBundle {
    player: Player,
    sprite: SpriteSheetBundle,
    animation: PlayerAnimation,
}

/// スプライトアニメーション
#[derive(Component, Default, Debug)]
struct SpriteAnimation {
    is_playing: bool,
    index: SpriteAnimationIndex,
    timer: SpriteAnimationTimer,
}

/// スプライトアニメーションのインデックス
#[derive(Component, Default, Debug)]
struct SpriteAnimationIndex {
    first: usize,
    last: usize,
}

/// スプライトアニメーションのタイマー(タプル構造体)
#[derive(Component, Default, Debug, Deref, DerefMut)]
struct SpriteAnimationTimer(Timer);

fn main() {
    App::new()
        .add_plugins(
            // アプリケーションのウィンドウ設定
            DefaultPlugins.set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Bevy Test".into(),
                    resolution: (1280.0, 720.0).into(),
                    resizable: false,
                    present_mode: PresentMode::AutoVsync,
                    window_theme: Some(WindowTheme::Dark),
                    ..default()
                }),
                ..default()
            })
        )
        // Startupはアプリ起動時に1回読み込まれる。
        .add_systems(Startup, create_player)
        // Updateはフレーム単位で読み込まれる。
        .add_systems(Update, update_player)
        // escでアプリ終了させる。
        .add_systems(Update, bevy::window::close_on_esc)
        .run();
}

/// プレイヤーの生成
fn create_player(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    // プレイヤーのテクスチャー画像を読み込む。
    let texture_handle: Handle<Image> = asset_server.load("test_player_atlas.png");
    // プレイヤーテクスチャー画像のアトラス形式にする。
    let texture_atlas = TextureAtlas::from_grid(texture_handle, Vec2::new(80.0, 80.0), 4, 3, None, None);
    // プライヤーテクスチャー画像のアトラスを取り扱いできるようにする。
    let texture_atlas_handle = texture_atlases.add(texture_atlas);

    // 2Dカメラの読み込み
    commands.spawn(Camera2dBundle::default());

    // プレイヤーの読み込み
    commands.spawn((
        PlayerBundle {
            // プレイヤーのスプライト追加
            sprite: SpriteSheetBundle {
                texture_atlas: texture_atlas_handle,
                sprite: TextureAtlasSprite::new(0),
                transform: Transform {
                    translation: Vec3::ZERO,
                    scale: Vec3::splat(2.0),
                    ..default()
                },
                ..default()
            },
            // 用意したプレイヤーアニメーションコンポーネントの追加
            animation: PlayerAnimation {
                // 待機状態のアニメーション追加
                idle: SpriteAnimation {
                    index: SpriteAnimationIndex { first: 0, last: 3 },
                    timer: SpriteAnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
                    ..default()
                },
                // 移動状態のアニメーション追加
                right_run: SpriteAnimation {
                    index: SpriteAnimationIndex { first: 4, last: 7 },
                    timer: SpriteAnimationTimer(Timer::from_seconds(0.12, TimerMode::Repeating)),
                    ..default()
                },
                // 移動状態のアニメーション追加
                left_run: SpriteAnimation {
                    index: SpriteAnimationIndex { first: 8, last: 11 },
                    timer: SpriteAnimationTimer(Timer::from_seconds(0.12, TimerMode::Repeating)),
                    ..default()
                }
            },
            ..default()
        },
    ));
}

/// プレイヤーの更新
fn update_player(
    time: Res<Time>,
    keyboard_input: Res<Input<KeyCode>>,
    mut player_query: Query<(&mut Transform, &mut TextureAtlasSprite, &mut PlayerAnimation), With<Player>>
) {
    // プレイヤーのEntityを取得(PlayerBundleの中にあるPlayerコンポーネントは1つなのでsingle_mutで取得)
    let (mut transform, mut sprite, mut anim) = player_query.single_mut();

    // 左キーで左に移動、右キーで右に移動させる。
    if keyboard_input.pressed(KeyCode::Left) {
        // 左に移動
        transform.translation.x -= 2.0;
        // 再生されている可能性があるアニメーションの停止
        anim.idle.stop();
        anim.right_run.stop();
        // 左に移動のアニメーション再生
        anim.left_run.play(sprite.as_mut(), time);
    } else if keyboard_input.pressed(KeyCode::Right) {
        // 右に移動
        transform.translation.x += 2.0;
        // 再生されている可能性があるアニメーションの停止
        anim.idle.stop();
        anim.left_run.stop();
        // 右に移動のアニメーション再生
        anim.right_run.play(sprite.as_mut(), time);
    } else {
        // 再生されている可能性があるアニメーションの停止
        anim.left_run.stop();
        anim.right_run.stop();
        // 停止アニメーションの再生
        anim.idle.play(sprite.as_mut(), time);
    }
}

// スプライトアニメーションの構造体にstopとplayの機能を追加
impl SpriteAnimation {
    /// スプライトアニメーションの停止
    /// 次のアニメーションを再生する際は再生している可能性があるものを停止する。
    fn stop(&mut self) {
        self.is_playing = false;
    }
    /// スプライトアニメーションの再生
    fn play(&mut self, sprite: &mut TextureAtlasSprite, time: Res<Time>) {
        // 停止中だった場合に再生中をfalseとする。
        if !self.is_playing {
            sprite.index = self.index.first;
            self.is_playing = true;
        }
        // アニメタイマーをtime.delta()秒進める。
        // Timer::from_seconds(0.1, TimerMode::Repeating)と設定しているため、0.1秒経つとjust_finished()がtrueになる。
        self.timer.tick(time.delta());
        if self.timer.just_finished() {
            sprite.index = if sprite.index == self.index.last {
                self.index.first
            } else {
                sprite.index + 1
            };
        }
    }
}
```

### 実装内容

#### コンポーネント
* Player
特にフィールドが1つも定義されていないPlayerを用意します。特に今回のシステムで必須ではないですが、BevyのシステムでQueryを使い、EntityのComponentへアクセスするためになります。
* PlayerAnimation
プレイヤーに紐づくアニメーションとして、停止状態、左に走る、右に走る状態をフィールドとして持たせます。
* SpriteAnimation
スプライトアニメーションの再生状況フラグ、スプライトアニメーションの再生指標(表示する画像の最初と最後を指定)、アニメーション再生速度をフィールドとして持たせます。
また、この構造体についてはメソッドを定義しています。stopとplay関数を用意しており、アニメーションの停止と再生をさせる機構を用意しており、どのシステムでも停止と再生が行えるようにしているつもりです。
* SpriteAnimationIndex
スプライントアニメーションの再生指標である表示する画像の最初と最後を指定してアニメーションとして再生するための情報をフィールドとして持たせます。
* SpriteAnimationTimer
スプライントアニメーションの再生速度をフィールドとして持たせます。

#### バンドル
* PlayerBundle
Player、SpriteSheetBundle、PlayerAnimationをまとめてバンドル化します。Playerはプレイヤーとしてシステムからアクセスするために使用します。SpriteSheetBundleはスプライトアニメーションとして使用する画像を読み込むために使用します。PlayerAnimationはスプライントアニメーションさせるための情報を持たせます。

#### システム
* create_player
Playerとしてどの画像を使用し、どのようなアニメーションを設定するかを決めます。いろいろ記述はありますが、コメントの方に記載していますので、コードを確認してみてください。なお、ここで2Dカメラの読み込みもついでに実施しています。
* update_player
Playerを左キー、右キーを押下した際に、移動する処理を追加しています。またアニメーションの再生・停止の制御もここで実施しております。

## 動作確認
```
$ cargo run
```
以下のように、キー操作をして止まっている時、右に移動した時、左に移動した時で指定したアニメーションが再生されていることができれば成功です。

![test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191987/50a73df4-fcda-62af-742c-bb1181ff10a1.gif)

## おわりに
今回は、2Dゲームの要であるスプライトアニメーションをRustのゲームエンジンであるBevyを使用して実現してみました。公式サイトのサンプルを参考にして実装をしましたが、アニメーションの切り替えは、自分である程度考えて実装しなくてはいけなかったのでさらに効率が良いやり方があるのではないかと思います。また、UnityやUnrealエンジンと比べて直感的に実装することは難しいかなと感じました。
複雑アニメーションを作成する際は、更なる改良が必要になると考えておりますが、やっぱりソースコードで完結でき他のエンジンよりハイスピードな処理速度を得られるというのは良いですね...。
次回は、2Dアクションゲームの要である横スクロースのアクションを試してみたいかなと思います！

明日は、@e73ryoさんになります。お楽しみに！