RustのゲームエンジンBevyでマスタデータを扱ってみる

[PONOS Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ponos)の19日目の記事です。
昨日は、[@tequila0725](https://qiita.com/tequila0725)さんの[スプレッドシートでブックマーク管理を実現するChrome拡張機能を作った話](https://qiita.com/tequila0725/items/f90a57a16a9a5e17e6cf)でした。

## はじめに
前回のアドベントカレンダーで、[RustのゲームエンジンBevyでスプライトアニメーションをしてみた](https://qiita.com/nisei275/items/c0c9aaf097a53ac3cf02)の記事を書かせていただきました。今回もRustのゲームエンジンを使ったゲームアプリ関連で記述してみようかなと思います。なお、使用しているゲームエンジンについて、2024年12月現在でもRustのゲームエンジンであるBevyは、活発に開発が進められており、現在ではバージョン[0.14](https://bevyengine.org/news/bevy-0-14/)が公開され、日々進化しております。もしかしたら、UnityやUnrealに変わるゲームエンジンとなるかもしれませんね。
さて、今回はゲームアプリの要であるマスタデータをBevyエンジンに読み込ませてみようかと思います。見た目上は地味ですが非常に重要な機能になるかなと思います。

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

| 開発環境 | バージョン |
|:-:|:-:|
| MacOS(Ventura) | 14.6.1 |
| Rust(rustc、cargo) | 1.82.0 |
| Bevy | 0.14.2 |

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

### プロジェクトの作成
```
$ cargo new bevy_test
```

生成されたプロジェクトの中に、assetsフォルダを作成しそこにマスタデータを設置します。マスタデータはステージ情報を記録した`stage.csv`を用意します。以下が最終的なディレクトリ構成となる想定です。

```
├──Cargo.toml
├──README.md
├──assets
|  └──stages.csv
└──src
   └──main.rs
```

### 実装する
まずは、マスタデータを用意します。今回はステージ情報を記録したcsvファイルを用意します。
```csvs:stages.csv
id,number,name,description,difficulty
1001,001,The Stage 1,The Stage 1 Description,1
1002,002,The Stage 2,The Stage 2 Description,2
1003,003,The Stage 3,The Stage 3 Description,1
1004,004,The Stage 4,The Stage 4 Description,2
1005,005,The Stage 5,The Stage 5 Description,3
```

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

```toml:Cargo.toml
[package]
name = "bevy test"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy = "0.14.2"
serde = { version = "1.0", features = ["derive"] }
csv = "1.3"
```

```rust:main.rs
use bevy::{
    prelude::*,
    window::{PresentMode, WindowTheme},
};
use std::error::Error;
use std::fs;
use std::path;

/// マスタデータのリソース
#[derive(Resource, Default, Debug)]
pub struct Masterdata {
    pub stages: Vec<Stage>,
}

/// ステージのリソース
#[derive(Resource, serde::Deserialize, Default, Debug)]
pub struct Stage {
    pub id: i32,
    pub number: String,
    pub name: String,
    pub description: String,
    pub difficulty: i32,
}

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()
            })
        )
        .add_systems(PreStartup, load_masterdata)
        .add_systems(Startup, display_stage)
        .run();
}

/// ステージ情報を表示
fn display_stage(mut command: Commands, masterdata: Res<Masterdata>) {
    // 2Dカメラを生成
    command.spawn(Camera2dBundle::default());

    // マスタデータからステージ情報を表示するテキストを生成
    let mut text_y = 60.0;
    for stage in masterdata.stages.iter() {
        let text = Text::from_section(
            format!("STAGE NUMBER: {}, STAGE NAME: {}, DESCRIPTION: {}", stage.number, stage.name, stage.description),
            TextStyle { font_size: 32.0, ..default() });
        command.spawn(
            Text2dBundle {
                text,
                transform: Transform {
                    translation: Vec3::new(0.0, text_y, 0.0),
                    ..default()
                },
                ..default()
            }
        );
        text_y -= 30.0;
    }
}

/// マスターデータを読み込む
fn load_masterdata(mut commands: Commands) {
    // マスタデータの構造体のベクト型をそれぞれ用意
    let mut stages: Vec<Stage> = Vec::new();

    // マスタデータが保管されているディレクトリパスを指定しその中にあるCSVのファイルパスを取得
    let paths = match file_paths("assets") {
        Ok(result) => result,
        Err(err) => panic!("{}{:?}", "Error Message: ", err),
    };

    // CSVファイルのパスを指定して内容を読み込む。ファイルがなければ強制終了
    for path in paths {
        let file_name = path.file_stem().unwrap().to_str().unwrap();
        // 取得したファイルパスの拡張子がCSVのみ処理を実行する
        if path.extension().unwrap() != "csv" { continue; }
        let path_str = path.to_str().unwrap();

        // パスを指定してCSVからレコード群を読み込む
        let records = match csv_read(path_str, false) {
            Ok(result) => result,
            Err(err) => panic!("{}{:?}", "Error Message: ", err),
        };

        // 読み込んだレコード群をそれぞれ用意した構造体のベクト型に保管
        for record in records {
            match file_name {
                "stages" => stages.push(record.deserialize(None).unwrap()),
                _ => println!("not matched"),
            }
        }
    }

    // マスタデータをアプリでグローバルに共有されるリソースとして登録
    // アプリ上では唯一な存在のデータとして扱う
    commands.insert_resource(Masterdata { stages } );
}

/// ディクトリを指定しその中にあるファイルのパス群を取得
///
/// * `dir_path` - ディレクトリのパス
fn file_paths(dir_path: &str) -> Result<Vec<path::PathBuf>, Box<dyn Error>> {
    // ディレクトリ情報を一時的に保管するベクト型
    let mut files: Vec<path::PathBuf> = Vec::new();
    // ディレクトリの情報を取得する。
    let dir = fs::read_dir(dir_path)?;

    // ディレクトリのパスを取得し収集する。
    for item in dir.into_iter() {
        files.push(item?.path());
    }

    Ok(files)
}

/// CSVファイルを読み込んでVecのString型を取得
///
/// * `file_path` - CSVファイルのパス
/// * `is_header` - ヘッダーありで取得するか
fn csv_read(file_path: &str, is_header: bool) -> Result<Vec<csv::StringRecord>, Box<dyn Error>> {
    // 読んだCSVファイルを一時的に保管するベクト型
    let mut records = Vec::new();
    // CSVファイルを読み込む
    let csv_text = fs::read_to_string(file_path)?;
    // ヘッダーありで取得するかしないかを判定して読み込む
    let mut reader = if is_header {
                            // CSVライブラリは通常以下のように読み込むが、1行目が読み込まない設定になっているため、読み込むように変更
                            csv::ReaderBuilder::new().has_headers(false).from_reader(csv_text.as_bytes())
                        } else {
                            // CSVライブラリで読み込むとヘッダーなしで読み込む
                            csv::Reader::from_reader(csv_text.as_bytes())
                        };

    // 読み込んだCSVの内容を1行ずつ読み込む
    for string_record in reader.records() {
        let record: csv::StringRecord = string_record?;
        records.push(record);
    }

    Ok(records)
}
```

### 実装内容

#### リソース
Bevyエンジンの[Resources](https://bevyengine.org/learn/quick-start/getting-started/resources/)は、単一のグローバルなインスタンスに保存されたデータを指します。リソースデータとして経過時間やアセットコレクション(サウンド、テクスチャ、メッシュ)、レンダラーなどが挙げられており、マスタデータもアセットの1つとしてResourcesのデータとして扱います。

* Masterdata
全てのマスタデータを1つのリソースデータとしてまとめるためにMasterdataとして定義します。その中には、マスタデータとして登録する構造体をVec型で保管できるように定義しておきます。

* Stage
マスタデータとしてのステージを定義します。`stages.csv`に存在する1レコードがこの構造体で定義します。

#### システム
* display_stage
2Dカメラの生成および`stages.csv`から読み込んだマスタデータの情報を表示します。Bevyエンジンでマスタデータをリソースとして登録した場合は、`masterdata: Res<Masterdata>`のように読み込むことができます。記述してある通りの構造体でマスタデータを登録した場合に、`masterdata.stages`で登録されているステージのマスタデータを読むことができます。

* load_masterdata
`assets`に配置されている`stages.csv`を読み込み、グローバルに共有されるようにリソースとして登録します。こちらで用意した関数`file_paths`と`csv_read`を使用して、CSVファイルに記述されているレコードを`stages.csv`ファイルだったら`Stage`の構造体として読み込みます。読み込んだ構造体は`Masterdata`にまとめて保管し、`insert_resource`を利用してリソースとして登録します。

* file_paths
ディレクトリを指定してそこに存在するファイルのパス群を取得します。

* csv_read
[CSVのクレート](https://crates.io/crates/csv)を使用し、指定したパスからCSVデータを1レコードずつ読み込んでいきます。CSVのクレートが用意しているStringRecordは、フィールド名(ヘッダー値対応)に基づいて構造体へのデシリアライズできる機能を持ちます。

## 動作確認
```
$ cargo run
```
以下のように、マスタデータ(CSV)に記録されているデータを読み込んで表示することができました。

![Screenshot 0006-12-07 at 20.30.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191987/fb0e55ce-6d25-7f2a-1420-a9a2f90d0954.png)

## おわりに
今回は、ゲームの要であるマスタデータ(CSV形式)をRustのゲームエンジンであるBevyで使用することを実現してみました。こちらの実装により、簡単に他のシステムからマスタデータにアクセスすることができるようになります。
次回は、[前回の記事](https://qiita.com/nisei275/items/c0c9aaf097a53ac3cf02)で2Dアクションゲームの要である横スクロースのアクションを試してみたいと書いておきながら実施できていないので、次回こそは実施できればと思います！

明日は、[@FW14B](https://qiita.com/FW14B)さんになります。お楽しみに！