5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust製ゲームエンジンBevy 0.17で、ゼロから落ち物パズルを実装する

Posted at

これはKMC Advent Calendar 20259日目の記事です(2日遅刻)。

はじめに

はじめまして!KMC(京大マイコンサークル)49代のfurakutaです。サークル内でアドベントカレンダーをするということで今回初めてブログ記事を書きます。

この記事ではRust製のゲームエンジンであるBevyを初めて触る方に向けて、ゼロからテトリスライクの落ちものパズルゲームを実装する過程を通してBevyの基本的な機能の使い方を紹介していきます。なおRustの基本的な書き方は理解している前提で進めるのでRust自体初めての方はRust公式のチュートリアル等を参考にしてください。

この記事の実装すべてを終わらせると以下のようなテトリスライクのゲームができます。

スクリーンショット 2025-12-11 185106.png

Bevyとは

BevyはRustで書かれたオープンソースのシンプルなデータ志向のゲームエンジンであり、以下のような特徴を持ちます。

  • Rustで書かれていることによるメモリ安全性と高速性、強力な型システム
  • データ(Component)と処理を分離して管理するEntity Component System(ECS)
  • 2Dと3Dの両方に対応
  • WASMも含めた広いクロスプラットフォーム性
  • コミュニティ主導のエコシステム

Bevyはまだ開発途上であり、数か月ごとのメジャーアップデートではAPIの破壊的変更がもたらされることもしばしばあります。そのため公式も大規模な開発に用いるのであればGodot Enginなどの他のオープンソースエンジンを使用することを推奨しています。GodotでもRustでスクリプトを書けるようにするためのバインディング(godot-rust)が存在するものの、どうせならバインディングではなくて内部までRust製のエンジンを使いたくなります。

Rust製のゲームエンジンの中ではBevyが最も有力ではあるものの、それでも学習リソースが非常に限られています。主な学習リソースとしてはBevy公式のExamplesやサードパーティーのライブラリ(プラグイン)のGitHub等にあるexamples、非公式のチートブックなどが存在しますが、基本的に英語です。公式のExamplesは体系的とは言い難く、チートブックも参考にはなるものの最新のAPIに対応していない場合が良くあります。

日本語でかつ新しめのAPIに対応しているチュートリアル的記事、つまり直接写すだけで手元に動くゲームを作れるような記事は少しずつ増えているもののまだまだ少なすぎると勉強している際に感じました。そのため私はBevyの勉強を始めたばかりですが、少しでも初心者の学習の助けとなるようにこの記事を書こうと思いました。

ウィンドウを表示する

設計的な話はいったん置いておいて、まずはウィンドウを表示するところから始めましょう。まずはcargo newでプロジェクトを作成してからCargo.tomlsrc/main.rsを以下のように編集してください。

Cargo.toml
[dependencies]
# ここから追記
bevy = "0.17.3"
bevy-inspector-egui = "0.35.0"
rand = "0.9.2"

# dev profileでは最適化を少なくする
[profile.dev]
opt-level = 1

# dev profileでも外部のライブラリの最適化は多くする
[profile.dev.package."*"]
opt-level = 3
src/main.rs
use bevy::prelude::*;
// デバッグツール(インスペクター)を使うためのインポート
// 開発効率が劇的に上がるため、最初に入れておきます
use bevy_inspector_egui::{bevy_egui::EguiPlugin, quick::WorldInspectorPlugin};

fn main() {
    App::new()
        // 1. Bevyの基本機能(ウィンドウ表示、入力、レンダリングなど)を追加
        .add_plugins(DefaultPlugins)
        
        // 2. デバッグ用プラグインの追加
        // EguiPlugin: GUI描画の基盤
        .add_plugins(EguiPlugin::default())
        // WorldInspectorPlugin: ゲーム内のEntityやComponentを可視化するツール
        .add_plugins(WorldInspectorPlugin::new())

        // 3. 起動時に一度だけ実行するシステム(セットアップ)を登録
        .add_systems(Startup, setup)
        
        // アプリケーションを実行
        .run();
}

// 初期設定を行うシステム
fn setup(
    // コマンドバッファ(エンティティ生成や削除を行うための機能)を受け取る
    mut commands: Commands
) {
    // 2Dゲーム用のカメラを生成
    // これをSpawn(生成)しないと画面には何も映りません
    commands.spawn((
        Name::new("Main Camera"),
        Camera2d
    ));
}

Cargo.tomlでは以下のライブラリ(クレート)を依存関係に加えています

  • bevy : ゲームエンジン本体
  • bevy-inspector-egui : 実行中にGUIでパラメータをのぞいたり変更したりすることができるInspectorを追加します
  • rand : 乱数生成用。今回は次に落ちてくるミノをランダムに決定するために使用

また[profile.dev]以下では、Rustのコンパイラが実行する最適化の度合いを落とすことでコンパイルを早くするための設定を行っています。ゲーム開発においてコンパイルによる待ち時間はかなりイライラするものなので、このような設定が推奨されます。さらなる高速化の方法については、公式のこのページで詳しく紹介されています。

それぞれの部分が何をしているかの詳細はコメントアウトで説明しているので、次の章でBevyの基本構造であるAppEntitySystemComponentについて説明します。ただ初回のコンパイルにかなり時間がかかるので先にcargo runを実行しておきましょう。

実行して以下の画像のように、灰色の背景の上にInspectorが表示されていたらうまくいっています。

スクリーンショット 2025-12-09 175603.png

Bevyの基本構造:AppとECS

先ほどのコードで登場したAppsetupといった要素が、Bevyの中でどのような役割を持っているのかを整理しましょう。なおこの章の内容は公式のチュートリアルの以下の部分を参考にしています。

App

Bevyアプリケーションの中心的な構造体であり、ゲームのライフサイクル全体を管理します。Appは以下のような役割を持ちます。

  • worldフィールド内にゲーム全体の状態を保持
    • Resource(グローバルに一つだけ存在するデータ)を格納
    • EntityComponentworld内に格納される
    • insert_resource()メソッド等により、Resourceを追加可能
    • world内のEntityComponentにはCommandsを通じてアクセス可能(実は直接アクセスする方法もあるが特殊な場合のみ推奨される)
  • scheduleフィールド内に実行順序も含めてSystemを管理
    • SystemResourceEntityComponentにアクセスしてゲームの状態を変更する関数
    • add_systems()メソッドにより、Systemを追加可能
  • runnerフィールドはscheduleを実行する役割
  • add_plugins()メソッドにより、外部で定義された機能群をまとめて追加可能
  • run()メソッドにより、アプリケーションの実行を開始
App::new()
    .add_plugins(DefaultPlugins) // 機能ブロックを追加
    .add_systems(Startup, setup) // ロジックを追加
    .run();                      // 実行!

Bevyアプリは、空の箱(App)を作成し、そこに状態やシステム(ロジック)を登録していく「ビルダーパターン」で構築されます。 DefaultPluginsを追加するだけでウィンドウが表示されたのは、このプラグインの中に「ウィンドウ管理」「描画エンジン」「入力処理」といったゲームに必要な機能セットがすべてパッケージングされているからです。

ECS(Entity Component System)

Bevyの最大の特徴であり、少し馴染みがないかもしれないのが ECS (Entity Component System) というアーキテクチャです。 これは従来のオブジェクト指向(クラスの継承)とは異なり、「データ」と「処理」を完全に切り離す考え方です。

ECSは以下の3つの要素で構成されています。

Entity

ゲーム内に存在するあらゆるモノ(プレイヤー、敵、カメラなど)を表す概念です。 しかし、ECSにおけるEntityの実体は、単なる「ID番号(u64)」に過ぎません。それ自体はデータも振る舞いも持たない、ただの識別子です。

Component

Entityに紐づけられるデータです。 今回のコードでは、カメラを作るために以下のコンポーネントを使いました。

Camera2d : 「これは2Dカメラですよ」という印

Name : 「Main Camera」という名前データ。Inspectorで見やすくするために付けました

「ID番号1(適当)のEntity」に「Camera2d」というComponentを付けることで、初めてそのIDがカメラとして扱われるようになります。

Componentを自作する際は以下のように#[derive(Component)]を付けて構造体や列挙型を定義します。

#[derive(Component)]
struct FrozenBlock{
    pos: IVec2,
}

ちなみにクエリで検索するのを助けるために付与されるマーカーコンポーネント(データを持たないコンポーネント)もよく使われます。

System

実際に処理を行う関数です。 システムはCommandsQuery<>Res<>などのシステムパラメータと呼ばれる特殊な引数を受け取ります。 これらのシステムパラメータを通じて、EntityComponentResourceにアクセスし、ゲームの状態を変更します。

CommandsEntityの生成や削除を行うための機能を提供します。 例えば、先ほどのsetup関数ではcommands.spawn()を使ってカメラ用のEntityを生成しました。

Query<>は特定のComponentを検索するための機能を提供します。 Query<&ComponentType>のように使用し、指定したComponentTypeに対する参照を取得します。Query<&mut ComponentType>のように&mutを使うと可変参照を取得できます。

そのままではEntityを取得できないので、Query<(Entity, &ComponentType)>のようにタプルでEntityを追加で指定することも可能です。他のコンポーネントも同様にタプルで複数指定できます。

Res<>Resourceにアクセスするための機能を提供します。 Res<ResourceType>のように使用し、指定したResourceTypeに対する参照を取得します。ResMut<ResourceType>のようにMutを使うと可変参照を取得できます。

以下に、実際にシステムを定義する例を示します。

fn fall_mino(
    time: Res<Time>,
    mut block_timer: ResMut<BlockTimer>,
    // コントロール中のブロックのTransformとControllingBlockコンポーネントを取得
    // &mutで可変参照を取得
    mut control_block: Query<(&mut Transform, &mut ControllingBlock)>,
    board: Res<Board>,
    keys: Res<ButtonInput<KeyCode>>,
    mut game_score: ResMut<GameScore>,
){
    // ブロックが落下可能かどうかをチェック
    // Queryで取得した全てのブロックについてイテレータを用いて確認
    let can_fall = control_block.iter().all(|(_, block)| {
        let new_pos = block.peek_parrallel_move(IVec2::new(0, -1));
        board.is_position_valid(new_pos)
    });

    // この部分の詳細は後述
    if !can_fall && !block_timer.block_landed {
        block_timer.block_has_landed();
    }else if can_fall && block_timer.block_landed {
        block_timer.block_reset();
    }
    if !can_fall {
        return;
    }

    let delta = if keys.pressed(KeyCode::ArrowDown) || keys.pressed(KeyCode::KeyS) {
        // ソフトドロップ
        game_score.score += 1;
        time.delta_secs() * 20.0
    } else {
        time.delta_secs()
    };
    if !block_timer.should_fall(delta) {
        return;
    }

    // もちろんfor文でもOK
    for (mut transform, mut block) in control_block.iter_mut() {
        block.parrallel_move(IVec2::new(0, -1));
        transform.translation = grid_to_world_position(block.get_board_position());
    }
}

Gemini_Generated_Image_14ft8114ft8114ft.png

背景の初期化

次に、フィールドの背景を初期化してみましょう。main.rsを以下のように書き換えてください。

use bevy::prelude::*;
use bevy_inspector_egui::{bevy_egui::EguiPlugin, quick::WorldInspectorPlugin};

// ブロックのサイズとフィールドの行数・列数を定義
const UNIT: f32 = 35.0;
const ROWS: usize = 20;
const COLUMS: usize = 10;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(EguiPlugin::default())
        .add_plugins(WorldInspectorPlugin::new())
        .add_systems(Startup, setup)
        .run();
}

// 初期設定を行うシステム
fn setup(
    // コマンドバッファ(エンティティ生成や削除を行うための機能)を受け取る
    mut commands: Commands,
    // メッシュとマテリアルのアセットを受け取る
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    // 2Dゲーム用のカメラを生成
    commands.spawn((
        Name::new("Main Camera"),
        Camera2d
    ));

    // フィールド用の長方形を生成
    commands.spawn((
        Name::new("Field Background"),
        // 幅は列数×ブロックサイズ、高さは行数×ブロックサイズ
        Mesh2d(meshes.add(Rectangle::new(COLUMS as f32 * UNIT, ROWS as f32 * UNIT))),
        MeshMaterial2d(materials.add(Color::srgb_u8(231, 226, 213))),
        // フィールドの中心に配置
        // z座標を-1.0にして奥の方に配置
        Transform::from_xyz(0.0, 0.0, -1.0),
    ));
}

スクリーンショット 2025-12-10 171915.png

長方形を表示するためだけにすごく大変そうなことをしていそうですが、これは主にBevyのAssetsシステムによるものです。Assetsシステムは、ゲーム内で使用する様々なリソース(メッシュ、テクスチャ、音声など)を効率的に管理するための仕組みです。Assets<T>は特定の型Tのアセットを格納するコンテナであり、ResMut<Assets<T>>としてシステムに渡すことで、その型のアセットを追加・取得・削除することができます。

基本的には以下の流れでアセットを扱います。

  • systemの引数としてResMut<Assets<T>>等を受け取る
  • Assets<T>::add()AssetServer::load()を用いてアセットを追加し、戻り値としてHandle<T>を取得
  • Handle<T>Mesh2dMeshMaterial2dなどの対応したコンポーネントにフィールドとして登録し、そのコンポーネントをエンティティに付与して使用

このゲームでは画像を用いていませんが、公式のexampleでの画像の使用例を見ると以下のようになります。

fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(Camera2d);

    commands.spawn(Sprite::from_image(
        asset_server.load("branding/bevy_bird_dark.png"),
    ));
}

同じアセットを用いる場合は再度asset_server.load()を呼び出すのではなく、最初に取得したHandle<T>を保存しておきましょう。
以下はBoardにグリッドの線を描画するためのコードですが、Handle<ColorMaterial>Handle<Mesh>を保存しておき、複数のエンティティでクローンして使っています。

fn main
let grid_line_material = materials.add(Color::srgb_u8(221, 213, 193));
let row_line_mesh = meshes.add(Rectangle::new((COLUMS as f32 + 0.1) * UNIT, 0.1* UNIT));
let col_line_mesh = meshes.add(Rectangle::new(0.1 * UNIT, (ROWS as f32 + 0.1) * UNIT));
for row in 0..=ROWS {
    commands.spawn((
        Mesh2d(row_line_mesh.clone()),
        MeshMaterial2d(grid_line_material.clone()),
        Transform::from_translation(Vec3::new(0.0, - (ROWS as f32 / 2.0) * UNIT + row as f32 * UNIT, -0.5)),
    ));
}
for col in 0..=COLUMS {
    commands.spawn((
        Mesh2d(col_line_mesh.clone()),
        MeshMaterial2d(grid_line_material.clone()),
        Transform::from_translation(Vec3::new(- (COLUMS as f32 / 2.0) * UNIT + col as f32 * UNIT, 0.0, -0.5)),
    ));
}

スクリーンショット 2025-12-11 130851.png

またResourceHandle<T>を保存しておくこともできます。以下のコードはミノの色やメッシュをまとめて保存しておくためのResourceです。

use bevy::{
    // Color::srgb()等で直接色を指定することもできるが、カラーパレットも用意されているので今回はそちらを使用
    color::palettes::{basic::*, css::{DARK_BLUE, LIGHT_BLUE, ORANGE}}, 
    // ハッシュをぶつける攻撃には弱いが高速なHashMapを使用
    platform::collections::HashMap, 
    prelude::*
};

// ミノの種類を表す列挙型
#[derive(Component, Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum Mino{
    O,
    T,
    S,
    Z,
    L,
    J,
    I
}

// ミノの色とメッシュのHandleをまとめて保存するResource
// Defaultトレイトを実装しておくと、Appに追加する際に便利
#[derive(Resource, Default)]
struct MinoHandles{
    colors:HashMap<Mino, Handle<ColorMaterial>>,
    mesh: Handle<Mesh>,
}
fn main
App::new()
    .add_plugins(DefaultPlugins)
    .add_plugins(EguiPlugin::default())
    .add_plugins(WorldInspectorPlugin::new())
    // Defaultトレイトを実装しているので
    // .init_resource::<MinoHandles>()
    // でも良い
+   .insert_resource(MinoHandles::default())
    .add_systems(Startup, setup)
    .run();
fn setup(
    // コマンドバッファ(エンティティ生成や削除を行うための機能)を受け取る
    mut commands: Commands,
    // メッシュとマテリアルのアセットを受け取る
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
    mut mino_handles: ResMut<MinoHandles>,
) {
    // (中略)
    // ミノ用のHandle<ColorMaterial>をMinoHandlesに保存
    let mut map = HashMap::new();
    map.insert(Mino::O, materials.add(Color::from(YELLOW)));
    map.insert(Mino::T, materials.add(Color::from(PURPLE)));
    map.insert(Mino::S, materials.add(Color::from(GREEN)));
    map.insert(Mino::Z, materials.add(Color::from(RED)));
    map.insert(Mino::L, materials.add(Color::from(ORANGE)));
    map.insert(Mino::J, materials.add(Color::from(DARK_BLUE)));
    map.insert(Mino::I, materials.add(Color::from(LIGHT_BLUE)));
    mino_handles.colors = map;

    // ミノ用のメッシュをMinoHandlesに保存
    mino_handles.mesh = meshes.add(Rectangle::new(UNIT * 0.9, UNIT * 0.9));

}

ブロックをスポーンさせる

次にこの保存されたミノの情報を使って、実際にブロックをスポーンさせてみましょう。ですがまず、ミノの形状データを定義する必要があります。

Tetris Channnelさんの記事を参考に、ミノの形状データを回転とともに以下のように定義します。

// ミノの回転状態を表す列挙型
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)]
enum Rotation{
    North,
    East,
    South,
    West,
}

impl Rotation {
    fn rotate_right(&self) -> Rotation {
        match self {
            Rotation::North => Rotation::East,
            Rotation::East => Rotation::South,
            Rotation::South => Rotation::West,
            Rotation::West => Rotation::North,
        }
    }
    fn rotate_left(&self) -> Rotation {
        match self {
            Rotation::North => Rotation::West,
            Rotation::West => Rotation::South,
            Rotation::South => Rotation::East,
            Rotation::East => Rotation::North,
        }
    }
}

impl Mino{
    // ミノの基本形状を定義
    // ミノは4つのブロックから構成されるため、IVec2の配列で表現
    // Iミノ以外は(0, 0)が回転の中心
    fn shape(&self) -> [IVec2; 4] {
        match self {
            // □□
            // ■□
            Mino::O => [IVec2::new(0,0), IVec2::new(1,0), IVec2::new(0,1), IVec2::new(1,1)],
            //  □
            // □■□
            Mino::T => [IVec2::new(-1,0), IVec2::new(0,0), IVec2::new(1,0), IVec2::new(0,1)],
            //  □□
            // □■
            Mino::S => [IVec2::new(-1,0), IVec2::new(0,0), IVec2::new(0,1), IVec2::new(1,1)],
            // □□
            //  ■□
            Mino::Z => [IVec2::new(-1,1), IVec2::new(0,1), IVec2::new(0,0), IVec2::new(1,0)],
            //   □
            // □■□
            Mino::L => [IVec2::new(-1,0), IVec2::new(0,0), IVec2::new(1,0), IVec2::new(1,1)],
            // □
            // □■□
            Mino::J => [IVec2::new(-1,1), IVec2::new(-1,0), IVec2::new(0,0), IVec2::new(1,0)],
            // □□□□
            Mino::I => [IVec2::new(-2,0), IVec2::new(-1,0), IVec2::new(0,0), IVec2::new(1,0)],
        }
    }
    fn get_rotated_shape(&self, rotation: &Rotation) -> [IVec2; 4] {
        let shape = self.shape();
        // Oミノは回転しない
        if *self == Mino::O {
            return shape;
        }else if *self == Mino::I {
            // Iミノの回転処理
            return match rotation {
                Rotation::North => shape,
                Rotation::East => shape.map(|pos| IVec2::new(0, -pos.x - 1)),
                Rotation::South => shape.map(|pos| IVec2::new(-pos.x - 1, -1)),
                Rotation::West => shape.map(|pos| IVec2::new(-1, pos.x)),
            }
        }
        // その他のミノの回転処理
        // それぞれの回転に対応する変換を適用
        match rotation {
            Rotation::North => shape,
            Rotation::East => shape.map(|pos| IVec2::new(pos.y, -pos.x)),
            Rotation::South => shape.map(|pos| IVec2::new(-pos.x, -pos.y)),
            Rotation::West => shape.map(|pos| IVec2::new(-pos.y, pos.x)),
        }
    }
}

スクリーンショット 2025-12-11 140705.png

これで実行すると、以下の画像のように上にはみ出していますが最初のミノがスポーンしていることが確認できます。このコードはあくまでテスト用なのでこのコードは削除して、次はランダムにミノをスポーンさせる処理を実装していきます。

ミノをランダムにスポーンさせる

ミノをスポーンさせていくのですが、ゲーム終了後もスポーンし続けてほしくはないのでゲームの状態を管理するStateを導入します。

#[derive(States, Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
enum GameState{
    #[default]
    Playing,
    GameOver
}

StateAppへと追加する際に.init_state::<GameState>()のようにして追加します。Stateを追加すると、in_state(GameState::Playing)のようにしてシステムの実行条件として使用できるようになるほかStateScopedコンポーネントを使ってStateが変化した時にEntityを自動的に削除することもできます。

ここではPlaying状態のときのみミノをスポーンさせる等のシステムが実行されるようにします。

次に、次に落ちてくるミノを管理するキューを作成します。

use std::collections::VecDeque;

const NEXT_BLOCKS_CAPACITY: usize = 5;

#[derive(Resource)]
struct UpcomingMinoQueue(VecDeque<Mino>);

そして、ミノをスポーンさせるシステムを実装します。

use rand::seq::IndexedRandom;

fn spawn_mino(
    mut commands: Commands,
    mino_handles: Res<MinoHandles>,
    mut next_blocks: ResMut<UpcomingMinoQueue>,
    control_block: Query<&ControllingBlock>,
    // board: Res<Board>, // 後で実装
    mut next_state: ResMut<NextState<GameState>>,
    // mut holded_mino: ResMut<HoldedMino>, // 後で実装
    // mut block_timer: ResMut<BlockTimer>, // 後で実装
    // time: Res<Time<Virtual>> // 後で実装
){
    // NEXT_BLOCKS_CAPACITYまでブロックを補充
    let all_blocks = [Mino::O, Mino::T, Mino::S, Mino::Z, Mino::L, Mino::J, Mino::I];
    let mut rng = rand::rng();
    while next_blocks.0.len() < NEXT_BLOCKS_CAPACITY {
        // ランダムにミノを選んでキューに追加
        if let Some(&block) = all_blocks.choose(&mut rng) {
            next_blocks.0.push_back(block);
        }
    }

    // 次のブロックを取り出してスポーン
    // 現在コントロール中のブロックがない場合のみスポーン
    if control_block.is_empty() && let Some(mino) = next_blocks.0.pop_front() {
        info!("Spawning mino: {:?}", mino);
        let material = mino_handles.colors.get(&mino).unwrap().clone();
        
        // 本来はここでゲームオーバー判定を行うが、Board実装後に実装

        for i in 0..4 {
            let block = ControllingBlock{
                kind: mino,
                rotation: Rotation::North,
                index_in_mino: i,
                // フィールドの上部中央にスポーンさせる
                pivot_pos: IVec2::new(COLUMS as i32 / 2, ROWS as i32 - 1),
            };
            
            // 本来はここで配置可能か判定を行うが、Board実装後に実装

            let world_pos = grid_to_world_position(block.get_board_position());
            commands.spawn((
                Name::new(format!("Mino {:?} - Block {}", mino, i)),
                Mesh2d(mino_handles.mesh.clone()),
                MeshMaterial2d(material.clone()),
                Transform::from_translation(world_pos),
                block,
            ));
        }
        // ホールド可能にする処理やタイマーのリセット処理もここに入る
    }
}

最後にmain関数を更新して、これらのリソースとシステムを登録します。

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(EguiPlugin::default())
        .add_plugins(WorldInspectorPlugin::new())
        .insert_resource(MinoHandles::default())
        // VecDequeを初期化して登録
        .insert_resource(UpcomingMinoQueue(VecDeque::with_capacity(NEXT_BLOCKS_CAPACITY)))
        .init_state::<GameState>()
        .add_systems(Startup, setup)
        // Playing状態のときのみspawn_minoを実行
        .add_systems(Update, spawn_mino.run_if(in_state(GameState::Playing)))
        .run();
}

これで実行すると、ミノがスポーンするはずです。ただし、まだ落下処理を実装していないので、スポーンしたミノはその場にとどまり、次のミノはスポーンしません(control_blockが空にならないため)。
あとテトリス本来の仕様ではすべての種類のミノが1回ずつ出た後にまた1回づつ出るという方式らしいのですが、ここではランダムにスポーンさせる方式としています。

Boardを実装する

テトリスでは、すでに配置されたブロックや壁との衝突判定を行う必要があります。そのために、フィールドの状態を管理するBoardリソースを実装します。

#[derive(Resource)]
struct Board([[Option<Entity>; COLUMS]; ROWS + 3]);

impl Board {
    // 指定された位置が有効かどうか(壁や他のブロックと重なっていないか)を判定
    // 上には3つまではみ出られるようにする(回転時や置いたときにBoard内にブロック1つでも入っていればゲームオーバーとはならないから)
    fn is_position_valid(&self, pos: IVec2) -> bool {
        // フィールドの範囲外なら無効
        if pos.x < 0 || pos.x >= COLUMS as i32 || pos.y < 0 || pos.y >= (ROWS + 3) as i32 {
            return false;
        }
        // すでにブロックがあるなら無効
        self.0[pos.y as usize][pos.x as usize].is_none()
    }
    // ゲームオーバー判定用など、厳密にフィールド内に収まっているかを判定
    fn is_position_rigidly_valid(&self, pos: IVec2) -> bool {
        if pos.x < 0 || pos.x >= COLUMS as i32 || pos.y < 0 || pos.y >= ROWS as i32 {
            return false;
        }
        self.0[pos.y as usize][pos.x as usize].is_none()
    }
}

BoardOption<Entity>の2次元配列で、ブロックが存在する場所にはそのEntityが入ります。

main関数でBoardを初期化します。

fn main() {
    App::new()
        // ...
        .insert_resource(Board([[None; COLUMS]; ROWS + 3]))
        // ...
        .run();
}

そして、spawn_mino関数でスポーン位置が有効かどうかをチェックするように修正します。

fn spawn_mino(
    mut commands: Commands,
    mino_handles: Res<MinoHandles>,
    mut next_blocks: ResMut<UpcomingMinoQueue>,
    control_block: Query<&ControllingBlock>,
    board: Res<Board>, // 追加
    mut next_state: ResMut<NextState<GameState>>,
    // ...
){
    // ...
    if control_block.is_empty() && let Some(mino) = next_blocks.0.pop_front() {
        // ...
        for i in 0..4 {
            let block = ControllingBlock{
                // ...
            };
            // 配置可能かチェック
            if !board.is_position_valid(block.get_board_position()) {
                info!("Game Over!");
                next_state.set(GameState::GameOver);
                return;
            }
            // ...
        }
        // ...
    }
}

ブロックを落下させる

ブロックを一定時間ごとに落下させるために、タイマーを管理するBlockTimerリソースを実装します。BevyにはTimerというタイマー用の構造体がありますが、ここではレベルにより落下する間隔が変化してしまうことなどから独自に実装します。

#[derive(Resource)]
struct BlockTimer{
    level: u32,
    fall_timer: f32,
    block_landed: bool,
    landed_or_last_operated_time_secs: f32,
    extended_counter: u32,
    min_y: u32,
    is_hard_dropped: bool
}

impl BlockTimer {
    fn new() -> Self {
        BlockTimer {
            level: 1,
            fall_timer: 1.0,
            block_landed: false,
            landed_or_last_operated_time_secs: 0.0,
            extended_counter: 0,
            min_y: ROWS as u32,
            is_hard_dropped: true
        }
    }
    fn new_mino(&mut self, time_secs: f32) {
        self.block_landed = false;
        self.landed_or_last_operated_time_secs = time_secs;
        self.extended_counter = 0;
        self.is_hard_dropped = false;
        self.min_y = ROWS as u32;
    }
    // レベルに応じて落下間隔を計算
    fn falling_interval(&self) -> f32 {
        3.0 / (5 + self.level) as f32
    }
    // 落下タイマーを更新し、落下すべきかどうかを返す
    fn try_fall(&mut self, delta: f32, min_y: u32) -> bool {
        self.fall_timer -= delta;
        if self.fall_timer <= 0.0 {
            self.fall_timer += self.falling_interval();
            // 落下した場合は、最低高度を更新し、固定までの猶予をリセット
            if self.min_y > min_y {
                self.extended_counter = 0;
                self.min_y = min_y;
            }
            true
        } else {
            false
        }
    }
    fn block_has_landed(&mut self, time_secs: f32) {
        self.block_landed = true;
        self.landed_or_last_operated_time_secs = time_secs;
    }
    fn hard_drop(&mut self) {
        self.block_landed = true;
        self.is_hard_dropped = true;
    }
    fn not_landed(&mut self) {
        self.block_landed = false;
    }
    // 凍結(固定)すべきかどうかを判定
    fn should_freeze(&mut self, time_secs: f32) -> bool {
        if !self.block_landed {
            return false;
        }
        // ハードドロップされたか、操作回数が上限を超えた場合は即座に固定
        if self.is_hard_dropped || self.extended_counter >= 15 {
            return true;
        }
        // 接地してから一定時間経過したら固定
        self.landed_or_last_operated_time_secs + 0.5 < time_secs
    }
    // 操作が行われた場合に固定までの時間を延長
    fn extend_freeze_time(&mut self, time_secs: f32) {
        self.extended_counter += 1;
        self.landed_or_last_operated_time_secs = time_secs;
    }
}

そして、fall_minoシステムを実装します。

fn fall_mino(
    time: Res<Time>,
    mut block_timer: ResMut<BlockTimer>,
    mut control_block: Query<(&mut Transform, &mut ControllingBlock)>,
    board: Res<Board>,
    keys: Res<ButtonInput<KeyCode>>,
    // mut game_score: ResMut<GameScore>, // 後で実装
){
    if control_block.is_empty() {
        return;
    }
    // 落下可能かチェック
    // すべてのブロックについて、一つ下に移動した位置が有効かどうかを確認
    let can_fall = control_block.iter().all(|(_, block)| {
        let new_pos = block.peek_paralell_move(IVec2::new(0, -1));
        board.is_position_valid(new_pos)
    });

    // 接地状態の更新
    if !can_fall && !block_timer.block_landed {
        block_timer.block_has_landed(time.elapsed_secs());
    }else if can_fall && block_timer.block_landed {
        block_timer.not_landed();
    }

    if !can_fall {
        return;
    }

    // ソフトドロップ(下キーまたはSキー)
    let delta = if keys.pressed(KeyCode::ArrowDown) || keys.pressed(KeyCode::KeyS) {
        // game_score.score += 1; // 後で実装
        time.delta_secs() * 20.0
    } else {
        time.delta_secs()
    };

    // 現在のブロックの最低高度を取得
    let min_y = control_block.iter().map(|(_, block)|
        block.peek_paralell_move(IVec2::new(0, -1)).y
    ).min().unwrap();

    // 落下タイマーを更新し、落下すべきでない場合はリターン
    if !block_timer.try_fall(delta, min_y as u32) {
        return;
    }

    // 実際に移動
    for (mut transform, mut block) in control_block.iter_mut() {
        block.paralell_move(IVec2::new(0, -1));
        transform.translation = grid_to_world_position(block.get_board_position());
    }
}

main関数でBlockTimerを初期化し、fall_minoシステムを登録します。

fn main() {
    App::new()
        // ...
        .insert_resource(BlockTimer::new())
        // ...
        .add_systems(Update, (
            (spawn_mino, fall_mino).run_if(in_state(GameState::Playing)),
        ))
        // ...
        .run();
}

ブロックを移動させる(簡易版)

キー入力に応じてブロックを左右に移動させたり、回転させたりする処理を実装します。まずは長押しや壁蹴り(SRS)などの複雑な処理は省略して、基本的な移動のみを実装します。

fn move_mino(
    keys: Res<ButtonInput<KeyCode>>,
    mut control_block: Query<(&mut Transform, &mut ControllingBlock)>,
    board: Res<Board>,
    mut block_timer: ResMut<BlockTimer>,
    // mut game_score: ResMut<GameScore>, // 後で実装
    // mut lateral_move_timer: ResMut<LateralMoveTimer>, // 後で実装
    time: Res<Time>
){
    let mut is_operated = false;
    
    // 横移動
    let mut lateral_delta = IVec2::new(0, 0);
    if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::KeyD) {
        lateral_delta = IVec2::new(1, 0);
    } else if keys.just_pressed(KeyCode::ArrowLeft) || keys.just_pressed(KeyCode::KeyA) {
        lateral_delta = IVec2::new(-1, 0);
    }

    // 移動先が有効なら移動
    if lateral_delta != IVec2::new(0, 0) 
        && control_block.iter().all(|(_, block)| board.is_position_valid(block.peek_paralell_move(lateral_delta))) {
        for (mut transform, mut block) in control_block.iter_mut() {
            block.paralell_move(lateral_delta);
            transform.translation = grid_to_world_position(block.get_board_position());
            is_operated = true;
        }
    }
    // 凍結タイマー延長
    if is_operated {
        block_timer.extend_freeze_time(time.elapsed_secs());   
    }
    is_operated = false;


    // ハードドロップ
    if keys.just_pressed(KeyCode::Space) {
        // 落下可能な距離を計算
        let drop_distance = control_block.iter().map(|(_, block)| 
            (0..).find(|&d| !board.is_position_valid(block.peek_paralell_move(IVec2::new(0, -(d + 1))))).unwrap()
        ).min().unwrap();
        
        block_timer.hard_drop();
        // game_score.score += drop_distance as u32 * 5; // 後で実装
        
        // 一気に移動
        for (mut transform, mut block) in control_block.iter_mut() {
            block.paralell_move(IVec2::new(0, - (drop_distance as i32)));
            transform.translation = grid_to_world_position(block.get_board_position());
        }
    }

    // 回転
    let (mino_kind, mino_rotation) = if let Some((_, block)) = control_block.iter().next() {
        (block.kind, block.rotation)
    } else {
        return;
    };

    let rotate_right = keys.just_pressed(KeyCode::ArrowUp) || keys.just_pressed(KeyCode::KeyW);
    let rotate_left = keys.just_pressed(KeyCode::KeyQ) || keys.just_pressed(KeyCode::KeyZ);

    // 両方押されているか、どちらも押されていない場合は何もしない
    if rotate_right == rotate_left {
        return;
    }
    let next_rotation = if rotate_right { mino_rotation.rotate_right() } else { mino_rotation.rotate_left() };
    
    // 回転可能かチェック(壁蹴りなし)
    if control_block.iter().all(|(_, block)| {
        let new_pos = block.peek_rotate(next_rotation);
        board.is_position_valid(new_pos)
    }) {
        for (mut transform, mut block) in control_block.iter_mut() {
            block.rotate(next_rotation);
            transform.translation = grid_to_world_position(block.get_board_position());
            is_operated = true;
        }
    }

    // 凍結タイマー延長
    if is_operated {
        block_timer.extend_freeze_time(time.elapsed_secs());   
    }
}

main関数でmove_minoシステムを登録します。

fn main() {
    App::new()
        // ...
        .add_systems(Update, (
            (spawn_mino, move_mino, fall_mino).run_if(in_state(GameState::Playing)),
        ))
        // ...
        .run();
}

ブロックが着地したときの処理を実装する

ブロックが着地して一定時間経過したら、そのブロックを固定(凍結)し、次のブロックをスポーンさせる必要があります。

まず、固定されたブロックを表すコンポーネントを定義します。

#[derive(Component)]
struct FrozenBlock{
    pos: IVec2,
}

そして、ブロックを固定するシステムを実装します。

fn freeze_block(
    mut commands: Commands,
    time: Res<Time<Virtual>>,
    mut block_timer: ResMut<BlockTimer>,
    control_block: Query<(Entity, &ControllingBlock)>,
    mut board: ResMut<Board>,
    mut next_state: ResMut<NextState<GameState>>,
) {
    // 固定すべきタイミングでなければリターン
    if !block_timer.should_freeze(time.elapsed_secs()) {
        return;
    }
    // もしブロックが一つもROW * COLUMSに収まらなかったらゲームオーバー
    if control_block.iter().any(|(_, block)| {
        !board.is_position_rigidly_valid(block.get_board_position())
    }) {
        info!("Game Over!");
        next_state.set(GameState::GameOver);
        return;
    }
    for (entity, block) in control_block.iter() {
        let board_pos = block.get_board_position();
        // BoardにEntityを登録
        board.0[board_pos.y as usize][board_pos.x as usize] = Some(entity);
        // ControllingBlockコンポーネントを削除し、FrozenBlockコンポーネントを追加
        // これにより、このブロックは操作対象から外れ、固定されたブロックとして扱われる
        commands.entity(entity)
            .remove::<ControllingBlock>()
            .insert(FrozenBlock{ pos: board_pos });
    }
}

ここでECS独特の方法でブロックを固定しています。ControllingBlockコンポーネントを削除し、FrozenBlockコンポーネントを追加することで、そのブロックがもはや操作対象ではなくなり、固定されたブロックとして扱われるようになります。
main関数でfreeze_blockシステムを登録します。このシステムはPostUpdateスケジュールに追加し、他の更新処理が終わった後に実行されるようにします。

fn main() {
    App::new()
        // ...
        .add_systems(PostUpdate, (
            (freeze_block).run_if(in_state(GameState::Playing)),
        ))
        // ...
        .run();
}

ライン消去を実装する

横一列がブロックで埋まった場合に、その行を消去し、上のブロックを下にずらす処理を実装します。これが実装できればついにゲームとして遊べるものとなります。

fn clear_lines(
    mut commands: Commands,
    mut board: ResMut<Board>,
    mut block_timer: ResMut<BlockTimer>,
    // mut game_score: ResMut<GameScore>, // 後で実装
    mut frozen_blocks: Query<(&mut Transform, &mut FrozenBlock)>,
){
    let mut num_erased_lines = 0;
    // 下の行から順に繰り返す
    for row in 0..ROWS {
        // 行がすべて埋まっているかチェック
        if board.0[row].iter().all(|cell| cell.is_some()) {
            // 行を消す
            for &cell in board.0[row].iter() {
                if let Some(entity) = cell {
                    // Entityをデスポーン(削除)
                    commands.entity(entity).despawn();
                }
            }
            num_erased_lines += 1;
            // 上の行を一つ下にずらす
            for r in row + 1..ROWS + 3 {
                for c in 0..COLUMS {
                    board.0[r - 1][c] = board.0[r][c];
                    if let Some(entity) = board.0[r][c] 
                    // QueryはこのようにEntityを与えて、そのEntityのコンポーネントを直接取得することもできる
                        && let Ok((mut transform, mut block)) = frozen_blocks.get_mut(entity)
                    {
                        // FrozenBlockの位置情報とTransformを更新
                        block.pos.y -= 1;
                        transform.translation = grid_to_world_position(block.pos);
                    }
                }
            }
            // 一番上の行を空にする
            for c in 0..COLUMS {
                board.0[ROWS + 2][c] = None;
            }
        }
    }
    // game_score.add_erased_lines(num_erased_lines); // 後で実装
    if num_erased_lines > 0 {
        // レベルアップ処理などがここに入る
        // block_timer.level = game_score.level;
    }
}

main関数でclear_linesシステムを登録します。freeze_blockの後に実行されるように.chain()を使用します。

fn main() {
    App::new()
        // ...
        .add_systems(PostUpdate, (
            (freeze_block, clear_lines).chain().run_if(in_state(GameState::Playing)),
        ))
        // ...
        .run();
}

スコア計算を実装する

消去したライン数やコンボ数に応じてスコアを計算するGameScoreリソースを実装します。スコア計算の方法は正直適当ですので気になる方はいじってください。

#[derive(Resource)]
struct GameScore{
    score: u32,
    level: u32,
    erased_lines: u32,
    combo_count: u32,
}

impl GameScore {
    fn new() -> Self {
        GameScore {
            score: 0,
            level: 1,
            erased_lines: 0,
            combo_count: 0,
        }
    }
    fn add_erased_lines(&mut self, num_lines: u32) {
        if num_lines == 0 {
            self.combo_count = 0;
            return;
        }
        self.erased_lines += num_lines;
        self.combo_count += 1;
        // 消去ライン数とコンボ数に応じてスコアを加算
        self.score += match num_lines {
            1 => 100,
            2 => 300,
            3 => 500,
            4 => 800,
            _ => 0,
        } * self.combo_count;
        // 10ライン消すごとにレベルアップ
        self.level = self.erased_lines / 10 + 1;
    }
}

main関数でGameScoreを初期化します。

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

そして、これまでコメントアウトしていたスコア加算処理のコメントアウトを外します。

  • fall_mino内のソフトドロップ時のスコア加算
  • move_mino内のハードドロップ時のスコア加算
  • clear_lines内のライン消去時のスコア加算とレベル更新
fall_mino
    let delta = if keys.pressed(KeyCode::ArrowDown) || keys.pressed(KeyCode::KeyS) {
        // ソフトドロップ
        game_score.score += 1; // コメントアウト解除
        time.delta_secs() * 20.0
    }
move_mino
    // ハードドロップ
    if keys.just_pressed(KeyCode::Space) {
        // ...
        block_timer.hard_drop();
        game_score.score += drop_distance as u32 * 5; // コメントアウト解除
        // ...
    }
clear_lines
    game_score.add_erased_lines(num_erased_lines); // コメントアウト解除
    if num_erased_lines > 0 {
        block_timer.level = game_score.level; // コメントアウト解除
    }

ホールドを実装する

現在操作中のミノを一時的に保持(ホールド)し、後で使えるようにする機能を実装します。

まず、ホールド中のミノを管理するHoldedMinoリソースを定義します。

#[derive(Resource, Default)]
struct HoldedMino{
    mino: Option<Mino>,
    can_hold: bool,
}

そして、ホールド処理を行うシステムを実装します。

fn hold_mino(
    mut commands: Commands,
    keys: Res<ButtonInput<KeyCode>>,
    mut holded_mino: ResMut<HoldedMino>,
    mut control_block: Query<(Entity, &mut Transform, &mut ControllingBlock)>,
    mino_handles: Res<MinoHandles>,
){
    // 一度ホールドしたら次のブロックがスポーンするまでホールドできない
    if !holded_mino.can_hold {
        return;
    }
    // ホールドキー(Cキー)が押されたらホールド処理
    if !keys.just_pressed(KeyCode::KeyC) {
        return;
    }
    // 現在操作中のミノの種類を取得
    let mino_to_hold = if let Some((_, _, block)) = control_block.iter_mut().next() {
        block.kind
    } else {
        return;
    };
    // ホールドしているブロックと入れ替え
    let new_mino = holded_mino.mino;
    holded_mino.mino = Some(mino_to_hold);
    holded_mino.can_hold = false;
    
    // 現在のブロックを削除
    for (entity, _, _) in control_block.iter_mut() {
        commands.entity(entity).despawn();
    }
    
    // すでにホールドしていたブロックがあればスポーン
    if let Some(new) = new_mino {
        let material = mino_handles.colors.get(&new).unwrap().clone();
        for i in 0..4 {
            let block = ControllingBlock{
                kind: new,
                rotation: Rotation::North,
                index_in_mino: i,
                pivot_pos: IVec2::new(COLUMS as i32 / 2, ROWS as i32 - 1),
            };
            let world_pos = grid_to_world_position(block.get_board_position());
            commands.spawn((
                Name::new(format!("Mino {:?} - Block {}", new, i)),
                Mesh2d(mino_handles.mesh.clone()),
                MeshMaterial2d(material.clone()),
                Transform::from_translation(world_pos),
                block,
            ));
        }
    }
    // ホールドしていなかった場合は、spawn_minoシステムが次のフレームで新しいミノをスポーンさせる
}

main関数でHoldedMinoを初期化し、hold_minoシステムを登録します。

fn main() {
    App::new()
        // ...
        .insert_resource(HoldedMino::default())
        // ...
        .add_systems(Update, (
            (spawn_mino, move_mino, fall_mino, hold_mino).run_if(in_state(GameState::Playing)),
        ))
        // ...
        .run();
}

また、spawn_mino関数内で、新しいミノがスポーンしたときにホールド可能フラグをリセットする処理を追加します。

spawn_mino
    // ...
    if control_block.is_empty() && let Some(mino) = next_blocks.0.pop_front() {
        // ...
        // ホールド可能にする
        holded_mino.can_hold = true; // コメントアウト解除
        // ...
    }

UIを実装する

スコアやレベル、ホールド中のミノ、次に落ちてくるミノを表示するUIを実装します。BevyのUIは正直かなり未成熟で使いずらいのですが、簡単なものを実装してみます。詳細な説明は省略します。ちなみに次のバージョンでUI周りに大幅なてこ入れが入るそうなので、その時に紹介するかもしれません。

まず、スコア表示用のコンポーネントとシステムを実装します。

#[derive(Component)]
struct ScoreText;

#[derive(Component)]
struct LevelText;

#[derive(Component)]
struct ElasedLinesText;

fn setup_score_ui(
    mut commands: Commands,
){
    let text_font = TextFont{
        font_size: 32.0,
        ..default()
    };
    // UIのルートノードを作成
    commands.spawn((
        Node {
            position_type: PositionType::Absolute, // 絶対配置
            flex_direction: FlexDirection::Row, // 子要素を横に並べる
            align_items: AlignItems::Start, // 上揃え
            justify_content: JustifyContent::Start, // 左揃え
            column_gap: px(20), // 列間の隙間
            bottom: px(20), // 下から20px
            left: px(20), // 左から20px
            ..default()
        },
    )).with_children(|builder| {
        // ラベル部分(Score, Level, Lines)
        builder.spawn((
            Name::new("Labels"),
            Text::new("Score\nLevel\nLines"),
            text_font.clone(),
            TextColor(WHITE.into()),
            TextLayout::new_with_justify(Justify::Left),
        ));
        // 区切り文字(:)
        builder.spawn((
            Name::new("Separators"),
            Text::new(":\n:\n:"),
            text_font.clone(),
            TextColor(GRAY.into()),
            TextLayout::new_with_justify(Justify::Center),
        ));
        // 値部分(実際のスコアなどの数値)
        builder.spawn((
            Name::new("Values"),
            Node {
                flex_direction: FlexDirection::Column, // 縦に並べる
                flex_grow: 1.0, // 余白を埋めるように伸長
                ..default()
            },
        )).with_children(|parent| {
            // スコアの値
            parent.spawn((
                Name::new("ScoreValue"),
                ScoreText, // 更新用マーカーコンポーネント
                Text::new("0"),
                text_font.clone(),
                TextColor(WHITE.into()),
                TextLayout::new_with_justify(Justify::Left),
            ));
            // レベルの値
            parent.spawn((
                Name::new("LevelValue"),
                LevelText,
                Text::new("1"),
                text_font.clone(),
                TextColor(WHITE.into()),
                TextLayout::new_with_justify(Justify::Left),
            ));
            parent.spawn((
                Name::new("ElasedLinesValue"),
                ElasedLinesText,
                Text::new("0"),
                text_font.clone(),
                TextColor(WHITE.into()),
                TextLayout::new_with_justify(Justify::Left),
            ));
        });
    });
}

fn update_score_ui(
    game_score: Res<GameScore>,
    mut score_text: Query<&mut Text, (With<ScoreText>, Without<LevelText>, Without<ElasedLinesText>)>,
    mut level_text: Query<&mut Text, (With<LevelText>, Without<ScoreText>, Without<ElasedLinesText>)>,
    mut elased_lines_text: Query<&mut Text, (With<ElasedLinesText>, Without<ScoreText>, Without<LevelText>)>,
){
    // スコアの更新
    if let Ok(mut text_span) = score_text.single_mut() {
        **text_span = format!("{}", game_score.score);
    }
    // レベルの更新
    if let Ok(mut text_span) = level_text.single_mut() {
        **text_span = format!("{}", game_score.level);
    }
    // 消去ライン数の更新
    if let Ok(mut text_span) = elased_lines_text.single_mut() {
        **text_span = format!("{}", game_score.erased_lines);
    }
}

次に、ホールドとネクストのプレビュー表示を実装します。

#[derive(Component)]
struct PreviewMino;

// プレビュー用のスケール定数
const PREVIEW_RATIO: f32 = 0.5;
const PREVIEW_SCALE: Vec3 = Vec3::new(PREVIEW_RATIO, PREVIEW_RATIO, 1.0);

fn updata_preview_minos(
    mut commands: Commands,
    preview_mino_query: Query<Entity, With<PreviewMino>>,
    mino_handles: Res<MinoHandles>,
    holded_mino: Res<HoldedMino>,
    next_blocks: Res<UpcomingMinoQueue>,
){
    // 既存のプレビューを削除
    for entity in preview_mino_query.iter() {
        commands.entity(entity).despawn();
    }
    // ホールドされたミノは左上に表示
    if let Some(holded) = holded_mino.mino {
        let material = mino_handles.colors.get(&holded).unwrap().clone();
        for &pos in holded.get_rotated_shape(&Rotation::North).iter() {
            // プレビュー用の位置計算(左側のスペース)
            let world_pos = Vec3::new(
                - (COLUMS as f32 / 2.0 + 2.0) * UNIT + pos.x as f32 * UNIT * PREVIEW_RATIO,
                (ROWS as f32 / 2.0 - 2.0) * UNIT + pos.y as f32 * UNIT * PREVIEW_RATIO,
                0.0,
            );
            commands.spawn((
                Name::new(format!("Preview Holded Mino {:?} - Block", holded)),
                Mesh2d(mino_handles.mesh.clone()),
                MeshMaterial2d(material.clone()),
                Transform::from_translation(world_pos).with_scale(PREVIEW_SCALE),
                PreviewMino,
            ));
        }
    }
    // 次のミノは右上に表示
    for (i, &mino) in next_blocks.0.iter().take(NEXT_BLOCKS_CAPACITY).enumerate() {
        let material = mino_handles.colors.get(&mino).unwrap().clone();
        for &pos in mino.get_rotated_shape(&Rotation::North).iter() {
            // プレビュー用の位置計算(右側のスペース、順番に下にずらす)
            let world_pos = Vec3::new(
                (COLUMS as f32 / 2.0 + 2.0) * UNIT + pos.x as f32 * UNIT * PREVIEW_RATIO,
                (ROWS as f32 / 2.0 - 2.0 - i as f32 * 3.0) * UNIT + pos.y as f32 * UNIT * PREVIEW_RATIO,
                0.0,
            );
            commands.spawn((
                Name::new(format!("Preview Next Mino {:?} - Block", mino)),
                Mesh2d(mino_handles.mesh.clone()),
                MeshMaterial2d(material.clone()),
                Transform::from_translation(world_pos).with_scale(PREVIEW_SCALE),
                PreviewMino,
            ));
        }
    }
}

main関数でこれらのシステムを登録します。

fn main() {
    App::new()
        // ...
        .add_systems(Startup, (setup_board_and_resources, setup_score_ui))
        .add_systems(Update, (
            (spawn_mino, move_mino, fall_mino, update_score_ui, hold_mino).run_if(in_state(GameState::Playing)),
            updata_preview_minos.run_if(resource_changed::<HoldedMino>.or(resource_changed::<UpcomingMinoQueue>)),
        ))
        // ...
        .run();
}

resource_changed()を使うことで前回からResourceが追加されたり、可変な参照を受けた場合のみ実行することができるのですが、Update内で毎回可変な参照をしているため今回追加する多分意味はなかったです。

Super Rotation System(SRS)を実装する

テトリスでは、壁際や床際で回転したときに、ブロックが壁にめり込まないように位置を補正する「スーパーローテーションシステム(SRS)」という仕組みがあります。これを実装することで、操作性が格段に向上します。私はこの実装を始めたときに初めてSRSの存在を知りました。操作して違和感がないくらいになったのですが、実装ミスがある可能性がかなりあるのでその点はご了承ください。正確な仕様はTetris Channelさんの記事を参照してください。

move_mino関数の回転処理部分を以下のように書き換えます。

move_mino
    // ...
    let rotate_right = keys.just_pressed(KeyCode::ArrowUp) || keys.just_pressed(KeyCode::KeyW);
    let rotate_left = keys.just_pressed(KeyCode::KeyQ) || keys.just_pressed(KeyCode::KeyZ);

    if rotate_right == rotate_left {
        return;
    }
    let next_rotation = if rotate_right { mino_rotation.rotate_right() } else { mino_rotation.rotate_left() };
    
    // Super Rotation Systemでの回転軸の補正順序
    let mut wall_kick_offsets;
    if mino_kind != Mino::I {
        // Iミノ以外の場合のSRSテーブル
        wall_kick_offsets = [
            IVec2::new(0, 0),
            IVec2::new(-1, 0),
            IVec2::new(-1, 1),
            IVec2::new(0, -2),
            IVec2::new(-1, -2),
        ];
        // 回転方向によってX軸を反転
        if mino_rotation == Rotation::East || next_rotation == Rotation::West {
            for offset in &mut wall_kick_offsets {
                offset.x *= -1;
            }
        }
        // 特定の回転状態ではY軸も反転
        if matches!(mino_rotation, Rotation::East | Rotation::West) {
            for offset in &mut wall_kick_offsets {
                offset.y *= -1;
            }
        }
    }
    else { // Iミノの場合のみ別の補正順序
        wall_kick_offsets = [
            IVec2::new(0,0),
            IVec2::new(-2,0),
            IVec2::new(1,0),
            IVec2::new(-2,-1),
            IVec2::new(1,2),
        ];
        let mut rotation_pair = [mino_rotation, next_rotation];
        rotation_pair.sort();
        // 特定の回転パターンの場合はテーブルを入れ替え
        if matches!(rotation_pair, [Rotation::East, Rotation::South] | [Rotation::North, Rotation::West]) {
            wall_kick_offsets.swap(1, 2);
            wall_kick_offsets.swap(3, 4);
        }
        // 回転方向によってX軸を反転
        if mino_rotation == Rotation::East || next_rotation == Rotation::West {
            for offset in &mut wall_kick_offsets {
                offset.x *= -1;
            }
        }
        // 特定の回転状態ではY軸も反転
        if mino_rotation == Rotation::South || next_rotation == Rotation::North {
            for offset in &mut wall_kick_offsets {
                offset.y *= -1;
            }
        }
    }

    // wall_kick_offsetsを順に試して、どれかで回転できたら回転を適用
    // できなかったら回転しない
    let can_rotate = wall_kick_offsets.iter().find_map(|&offset| {
        if control_block.iter().all(|(_, block)| {
            let new_pos = block.peek_rotate(next_rotation) + offset;
            board.is_position_valid(new_pos)
        }) {
            Some(offset)
        } else {
            None
        }
    });
    if let Some(offset) = can_rotate {
        for (mut transform, mut block) in control_block.iter_mut() {
            block.rotate(next_rotation);
            block.paralell_move(offset);
            transform.translation = grid_to_world_position(block.get_board_position());
            is_operated = true;
        }
    }
    // ...

長押しでの移動を実装する

現状横移動するときにキーを連打するしかないので、キーを長押したときに連続して横移動するようにしましょう。

まず、横移動のタイマーを管理するLateralMoveTimerリソースを定義します。

#[derive(Resource, Default)]
struct LateralMoveTimer {
    move_count: usize,
    last_moved: Option<Instant>,
    is_right: bool
}

そして、move_mino関数の横移動処理部分を以下のように書き換えます。

move_mino
fn move_mino(
    keys: Res<ButtonInput<KeyCode>>,
    mut control_block: Query<(&mut Transform, &mut ControllingBlock)>,
    board: Res<Board>,
    mut block_timer: ResMut<BlockTimer>,
    mut game_score: ResMut<GameScore>,
    mut lateral_move_timer: ResMut<LateralMoveTimer>, // 追加
    time: Res<Time>
){
    let mut is_operated = false;
    
    // キー入力の判定とタイマーの更新
    if keys.any_just_pressed([KeyCode::ArrowRight, KeyCode::KeyD, KeyCode::ArrowLeft, KeyCode::KeyA]) {
        // キーが押された瞬間はタイマーをリセットして即座に移動
        lateral_move_timer.move_count = 0;
        lateral_move_timer.last_moved = Some(Instant::now());
        lateral_move_timer.is_right = keys.any_just_pressed([KeyCode::ArrowRight, KeyCode::KeyD]);
    }
    else if !keys.any_pressed([KeyCode::ArrowRight, KeyCode::KeyD, KeyCode::ArrowLeft, KeyCode::KeyA]) {
        // キーが離されたらタイマーをクリア
        lateral_move_timer.last_moved = None;
    }

    let first_interval = Duration::from_millis(300); // 初回の遅延(長押し判定までの時間)
    let next_interval = Duration::from_millis(50);   // 2回目以降の間隔(連続移動の速度)

    // 横移動量の計算
    let lateral_delta = match &mut *lateral_move_timer {
        LateralMoveTimer {
            last_moved: Some(last),
            move_count,
            is_right
        } if *move_count == 0 || last.elapsed() > if *move_count == 1 { first_interval } else { next_interval } => {
            // 初回移動、または長押し後の連続移動タイミングの場合
            *last = Instant::now();
            *move_count += 1;
            IVec2::new(if *is_right { 1 } else { -1 }, 0)
        }
        _ => IVec2::new(0, 0)
    };

    if lateral_delta != IVec2::new(0, 0) 
        && control_block.iter().all(|(_, block)| board.is_position_valid(block.peek_paralell_move(lateral_delta))) {
        // 移動可能であれば移動を適用
        for (mut transform, mut block) in control_block.iter_mut() {
            block.paralell_move(lateral_delta);
            transform.translation = grid_to_world_position(block.get_board_position());
            is_operated = true;
        }
    }
    // ...
}

main関数でLateralMoveTimerを初期化します。

fn main() {
    App::new()
        // ...
        .insert_resource(LateralMoveTimer::default())
        // ...
        .run();
}

おわりに

以上で、Bevyを使ったテトリスライクなゲームの基本的な実装は完了です。以下のリンクのリポジトリにコード全体があります。

もともとはもっと簡単な実装で終わる予定だったのですが、本物のテトリスの挙動に近づけたくなったり、友人にこういう機能が必要だといわれたりして(その友人にも実装を手伝ってもらっています)最終的に実装がかなり複雑になってしまいました。テトリスのようなゲームにとってはECSによるデータと処理の分離は実装をかえってややこしくしているだけに思われてしまうかもしれませんが、ゲームの規模が大きくなるほどにデータと処理を分離することは依存関係が複雑になりすぎるのを防ぐのに役立つと思います。

まだまだBevyには未成熟なところはあるものの、ちょっとしたゲームを開発する分には困らず、またRustでゲームを開発するのも楽しいので、ぜひ興味のある方はBevyでゲームを作ってみてください。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?