Behind Coleus

大学生なのに特別大学で楽しいことが一 つもない。虐待と男子校病のせいにしてみる。→日本脱出しました 今ジャカルタにいます。→ジャカルタから帰ってきました。

クエリを制すものはbevyを制す

https://displacedlobster.itch.io/sigil-siege
このゲーム凄いです。

youtu.be

github.com今日も昨日に引き続き、コードのレポートをやっていきます。

use bevy::{prelude::*, window::PrimaryWindow};
use rand::random;
pub const PLAYER_SPEED: f32 = 500.0;
pub const PLAYER_SIZE: f32 = 115.0;
pub const NUMBER_OF_ENEMIES: usize = 2;

fn main() {
    App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Startup, spawn_camera)
    .add_systems(Startup, spawn_player)
    .add_systems(Startup, spawn_enemies)
    .add_systems(Update, (player_movement, confine_player_movement))
    .run();
}

#[derive(Component)]
pub struct Player{}

#[derive(Component)]
pub struct Enemy{}

pub fn spawn_player(
    mut commands: Commands,
    window_query: Query<&Window, With<PrimaryWindow>>,
    asset_server: Res<AssetServer>,
){
    let window = window_query.get_single().unwrap();

    commands.spawn(
        (
            Sprite{
            image: asset_server.load("sprites/ball_blue_large.png"),
            ..default()
        },
        Transform::from_xyz(window.width()/2.0, window.height()/2.0, 0.0),
        Player {},        
    ));
}

pub fn spawn_camera(mut commands: Commands, window_query: Query<&Window, With<PrimaryWindow>>) {
    let window = window_query.get_single().unwrap();

    commands.spawn(
        (
            Camera2d::default(),
            Camera{
                hdr:true, ..default()
            },
            Transform::from_xyz(window.width()/2.0, window.height()/2.0, 0.0),
        )

    );
}

pub fn spawn_enemies(
    mut commands: Commands,
    window_query: Query<&Window, With<PrimaryWindow>>,
    asset_server: Res<AssetServer>,
){
    let window = window_query.get_single().unwrap();
    for _ in 0..NUMBER_OF_ENEMIES{
        let random_x = random::<f32>() * window.width();
        let random_y = random::<f32>() * window.height();

        commands.spawn(
            (
                Sprite{
                image: asset_server.load("sprites/ball_red_large.png"),
                ..default()
            },
            Transform::from_xyz(random_x, random_y, 0.0),
            Enemy {},        
        ));
    }

}

pub fn player_movement(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut player_query: Query<&mut Transform, With<Player>>,
    time: Res<Time>,
){
    if let Ok(mut transform) = player_query.get_single_mut(){
        let mut direction = Vec3::ZERO;

        if keyboard_input.pressed(KeyCode::ArrowLeft)||keyboard_input.pressed(KeyCode::KeyA){
            direction += Vec3::new(-1.0, 0.0, 0.0);
        }
        if keyboard_input.pressed(KeyCode::ArrowRight)||keyboard_input.pressed(KeyCode::KeyD){
            direction += Vec3::new(1.0, 0.0, 0.0);
        }
        if keyboard_input.pressed(KeyCode::ArrowUp) || keyboard_input.pressed(KeyCode::KeyW) {
            direction += Vec3::new(0.0, 1.0, 0.0);
        }
        if keyboard_input.pressed(KeyCode::ArrowDown) || keyboard_input.pressed(KeyCode::KeyS) {
            direction += Vec3::new(0.0, -1.0, 0.0);
        }

        if direction.length() > 0.0{
            direction = direction.normalize();
        }

        transform.translation += direction * PLAYER_SPEED * time.delta_secs();
    }
}

pub fn confine_player_movement(
    mut player_query: Query<&mut Transform, With<Player>>,
    window_query: Query<&Window, With<PrimaryWindow>>
){
    if let Ok(mut player_transform) = player_query.get_single_mut(){
        let window = window_query.get_single().unwrap();

        let half_player_size = PLAYER_SIZE / 2.0;
        let x_min = 0.0 + half_player_size;
        let x_max = window.width()/1.0 - half_player_size;
        let y_min = 0.0 + half_player_size;
        let y_max = window.height()/1.0 -half_player_size;

        let mut translation = player_transform.translation;

        translation.x = translation.x.clamp(x_min, x_max);
        translation.y = translation.y.clamp(y_min, y_max);
        player_transform.translation = translation;

    }
}

目的

このコードの目的は、まず、配置した画像をキー入力WASDと、矢印キーによって動かすことである。
そして、動かせる範囲を、制限することである。
最後に、ランダムな位置に画像を配置することである。


目的への取り組み


前回のコードで作ったspawn player とspawn cameraのシステムによってウィンドウと画像の表示をしたプログラムをもとに、制作した。

各機能は、システム一つずつに分けた。

書いた、システムを一つずつ説明していく。

まず、一つ目は前回と同じような手法で書けた画面上のランダムな位置に画像を配置するシステムspawn enemiesについて説明する。

まず、この関数は、NUMBER_OF_ENEMIESの数だけ、繰り返すこと、乱数を発生させる事、画像の座標(Transform)をその乱数にすること。 この3つ以外は、spawn player関数で用いられており、前回のブログで解説している。

brogmymxo.hatenablog.com

その3つを説明していく。

まず、座標を、ランダムにする部分についてみていく、

    for _ in 0..NUMBER_OF_ENEMIES{
        let random_x = random::<f32>() * window.width();
        let random_y = random::<f32>() * window.height();

        commands.spawn(
            (
                Sprite{
                image: asset_server.load("sprites/ball_red_large.png"),
                ..default()
            },
            Transform::from_xyz(random_x, random_y, 0.0),
            Enemy {},
        ));
    }

Transformで、xy座標を設定しているが、それが、前回はwindow.width()/2.0, window.height()/2.0, 0.0で設定していた。それが、random_x, random_y, 0.0 になっている。 このふたつのへんすうは、上で設定されていて、random関数を用いている。

use rand::random;

で導入されており、これは、bevyのrandではなく、rustのrandである。 chatGPT等でコードを書かせると、かなりの頻度でエラーを吐くが、問題は、依存関係

[package]
name = "bevy-ball-game"
version = "0.1.0"
edition = "2024"

[dependencies]
bevy = "0.15.3"
rand = "0.9.0"

に、適切に記述されていない、use rand ::prelude::*;の宣言をしている、などが原因でエラーを吐いていると考えられる。
random関数は、

let x = rand::random::<u8>();
println!("{}", x);

let y = rand::random::<f64>();
println!("{}", y);

if rand::random() { // generates a boolean
    println!("Better lucky than good!");
}

docs.rsのように、random::<指定したデータ型>() これにより、乱数を取得できます。

今回の

random::<f32>()

範囲は、[0, 1]ですそのため、それで得た値に、window.width();

をかけることで、範囲を[0, window.width()]にしている。

これにより、画面の左端から右端のランダムな座標を取得できる。

また、y座標についても、window.width();ではなく、window.height();を用いることで、画面の上端から下端までのランダムな座標を取得できる。

 

この動作を、for文で繰り返しています。

fn main() {
    // `n` will take the values: 1, 2, ..., 100 in each iteration
    // `n`は1, 2, ...., 100のそれぞれの値を取ります。
    for n in 1..101 {
        if n % 15 == 0 {
            println!("fizzbuzz");
        } else if n % 3 == 0 {
            println!("fizz");
        } else if n % 5 == 0 {
            println!("buzz");
        } else {
            println!("{}", n);
        }
    }
}

doc.rust-jp.rs
rustでは、for文は、 for n in 0..2{

と書くと、n = 0 n=1に代入して計算するという意味になる。
また、 nを _にすると、 ただ、2回繰り返すだけという意味になる。

今回の場合繰り返しでは、ただランダムな座標に画像を置きたいだけなので、回数によって意味はない。だから、ただ単純にnの部分を_にして、NUMBER_OF_ENEMIESの回数だけ繰り返すような関数となっている。 このままだと、この画像の座標とかが取得できずにだめだろうと考えがちであるが、実はEenmy{}のコンポーネントの宣言がなされているおかげで自動的に取得されている。らしい。そのため、今後、クエリ機能を用いてwith<Enemy>でidを入手しイテレーターを使えば、各エネミーについての操作を行うことが出来る。

次の、プレイヤー画像を操作する関数を説明する。

pub fn player_movement(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut player_query: Query<&mut Transform, With<Player>>,
    time: Res<Time>,
)


この部分の引数は、キーボードの入力を使う事、Playerコンポーネントがあれば、その情報を取得すること、時間を使う事を表している。

    if let Ok(mut transform) = player_query.get_single_mut(){

この部分は正直言って分かりにくい!!
まず、クエリを使わない、let OK 構文を参考にしてみよう。

let result: Result<i32, &str> = Ok(100);

if let Ok(value) = result {
    println!("Operation succeeded with value: {}", value);
} else {
    println!("Operation failed");
}

note.com


if let Ok(変数) = 構文で使えるのは、result型の変数です。result型の変数というのは、特定の値と、エラーをもつ列挙型の変数です。 一番上のresultという変数の宣言では、その特定の値が、100であることを表しています。

で、if let Ok(value) = result の部分では、
エラー処理と代入を同時に行っています。

というのも、value という変数に、Okの特定の値を渡すと同時に、resultがエラーでないかチェックするという事もしています。この関数では、エラーが起きることは無いので、テキストとともに、100が出てくるという事になっています。

はい、そして、本題の

    if let Ok(mut transform) = player_query.get_single_mut(){

このコードに話を戻します。この右部分player_query.get_single_mut()は、playerクエリから、一つの値か、エラーを返すResult型の変数です。今回の場合、プレイヤークエリは引数の時点で、

mut player_query: Query<&mut Transform, With<Player>>,

と宣言しており、Transformが入手できます。

そして、この部分をエラーが起きないかチェックするとともに、新しく宣言したtransformという変数に可変参照するという事を指示しています。つまり、現在transformという変数に、playerの初期座標が入っています。

        Transform::from_xyz(window.width()/2.0, window.height()/2.0, 0.0),

player spawn関数内にあるこのTransform::from_xyzには、Transformの中の、translationというフィールドを設定するという意味が含まれています。

そして、次にシステムの最後の部分

  transform.translation += direction * PLAYER_SPEED * time.delta_secs();

を説明します。これは、transform.translationのベクトルに、ベクトル、速さ、時間のベクトルを足し合わせる処理をしています。time.delta_secs()は、前のフレームから今のフレームまでの時間を表す変数で、引数にあったtime: Res<Time>から呼び出しました。directionは移動するベクトルを表すもので、あとで説明します。PLAYER_SPEEDは定数です。 正規化した速度ベクトルと、時間をかけることで、fpsに依存しない、移動を実現することが出来ます。
そのベクトルを設定するコードが、

        let mut direction = Vec3::ZERO;

        if keyboard_input.pressed(KeyCode::ArrowLeft)||keyboard_input.pressed(KeyCode::KeyA){
            direction += Vec3::new(-1.0, 0.0, 0.0);
        }
        if keyboard_input.pressed(KeyCode::ArrowRight)||keyboard_input.pressed(KeyCode::KeyD){
            direction += Vec3::new(1.0, 0.0, 0.0);
        }
        if keyboard_input.pressed(KeyCode::ArrowUp) || keyboard_input.pressed(KeyCode::KeyW) {
            direction += Vec3::new(0.0, 1.0, 0.0);
        }
        if keyboard_input.pressed(KeyCode::ArrowDown) || keyboard_input.pressed(KeyCode::KeyS) {
            direction += Vec3::new(0.0, -1.0, 0.0);
        }

        if direction.length() > 0.0{
            direction = direction.normalize();
        }

キーボード入力は、

    keyboard_input: Res<ButtonInput<KeyCode>>,

この引数によって得ています。 4つのif文は、説明するまでもなく、wasdと、矢印キーでの操作の、テンプレートです。

一番下のif文は。Vec3というbevyのライブラリから、そのベクトルの大きさを示し、それが0でなかったら、大きさを1になるように正規化するという事を示している。それをすることにより、斜め入力が可能になっている。
一番上ですが、これはベクトルの初期値です。

        let mut direction = Vec3::ZERO;

この部分を、

        let mut direction = Vec3::new(1.0,0.0,0.0);

このように変化させたとき、

何も操作を加えなくても、動いてウィンドウの右端に衝突した。

最後のシステムconfine player movementを解説する。

引数では、mut player_query: Query<&mut Transform, With<Player>>,
window_query: Query<&window, With<PrimaryWindow>>
を用いている。 これは、PlayerのTransformに可変参照することと、PrimaryWindowのWindowを固定参照することを示しています。


if let Okの部分は、先ほどと同じで、PlayerのTransformを、player_transformに代入しています。

        let window = window_query.get_single().unwrap();

この部分は、window.widthと、window.heightを使うためのものです。

        let mut translation = player_transform.translation;

translationという変数にTransform.translation(さっきやった、transform.translationの意味と同じ)を、可変参照で代入した。

で、その、translationの、x座標と、y座標をclampという機能によって、制限した。Vec3の機能で、translation.xと、translation.x.clampって言うのがあるみたい。
それで、x座標に操作を限定したり、clampでは、最大値を超えると、最大値を返す。最小値を下回ると最小値を返す。

端っこで、移動キーを押すと、少しめり込む。これが、clampの発生してないフレームの間のこと。

ただし、2回ビルドするとめり込まなくなる。なぜかは不明。


最後に、

    App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Startup, spawn_camera)
    .add_systems(Startup, spawn_player)
    .add_systems(Startup, spawn_enemies)
    .add_systems(Update, (player_movement, confine_player_movement))
    .run();
}

このmain関数を見ればわかるように、移動に関するシステムはスケジュールをUpdateとして、フレームごとに呼び出すようにした、player movement関数で用いた微小時間の呼び出しも、Updateスケジュールだから意味がある。

検証

 


上二つの実行結果の画像を見ればわかるように、赤いボールの画像(Enemyエンティティ)は、ランダムに表示されている。
また、移動機能についても全ての位置に行くことが出来た。

Startupスケジュールには、順序は無いのかもしれない。 何故なら、青が赤の上に行ったからである。

画面の端は一周しても、穴が開いていたりはしなかった。
つまり、移動機能、移動の制限、画像のランダム配置、全て完了した。