Bevyでスネークゲームを作成する

bevy.org

Rust製のゲームエンジンです。Rustによるゲーム開発にて完全にフルスクラッチでのゲーム開発以外での現実的な選択肢です。1

実績には以下のゲームがあります。実際にSteamで発売されています。

store.steampowered.com https://www.reddit.com/r/rust/comments/1frfc6q/the_release_of_tiny_glade_a_game_built_in_rust/

store.steampowered.com https://www.reddit.com/r/gamedev/comments/1i6cqzn/rust_language_game_dev/

Tiny Glade』はレビュー数も多くBevyの実績としてもかなりものと思われます。

2Dと3Dの両方を扱えます。UnityUnrealEngineRPG Developer Bakinツクールシリーズと異なりGUIはありません。完全にコードベースです。ただエンジンとして持っている機能は他のエンジンに引けを取らないため開発できないゲームは存在しないと思います。※ とか書きましたが日本語入力には最新版でもめっぽう弱いため日本語を入力させる必要のあるゲームは向いてないです…

Bevyを勉強するために以下のチュートリアルを参考にスネークゲームを製作しました。

mbuffett.com

ただ情報が古いためそのままでは動かない箇所がそこそこかなりあるためここにメモ書きを残しておきます。ちなみにスネークゲームとはなんぞや?と言う人は以下のWikipediaの記事を参照してください。

ja.wikipedia.org

目次

実装されるスネークゲームの動作例

このフレームにフォーカスが当たっている状態でキーボードの↑←↓→キーを押してみてください。スネークゲームをプレイできます。

スマートフォンは非対応です

2Dカメラのセットアップ

まずはカメラのセットアップからチュートリアルは始まります。

fn main() {
    App::new().add_plugins(DefaultPlugins).run();
}
 fn setup_camera(mut commands: Commands) {
    commands.spawn_bundle(OrthographicCameraBundle::new_2d());
 }
App::new()
    .add_startup_system(setup_camera) // <--
    .add_plugins(DefaultPlugins)
    .run();

記事にあるカメラをセットアップする箇所のコードです。BevyECSEntity Component System)を採用しています。すべてはコンポーネントでありシステムに追加することで動作します。上記コードは2Dカメラのセットアップを行うコードです。カメラをセットアップすることでカメラを通してウィンドウに描画された内容を見ることができます。

ただしセットアップの方法に最新のBevyで破壊的変更が入っているためbevy = "0.16.0"では動かないです。

まずは全体を以下のように修正する必要があります。

use bevy::{prelude::*, render::camera::SubCameraView};
use once_cell::sync::Lazy;

const DISPLAY_FULL_SIZE: Lazy<UVec2> = Lazy::new(|| UVec2::new(1280, 720));
const DISPLAY_SIZE: Lazy<UVec2> = Lazy::new(|| UVec2::new(1280, 720));

fn setup_camera(mut commands: Commands) {
    let bundle = (
        Camera2d,
        Camera {
            sub_camera_view: Some(SubCameraView {
                full_size: *DISPLAY_FULL_SIZE,
                offset: Vec2::new(0.0, 0.0),
                size: *DISPLAY_SIZE,
            }),
        order: 1,
        ..default()
        }
    );
 
    commands.spawn(bundle);
}

fn main() {
    App::new()
         .add_plugins(DefaultPlugins)
         .add_systems(Startup, setup_camera)
         .run()
         ;
}

DISPLAY_FULL_SIZEDISPLAY_SIZELazyを使った固定値みたいなものでチュートリアルに記載はありません。私の好みです。適宜読み替えてください。
UVec::newconst fnではないため定数的に使いたい場合はLazyのような遅延初期化が必要になります。

ヘビの頭の色を定義する

const SNAKE_HEAD_COLOR: Color = Color::rgb(0.7, 0.7, 0.7);

ここも動かないです。以下のように修正する必要があります。

const SNAKE_HEAD_COLOR: Srgba = Srgba::rgb(0.7, 0.7, 0.7);

ヘビの頭のコンポーネントを定義

#[derive(Component)]
struct SnakeHead;

ここはそのままで大丈夫です。

ヘビの頭を表示する

fn spawn_snake(mut commands: Commands) {
    commands
        .spawn_bundle(SpriteBundle {
            sprite: Sprite {
                color: SNAKE_HEAD_COLOR,
                ..default()
            },
            transform: Transform {
                scale: Vec3::new(10.0, 10.0, 10.0),
                ..default()
            },
            ..default()
        })
        .insert(SnakeHead);
}

spawn_bundlebevy = "0.16.0"では廃止されています。以下のように修正します。

fn spawn_snake(mut commands: Commands) {
    let bundle = (
        Sprite::from_color(SNAKE_HEAD_COLOR, Vec2::new(1.0, 1.0)),
        Transform {
            translation: Vec3::new(0.0, 0.0, 0.0),
            scale: Vec3::new(10.0, 10.0, 0.0),
            ..default()
        },
        SnakeHead
    );

    commands.spawn(bundle);
}
fn main() {
    App::new()
         .add_plugins(DefaultPlugins)
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake) // ← Starup に追加
         .run()
         ;
}

ヘビの頭のスプライトを描画する

cargo runでビルドして実行するとキャプチャのように正方形のスプライトが描画されているはずです。

ECSに詳しくなくても何となくフレームワークの仕組みが理解できます。つまるところderiveマクロで構造体をBevyコンポーネントとして定義して、spawnでゲームシステムに渡せばでシステム(App)で扱えるようになるということでしょう。

タプル(()の構文)で複数のコンポーネントCommandsに渡すことでそれらを1つの塊としてシステムに渡しています。これをBevyではBundleと呼んでいます。 2

bevy-cheatbook.github.io

上記で言えば、 自前で定義したSnakeHeadコンポーネントに組み込みコンポーネントであるSpriteTransformにて描画、位置とサイズ情報機能を持たせて正方形のスプライトとしてシステムに描画するようシステムに渡しています。これらはすべてコンポーネントです。

ここを理解すると最初の2Dカメラのセットアップも何をしているのか何となく分かるようになります。ちなみにBevyの2Dカメラのワールド座標系は中心が(0,0)で右が+x、上が+yになります。sub_camera_viewoffset: Vec2::new(0.0, 0.0)で カメラをセットアップしたためスプライトは画面の中心に描画されているはずです。

SnakeHeadtranslationをそのままにsub_camera_viewoffsetx500にすればスプライトは左にずれるはずです。sub_camera_viewoffsetはそのままでSnakeHeadtranslationx500にしたらスプライトは右にずれます。

ヘビの頭を動かす

スネークゲームでは常にヘビの頭が動いている必要があります。まずはどうすれば動かせるのかをチュートリアルにて以下のコードで解説しています。

fn snake_movement(mut head_positions: Query<(&SnakeHead, &mut Transform)>) {
    for (_head, mut transform) in head_positions.iter_mut() {
        transform.translation.y += 2.0;
    }
}

ヘビの頭を動かすための関数を定義します。これはチュートリアルそのままで大丈夫です。関数は、BevyQuery構造体の変数を引数として受け取ります。ここには何が入ってくるのかと言うとQueryの条件に合致するコンポーネントが入ってきます。Queryはタプルのジェネリクスで二つの型(コンポーネント)を持ちます。自前で定義したSnakeHeadコンポーネントと組み込みTransformコンポーネントです。タプルの第1引数に指定する型が欲しいコンポーネントで第2引数以降がコンポーネントの条件です。、Transformコンポーネントを持つSnakeHeadコンポーネントを引っ張ってきて欲しいということになります。

bevy-cheatbook.github.io

引っ張ってきたコンポーネントy座標に数値をプラスすることで上方向へ移動させます。

チュートリアルだと以下のように定義したメソッドをシステムに加えています。

fn main() {
    App::new()
         .add_plugins(DefaultPlugins)
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_system(snake_movement) // ←
         .run()
         ;
}

これだと例によって動かないので以下のように修正してください。

fn main() {
    App::new()
         .add_plugins(DefaultPlugins)
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement)
         .run()
         ;
}

Bevyではシステムに登録された関数はすべてスケジューリングによって呼び出されます。ここまでコードを読めば仕組みはともかく何となく意味は通るかと思います。add_systemsの第1引数に登録した関数を実行するタイミングを指定するスケジュールの型を指定、第2引数に登録したい関数です。

スケジュールにどのような種別があるのかは以下が参考になります。

bevy-cheatbook.github.io

ちなみにUpdateはフレームごとに登録した関数を呼び出します。ドキュメントでは主にアニメーションの描画を担う処理はUpdateで呼び出すことが推奨されるようです。

動かし方が分かったのでsnake_movementの実装を改修します。キーボードの入力に応じて左右上下方向に移動するように修正しましょう。チュートリアルでは以下のように修正されています。

fn snake_movement(
    keyboard_input: Res<Input<KeyCode>>,
    mut head_positions: Query<&mut Transform, With<SnakeHead>>,
) {
    for mut transform in head_positions.iter_mut() {
        if keyboard_input.pressed(KeyCode::Left) {
            transform.translation.x -= 2.;
        }
        if keyboard_input.pressed(KeyCode::Right) {
            transform.translation.x += 2.;
        }
        if keyboard_input.pressed(KeyCode::Down) {
            transform.translation.y -= 2.;
        }
        if keyboard_input.pressed(KeyCode::Up) {
            transform.translation.y += 2.;
        }
    }
}

例によってビルドは通らないので以下のように修正します。

fn snake_movement(
    keyboard_input: Res<ButtonInput<KeyCode>>, // ← 
    mut head_positions: Query<&mut Transform, With<SnakeHead>>, // ←
) {
    for mut transform in head_positions.iter_mut() {
        if keyboard_input.pressed(KeyCode::ArrowLeft) { // ←
            transform.translation.x -= 2.;
        }
        if keyboard_input.pressed(KeyCode::ArrowRight) { // ←
            transform.translation.x += 2.;
        }
        if keyboard_input.pressed(KeyCode::ArrowDown) { // ←
            transform.translation.y -= 2.;
        }
        if keyboard_input.pressed(KeyCode::ArrowUp) { // ←
            transform.translation.y += 2.;
        }
    }
}

ポイントはInputButtonInputになっています。そして↑↓→←がそれぞれ、ArrowUpArrowDownArrowRightArrowLeftenumの定義が変わっています。

修正前、修正後両方で異なる部分にmut head_positions: Query<&mut Transform, With<SnakeHead>>があります。これは先ほどのQueryの説明でそのまま解釈できます。SnakeHeadコンポーネントを持つTransformコンポーネントを引っ張ってきている訳です。Withは特定のコンポーネントを持つコンポーネントのみに限定する機能です。つまりこの場合は、Transformコンポーネントの中でもSnakeHeadを持つ物だけを引っ張ってきている訳です。実際、ゲーム内にあるあらゆるコンポーネントTransformを持つことになると思いますがWithの機能により移動させたいコンポーネントのみを絞り込んで取得できます。

矢印キーで正方形を動かす

cargo runで実行して、↑↓→←`のどれかを押してください。正方形のスプライトが動きます。

余談

GodotとかUnityの経験がある人はこれってQueryで目的のBundleを取得出来れば良いのでは?と思うかもしれません。ですがドキュメント曰くBundleQueryで取得できないそうです。Bundleとはエンティティにコンポーネントをセットアップするための便宜的な機能だそうです。事実、Bundleをセットアップする関数の実装を見てもタプルでまとめたものをcommands.spawnで渡しているだけです。Godotとかだとノードをスクリプトから名前で取得できたりするのですがこの点が他のエンジンとの違い、ECSならではの部分だと思います。

bevy-cheatbook.github.io

ここでECSEであるエンティティが出てきました。エンティティとは何か?実態としては単なるIDです。spwanの処理によってこのIDコンポーネントが紐づけられます。つまり識別子と言うことになります。Bundleとはすなわち()でまとめたコンポーネントに同じエンティティ(ID)を割り振っているものと解釈できます。そのためエンティティはBevyが自動で採番しフレームワークが内部で使用するものです。もちろんゲーム開発者が特定のコンポーネントを識別する際にも使用できます。オブジェクト指向ECSの違いは以下のようなイメージになります。

OOPオブジェクト指向 ECS(Entity Component System)
主語 「もの(オブジェクト)」が自分で動く 「処理(システム)」が必要な対象に対して動作する
実体の管理 開発者はインスタンス(=実体)を直接操作 開発者はコンポーネントを付けてエンティティの「構造」を定義
処理の流れ メソッド呼び出しが主 クエリで「対象を抽出し、処理を一括適用」
データ構造 オブジェクトごとにバラバラ コンポーネントごとに一括管理
主な設計感覚 「プレイヤーという実体が動作する」 「動けるものすべてに移動処理を適用する」

ゲームをグリッドに分割する

ヘビの頭を移動させる方法は分かりました。それと入力に応じて移動する方向を切り替える方法も分かりました。

しかし現状だと、ヘビの頭はピクセル単位で動きます。これだとスネークゲームの実装は難しくなります。そこで画面を疑似的にグリッド(マス目)に分割します。画面が疑似的にグリッドになればスネークゲームを作りやすくなります。

const ARENA_WIDTH: u32 = 10;
const ARENA_HEIGHT: u32 = 10;
#[derive(Component, Clone, Copy, PartialEq, Eq)]
struct Position {
    x: i32,
    y: i32,
}

#[derive(Component)]
struct Size {
    width: f32,
    height: f32,
}

impl Size {
    pub fn square(x: f32) -> Self {
        Self {
            width: x,
            height: x,
        }
    }
}

チュートリアルに記載のままでビルドは通ります。ここはBevyは関係なくRustの構造体定義とトレイト定義ですね。Bevyで扱うためにderiveマクロでComponentが追加されている事だけ注意してください。

commands
    .spawn_bundle(SpriteBundle {
        sprite: Sprite {
            color: SNAKE_SEGMENT_COLOR,
            ..default()
        },
        ..default()
    })
    .insert(SnakeHead)
    .insert(Position { x: 3, y: 3 }) // <--
    .insert(Size::square(0.8)); // <--

ここは例によってビルドが通りません。以下のように修正しましょう。

fn spawn_snake(mut commands: Commands) {
    let bundle = (
        Sprite::from_color(SNAKE_HEAD_COLOR, Vec2::new(1.0, 1.0)),
        Transform {
            translation: Vec3::new(0.0, 0.0, 0.0),
            scale: Vec3::new(1.0, 1.0, 0.0), // ← ここも修正しているので注意
            ..default()
        },
        Position { x: 3, y: 3 }, // ←
        Size::square(1.0),      // ←
        SnakeHead
    );
    commands.spawn(bundle);
}

修正前のコードの意図としては、新たに追加した構造体(コンポーネント)をSnakeHeadコンポーネントBundleしたいということなので、spawn_snake関数内を上記のように修正すれば良いです。

続いてスプライトのサイズをスケールする関数と、表示位置をスケールされた状態に合わせて設定する関数を定義します。

fn size_scaling(windows: Res<Windows>, mut q: Query<(&Size, &mut Transform)>) {
    let window = windows.get_primary().unwrap();
    for (sprite_size, mut transform) in q.iter_mut() {
        transform.scale = Vec3::new(
            sprite_size.width / ARENA_WIDTH as f32 * window.width() as f32,
            sprite_size.height / ARENA_HEIGHT as f32 * window.height() as f32,
            1.0,
        );
    }
}
fn position_translation(windows: Res<Windows>, mut q: Query<(&Position, &mut Transform)>) {
    fn convert(pos: f32, bound_window: f32, bound_game: f32) -> f32 {
        let tile_size = bound_window / bound_game;
        pos / bound_game * bound_window - (bound_window / 2.) + (tile_size / 2.)
    }
    let window = windows.get_primary().unwrap();
    for (pos, mut transform) in q.iter_mut() {
        transform.translation = Vec3::new(
            convert(pos.x as f32, window.width() as f32, ARENA_WIDTH as f32),
            convert(pos.y as f32, window.height() as f32, ARENA_HEIGHT as f32),
            0.0,
        );
    }
}

どちらも例によってビルドは通らないです。以下のように修正します。

use bevy::{prelude::*, window::PrimaryWindow, render::camera::SubCameraView};

まずuseを上記のように修正して、PrimaryWindowを使えるようにします。

fn size_scaling(mut windows: Query<&mut Window, With<PrimaryWindow>>, mut q: Query<(&Size, &mut Transform)>) {
    let window = windows.single_mut().unwrap();
    for (sprite_size, mut transform) in q.iter_mut() {
        transform.scale = Vec3::new(
            sprite_size.width / ARENA_WIDTH as f32 * window.width() as f32,
            sprite_size.height / ARENA_HEIGHT as f32 * window.height() as f32,
            1.0,
        );
    }
}
fn position_translation(mut windows: Query<&mut Window, With<PrimaryWindow>>, mut q: Query<(&Position, &mut Transform)>) {
    let window = windows.single_mut().unwrap();

    fn convert(pos: f32, bound_window: f32, bound_game: f32) -> f32 {
        let tile_size = bound_window / bound_game;
        pos / bound_game * bound_window - (bound_window / 2.) + (tile_size / 2.)
    }

    for (pos, mut transform) in q.iter_mut() {
        transform.translation = Vec3::new(
            convert(pos.x as f32, window.width() as f32, ARENA_WIDTH as f32),
            convert(pos.y as f32, window.height() as f32, ARENA_HEIGHT as f32),
            0.0,
        );
    }
}

Res<Windows>ではウィンドウを取得できなくなっています。どうマイグレーションすればよいのかかなり探したのですが以下にありました。

docs.rs

Queryによる取得になっています。またコンポーネントを取り出す際はsingle_mut().unwrap()を使いました。これはPrimaryWindowはドキュメント曰く、1コンポーネントつにつき1エンティティになるらしいからです。存在するコンポーネントが絶対に1つであることが分かっている際はsingle()single_mut()を使用できます。※ ゲーム終了時に panic が出ます。気持ち悪い場合は補足で修正しています。参考にしてください。

docs.rs

続いて、定義した関数をシステムに追加します。チュートリアルでは以下のようになっています。

.add_system(snake_movement)
.add_system_set_to_stage(
    CoreStage::PostUpdate,
    SystemSet::new()
        .with_system(position_translation)
        .with_system(size_scaling),
)
.add_plugins(DefaultPlugins)
.run();

例によってビルドは通らないのですが、まずは実装の意図を知る必要があります。PostUpdateposition_translationsize_scaling関数を登録しています。定義されたsize_scaling関数は何を行うのが目的か実装を読みましょう。描画しているスプライトの幅を例にしますが、幅をARENA_WIDTHwindow.width()でかけたもので割ったもので新たにTransformコンポーネントscaleに対してVec3::newに設定し直しています。つまりARENA_WIDTH10と定義してウィンドウの幅は1280に定義、スプライトの幅は1です。つまり1 / 10 * 1280128倍にスプライトの幅がスケールされます。つまり幅が10個のマス目を想定したサイズになるはずです。

続いて、position_translation関数の実装を読みます。関数の中に関数が定義されていますがこれはローカル関数と呼ばれposition_translation内のみで有効な特定の処理をまとめた関数です。convert関数の第1引数のposにはスプライトの現在位置、bound_windowにはウィンドウのサイズ、bound_gameにはARENA_*で定義した数(マス目の数)が渡されます。まずはlet tile_size = bound_window / bound_game;で1マスのサイズを計算しています。ウィンドウのサイズは1280ARENA_WIDTH10なので1マスは128です。

ウィンドウの幅が1280の場合、Bevyはワールド座標系では中心が(0,0)ですので左端は-640、右端は640の座標になるはずです。Positionコンポーネントxyは10x10のグリッドに分割した際の座標になります。またBevyではSpriteコンポーネントは何も指定しなければオリジン(原点)は中心に設定されます。つまりx0を指定された場合は、ピクセル単位で指定すべき座標は-640 + (128 / 2)-576ということになります。

ここで文章で記載したロジックを計算式にすると、pos / bound_game * bound_window - (bound_window / 2.) + (tile_size / 2.)になることに気づいたでしょうか?pos / bound_game * bound_windowの部分を見てみましょう。x0が入れば0です。- (bound_window / 2.)の部分を見ます。ウィンドウのサイズが1280なので-640になりますね。残りの(tile_size / 2.)128 / 264です。最終的には-640 + (128 / 2)に収束します。Rustは左から順の優先度で計算されますのでもう少し分かりやすく以下のように記載しても良いと思います。

(pos / bound_game * bound_window) - (bound_window / 2.) + (tile_size / 2.)

以上から、position_translationは描画しているヘビの頭の位置を疑似的に分割したグリッドの座標指定でピクセル単位での位置を計算しています。size_scalingは疑似的なグリッドのサイズにヘビの頭をスケールしてることが分かります。つまりシステムに追加した際に、ヘビの頭には常にこの二つの関数による変換が効いて欲しい訳です。そこで以下のように修正します。

fn main() {
    App::new()
         .add_plugins(DefaultPlugins)
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement)
         .add_systems(PostUpdate, position_translation) // ←
         .add_systems(PostUpdate, size_scaling)  // ←
         .run()
         ;
}

PostUpdateUpdateより前に登録した関数を呼び出します。ヘビの頭はStartupで初期生成を行い、Updateで位置を更新します。スネークゲームでは随時、ヘビの頭やしっぽ、フードが追加されることになりますがBebyではUpdateコンポーネントを追加しても処理が過ぎたら途中で追加されたコンポーネントにはスケール処理が実施されません。そこでPostUpdateで変換処理を登録することで現在存在するすべてのコンポーネントに処理がかかるようにしているのです。

ここまで実装出来たら試しに以下の箇所のPositionの初期化時をx: 0, y:0にしてcargo runしてみてください。

fn spawn_snake(mut commands: Commands) {
    let bundle = (
        Sprite::from_color(SNAKE_HEAD_COLOR, Vec2::new(1.0, 1.0)),
        Transform {
            translation: Vec3::new(0.0, 0.0, 0.0),
            scale: Vec3::new(10.0, 10.0, 0.0),
            ..default()
        },
        Position { x: 0, y: 0 }, // ←
        Size::square(1.0),
        SnakeHead
    );
    commands.spawn(bundle);
}

1マスのヘビの頭

画面を10x10に分割した場合のヘビの頭が、x: 0, y: 0の位置に描画されているのが分かります。ここまで実装出来たらsnake_movement関数を修正しましょう。

fn snake_movement(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut head_positions: Query<&mut Position, With<SnakeHead>>,
) {
    for mut pos in head_positions.iter_mut() {
        if keyboard_input.pressed(KeyCode::ArrowLeft) {
            pos.x -= 1;
        }
        if keyboard_input.pressed(KeyCode::ArrowRight) {
            pos.x += 1;
        }
        if keyboard_input.pressed(KeyCode::ArrowDown) {
            pos.y -= 1;
        }
        if keyboard_input.pressed(KeyCode::ArrowUp) {
            pos.y += 1;
        }
    }
}

マス目ごとに動くヘビの頭

ちょっと素早いですがグリッドのマス目ごとにヘビの頭が動いています。まだ壁の判定がないためキーを押し過ぎると消えてしまいます。スネークゲームでは壁に当たった際はゲームオーバーとなるため壁との当たり判定は最期に実装します。

 Size::square(1.0), 

こちらを

 Size::square(0.8), 

に修正しておきましょう。

ヘビの頭に余白

これでマス目一杯でなく少し余白ができます。

アプリケーションに名前をつけウィンドウサイズを変更する

ここまで実装出来たら、ヘビゲームが何となく実装できそうですよね。チュートリアルではこのタイミングでアプリに名前つけウィンドウのサイズを変更しています。

    App::new()
        .insert_resource(WindowDescriptor { // <--
            title: "Snake!".to_string(), // <--
            width: 500.0,                 // <--
            height: 500.0,                // <--
            ..default()         // <--
        })
        .add_startup_system(setup_camera)

チュートリアルではこうなっていますが、ビルドは通らないので修正します。

const WINDOW_WIDTH: f32 = 500.;
const WINDOW_HEIGHT: f32 = 500.;
const DISPLAY_FULL_SIZE: Lazy<UVec2> = Lazy::new(|| UVec2::new(WINDOW_WIDTH as u32, WINDOW_HEIGHT as u32));
const DISPLAY_SIZE: Lazy<UVec2> = Lazy::new(|| UVec2::new(WINDOW_WIDTH as u32, WINDOW_HEIGHT as u32));

まず、ここはカメラのサイズを規定していました。ウィンドウのサイズを変えるのでここをウィンドウサイズ用の定数値を定義して、一緒にカメラのサイズもに修正しておきます。

最新のBebyでのウィンドウの設定方法は以下が参考になります。

bevy.org

ここは細かい話は抜きにウィンドウ(アプリ)の設定はこのように実装すると覚えればよいと思います。

ついでなのでヘビの頭の初期位置を真ん中付近にしておきましょう。(※ 10マスは偶数なので完全に真ん中にはならないです。)

Position { x: 4, y: 5 },
.insert_resource(ClearColor(Color::rgb(0.04, 0.04, 0.04)))

チュートリアルにあるウィンドウの背景色を初期化するコードもそのままだとビルドできません。以下のように修正してApp::newに追加してください。

.insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))

App::new部分はこの時点で以下のようになります。

fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement)
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

正方形の画面に修正

cargo runで実行してみてください。正方形のウィンドウになりヘビの頭も正方形になりました。

ヘビのフードを実装する

ヘビがフードに接触すると頭が増えていきます。ゲームによってはりんごだったりします。(MiSideミニゲームではりんごでしたね。)

そのフードをステージ上にランダムで出現する処理を実装します。

const FOOD_COLOR: Color = Color::rgb(1.0, 0.0, 1.0); // <--
use rand::prelude::random;
use bevy::core::FixedTimestep;
#[derive(Component)]
struct Food;
fn food_spawner(mut commands: Commands) {
    commands
        .spawn_bundle(SpriteBundle {
            sprite: Sprite {
                color: FOOD_COLOR,
                ..default()
            },
            ..default()
        })
        .insert(Food)
        .insert(Position {
            x: (random::<f32>() * ARENA_WIDTH as f32) as i32,
            y: (random::<f32>() * ARENA_HEIGHT as f32) as i32,
        })
        .insert(Size::square(0.8));
}
.add_system_set(
    SystemSet::new()
        .with_run_criteria(FixedTimestep::step(1.0))
        .with_system(food_spawner),
)

チュートリアルで提示されているコードですがすべてビルドは通らないので以下のように修正ます。

use std::time::Duration; // ←
use bevy::{prelude::*, window::PrimaryWindow, render::camera::SubCameraView, time::common_conditions::on_timer}; // ←
use once_cell::sync::Lazy;
use rand::Rng; // ←

まずuseでは上記に示すクレートを追記してください。randCargo.tomlにパッケージを追加する必要があります。cargo add randで追加できます。

const FOOD_COLOR: Srgba = Srgba::rgb(1.0, 0.0, 1.0); // ←

フードの色は定数で定義しておきます。

#[derive(Component)]
struct Food;

ここは修正は必要ないです。

fn spawn_food(mut commands: Commands) {
    let arena_width = ARENA_WIDTH as i32; // ←
    let arena_height = ARENA_HEIGHT as i32; // ←
    let bundle = (
        Sprite::from_color(FOOD_COLOR, Vec2::new(1.0, 1.0)), // ←
        Transform {
            translation: Vec3::new(0.0, 0.0, 0.0),
            scale: Vec3::new(1.0, 1.0, 0.0),
            ..default()
        },
        Position {
            x: rand::rng().random_range(0..arena_width), // ←
            y: rand::rng().random_range(0..arena_height), // ←
        },
        Size::square(0.8),
        Food
    );

    commands.spawn(bundle);
}

基本的にはspawn_snakeと同じですが、色が違うのと、Positionでランダムに座標の値が入るようにしています。実際にはヘビの頭と位置が被らないようにする必要もありますがそこは省いています。

fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement)
         .add_systems(Update, spawn_food.run_if(on_timer(Duration::from_secs(1)))) // ←
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

定義した関数をUpdateでシステムに追加しますが。上記方法にて1秒ごとに実行されるようにすることが出来ます。Bevyon_timerについてより詳細に知るには以下のドキュメントを参照してください。

docs.rs

ランダムな位置にフードが出現する

cargo runでこのように動作するはずです。

動きをスネークゲームにする

現時点の実装だとヘビの頭はキーの入力がなければ動きません。スネークゲームをプレイしたことがある方は分かると思いますが本来、スネークゲームのヘビは基本決まった方向に動き続けプレイヤーの入力した方向に進む方向が切り替わる動き方をします。

ということでそのように動くようにコードを改修していきます。チュートリアルのコードは以下の通りです。

#[derive(PartialEq, Copy, Clone)]
enum Direction {
    Left,
    Up,
    Right,
    Down,
}

impl Direction {
    fn opposite(self) -> Self {
        match self {
            Self::Left => Self::Right,
            Self::Right => Self::Left,
            Self::Up => Self::Down,
            Self::Down => Self::Up,
        }
    }
}

これはそのままビルドできます。

struct SnakeHead {
    direction: Direction,
}

SnakeHeadに定義した構造体をメンバーとして追加しましょう。これもそのままビルドできます。

.insert(SnakeHead {
    direction: Direction::Up,
})

ここは初期追加時の方向を設定しています。今までの流れを理解していれば以下のように修正すればよいでしょう。

fn spawn_snake(mut commands: Commands) {
    let bundle = (
        Sprite::from_color(SNAKE_HEAD_COLOR, Vec2::new(1.0, 1.0)),
        Transform {
            translation: Vec3::new(0.0, 0.0, 0.0),
            scale: Vec3::new(1.0, 1.0, 0.0),
            ..default()
        },
        Position { x: 4, y: 5 },
        Size::square(0.8),
        SnakeHead {
            direction: Direction::Up // ←
        }
    );
    commands.spawn(bundle);
}

続いて以下のチュートリアルのコードです。

.add_system_set(
    SystemSet::new()
        .with_run_criteria(FixedTimestep::step(0.150))
        .with_system(snake_movement),
)

このコードはヘビの動きを規定した関数を150msごとに呼び出すように修正されています。つまり以下のように修正すればよいことが分かります。ステップ時間は適宜調整してください。

fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement.run_if(on_timer(Duration::from_millis(1000)))) //
         .add_systems(Update, spawn_food.run_if(on_timer(Duration::from_secs(1))))
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

続いてのチュートリアルコードは以下になります。

.add_system(snake_movement_input.before(snake_movement))
fn snake_movement_input(keyboard_input: Res<Input<KeyCode>>, mut heads: Query<&mut SnakeHead>) {
    if let Some(mut head) = heads.iter_mut().next() {
        let dir: Direction = if keyboard_input.pressed(KeyCode::Left) {
            Direction::Left
        } else if keyboard_input.pressed(KeyCode::Down) {
            Direction::Down
        } else if keyboard_input.pressed(KeyCode::Up) {
            Direction::Up
        } else if keyboard_input.pressed(KeyCode::Right) {
            Direction::Right
        } else {
            head.direction
        };
        if dir != head.direction.opposite() {
            head.direction = dir;
        }
    }
}

fn snake_movement(mut heads: Query<(&mut Position, &SnakeHead)>) {
    if let Some((mut head_pos, head)) = heads.iter_mut().next() {
        match &head.direction {
            Direction::Left => {
                head_pos.x -= 1;
            }
            Direction::Right => {
                head_pos.x += 1;
            }
            Direction::Up => {
                head_pos.y += 1;
            }
            Direction::Down => {
                head_pos.y -= 1;
            }
        };
    }
}

入力を受け取る関数が別で定義され、ヘビの頭を動かす関数に修正が入っています。これらは以下のように修正されます。

fn snake_movement_input(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut heads: Query<&mut SnakeHead>
) {
    if let Some(mut head) = heads.iter_mut().next() {
        let direction: Direction = if keyboard_input.pressed(KeyCode::ArrowLeft) {
            Direction::Left
        } else if keyboard_input.pressed(KeyCode::ArrowDown) {
            Direction::Down
        } else if keyboard_input.pressed(KeyCode::ArrowUp) {
            Direction::Up
        } else if keyboard_input.pressed(KeyCode::ArrowRight) {
            Direction::Right
        } else {
            head.direction
        };

        // 入力された方向がヘビの進行方向の反対方向でなければ方向転換
        if direction != head.direction.opposite() {
            head.direction = direction;
        }
    }
}
fn snake_movement(mut heads: Query<(&mut Position, &mut SnakeHead)>) {
    if let Some((mut head_pos, head)) = heads.iter_mut().next() {
        match &head.direction {
            Direction::Left => {
                head_pos.x -= 1;
            }
            Direction::Right => {
                head_pos.x += 1;
            }
            Direction::Up => {
                head_pos.y += 1;
            }
            Direction::Down => {
                head_pos.y -= 1;
            }
        };
    }
}
fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement.run_if(on_timer(Duration::from_millis(1000))))
         .add_systems(Update, snake_movement_input.before(snake_movement)) // ←
         .add_systems(Update, spawn_food.run_if(on_timer(Duration::from_secs(1))))
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

ポイントは、snake_movement_input.before(snake_movement)でこれでヘビの頭を動かす処理の前に入力処理が実行されるようにできます。※ beforeがあるならafterもあります。

入力処理をsnake_movementから切り出し、受け取った入力に応じてヘビの方向をセット、snake_movementコンポーネントのメンバーであるdirectionに設定された値を読み取りその方向に進行する処理になっています。

それとsnake_movementQueryジェネリクス(&mut Position, &mut SnakeHead)のタプルに修正されていることに注意してください。TransformSnakeHeaddirecitonを両方読み取る必要があるからです。

自動で進み入力の応じて方向を変える

動きがスネークゲームになりました!

しっぽを追加する

ここまでくればしっぽを追加する処理を作ります。まずはしっぼを表示する処理を実装します。チュートリアルで提示されているコードは以下の通りです。

const SNAKE_SEGMENT_COLOR: Color = Color::rgb(0.3, 0.3, 0.3);
#[derive(Component)]
struct SnakeSegment;
#[derive(Default)]
struct SnakeSegments(Vec<Entity>);
.insert_resource(SnakeSegments::default()) // <--
fn spawn_segment(mut commands: Commands, position: Position) -> Entity {
    commands
        .spawn_bundle(SpriteBundle {
            sprite: Sprite {
                color: SNAKE_SEGMENT_COLOR,
                ..default()
            },
            ..default()
        })
        .insert(SnakeSegment)
        .insert(position)
        .insert(Size::square(0.65))
        .id()
}
fn spawn_snake(mut commands: Commands, mut segments: ResMut<SnakeSegments>) {
    *segments = SnakeSegments(vec![
        commands
            .spawn_bundle(SpriteBundle {
                sprite: Sprite {
                    color: SNAKE_HEAD_COLOR,
                    ..default()
                },
                ..default()
            })
            .insert(SnakeHead {
                direction: Direction::Up,
            })
            .insert(SnakeSegment)
            .insert(Position { x: 3, y: 3 })
            .insert(Size::square(0.8))
            .id(),
        spawn_segment(commands, Position { x: 3, y: 2 }),
    ]);
}

例によってビルドできる箇所、そうでない箇所とあるので以下のように修正します。

const WINDOW_WIDTH: f32 = 500.;
const WINDOW_HEIGHT: f32 = 500.;
const DISPLAY_FULL_SIZE: Lazy<UVec2> = Lazy::new(|| UVec2::new(WINDOW_WIDTH as u32, WINDOW_HEIGHT as u32));
const DISPLAY_SIZE: Lazy<UVec2> = Lazy::new(|| UVec2::new(WINDOW_WIDTH as u32, WINDOW_HEIGHT as u32));
const SNAKE_HEAD_COLOR: Srgba = Srgba::rgb(0.7, 0.7, 0.7);
const FOOD_COLOR: Srgba = Srgba::rgb(1.0, 0.0, 1.0);
const SNAKE_SEGMENT_COLOR:  Srgba = Srgba::rgb(0.3, 0.3, 0.3); // ←
const ARENA_WIDTH: u32 = 10;
const ARENA_HEIGHT: u32 = 10;
// へびのしっぽ
#[derive(Component)]
struct SnakeSegment;

// へびのしっぽ(リスト)
#[derive(Default, Resource)]
struct SnakeSegments(Vec<Entity>);
// ヘビのしっぽを出現させる
fn spawn_segment(mut commands: Commands, position: Position) -> Entity {
    let bundle = Sprite::from_color(SNAKE_SEGMENT_COLOR, Vec2::new(1.0, 1.0));
    commands.spawn(bundle)
            .insert(SnakeSegment)
            .insert(position)
            .insert(Size::square(0.65))
            .id()
}
fn spawn_snake(mut commands: Commands, mut segments: ResMut<SnakeSegments>) { // ←
    let bundle = (
        Sprite::from_color(SNAKE_HEAD_COLOR, Vec2::new(1.0, 1.0)),
        Transform {
            translation: Vec3::new(0.0, 0.0, 0.0),
            scale: Vec3::new(1.0, 1.0, 0.0),
            ..default()
        },
        Position { x: 4, y: 5 },
        Size::square(0.8),
        SnakeHead {
            direction: Direction::Up
        }
    );

    // 2つのエンティティをリソースに追加したSnakeSegmentsのVec<Entity>に追加
    * segments = SnakeSegments(vec![
        commands.spawn(bundle).id(),
        spawn_segment(commands, Position { x: 3, y: 2 }),
    ])
}

構造体の定義部分は修正の必要なくビルドできます。色の修正は今までと同じです。spawn_segmentという関数を新たに定義しています。ここまで読めば何を行っているかは簡単でしょう。今までと少し違うのはinsertを使用しています。insertを使えばpositionのみ引数から受け取ったものをbundleに対して追加することが出来ます。次のセクションでこのpositionが頭に追従するための位置が入ることが予測できます。

もう1つ特徴的なことにid()というメソッドを呼び出しています。こうすることでコンポーネントEntityを取得することができるようになります。なぜid()を呼び出す必要があるかは以下で解説があります。

docs.rs

エンティティに関して、少しだけ余談で解説しましたがECSというフレームワークではコンポーネントが実体化されると同時にエンティティ(id)を付与するわけではないのです。そのため処理のタイミングによっては識別子がない状態がありえます。ですがゲームによっては特定のタイミングで特定のコンポーネントの識別子が必要になることがあります。そのためid()を実行することで識別子を予約することができるのです。

続いてspawn_snakeが修正されています。ここで新しい要素が出てきました。ResourceEntityです。厳密にはResourceに関してはウィンドウの背景色を変える際に使いましたが、ここでの使い方はヘビゲームを構築するために使用します。Resourceで定義された構造体はinsert_resourceでシステムに追加することにより、add_systemで登録された関数が引数にてその参照を受け取ることができるようになります。Entityの使い方に関しては次のセクションで解説します。

処理としてはSnakeSegments構造体にヘビの頭としっぽを追加しています。

Resourceについて以下にドキュメントがあります。

bevy-cheatbook.github.io

へびのしっぽが表示される

キャプチャではヘビの頭が上に行って消えてしまい見えないですが、しっぽが表示されていればОKです。

しっぽの動きを追従するようにする

しっぽの表示も指定した位置に表示できるようになりました。動きを追従させるようにするにはヘビを動かすタイミングで追従する位置にしっぽの位置情報を変えてやればいいはずです。チュートリアルのコードは以下の通りです。

fn snake_movement(
    segments: ResMut<SnakeSegments>,
    mut heads: Query<(Entity, &SnakeHead)>,
    mut positions: Query<&mut Position>,
) {
    if let Some((head_entity, head)) = heads.iter_mut().next() {
        let segment_positions = segments
            .iter()
            .map(|e| *positions.get_mut(*e).unwrap())
            .collect::<Vec<Position>>();
        let mut head_pos = positions.get_mut(head_entity).unwrap();
        match &head.direction {
            Direction::Left => {
                head_pos.x -= 1;
            }
            Direction::Right => {
                head_pos.x += 1;
            }
            Direction::Up => {
                head_pos.y += 1;
            }
            Direction::Down => {
                head_pos.y -= 1;
            }
        };
        segment_positions
            .iter()
            .zip(segments.iter().skip(1))
            .for_each(|(pos, segment)| {
                *positions.get_mut(*segment).unwrap() = *pos;
            });
    }
}

これを以下のように修正します。

fn snake_movement(
    segments: ResMut<SnakeSegments>,
    mut heads: Query<(Entity, &SnakeHead)>,
    mut positions: Query<&mut Position>
){
    if let Some((head_entity, head)) = heads.iter_mut().next() {
         let segment_positions = segments.0.iter() // ←
                                         .map(|e| *positions.get_mut(*e).unwrap())
                                         .collect::<Vec<Position>>();
        let mut head_pos = positions.get_mut(head_entity).unwrap();
        match &head.direction {
            Direction::Left => {
                head_pos.x -= 1;
            }
            Direction::Right => {
                head_pos.x += 1;
            }
            Direction::Up => {
                head_pos.y += 1;
            }
            Direction::Down => {
                head_pos.y -= 1;
            }
        };

        segment_positions.iter().zip(segments.0.iter().skip(1)).for_each(|(pos, segment)| { // ←
            *positions.get_mut(*segment).unwrap() = *pos;
        });
    }
}

基本そのままコピペで良いのですが、コメントの位置は修正が必要です。segments.0.iter()の所ですね。segmentsに渡ってくるのはSnakeSegmentsの構造体でiter()を呼び出せるVec<Entity>はタプルの1番目なのでインデックス0番目を指定する必要があります。

ここでついにQueryにてEntityを取り出しています。前のセクションでEntityとはidであると述べました。つまりheadsiter_mut()で取り出しているのは、SnakeHeadコンポーネントとそれに対応するid値であることが分かります。

ポイント①はまず、.map(|e| *positions.get_mut(*e).unwrap()).collect::<Vec<Position>>();の部分です。これはRustでよく使う典型的な処理でイテレーターで取り出した値をVec<Position>にまとめています。順を追って読み解きましょう。まず前のセクションで実装した内容からsegumentsには以下の形式で値が入っているはずです。

* segments = SnakeSegments(vec![
    commands.spawn(bundle).id(),
    spawn_segment(commands, Position { x: 3, y: 2 }),
])

vec[0]にはヘビの頭のidvec[1]にはしっぽのidが入っているはずです。positionsにはQuery<&mut Position>によって得られたPositionコンポーネントの一覧が入っているはずです。ここがECSもといBevyの特徴ともいえるのですがQueryによって返された一覧のインデックス番号とEntityidは対応しています。つまり*positions.get_mut(*e).unwrap()iter(),map()segumentsに入っていたid値を一つずつ取り出し、get_mut(*e)で該当するPositionsコンポーネントを取り出していることが分かります。そしてその値を.collect()を使いVec<Position>に変換してsegment_positionsに格納しています。つまり、segment_positionsにはVec<Position>のヘビの頭と、しっぽの位置情報が入っていることになります。

頭の向きで進む方向に+1の処理はEntityを使いhead_posを取り出している以外はそのままです。作ったsegment_positionsは以下で使用されます。

 segment_positions.iter().zip(segments.0.iter().skip(1)).for_each(|(pos, segment)| { 
    *positions.get_mut(*segment).unwrap() = *pos;
});

ここが何を行っているのかを順に追っていきます。segment_positions.iter()は作成したリストをiter()で値を取り出しています。.zip()ですがこれは以下のようなことを実現するiter()のトレイトメソッドです。

let a = [1 ,2 ,3]
let b = ['a', 'b', 'c']

a.iter() → &1, &2, &3
b.iter() → &'a', &'b', &'c'
a.iter().zip(b.iter()) → (&1, &'a), (&2, &'b), (&3, &'c)

iter()で取り出した要素同士をタプルの組み合わせに変換します。つまり、.zip(segments.0.iter().skip(1))segment_positionssegmentsのリストの要素をタプルの組み合わせにしてる事が分かります。ポイント②はsegments.0.iter().skip(1)を行っています。これは1番目のsegumentEntityもといidは除いたリストで組み合わせています。1番目はヘビの頭のidなので頭を除いたリストです。

segment_positionsには頭としっぽのPositionposition(head), position(tail),...のようなVec!で入っており、segumentsskip(1)したのでしっぽのid2,3,...のようなVec!で入っている訳です。これをzipすると(positoin(head), 2),(position(tail), 3),...のようなタプルのリストに変換したということが分かりました。そしてこのリストに対して、.for_each(|(pos, segment)|{}しているわけです。posにはPositionコンポーネントが渡され、segmentにはidが渡されるはずです。

そして*positions.get_mut(*segment).unwrap() = *pos;にて*posは頭の位置です。*positions.get_mut(*segment).unwrap()はしっぽの位置です。つまりこの式はしっぽの位置を頭の位置で上書きしています。ヘビの頭の位置はすでに&head.directionの処理部分で加算済みなため次のUpdateの処理ではヘビの頭は進んでいて、しっぽは以前の頭の位置に移動することになります。結果としてしっぽが頭に追従する動きを実現しています。

ポイント③は関数の引数にてsegments: ResMut<SnakeSegments>,mut heads: Query<(Entity, &SnakeHead)>mut positions: Query<&mut Position>でそれぞれ別にQueryコンポーネントを取り出しResourceで頭としっぽのEntity(id)の保持を活用することで頭の動きと追従の処理を同時に行っていることです。ECSならではのゲームアルゴリズムの実現です。ECSでは特定のコンポーネントに処理を加えたければidさえどこかに保持しておけばQueryから取り出すことができるということです。

しっぽの追従

このように追従したらここまでの実装はできています!

ヘビを成長させる

ここまでくればあともう少しです!次はへびを成長させるロジックの実装です。提示されるコードは以下の通りです。

fn snake_eating(
    mut commands: Commands,
    mut growth_writer: EventWriter<GrowthEvent>,
    food_positions: Query<(Entity, &Position), With<Food>>,
    head_positions: Query<&Position, With<SnakeHead>>,
) {
    for head_pos in head_positions.iter() {
        for (ent, food_pos) in food_positions.iter() {
            if food_pos == head_pos {
                commands.entity(ent).despawn();
                growth_writer.send(GrowthEvent);
            }
        }
    }
}
struct GrowthEvent;
.add_event::<GrowthEvent>()
SystemSet::new()
    .with_run_criteria(FixedTimestep::step(0.150))
    .with_system(snake_movement)
    .with_system(snake_eating.after(snake_movement))
#[derive(Default)]
struct LastTailPosition(Option<Position>);
.insert_resource(LastTailPosition::default())
fn snake_movement(
    // ...
    mut last_tail_position: ResMut<LastTailPosition>,
    // ...
*last_tail_position = LastTailPosition(Some(*segment_positions.last().unwrap()));
fn snake_growth(
    commands: Commands,
    last_tail_position: Res<LastTailPosition>,
    mut segments: ResMut<SnakeSegments>,
    mut growth_reader: EventReader<GrowthEvent>,
) {
    if growth_reader.iter().next().is_some() {
        segments.push(spawn_segment(commands, last_tail_position.0.unwrap()));
    }
}
.with_system(snake_growth.after(snake_eating))

一気にコードが投下されました。落ち着いて読み解きこれらを以下のように修正します。

#[derive(Event)]
struct GrowthEvent;
fn snake_eating(
    mut commands: Commands,
    mut growth_writer: EventWriter<GrowthEvent>,
    food_positions: Query<(Entity, &Position), With<Food>>,
    head_positions: Query<&Position, With<SnakeHead>>,
) {
    for head_pos in head_positions.iter() {
        for (ent, food_pos) in food_positions.iter() {
            if food_pos == head_pos {
                commands.entity(ent).despawn();
                growth_writer.write(GrowthEvent); // ←
            }
        }
    }
}

GrowthEventイベント構造体として定義するため#[derive(Event)]が必要です。先にsnake_eatingを読みましょう。引数で新しい部分はmut growth_writer: EventWriter<GrowthEvent>です。これはBevyのイベントを引数で受け取っています。以下にドキュメントを貼っておきます。

bevy-cheatbook.github.io

中身の処理を見ます。QuerySnakeHeadPositionFoodPostionを取得して同じ位置ならばFoodEntityを削除しています。つまり食べたということになる訳です。コンポーネントを破棄する方法もここで分かりました。

該当のFoodコンポーネントを破棄した後はgrowth_writer.write(GrowthEvent)を実行しています。ECSにおけるEventsの考え方は他のフレームワークとは少し異なります。GrowthEvent.writeするとキューに構造体の情報が詰まれるだけです。より詳しくは先を読み進める過程で解説します。

作成したイベントと関数を例によって以下のようにシステムに登録します。

fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .insert_resource(SnakeSegments::default())
         .add_event::<GrowthEvent>() // ←
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                position: WindowPosition::Centered(MonitorSelection::Primary),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement.run_if(on_timer(Duration::from_millis(1000))))
         .add_systems(Update, snake_movement_input.before(snake_movement))
         .add_systems(Update, spawn_food.run_if(on_timer(Duration::from_secs(1))))
         .add_systems(Update, snake_eating.after(snake_movement)) // ←
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

ヘビがフードを食べる

この時点でビルドして動かすとヘビの頭がフードに当たればフードが消えることが確認できます。ヘビがフードを食べたらしっぽが成長して欲しいです。次は以下のようにコードを追記します。

#[derive(Default, Resource)] // ←
struct LastTailPosition(Option<Position>);
fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .insert_resource(SnakeSegments::default())
         .insert_resource(LastTailPosition::default()) // ←
         .add_event::<GrowthEvent>() 
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                position: WindowPosition::Centered(MonitorSelection::Primary),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement.run_if(on_timer(Duration::from_millis(1000))))
         .add_systems(Update, snake_movement_input.before(snake_movement))
         .add_systems(Update, spawn_food.run_if(on_timer(Duration::from_secs(1))))
         .add_systems(Update, snake_eating.after(snake_movement))
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

LastTailPosition(Option<Position>)Resourceをシステムに追加します。そして以下の関数を修正します。

fn snake_movement(
    segments: ResMut<SnakeSegments>,
    mut last_tail_position: ResMut<LastTailPosition>, // ←
    mut heads: Query<(Entity, &SnakeHead)>,
    mut positions: Query<&mut Position>
){
    if let Some((head_entity, head)) = heads.iter_mut().next() {
         let segment_positions = segments.0.iter()
                                         .map(|e| *positions.get_mut(*e).unwrap())
                                         .collect::<Vec<Position>>();
        let mut head_pos = positions.get_mut(head_entity).unwrap();
        match &head.direction {
            Direction::Left => {
                head_pos.x -= 1;
            }
            Direction::Right => {
                head_pos.x += 1;
            }
            Direction::Up => {
                head_pos.y += 1;
            }
            Direction::Down => {
                head_pos.y -= 1;
            }
        };

        segment_positions.iter().zip(segments.0.iter().skip(1)).for_each(|(pos, segment)| {
            *positions.get_mut(*segment).unwrap() = *pos;
        });

        // ここでしっぽの末尾の位置をリソースに保持させる
        *last_tail_position = LastTailPosition(Some(*segment_positions.last().unwrap()));
    }
}

ヘビが動いた際の末尾のしっぽの位置を常にResourceで保持するようにします。ヘビを成長させる関数は以下のようにリファクトします。

fn snake_growth(
    commands: Commands,
    last_tail_position: Res<LastTailPosition>,
    mut segments: ResMut<SnakeSegments>,
    mut growth_reader: EventReader<GrowthEvent>,
) {
    if let Some(_event) = growth_reader.read().next() {
        segments.0.push(spawn_segment(commands, last_tail_position.0.unwrap()));
    }
}

システムには以下のように登録します。

fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .insert_resource(SnakeSegments::default())
         .insert_resource(LastTailPosition::default())
         .add_event::<GrowthEvent>()
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                position: WindowPosition::Centered(MonitorSelection::Primary),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement.run_if(on_timer(Duration::from_millis(1000))))
         .add_systems(Update, snake_movement_input.before(snake_movement))
         .add_systems(Update, spawn_food.run_if(on_timer(Duration::from_secs(1))))
         .add_systems(Update, snake_eating.after(snake_movement))
         .add_systems(Update, snake_growth.after(snake_eating)) // ←
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

snake_eatingが実行された後に実行するように登録します。関数の引数にてmut growth_reader: EventReader<GrowthEvent>のように定義することでキューに入ったEventsの構造体を受け取ることできます。つまり、Eventsとはキューに構造体の情報を書き込み、EventReader<T>で定義された引数を持つ関数で受け取ることができる仕組みです。Eventsの構造体には何か特定のメンバーを含めても良いですし今回のように文字通り何か発生したことを通知するだけを目的としてメンバーのない構造体でも良いです。実際、snake_growth関数ではgrowth_readerに値があればしっぽを増やす処理を実行しているだけです。

フードを食べたらしっぽが増える

ビルドして実行してください。このようにフードを食べたらしっぽが増えれば成功です!

EventsResourceと使い方が似ていますが決定的な違いはライフサイクルです。Resourceはゲーム開始から永続的かつシングルトンで存在し続けるのに対して、Eventsはキューに挿入されてから複数のシステム(関数)から取り出されるまでの間のフレーム間のみ生存します。ドキュメントにも記載がありますがゲームアルゴリズムの中でも特定の条件のみに実行したい機能の分離に適しています。

ゲームオーバーを実装する

ついにチュートリアルの最後です。スネークゲームのゲームーバー条件はヘビが壁に当たる or 自身のしっぽに当たるです。これが実装出来ればスネークゲームとしては最低限の要件を満たすことになります。ここからは今まで学んできたことで実装できます。

#[derive(Event)]
struct GameOverEvent;
fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .insert_resource(SnakeSegments::default())
         .insert_resource(LastTailPosition::default())
         .add_event::<GrowthEvent>()
         .add_event::<GameOverEvent>() // ←
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                position: WindowPosition::Centered(MonitorSelection::Primary),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement.run_if(on_timer(Duration::from_millis(1000))))
         .add_systems(Update, snake_movement_input.before(snake_movement))
         .add_systems(Update, spawn_food.run_if(on_timer(Duration::from_secs(1))))
         .add_systems(Update, snake_eating.after(snake_movement))
         .add_systems(Update, snake_growth.after(snake_eating))
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

まずはGameOverEventを定義してシステムに登録します。

//
// ヘビの頭を動かす
//
fn snake_movement(
    segments: ResMut<SnakeSegments>,
    mut last_tail_position: ResMut<LastTailPosition>,
    mut game_over_writer: EventWriter<GameOverEvent>, // ←
    mut heads: Query<(Entity, &SnakeHead)>,
    mut positions: Query<&mut Position>
){
    if let Some((head_entity, head)) = heads.iter_mut().next() {
         let segment_positions = segments.0.iter()
                                         .map(|e| *positions.get_mut(*e).unwrap())
                                         .collect::<Vec<Position>>();
        let mut head_pos = positions.get_mut(head_entity).unwrap();
        match &head.direction {
            Direction::Left => {
                head_pos.x -= 1;
            }
            Direction::Right => {
                head_pos.x += 1;
            }
            Direction::Up => {
                head_pos.y += 1;
            }
            Direction::Down => {
                head_pos.y -= 1;
            }
        };

        // ヘビの頭の位置が壁を越えていたらゲームーオーバーイベントをキューに追加
        if head_pos.x < 0
            || head_pos.y < 0
            || head_pos.x as u32 >= ARENA_WIDTH
            || head_pos.y as u32 >= ARENA_HEIGHT
        {
            game_over_writer.write(GameOverEvent);
        }

        // ヘビの頭がしっぽの位置に含まれた場合もゲームオーバーイベントをキューに追加
        if segment_positions.contains(&head_pos) {
            game_over_writer.write(GameOverEvent);
        }

        segment_positions.iter().zip(segments.0.iter().skip(1)).for_each(|(pos, segment)| {
            *positions.get_mut(*segment).unwrap() = *pos;
        });

        // ここでしっぽの末尾の位置をリソースに保持させる
        *last_tail_position = LastTailPosition(Some(*segment_positions.last().unwrap()));
    }
}

続いて、snake_movement関数でヘビの頭が壁を越えていたらGameOverEventを通知するように修正します。またヘビの頭がしっぽの位置と被ってもGameOverEventを通知するようにも修正します。

//
// ゲームーオーバー
//
fn game_over(
    mut commands: Commands,
    mut game_over_reader: EventReader<GameOverEvent>,
    segments_res: ResMut<SnakeSegments>,
    food: Query<Entity, With<Food>>,
    heads: Query<Entity, With<SnakeHead>>,
    segments: Query<Entity, With<SnakeSegment>>,
) {
    if let Some(_event) = game_over_reader.read().next() {
        for ent in food.iter().chain(segments.iter()).chain(heads.iter()) {
            commands.entity(ent).despawn();
        }
        spawn_snake(commands, segments_res);
    }
}

ゲームオーバー時の挙動を関数で定義します。引数に関しては新しいことは特になく今までの章で記載した内容から意図を読み取れます。キューに入ったGameOverEventを検知したらifブロックが実行されます。iter.chain()イテレーターを繋いで回せます。つまりFoodSnakeHeadSnakeSegment(フードと頭としっぽ)をすべて取り出してdespwanで削除しています。つまり画面がクリアされます。そしてspawn_snakeを実行して最初からゲーム再開です。

fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .insert_resource(SnakeSegments::default())
         .insert_resource(LastTailPosition::default())
         .add_event::<GrowthEvent>()
         .add_event::<GameOverEvent>() // ←
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                position: WindowPosition::Centered(MonitorSelection::Primary),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement.run_if(on_timer(Duration::from_millis(1000))))
         .add_systems(Update, snake_movement_input.before(snake_movement))
         .add_systems(Update, spawn_food.run_if(on_timer(Duration::from_secs(1))))
         .add_systems(Update, snake_eating.after(snake_movement))
         .add_systems(Update, snake_growth.after(snake_eating))
         .add_systems(Update, game_over.after(snake_movement)) // ←
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

最後に、game_over関数をシステムに登録します。

壁かしっぽにあたればリセット

cargo runしてください。壁かしっぽに頭が当たればゲームオーバーで最初からになれば完成です!延々とプレイできるはずです。

ここまでメモ書きを書いてる私もコード量自体はそんなでもないですがECSを理解しつつでかなり勉強になりました。スネークゲームはECSで構築するのは相性が良さそうです。

このスネークゲーム自体は小さいコードなため改善の余地はたくさんあります。例えばですが、フードが出現する位置はヘビの頭やしっぽと重ならないようにするであったり音を付けたり等です。

※ ちょっと余談ですがしっぽの位置にもフードが出るとスネークゲームは後半になるにつれ難易度が段違いになります。気になる方は実装されるスネークゲームの動作例で遊べそうなら遊んでみてください。

この記事が少しでも誰かの役に立てば幸いです。

微調整

ここはチュートリアルとは関係なくちょっとした微調整についてです。

fn spawn_snake(mut commands: Commands, mut segments: ResMut<SnakeSegments>) {
    let bundle = (
        Sprite::from_color(SNAKE_HEAD_COLOR, Vec2::new(1.0, 1.0)),
        Transform {
            translation: Vec3::new(0.0, 0.0, 0.0),
            scale: Vec3::new(1.0, 1.0, 0.0),
            ..default()
        },
        Position { x: 4, y: 5 },
        Size::square(0.8),
        SnakeHead {
            direction: Direction::Up
        }
    );

    // 2つのエンティティをリソースに追加したSnakeSegmentsのVec<Entity>に追加
    * segments = SnakeSegments(vec![
        commands.spawn(bundle).id(),
        spawn_segment(commands, Position { x: 4, y: 4 }), // ← しっぽの初期位置を頭のすぐ下に(頭の位置とは重ならないのようにする)
    ])
}
fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .insert_resource(SnakeSegments::default())
         .insert_resource(LastTailPosition::default())
         .add_event::<GrowthEvent>()
         .add_event::<GameOverEvent>() // ←
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                position: WindowPosition::Centered(MonitorSelection::Primary),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement.run_if(on_timer(Duration::from_millis(500)))) // ← へびの動きをもう少し速く
         .add_systems(Update, snake_movement_input.before(snake_movement))
         .add_systems(Update, spawn_food.run_if(on_timer(Duration::from_secs(3)))) // フードの出現量を抑える
         .add_systems(Update, snake_eating.after(snake_movement))
         .add_systems(Update, snake_growth.after(snake_eating))
         .add_systems(Update, game_over.after(snake_movement))
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

補足

ウィンドウ終了時にpanicが出る

ウィンドウのリサイズと座標変換処理でsingle_mutPrimaryWindowを取得しています。ゲームを終了してウィンドウが消えてもゲームの処理自体は最期のフレームまで生存していた場合にウィンドウがないせいで発生したりします。ECSフレームワークはフレームごとに関数を実行させるためこの手の事象は起きやすいです。気持ち悪い場合は以下のように修正してください。

fn size_scaling(
    mut windows: Query<&mut Window, With<PrimaryWindow>>,
    mut q: Query<(&Size, &mut Transform)>
) {
    if let Some(window) = windows.iter_mut().next() {
        for (sprite_size, mut transform) in q.iter_mut() {
            transform.scale = Vec3::new(
                sprite_size.width / ARENA_WIDTH as f32 * window.width() as f32,
                sprite_size.height / ARENA_HEIGHT as f32 * window.height() as f32,
                1.0,
            );
        }
    }
}
fn position_translation(
    mut windows: Query<&mut Window, With<PrimaryWindow>>,
    mut q: Query<(&Position, &mut Transform)>
) {
    if let Some(window) = windows.iter_mut().next() {
        fn convert(pos: f32, bound_window: f32, bound_game: f32) -> f32 {
            let tile_size = bound_window / bound_game;
            (pos / bound_game * bound_window) - (bound_window / 2.) + (tile_size / 2.)
        }

        for (pos, mut transform) in q.iter_mut() {
            transform.translation = Vec3::new(
                convert(pos.x as f32, window.width() as f32, ARENA_WIDTH as f32),
                convert(pos.y as f32, window.height() as f32, ARENA_HEIGHT as f32),
                0.0,
            );
        }
    }
}

最終的なコード

[package]
name = "bevy_snake"
version = "0.1.0"
edition = "2024"

[dependencies]
bevy = "0.16.0"
once_cell = "1.21.3"
rand = "0.9.1"
use std::time::Duration;
use bevy::{prelude::*, window::PrimaryWindow, render::camera::SubCameraView, time::common_conditions::on_timer};
use once_cell::sync::Lazy;
use rand::Rng;

const WINDOW_WIDTH: f32 = 500.;
const WINDOW_HEIGHT: f32 = 500.;
const DISPLAY_FULL_SIZE: Lazy<UVec2> = Lazy::new(|| UVec2::new(WINDOW_WIDTH as u32, WINDOW_HEIGHT as u32));
const DISPLAY_SIZE: Lazy<UVec2> = Lazy::new(|| UVec2::new(WINDOW_WIDTH as u32, WINDOW_HEIGHT as u32));
const SNAKE_HEAD_COLOR: Srgba = Srgba::rgb(0.7, 0.7, 0.7);
const FOOD_COLOR: Srgba = Srgba::rgb(1.0, 0.0, 1.0);
const SNAKE_SEGMENT_COLOR:  Srgba = Srgba::rgb(0.3, 0.3, 0.3);
const ARENA_WIDTH: u32 = 10;
const ARENA_HEIGHT: u32 = 10;

// ヘビの頭
#[derive(Component)]
struct SnakeHead {
    direction: Direction
}

// フード
#[derive(Component)]
struct Food;

// へびのしっぽ
#[derive(Component)]
struct SnakeSegment;

// へびのしっぽ(リスト)
#[derive(Default, Resource)]
struct SnakeSegments(Vec<Entity>);

// ヘビの位置
#[derive(Component, Clone, Copy, PartialEq, Eq)]
struct Position {
    x: i32,
    y: i32,
}

// ヘビのサイズ
#[derive(Component)]
struct Size {
    width: f32,
    height: f32,
}

impl Size {
    pub fn square(x: f32) -> Self {
        Self {
            width: x,
            height: x,
        }
    }
}

// ヘビの方向
#[derive(PartialEq, Copy, Clone)]
enum Direction {
    Left,
    Up,
    Right,
    Down,
}

impl Direction {
    fn opposite(self) -> Self {
        match self {
            Self::Left => Self::Right,
            Self::Right => Self::Left,
            Self::Up => Self::Down,
            Self::Down => Self::Up,
        }
    }
}

// ヘビがフードを食べたイベント
#[derive(Event)]
struct GrowthEvent;

// しっぽの最後尾を保持するリソース
#[derive(Default, Resource)]
struct LastTailPosition(Option<Position>);

// ゲームオーバーイベント
#[derive(Event)]
struct GameOverEvent;

fn setup_camera(mut commands: Commands) {
    let bundle = (
        Camera2d,
        Camera {
            sub_camera_view: Some(SubCameraView {
                full_size: *DISPLAY_FULL_SIZE,
                offset: Vec2::new(0.0, 0.0),
                size: *DISPLAY_SIZE,
            }),
        order: 1,
        ..default()
        }
    );
 
    commands.spawn(bundle);
}

//
// ヘビの頭を出現させる
//
fn spawn_snake(mut commands: Commands, mut segments: ResMut<SnakeSegments>) {
    let bundle = (
        Sprite::from_color(SNAKE_HEAD_COLOR, Vec2::new(1.0, 1.0)),
        Transform {
            translation: Vec3::new(0.0, 0.0, 0.0),
            scale: Vec3::new(1.0, 1.0, 0.0),
            ..default()
        },
        Position { x: 4, y: 5 },
        Size::square(0.8),
        SnakeHead {
            direction: Direction::Up
        }
    );

    // 2つのエンティティをリソースに追加したSnakeSegmentsのVec<Entity>に追加
    * segments = SnakeSegments(vec![
        commands.spawn(bundle).id(),
        spawn_segment(commands, Position { x: 4, y: 4 }),
    ])
}

//
// フードを出現させる
//
fn spawn_food(mut commands: Commands) {
    let arena_width = ARENA_WIDTH as i32;
    let arena_height = ARENA_HEIGHT as i32;
    let bundle = (
        Sprite::from_color(FOOD_COLOR, Vec2::new(1.0, 1.0)),
        Transform {
            translation: Vec3::new(0.0, 0.0, 0.0),
            scale: Vec3::new(1.0, 1.0, 0.0),
            ..default()
        },
        Position {
            x: rand::rng().random_range(0..arena_width),
            y: rand::rng().random_range(0..arena_height),
        },
        Size::square(0.8),
        Food
    );

    commands.spawn(bundle);
}

// ヘビのしっぽを出現させる
fn spawn_segment(mut commands: Commands, position: Position) -> Entity {
    let bundle = (
        Sprite::from_color(SNAKE_SEGMENT_COLOR, Vec2::new(1.0, 1.0)),
        SnakeSegment,
        Size::square(0.65)
    );
    commands.spawn(bundle)
            .insert(position)
            .id()
}

//
// ヘビの頭を動かす方向の入力を受け取る
//
fn snake_movement_input(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut heads: Query<&mut SnakeHead>
) {
    if let Some(mut head) = heads.iter_mut().next() {
        let direction: Direction = if keyboard_input.pressed(KeyCode::ArrowLeft) {
            Direction::Left
        } else if keyboard_input.pressed(KeyCode::ArrowDown) {
            Direction::Down
        } else if keyboard_input.pressed(KeyCode::ArrowUp) {
            Direction::Up
        } else if keyboard_input.pressed(KeyCode::ArrowRight) {
            Direction::Right
        } else {
            head.direction
        };

        // 入力された方向がヘビの進行方向の反対方向でなければ方向転換
        if direction != head.direction.opposite() {
            head.direction = direction;
        }
    }
}

//
// ヘビの頭を動かす
//
fn snake_movement(
    segments: ResMut<SnakeSegments>,
    mut last_tail_position: ResMut<LastTailPosition>,
    mut game_over_writer: EventWriter<GameOverEvent>, 
    mut heads: Query<(Entity, &SnakeHead)>,
    mut positions: Query<&mut Position>
){
    if let Some((head_entity, head)) = heads.iter_mut().next() {
         let segment_positions = segments.0.iter()
                                         .map(|e| *positions.get_mut(*e).unwrap())
                                         .collect::<Vec<Position>>();
        let mut head_pos = positions.get_mut(head_entity).unwrap();
        match &head.direction {
            Direction::Left => {
                head_pos.x -= 1;
            }
            Direction::Right => {
                head_pos.x += 1;
            }
            Direction::Up => {
                head_pos.y += 1;
            }
            Direction::Down => {
                head_pos.y -= 1;
            }
        };

        // ヘビの頭の位置が壁を越えていたらゲームーオーバーイベントをキューに追加
        if head_pos.x < 0
            || head_pos.y < 0
            || head_pos.x as u32 >= ARENA_WIDTH
            || head_pos.y as u32 >= ARENA_HEIGHT
        {
            game_over_writer.write(GameOverEvent);
        }

        // ヘビの頭がしっぽの位置に含まれた場合もゲームオーバーイベントをキューに追加
        if segment_positions.contains(&head_pos) {
            game_over_writer.write(GameOverEvent);
        }

        segment_positions.iter().zip(segments.0.iter().skip(1)).for_each(|(pos, segment)| {
            *positions.get_mut(*segment).unwrap() = *pos;
        });

        // ここでしっぽの末尾の位置をリソースに保持させる
        *last_tail_position = LastTailPosition(Some(*segment_positions.last().unwrap()));
    }
}

//
// ヘビがフードを食べる
//
fn snake_eating(
    mut commands: Commands,
    mut growth_writer: EventWriter<GrowthEvent>,
    food_positions: Query<(Entity, &Position), With<Food>>,
    head_positions: Query<&Position, With<SnakeHead>>,
) {
    for head_pos in head_positions.iter() {
        for (ent, food_pos) in food_positions.iter() {
            if food_pos == head_pos {
                commands.entity(ent).despawn();
                growth_writer.write(GrowthEvent);
            }
        }
    }
}

//
// ヘビが成長する
//
fn snake_growth(
    commands: Commands,
    last_tail_position: Res<LastTailPosition>,
    mut segments: ResMut<SnakeSegments>,
    mut growth_reader: EventReader<GrowthEvent>,
) {
    if let Some(_event) = growth_reader.read().next() {
        segments.0.push(spawn_segment(commands, last_tail_position.0.unwrap()));
    }
}

//
// ゲームーオーバー
//
fn game_over(
    mut commands: Commands,
    mut game_over_reader: EventReader<GameOverEvent>,
    segments_res: ResMut<SnakeSegments>,
    food: Query<Entity, With<Food>>,
    heads: Query<Entity, With<SnakeHead>>,
    segments: Query<Entity, With<SnakeSegment>>,
) {
    if let Some(_event) = game_over_reader.read().next() {
        for ent in food.iter().chain(segments.iter()).chain(heads.iter()) {
            commands.entity(ent).despawn();
        }
        spawn_snake(commands, segments_res);
    }
}

//
// 画面に表示されるモノの大きさをスケールする
//
fn size_scaling(
    mut windows: Query<&mut Window, With<PrimaryWindow>>,
    mut q: Query<(&Size, &mut Transform)>
) {
    if let Some(window) = windows.iter_mut().next() {
        for (sprite_size, mut transform) in q.iter_mut() {
            transform.scale = Vec3::new(
                sprite_size.width / ARENA_WIDTH as f32 * window.width() as f32,
                sprite_size.height / ARENA_HEIGHT as f32 * window.height() as f32,
                1.0,
            );
        }
    }
}

//
// スケールに合わせて表示位置を更新する
//
fn position_translation(
    mut windows: Query<&mut Window, With<PrimaryWindow>>,
    mut q: Query<(&Position, &mut Transform)>
) {
    if let Some(window) = windows.iter_mut().next() {
        fn convert(pos: f32, bound_window: f32, bound_game: f32) -> f32 {
            let tile_size = bound_window / bound_game;
            (pos / bound_game * bound_window) - (bound_window / 2.) + (tile_size / 2.)
        }

        for (pos, mut transform) in q.iter_mut() {
            transform.translation = Vec3::new(
                convert(pos.x as f32, window.width() as f32, ARENA_WIDTH as f32),
                convert(pos.y as f32, window.height() as f32, ARENA_HEIGHT as f32),
                0.0,
            );
        }
    }
}


fn main() {
    App::new()
         .insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
         .insert_resource(SnakeSegments::default())
         .insert_resource(LastTailPosition::default())
         .add_event::<GrowthEvent>()
         .add_event::<GameOverEvent>()
         .add_plugins(DefaultPlugins.set( WindowPlugin {
            primary_window: Some( Window {
                title: "Snake!".into(),
                name: Some("Snake.app".into()),
                resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
                position: WindowPosition::Centered(MonitorSelection::Primary),
                ..default()
            }),
            ..default()
         }))
         .add_systems(Startup, setup_camera)
         .add_systems(Startup, spawn_snake)
         .add_systems(Update, snake_movement.run_if(on_timer(Duration::from_millis(500))))
         .add_systems(Update, snake_movement_input.before(snake_movement))
         .add_systems(Update, spawn_food.run_if(on_timer(Duration::from_secs(3))))
         .add_systems(Update, snake_eating.after(snake_movement))
         .add_systems(Update, snake_growth.after(snake_eating))
         .add_systems(Update, game_over.after(snake_movement))
         .add_systems(PostUpdate, position_translation)
         .add_systems(PostUpdate, size_scaling)
         .run()
         ;
}

Rustをインストールして、適当なディレクトリを作成してそのディレクトリに移動したらcargo initコマンドを実行して生成されたcargo.tomlに上のコード、src/main.rsに下のコードをコピペしてcargo runしてください。スネークゲームが起動します。


  1. かつてAmethystと呼ばれるゲームエンジンがあり多数書籍まで出ていたのですが開発中止になってしまいました…
  2. Godotで言えばノードに相当するものと考えて差し支えないかもしれません。