WebAssemblyで遊んでみるその3〜RustとTypeScriptの分業〜

こんにちは、Y.Fです。

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

前回はweb_sysを使ってDOM操作までRustで行っていましたが、以下の理由から分離したいと思います。

ただし、 OffscreenCanvasRenderingContext2d の問題についてはすでにChromeでは実装されているため、時間の問題と思われます。
したがって、以下のような方針で分離してみます。

  • Rust側は基本的に画面描画に使うModel(struct)毎に分離する
  • 今後描画処理も含めてRustに戻す場合に備えて各Modelが自分で自分を描画するようにする

上記の様にしておけば、 OffscreenCanvasRenderingContext2d のようなAPIが存在しない場合でも、実装が無いときだけTypeScript/JavaScriptで書いて、
実装されたらRust側に処理を任せるなどの切り分けができそうかなと思います。

Modelの切り分け

今回は単純に画面上に出ているものをModelとして struct に分離していきます。

f:id:toranoana-lab:20190822145649p:plain

  • ゲーム自体の状態を管理する: GameStatus
    • 画面上部の赤枠です。
  • ブロック一つ一つ: Brick
  • ブロックの集合: Bricks
    • これはすでに多重配列として定義していたので、 type alias で定義するだけです
  • ボール: Ball
  • ボールを跳ね返すパドル: Paddle

これら一つ一つに描画用の関数と状態更新用の関数を必要に応じて実装していきます。

今回は、GameStatusの実装のみ参考として説明します。基本はどれも同じ塩梅で切り分けできます。

GameStatusの切り分け

最初にソースの全景を貼ります。

use crate::consts::DEFAULT_LIVES;
use crate::consts::DEFAULT_SCORE;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[repr(u8)]
pub enum Status {
    Prepare,
    Stop,
    Start
}

#[wasm_bindgen]
#[derive(Debug, Clone, Copy, Serialize)]
pub struct GameStatus {
    score: u32,
    lives: u32,
    status: Status
}

#[wasm_bindgen]
impl GameStatus {
    pub fn set_score(&mut self, score: u32) {
        self.score = score;
    }

    pub fn get_score(&self) -> u32 {
        self.score
    }
    pub fn set_lives(&mut self, lives: u32) {
        self.lives = lives;
    }

    pub fn get_lives(&self) -> u32 {
        self.lives
    }

    pub fn get_status(&self) -> Status {
        self.status
    }

    pub fn set_status(&mut self, status: Status) {
        self.status = status;
    }

    pub fn new() -> GameStatus {
        GameStatus {
            score: DEFAULT_SCORE,
            lives: DEFAULT_LIVES,
            status: Status::Prepare
        }
    }

    // ここらへんはRust側に描画処理を戻したときのために残してある
    pub fn draw_score(&self, ctx: &web_sys::CanvasRenderingContext2d) {
        ctx.set_font("16px Arial");
        ctx.set_fill_style(&"rgb(0, 149, 208)".into());
        let _ = ctx.fill_text(&format!("Score: {}", self.score), 8.0, 20.0);
    }

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

メソッド呼び出し

以下の部分はstatic methodになっており、GameStatus自身を生成します。俗に言うコンストラクタみたいなものです。

pub fn new() -> GameStatus {
    GameStatus {
        score: DEFAULT_SCORE,
        lives: DEFAULT_LIVES,
        status: Status::Prepare
    }
}

JavaScript側で使う場合は以下のような感じです。

const ball = Ball.new(
  2 * speed(),
  -2 * speed(),
  canvas.width / 2,
  canvas.height - ball_radius() * 2.0
);

new関数を呼んでいるように、 impl で指定した他の関数も似た感じでJS側から実行可能です。

こんな感じで、基本的には構造体とメソッドを [wasm_bindgen] で公開し、JavaScript側でWebAssembly側のロジックを叩く感じになります。

描画以外の重要ロジックもRustに寄せる

モデルをRust側にしただけだと旨味が少なそうです。 なので、ブロックの表示、ブロックの判定処理、盤面アップデート処理のような多重のループ処理をRustに寄せます。 とはいえ、描画処理や、alert表示はRust側ではやっぱりやってほしくないので、コールバックとして描画系処理を受け取るようにします。

ブロック描画(Bricksのメソッド)

pub fn draw_with_callback(&mut self, callback: &js_sys::Function) {
    for c in 0..self.bricks.len() {
        for r in 0..self.bricks[c].len() {
            if self.bricks[c][r].get_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;
                self.bricks[c][r].set_x(brick_x);
                self.bricks[c][r].set_y(brick_y);

                let this = JsValue::NULL;
                let _ = callback.call2(&this, &JsValue::from(brick_x), &JsValue::from(brick_y));
            }
        }
    }
}

コールバックは callback: &js_sys::Function という形で引数に取れます。 また、呼び出し時は引数の数によって call0call1call2 を呼び替えます。

ブロックあたり判定処理(Bricksのメソッド)

pub fn collision_detection_with_callback(
    &mut self,
    status: &mut GameStatus,
    ball: &mut Ball,
    callback: &js_sys::Function,
) {
    for c in 0..self.bricks.len() {
        for r in 0..self.bricks[c].len() {
            let b = self.bricks[c][r];
            if b.get_status() == 1 {
                let brick_x = b.get_x();
                let brick_y = b.get_y();
                let x = ball.get_x();
                let y = ball.get_y();
                if x > brick_x
                    && x < brick_x + BRICK_WIDTH
                    && y > brick_y
                    && y < brick_y + BRICK_HEIGHT
                {
                    ball.set_dy(-ball.get_dy());
                    ball.add_speed();
                    self.bricks[c][r].set_status(0);
                    status.set_score(status.get_score() + 1);
                    if status.get_score() == BRICK_SUM {
                        let this = JsValue::NULL;
                        let _ = callback.call1(&this, &"YOU WIN, CONGRATULATIONS!".into());
                    }
                }
            }
        }
    }
}

全ブロックを破壊している場合はalertでメッセージを出すのでその関数をコールバックとしてもらいます。

盤面更新処理(通常の関数)

pub fn update_with_callback(
    ball: &mut Ball,
    paddle: &mut Paddle,
    status: &mut GameStatus,
    width: f64,
    height: f64,
    callback: &js_sys::Function,
) {
    if status.get_status() != Status::Start {
        return;
    }
    if ball.get_x() + ball.get_dx() > width as f64 - BALL_RADIUS
        || ball.get_x() + ball.get_dx() < BALL_RADIUS
    {
        // 壁にあたった場合その1
        ball.set_dx(-ball.get_dx());
    }

    if ball.get_y() + ball.get_dy() < BALL_RADIUS {
        // 壁にあたった場合その2
        ball.set_dy(-ball.get_dy());
    } else if ball.get_y() + ball.get_dy() > height as f64 - BALL_RADIUS {
        if ball.get_x() > paddle.get_x() && ball.get_x() < paddle.get_x() + PADDLE_WIDTH {
            // パドルにボールが当たった場合
            let dist = (ball.get_x() + BALL_RADIUS) - (paddle.get_x() + PADDLE_WIDTH / 2.0);
            let radian = (90.0 - (dist / (PADDLE_WIDTH / 2.0)) * 80.0).to_radians();
            let speed = (ball.get_dx().powf(2.0) + ball.get_dy().powf(2.0)).sqrt();
            ball.set_dx(radian.cos() * speed);
            ball.set_dy(-radian.sin() * speed);
            ball.add_speed();
        } else {
            ball.set_dy(-ball.get_dy());
            // ここのelse節は下に突き抜けた場合
            status.set_status(Status::Stop);
            status.set_lives(status.get_lives() - 1);
            if status.get_lives() == 0 {
                let this = JsValue::NULL;
                let _ = callback.call1(&this, &"GAME OVER".into());
            } else {
                ball.set_x(width as f64 / 2.0);
                ball.set_y(height as f64 - BALL_RADIUS * 2.0);
                ball.set_dx(2.0 * SPEED);
                ball.set_dy(-2.0 * SPEED);
                ball.init_speed();
                paddle.set_x((width as f64 - PADDLE_WIDTH) / 2.0);
            }
        }
    }
    if paddle.get_right_pressed() && paddle.get_x() < width as f64 - PADDLE_WIDTH {
        paddle.set_x(paddle.get_x() + 7.0);
    } else if paddle.get_left_pressed() && paddle.get_x() > 0.0 {
        paddle.set_x(paddle.get_x() - 7.0);
    }
    ball.set_x(ball.get_x() + ball.get_dx());
    ball.set_y(ball.get_y() + ball.get_dy());
}

特筆する部分はありません。ブロック破壊処理と同様に勝ち負け判定時用にコールバックを受けています。

まとめと今後

  • RustとJavaScript側での切り分けができました
    • 構造体を通して切り分けると簡単そうです
  • JavaScriptと接続するためにRust側にコールバックを渡すことができました
  • 次はRust一旦おいておいて番外編としてWebWorkerの記事にするかもしれません。

P.S.

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

また、9/11(水)には御茶ノ水にて会社説明会を開催いたします。ご興味のある方は是非ご応募ください! yumenosora.connpass.com

さらに、9/25(水)には2回目となる渋谷採用説明会を開催いたします。こちらも、ご興味のある方は是非ご応募ください! yumenosora.connpass.com