22
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Bevyを使ってブラウザで動くゲームを作るチュートリアル

Last updated at Posted at 2021-01-24

BevyはRustで作られたゲームエンジンです。

まだあまり日本語の紹介がないようなので、ターゲットをWebAssemblyにし、ブラウザで動くゲームを作るチュートリアルを書きました。

作るもの

こちらにうごくものが置いてあります。理由はわからないのですが、現状、MacやiOSのSafariでは動かないそうです(所有してないため、確認できてません)。MacでもChrome、LinuxやAndroidのFirefoxやChromeでは動きます。

ソースは、自分のレポジトリにあります。

青いものがプレイヤー・キャラクターで、基本的には直進しますが、画面をクリック(タップ)すると、クリックしている間、その点を中心に弧を描いて動きます。画面内に出現するゲートを通過することで得点が入りますが、ゲートの脇にあたってしまうと尾が半減、自分の尾に接触すると、接触した部分から先の尾が消滅し、尾が完全になくなるとゲームオーバーです。

壁にぶつかると角度によっては一番近い尾にぶつかることになり即死します。

環境準備

環境の準備をします。自分はUbuntu Linuxを使用していますが、WindowsやMacでもほぼ同じだと思います。

Rustとcrate

Rustはrustup等を使って導入します。インストール後に、WebAssemblyをターゲットとして追加します。

wasm.sh
rustup target add wasm32-unknown-unknown

また、いくつかビルド・テストに必要なツールがあるため事前に導入します。どれもcargo installするだけで導入できます。後述するbevy_webgl2_app_templateで使用します。

cargo install cargo-make
cargo install wasm-bindgen-cli
cargo install basic-http-server

bevy_webgl2_app_template

Bevyのwasmターゲットに、WebGL2を使用したレンダリングバックエンドを提供するbevy_webgl2を使うためのテンプレート、bevy_webgl2_app_templateを使用します。

テンプレートなので、git cloneします。

git clone https://github.com/mrk-its/bevy_webgl2_app_template.git my_game
cd my_game

これで、ビルドが可能になっているはずなので、一度ビルドしてみます。初回は依存するcargoパッケージもすべてビルドするため、非常に時間がかかりますが、二度目以降は、マシンによりますが数秒から数十秒程度になると思います。

build.sh
# ネイティブ(開発しているPC)向けにビルド。
cargo build run

# Web向けにビルド
cargo build serve

runした場合は、ウィンドウが立ち上がります。

serve した場合は、先にインストールした、basic-http-serverが立ち上がり、http://localhost:4000/ でゲームを起動できます。なお、開発マシン以外からこのページを表示したい場合、basic-http-serverのオプションを変更してローカルIPアドレスを渡すことができます。

Makefile.toml
[tasks.serve]
command = "basic-http-server"
args = ["-x", "-a", "ここにローカルIPアドレス:4000"]
# args = ["-x"]
dependencies = ["build-web", "basic-http-server"]

デプロイ

完成したものをcargo build serveして開いたページの通信内容を見るとわかりますが、デプロイに必要なのは、以下のファイルだけなので、任意のサーバーにコピーすることでデプロイできます。

├── assets
│   └── fonts
│       └── FiraSans-Bold.ttf
├── index.html
└── target
    ├── wasm.js
    └── wasm_bg.wasm

bevy用語

BevyはEntity Component System(ECS)を使用したゲームエンジンでECSの用語がわからないと理解しづらいため、ECSに馴染みがない場合は旦、The BookのECSのページや、Unofficial Bevy Cheat Bookを参照してください。

軽く説明すると、BevyのECSにおいては、以下のような感じです。

  • Entity : ゲーム内のもの。複数のコンポーネントを持つ
    • rust上ではただのid: u32とgeneration: u32を持つEntityというstructで、それ以上のデータは直接保持していない。commands.spawn()で生成する
  • Component : ゲーム内のオブジェクトの一側面。座標であったり、敵としての性質であったり
    • rustでは単なるstructで、自由に定義でき、状態を持つ
    • commands.spawn()でEntityを生成する際にWithで付与したり、あとからcommands.insert()で追加したりする
  • System : Componentの集合に対してなんらかの処理をするもの
    • rustでは関数。基本的には毎フレーム呼び出され、Command、Query、Resourceを引数として利用できる
    • Query : Componentを、全て、或いは条件を指定して取得するためのクエリ。取得したComponentのメンバーは直接更新できるが、追加や削除はCommandを経由する
    • Resource : 状態を保持するstructだが、ゲーム内にひとつだけ存在する。シングルトンのようなもの
    • Command : Entityの生成、削除、Componentの追加、削除等を行うためのstruct。Commands

ゲームの作成

全体の構成

チュートリアルなので、コードの断片をエディタで貼り付けてもらう感じで書こうと思ったのですが、思ったより量が多くなったのと、いずれの時点でもコンパイル可能な状態で進めていくのが難しそうだったので、以下の方針で行こうと思います。

  • コードの貼り付けは基本的にファイル全体を単位として行い、編集しない
  • main.rsのみ、随時編集する

幸い、Bevyはプラグインをアプリケーションに追加する形で開発するため、各機能を1ファイル、1プラグインとして作成、追加していくことができます。

必要なrsファイルは、12ファイルあります。このうち、mainはmodの指定、プラグインの追加を行うため、随時編集します。constants, event, utilは他のプラグインを補助するためのファイルなので、事前に作成します。

  • 随時編集する
    • main.rs
  • 最初から作成しておく
    • constants.rs : 各種定数
    • stage.rs : ステージの定義
    • event.rs : プラグイン間でやり取りするイベントの定義
    • util.rs : プラグインで共通して利用するstructや関数
  • プラグインとして、以下の順番で作成
    • space.rs : 背景、カメラ等
    • input.rs : ユーザーの入力をゲーム内のリソースに変換する
    • ui.rs : スコアの表示
    • head.rs : 自機の頭部の表示、移動
    • tail.rs : 自機の尾の表示、移動
    • gate.rs : ゲートの作成
    • interaction.rs : 自機と障害物の衝突判定

事前ファイルコピー

上で紹介した4つのrsファイル(constants.rs, stage.rs, event.rs, util.rs)と、を、完成したレポジトリから取得して、srcディレクトリに置いてください。

rustはsrcディレクトリに*.rsファイルがあっても、モジュールのツリーから外れているファイルはコンパイルしないので、実は全ファイルコピーしておいて、main.rsのmod指定を消したり足したりすることでも進めることができます。そっちのほうが簡単だと思いすが、全くチュートリアル感はなくなります。

UIでFira Sansのboldを使用するため、assets/fonts/ に FiraSans-Bold.ttf を置いてください。これ以外には一切アセットを使用しません。

main.rs

最終的に、main.rsは以下のようになります。

main.rs
pub use bevy::prelude::*;

use crate::event::*;
pub use constants::*;
pub use util::*;

mod constants;
mod event;
mod gate;
mod head;
mod input;
mod interaction;
mod space;
mod stage;
mod tail;
mod ui;
mod util;

fn main() {
    let mut app = App::build();

    app.add_resource(Msaa { samples: 4 })
        .add_plugins(DefaultPlugins);

    #[cfg(target_arch = "wasm32")]
    app.add_plugin(bevy_webgl2::WebGL2Plugin);

    app.add_plugin(event::ModPlugin {})
        .add_plugin(stage::ModPlugin {})
        .add_plugin(space::ModPlugin {})
        .add_plugin(input::ModPlugin {})
        .add_plugin(ui::ModPlugin {})
        .add_plugin(head::ModPlugin {})
        .add_plugin(tail::ModPlugin {})
        .add_plugin(gate::ModPlugin {})
        .add_plugin(interaction::ModPlugin {});

    app.run();
}

現時点では、stage、event以外のプラグインは作成していないため、該当するmodとapp_pluginを削除すると、以下のようになります。

main.rs
pub use bevy::prelude::*;

use crate::event::*;
pub use constants::*;
pub use util::*;

mod constants;
mod event;
mod stage;
mod util;

fn main() {
    let mut app = App::build();

    app.add_resource(Msaa { samples: 4 })
        .add_plugins(DefaultPlugins);

    #[cfg(target_arch = "wasm32")]
    app.add_plugin(bevy_webgl2::WebGL2Plugin);

    app.add_plugin(event::ModPlugin {})
        .add_plugin(stage::ModPlugin {});

    app.run();
}

以上で、一旦コンパイルが通るようになります。今後、プラグインを作成するたびに、該当するmodと、add_pluginを追加することによって、機能を追加していきます。

space.rs

まだ、シーン内にカメラが存在していないため、現在はゲームを起動しても何も表示されません。カメラと、背景をspace.rsとして追加します。

space.rsをsrc/に作成し、main.rsで削除したmod space;と、.add_plugin(space::ModPlugin {})を再度記述してください。

Screenshot from 2021-01-24 13-45-55.png

cargo make runすると、カメラと背景が追加されたため、上の画像のように表示されると思います。

プラグインの定義

プラグインはAppBuilderに対してメソッドを呼び出すことで処理(System)を追加していきます。このspace::ModPluginでは、以下の2つのメソッドでSystemを登録しています。

  • add_startup_system : 引数に指定された関数を、ゲーム起動時に一度呼び出す
  • add_system_to_stage : 引数に指定された関数を、毎フレーム指定したstageで呼び出す
impl Plugin for ModPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_startup_system(setup_system.system())
            .add_system_to_stage(stage::PRE_RENDER, position_to_translation_system.system());
    }
}

setup_systemでは、カメラの作成や背景として表示されているパネルの作成を行っています。こちらは、毎フレーム状態が変化するものではないので、起動時に一度だけ行います。

position_to_translation_systemでは、このゲームのコード内で使用しているPositionという2Dの座標系コンポーネントと、Bevyの3Dの座標系を変換しています。といっても、カメラを画面の中央に置き、x: 0, y: 0の原点として使用しているため、ここは同一です。このゲームは2Dなので、Positionにはz座標がなく、替わりにvisibleというbooleanを保持しています。visibleがfalseの場合には、zを負の大きな値にし、カメラに映らないようにしています。

stage

add_system_to_stageでは、systemを実行するステージを指定しています。systemはステージごとに並行して実行され、ステージが切り替わる際に、Commandはすべて実行されます。Bevyにはあらかじめ5つのステージがありますが、このゲームでは、
stage.rsでステージを追加しています。

add_system_to_stageではなく、add_systemを使うと、UPDATEステージに追加されます。実行順は、下で引用したconst定義
と同じです。

pub const FIRST: &str = bevy::prelude::stage::FIRST;
pub const PRE_UPDATE: &str = bevy::prelude::stage::PRE_UPDATE;
pub const UPDATE: &str = bevy::prelude::stage::UPDATE;
pub const POST_UPDATE: &str = bevy::prelude::stage::POST_UPDATE;
pub const LAST: &str = bevy::prelude::stage::LAST;
pub const SEND_EVENT: &str = "SEND_EVENT";
pub const RECEIVE_EVENT: &str = "RECEIVE_EVENT";
pub const PRE_RENDER: &str = "PRE_RENDER";

カメラ

カメラEntityは以下のように指定しています。

camera.rs
        .spawn(Camera2dBundle {
            transform: Transform::from_translation(Vec3::new(0., 0., 100.0))
                .looking_at(Vec3::default(), Vec3::unit_y()),
            ..Default::default()
        });

ここで、カメラをCamera2dBundleとしていますが、orthographic(物が遠くにあっても小さく映らない、正投影)となるだけで、それ以外に3Dのカメラとの違いはないようです。オブジェクトを生成するときはZ座標も必要になりますが、先述したposition_to_translation_systemですべて同じ値にしています。

Camera2DBundleはカメラに必要なComponentを集めた(Bundleした)もので、ソースを確認すると、Camera, OrthographicProjection, VisibleEntities, Transform, GlobalTransformというComponentをまとめたものだということがわかります。

input.rs

input.rsをsrc/に作成し、main.rsにmodとadd_pluginを再度記述します。今回のプラグインはマウスの入力を受け取るだけなので、ビルドし直しても特に変化はありません。

input.rs
impl Plugin for ModPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<CursorState>()
            .add_system_to_stage(stage::FIRST, read_input_events_system.system());
    }
}

このプラグインでは、CursorStateリソースの初期化と、ユーザーの入力を読み取ってCursorStateに反映するread_input_events_systemをappに追加しています。

リソースも単なるstructですが、ゲーム全体で一つしか存在しません。ユーザーの入力はゲーム全体で一つ管理できれば良いため、リソースとしています。リソースの詳細に関しては、Bevy The BookのResourcesを参照してください。

マウスカーソルは、左クリックされているかどうかと、場所さえわかれば良いため、CursorStateは以下のように定義しています。

#[derive(Default, Debug)]
pub struct CursorState {
    pub screen_position: Vec2,
    pub position: Position,
    pub left_pressed: bool,
}

read_input_events_systemでは、CursorState以外にも、WindowやInputといったリソースを参照しています。これは、bevy_inputや、bevy_windowなどの、bevyの提供するcrateに含まれるものです。

fn read_input_events_system(
    mouse_input: Res<Input<MouseButton>>,
    windows: Res<Windows>,
    mut cursor_state: ResMut<CursorState>,
    (events, mut reader): (Res<Events<CursorMoved>>, Local<EventReader<CursorMoved>>),
) {

CursorStateのみ更新するのでResMut、他のリソースは読み取るだけなので、Resになっています。また、最終行のCursorMovedイベントのEvents、EventReaderはタプルとしていますが、ソースが見やすくなるだけで、分けて引数として定義しても同じです。

ui.rs

スコアや、FPSを表示したいため、UIを追加します。UIはドキュメントが見つけられなかったため、examples/uiを参照して最低限必要な情報が表示されるようにしました。

他のプラグインと同じく、ui.rsをsrc/に作成し、main.rsを更新します。

Screenshot from 2021-01-24 15-18-07.png

cargo make runすると、画面上部にFPSが表示されます。

ui.rs
impl Plugin for ModPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_plugin(FrameTimeDiagnosticsPlugin::default())
            .init_resource::<Status>()
            .add_startup_system(setup.system())
            .add_system_to_stage(stage::LAST, on_game_start.system())
            .add_system_to_stage(stage::LAST, on_through_gate.system())
            .add_system_to_stage(stage::PRE_RENDER, score_update_system.system())
            .add_system_to_stage(stage::PRE_RENDER, fps_update_system.system());
    }
}

このプラグインでは、on_game_startとon_through_gateでのイベント処理と、score_update_system, fps_update_systemでの毎フレームの表示更新を行っています。score_update_systemとfps_update_systemはリソースで保持しているゲームの状態を元に表示を更新しているだけなので、省略します。

on_game_startとon_through_gateではイベントの処理を行っているため、細かく見てみます。

on_game_start.rs
fn on_game_start(
    commands: &mut Commands,
    (events, mut reader): (Res<Events<GameStart>>, Local<EventReader<GameStart>>),
    query: Query<Entity, With<Gate>>,
) {
    for _ in reader.iter(&events) {
        for entity in query.iter() {
            commands.despawn_recursive(entity);
        }
    }
}

イベントを受信するには、Res<Events<_>>と、EventReader<_>が必要です。Res<Events<_>>には発生したイベントが全て残されています。EventReader<_>はそのイベントをどこまで処理したか、を保持しています。イベントには複数の受信者が存在する可能性があるため、イベントをどこまで読んだかは、それぞれ別に管理する必要があります。Res<_>とは異なり、Local<_>を使用すると、それぞれのシステムにローカルなオブジェクトを作成し、管理することができます。そのため、on_game_startは毎フレーム呼び出されますが、すでに処理したイベントをもう一度処理することはありません。

なお、event::GameStartは、event.rsで送信しています。送信する際は、Readerのようなものは必要ありません。

events.rs
fn game_start_system(
    time: Res<Time>,
    centipede_container: Res<CentipedeContainer>,
    mut game_start_events: ResMut<Events<event::GameStart>>,
) {
    if let Centipede::Dead(dead_at) = centipede_container.centipede {
        if dead_at == 0.0 || dead_at < time.seconds_since_startup() - 2.0 {
            game_start_events.send(event::GameStart {});
        }
    }
}

ゲーム起動直後、もしくは自機が死んで2秒経ったら、GameStartイベントを送信しています。

head.rs

自機の頭を表示し、操作できるようにします。head.rsをsrc/に作成し、main.rsを更新します。丸いボールが表示され、クリックで軌道が変えられます。

Screenshot from 2021-01-24 15-42-56.png

impl Plugin for ModPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ModResources>()
            .add_startup_system(setup.system())
            .add_system_to_stage(
                stage::PRE_UPDATE,
                select_movement_system.system().chain(void.system()),
            )
            .add_system(move_head_system.system().chain(void.system()))
            .add_system_to_stage(stage::LAST, on_game_start.system())
            .add_system_to_stage(stage::LAST, on_game_over.system());
    }
}

head::ModPluginでは、ゲーム開始、終了イベントの処理、自機の移動、などの各システムを追加します。

自機情報の管理

自機が、回転移動しているか、直線移動しているかを決めるシステムの冒頭は以下のようになっています。

fn select_movement_system(
    mut centipede_container: ResMut<CentipedeContainer>,
    cursor_state: Res<input::CursorState>,
    mut marker_query: Query<&mut Position, With<CenterMarker>>,
    head_query: Query<&Position, With<Head>>,
) -> Option<()> {
    let mut centipede = centipede_container.alive_mut()?;
    let position = head_query.get(centipede.head_entity).ok()?;

この部分では、自機の状態を持つ、centipede_containerリソースから、自機の情報と、位置(Poisitionコンポーネント)を取得しています。行末の?はRustの?演算子です。Optionに対して?演算子を使用するためには、関数がOptionを返す必要があります。実際にはこの返り値は使用しないのですが、systemとして登録するために、この戻り値を解消してしまう必要があります。

systemは戻り値を別のsystemに渡す(chainする)ことができるため、なにもしないvoid systemをutil.rsに用意しています。

util.rs
pub fn void(_: In<Option<()>>) {}

tail.rs

自機の尾を表示します。tail.rsをsrc/に作成し、main.rsを更新します。

Screenshot from 2021-01-24 16-05-38.png

impl Plugin for ModPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ModResources>()
            .add_system_to_stage(
                stage::POST_UPDATE,
                move_tail_system.system().chain(void.system()),
            )
            .add_system_to_stage(stage::POST_UPDATE, purged_tail_system.system())
            .add_system_to_stage(stage::POST_UPDATE, rotate_tail_system.system())
            .add_system_to_stage(stage::RECEIVE_EVENT, on_game_start.system())
            .add_system_to_stage(
                stage::RECEIVE_EVENT,
                on_through_gate.system().chain(void.system()),
            )
            .add_system_to_stage(stage::RECEIVE_EVENT, on_miss.system().chain(void.system()));
    }
}

各種イベント発生時の処理と、尾の移動を処理しています。

尾は、消滅、作成するタイミングで必要になる、しかし、毎回同じで良い情報(mesh, material, 回転の軸など)が多いため、ModResourceにまとめて持たせています。init_resource::()の形でリソースを登録すると、通常、structのデフォルトの値が使用されますが、FromResourcesを実装することで、他のリソースを参照しながらリソースの初期化を行えるので、meshやmaterialのハンドラはここで生成します。

gate.rs

得点源となる門をランダムに生成、表示します。gate.rsをsrc/に作成し、main.rsを更新します。

Screenshot from 2021-01-24 16-26-33.png

頭、尾、背景はすべて単純な図形一つで作成していたのですが、門はボール2個と、その間を結ぶ棒の3つのEntityで構成されています。そのため、Entityに親子関係をもたせています。親子関係をもたせることで、Entityをまとめて回転させたり、移動させることが可能になります。

親となるEntityには、TransformとGlobalTransformのみをもつBundleをutil.rsに定義し、これを使用します。Transformは親となる要素との相対位置、GlobalTransformはグローバル座標での位置を表すようです。

util.rs
#[derive(Bundle)]
pub struct ContainerBundle {
    pub transform: Transform,
    pub global_transform: GlobalTransform,
}

Entityの階層構造に関してはドキュメントが見つけられなかったため、examples/ecs/hierarchyを参考にしました。

interaction.rs

最後に、頭と門、尾との衝突判定を追加します。interaction.rsをsrc/に作成し、main.rsを更新します。

head_and_gate_systemでは、頭と門の衝突を判定しています。Entityを移動させる場合はPositionを使用していますが、門の両脇は階層構造で管理しているため、子要素である門の脇はPositionコンポーネントを持っていません。そこで、グローバル座標をもつGlobalTransformを使用して衝突を判定しています。

head.rs、tail.rs等ですでにイベント受信時の処理は作成しているので、イベントを送信することで、各種衝突時の処理が動くようになります。

以上で完成です!!!!!!!!! お疲れ様でした。

22
15
1

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
22
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?