Open56

RustのBevy(0.14.2)でスネークゲームを作る

so-heyso-hey

ECSとやらもあまりわかっていない状態だが,やってみる.
このサイトを今のバージョンに合わせながら書いていくことから始める.
元コードはサイトの方を参照してください.

so-heyso-hey
main.rs
use bevy::prelude::*;

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

ここは問題なし

so-heyso-hey

randクレートを依存関係に追加する.

so-heyso-hey
main.rs
fn main() {
    App::new().add_plugins(DefaultPlugins).run();
}

も問題なし

so-heyso-hey

cameraのセットアップを行う.

main.rs
fn setup_camera(mut commands: Commands) {
    commands.spawn((
        Camera2dBundle {
            camera: Camera {
                hdr: true,
                ..default()
            },
            ..default()
        },
        BloomSettings::NATURAL,
    ));
}

これはかなり変更が必要.
まずspawn_bundleではなくspawnを利用し,OrthographicCameraBundleも無いので他で代替する.公式githubのこの例をコピペさせてもらった.

so-heyso-hey
main.rs
App::new()
    .add_systems(Startup, setup_camera)
    .add_plugins(DefaultPlugins)
    .run();

システムを付与するのは全てadd_system()になっており,第1引数にシステムを呼び出すタイミングを与える.

so-heyso-hey

これで実行すると,灰色の画面が出てくる.

so-heyso-hey

蛇の頭を作成する.

main.rs
#[derive(Component)]
struct SnakeHead;

これがコンポーネントらしい.
次に蛇の頭の色を定義する.

main.rs
const SNAKE_HEAD_COLOR: Color = Color::linear_rgb(0.7, 0.7, 0.7);

rgb()は今ではよくない書き方らしいので,linear_rgb()に変えておく.

so-heyso-hey

スネークヘッドエンティティを作成するspawn_snakeというシステムを作成する.

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

先程と同様,spawn()で代替する.他は変わらない.

so-heyso-hey

Appspawn_snakeを追加するが,add_systems()を2つ以上連ねる必要はなく

main.rs
App::new()
    .add_systems(Startup, (setup_camera, spawn_snake))
    .add_plugins(DefaultPlugins)
    .run();

という風にまとめて書ける.(呼び出しのタイミングが同じ場合のみ)

so-heyso-hey

次に蛇をうごかすシステムを定義する.

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

これはそのまま書ける.y += 2.としているが,上に移動する.珍しい.
Query<D, F>でデータDの属性Fに対するクエリということかな?

so-heyso-hey

App.add_system(Update, snake_movement)としてフレームごとにこれを呼び出すように設定する.

so-heyso-hey

snake_movementをキーボード入力によって蛇が移動するように変更する.

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

引数のkeyboard_inputの型はRes<ButtonInput<KeyCode>>に変更し,KeyCodeはそれぞれ向きの前にArrowをつける.

so-heyso-hey

これで実行すると,蛇を自由自在に動かせるようになった.

so-heyso-hey

PositionSizeというコンポーネントを導入する.これはそのままで大丈夫.
その後,spawn_snakePositionSizeをinsertする.これも変わらない.

so-heyso-hey

このままでは画面は何も変わらないので,位置と大きさを設定するシステムを定義していく.

so-heyso-hey

大きさを設定するシステムを定義していく.

main.rs
fn size_scaling(window: Query<&Window>, mut q: Query<(&Size, &mut Transform)>) {
    let window = window.single();
    for (sprite_size, mut transform) in q.iter_mut() {
        transform.scale = Vec3::new(
            sprite_size.width / ARENA_WIDTH as f32 * window.width(),
            sprite_size.height / ARENA_HEIGHT as f32 * window.height(),
            1.0,
        );
    }
}

元のコードでは引数のリソースからウィンドウサイズを取得しているが,今のバージョンではウィンドウ関連でResourceを継承しているものが無さそうだったので,クエリにした.(公式githubでそうしているから問題なさそう)
そのためwindowを上書きする形になってたり,ウィンドウサイズにas f32がついていなかったりと細々した変更がある.

so-heyso-hey

次に位置を設定するシステム.

main.rs
fn position_translation(window: Query<&Window>, 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 = window.single();
    for (pos, mut transform) in q.iter_mut() {
        transform.translation = Vec3::new(
            convert(pos.x as f32, window.width(), ARENA_WIDTH as f32),
            convert(pos.y as f32, window.height(), ARENA_HEIGHT as f32),
            0.0,
        );
    }
}

変更点はさっきと一緒なので,言うことはない.

so-heyso-hey

これらのシステムをAppに追加するが,これはエンティティの状態更新直後に行ってほしいので,add_systems(PostUpdate, (position_translation, size_scaling))とする.

so-heyso-hey

これで実行すると,画面の少し左下の方につぶれた蛇が移される.
キーボード入力が無効になってしまっているが,問題ないらしい.

so-heyso-hey

snake_movementを少し変更.

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

ここで書いたクエリについての考察は,文章はだいたい合っているけど自分の中でのイメージとは違ってたぽい.このFにあたるフィルターはWithとかWithoutで書く必要がある.

so-heyso-hey

次にウィンドウの初期設定を行っていく.insert_resourceでは引数にResourceを継承した構造体をとるが,先ほど言ったようにそんなものが無さそうなので,またまた公式githubを参照させていただく.既存のコードの.add_plugins(DefaultPlugin)を以下のように変更.

main.rs
.add_plugins(DefaultPlugins.set(WindowPlugin {
    primary_window: Some(Window {
        title: "Snake!".into(),
        resolution: (500., 500.).into(),
        ..default()
    }),
    ..default()
}))
so-heyso-hey

次のClearColorrgblinear_rgbに変更するだけ.

so-heyso-hey

これで実行すると,正方形の画面に正方形の蛇ができた.キーボード入力も受け取ってくれている.

ただ,これはウィンドウの形に合わせて蛇も伸び縮みするようになっているので,少し修正する.
size_scalingを以下のように変更するだけ.

main.rs
let window = window.single();
let window_size = window.width().min(window.height());
for (sprite_size, mut transform) in q.iter_mut() {
    transform.scale = Vec3::new(
        sprite_size.width / ARENA_WIDTH as f32 * window_size,
        sprite_size.height / ARENA_HEIGHT as f32 * window_size,
        1.0,
    );
}

ウィンドウの縦と横の小さいほうに合わせて蛇の大きさを設定するようにした.これで常に正方形になってくれるはず.

so-heyso-hey

次は餌を生成するシステムを作る.

main.rs
fn food_spawner(mut commands: Commands) {
    commands
        .spawn(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));
}

spawn_bundleのところ以外は変更はない.

so-heyso-hey

これをAppに追加して1秒間隔で呼び出してもらうようにするのだが,例のコードが今のバージョンでは全く使えないので,大きく変更する必要がある.

main.rs
.add_systems(FixedMain, food_spawner)
...
.insert_resource(Time::<Fixed>::from_duration(Duration::from_secs(1)))

これで同じように実装できているはず.

so-heyso-hey

ただ,ここで1つ問題がある.今は〇秒間隔で使用するシステムが1つだけだから大丈夫だが,他にもそのようなシステムを実装した場合,今の実装では全て同じ秒数にしか設定できない.
これ以降も〇秒間隔で使用するシステムなどいくらでも出てくると思うので,そこを柔軟に対応できるようにするために実装してみた.

so-heyso-hey

まず,ジェネリックに使えるIntervalSettingという型を作成.

main.rs
#[derive(Resource)]
struct IntervalSetting<T> {
    last_update: Instant,
    phantom: PhantomData<T>,
}

impl<T> Default for IntervalSetting<T> {
    fn default() -> Self {
        Self {
            last_update: Instant::now(),
            phantom: PhantomData,
        }
    }
}

impl<T> IntervalSetting<T> {
    fn check(&self, time: Instant, diff: Duration) -> bool {
        time - self.last_update >= diff
    }

    fn update(&mut self, time: Instant) {
        self.last_update = time;
    }
}

前回の更新との差分が設定インターバルに達しているかを確認するcheckと,時間を更新するupdateを実装している.
ここで面倒なのは,Resourceとして使用できるのは構造体のインスタンスではなく構造体そのものであるということなので,このような回りくどい実装になってしまった.PhantomData<T>を属性として持つことで,ジェネリックに対応できるようにした.PhantomData<T>なんて初めて使ったよ...(ドキドキ)

so-heyso-hey

次にfood_spawnerを以下のように変更.

main.rs
fn food_spawner(mut commands: Commands, mut time: ResMut<IntervalSetting<Food>>) {
    let now = Instant::now();
    if !time.check(now, Duration::from_secs(1)) {
        return;
    }
    time.update(now);
    // ...
    // 元の処理
    // ...
}

Resourceをミュータブルに使用するためにResMutを引数にとり,Foodを型パラメータとして受け取るようにした.
そしてこれをinsert_resource(IntervalSetting::<Food>::default())Appに追加する.

so-heyso-hey

より蛇らしい動きをつけていく.今のままでは蛇は基本止まっていて,キーボード入力がある間だけその方向に動くようになっている.これを常に動いてキーボード入力で向きを変えるだけにする.まず向きを定義していく.

main.rs
#[derive(PartialEq, Clone, Copy)]
enum Direction {
    UP,
    DOWN,
    LEFT,
    RIGHT,
}

impl Direction {
    fn opposite(self) -> Self {
        match self {
            Self::UP => Self::DOWN,
            Self::DOWN => Self::UP,
            Self::LEFT => Self::RIGHT,
            Self::RIGHT => Self::LEFT,
        }
    }
}

oppositeは後で使う.

so-heyso-hey

次に,SnakeHeadの属性にdirectionを付与する.

main.rs
#[derive(Component)]
struct SnakeHead {
    direction: Direction,
}

これによって影響を受けるところを修正する.

main.rs
fn spawn_snake(mut commands: Commands) {
    // ...
        .insert(SnakeHead {
            direction: Direction::UP,
        })
    // ...
}
so-heyso-hey

フレームごとに移動するとすごい速さになるので,0.15秒に1度だけ移動するようにする.これはさっきのIntervalSettingを使う.

main.rs
fn snake_movement(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut head_position: Query<(&SnakeHead, &mut Transform)>,
    mut time: ResMut<IntervalSetting<SnakeHead>>,
) {
    let now = Instant::now();
    if !time.check(now, Duration::from_millis(150)) {
        return;
    }
    time.update(now);
    // ...
    // 元の処理
    // ...
}
so-heyso-hey

今のままだとキーボード入力も0.15秒間隔なので,キーボード入力受付と蛇の移動を分離させる.

main.rs
fn snake_movement_input(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut heads: Query<&mut SnakeHead>,
) {
    if let Some(mut head) = heads.iter_mut().next() {
        let dir: Direction = if keyboard_input.pressed(KeyCode::ArrowUp) {
            Direction::UP
        } else if keyboard_input.pressed(KeyCode::ArrowDown) {
            Direction::DOWN
        } else if keyboard_input.pressed(KeyCode::ArrowRight) {
            Direction::RIGHT
        } else if keyboard_input.pressed(KeyCode::ArrowLeft) {
            Direction::LEFT
        } else {
            head.direction
        };
        if dir != head.direction.opposite() {
            head.direction = dir;
        }
    }
}

fn snake_movement(
    mut heads: Query<(&mut Position, &SnakeHead)>,
    mut time: ResMut<IntervalSetting<SnakeHead>>,
) {
    let now = Instant::now();
    if !time.check(now, Duration::from_millis(150)) {
        return;
    }
    time.update(now);
    if let Some((mut head_pos, head)) = heads.iter_mut().next() {
        match &head.direction {
            Direction::UP => {
                head_pos.y += 1;
            }
            Direction::DOWN => {
                head_pos.y -= 1;
            }
            Direction::RIGHT => {
                head_pos.x += 1;
            }
            Direction::LEFT => {
                head_pos.x -= 1;
            }
        };
    }
}
so-heyso-hey

(蛇の移動が呼び出されるなら)キーボード入力受付は蛇の移動より前に呼び出されるべきであるので,そのようにAppに追加する.

main.rs
.add_systems(
    Update,
    (
        snake_movement_input.before(snake_movement),
        snake_movement,
        food_spawner,
    ),
)

snake_movement_input.before(snake_movement)snake_movementはどちらも書く.(私はこれで1時間ほど悩まされた)

so-heyso-hey

これで実行するとよくあるスネークゲームの動きをしてくれるようになった!

so-heyso-hey

今のままでは蛇はただの正方形なので,体を伸ばしていく.
そのために新たにSnakeSegmentsを用意する.

main.rs
const SNAKE_SEGMENT_COLOR: Color = Color::linear_rgb(0.3, 0.3, 0.3);

#[derive(Component)]
struct SnakeSegment;

#[derive(Default, Resource)]
struct SnakeSegments(Vec<Entity>);

後でリソースとして追加するので,Resourceをderiveしておく.

so-heyso-hey

これをリソースとしてAppに追加する.

main.rs
.insert_resource(SnakeSegments::default())
so-heyso-hey

次に,セグメントを新たに作成してそのEntityを返すシステムを定義していく.

main.rs
fn spawn_segment(mut commands: Commands, position: Position) -> Entity {
    commands
        .spawn(SpriteBundle {
            sprite: Sprite {
                color: SNAKE_SEGMENT_COLOR,
                ..default()
            },
            ..default()
        })
        .insert(SnakeSegment)
        .insert(position)
        .insert(Size::square(0.5))
        .id()
}

spawn以外は変更なし.

so-heyso-hey

これに伴ってspawn_snakeも変更する.

main.rs
fn spawn_snake(mut commands: Commands, mut segments: ResMut<SnakeSegments>) {
    *segments = SnakeSegments(vec![
        commands
            .spawn(SpriteBundle {
                sprite: Sprite {
                    color: SNAKE_HEAD_COLOR,
                    ..default()
                },
                transform: Transform {
                    scale: Vec3::new(10.0, 10.0, 10.0),
                    ..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 }),
    ]);
}

特にいうことはない.

so-heyso-hey

このまま実行すると,セグメントは生成されているが頭についてきていない.これを修正する.

main.rs
fn snake_movement(
    segments: ResMut<SnakeSegments>,
    mut heads: Query<(Entity, &SnakeHead)>,
    mut positions: Query<&mut Position>,
    mut time: ResMut<IntervalSetting<SnakeHead>>,
) {
    let now = Instant::now();
    if !time.check(now, Duration::from_millis(150)) {
        return;
    }
    time.update(now);

    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::UP => {
                head_pos.y += 1;
            }
            Direction::DOWN => {
                head_pos.y -= 1;
            }
            Direction::RIGHT => {
                head_pos.x += 1;
            }
            Direction::LEFT => {
                head_pos.x -= 1;
            }
        };
        segment_positions
            .iter()
            .zip(segments.0.iter().skip(1))
            .for_each(|(pos, segment)| {
                *positions.get_mut(*segment).unwrap() = *pos;
            });
    }
}

segmentsだけでは中のSnakeSegmentsにアクセスできないので,.0を追加する.

これで体が頭についてくるようになった.

so-heyso-hey

蛇が餌を食べるシステムを定義していく.

main.rs
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);
            }
        }
    }
}

そしてこれをAppに追加

main.rs
.add_systems(
    Update,
    (
        snake_movement_input.before(snake_movement),
        snake_movement,
        snake_eating.after(snake_movement),
        food_spawner,
    ),
)
so-heyso-hey

GrouwthEventを定義してAppに追加する.

main.rs
#[derive(Event)]
struct GrowthEvent;
main.rs
.add_event::<GrowthEvent>()
so-heyso-hey

次に,一番後ろのしっぽの位置を記録するためにLastTailPositionを定義する.

main.rs
#[derive(Default, Resource)]
struct LastTailPosition(Option<Position>);

これをリソースとしてAppに追加する.

main.rs
.insert_resource(LastTailPosition::default())
so-heyso-hey

蛇が動くたびにLastTailPositionを変更するためにsnake_movementを少し変更する.

main.rs
fn snake_movement(
    // ...
    mut last_tail_position: ResMut<LastTailPosition>,
    // ...
) {
    // ...
    if let Some((head_entity, head)) = heads.iter_mut().next() {
        // ...
        *last_tail_position = LastTailPosition(Some(*segment_positions.last().unwrap()))
    }
}

このコード,「わざわざSomeで囲うんやったらunwrapいらんやんけ!」と思ったけど,想定しているのがOption<Position>で,そのまま渡してしまうとOption<&Position>になってしまうからダメだった.

so-heyso-hey

やっと,蛇が成長するシステムを定義していく.

main.rs
fn snake_growth(
    commands: Commands,
    last_tail_position: Res<LastTailPosition>,
    mut segments: ResMut<SnakeSegments>,
    mut growth_reader: EventReader<GrowthEvent>,
) {
    if growth_reader.read().next().is_some() {
        segments.0.push(spawn_segment(
            commands,
            last_tail_position.0.unwrap(),
            Size::square(0.5),
        ));
    }
}

EventReaderをイテレータとして返すreadがあるのでこれを使用した.(というかこれでするしかない)
あといつも通りAppに追加するだけ

main.rs
.add_systems(
    Update,
    (
        snake_movement_input.before(snake_movement),
        snake_movement,
        snake_eating.after(snake_movement),
        snake_growth.after(snake_eating),
        food_spawner,
    ),
)
so-heyso-hey

最後に,ゲームオーバーを定義していく.
このゲームでは,「画面外に出てしまったとき」と「頭が体にあたったとき」にゲームオーバーとなる.まずはイベントを作成する.

main.rs
#[derive(Event)]
struct GameOverEvent;

これをAppに追加.

main.rs
.add_event::<GameOverEvent>()
so-heyso-hey

このイベントをsnake_movementに付与していく.まず「画面外に出てしまったとき」の場合のみ書く.

main.rs
fn snake_movement(
    // ...
    mut game_over_writer: EventWriter<GameOverEvent>,
    // ...
) {
    // ...
    if let Some((head_entity, head)) = heads.iter_mut().next() {
        // ...
        match &head.direction {
            // ...
        };
        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.send(GameOverEvent);
        }
        // ...
    }
}

これで画面外に蛇が出た時にゲームオーバーイベントを送信するようになった.

so-heyso-hey

ゲームオーバーイベントをリッスンするシステムを定義する.

main.rs
fn game_over(
    mut commands: Commands,
    mut reader: EventReader<GameOverEvent>,
    segments_res: ResMut<SnakeSegments>,
    food: Query<Entity, With<Food>>,
    segments: Query<Entity, With<SnakeSegment>>,
) {
    if reader.read().next().is_some() {
        for ent in food.iter().chain(segments.iter()) {
            commands.entity(ent).despawn();
        }
        spawn_snake(commands, segments_res);
    }
}

ゲームオーバーイベントを受け取るとすべての餌と蛇の体を消して蛇を初期位置に配置している.

ここで,spawn_snakeをシステムとしてではなく,ただの関数として使用していることに注目してほしいらしい.

so-heyso-hey

次に,「頭が体にあたったとき」のコードを書いていく.

main.rs
fn snake_movement(
    // ...
) {
    // ...
    if let Some((head_entity, head)) = heads.iter_mut().next() {
        // ...
        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.send(GameOverEvent);
        }
        if segment_positions.contains(&head_pos) {
            game_over_writer.send(GameOverEvent);
        }
        // ...
    }
}
so-heyso-hey

最後にgame_overシステムをAppに追加して終了.

main.rs
.add_systems(
    Update,
    (
        snake_movement_input.before(snake_movement),
        snake_movement,
        snake_eating.after(snake_movement),
        snake_growth.after(snake_eating),
        game_over.after(snake_movement), // <--
        food_spawner,
    ),
)
so-heyso-hey

これで実行すると,スネークゲームができた.

so-heyso-hey

汎用性を上げるために色々変更したが,最終的には以下のようになった.

すべてのコード
main.rs
use std::{
    marker::PhantomData,
    time::{Duration, Instant},
};

use bevy::{core_pipeline::bloom::BloomSettings, prelude::*};
use rand::random;

const INITIAL_WIDTH: f32 = 1280.;
const INITIAL_HEIGHT: f32 = 720.;

const SNAKE_HEAD_COLOR: Color = Color::linear_rgb(0.7, 0.7, 0.7);
const SNAKE_SEGMENT_COLOR: Color = Color::linear_rgb(0.3, 0.3, 0.3);
const FOOD_COLOR: Color = Color::linear_rgb(1.0, 0.0, 1.0);

const ARENA_WIDTH: u32 = 30;
const ARENA_HEIGHT: u32 = 30;

fn in_arena(x: i32, y: i32) -> bool {
    0 <= x && x < ARENA_WIDTH as i32 && 0 <= y && y < ARENA_HEIGHT as i32
}

#[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, Clone, Copy)]
enum Direction {
    UP,
    DOWN,
    LEFT,
    RIGHT,
}

impl Direction {
    fn opposite(self) -> Self {
        match self {
            Self::UP => Self::DOWN,
            Self::DOWN => Self::UP,
            Self::LEFT => Self::RIGHT,
            Self::RIGHT => Self::LEFT,
        }
    }
}

#[derive(Resource)]
struct IntervalSetting<T> {
    last_update: Instant,
    phantom: PhantomData<T>,
}

impl<T> Default for IntervalSetting<T> {
    fn default() -> Self {
        Self {
            last_update: Instant::now(),
            phantom: PhantomData,
        }
    }
}

impl<T> IntervalSetting<T> {
    fn check(&self, time: Instant, diff: Duration) -> bool {
        time - self.last_update >= diff
    }

    fn update(&mut self, time: Instant) {
        self.last_update = time;
    }
}

#[derive(Component)]
struct SnakeHead {
    direction: Direction,
}

#[derive(Component)]
struct SnakeSegment;

#[derive(Default, Resource)]
struct SnakeSegments(Vec<Entity>);

#[derive(Default, Resource)]
struct LastTailPosition(Option<Position>);

#[derive(Component)]
struct Food;

#[derive(Event)]
struct GrowthEvent;

#[derive(Event)]
struct GameOverEvent;

fn main() {
    App::new()
        .add_systems(Startup, (setup_camera, spawn_snake))
        .add_systems(
            Update,
            (
                snake_movement_input.before(snake_movement),
                snake_movement,
                snake_eating.after(snake_movement),
                snake_growth.after(snake_eating),
                game_over.after(snake_movement),
                food_spawner,
            ),
        )
        .add_systems(PostUpdate, (position_translation, size_scaling))
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "snake-game".into(),
                name: Some("snake.app".into()),
                resolution: (INITIAL_WIDTH, INITIAL_HEIGHT).into(),
                enabled_buttons: bevy::window::EnabledButtons {
                    maximize: false,
                    ..default()
                },
                ..default()
            }),
            ..default()
        }))
        .insert_resource(SnakeSegments::default())
        .insert_resource(LastTailPosition::default())
        .insert_resource(ClearColor(Color::linear_rgb(0.04, 0.04, 0.04)))
        .insert_resource(IntervalSetting::<SnakeHead>::default())
        .insert_resource(IntervalSetting::<Food>::default())
        .add_event::<GrowthEvent>()
        .add_event::<GameOverEvent>()
        .run();
}

fn setup_camera(mut commands: Commands) {
    commands.spawn((
        Camera2dBundle {
            camera: Camera {
                hdr: true,
                ..default()
            },
            ..default()
        },
        BloomSettings::NATURAL,
    ));
}

fn size_scaling(window: Query<&Window>, mut q: Query<(&Size, &mut Transform)>) {
    let window = window.single();
    let window_size = window.width().min(window.height());
    for (sprite_size, mut transform) in q.iter_mut() {
        transform.scale = Vec3::new(
            sprite_size.width / ARENA_WIDTH as f32 * window_size,
            sprite_size.height / ARENA_HEIGHT as f32 * window_size,
            1.0,
        );
    }
}

fn position_translation(window: Query<&Window>, 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 = window.single();
    for (pos, mut transform) in q.iter_mut() {
        transform.translation = Vec3::new(
            convert(pos.x as f32, window.width(), ARENA_WIDTH as f32),
            convert(pos.y as f32, window.height(), ARENA_HEIGHT as f32),
            0.0,
        );
    }
}

fn spawn_snake(mut commands: Commands, mut segments: ResMut<SnakeSegments>) {
    *segments = SnakeSegments(vec![
        commands
            .spawn(SpriteBundle {
                sprite: Sprite {
                    color: SNAKE_HEAD_COLOR,
                    ..default()
                },
                transform: Transform {
                    scale: Vec3::new(10.0, 10.0, 10.0),
                    ..default()
                },
                ..default()
            })
            .insert(SnakeHead {
                direction: Direction::UP,
            })
            .insert(SnakeSegment)
            .insert(Position { x: 3, y: 3 })
            .insert(Size::square(0.8))
            .id(),
        spawn_segment(&mut commands, Position { x: 3, y: 2 }, Size::square(0.5)),
    ]);
}

fn spawn_segment(commands: &mut Commands, position: Position, scale: Size) -> Entity {
    commands
        .spawn(SpriteBundle {
            sprite: Sprite {
                color: SNAKE_SEGMENT_COLOR,
                ..default()
            },
            ..default()
        })
        .insert(SnakeSegment)
        .insert(position)
        .insert(scale)
        .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 dir: Direction = if keyboard_input.pressed(KeyCode::ArrowUp) {
            Direction::UP
        } else if keyboard_input.pressed(KeyCode::ArrowDown) {
            Direction::DOWN
        } else if keyboard_input.pressed(KeyCode::ArrowRight) {
            Direction::RIGHT
        } else if keyboard_input.pressed(KeyCode::ArrowLeft) {
            Direction::LEFT
        } else {
            head.direction
        };
        if dir != head.direction.opposite() {
            head.direction = dir;
        }
    }
}

fn snake_movement(
    segments: ResMut<SnakeSegments>,
    mut heads: Query<(Entity, &SnakeHead)>,
    mut positions: Query<&mut Position>,
    mut last_tail_position: ResMut<LastTailPosition>,
    mut game_over_writer: EventWriter<GameOverEvent>,
    mut time: ResMut<IntervalSetting<SnakeHead>>,
) {
    let now = Instant::now();
    if !time.check(now, Duration::from_millis(150)) {
        return;
    }
    time.update(now);

    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::UP => {
                head_pos.y += 1;
            }
            Direction::DOWN => {
                head_pos.y -= 1;
            }
            Direction::RIGHT => {
                head_pos.x += 1;
            }
            Direction::LEFT => {
                head_pos.x -= 1;
            }
        };
        if !in_arena(head_pos.x, head_pos.y) {
            game_over_writer.send(GameOverEvent);
        }
        if segment_positions.contains(&head_pos) {
            game_over_writer.send(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 food_spawner(mut commands: Commands, mut time: ResMut<IntervalSetting<Food>>) {
    let now = Instant::now();
    if !time.check(now, Duration::from_secs(1)) {
        return;
    }
    time.update(now);
    commands
        .spawn(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));
}

fn snake_eating(
    mut commands: Commands,
    mut growth_writer: EventWriter<GrowthEvent>,
    food_position: Query<(Entity, &Position), With<Food>>,
    head_position: Query<&Position, With<SnakeHead>>,
) {
    for head_pos in head_position.iter() {
        for (ent, food_pos) in food_position.iter() {
            if food_pos == head_pos {
                commands.entity(ent).despawn();
                growth_writer.send(GrowthEvent);
            }
        }
    }
}

fn snake_growth(
    mut commands: Commands,
    last_tail_position: Res<LastTailPosition>,
    mut segments: ResMut<SnakeSegments>,
    mut growth_reader: EventReader<GrowthEvent>,
) {
    if growth_reader.read().next().is_some() {
        segments.0.push(spawn_segment(
            &mut commands,
            last_tail_position.0.unwrap(),
            Size::square(0.5),
        ));
    }
}

fn game_over(
    mut commands: Commands,
    mut reader: EventReader<GameOverEvent>,
    segments_res: ResMut<SnakeSegments>,
    food: Query<Entity, With<Food>>,
    segments: Query<Entity, With<SnakeSegment>>,
) {
    if reader.read().next().is_some() {
        for ent in food.iter().chain(segments.iter()) {
            commands.entity(ent).despawn();
        }
        spawn_snake(commands, segments_res);
    }
}

so-heyso-hey

知らない間にbevyのバージョンが0.15.0に上がっていたので,またまた変更を追加していく.

so-heyso-hey

コードを見ると,Camera2dBundleSpriteBundleがDeprecatedになったようなので,ここを変更していく.
ドキュメントを見ると,Camera2dBundleには

👎Deprecated since 0.15.0: Use the Camera2d component instead. Inserting it will now also insert the other components required by it automatically.

SpriteBundleには

👎Deprecated since 0.15.0: Use the Sprite component instead. Inserting it will now also insert Transform and Visibility automatically.

と書かれてあるので,これに従っていく.

so-heyso-hey

まずCamera2dBundleを変更する.

main.rs
fn setup_camera(mut commands: Commands) {
    commands.spawn(Camera2d);
}

Camera2dを挿入するだけでそのほか必要なコンポーネントも自動的に挿入されるため,ここまで簡素にまとまった.
BloomSettingsもバージョン0.15.0からDeprecatedになっており,Bloomに変更するように言われているが,まずコードから消えるため言及していない.

so-heyso-hey

次に,SpriteBundleを変更していく.

main.rs
.spawn(Sprite {
    color: SNAKE_SEGMENT_COLOR,
    ..default()
})

Spriteを挿入するだけでTransformVisibilityも自動的に挿入されるため,Transformについて記述する必要がなくなった.