WebAssemblyで遊んでみるその2〜web_sysでブロック崩し〜

こんにちは。とらのあなラボ所属のY.Fです。
前回のWebAssemblyの記事ではチュートリアルを通して環境構築&JavaScriptとWebAssemblyを連携する方向でアプリケーションを作成しました。

(前回の記事はこちら) toranoana-lab.hatenablog.com

今回は、web_sysというCrateを使ってRustのみでMDNのCanvasチュートリアルで紹介されているブロック崩しを作ってみます。

(MDN) developer.mozilla.org

(web_sys) rustwasm.github.io

web_sysとは

前回の記事では説明していませんでしたが、中身では wasm_bindgen というCrateを使って、RustとJSの世界の結びつけを行っていました。
今回は更に web_sys というCrateを使って見ます。
これはRustからDOM APIを触るためのライブラリとなります。(以下のような感じ)

let document = web_sys::window().unwrap().document().unwrap();
let canvas = document.get_element_by_id("myCanvas2").unwrap();

今回はこれを使ってCanvasAPIを触ります。

MDNのCanvasチュートリアルで出来上がるもの

前回同様、先に出来るものをお見せします。
マウスと矢印で操作できるブロック崩しが出来上がります。
f:id:toranoana-lab:20190813182349g:plain

JavaScriptのソースはまたMDN見てもらえれば分かると思うので割愛します。
以下ではJavaScriptからRustへ移植する際に困ったところなどを中心に説明します。

多重配列の扱い

JavaScript版では対象のブロック一覧を縦横の多重配列で確保していました。

let bricks = [];
for (let c = 0; c < brickColumnCount; c++) {
  bricks[c] = [];
  for (let r = 0; r < brickRowCount; r++) {
    bricks[c][r] = { x: 0, y: 0, status: 1 };
  }
} 

JavaScriptので、配列の大きさ指定なども不要なので上記のような書き方ができていましたが、Rustではそうも行きません。
ココでは Vec を使って以下のようにしました。

type Bricks = Vec<Vec<Brick>>;
let mut bricks: Bricks = Vec::new();
bricks.resize(BRICK_COLUMN_COUNT, Vec::new());
for c in 0..bricks.len() {
    bricks[c].resize(BRICK_ROW_COUNT, Brick::new(0, 0, 1));
}

JavaScriptと同じゆるふわループでは動かないので、 resize メソッドを使って、行と列の大きさに長さを拡張しています。
resize メソッドは resize(確保したい大きさusize, 初期値として埋め込む値) という感じで使います。
これで、多重配列を初期値で埋めることが出来ました。

イベントハンドラの設定

こちらの方は結構悩みました。Rustの所有権やライフタイム、借用周りとイベントハンドラといういつまで参照を持てばいいかわからないような関数との兼ね合いといった感じです。
結果から先に見せると、以下のような感じになりました。

let paddle_x = (canvas.width() - PADDLE_WIDTH) as f64 / 2.0;
let paddle_x = Rc::new(Cell::new(paddle_x));
// マウスイベント
{
    let paddle_x = paddle_x.clone();

    let mousemove_handler = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
        let relative_x = e.client_x() - offset_left;
        if relative_x > 0 && relative_x < width {
            paddle_x.set(relative_x - PADDLE_WIDTH / 2.0);
        }
    }) as Box<dyn FnMut(web_sys::MouseEvent)>);
    document.set_onmousemove(Some(mousemove_handler.as_ref().unchecked_ref()));
    mousemove_handler.forget();
}

悩んだ点は以下です。

  1. paddle_x はボールを跳ね返すパドルの位置を持つ変数なので、イベントハンドラ外でも参照、変更される。
    • 矢印キーでの移動もするのでキーイベント内でも同じ参照を見ないといけない
  2. イベントハンドラどうやってつけるんだろう?
    • JavaScriptだとクロージャなどを使いこなして外側の変数にアクセスしていますが、そこら変どうしよう
    • そもそもイベントハンドラの生存期間とは・・・

まずひとつ目ですが、 Rc を使うことで解決可能です。

doc.rust-lang.org

// 普通に変数定義
let paddle_x = (canvas.width() - PADDLE_WIDTH) as f64 / 2.0;
// 元の変数に対して参照カウンタを持つポインタを作成する
// かつ可変参照をいくつかほしいのでCellで包む
let paddle_x = Rc::new(Cell::new(paddle_x));

これを先頭あたりに書いておいて、必要になった場合は中括弧でスコープを作り、その中で clone して参照を作ってあげれば複数のイベントハンドラで同じ参照先を使うことが出来ます。

次に2つ目ですが、 wasm_bindgen に用意されている Closure 構造体と、Rustのクロージャを組み合わせると出来るみたいです。

(Closureのドキュメント) rustwasm.github.io

基本的にはこれを使えばRustのクロージャをJavaScriptのクロージャにできます。
ただし、ドキュメントに書いてあるようにライフタイム的には 'static である必要があるようです。
したがって、設定するクロージャを move にしないと、キャプチャされる変数すべてのライフタイムをつけないといけなくなります。

// wrapメソッドでRustのクロージャをJSのクロージャにする
let mousemove_handler = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
    let relative_x = e.client_x() - offset_left;
    if relative_x > 0 && relative_x < width {
        // RcとCellで作った変数の値を更新
        paddle_x.set(relative_x - PADDLE_WIDTH / 2.0);
    }
}) as Box<dyn FnMut(web_sys::MouseEvent)>);
// as_refでJsValueを取り出し、unchecked_refでFunctionを取り出す
document.set_onmousemove(Some(mousemove_handler.as_ref().unchecked_ref()));
// このイベントハンドラの有効期限はページにいる間中ずっとなのでforgetする
mousemove_handler.forget();

これを使えば、 setTimeoutrequestAnimationFrame のような関数もRustから呼び出すことが出来ます。

...実は、ココまでの内容は以下のサンプルソースを見ると大体書いてあったりします。

rustwasm.github.io

ソースコード全体

拙いソースですが全体を貼っておきます。

出来上がったソース

mod utils;

use std::cell::Cell;
use std::f64;
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::console;

const BALL_RADIUS: f64 = 10.0;
const PADDLE_HEIGHT: f64 = 10.0;
const PADDLE_WIDTH: f64 = 75.0;

const BRICK_ROW_COUNT: usize = 3;
const BRICK_COLUMN_COUNT: usize = 5;
const BRICK_SUM: u32 = (BRICK_ROW_COUNT * BRICK_COLUMN_COUNT) as u32;
const BRICK_WIDTH: f64 = 75.0;
const BRICK_HEIGHT: f64 = 20.0;
const BRICK_PADDING: f64 = 10.0;
const BRICK_OFFSET_TOP: f64 = 30.0;
const BRICK_OFFSET_LEFT: f64 = 30.0;
const SPEED: f64 = 2.0;

type Bricks = Vec<Vec<Brick>>;

#[derive(Debug, Clone, Copy)]
struct Brick {
    x: f64,
    y: f64,
    status: u32,
}

impl Brick {
    fn new(x: f64, y: f64, status: u32) -> Brick {
        Brick {
            x: x,
            y: y,
            status: status,
        }
    }
}

// startやると自動で実行されるみたい
#[wasm_bindgen(start)]
pub fn start() -> Result<(), JsValue> {
    let document: web_sys::Document = web_sys::window().unwrap().document().unwrap();
    // unwrapするのでJS版のifチェックは不要(panicするので・・・)
    let canvas = document.get_element_by_id("myCanvas2").unwrap();
    let canvas: web_sys::HtmlCanvasElement = canvas
        .dyn_into::<web_sys::HtmlCanvasElement>()
        .map_err(|_| console::log_1(&JsValue::from_str("CanvasElement is invalid")))
        .unwrap();

    let context = canvas
        .get_context("2d")
        .unwrap()
        .unwrap()
        .dyn_into::<web_sys::CanvasRenderingContext2d>()
        .unwrap();

    let width = canvas.width();
    let height = canvas.height();
    let offset_left = canvas.offset_left();

    let paddle_x = (canvas.width() as f64 - PADDLE_WIDTH) / 2.0;
    let right_pressed = false;
    let left_pressed = false;

    let mut bricks = build_bricks();
    let mut score = 0 as u32;
    let mut lives = 3 as u32;
    let mut x = canvas.width() as f64 / 2.0;
    let mut y = canvas.height() as f64 - 30.0;
    let mut dx = 2.0 * SPEED;
    let mut dy = -2.0 * SPEED;

    let f = Rc::new(RefCell::new(None));
    // イベントハンドラ内で変更して、描画処理で使うものについては参照を共有したいのでRcで作る
    let context = Rc::new(context);
    // 変更したいやつらはCellで作る
    let paddle_x = Rc::new(Cell::new(paddle_x));
    let right_pressed = Rc::new(Cell::new(right_pressed));
    let left_pressed = Rc::new(Cell::new(left_pressed));

    {
        let g = f.clone();
        let context = context.clone();
        let paddle_x = paddle_x.clone();
        let right_pressed = right_pressed.clone();
        let left_pressed = left_pressed.clone();

        *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
            draw_ball(&context, width as f64, height as f64, x, y);
            draw_bricks(&context, &mut bricks);
            draw_paddle(&context, height as f64, &paddle_x.get());

            draw_score(&context, score);
            draw_lives(&context, lives, width as f64);
            collision_detection(&mut bricks, &mut score, &mut dy, x, y);

            if x + dx > width as f64 - BALL_RADIUS || x + dx < BALL_RADIUS {
                dx = -dx;
            }

            if y + dy < BALL_RADIUS {
                dy = -dy;
            } else if y + dy > height as f64 - BALL_RADIUS {
                if x > paddle_x.get() && x < paddle_x.get() + PADDLE_WIDTH {
                    dy = -dy;
                } else {
                    lives -= 1;
                    if lives == 0 {
                        let _ = web_sys::window().unwrap().alert_with_message("GAME OVER");
                        let _ = web_sys::window().unwrap().location().reload();
                    } else {
                        x = width as f64 / 2.0;
                        y = height as f64 - 30.0;
                        dx = 2.0 * SPEED;
                        dy = -2.0 * SPEED;
                        paddle_x.set((width as f64 - PADDLE_WIDTH) / 2.0);
                    }
                }
            }


            if right_pressed.get() && paddle_x.get() < width as f64 - PADDLE_WIDTH {
                paddle_x.set(paddle_x.get() + 7.0);
            } else if left_pressed.get() && paddle_x.get() > 0.0 {
                paddle_x.set(paddle_x.get() - 7.0);
            }
            x += dx;
            y += dy;

            request_animation_frame(f.borrow().as_ref().unwrap());
        }) as Box<dyn FnMut()>));

        request_animation_frame(g.borrow().as_ref().unwrap());
    }

    // キーボードのキー押した時のイベント
    {
        let right_pressed = right_pressed.clone();
        let left_pressed = left_pressed.clone();

        let keydown_handler = Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
            if e.key() == "Right" || e.key() == "ArrowRight" {
                right_pressed.set(true);
            } else if e.key() == "Left" || e.key() == "ArrowLeft" {
                left_pressed.set(true);
            }
        }) as Box<dyn FnMut(web_sys::KeyboardEvent)>);

        document.set_onkeydown(Some(keydown_handler.as_ref().unchecked_ref()));
        keydown_handler.forget();
    }

    // キーボードのキー離したときのイベント
    {

        let right_pressed = right_pressed.clone();
        let left_pressed = left_pressed.clone();

        let keyup_handler = Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
            if e.key() == "Right" || e.key() == "ArrowRight" {
                right_pressed.set(false);
            } else if e.key() == "Left" || e.key() == "ArrowLeft" {
                left_pressed.set(false);
            }
        }) as Box<dyn FnMut(web_sys::KeyboardEvent)>);
        document.set_onkeyup(Some(keyup_handler.as_ref().unchecked_ref()));
        keyup_handler.forget();
    }

    // マウスイベント
    {
        let paddle_x = paddle_x.clone();

        let mousemove_handler = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
            let relative_x = e.client_x() - offset_left;
            if relative_x > 0 && relative_x < (width as i32) {
                paddle_x.set(relative_x as f64 - PADDLE_WIDTH / 2.0);
            }
        }) as Box<dyn FnMut(web_sys::MouseEvent)>);
        document.set_onmousemove(Some(mousemove_handler.as_ref().unchecked_ref()));
        mousemove_handler.forget();
    }
    Ok(())
}

fn request_animation_frame(f: &Closure<dyn FnMut()>) {
    web_sys::window()
        .unwrap()
        .request_animation_frame(f.as_ref().unchecked_ref())
        .expect("should register `requestAnimationFrame` OK");
}

fn build_bricks() -> Bricks {
    let mut bricks: Bricks = Vec::new();
    bricks.resize(BRICK_COLUMN_COUNT, Vec::new());
    for c in 0..bricks.len() {
        bricks[c].resize(BRICK_ROW_COUNT, Brick::new(0.0, 0.0, 1));
    }
    bricks
}

fn draw_ball(ctx: &web_sys::CanvasRenderingContext2d, width: f64, height: f64, x: f64, y: f64) {
    ctx.clear_rect(0.0, 0.0, width, height);
    ctx.begin_path();
    ctx.arc(x, y, BALL_RADIUS, 0.0, f64::consts::PI * 2.0)
        .unwrap();
    // consoleのときと同じ要領でJsValueに変換
    ctx.set_fill_style(&"rgb(0, 149, 208)".into());
    ctx.fill();
    ctx.close_path();
}

fn draw_paddle(ctx: &web_sys::CanvasRenderingContext2d, height: f64, paddle_x: &f64) {
    ctx.begin_path();
    ctx.rect(
        *paddle_x,
        height - PADDLE_HEIGHT,
        PADDLE_WIDTH,
        PADDLE_HEIGHT,
    );
    ctx.set_fill_style(&"rgb(0, 149, 208)".into());
    ctx.fill();
    ctx.close_path();
}

fn draw_bricks(ctx: &web_sys::CanvasRenderingContext2d, bricks: &mut Bricks) {
    for c in 0..bricks.len() {
        for r in 0..bricks[c].len() {
            if bricks[c][r].status == 1 {
                let brick_x = c as f64 * (BRICK_WIDTH + BRICK_PADDING) + BRICK_OFFSET_LEFT;
                let brick_y = r as f64 * (BRICK_HEIGHT + BRICK_PADDING) + BRICK_OFFSET_TOP;
                bricks[c][r].x = brick_x;
                bricks[c][r].y = brick_y;
                ctx.begin_path();
                ctx.rect(brick_x, brick_y, BRICK_WIDTH, BRICK_HEIGHT);
                ctx.set_fill_style(&"rgb(0, 149, 208)".into());
                ctx.fill();
                ctx.close_path();
            }
        }
    }
}

fn collision_detection(bricks: &mut Bricks, score: &mut u32, dy: &mut f64, x: f64, y: f64) {
    for c in 0..bricks.len() {
        for r in 0..bricks[c].len() {
            let b = bricks[c][r];
            if b.status == 1 {
                if x > b.x && x < b.x + BRICK_WIDTH && y > b.y && y < b.y + BRICK_HEIGHT {
                    *dy = -(*dy);
                    bricks[c][r].status = 0;
                    *score += 1;
                    if *score == BRICK_SUM {
                        let _ = web_sys::window()
                            .unwrap()
                            .alert_with_message("YOU WIN, CONGRATULATIONS!");
                        let _ = web_sys::window().unwrap().location().reload();
                    }
                }

            }
        }
    }
}

fn draw_score(ctx: &web_sys::CanvasRenderingContext2d, score: u32) {
    ctx.set_font("16px Arial");
    ctx.set_fill_style(&"rgb(0, 149, 208)".into());
    let _ = ctx.fill_text(&format!("Score: {}", score), 8.0, 20.0);
}

fn draw_lives(ctx: &web_sys::CanvasRenderingContext2d, lives: u32, width: f64) {
    ctx.set_font("16px Arial");
    ctx.set_fill_style(&"rgb(0, 149, 208)".into());
    let _ = ctx.fill_text(&format!("Lives: {}", lives), width - 65.0, 20.0);
}

まとめ

Rustでフロントのソースをガリガリ書くのは新感覚ですが、以下のような印象を受けました。

  • Rustの所有権や参照周りの安全性は変わらずに享受出来ると思いました
  • 単純にJSがRustになっただけなような感覚なので、カオスになる予感がする
    • React.jsなどの仕組みが欲しくなります。(要するにフレームワーク?)
    • 前の記事と同様に画面描画はJSで行い、内部ロジックだけRustとかのほうが良いかもしれないです
  • unwrap とかが沢山出てくる
    • expect とかでちゃんと処理すべきな気がしますが、今回のソースではサボっていますね・・・

P.S.

虎の穴では一緒に働く仲間を絶賛募集中です! この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。 yumenosora.co.jp

また、今月8/28には渋谷にて会社説明会を開催いたします。ご興味のある方は是非ご応募ください! yumenosora.connpass.com