LoginSignup
26
20

More than 3 years have passed since last update.

Rustでスネークゲームを作る【pistonチュートリアル】

Last updated at Posted at 2019-12-31

あけましておめでとうございます!!

今年もよろしくおねがいします!

今年は子年で、蛇関係ないですが、スネークゲームを作ります。

Rustに入門したい

ゲームを作りながらRustを勉強してみたいんです。

海外の方がYoutubeでRust向けゲームエンジンであるpistonを用いて、
スネークゲームを作ってる動画を見つけたので、
翻訳しながら、自分が勉強したことをまとめていこうと思います。
Making a Snake Game in Rust
日本語訳チュートリアルとして、貢献したいな、と思っています。

この記事での完成形は、こんな感じ。この記事では、スネークが動くところまで実装します。
ここまでのソースコードはGithubに置いておきます。

Snake Game_065

Rust初心者が書く記事ですので、
間違ったことを書いてしまっていた場合はご指摘いただけると幸いです。
よろしくおねがいします。

200行のRustでスネークゲームを作られた方も。
記事を書き終えてから、ぜひソースコードを眺めて理解したいです。

Rustの開発環境の準備はとても簡単なので省略します。
記事も書かれているので参考に。Rustのアップデート方法もここにお世話になりました。
rustup で Rust コンパイラーを簡単インストール

開発環境

  • Manjaro Linux 18.1.5
  • Visual Studio Code 1.14.1
  • rustup 1.12.1
  • rustc 1.40.0
  • cargo 1.40.0

Rustのアップデートは以下のコマンドを叩きます。

$ rustup update

プロジェクトの構築

PistonDevelopers/Piston-Tutorialsにもプロジェクトの構築手順が記載されています。
こちらも適宜参考にします。

プロジェクトを作成します。
--binオプションの他にも、--libオプションがあります。

  • --bin : ビルドした際に、実行可能ファイルを作成する場合
  • --lib : ビルドした際に、他のRustパッケージから利用できるライブラリファイルを作成する場合

というように使い分け、どちらも指定しなければ、--binが使用されます。

$ cargo new snake_game --bin

次に依存関係のインストールを行う。

Setting Up The Projectに書かれているdependenciesをコピペする。

Cargo.toml
[package]
name = "snake_game"
version = "0.1.0"
authors = ["Hiroya_W <mail@address>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
# ここから下の部分
piston = "0.49.0"
piston2d-graphics = "0.35.0"
pistoncore-glutin_window = "0.63.0"
piston2d-opengl_graphics = "0.69.0"

コマンドからビルドする。
これにより、すべての依存関係が取得され、コンパイルされます。

$ cd snake_game
$ cargo build

ドキュメントを作成する。--openオプションをつければ、ブラウザでドキュメントが開く。
あとで使うらしい。

$ cargo doc --open

いざ

ソースコードは全てmain.rsに書いていきます。

外部クレートの読み込み(OLD)

クレートとは、他の言語における「ライブラリ」やパッケージと同じ意味です。
先程の依存関係のインストールでは、クレートがインストールされます。

このクレートを使用することをRust 2015では記述する必要がありました。
ので、こう書きます。

extern crate glutin_window;
extern crate graphics;
extern crate opengl_graphics;
extern crate piston;

fn main() {
    println!("Hello, world!");
}

現在はRust 2018がリリースされ、それを利用しています。
Rust 2018からは書く必要が無くなったようです。

extern crateが不要になり、use文で他クレートのシンボルを直接インポートできるようになります。

Rust 2018のリリース前情報

消しちゃいましょう。

以下のようにuse文を用いて他の名前空間にあるプログラムの要素をインポートします。

use glutin_window::GlutinWindow;
use opengl_graphics::{GlGraphics, OpenGL};
use piston::event_loop::*;
use piston::input::*;
use piston::window::WindowSettings;

この時、アスタリスクには注意する必要があります。
アスタリスクを用いることで、私達の名前空間にその名前空間の全てを読み込むことになります。
使用している関数や変数がどこから読み込んだものなのか見失ってしまったり、意図しない関数や変数を読み込んでしまう原因になります。

当時は、サンプルプログラムがアスタリスクを使用して記述されていたようです。現在は使わない記述がされているので、そのように修正します。

しなければならない、ではなく、した方がいい、という部分ですね。

外部クレートの読み込み(NEW)

結局のところ、外部クレートを読み込む部分の記述は以下のようになります。
サンプルプログラムのように、asを使えば、自分で分かりやすい名前を付けて
読み込めるようですが、あえてここでは使わずに進めます。
以降、本記事では、記載するコードブロックが長くなってしまうため、use文の記述を省略します。

use glutin_window::GlutinWindow;
use opengl_graphics::{GlGraphics, OpenGL};
use piston::event_loop::{EventSettings, Events};
use piston::input::{RenderArgs, RenderEvent, UpdateArgs, UpdateEvent};
use piston::window::WindowSettings;

アプリの設計のお話

画像は参考元動画から使用しています。

アプリの一般的なデザインは次の通りです。
ゲームを表すBlobデータまたは構造体があります。
この構造は、ウィンドウに描画する「レンダリング」メソッドを持っています。

Making a Snake Game in Rust 2-4 screenshot

キー入力やスネークの当たり判定など、ゲーム内の全てのものはレンダリングとは別に発生します。
それらは、このBlobデータを更新するだけです。

Making a Snake Game in Rust 2-7 screenshot

Making a Snake Game in Rust 2-11 screenshot

もちろん、その時Blobデータはレンダリングするように伝えられるので、スネークが歩き回るアニメーションを見ることができます。

Making a Snake Game in Rust 2-18 screenshot

ウィンドウの作成

最初のステップはウィンドウを作成することです。
main関数では、opengl変数とGlutinWindowを作成します。

fn main() {
    let opengl = OpenGL::V3_2;
    let mut window : GlutinWindow = WindowSettings::new(?,?); // この使い方は...?
}

サンプルコードを見れば、この静的メソッドの使い方がわかります。
しかし、そうしない場合はどうすればいいのでしょうか?

ドキュメントを見てみる

プロジェクトの作成時に作成したドキュメントを使用し、
WindowSettingを検索してみます。

出てきました!

範囲を選択_054

見てみると、まず、titleはジェネリック型のTを持ち、
String型に変換できる必要があることが分かります。
また、ジェネリック型のSを持つsizeもあり、Size型に変換できる必要があります。

わかった?

もちろん、わかりません。Size型とは何でしょうか?
Sizeをクリックしてみれば、分かります。

つまり、Trait Implementationsに書かれている4つのうち、どれかの形で記述すれば良いのです。

コードを書く

ウィンドウのタイトル、幅と高さを記述し、続くメソッドはサンプルを参考に記述していきます。

fn main() {
    let opengl = OpenGL::V3_2;
    let mut window: GlutinWindow = WindowSettings::new("Snake Game", [200, 200])
        .graphics_api(opengl)
        .exit_on_esc(true)
        .build()
        .unwrap();
}

実行してみる

以下のコマンドでビルドしてから実行することができます。

$ cargo run

今の状態では、ウィンドウが開くと、すぐに閉じるという動作をします。
なぜなら、プログラムのmain関数の最後まで実行されたため、プログラムが終了するからです。

イベントループ

コードを書く

もう少しサンプルからコピペします。
動画じゃコピーパスタって言ってて面白かった。
もちろん、常に責任を持ってコピペをするようにしてください。

fn main() {
    let opengl = OpenGL::V3_2;
    let mut window: GlutinWindow = WindowSettings::new("Snake Game", [200, 200])
        .graphics_api(opengl)
        .exit_on_esc(true)
        .build()
        .unwrap();
    /* ---------------------------
              ここから下
     --------------------------- */
    let mut events = Events::new(EventSettings::new());
    while let Some(e) = events.next(&mut window) { // ここは何をしているのでしょうか?
        if let Some(args) = e.render_args() {
            // app.render(&args);
        }
    }
}

さて、event.nextは何をするのでしょうか?

ドキュメントを見てみる

event_loop::Eventsを検索します。

範囲を選択_056

見つかりました!探すのが上手になりましたね!

範囲を選択_057

Option<Event>Eventをクリックして見てみると、
Event列挙型(Enum)であり、Input,Loop,Customの異なる表現があることが分かります。

範囲を選択_058

Trait Implementationsには、全て異なるイベントのトレイトが書かれています。
これらは全て、Event列挙体にメソッドを追加します。
ここでは、基本的に、受け取ったイベントの型を確認することができます。

範囲を選択_059

今知りたいRenderEventトレイトのrender_argsメソッドは、
オプション型Option<RenderArgs>の型を返す、ということが分かります。

オプション型は値があれば、Someで値を包み、無いときはNoneが使われます。

if let文を眺めてみる

次にif let文によるパターンマッチを眺めてみます。

    let mut events = Events::new(EventSettings::new());
    while let Some(e) = events.next(&mut window) {
        if let Some(args) = e.render_args() { // ここのif let文を眺める
            // app.render(&args);
        }
    }

このif let文が意味するのは、「EventRenderEventである時、何か処理をする」ということです。

EventRenderEventでない時、render_argsメソッドはNone返します。
つまり、if let文でのパターンマッチに失敗するので、処理は行われません。

App構造体を作る

Appという構造体を作り、GlGraphicsを持たせます。
GlGraphicsは、ウィンドウ内に物を描画する役割を持ちます。
続いて、構造体にrenderメソッドを追加します。

// App構造体
struct App{
    gl : GlGraphics,
}

// App構造体のメソッドの定義
impl App {
    fn render(&mut self, arg: &RenderArgs) {}
}

fn main() {
    //...
}

RustはPythonのように動き、renderメソッドの最初の引数self
メソッドを呼び出すインスタンス(レシーバともいう)が渡されます。

rubyのレシーバとは

また、Rustなので、メソッドがインスタンスを取得する方法を指定できます。

  • self : メソッドを呼び出すインスタンスの所有権がメソッドに移動する
  • &self : メソッドを呼び出すインスタンスをイミュータブルな参照として使用する
  • &mut self : メソッドを呼び出すインスタンスをミュータブルな参照として使用する

今回のrenderメソッドでは、&mut selfとして使用します。
なぜなら、画面への描画はミュータブルである必要があるからです。
また、renderRenderArgsイベントへの参照も行います。

色を作る

緑色の背景がほしいので、色を作ります。

色はRed,Green,Blue,Opacityの4つの値を持ち、
値の範囲は0.0~1.0でなければなりません。

impl App {
    fn render(&mut self, arg: &RenderArgs) {
        use graphics;

        let GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
    }
}

描画する部分

グラフィックスレンダラーを用いて描画させます。
そのためには、レンダラーイベントから取得できるviewportとクロージャが必要です。
クロージャはdrawメソッドにより呼び出される無名関数として働き、
コンテキストと自分自身を2つの引数として渡します。

graphicsライブラリのclearメソッドを使用して、ウィンドウの色を指定できます。

impl App {
    fn render(&mut self, args: &RenderArgs) {
        use graphics;

        let GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
        // 緑色の背景を描画させる
        self.gl.draw(args.viewport(), |_c, gl| {
            graphics::clear(GREEN, gl);
        });
    }
}

App構造体を使う

main関数で作成したApp構造体を初期化し、renderメソッドを呼び出します。

fn main() {
    let opengl = OpenGL::V3_2;
    let mut window: GlutinWindow = WindowSettings::new("Snake Game", [200, 200])
        .graphics_api(opengl)
        .exit_on_esc(true)
        .build()
        .unwrap();

    // 初期化
    let mut app = App {
        gl: GlGraphics::new(opengl),
    };

    let mut events = Events::new(EventSettings::new());
    while let Some(e) = events.next(&mut window) {
        if let Some(args) = e.render_args() {
            // メソッド呼び出し
            app.render(&args);
        }
    }
}

実行してみる

緑色のウィンドウが描画されれば成功です!

Snake Game_060

ウィンドウタイトルはスクリーンショットを撮る関係上消えてしまってますが、
ちゃんとSnake Gameと表示されています。

これを、スネークゲームにしていきます。

スネークゲームを作る

スネーク構造体を追加する

App構造体に、snakeフィールドを作る

struct App {
    gl: GlGraphics,
    snake: Snake,
}

そして、とても簡単なSnake構造体を作ります。
Snake構造体はX座標Y座標を持ち、renderメソッドを持たせます。
renderメソッドには、GlGraphicsを引数に渡すことでrenderイベントだけでなく、自分自身を描画できるようにします。

struct Snake {
    pos_x: i32,
    pos_y: i32,
}

impl Snake {
    fn render(&self, gl: &mut GlGraphics, args: &RenderArgs) {
        use graphics;

        let RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
        // 正方形を作る
        let square = graphics::rectangle::square(self.pos_x as f64, self.pos_y as f64, 20_f64);
        // 自分自身を描画する
        gl.draw(args.viewport(), |c, gl| {
            let transform = c.transform;

            graphics::rectangle(RED, square, transform, gl);
        });
    }
}

正方形を描画するコードは、Apprenderメソッドと非常に似ていて、Apprenderメソッドでウィンドウを緑1色にclearした後、このメソッドを呼び出す必要があります。

impl App {
    fn render(&mut self, args: &RenderArgs) {
        use graphics;

        let GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];

        self.gl.draw(args.viewport(), |_c, gl| {
            graphics::clear(GREEN, gl);
        });
        // スネークの描画
        self.snake.render(&mut self.gl, args);
    }
}

App構造体のインスタンスで、Snakeを初期化することを忘れないように。

    let mut app = App {
        gl: GlGraphics::new(opengl),
        // 初期化
        snake: Snake {
            pos_x: 50,
            pos_y: 100,
        },
    };

ひとまず実行してみる

赤色の正方形が表示されれば成功です!

これで、基本的なスネークゲームを作るための必要なものが揃いました。

他のイベント

キー入力をする

キーボードイベントは別の種類のEventですが、RenderEventと同じ方法で取得できます。

範囲を選択_062

ゲームを更新する

ゲームを更新するためのUpdateEventもあります。
例えば、スネークが移動するとか。

範囲を選択_063

グリッドで移動するようにする

スネークは、1つの正方形ではなく、単なる正方形が連なったものです。

Rustでスネークゲームを作成する 5-46 screenshot

スネークに方向の情報を持たせることにします。

enum Direction {
    Right,
    Left,
    Up,
    Down,
}

ゲームをグリッドとして表示するようにスネークを修正します。

struct Snake {
    pos_x: i32,
    pos_y: i32,
    // 方向を持たせる
    dir: Direction,
}
    let mut app = App {
        gl: GlGraphics::new(opengl),
        snake: Snake {
            pos_x: 0,
            pos_y: 0,
            // 初期化
            dir: Direction::Right,
        },
    };

renderメソッドを修正し、グリッドに従って移動するようにします。

impl Snake {
    fn render(&self, gl: &mut GlGraphics, args: &RenderArgs) {
        use graphics;

        let RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];

        let square =
            // 移動量を調整
            graphics::rectangle::square((self.pos_x * 20) as f64, (self.pos_y * 20) as f64, 20_f64);

        gl.draw(args.viewport(), |c, gl| {
            let transform = c.transform;

            graphics::rectangle(RED, square, transform, gl);
        });
    }
}

今、ウィンドウサイズは200x200としています(単位はpixel)。
1マスのサイズは20x20とするなら、10x10のグリッドに区切ることができます。
この時、スネークの場所をグリッドの座標を用いて表すと、

  • 左上 pos_x : 0, pos_y : 0
  • 右下 pos_x : 9, pos_x : 9

と表せます。

では、UpdateEventでスネークが移動するように、App構造体にupdateメソッドを追加します。

    while let Some(e) = events.next(&mut window) {
        // RenderEvent
        if let Some(args) = e.render_args() {
            app.render(&args);
        }
        // UpdateEventの時、updateメソッドを呼び出す
        if let Some(args) = e.update_args(){
            app.update();
        }
    }

App構造体のupdateメソッドで、Snakeupdateメソッドを呼び出すようにします。

impl App {
    fn render(&mut self, args: &RenderArgs) {
        // ...
    }
    // updateメソッドの実装
    fn update(&mut self){
        self.snake.update();
    }
}

Snakeupdateメソッドでは、スネークが向いている方向に移動するようにします。
パターンマッチを使って座標を変更します。

impl Snake {
    fn render(&self, gl: &mut GlGraphics, args: &RenderArgs) {
        // ...
    }

    fn update(&mut self) {
        match self.dir {
            Direction::Left => self.pos_x -= 1,
            Direction::Right => self.pos_x += 1,
            Direction::Up => self.pos_y -= 1,
            Direction::Down => self.pos_y += 1,
        }
    }
}

とりあえず実行してみる

ものすごい速さで右側に移動するのが見えたでしょうか...。

イベントの更新回数を調節する

upsメソッドを使って1秒あたり8回更新されるようにします。
EventLoopの名前空間を読み込んで、

use piston::event_loop::{EventSettings, Events, EventLoop};

upsメソッドを呼び出します。

    let mut events = Events::new(EventSettings::new()).ups(8);

これで実行してみれば、いい感じに移動するようになっていると思います。

キー入力で方向を切り替える

キー入力のイベントを使用するために、名前空間を読み込みます。

use piston::input::{
    keyboard::Key, Button, ButtonEvent, ButtonState, RenderArgs, RenderEvent, UpdateArgs,
    UpdateEvent,
};

キー入力があった時に、pressedメソッドを呼び出すようにします。

        // ButtonEvent
        if let Some(args) = e.button_args() {
            // "キーを押した"っていう状態になって発火したイベントだったら
            if args.state == ButtonState::Press{
                app.pressed(&args.button);
            }
        }

pressedメソッドを実装します。
last_directionに所有権を渡すわけではないので、cloneで値をコピーします。
入力されたキーをパターンマッチで判断し、更にパターンに条件(ガード)をつけます。

こうすることで、スネークの方向転換に制限をかけます。

impl App {
    fn render(&mut self, args: &RenderArgs) {
        // ...
    }
    fn update(&mut self) {
        // ...
    }

    fn pressed(&mut self, btn: &Button) {
        // 値はcloneする
        let last_direction = self.snake.dir.clone();

        self.snake.dir = match btn {
            &Button::Keyboard(Key::Up) if last_direction != Direction::Down => Direction::Up,
            &Button::Keyboard(Key::Down) if last_direction != Direction::Up => Direction::Down,
            &Button::Keyboard(Key::Left) if last_direction != Direction::Right => Direction::Left,
            &Button::Keyboard(Key::Right) if last_direction != Direction::Left => Direction::Right,
            _ => last_direction,
        }
    }
}

でも、これにはバグがあり、コンパイラはDirectionを比較しようとするとエラーを吐きます。

そもそも、それらが等しいという概念を実装していないからです。

#[derive(Clone, PartialEq)]
enum Direction {
    Right,
    Left,
    Up,
    Down,
}

deriveで自動的に実装できます。とても簡単でした...。

ついてくる尻尾を追加する

ついてくる尻尾を追加します。
そのために、標準ライブラリのLinkedListを使用します。

use std::collections::LinkedList;

各尻尾のX座標,Y座標をペアとして持つLinkedListにスネークを修正していきます。

struct Snake {
    body: LinkedList<(i32,i32)>,
    dir: Direction,
}

そうすると、スネークの移動と、レンダリングの部分のコードを書き変えなければなりません。
移動するには、新しいスネークの頭、もしくは、リストの先頭をクローンします。
先程と同様の理由で、cloneしなければならないことに注意してください。

方向に基づいて頭の位置を更新し、LinkedListの先頭に追加し、最後の要素を取り除きます。
移動させるというよりかは、進行方向に新しい頭を置き、尻尾を1個消す、みたいなイメージ。

impl Snake {
    fn render(&self, gl: &mut GlGraphics, args: &RenderArgs) {
        // ...
    }

    fn update(&mut self) {
        let mut new_head = (*self.body.front().expect("Snake has no body")).clone();
        match self.dir {
            Direction::Left => new_head.0 -= 1,
            Direction::Right => new_head.0 += 1,
            Direction::Up => new_head.1-= 1,
            Direction::Down => new_head.1 += 1,
        }

        self.body.push_front(new_head);
        self.body.pop_back().unwrap();
    }
}

これをレンダリングしていきます。

レンダリングは各尻尾の部分に対して行いますが、本質的には全て同じことを行います。

  1. まず、Snake.bodyiterを使ってイテレータを回します。
    それぞれの要素において、mapを使って、座標のペアをxyの変数にマッピングして
    正方形を作成したものをベクトル型に格納していきます。

  2. 次に、作成したベクトル型のsquaresに対して、into_iterを使って、
    イテレータを回し、それぞれの正方形を描画していきます。

イテレータには3種類あり、

  • iter(&self) : 各要素を&T型で返すイテレータ
  • iter_mut(&mut self) : 各要素を&mut T型で返すイテレータ
  • into_iter(self) : 各要素をT型で返すイテレータ

into_iterメソッドは引数がselfで、コレクションの所有権が移動します。
なので、一度呼ぶとそのコレクションにはアクセスできなくなります。

参照の場合は、iterメソッド、ですね。

impl Snake {
    fn render(&self, gl: &mut GlGraphics, args: &RenderArgs) {
        use graphics;

        let RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
        // 正方形を作成したものをベクトル型に格納
        let squares: Vec<graphics::types::Rectangle> = self
            .body
            .iter()
            .map(|&(x, y)| graphics::rectangle::square((x * 20) as f64, (y * 20) as f64, 20_f64))
            .collect();

        gl.draw(args.viewport(), |c, gl| {
            let transform = c.transform;
            // それぞれの正方形を描画
            squares
                .into_iter()
                .for_each(|square| graphics::rectangle(RED, square, transform, gl));
        });
    }

    fn update(&mut self) {
        // ...
    }
}

main関数のsnakeインスタンスでの初期化も修正しなければなりません。
from_iterメソッドを使って、ベクトル型からLinkedListを作成します。
FromIteratorをインポートします。

use std::iter::FromIterator;

スネークの初期位置は、(0,0)と`(0,1)となります。

    let mut app = App {
        gl: GlGraphics::new(opengl),
        snake: Snake {
            body: LinkedList::from_iter((vec![(0, 0), (0, 1)]).into_iter()),
            dir: Direction::Right,
        },
    };

実行してみる

Snake Game_065

静止画でわかりにくくてごめんなさい。
いつかGIF画像に置き換えます。
こんな感じに、キー入力で移動、尻尾がついてくるところまで実装できました。

一旦ここまで

Youtubeでの解説はここまでで終わりでした。

ので、この記事も一旦ここまで、としたいと思います。

まだ移動までしか出来てなくて、ここからゲームらしさが追加されていくことになるので楽しみですね。

完成形のソースコードへのリンクはYoutubeの概要欄に記載されているので、次回はそれを参考に完成させたいと思います。

ここまで書いてみて、Rust特有の仕様、書き方を体験しつつ、ゲーム制作ができるいい題材だったのではないかな、と感じています。

26
20
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
26
20