Arganoの小副川です。 この記事では、Rustの Bevy Engine を使って、ライフゲームを実装していきます。
Bevy Engineについて
Bevy Engineとは
Bevy Engineは、Rustで書かれたデータ駆動型のゲームエンジンです。活発なコミュニティによって開発が進められており、永久に無料で使えるOSSとして提供されています。
また、GUIツールを持たないコードファーストのゲームエンジンであり、Rustのコンパイラとの親和性が高いことから、AIを使ったバイブコーディングとの相性が良いという特徴があります。
この記事で扱うBevyのバージョンは0.17.2です。Bevyはまだ開発段階のエンジンであり、頻繁にアップデートが行われています。APIの破壊的変更も多いため、最新の情報は公式ドキュメントを参照してください。
Bevy Engineの最大の特徴は、ECS(Entity Component System)アーキテクチャを採用している点です。
ECS(Entity Component System)の基本概念
ECSは、ゲーム開発における設計パターンの一つで、以下の3つの要素から構成されます。
Entity(エンティティ)
エンティティは、ゲーム世界に存在する「もの」です。ユニークなIDを持ち、それ自体にはデータやロジックを持ちません。ライフゲームでは、各セル(マス目)が1つのエンティティになります。
Component(コンポーネント)
コンポーネントは、エンティティに付与されるデータです。構造体として定義され、エンティティの性質を表現します。ライフゲームでは、セルの位置を表すPositionや、生死を表すAliveなどがコンポーネントになります。
System(システム)
システムは、ロジックを実行する関数です。特定のコンポーネントを持つエンティティに対して処理を行います。ライフゲームでは、「スペースキーが押すと1世代進む」というロジックをシステムとして実装します。
従来のゲームエンジンとの違い
従来のオブジェクト指向型のゲームエンジンでは、ゲームオブジェクトが継承階層を持ち、データとロジックが密結合していました。
一方、ECSでは以下のような利点があります。
- データとロジックが分離されており保守性が高い
- 再利用性が高い(コンポーネントを組み合わせて新しいエンティティを作成)
- 大量のオブジェクトを扱う際のパフォーマンスが良い
環境構築
プロジェクトの作成
まず、Cargoで新しいプロジェクトを作成します。
cargo new bevy-life-game
cd bevy-life-game
Bevy 0.17.2の追加
以下を実行してBevyを依存関係に追加します。
cargo add bevy@0.17.2
BevyでHello Worldを実行
以下をsrc/main.rsに記述して、コンソールに出力してみましょう。
use bevy::prelude::*;
fn main() {
App::new()
.add_systems(Startup, hello_world)
.run();
}
fn hello_world() {
println!("Hello, World!");
}
cargo runを実行して、“Hello, World!“がコンソールに表示されれば成功です。
ライフゲームについて
ライフゲームの基本ルール
ライフゲームは、1970年にイギリスの数学者ジョン・ホートン・コンウェイが考案したシミュレーションゲームです。以下の3つのシンプルなルールで動作します。
- 生存:生きているセルの周囲に2〜3個の生きたセルがあれば、次世代も生存
- 誕生:死んでいるセルの周囲に3個の生きたセルがあれば、次世代で誕生
- 死亡:その他の場合は死亡(過疎または過密)
この記事で作るもの
今回作成するライフゲームの仕様は以下の通りです。
- 40x40のグリッド状にセルを配置
- 初期パターンとしてグライダーパターンを配置
- マウスクリックでセルの生死を切り替え
- スペースキーを押すと1世代進む
グライダーパターンは、5つのセルで構成される有名なパターンで、斜め方向に移動し続ける性質があります。
// グラインダーパターン
■ ■ ■
□ ■ □
■ □ □
ステップバイステップ実装
それでは、実際にコードを書いていきましょう。src/main.rsを開いて、順番に実装していきます。
基本的なウィンドウの表示
まず、必要なモジュールをインポートし、定数を定義します。
use bevy::prelude::*;
use bevy::window::WindowResolution;
const GRID_WIDTH: i32 = 40;
const GRID_HEIGHT: i32 = 40;
const CELL_SIZE: f32 = 20.0;
const WINDOW_WIDTH: u32 = (GRID_WIDTH as f32 * CELL_SIZE) as u32;
const WINDOW_HEIGHT: u32 = (GRID_HEIGHT as f32 * CELL_SIZE) as u32;
次に、Bevyアプリケーションのエントリーポイントとなるmain関数を実装します。
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "ライフゲーム".to_string(),
resolution: WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT),
resizable: false,
..default()
}),
..default()
}))
.run();
}
ここでは以下のことを行っています。
App::new():Bevyアプリケーションを作成add_plugins(DefaultPlugins):Bevyの基本機能(ウィンドウ、入力、描画など)を追加WindowPluginのカスタマイズ- タイトル:「ライフゲーム」
- ウィンドウサイズ:40 * 20 = 800ピクセルの正方形
- リサイズ不可
この状態でcargo runを実行し、指定したウィンドウが表示されることを確認してください。
セルの描画
次に、グリッド状にセルを描画していきます。ここでは、カメラの設定とセルの生成を行う2つのシステムを追加します。
Startupスケジュールについて
Bevyでは、システムを実行するタイミングを「スケジュール」で管理します。Startupスケジュールは、アプリケーションの起動時に一度だけ実行されるスケジュールです。初期化処理やゲーム開始時のセットアップに使用します。
その他のスケジュールには、毎フレーム実行されるUpdateスケジュールなどがあります。今回は、ゲーム開始時にカメラとボードを生成したいので、Startupスケジュールを使用します。
システムパラメータについて
Bevyのシステムは、引数を通じて様々な機能にアクセスできます。主なパラメータは以下の通りです。
- Commands:エンティティやコンポーネントの追加・削除を行う
- Query:特定のコンポーネントを持つエンティティを検索・操作する
- Res:リソース(ゲーム内のグローバルなデータ)にアクセスする
これらのパラメータは、Bevyが自動的に依存性注入してくれるため、必要なものを引数に指定するだけで使用できます。例えば、mut commands: Commandsと書けば、エンティティを生成するためのCommandsが自動的に渡されます。
カメラの設定
2Dゲームを表示するには、カメラが必要です。以下のシステムでカメラを生成します。
fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
spawnメソッドでカメラエンティティを生成しています。
セルの生成
次に、グリッド状にセルを配置するシステムを実装します。
fn spawn_board(mut commands: Commands) {
// 2重ループですべてのセルを生成
for y in 0..GRID_HEIGHT {
for x in 0..GRID_WIDTH {
// 色は暗いグレーに設定
let color = Color::srgb(0.1, 0.1, 0.1);
commands.spawn((
// Spriteコンポーネントでセルを表現
Sprite {
color,
// - 1.0でグリッド線を表現
custom_size: Some(Vec2::new(CELL_SIZE - 1.0, CELL_SIZE - 1.0)),
..default()
},
Transform::from_xyz(
((x as f32 + 0.5) - GRID_WIDTH as f32 / 2.0) * CELL_SIZE,
((y as f32 + 0.5) - GRID_HEIGHT as f32 / 2.0) * CELL_SIZE,
0.0,
),
));
}
}
}
Bevyの2D座標系について
Bevyの2Dカメラを使用する際、座標系について理解しておくことが重要です。
- 原点の位置:画面中央が座標(0, 0)
- X軸:右方向がプラス(正の値)、左方向がマイナス(負の値)
- Y軸:上方向がプラス(正の値)、下方向がマイナス(負の値)
Y軸
+
↑
|
|
- ------+------→ + X軸
|
|
|
-
この座標系は、Godotエンジンと同じ右手座標系です。一般的なUI座標系(左上が原点でY軸が下向き)とは異なるため、注意が必要です。
そのため、セルを配置する際には、グリッド座標から画面座標への変換が必要になります。spawn_board関数では、- GRID_WIDTH as f32 / 2.0のような計算で、グリッドの左下を負の座標、右上を正の座標に配置しています。
main関数の更新
システムを登録するため、main関数を以下のように更新します。
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {...}))
.add_systems(Startup, (spawn_camera, spawn_board)) // システムの登録
.run();
}
add_systems(Startup, ...):起動時に1度だけ実行されるStartupスケジュールに2つのシステムを追加
この状態でcargo runを実行すると、暗いグレーのグリッドが表示されます。
初期パターンの配置
次に、グライダーパターンをグリッドの中央に配置するため、セルの位置と生死状態を管理するコンポーネントを定義します。
コンポーネントの定義
まず、セルの位置を表すPositionコンポーネントを定義します。
#[derive(Component, Clone, Copy, PartialEq, Eq, Hash)]
struct Position {
x: i32,
y: i32,
}
Positionは、グリッド上のx, y座標を保持します。#[derive(...)]で以下のトレイトを自動実装しています。
Component:Bevyのコンポーネントとして使用できるようにするClone, Copy:値のコピーを可能にするPartialEq, Eq, Hash:位置の比較やハッシュマップでの使用を可能にする
次に、セルの生死状態を表すAliveコンポーネントを定義します。
#[derive(Component)]
struct Alive(bool);
Aliveは、セルが生きているか(true)死んでいるか(false)を表すシンプルなコンポーネントです。
グライダーパターンの定義
グライダーパターンを配列で定義します。
// 2 ■ ■ ■
// 1 □ □ ■
// 0 □ ■ □
// 0 1 2
const GLIDER_PATTERN: [(i32, i32); 5] = [(1, 0), (2, 1), (0, 2), (1, 2), (2, 2)];
このパターンは、5つのセルの相対座標を表しています。
spawn_board関数の更新
spawn_board関数を更新して、グライダーパターンを中央に配置し、各セルにPositionとAliveコンポーネントを追加します。
fn spawn_board(mut commands: Commands) {
// 初期パターンを中央に配置するためのオフセット
let offset_x = GRID_WIDTH / 2;
let offset_y = GRID_HEIGHT / 2;
// すべてのセルを生成
for y in 0..GRID_HEIGHT {
for x in 0..GRID_WIDTH {
let position = Position { x, y };
// このセルがグライダーパターンに含まれるかチェック
let is_alive = GLIDER_PATTERN
.iter()
.any(|(px, py)| x == offset_x + px && y == offset_y + py);
let color = if is_alive {
Color::WHITE
} else {
Color::srgb(0.1, 0.1, 0.1)
};
commands.spawn((
...,
position, // Positionコンポーネントを追加
Alive(is_alive), // Aliveコンポーネントを追加
));
}
}
}
この状態でcargo runを実行すると、グリッドの中央にグライダーパターンが白いセルで表示されます。
セルの生死を切り替える機能
次に、マウスでセルをクリックして生死を切り替える機能を実装します。Bevyでは、エンティティに対するイベントを簡単に処理できるオブザーバー機能が導入されています。
Bevyのオブザーバーシステムとは
Bevyのオブザーバーは、特定のエンティティに対するイベントを監視し、反応する仕組みです。以下の特徴があります。
- エンティティ単位でイベントを処理できる
- イベントが発火したエンティティに直接アクセスできる
- エンティティのスポーン時に登録できる
今回は、セルがクリックされたときに反応するオブザーバーを実装します。
クリック処理の実装
次に、クリックされたときの処理を行うhandle_cell_click関数を実装します。
fn handle_cell_click(trigger: On<Pointer<Click>>, mut cells: Query<(&mut Sprite, &mut Alive)>) {
let entity = trigger.event_target();
let Ok((mut sprite, mut alive)) = cells.get_mut(entity) else {
return;
};
// セルの状態を切り替え
alive.0 = !alive.0;
sprite.color = if alive.0 {
Color::WHITE
} else {
Color::srgb(0.1, 0.1, 0.1)
};
}
trigger: On<Pointer<Click>>:クリックイベントのトリガー情報を受け取るmut cells: Query<(&mut Sprite, &mut Alive)>:Queryによるエンティティ検索- Queryは、特定のコンポーネントを持つエンティティを検索・操作するためのシステムパラメータです
Query<(&mut Sprite, &mut Alive)>は、「SpriteとAliveの両方のコンポーネントを持つエンティティ」を検索します
trigger.event_target():クリックされたエンティティのIDを取得cells.get_mut(entity):特定のエンティティIDからコンポーネントを取得
Pickableコンポーネントの追加 & オブザーバーの登録
クリック可能にするために、セルにPickableコンポーネントを追加します。spawn_board関数のセル生成部分を以下のように更新します。
commands
.spawn((
...,
Pickable::default(), // クリック可能にする
))
.observe(handle_cell_click); // クリックイベントのオブザーバーを登録
Pickable::default()を追加することで、このエンティティがクリック可能になります。また、.observe(handle_cell_click)で、クリックイベントを処理するオブザーバーを登録します。
この状態でcargo runを実行すると、セルをクリックすることで生死を切り替えられるようになります。
セルの世代を進める機能
最後に、スペースキーを押すとライフゲームの世代を進める機能を実装します。この機能では、Bevyのメッセージングシステムを使用します。
Bevyのメッセージングシステムとは
Bevy 0.17では、システム間で通信するための新しい仕組みとしてメッセージングが導入されました。以下の特徴があります。
- イベントよりも軽量で高速
- システム間の疎結合を実現
- 1フレーム内でのみ有効(次フレームには持ち越されない)
今回は、キー入力を検知するシステムから、世代を進めるシステムへメッセージを送信します。
必要なインポートの追加
まず、std::collections::HashMapを使用するため、ファイルの先頭に以下を追加します。
use std::collections::HashMap;
NextGenerationEventの定義
次世代に進むイベントを表すメッセージを定義します。
#[derive(Message)]
struct NextGenerationEvent;
#[derive(Message)]を使用することで、この構造体をメッセージとして使用できるようになります。
キー入力処理の実装
スペースキーが押されたときにメッセージを送信するシステムを実装します。
fn handle_input_key(
keyboard: Res<ButtonInput<KeyCode>>,
mut next_gen_event: MessageWriter<NextGenerationEvent>,
) {
if keyboard.just_pressed(KeyCode::Space) {
next_gen_event.write(NextGenerationEvent);
}
}
keyboard: Res<ButtonInput<KeyCode>>:キーボード入力状態を取得mut next_gen_event: MessageWriter<NextGenerationEvent>:メッセージ送信用のライターjust_pressed(KeyCode::Space):スペースキーが押された瞬間を検知next_gen_event.write(NextGenerationEvent):メッセージを送信
隣接セルのカウント関数
ライフゲームのルールを適用するため、各セルの周囲8マスの生きたセルをカウントする関数を実装します。
fn count_neighbors(position: &Position, alive_cells: &HashMap<Position, Entity>) -> usize {
let mut count = 0;
// 8方向の隣接セルをチェック
for dy in -1..=1 {
for dx in -1..=1 {
if dx == 0 && dy == 0 {
continue; // 自分自身はスキップ
}
let nx = position.x + dx;
let ny = position.y + dy;
// 境界チェック:盤面外のセルは無視
if !(0..GRID_WIDTH).contains(&nx) || !(0..GRID_HEIGHT).contains(&ny) {
continue;
}
let neighbor_pos = Position { x: nx, y: ny };
if alive_cells.contains_key(&neighbor_pos) {
count += 1;
}
}
}
count
}
次世代の計算システム
最後に、ライフゲームのルールに基づいて次世代を計算するシステムを実装します。
fn next_generation(
mut next_gen_events: MessageReader<NextGenerationEvent>,
mut cells: Query<(Entity, &Position, &mut Alive, &mut Sprite)>,
) {
// NextGenerationEventが発火していない場合は何もしない
if next_gen_events.read().next().is_none() {
return;
}
// 現在生きているセルのマップを作成
let mut alive_cells = HashMap::new();
for (entity, position, alive, _) in cells.iter() {
if alive.0 {
alive_cells.insert(*position, entity);
}
}
// 各セルの次世代の状態を計算
let mut next_states = Vec::new();
for (entity, position, alive, _) in cells.iter() {
let neighbor_count = count_neighbors(position, &alive_cells);
// ライフゲームのルールに基づいて次の状態を決定
let should_be_alive = if alive.0 {
neighbor_count == 2 || neighbor_count == 3 // 生存条件
} else {
neighbor_count == 3 // 誕生条件
};
// 状態が変化するセルだけを記録
if should_be_alive != alive.0 {
next_states.push((entity, should_be_alive));
}
}
// 状態を更新
for (entity, should_be_alive) in next_states {
if let Ok((_, _, mut alive, mut sprite)) = cells.get_mut(entity) {
alive.0 = should_be_alive;
sprite.color = if should_be_alive {
Color::WHITE
} else {
Color::srgb(0.1, 0.1, 0.1)
};
}
}
}
mut next_gen_events: MessageReader<NextGenerationEvent>:メッセージ受信用のリーダーmut cells: Query<(Entity, &Position, &mut Alive, &mut Sprite)>:セルのエンティティとコンポーネントを取得next_gen_events.read().next().is_none():メッセージが発火しているかのチェック
main関数の更新
メッセージとシステムを登録するため、main関数を以下のように更新します。
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {...}))
.add_systems(Startup, (spawn_camera, spawn_board))
.add_systems(Update, (handle_input_key, next_generation)) // 新しいシステムを追加
.add_message::<NextGenerationEvent>() // メッセージの登録
.run();
}
add_systems(Update, ...):毎フレーム実行されるUpdateスケジュールに2つのシステムを追加add_message::<NextGenerationEvent>():メッセージシステムに登録
この状態でcargo runを実行し、スペースキーを押すと、グライダーパターンが1世代ずつ進み、斜め方向に移動していく様子が確認できます。
まとめ
お疲れ様でした!この記事では、Bevy Engineを使って、シンプルながら奥深いライフゲームを実装しました。
こちらをベースに、機能拡張に挑戦してみてください!
- 自動再生機能:タイマーを使って一定間隔で自動的に世代を進める
- 初期パターン集:ビーハイブ、ボート、パルサーなど様々なパターンを追加
- UI要素の追加:世代数カウンター、再生/停止ボタンなど
