Rust のゲーム作成フレームワーク Bevy を使って「2048」を作ります。シリーズ第一回の今回は、タイルを表示して動かせるようになるところまで解説します。
本記事で作成しているプログラムは GitHub で公開しています。
シリーズ記事
- 第一回: タイルを動かす(この記事です)
- 第二回: タイルを生成する
環境
- Windows 10 Home
- CPU: Intel(R) Core(TM) i5-6300U
- GPU: プロセッサ内蔵のやつ
- メモリ: 8.00 GB
- Rust: 1.58.1
- Bevy: 0.6.0
大層なグラボがなくても全然大丈夫なので試してみてください。普段は WSL 中の Ubuntu で開発しているんですが、グラフィックスとかオーディオの設定に手こずったので仕方なく Windows 内で作業することにしました。 WSL で作れた人は Twitter か何かでやり方教えてください。
はじめに
まずは Bevy 公式の Introduction を一読することをおすすめします。英語が大嫌いでもコードの部分だけ読めば何やってるかある程度分かると思います。
そもそも ECS って何?という方は以下の記事を読んでください。すべてが理解できます。
特に上記2つのサイトに書かれている概念や言葉についてはあんまり説明せずに使ったりするので、適宜参照してください。
また、公式サイトには実装例もいくつか載っているので是非見てみてください。
以下の2つは特におすすめです。
breakout
コードの総量が少ないので結構読みやすいです。シンプルながらいろいろと知らない機能をたくさん使っているので、ドキュメント片手に読み進めるとかなり勉強になると思います。
Snake Game
一ステップずつ丁寧に解説してくれる上、結構すぐにゲームっぽいものが手元で完成するのでやってて楽しいです。
Step 0. 導入
Linuxだといくつか必要なライブラリがあるので公式 Introduction に従って準備してください。クレートがそこそこ大きいので初回コンパイルは結構時間かかります。公式サイトにはコンパイルを高速化する方法も載ってるので興味のある人は試してみてください。
Step 1. ウィンドウを表示
ゲーム画面を表示しましょう。 DefaultPlugins
を追加するだけでポップアップされますが、せっかくなのでいくつか設定を追加します。
use bevy::prelude::*;
const WINDOW_SIZE: f32 = 500.0;
fn main() {
App::new()
.insert_resource(WindowDescriptor {
title: "2048".to_string(),
width: WINDOW_SIZE,
height: WINDOW_SIZE,
..Default::default()
})
.insert_resource(ClearColor(Color::rgb(0.04, 0.04, 0.04)))
.add_plugins(DefaultPlugins)
.add_system(bevy::input::system::exit_on_esc_system)
.run();
}
WindowDescriptor
ウィンドウの設定を行います。
ClearColor
背景色を変更します。デフォルトは結構明るめの灰色だったので黒っぽくしました。
DefaultPlugin
まず一番最初にこれを入れておきましょう。ゲームを作るにあたって絶対に必要になる機能をまとめて追加できます。
ただし WindowDescriptor
より先に設定してしまうと、 DefaultPlugins
内のウィンドウ設定が優先されてしまい自分で決めたサイズとかが適用されないので注意してください。
input::system::exit_on_esc_system
「Esc キーを押したらウィンドウを閉じる」 System です。ちゃんと作るときは自分で挙動を設定したい(「本当にやめますか?」みたいなメッセージを表示したり)ですが、作り初めの段階ではとりあえず入れておくと意外と便利です。
Plugin とは?
Plugin とは、同時に使用されることの多い System や Resource をひとまとめに導入する機能です。内部の実装は main()
内と同じように System や Resource を App に追加しているだけで、意外と単純です。
#[derive(Default)]
pub struct ScenePlugin;
impl Plugin for ScenePlugin {
fn build(&self, app: &mut App) {
app.add_asset::<DynamicScene>()
.add_asset::<Scene>()
.init_asset_loader::<SceneLoader>()
.init_resource::<SceneSpawner>()
.add_system_to_stage(
CoreStage::PreUpdate,
scene_spawner_system.exclusive_system().at_end(),
);
}
}
DefaultPlugins
をはじめとする Plugins は、複数の Plugin をさらにまとめて導入するためのものです。
実行
正方形の黒いウィンドウが表示されました⬛️
Escキーを押すと閉じます。
Step2. タイルを表示する
2-1. カメラの設定
まずはカメラを設定しましょう。今回は OrthographicCameraBundle
を使用します。
fn setup(mut commands: Commands) {
commands.spawn_bundle(OrthographicCameraBundle::new_2d());
}
fn main() {
# ...
.add_system(bevy::input::system::exit_on_esc_system)
.add_startup_system(setup)
.run();
}
Bundle とは?
Bundle とは、複数の Component をいっぺんに設定できる機能です。 Plugin の Component 版みたいなものです。
カメラには大きく分けて Orthographic Camera と Perspective Camera が存在しますが、それぞれ以下のようなイメージです。
Perspective Camera では遠くのものほど小さく映り、 Orthographic Camera では距離に関係なく同じサイズのものは常に同じ大きさに映ります。 2D のゲームであれば基本的には遠近感のない Orthographic Camera を使うことになるでしょう。
カメラは座標 $(0.0, 0.0, 999.9)$ にあり、原点を向いています。そのため、 z 座標でオブジェクト同士の重なり方を表現できます。
2-2. タイルを作る
タイルのスプライトを作成しましょう。 Entity を作り、 SpriteBundle
をアタッチすることで設定できます。
const TILE_SIZE: f32 = 60.0;
const SIDE_LENGTH: usize = 4;
#[derive(Component)]
struct Tile(u64);
#[derive(Debug, Clone, PartialEq, Eq, Component)]
struct Position {
x: i32,
y: i32,
}
impl Position {
fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
}
fn create_tile(commands: &mut Commands) {
let position = Position::new(0, 0);
commands
.spawn()
.insert(Tile(2))
.insert(position.clone())
.insert_bundle(SpriteBundle {
sprite: Sprite {
color: Color::ORANGE,
custom_size: Some(Vec2::new(TILE_SIZE, TILE_SIZE)),
..Default::default()
},
transform: Transform::from_xyz(0.0, 0.0, 10.0),
..Default::default()
});
}
今回は以下の2つの Component を理解していれば十分です。
Sprite
その名の通り、スプライトの情報を管理します。大きさや色、自前で用意した画像を表示するといった設定ができます。
最初に ClearColor
で設定したように、 RGB 値を指定して色を作成するのが基本です。ただし、基本的な色であれば const 値として色々と定義されているので、試作の段階では適当に試すことができて便利です。
impl Color {
pub const ALICE_BLUE: Color = Color::rgb(0.94, 0.97, 1.0);
pub const ANTIQUE_WHITE: Color = Color::rgb(0.98, 0.92, 0.84);
pub const AQUAMARINE: Color = Color::rgb(0.49, 1.0, 0.83);
pub const AZURE: Color = Color::rgb(0.94, 1.0, 1.0);
pub const BEIGE: Color = Color::rgb(0.96, 0.96, 0.86);
pub const BISQUE: Color = Color::rgb(1.0, 0.89, 0.77);
# ...
Transform
スプライトの位置や回転などを管理します。カメラのところで説明したように、原点は画面中央にあり、 z 座標が大きいものほど手前側に表示されます。このゲームではタイルより上側に何かが重なることがないので、 z 座標を大きめにしてみました。
さらに、独自に以下の2つの Component をアタッチします。自作の struct や enum を Component とする場合、 Component
トレイトを予め実装しておきます。(#[derive(Component)]
するだけで大丈夫です。)
Tile
その Entity がタイルであることを示すマーカーです。ついでにタイルに書かれる数字も持たせます。
Position
タイルの位置を2次元座標で持つ struct です。位置を表すのは Transform
なので今のところ Position
は何の意味もありませんが、次のステップで活躍します。
2-3. タイルを16枚作る
さて、「2048」は 4×4 の盤面で遊ぶゲームなので、タイルの位置も $(0,0)$ 〜 $(3,3)$ のように整数で指定したいです。しかし、 Transform
は f32
で指定しなければならない上、盤面の中心が原点になるようにするにはタイルの大きさの半分の値を足し引きする必要があり結構面倒くさいです。
// Position を引数で指定したいけど、
// 対応するTransformを計算するのは結構面倒
// タイルが動いてPositionが変わるたびにこれを計算しなきゃいけない
fn create_tile(commands: &mut Commands, num: u64, position: Position) {
let transform = Transform::from_xyz(
(position.x as f32 - (SIDE_LENGTH - 1) as f32 / 2.0) * TILE_SIZE,
(position.x as f32 - (SIDE_LENGTH - 1) as f32 / 2.0) * TILE_SIZE,
10.0,
)
commands
.spawn()
.insert(Tile(num))
.insert(position.clone())
.insert_bundle(SpriteBundle {
sprite: Sprite {
color: Color::ORANGE,
custom_size: Some(Vec2::new(TILE_SIZE, TILE_SIZE)),
..Default::default()
},
transform,
..Default::default()
});
}
そこで、 Transform
に From<Position>
を実装することで、自分では Position
の値を書き換えるだけで簡単にタイルの位置を指定できるようにします。この後に使う都合上、 z 座標を自分で決定できる関数も用意しておきます。
impl Position {
/// z座標を指定できる関数も用意しておく(後々のため)
fn to_transform(&self, z: f32) -> Transform {
let x = (self.x as f32 - (SIDE_LENGTH - 1) as f32 / 2.0) * TILE_SIZE;
let y = (self.y as f32 - (SIDE_LENGTH - 1) as f32 / 2.0) * TILE_SIZE;
Transform::from_xyz(x, y, z)
}
}
// タイル用の変換はこれを使えばOK
impl From<Position> for Transform {
fn from(pos: Position) -> Self {
pos.to_transform(10.0)
}
}
fn create_tile(commands: &mut Commands, num:u64, position: Position) {
commands
.spawn()
.insert(Tile(num))
.insert(position.clone())
.insert_bundle(SpriteBundle {
sprite: Sprite {
color: Color::ORANGE,
custom_size: Some(Vec2::new(TILE_SIZE, TILE_SIZE)),
..Default::default()
},
transform: position.into(), // 今後の変換が楽になる
..Default::default()
});
}
あとは16枚のタイルを順に作成するだけです。
fn setup(mut commands: Commands) {
commands.spawn_bundle(OrthographicCameraBundle::new_2d());
for i in 0..SIDE_LENGTH as i32 {
for j in 0..SIDE_LENGTH as i32 {
create_tile(&mut commands, 2, Position::new(i, j));
}
}
}
タイル同士がくっついちゃって見にくいので少し隙間を空けます。
impl Position {
fn to_transform(&self, z: f32) -> Transform {
let x = (self.x as f32 - (SIDE_LENGTH - 1) as f32 / 2.0) * (TILE_SIZE * 1.05); // 若干スペースを空ける
let y = (self.y as f32 - (SIDE_LENGTH - 1) as f32 / 2.0) * (TILE_SIZE * 1.05);
Transform::from_xyz(x, y, z)
}
}
良い感じですね!
2-4 背景を表示する
盤面の境界がわかりにくく味気ないので、タイルの裏側に白っぽい盤面を表示させましょう。大きめの四角いスプライトを1つと、タイルと同じ位置にスプライトを16個配置します。背景の動かないオブジェクトであることを示すため、 Background
という名前の空の struct をアタッチしておきます。また、動かす必要もないので Position
は与えなくてもよいです。ここでは背景なので、 z 座標を小さくするために <Translation as From<Position>>::from
ではなく Position::to_transform
で変換します。
#[derive(Component)]
struct Background;
fn create_board(commands: &mut Commands) {
// 大きな盤
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: Color::BEIGE,
custom_size: Some(Vec2::new(TILE_SIZE * 4.4, TILE_SIZE * 4.4)), // サイズは適当
..Default::default()
},
..Default::default()
})
.insert(Background);
// タイルの位置にある小さな四角
for i in 0..SIDE_LENGTH as i32 {
for j in 0..SIDE_LENGTH as i32 {
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: Color::GRAY,
custom_size: Some(Vec2::new(TILE_SIZE, TILE_SIZE)),
..Default::default()
},
transform: Position::new(i, j).to_transform(0.0),
..Default::default()
})
.insert(Background);
}
}
}
fn setup(mut commands: Commands) {
commands.spawn_bundle(OrthographicCameraBundle::new_2d());
create_board(&mut commands);
// 背景が見えるよういくつかのタイルは生成しないでみる
for i in 1..SIDE_LENGTH as i32 {
for j in 1..SIDE_LENGTH as i32 {
create_tile(&mut commands, 2, Position::new(i, j));
}
}
}
それっぽくなってきました。
Step3. タイルを動かす
キーボードの十字キーに反応してタイルを動かす機能を作りましょう。アニメーションとか考えるとかなり面倒くさいので、まずは一瞬で移動が完了するようにしてしまいます。ここでは、 Event という機能を使用します。
何らかの条件を満たしたとき(イベント発生時)のみ何かを実行しなければならないとき、
- 実行するべきかどうか判断して命令する関数
- 実際に実行する関数
を分けて実装します。
Reader は複数用意することができます。そのため、イベント発生時に行うべき処理が大量にあった場合、それぞれを別の System として切り分けることができるので非常に便利です。
3-1. Writer
まずは実行を判断する関数を作ります。上キーが押された場合に「上に移動」という命令を出力、と言った具合に十字キーの押下を察知させます。
#[derive(PartialEq, Eq)]
enum MoveEvent {
Left,
Right,
Up,
Down,
}
fn send_move_event(keyboard: Res<Input<KeyCode>>, mut ev_move: EventWriter<MoveEvent>) {
if keyboard.just_pressed(KeyCode::Left) {
ev_move.send(MoveEvent::Left)
} else if keyboard.just_pressed(KeyCode::Right) {
ev_move.send(MoveEvent::Right)
} else if keyboard.just_pressed(KeyCode::Up) {
ev_move.send(MoveEvent::Up)
} else if keyboard.just_pressed(KeyCode::Down) {
ev_move.send(MoveEvent::Down)
}
}
押されたキーに応じたイベントを送信するだけでOKです。引数には入力を受け取る Resource Res<Input<_>>
、そして新規イベントを書き込み可能な EventWriter<_>
を取ります。
3-2. Reader
続いては実際に実行する関数です。先ほど Writer が送信したイベントを EventReader<_>
で受信します。移動命令が溜まっていた場合、それを淡々とこなします。
タイルの位置の更新を行う必要がありますが、 Query
を使って必要な Component にのみアクセスしましょう。ここでは全タイルに対する位置情報を更新するので、 With<Tile>
の条件下で Position
と Transform
を可変で取得します。ここで使える追加条件は With
の他にも Without
とか Changed
とかがあって結構面白いです。(一覧: bevy::ecs::query)
ひとまず現状の盤面を取得する必要がありますが、なんとなく2次元配列じゃなく1次元で管理したくなったので、タイルの座標と index を対応させる関数を書きます。
impl Position {
fn index(&self) -> usize {
self.y as usize * SIDE_LENGTH + self.x as usize
}
fn from_index(i: usize) -> Self {
Self {
x: (i % SIDE_LENGTH) as i32,
y: (i / SIDE_LENGTH) as i32,
}
}
# ...
}
fn move_tiles_system(
mut _ev_move: EventReader<MoveEvent>,
mut query: Query<(&mut Transform, &mut Position), With<Tile>>,
) {
// 現在の盤面
// タイルがあれば 1
let mut map = vec![0; SIDE_LENGTH * SIDE_LENGTH];
for (_, pos) in query.iter() {
map[pos.index()] = 1;
}
}
これで現在の盤面を取得できました。
4方向の移動を実装するのが面倒なんですが、次のように実装することにしました。
こうすることで、左向きの移動だけ実装すればあとは移動させたい方向に応じて適当に回転させれば良くなります。
それでは個々のタイルについて移動距離を求めて Position
を更新しましょう。詳しい実装を説明するのがとても面倒くさいので好きに実装してください。16枚しかタイルがないので多少非効率的な方法で書いても問題ないと思います。 Transform
も併せて更新しないと描画位置に反映されないので注意してください。
fn move_tiles_system(
mut ev_move: EventReader<MoveEvent>,
mut query: Query<(&mut Transform, &mut Position), With<Tile>>,
) {
for ev in ev_move.iter() {
// 移動方向と左に何回転させるか
let (dx, dy, rot) = match ev {
MoveEvent::Left => (-1, 0, 0),
MoveEvent::Right => (1, 0, 2),
MoveEvent::Up => (0, 1, 3),
MoveEvent::Down => (0, -1, 1),
};
// 盤面取得
let mut map = vec![0; SIDE_LENGTH * SIDE_LENGTH];
for (_, pos) in query.iter() {
map[pos.index()] = 1;
}
// 盤面を回転
for _ in 0..rot {
rotate_map(&mut map);
}
// その位置にあるタイルが何マス左に動けるか
for i in 0..SIDE_LENGTH {
let mut v = 0;
for j in 0..SIDE_LENGTH {
v += 1 - map[i * SIDE_LENGTH + j];
map[i * SIDE_LENGTH + j] += v - 1;
}
}
// 盤面を元の向きに戻す
for _ in rot..4 {
rotate_map(&mut map);
}
// 移動方向と移動距離が分かってるので各タイルの位置を更新
for (mut trans, mut pos) in query.iter_mut() {
let idx = pos.index();
pos.x += dx * map[idx];
pos.y += dy * map[idx];
*trans = pos.clone().into();
}
}
}
// 盤面を左に90度回転
fn rotate_map(a: &mut [i32]) {
const ROT: [usize; SIDE_LENGTH * SIDE_LENGTH] =
[3, 7, 11, 15, 11, 6, 10, 14, 14, 10, 10, 13, 15, 13, 14, 15];
for (i, &j) in ROT.iter().enumerate() {
a.swap(i, j)
}
}
まだタイルの合成を考えていないので、この部分は後で結構書き直すことになりそうです。
ではこれらの System を App に追加して実行しましょう。タイルが動けるように盤面を埋めきらず、いくつかだけ生成するようにしておきます。
fn setup(mut commands: Commands) {
commands.spawn_bundle(OrthographicCameraBundle::new_2d());
create_board(&mut commands);
for (idx, num) in [(1, 2), (3, 2), (13, 2)] {
create_tile(&mut commands, num, Position::from_index(idx));
}
}
fn main() {
# ...
.add_startup_system(setup)
.add_event::<MoveEvent>()
.add_system(send_move_event)
.add_system(move_tiles_system)
.run();
}
十字キーを押すとちゃんとタイルが動きます!
3-3. タイルの色を変える
今のところタイルが全部同じ色なのでどのタイルがどこに移動したのか分かりにくいです。なのでタイルの数字によって色を変えましょう。 Tile(u64)
を Color
に変換する関数を書き、 create_tile
の中の Sprite
の設定を書き換えます。
impl From<Tile> for Color {
fn from(Tile(num): Tile) -> Self {
match num {
2 => Color::GOLD,
4 => Color::ORANGE,
8 => Color::ORANGE_RED,
_ => Color::RED, # とりあえずここまで
}
}
}
fn create_tile(commands: &mut Commands, num: u64, position: Position) {
# ...
.insert_bundle(SpriteBundle {
sprite: Sprite {
color: Color::from(Tile(num)), // 数字に応じた色にする
custom_size: Some(Vec2::new(TILE_SIZE, TILE_SIZE)),
..Default::default()
},
# ...
}
match 式中に数字と色の対応を書くことでタイルの色を自由に変更できます。
それでは初期生成するタイルの数字を全部変えた上で実行してみます。
fn setup(mut commands: Commands) {
commands.spawn_bundle(OrthographicCameraBundle::new_2d());
create_board(&mut commands);
for (idx, num) in [(1, 2), (3, 4), (13, 8)] {
create_tile(&mut commands, num, Position::from_index(idx));
}
}
どれがどう動いたのか分かりやすくなりましたね!思った通りに動いていることが確認できました。
おわり
ここまで読んでいただきありがとうございました。次回は移動するたびに新しいタイルをランダムに出現させる処理を書きます。