この記事は新歓ブログリレー2日目の記事です。
こんにちは!普段はUnityなどを用いてゲームを制作しているひなるひです。
皆さんはゲームエンジンを用いてゲームを作ったことがありますか?
今の時代、「ゲームエンジンを使ってゲーム作ったことあるよ!」っていう人は結構いるとは思うのですが、「ゲームエンジン(もしくはゲーム用のライブラリ)使ったことないけどゲーム作ったことあるよ!」っていう人は少ないと思うんですね。
ただこのままだと、例えばUnityだとC#、UnrealEngineだとC++、あとはSwiftとかJavaScript...みたいゲームエンジニアが使う言語が割と偏ってくるんじゃないかなという懸念があります。
こう考えると、純粋にゲームを作りたいと思い始めた人のって大概は以下のようになって、ほかの言語に触る機会って少なくなってるのではないかと思います。
僕もそのうちの一人だったので、これではだめだなって思い、入り口がゲームエンジンでもいいので、普段使わない言語を触ってみようって考えたのが発端です。
速度の問題などありますが、一応大体の言語でゲームは作れますし、なんならゲームエンジン(ゲーム用ライブラリ)が存在する言語も近年かなり増えてきていたのを知ってたので、今回制作に踏み切りました。
まあなんやかんやあって、Rust触ってみたいのでそのゲームエンジンのAmethystを触ってみようという記事です。
注意書き
- RustとAmethystの両方の体験を書いていきます。
- 現在Amethystでは、様々な人が追加のモジュール(たとえば当たり判定)などを作成してくれていますが、今回は使用しないことにします。(正直本体だけでもまあまあ充実しているので、そこは勘弁してください。)
Amethystについての概要
プログラミング言語「Rust」で書くことができるゲームエンジンです。
公式サイトが存在するので、それを見てもらえれば割とわかりますが、ここでも少しだけ説明を加えておきます。
対応プラットフォームなど
- 僕が確認した限りですが、Windows,Mac,Linuxで動作します。
- ゲームのエクスポートは実行ファイルとともに、Web用にビルドすることもできます。
- 2D,3Dの両方で開発が可能です。
Entity Component System(ECS)
ECSとは近年ゲーム制作において考えられているデータの管理構造です。
各々のオブジェクトに位置情報や重さなどの性質を表したコンポーネントの管理方法を変えることで、大量のゲームオブジェクトが存在しても高速で動かすための工夫です。
最近だと、UnityがDOTSの開発を進めていて、そのなかの一つとしてECSがありますが、まだ標準搭載までいってないらしいので(2021/03/10現在)、Amethystにとって一種の強みとなってます。
ゲームエンジン?
ゲームエンジンというと、特にUnityやUnrealEngineなどを思い浮かべた人は、おそらく下のような画面を創造すると思いますが、 Amethystにはない です。
通常のRustと同様、コマンドcargo runさせて、プログラムを走らせます。
なので正直ゲームライブラリのほうが正しいのでは?そう思ってます。
ゲームエンジンだなと思った点としては、とりあえず触った所感としては、ゲームオブジェクトにコンポーネントとして座標系を持たせたり、イベントシステムやプレファブの存在といった部分ですね。
実際に使ってみる
おそらく実感がわかないと思うので、個人的にやってて思ったことを交えながら解説していきます。
チュートリアルに関しては、先ほどの公式サイトにチュートリアルが載っているのでそこを参照していただければと思います。ここでは簡単な流れを紹介します。
インストールしよう
インストールの方法ですが、まずRustをダウンロードしてない人は公式サイトに行ってRustをダウンロードしましょう。
次にAmethystの入れ方ですが、rustとネット環境さえあれば、あとはインストールする必要はないです。
ここにスターターがあるので、ダウンロードして適当なところに解凍して、コマンドプロンプトとかを開きましょう。
後はフォルダのディレクトリまで移動して、以下のように打ち込むだけです。
cargo run --features "vulkan" (Windows,Linuxの場合)
cargo run --features "metal" (Macの場合)
※この違いはGPUドライバーの違いに起因するものらしいです。
長いコンパイル、ビルドの末、説明に書かれてた画面と同じ画面が出てくれば成功です!
実際に作っていこう
まずはどういう仕組みでこのエンジンが動いているかを知る必要があります。
まだ使用して1週間ほどしか経ってないので、詳しいことは何も言えないですが、初歩としてゲームを作るのに困らない程度の仕組みを紹介します。
この記事と平行して、下の図のように電光掲示板をモチーフにした逆タイピングゲームを作成していて、載せてあるプログラムはそれに沿ったものとなります。
簡単にゲーム説明をしておくと、右端から左に文字が流れてくるので、文字が左端に到達しないよう左にある文字から逆タイピングして消していくものを予定しています。
state
簡単にいうとUnityのシーンと同じです。stateが始まったときのon_start()や、update()などを含んでいるので、考え方的には簡単にゲームを作るときによく見かけるGameManagerてきな何かの要素を併せ持つ感じですね。
#[derive(Default)]
pub struct MainScene{
pub sprite_sheet_handle: Option<Handle<SpriteSheet>>,
}
impl SimpleState for MainScene {
fn on_start(&mut self, data: StateData<'_, GameData<'_, '_>>) {
let world = data.world;
self.sprite_sheet_handle.replace(def::load_sprite_sheet(world));
let x: i32 = def::WINDOW_WIDTH as i32;
//make init chara
def::init_chara_with_destination(world, self.sprite_sheet_handle.clone().unwrap(), 4, x, 1, 0.0, def::STANDARD_SPEED);
def::init_chara_with_destination(world, self.sprite_sheet_handle.clone().unwrap(), 4, x, 0, 0.0, def::STANDARD_SPEED);
let x: i32 = x + def::CHARA_WIDTH as i32;
def::init_chara_with_destination(world, self.sprite_sheet_handle.clone().unwrap(), 4, x, 1, 0.0, def::STANDARD_SPEED);
def::init_chara_with_destination(world, self.sprite_sheet_handle.clone().unwrap(), 4, x, 0, 0.0, def::STANDARD_SPEED);
let x: i32 = x + def::CHARA_WIDTH as i32;
def::init_chara_with_destination(world, self.sprite_sheet_handle.clone().unwrap(), 4, x, 1, 0.0, def::STANDARD_SPEED);
def::init_chara_with_destination(world, self.sprite_sheet_handle.clone().unwrap(), 4, x, 0, 0.0, def::STANDARD_SPEED);
//Init_gameData
let fetched = world.try_fetch_mut::<PlayerData>();
if let Some(mut player_data) = fetched{
player_data.init_game();
player_data.now_last_x = def::WINDOW_WIDTH + def::CHARA_WIDTH * 2.0;
}
}
fn update(&mut self, data: &mut StateData<'_, GameData<'_, '_>> ) -> SimpleTrans {
// let fetched = data.world.try_fetch_mut::<PlayerData>();
// let time = data.world.fetch::<Time>();
let mut last_x: f32 = def::WINDOW_WIDTH;
let mut now_speed: f32 = 0.1;
if let Some(mut player_data) = data.world.try_fetch_mut::<PlayerData>(){
// if let Some(mut player_data) = fetched{
player_data.now_time += data.world.fetch::<Time>().delta_seconds();
if player_data.now_time > next_level_second {
player_data.now_time -= next_level_second;
self.next_level(&mut player_data);
}
last_x = player_data.now_last_x;
now_speed = player_data.now_speed;
// self.make_alphabet_manage(&mut player_data, &mut data.world);
}
self.make_alphabet_manage(last_x, data.world, now_speed);
println!("{}", last_x);
//println!("MainScene Now");
Trans::None
}
今作ってるゲームのメインシーンの作りかけです。
on_startやupdateの引数が色々ありますが、とりあえず呪文と考えても差し支えないです。data変数に色々入れたので後で引っ張り出せるぐらいの感覚でいいです。
on_startでは、まず最初にsprite_sheet_handleにキャラの画像を操作するためのHandleを与え、init_chara_with_destinationでキャラクター(スプライト)を出している感じです。(このinit_chara_with_destinationは自作関数なので、引数が多いなどはご了承ください。)
次にfetchedにPlayerDataというSystemDataを入れて、ゲームのスコアなどを初期化してます。(SystemDataに関しては後で説明します。)
System
キャラクターを実際に動かしたり、一個一個の機能ごとにプログラムを組んで動かすためにあるものです。
UnityでいうMonobehaviourに近いようでちょっと異なります。
例えばボールのオブジェクトに動きをつけるスクリプトをつけ、ボールがバーで跳ね返ったときの処理をするスクリプトをつけ、これはスクリプト内でバーを参照する、みたいな感じで、ある一つの対象のオブジェクトに動作を加えていく感じがUnityとかで行います。
それに対し、AmethystではSystemというボールの動きを再現するもの、ボールがバーで跳ね返すときの処理をするものをそれぞれ作成するのですが、それぞれどのオブジェクトを登場させるかを後から指定します。つまりUnityとは逆に、動き(のシステム)に関係のあるオブジェクトを追加する感じになります。
#[derive(SystemDesc)]
pub struct CharasSystem;
impl CharasSystem {
fn move_to_destination(&mut self, transform: &mut Transform, charas: &mut Charas) {
//キャラクターの移動をさせる
}
}
impl<'s> System<'s> for CharasSystem {
type SystemData = (
Entities<'s>,
WriteStorage<'s, Transform>,
WriteStorage<'s, Charas>,
Read<'s, InputHandler<StringBindings>>,
Read<'s, Time>,
);
fn run(&mut self, (entities, mut transforms, mut charass, input, time): Self::SystemData) {
for (charas, transform) in (&mut charass, &mut transforms).join() {
if charas.now_move {
self.move_to_destination(transform, charas);
charas.time_update(time.delta_seconds() as f32);
}
}
}
}
電光掲示板上の文字を動かすSystemです。
SystemDataでどういう内容の情報を読み取る、または書き込むかを指定して、runの引数で受け取ります。Charas(文字のstruct)とtrnasformを編集するので、WriteStrorageで呼び出します。for分で登録された全文字を一つずつ取り出して、移動させます。
SystemData
簡単に言うとグローバル変数やグローバル関数を機能ごとに作れるというものです。これの強みは、SystemとState間の値の受け渡しが簡単に済むことです。
pub struct PlayerData {
pub now_time: f32,
pub now_speed: f32,
pub is_running: bool,
pub make_alphabet_flag: bool,
pub speed_up_flag: bool,
pub now_last_x: f32,
}
impl PlayerData {
pub fn init_game(&mut self) {
self.now_time = 0.0;
self.now_speed = speed_at_game_start;
self.is_running = true;
self.make_alphabet_flag = false;
self.speed_up_flag = false;
self.now_last_x = 352.0;
}
}
impl Default for PlayerData {
fn default() -> Self {
PlayerData {
now_time: 0.0,
now_speed: speed_at_game_start,
is_running: true,
make_alphabet_flag: false,
speed_up_flag: false,
now_last_x: 352.0,
}
}
}
今回作ったゲームでは、ゲームオーバーまでの時間が得点なので、メンバにnow_timeをもたせてState内で更新する感じです。
また、文字が右から出てきて、左に到達してしまうとゲームオーバーの仕組みなので、先ほどの文字のSystem(CharasSystem)が、左端に来た時を検知して、is_runningをtrueにして、Stateがそれを読み込んで、ゲームオーバーにシーン遷移(state遷移!?)させるっといったこともできます。
world
世界ですね。
キャラのentity(物体)や、SystemDataなどを保持していて、全State上やSystem上で呼び出し可能な形にするために入れておくプールみたいなもののひとくくりを表す感じです。
#[derive(Default)]
pub struct Def{
pub sprite_sheet_handle: Option<Handle<SpriteSheet>>,
}
impl SimpleState for Def {
fn on_start(&mut self, data: StateData<'_, GameData<'_, '_>>) {
let world = data.world;
self.sprite_sheet_handle.replace(load_sprite_sheet(world));
world.register::<Charas>();
world.insert(self.sprite_sheet_handle.clone().unwrap());
init_camera(world);
init_bg(world, self.sprite_sheet_handle.clone().unwrap());
let score_data = PlayerData::default();
world.insert(score_data);
}
fn update(&mut self, data: &mut StateData<'_, GameData<'_, '_>> ) -> SimpleTrans {
//Test mainScene
data.world
.write_resource::<EventChannel<TransEvent<GameData<'static, 'static>, StateEvent>>>()
.single_write(Box::new(|| Trans::Push(Box::new(MainScene::default()))));
println!("First Scene Now");
Trans::None
}
}
最初のほうで紹介したMainSceneとはことなり、ただ画像などを準備するだけのシーンです。world.register::<Charas>();でworldにCharasというキャラクタ(文字)のオブジェクト情報(struct)を入れてます。これにより、あとから文字のオブジェクトを作るためのコピー元がworldに入るので、スポーンする準備ができました。
world.insert(score_data)ではSystemDataで説明したscore_dataを
まとめると…
- Stateでシーンとその全体の流れを作る
- Systemでゲームオブジェクトに動きをつける
- SystemDataでStateとSystem間の情報のやり取りを行う。
ていう感じでやるのがおそらく簡単だと思います。 まだ使い始めて一週間なのでこれ以上詳しい議論ができない。
後キー入力の話などは結構特殊で、jsonファイルみたいなronファイルにキーの情報を記入していきます。
Aキーが押されたらどの関数を呼び出すなど。
触って気づいた点について
全部コードなのでGitで見た時の変更がわかりやすい
まあ普段から、Git使って.unityファイルのようなバイナリファイルを扱うような人にしかなかなか伝わらないのですが、特にunityのシーンファイルの変更はバイナリのため、いちいち開かないと何をどう編集したかわからないのが問題です。特にコンフリクトを起こされた日にはかなり厄介です。
その点、Amethystはバイナリファイルが基本ないので、そういう意味での視認性はとても高いと考えられます。
エディタウィンドウがないことについて
エディタウィンドウがないので、特にスクリーンが一つの場合いちいちテストのためにテキストエディタとウィンドウ表示を入れ替えなくていいことが利点でもありますが、やはりゲームってビジュアルも大切で、そのように直観的に操作できない点についてはちょっと問題かなと思います。
Rustが励ましてくれる
コンパイルエラーは基本突き放すような表現が多いので、どんどんしんどさが積もりますが、Rustではこういう風にhelpを自動でくれたりするので、まだ頑張れる気になります。
終わりに
このゲームエンジン自体かなり最近できたものだけあって、今も開発、仕様変更が盛んなので、いろいろ振り回されましたが、いい経験になったと思います。
ここで終わる…といいたいところですが、せっかくゲームを作った(厳密にいうとこの記事を書いた今日時点ではまだ完成してない)ので明日の別の記事に利用させてもらおうと思います。
てなわけで、明日の担当者も @Hinaruhi です。お楽しみに~