🕹️

はじめてのBevy: Rustでブロック崩しを作ろう!

に公開

この記事はambr, Inc. Advent Calendar 2025 10日目の記事です。


はじめに

社内のRust勉強会に参加しているのですが、徐々に話についていけなくなってしまいました。
ここらで自習をして、巻き返しを図ろうと思います。

想定読者

  • (私のような)Rustに興味があるけどまだ手を出せていない人
  • (私のような)Unityなどのゲームエンジンに触れた経験がある人

私の開発環境

  • MacBookAir (M2, 2022)
  • MacOS Tahoe26.0.1

本記事では、Rustの所有権やBevyのECSなどの専門用語の解説は行いません。
とりあえず写経して、なんとなくの雰囲気を感じてみたい!という私のような方にオススメです。

なお、今回作成するブロック崩しは、Bevy公式リポジトリのexamplesの1つであるbreakout.rsのコードを私なりに整理したものです[1]
https://github.com/bevyengine/bevy
興味のある方は、是非公式のexamplesも確認してみてくださいね。

今回作成するブロック崩しはこのようなものになります。
despawn

それではやっていきましょう。

本編

0. Rustをインストールする

下記の公式サイトに従って、Rustをインストールしてください。
https://rust-lang.org/ja/tools/install/

終わったら、cargo -hを実行してみましょう。
cargoコマンドのUsageやOptionsなどが表示されていれば、正しくインストールされています。
うまく行かない方は、ウインドウの再起動や、パスの確認などを試してみてください。

1. プロジェクトをつくる

プロジェクトを作成するディレクトリを1つ選んでください。私は~/Documentsにしました。
プロジェクト名もここで決めましょう。今回はmy_breakoutとします。
Bevy公式サイトのQuick Startではプロジェクト名がスネークケースでしたので、本記事でもそれに従った命名にします。
https://bevy.org/learn/quick-start/getting-started/setup/

ターミナルで選んだディレクトリまで移動し、Rustの新しいプロジェクトをcargo newで作成しましょう。

cd ~/Documents
cargo new my_breakout

これで空のRustプロジェクトが作成されました。

作成したプロジェクト内に移動し、Visual Studio Codeで開いてみましょう。

cd my_breakout
code .

VSCodeでRustを書く際には、Rust公式が提供しているrust-analyzerというプラグインがオススメです。
お好みでインストールしてみてください。
https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer

2. Bevyを導入する

プロジェクト内のCargo.tomlというファイルを探し、開いてみましょう。
下記のようになっていると思います。

Cargo.toml
[package]
name = "my_breakout"
version = "0.1.0"
edition = "2024"

[dependencies]

my_breakoutはまだ生まれたてのプロジェクトですので、dependenciesは空のままですね。
ここに、cargo addを用いてBevyを追加しましょう。
プロジェクトのルートディレクトリで下記を実行します。

cargo add bevy

Cargo.tomlのdependenciesにbevyが追加されていれば完了です。

Cargo.toml
[dependencies]
bevy = "0.17.3"

本記事執筆時点(2025/12/05)でのバージョンは0.17.3でした。
これで、プロジェクトへのBevyの追加は完了です。

3. ウインドウを出す

ブロック崩しをプレイするためのウインドウを用意しましょう。
src/main.rsを開きます。

src/main.rs
fn main() {
    println!("Hello, world!");
}

これを次のように書き換えます。

src/main.rs
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .run();
}

それでは、試しにアプリを実行してみましょう。
プロジェクトのルートディレクトリで下記を実行します。

cargo run

空のウインドウが開けば成功です。

空のウインドウ

今後、アプリを実行する際にはcargo runを実行しましょう。
ウインドウは左上の☓ボタン、またはcommand+c(ctrl+c)で閉じることができます。

4. 画像を表示する

ウインドウ内にSpriteを作成して、背景画像を表示してみましょう。
今回はサンプル画像として、以下のようなsample.png[2]を用意しました。

sample.png

画像をプロジェクトに取り込むために、まずアセットを格納するディレクトリを用意します。
プロジェクトルートにassetsというディレクトリを作成しましょう。

mkdir assets

Bevyではアセットをassetsディレクトリで管理します。
assets内に、画像アセットを管理するためのimagesディレクトリを作成しましょう。

cd assets
mkdir images

先程用意したsample.pngをimagesディレクトリ内に配置します。これで準備完了です。

次に、ウインドウ内にsample.pngを表示するコードを書いていきます。
main.rsに、setup関数を追記しましょう。

src/main.rs
use bevy::prelude::*;

fn main() {
    // 略
}

fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    // cameraを配置
    commands.spawn(Camera2d);

    // 背景画像を追加
    commands.spawn(
        Sprite {
            image: asset_server.load("images/sample.png"),  // 
            custom_size: Some(Vec2::new(1280.0, 720.0)),  // 画像サイズを指定
            ..default()
        },
    );
}

そして、setup関数を実行するために、main関数に以下のように追加します。

src/main.rs
fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)  // 追加
        .run();
}

Bevyではadd_system関数を用いてアプリ上で実行する処理(system)を追加できます。
0番目の引数は実行するタイミング、1番目の引数は実行する関数を取ります。
今回は、"Startup(= アプリ開始時)にsetup関数を実行する"、と理解しておけば大丈夫です。

cargo runで確認してみましょう。
ウインドウ内に画像が表示されていれば成功です。

only_bg

5. Paddleを追加する

ブロック崩し内で操作するPaddle(足場)を作成します。
このあたりから、ECS(Entity component system)らしくなってきます。

今回作成するPaddleは以下のようなものです。

  • 横長の長方形
  • 青色
  • キーボードの矢印キーで左右に移動する

main.rsに、Paddleコンポーネントを定義しましょう。

src/main.rs
// ...前略
#[derive(Component)]
struct Paddle;

コンポーネントは、IDやタグのようなものだと思っていれば大丈夫だと思います。
早速、Paddleコンポーネントを持つエンティティをアプリに追加しましょう。

まずはPaddleの位置やサイズ、色の定数を定義します。

src/main.rs
const PADDLE_Y: f32 = -200.0;
const PADDLE_SIZE: Vec2 = Vec2::new(120.0, 20.0);
const COLOR_PADDLE: Color = Color::srgb(0.3, 0.7, 1.0);

次に、setup関数内にPaddleを追加するコードを追記します。

src/main.rs
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    // ... 前略
    commands.spawn((
        Sprite::from_color(
            COLOR_PADDLE, 
            Vec2::ONE,
        ),
        Transform {
            translation: Vec3::new(0.0, PADDLE_Y, 0.0),
            scale: PADDLE_SIZE.extend(1.0),
            ..default()
        },
        Paddle,  // Paddleコンポーネント
    ));
}

cargo runで確認してみましょう。

only_bg

...何も変わっていません。画像が表示されているだけです。
どうやら、Paddleが背景画像の後ろに隠れてしまっているようです。(一敗)
背景画像とPaddleのZ座標を調整して、Paddleが手前に表示されるようにしましょう。

src/main.rs

// ... 前略
// Z座標
const Z_BACKGROUND: f32 = -1.0;
const Z_PADDLE: f32 = 1.0;

// 中略

fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    // ... 前略
    commands.spawn((  // 括弧を1つ増やす
        Sprite {
            image: asset_server.load("images/sample.png"),
            custom_size: Some(Vec2::new(1280.0, 720.0)),
            ..default()
        },
        Transform {  // Transformコンポーネントを追加
            translation: Vec3::new(0.0, 0.0, Z_BACKGROUND),  // Z座標を設定
            ..default()
        },
    ));

    commands.spawn((
        Sprite::from_color(
            COLOR_PADDLE, 
            Vec2::ONE,
        ),
        Transform {
            translation: Vec3::new(0.0, PADDLE_Y, Z_PADDLE),  // Z座標を設定
            scale: PADDLE_SIZE.extend(1.0),
            ..default()
        },
        Paddle,
    ));
}

背景画像に複数のコンポーネントを追加するために、commands.spawnの引数をタプルに変更しました。括弧が増えたことに注意してください。
cargo runで確認してみましょう。
Paddleが背景画像の手前に表示されていれば成功です。

paddle

それでは、キーボードの矢印キーでPaddleを左右に移動できるようにしましょう。
main.rsに、Paddleを移動させるためのmove_paddle関数を追加します。

src/main.rs
// ... 前略

const PADDLE_SPEED: f32 = 500.0;  // Paddleの移動速度(ピクセル/秒)

/// 矢印キーでPuddleを左右に動かすシステム
fn move_paddle(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
    mut query: Query<&mut Transform, With<Paddle>>,  // Paddleコンポーネントを持つエンティティのTransformを取得
) {
    let mut direction = 0.0;

    if keyboard_input.pressed(KeyCode::ArrowLeft) {
        direction -= 1.0;
    }
    if keyboard_input.pressed(KeyCode::ArrowRight) {  // 同時押しなら0
        direction += 1.0;
    }

    if direction == 0.0 {
        return;  // 移動しない
    }

    let delta = direction * PADDLE_SPEED * time.delta_secs();  // MEMO: delta_seconds()ではなくdelta_secs()

    for mut transform in &mut query {
        transform.translation.x += delta;
    }
}

この関数をシステムとして登録しましょう。main関数を以下のように変更します。

src/main.rs
fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(FixedUpdate, move_paddle)  // 追加
        .run();
}

FixedUpdateはBevyのスケジュールの1つで、レンダリングフレームごとではなく、固定レートで実行されます。
https://docs.rs/bevy/latest/bevy/prelude/struct.FixedUpdate.html

cargo runで確認してみましょう。
キーボードの矢印キーでPaddleが左右に移動すれば成功です。

paddle_moving

6. ボールを追加する

次に、ブロックを崩すためのボールを追加しましょう。
今回作成するボールは以下のようなものです。

  • 円形
  • 赤色
  • 一定速度で移動

Paddleと同様に、まずはBallコンポーネントを定義します。
Ballのための定数もついでに定義しましょう。

src/main.rs
#[derive(Component)]
struct Ball;

const BALL_DIAMETER: f32 = 30.;  // ボールの直径
const BALL_SPEED: f32 = 400.0;  // ボールの速度(ピクセル/秒)
const INITIAL_BALL_DIRECTION: Vec2 = Vec2::new(0.5, -0.5);  // ボールの初期移動方向
const BALL_STARTING_POSITION: Vec3 = Vec3::new(0.0, -50.0, 1.0);  // ボールの初期位置

const BALL_COLOR: Color = Color::srgb(1.0, 0.5, 0.5);

次に、setup関数内にボールを追加するコードを追記します。
setup関数の引数にmeshesmaterialsが増えていることに注意してください[3]

src/main.rs
fn setup(
    mut commands: Commands, 
    mut meshes: ResMut<Assets<Mesh>>,  // 追加
    mut materials: ResMut<Assets<ColorMaterial>>,  // 追加
    asset_server: Res<AssetServer>) 
{
    // 略

    commands.spawn((
        Mesh2d(meshes.add(Circle::default())),
        MeshMaterial2d(materials.add(BALL_COLOR)),
        Transform::from_translation(BALL_STARTING_POSITION)
            .with_scale(Vec2::splat(BALL_DIAMETER).extend(1.)),
        Ball,
    ));
}

cargo runで確認してみましょう。赤いボールが少しPaddleよりも上に表示されていれば成功です。

ball

いくつかのWarningが出ていると思います。これは、Rustコンパイラが未使用の定数を指摘しているものです。
先程いくつかの定数を定義しましたが、ボールの移動に関するものはまだ使用していません。
ボールを移動させるために、速度を管理するためのVelocityコンポーネントを定義し、Ballに追加しましょう。

src/main.rs
#[derive(Component, Deref, DerefMut)]
struct Velocity(pub Vec2);

fn setup(
    // 略
){
    // 略
    commands.spawn((
        Mesh2d(meshes.add(Circle::default())),
        MeshMaterial2d(materials.add(BALL_COLOR)),
        Transform::from_translation(BALL_STARTING_POSITION)
            .with_scale(Vec2::splat(BALL_DIAMETER).extend(1.)),
        Ball,
        Velocity(INITIAL_BALL_DIRECTION.normalize() * BALL_SPEED),  // 追加
    ));
}

BallにVelocityコンポーネントを追加しました。
このVelocityの値に基づいてボールを移動させるためには、Velocityコンポーネントを持つエンティティのTransformを更新するシステムを追加する必要があります。
次のようなapply_velocity関数を追加しましょう。

src/main.rs
/// Velocityコンポーネントを持つエンティティに対して速度を適用するシステム
fn apply_velocity(mut query: Query<(&mut Transform, &Velocity)>, time: Res<Time>) {
    for (mut transform, velocity) in &mut query {
        transform.translation.x += velocity.x * time.delta_secs();
        transform.translation.y += velocity.y * time.delta_secs();
    }
}

この関数を一定間隔ごとに実行するようにシステムとして登録しましょう。main関数を以下のように変更します。

src/main.rs
fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(FixedUpdate, (apply_velocity, move_paddle).chain())  // apply_velocityを追加
        .run();
}

cargo runで確認してみましょう。ボールが一定速度で移動し始めていれば成功です。

ball_moving

しかしこれではボールがウインドウの外へ飛んでいってしまいますので、次はこのボールを閉じ込めるための壁を追加しましょう。

7. 壁を追加する

このあたりから少し複雑になってきます。

まずは、これまでと同様にWallコンポーネントを宣言しましょう。
Wallの大きさや厚み、色の定数もついでに定義します。

src/main.rs
#[derive(Component)]
#[require(Sprite, Transform)]
struct Wall;

pub const WALL_THICKNESS: f32 = 10.0;
// x coordinates
pub const LEFT_WALL: f32 = -450.;
pub const RIGHT_WALL: f32 = 450.;
// y coordinates
pub const BOTTOM_WALL: f32 = -300.;
pub const TOP_WALL: f32 = 300.;

pub const WALL_COLOR: Color = Color::srgb(0.8, 0.8, 0.8);

今回は、長方形を上下左右に4つ配置してボールを閉じ込める壁を作成します。
壁を区別するためのenumとしてWallLocationを用意しましょう。

src/main.rs
enum WallLocation {
    Left,
    Right,
    Bottom,
    Top,
}

Rustでは列挙型(enum)にメソッドを定義できます。
implブロックを用いて、WallLocationに対して壁の位置とサイズを取得するためのメソッドを定義しましょう。

src/main.rs
impl WallLocation {
    fn position(&self) -> Vec2 {
        match self {
            WallLocation::Left => Vec2::new(LEFT_WALL, 0.0),
            WallLocation::Right => Vec2::new(RIGHT_WALL, 0.0),
            WallLocation::Bottom => Vec2::new(0.0, BOTTOM_WALL),
            WallLocation::Top => Vec2::new(0.0, TOP_WALL),
        }
    }

    fn size(&self) -> Vec2 {
        let arena_height = TOP_WALL - BOTTOM_WALL;
        let arena_width = RIGHT_WALL - LEFT_WALL;
        assert!(arena_height > 0.0);
        assert!(arena_width > 0.0);

        match self {
            WallLocation::Left | WallLocation::Right => {
                Vec2::new(WALL_THICKNESS, arena_height + WALL_THICKNESS)
            }
            WallLocation::Bottom | WallLocation::Top => {
                Vec2::new(arena_width + WALL_THICKNESS, WALL_THICKNESS)
            }
        }
    }
}

ここまでのコードで、setup関数に壁を追加するコードを書くこともできます。
しかし、「上」「下」「左」「右」の壁を1つ1つ追加するコードは重複が多く冗長なので、Wallにnewを実装して、引数にWallLocationを渡すことで簡潔に書けるようにしておきましょう。

src/main.rs
impl Wall {
    fn new(location: WallLocation) -> (Wall, Sprite, Transform) {
        (
            Wall,
            Sprite::from_color(WALL_COLOR, Vec2::ONE),
            Transform {
                translation: location.position().extend(0.),
                scale: location.size().extend(1.),
                ..default()
            }
        )
    }
}

このWall::newメソッドを用いて、setup関数内に壁を追加するコードを追記します。

src/main.rs
fn setup(
    // 略
){
    // 略
    commands.spawn(Wall::new(WallLocation::Left));
    commands.spawn(Wall::new(WallLocation::Right));
    commands.spawn(Wall::new(WallLocation::Top));
    commands.spawn(Wall::new(WallLocation::Bottom));
}

cargo runで確認してみましょう。壁が表示されていれば成功です。

wall

しかしこれでは、ボールが壁を貫通してしまいます。
次は、ボールが壁やPaddleに衝突した際に跳ね返るようにしてみましょう。

8. 衝突判定を追加する

衝突判定をボールと衝突対象(壁・Paddle・ブロック)の座標と大きさから計算して実装します。

今回のブロック崩しには以下のような制約があります。

  • 2D空間
  • ボールが衝突する対象は壁・Paddle・ブロック(Brick)のみである
  • 壁・Paddle・ブロックは全て長方形
  • 壁・Paddle・ブロックの配置に角度がついていない

なので、衝突判定はボールの中心から、壁・Paddle・ブロックの矩形範囲までの距離で行うことができます。
この距離がボールの半径以下であれば衝突している -> ボールの衝突した壁の垂線方向の速度成分を反転させる、というしくみです。

衝突の種類は、(x, y) * (正, 負)の4つのみですので、まずこれらを表現するためのenumを定義しましょう。

src/main.rs
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
enum Collision {
    Left,
    Right,
    Top,
    Bottom,
}

次に、ボールのBoundingCircleと衝突対象のBoundingBoxから、どの衝突なのかを判定する関数を定義します。

src/main.rs
use bevy::math::bounding::{Aabb2d, BoundingCircle, BoundingVolume, IntersectsVolume};  // 追加

// 中略

fn ball_collision(ball: BoundingCircle, bounding_box: Aabb2d) -> Option<Collision> {
    if !ball.intersects(&bounding_box) {
        return None;
    }

    let closest = bounding_box.closest_point(ball.center());
    let offset = ball.center() - closest;
    let side = if offset.x.abs() > offset.y.abs() {
        if offset.x < 0. {
            Collision::Left
        } else {
            Collision::Right
        }
    } else if offset.y > 0. {
        Collision::Top
    } else {
        Collision::Bottom
    };

    Some(side)
}

ball_collision関数は、ボールのBoundingCircleと衝突対象のAabb2d(Axis-Aligned Bounding Boxの略[4])を引数に取り、衝突していればSome(Collision)を、衝突していなければNoneを返します。
返り値のOption<T>型は 取得できないかもしれない値 を表現するRustの列挙型です[5]
(私は、bool型のtrueに値が含まれているイメージで理解していますが、あまり正しくない気がしています。)

次に、衝突するオブジェクトを表現するためにColliderコンポーネントを定義しましょう。

src/main.rs
#[derive(Component, Default)]
pub struct Collider;

ボールがColliderコンポーネントを持っているエンティティと衝突したとき、跳ね返るようにシステムを追加しましょう。
check_for_collisions関数を追加し、ボールとColliderが衝突しているか確認します。

src/main.rs
fn check_for_collisions(
    mut commands: Commands,
    ball_query: Single<(&mut Velocity, &Transform), With<Ball>>,
    collider_query: Query<(Entity, &Transform), With<Collider>>,
) {
    let (mut ball_velocity, ball_transfrom) = ball_query.into_inner();

    for (collider_entity, collider_transform) in &collider_query {
        let collision = ball_collision(
            BoundingCircle::new(ball_transfrom.translation.truncate(), BALL_DIAMETER / 2.),
            Aabb2d::new(
                collider_transform.translation.truncate(),
                collider_transform.scale.truncate() / 2.,
            ),
        );

        if let Some(collision) = collision {
            // reflection
            let mut reflect_x = false;
            let mut reflect_y = false;

            match collision {
                Collision::Left => reflect_x = ball_velocity.x > 0.,  // 反射方向に進んでいる場合のみ反射させる
                Collision::Right => reflect_x = ball_velocity.x < 0.,
                Collision::Top => reflect_y = ball_velocity.y < 0.,
                Collision::Bottom => reflect_y = ball_velocity.y > 0.,
            }

            if reflect_x {
                ball_velocity.x = -ball_velocity.x;
            }
            if reflect_y {
                ball_velocity.y = -ball_velocity.y;
            }
        }
    }
}

if let Some(collision) = collisionの部分で使われているif letはRust初心者がつまづきやすい部分なので注意しましょう。
こちらの解説記事等を参考にしてください。
https://qiita.com/plotter/items/0d8dc2782f21178d64f1
この関数を一定間隔ごとに実行するようにシステムとして登録しましょう。
main関数を以下のように変更します。

src/main.rs
fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(
            FixedUpdate, 
            (apply_velocity, move_paddle, check_for_collisions)  // check_for_collisionsを追加
                .chain()
            )
        .run();
}

忘れずに、PaddleとWallにColliderコンポーネントを追加しましょう。

src/main.rs
fn setup(
    // 略
){
    // 略
    commands.spawn((
        // 略
        Paddle,
        Collider,  // 追加
    ));
}

#[derive(Component)]
#[require(Sprite, Transform, Collider)]  // Colliderをrequireに追加
pub struct Wall;

requireアトリビュートは、Wallコンポーネントを持つエンティティがColliderコンポーネントも持っていることを保証します。
newメソッド内で明示的にColliderコンポーネントを追加しなくても、Bevyが自動的にColliderコンポーネントを追加してくれます。
なので、Wall::newメソッドを変更する必要はありません。

cargo runで確認してみましょう。ボールが壁やPaddleに衝突して跳ね返れば成功です。

bouncing

9. ブロックを追加する

最後に、ブロック(Brick)を追加しましょう。
これまでと同様に、まずはBrickコンポーネントを定義します。

src/main.rs
#[derive(Component)]
pub struct Brick;

pub const BRICK_SIZE: Vec2 = Vec2::new(100., 30.);
pub const GAP_BETWEEN_PADDLE_AND_BRICKS: f32 = 270.0;  // Paddleと一番下のブロックの間隔
pub const GAP_BETWEEN_BRICKS: f32 = 5.0;  // ブロック間の間隔
pub const GAP_BETWEEN_BRICKS_AND_CEILING: f32 = 20.0;  // ブロックと天井の間隔
pub const GAP_BETWEEN_BRICKS_AND_SIDES: f32 = 20.0;  // ブロックと左右の壁の間隔

pub const BRICK_COLOR: Color = Color::srgb(0.5, 0.5, 1.0);

次に、setup関数内にブロックを追加するコードを追記します。
少し長いですが、特に新しい内容はありません。
前半では、ブロックの大きさと壁との隙間の広さから、ブロックを並べられる列数と行数を求めるための計算をしています。
後半では、求められた列数と行数の数だけ、簡単なfor文でブロックを並べています。

src/main.rs

fn setup(
    // 略
)
    {
    // 略

    let total_width_of_bricks = (RIGHT_WALL - LEFT_WALL) - 2. * GAP_BETWEEN_BRICKS_AND_SIDES;
    let bottom_edge_of_bricks = PADDLE_Y + GAP_BETWEEN_PADDLE_AND_BRICKS;
    let total_height_of_bricks = TOP_WALL - bottom_edge_of_bricks - GAP_BETWEEN_BRICKS_AND_CEILING;

    assert!(total_width_of_bricks > 0.0);
    assert!(total_height_of_bricks > 0.0);

    // 利用可能なスペースから、Brickが並べられる列数と行数を計算する
    let n_columns = (total_width_of_bricks / (BRICK_SIZE.x + GAP_BETWEEN_BRICKS)).floor() as usize;
    let n_rows = (total_height_of_bricks / (BRICK_SIZE.y + GAP_BETWEEN_BRICKS)).floor() as usize;
    let n_vertical_gaps = n_columns - 1;

    let center_of_bricks = (LEFT_WALL + RIGHT_WALL) / 2.0;
    let left_edge_of_bricks = center_of_bricks
        // Brickが占める横幅
        - (n_columns as f32 / 2.0 * BRICK_SIZE.x)
        // Brick間の隙間
        - n_vertical_gaps as f32 / 2.0 * GAP_BETWEEN_BRICKS;

    // Bevy では、`translation` はEntityの左下ではなく中心の座標を表すことに注意
    let offset_x = left_edge_of_bricks + BRICK_SIZE.x / 2.;
    let offset_y = bottom_edge_of_bricks + BRICK_SIZE.y / 2.;

    for row in 0..n_rows {
        for column in 0..n_columns {
            let brick_position = Vec2::new(
                offset_x + column as f32 * (BRICK_SIZE.x + GAP_BETWEEN_BRICKS),
                offset_y + row as f32 * (BRICK_SIZE.y + GAP_BETWEEN_BRICKS),
            );

            // Brickを追加
            commands.spawn((
                Sprite {
                    color: BRICK_COLOR,
                    ..default()
                },
                Transform {
                    translation: brick_position.extend(0.0),
                    scale: Vec3::new(BRICK_SIZE.x, BRICK_SIZE.y, 1.0),
                    ..default()
                },
                Brick,
                Collider,
            ));
        }
    }
}

cargo runで確認してみましょう。ブロックが並んでいれば成功です。

bricks

次に、ボールがブロックに衝突した際にブロックが消えるようにしましょう。
check_for_collisions関数を以下のように変更します。

src/main.rs
pub fn check_for_collisions(
    mut commands: Commands,
    ball_query: Single<(&mut Velocity, &Transform), With<Ball>>,
    collider_query: Query<(Entity, &Transform, Option<&Brick>), With<Collider>>,  // Option<&Brick>を追加
) {
    let (mut ball_velocity, ball_transfrom) = ball_query.into_inner();

    for (collider_entity, collider_transform, maybe_brick) in &collider_query {  // maybe_brickを追加
        
        // 略

        if let Some(collision) = collision {
            // Brickの場合はそのEntityを削除する
            if maybe_brick.is_some() {
                commands.entity(collider_entity).despawn();
            }

            // reflection
            let mut reflect_x = false;
            let mut reflect_y = false;

            // ... 以下略
        }
    }
}

maybe_brickは、衝突対象のエンティティがBrickコンポーネントを持っているかどうかを表します。もし持っていればSome(&Brick)、持っていなければNoneが入ります。
maybe_brick.is_some()がtrueであれば、衝突対象はブロックであるため、そのエンティティをcommands.entity(...).despawn()で削除しています。
これで、ボールがブロックに衝突した際にブロックが消えるようになりました。

cargo runで確認してみましょう。ボールがブロックに衝突した際にブロックが消えれば成功です。

despawn

これで、最低限のブロック崩しが完成しました!お疲れ様でした!

おわりに

私はECSのEの字も知らない状態で公式のexamplesのコードを読み始めたのですが、要素ごとに切り離し、順序立てて理解していくことで、なんとなくECSの考え方が掴めたような気がしています。

公式のbreakout.rsには、今回紹介した内容の他に、

  • スコア表示
  • 効果音の再生

などの要素が含まれています。
記事を読み終えられた皆さんは、もう公式のbreakout.rsを直接読んで理解できるようになっていると思いますので、ぜひ挑戦してみてください。

公式のbreakout.rsのコードはコチラ。
https://github.com/bevyengine/bevy/blob/main/examples/games/breakout.rs

ここまで読んでいただき、ありがとうございました。

参考

脚注
  1. ちなみにbreakoutという名前は、1976年にアタリが発売した世界初のブロック崩しゲームがBreakoutという名前であったことに由来しています
    https://ja.wikipedia.org/wiki/アタリブレイクアウト_2600 ↩︎

  2. 画像出典: <a href="https://jp.freepik.com/free-vector/linear-retro-gradient-background_13405371.htm#fromView=search&page=1&position=1&uuid=9b896c89-71bc-4b2a-b498-9e9d9cf8ca24&query=SF レトロ 背景">著作者:freepik</a> ↩︎

  3. ResMut<T>は、リソースを可変(変更可能)な状態で借用するための型です。https://docs.rs/bevy/latest/bevy/ecs/system/struct.ResMut.html ↩︎

  4. 初めて見たとき、aabbccdd...と続くものだと思った人は私だけではありませんよね? ↩︎

  5. 参考: https://qiita.com/take4s5i/items/c890fa66db3f71f41ce7 ↩︎

ambr Tech Blog

Discussion