49
37

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.

Rust+WASMで感染症の数理シミュレーション

Posted at

sample.gif

はじめに

"Rust and WebAssembly"の公式チュートリアルに一通り目を通したのでこれを拡張して遊んでみます。最近巷では感染症の数理モデルで遊ぶのが流行っているようなので、これをセルオートマトンにしたシミュレーションアプリを作ります。ただ、チュートリアル通りにゲームのロジック側をRustで記述しつつcanvasの描画やイベントハンドラをJavaScriptで書くのも味気ないので、ここではWeb側の描画・操作も全てRustで書きます。

1 感染症の数理モデル

感染症の流行過程を記述する数理モデルとして最も基本的なものにSIRモデルと呼ばれるものがあります。SIRモデルはまず人口を

  • S (Susceptible): 感受性保持者(これから感染する人)
  • I (Infected): 感染者
  • R (Recovered): 免疫獲得者(感染症から回復し免疫を得た人)

の3つの状態に区分します。状態間の遷移としては

  1. 感受性保持者が感染者になる (S → I)
  2. 感染者が免疫を獲得する (I → R)

の2つのみを考え、例えば回復した人が免疫を喪失してしまうR → S過程などは考慮しません。ある時刻 t における感受性保持者、感染者、免疫獲得者の数をそれぞれ S(t), I(t), R(t) とすると、モデルの表現は β: 感染率、γ: 回復率、N: 全人口(定数)として

\frac{d}{dt}S(t) = -\beta S(t)I(t)\\
\frac{d}{dt}I(t) = \beta S(t)I(t) - \gamma I(t)\\
\frac{d}{dt}R(t) = \gamma I(t)\\
S(t) + I(t) + R(t) = N 

と書けます。βとγは適当に設定してS(0) = Nの状態からこれを数値的に解くと、各状態数の時間遷移は下のグラフのようになります。
青:S, 緑:I, 赤: R
440px-Sirsys-p9.png
十分な時間が経過すれば全員が最終的に免疫獲得者に遷移した定常状態となります。

ところがセルオートマトンでこれを表現すると少々物足りません。なぜなら(パラメータの初期値にも依りますが)数十〜数百ステップも経てばフィールドの全セルが免疫獲得者となって終了してしまうからです。ゲーム性を高めるためにもここではSIRモデルを拡張したものを用います。

SEIRSモデル

現実の感染症には潜伏期間が付き物です。そこでSIRモデルに新たな状態として E (Exposed) を加え、モデルを次のように定義し直します。

  • S (Susceptible): 感受性保持者(これから感染する人)
  • E (Exposed): 感染しているが発症はしていない人
  • I (Infected): 発症者
  • R (Recovered): 免疫獲得者

さらに免疫獲得者Rは一定の確率で免疫を喪失しSに戻るとします。つまり全体の状態遷移としては S → E → I → R → S となっています。このように拡張すると先のSIRモデルよりは現実的になっているのではないでしょうか。
SEIRSモデルもSIRモデルと同様に、それぞれの状態遷移に応じて感染率、発症率、回復率、免疫喪失率の4つのパラメータを用いて記述できますが、ここでは立ち入りません。興味がある方はこちらのサイトなどを参考にするといいかもしれません。

実装

2 セルオートマトンの実装

まずは各セルの状態を表す列挙型Cellとフィールドそのものを表す構造体Universeを用意します。セルオートマトンのロジック自体は公式チュートリアルのライフゲームの実装をほぼそのまま拡張しただけなので詳しい説明は割愛します。

src/universe.rsの実装
src/universe.rs
use rand::prelude::*;

#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cell {
    Susceptible, // 無免疫者
    Exposed,     // 感染者
    Infected,    // 発症者
    Recovered,   // 回復者(免疫獲得)
}

impl Cell {
    // セルの状態を次の状態へ変更させる
    // canvas上でのクリックイベントが発生した際に用いる
    fn change(&mut self) {
        *self = match *self {
            Cell::Susceptible => Cell::Exposed,
            Cell::Exposed     => Cell::Infected,
            Cell::Infected    => Cell::Recovered,
            Cell::Recovered   => Cell::Susceptible,
        }
    }
}


pub struct Universe {
    pub infection_rate: f32,      // 感染する確率
    pub incubation_rate: f32,     // 感染者が発症する確率
    pub recover_rate: f32,        // 発症者が回復する確率
    pub lose_immunity_rate: f32,  // 回復者が免疫を喪失する確率
    pub width: u32,               // 横方向のセル数
    pub height: u32,              // 縦方向のセル数
    cells: Vec<Cell>,             // 全てのセル(個数はwidth*height)を配列として格納
    next_cells: Vec<Cell>         // 全てのセル次の状態
}


impl Universe {
    // コンストラクタ
    pub fn new() -> Universe {
        let mut rng = rand::thread_rng();

        let width: u32 = 200;
        let height: u32 = 200;
        let cells = (0..width*height)
            .map(|_| {
                let x: f32 = rng.gen();
                if x < 0.0001 {
                    Cell::Infected
                } else {
                    Cell::Susceptible
                }
            })
            .collect();

        let next_cells = (0..width*height)
            .map(|_| Cell::Susceptible )
            .collect();

        Universe {
            infection_rate: 0.4,
            incubation_rate: 0.8,
            recover_rate: 0.2,
            lose_immunity_rate: 0.1,
            width,
            height,
            cells,
            next_cells,
        }
    }

    // 行番号row, 列番号col から Vec<Cell> におけるインデックスを取得
    pub fn get_index(&self, row: u32, col: u32) -> usize {
        (row * self.width + col) as usize
    }

    // フィールド全体を1ステップ進める
    pub fn tick(&mut self) {
        let mut rng = rand::thread_rng();

        for row in 0..self.height {
            for col in 0..self.width {
                let idx = self.get_index(row, col);
                let cell = self.cells[idx];

                let infected_neighbors = self.infected_neighbor_count(row, col);
                let p_infection = 1.0 - (1.0 - self.infection_rate).powf(infected_neighbors as f32);

                let next_cell = match (cell, rng.gen::<f32>()) {
                    (Cell::Susceptible, x) if x < p_infection => Cell::Exposed,
                    (Cell::Susceptible, _) => Cell::Susceptible,
                    (Cell::Exposed, x) if x < self.incubation_rate => Cell::Infected,
                    (Cell::Exposed, _) => Cell::Exposed,
                    (Cell::Infected, x) if x < self.recover_rate => Cell::Recovered,
                    (Cell::Infected, _) => Cell::Infected,
                    (Cell::Recovered, x) if x < self.lose_immunity_rate => Cell::Susceptible,
                    (Cell::Recovered, _) => Cell::Recovered,
                };

                self.next_cells[idx] = next_cell;
            }
        }

        self.cells.copy_from_slice(&self.next_cells);
    }

    // 全セルをSで初期化
    pub fn clear(&mut self) {
        self.cells = (0..self.width*self.height)
            .map(|_| Cell::Susceptible)
            .collect();
    }

    // 全セルをSで初期化し、低確率でIも混ぜる
    pub fn randomize(&mut self) {
        let mut rng = rand::thread_rng();

        self.cells = (0..self.width*self.height)
            .map(|_| {
                let x: f32 = rng.gen();
                if x < 0.0001 {
                    Cell::Infected
                } else {
                    Cell::Susceptible
                }
            })
            .collect();
    }

    // 指定したマスの周囲8マスにいる発症者の数を数える
    // tick()に用いる補助関数
    fn infected_neighbor_count(&self, row: u32, col: u32) -> u8 {
        let mut count: u8 = 0;
        for delta_row in [self.height - 1, 0, 1].iter().cloned() {
            for delta_col in [self.width - 1, 0, 1].iter().cloned() {
                if delta_row == 0 && delta_col == 0 { // 自分自身は勘定しない
                    continue;
                }

                let neighbor_row = (row + delta_row) % self.height;
                let neighbor_col = (col + delta_col) % self.width;
                if self.get_cell(neighbor_row, neighbor_col) == Cell::Infected {
                    count += 1;
                }
            }
        }

        count
    }

    // 指定した行番号・列番号のセル状態を取得する
    pub fn get_cell(&self, row: u32, col: u32) -> Cell {
        let idx = self.get_index(row, col);
        self.cells[idx]
    }

    // 指定した行番号・列番号のセル状態を変更する
    pub fn change_cell(&mut self, row: u32, col: u32) {
        let idx = self.get_index(row, col);
        self.cells[idx].change();
    }
}

randクレートの使用

チュートリアルではフィールドをランダムに初期化する際js_sys経由でJSのMath.random()を用いていますが、Rustのrandを使用したいときはCargo.tomlでwasm-bindgenをフィーチャーに指定する必要があります。

Cargo.toml
[dependencies.rand]
version = "0.7"
features = [
    "wasm-bindgen"
]

3 Web APIとの連携

前節で実装したUniverseを実際にWeb APIと連携して描画・操作できるようにします。

3.1 描画ループ

Rustで書く前にJSで描画ループを書いてみます(イメージ)。

render.js
const universe = new Universe();
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

// requestAnimationFrameの返り値を格納
// ループ中は何らかの値を持ち、停止中はnullにする
let animationId = null;

// universeの全セルをcanvasに描画する関数
function draw() { ... }

// 描画ループする関数
function renderLoop() {
    // 1. フィールドを更新する
    universe.tick();

    // 2. 描画する
    draw();

    // 3. 再帰的に描画ループを呼び出す
    animationId = requestAnimationFrame(renderLoop);
}

// ループ実行
renderLoop();

これをRustで実現するために、まずは次のGame構造体を定義し、コンストラクタnew()と描画関数render()を用意します。

src/game.rs
use std::rc::Rc;
use std::cell::RefCell;
use wasm_bindgen::prelude::*;

use crate::universe::{Universe, Cell};

pub struct Game {
    pub window: web_sys::Window,
    pub universe: Rc<RefCell<Universe>>,
    pub canvas: Rc<RefCell<web_sys::HtmlCanvasElement>>,
    pub animation_id: Option<i32>,
    pub closure: Option<Closure<dyn FnMut()>>,
}

impl Game {
    pub fn new(
        universe: Rc<RefCell<Universe>>,
        canvas: Rc<RefCell<web_sys::HtmlCanvasElement>>,
    ) -> Game {
        let window = web_sys::window().unwrap();
        let document = window.document().unwrap();

        Game {
            window,
            universe,
            canvas,
            animation_id: None,
            closure: None
        }
    }

    // JSのdraw()に相当
    pub fn draw(&mut self) {
        let ctx = self.canvas.borrow()
            .get_context("2d")
            .unwrap()
            .unwrap()
            .dyn_into::<web_sys::CanvasRenderingContext2d>()
            .unwrap();
        ctx.begin_path();

        let universe = self.universe.borrow();
        for row in 0..universe.height {
            for col in 0..universe.width {
                match universe.get_cell(row, col) {
                    Cell::Susceptible => ctx.set_fill_style(&JsValue::from_str(SUSCEPTIBLE_COLOR)),
                    Cell::Exposed     => ctx.set_fill_style(&JsValue::from_str(EXPOSED_COLOR)),
                    Cell::Infected    => ctx.set_fill_style(&JsValue::from_str(INFECTED_COLOR)),
                    Cell::Recovered   => ctx.set_fill_style(&JsValue::from_str(RECOVERED_COLOR)),
                }

                ctx.fill_rect(
                    (CELL_SIZE * col) as f64,
                    (CELL_SIZE * row) as f64,
                    CELL_SIZE as f64,
                    CELL_SIZE as f64,
                );
            }
        }
        ctx.stroke();
    }

    // JSのrenderLoop()に相当
    pub fn render_loop(&mut self) {
        // 1. フィールドを更新する
        self.universe.borrow_mut().tick();

        // 2. 描画する
        self.draw();

        // 3. 描画ループの呼び出し
        self.animation_id = if let Some(f) = &self.closure {
            Some(self.window
                     .request_animation_frame(f.as_ref().unchecked_ref())
                     .unwrap())
        } else {
            None
        }
    }
}

描画ループの呼び出しはlib.rsで行います。

src/lib.rs
#[wasm_bindgen(start)]
pub fn run() {
    let game = Game::new(...);
    game.render_loop();
}

render()canvasのcontextを取得するのにやたらとunwrap()しているのは以下の流れになっています。

src/game.rs
let ctx = self.canvas.borrow() // -> HtmlCanvasElementが返ってくる
    .get_context("2d")         // -> Result<Option<Object>, JsValue>
    .unwrap()                  // Resultを剥がす
    .unwrap()                  // Optionを剥がす
    .dyn_into::<web_sys::CanvasRenderingContext2d>() // Object型をCanvasRenderingContext2d型にキャスト(Result値)
    .unwrap();                 // Resultを剥がす
ctx.begin_path(); // 新しいパスの開始

JSのbeginPath()に相当するweb_sysのbegin_path()web_sys::CanvasRenderingContext2dにしか用意されていないので、dyn_into()でそのように型キャストしてあげないとコンパイルエラーを吐きます。RustからWeb APIのオブジェクトを触るときはこのようなunwrap()が頻出するので、関数の返り値を全てResult<(), JsValue>にして?で剥がすのもよいかもしれません。

ClosureによるRust/JavaScript間での関数の橋渡し

JSのrequestAnimationFrameに相当するものは、web_sysにおいてrequest_animation_frame()

impl Window {
    fn request_animation_frame(&self, callback: &Function) -> Result<i32, JsValue>;
}

の形で用意されています。どうやらコールバック関数として単なるRustの関数/クロージャではなく&Functionなるものを渡さなければならないようです。そこでRustの関数/クロージャを&Functionに変換する手段としてweb_sysにはClosureという構造体が用意されていてwrap()

pub fn Closure::wrap(Box<T>) -> Closure<T>
ボックス化されたRustの関数Box<T>からClosureのインスタンスを生成し、Box<T>は次の要件を満たす。

  • TFnもしくはFnMutトレイトをもつ。
  • 'staticライフタイムであり、スタック領域に参照を持たない(moveを用いること)。
  • ...

よってBox<dyn Fn(...)>もしくはBox<dyn FnMut()>のRustクロージャをClosure::wrap()に渡せばよさそうです。そしてClosureclosure.as_ref().unchecked_ref()のチェインで&Functionに変換することができます。まとめると

Closure
let closure = Closure::wrap(Box::new(move || {
    // やりたい処理
}) as Box<dyn FnMut()>);
let animation_id = window.request_animation_frame(
        closure
            .as_ref() // JsValueに変換
            .unchecked_ref() // &Functionに変換
        );

以上から、Gameにはrequest_animation_frame()の返り値をanimation_idとして、Game::render_loop()をボックス化してClosure::wrap()で包んだものをclosureとして持たせます。

src/game.rs
pub struct Game {
    // ...
    pub animation_id: Option<i32>,
    pub closure: Option<Closure<dyn FnMut()>>,
}

impl Game {
    pub fn render_loop(&mut self) {
        self.universe.borrow_mut().tick();
        self.draw();

        self.animation_id = if let Some(f) = &self.closure {
            Some(self.window
                     .request_animation_frame(f.as_ref().unchecked_ref())
                     .unwrap())
        } else {
            None
        }
    }
}
src/lib.rs
#[wasm_bindgen]
pub fn run() {
    let game = Rc::new(RefCell::new(Game::new(...)));
    
    // 描画ループ
    game.borrow_mut().closure = Some({
        let g = Rc::clone(&game);
        Closure::wrap(Box::new(move || {
            g.borrow_mut().render_loop();
        }))
    })
}

大変ですね。

3.2 入力の受け取り

今回のアプリはボタン押下やスライダーなど様々な入力がありますが、ここではフィールド更新の再生/一時停止をトグルするボタンだけ作ります。Gameに新しくメソッドをいくつか追加しましょう。

src/game.rs
pub struct Game {
    pub window: web_sys::Window,
    pub universe: Rc<RefCell<Universe>>,
    pub canvas: Rc<RefCell<web_sys::HtmlCanvasElement>>,
    pub play_pause_button: web_sys::Element,
    pub animation_id: Option<i32>,
    pub closure: Option<Closure<dyn FnMut()>>,

}

impl Game {
    pub fn new(
        universe: Rc<RefCell<Universe>>,
        canvas: Rc<RefCell<web_sys::HtmlCanvasElement>>,
    ) -> Game {
        let window = web_sys::window().unwrap();
        let document = window.document().unwrap();
        let play_pause_button = document.get_element_by_id("play-pause").unwrap();

        Game {
            window,
            universe,
            canvas,
            play_pause_button,
            animation_id: None,
            closure: None
        }

    // ...

    // 一時停止中であるか
    fn is_paused(&self) -> bool {
        self.animation_id.is_none()
    }

    // 再生する
    pub fn play(&mut self) {
        (self.play_pause_button.as_ref() as &web_sys::Node)
            .set_text_content(Some("pause"));
        self.render_loop();
    }

    // 一時停止する
    fn pause(&mut self) {
        (self.play_pause_button.as_ref() as &web_sys::Node)
            .set_text_content(Some("play"));
        if let Some(id) = self.animation_id {
            self.window.cancel_animation_frame(id).unwrap();
            self.animation_id = None;
        }
    }

    // 再生/一時停止の状態を切り替える
    pub fn play_pause(&mut self) {
        if self.is_paused() {
            self.play();
        } else {
            self.pause();
        }
    }

あまり難しいところはないです。例によってWindow::get_element_by_id(id)の返り値はOption<Element>ですが、set_text_content()のメソッドはElementではなくNodeのものなのでそのようにキャストします。

src/lib.rs
#[wasm_bindgen(start)]
pub fn run() {
    let game = Rc::new(RefCell::new(Game::new(...)));

    // play-pause button
    let g = Rc::clone(&game);
    let closure = Closure::wrap(Box::new(move || {
        g.borrow_mut().play_pause();
    }) as Box<dyn FnMut()>);
    (game.borrow().play_pause_button.as_ref() as &web_sys::EventTarget)
        .add_event_listener_with_callback("click", closure.as_ref().unchecked_ref()).unwrap();
    closure.forget();
}

addEventListenerの追加を除きrender_loop()とほぼ同じです。最後のclosure.forget()は裏でstd::mem::forget(closure)を呼び、意図的にメモリリークを起こします(!)。closureがスコープを抜けてdropすると紐づけたJSのクロージャも無効になってしまうため、dropしないようにする訳ですね。

こんな感じで全てのイベントハンドラをClosureに紐づけてあげれば完成です。

src/game.rs
src/game.rs
use std::rc::Rc;
use std::cell::RefCell;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;

use crate::controller::ParameterController;
use crate::universe::{Universe, Cell};

pub const CELL_SIZE: u32 = 2;
const SUSCEPTIBLE_COLOR: &str = "#FFFFFF";
const EXPOSED_COLOR: &str = "#FFA500";
const INFECTED_COLOR: &str = "#FF0000";
const RECOVERED_COLOR: &str = "#ADFFC9";


pub struct Game {
    pub window: web_sys::Window,
    pub universe: Rc<RefCell<Universe>>,
    pub canvas: Rc<RefCell<web_sys::HtmlCanvasElement>>,
    pub play_pause_button: web_sys::Element,
    pub clear_button: web_sys::Element,
    pub randomize_button: web_sys::Element,
    pub infection_rate_controller: ParameterController,
    pub incubation_rate_controller: ParameterController,
    pub recover_rate_controller: ParameterController,
    pub lose_immunity_rate_controller: ParameterController,
    pub animation_id: Option<i32>,
    pub closure: Option<Closure<dyn FnMut()>>,
}


impl Game {
    pub fn new(
        universe: Rc<RefCell<Universe>>,
        canvas: Rc<RefCell<web_sys::HtmlCanvasElement>>,
    ) -> Game {
        let window = web_sys::window().unwrap();
        let document = window.document().unwrap();

        let play_pause_button = document.get_element_by_id("play-pause").unwrap();
        let clear_button = document.get_element_by_id("clear").unwrap();
        let randomize_button = document.get_element_by_id("randomize").unwrap();

        let infection_rate_controller = ParameterController::new(
            "infectionRate",
            "infectionRateText",
            universe.borrow().infection_rate
        );

        let incubation_rate_controller = ParameterController::new(
            "incubationRate",
            "incubationRateText",
            universe.borrow().incubation_rate
        );

        let recover_rate_controller = ParameterController::new(
            "recoverRate",
            "recoverRateText",
            universe.borrow().recover_rate
        );
        let lose_immunity_rate_controller = ParameterController::new(
            "loseImmunityRate",
            "loseImmunityRateText",
            universe.borrow().lose_immunity_rate
        );

        Game {
            window,
            universe,
            canvas,
            play_pause_button,
            clear_button,
            randomize_button,
            infection_rate_controller,
            incubation_rate_controller,
            recover_rate_controller,
            lose_immunity_rate_controller,
            animation_id: None,
            closure: None
        }
    }

    pub fn draw(&mut self) {
        let ctx = self.canvas.borrow()
            .get_context("2d")
            .unwrap()
            .unwrap()
            .dyn_into::<web_sys::CanvasRenderingContext2d>()
            .unwrap();
        ctx.begin_path();

        let universe = self.universe.borrow();
        for row in 0..universe.height {
            for col in 0..universe.width {
                match universe.get_cell(row, col) {
                    Cell::Susceptible => ctx.set_fill_style(&JsValue::from_str(SUSCEPTIBLE_COLOR)),
                    Cell::Exposed     => ctx.set_fill_style(&JsValue::from_str(EXPOSED_COLOR)),
                    Cell::Infected    => ctx.set_fill_style(&JsValue::from_str(INFECTED_COLOR)),
                    Cell::Recovered   => ctx.set_fill_style(&JsValue::from_str(RECOVERED_COLOR)),
                }

                ctx.fill_rect(
                    (CELL_SIZE * col) as f64,
                    (CELL_SIZE * row) as f64,
                    CELL_SIZE as f64,
                    CELL_SIZE as f64,
                );
            }
        }

        ctx.stroke();
    }

    pub fn render_loop(&mut self) {
        self.universe.borrow_mut().tick();
        self.draw();

        self.animation_id = if let Some(f) = &self.closure {
            Some(self.window
                 .request_animation_frame(f.as_ref().unchecked_ref())
                 .unwrap()
                 )
        } else {
            None
        };
    }

    fn is_paused(&self) -> bool {
        self.animation_id.is_none()
    }

    pub fn play(&mut self) {
        (self.play_pause_button.as_ref() as &web_sys::Node)
            .set_text_content(Some("pause"));
        self.render_loop();
    }

    pub fn pause(&mut self) {
        (self.play_pause_button.as_ref() as &web_sys::Node)
            .set_text_content(Some("play"));
        if let Some(id) = self.animation_id {
            self.window.cancel_animation_frame(id).unwrap();
            self.animation_id = None;
        }
    }

    pub fn play_pause(&mut self) {
        if self.is_paused() {
            self.play();
        } else {
            self.pause();
        }
    }
}


src/controller.rs : スライダー入力を扱うコンポーネントを定義
src/controller.rs
use wasm_bindgen::JsCast;

pub struct ParameterController {
    pub slider: web_sys::HtmlInputElement,
    pub text: web_sys::HtmlOutputElement,
}

impl ParameterController {
    pub fn new(slider_id: &str, text_id: &str, default_value: f32) -> ParameterController {
        let document = web_sys::window().unwrap().document().unwrap();

        let slider = document
            .get_element_by_id(slider_id)
            .unwrap()
            .dyn_into::<web_sys::HtmlInputElement>()
            .unwrap();
        let text = document
            .get_element_by_id(text_id)
            .unwrap()
            .dyn_into::<web_sys::HtmlOutputElement>()
            .unwrap();

        let sval = format!("{:.2}", default_value);

        slider.set_min("0.0");
        slider.set_max("1.0");
        slider.set_step("0.01");
        slider.set_value(&sval);
        slider.set_default_value(&sval);
        text.set_default_value(&sval);

        ParameterController { slider, text }
    }
}

src/lib.rs
src/lib.rs
use std::cmp::min;
use std::rc::Rc;
use std::cell::RefCell;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;

mod controller;
mod game;
mod universe;
mod utils;

use game::{Game, CELL_SIZE};
use universe::Universe;

// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;


#[wasm_bindgen(start)]
pub fn run() -> Result<(), JsValue> {
    let universe = Universe::new();

    let canvas = web_sys::window().unwrap()
        .document().unwrap()
        .get_element_by_id("simulator-canvas")
        .unwrap()
        .dyn_into::<web_sys::HtmlCanvasElement>()?;
    canvas.set_height(CELL_SIZE * universe.height);
    canvas.set_width(CELL_SIZE * universe.width);

    let game = Rc::new(RefCell::new(Game::new(
        Rc::new(RefCell::new(universe)),
        Rc::new(RefCell::new(canvas))
    )));

    { // Render loop handling
        game.borrow_mut().closure = Some({
            let g = Rc::clone(&game);
            Closure::wrap(Box::new(move || {
                g.borrow_mut().render_loop();
            }))
        });
    }

    { // play-pause button
        let g = Rc::clone(&game);
        let closure = Closure::wrap(Box::new(move || {
            g.borrow_mut().play_pause();
        }) as Box<dyn FnMut()>);
        (game.borrow().play_pause_button.as_ref() as &web_sys::EventTarget)
            .add_event_listener_with_callback("click", closure.as_ref().unchecked_ref())?;
        closure.forget();
    }

    { // clear button
        let g = Rc::clone(&game);
        let closure = Closure::wrap(Box::new(move || {
            g.borrow().universe.borrow_mut().clear();
            g.borrow_mut().draw();
        }) as Box<dyn FnMut()>);
        (game.borrow().clear_button.as_ref() as &web_sys::EventTarget)
            .add_event_listener_with_callback("click", closure.as_ref().unchecked_ref())
            .unwrap();
        closure.forget();
    }

    { // randomize button
        let g = Rc::clone(&game);
        let closure = Closure::wrap(Box::new(move || {
            g.borrow().universe.borrow_mut().randomize();
            g.borrow_mut().draw();
        }) as Box<dyn FnMut()>);
        (game.borrow().randomize_button.as_ref() as &web_sys::EventTarget)
            .add_event_listener_with_callback("click", closure.as_ref().unchecked_ref())?;
        closure.forget();
    }

    { // input infection rate
        let g = Rc::clone(&game);
        let universe = Rc::clone(&g.borrow().universe);
        let closure = Closure::wrap(Box::new(move || {
            let infection_rate = g.borrow()
                .infection_rate_controller
                .slider
                .value()
                .parse::<f32>()
                .unwrap_or(0.0);
            g.borrow().infection_rate_controller
                .text
                .set_value(&format!("{:.2}", infection_rate));
            universe.borrow_mut().infection_rate = infection_rate;
        }) as Box<dyn FnMut()>);
        (game.borrow().infection_rate_controller.slider.as_ref() as &web_sys::EventTarget)
            .add_event_listener_with_callback("input", closure.as_ref().unchecked_ref())
            .unwrap();
        closure.forget();
    }

    { // input incubation rate
        let g = Rc::clone(&game);
        let universe = Rc::clone(&g.borrow().universe);
        let closure = Closure::wrap(Box::new(move || {
            let incubation_rate = g.borrow()
                .incubation_rate_controller
                .slider
                .value()
                .parse::<f32>()
                .unwrap_or(0.0);
            g.borrow().incubation_rate_controller
                .text
                .set_value(&format!("{:.2}", incubation_rate));
            universe.borrow_mut().incubation_rate = incubation_rate;
        }) as Box<dyn FnMut()>);
        (game.borrow().incubation_rate_controller.slider.as_ref() as &web_sys::EventTarget)
            .add_event_listener_with_callback("input", closure.as_ref().unchecked_ref())?;
        closure.forget();
    }

    { // input recover rate
        let g = Rc::clone(&game);
        let universe = Rc::clone(&g.borrow().universe);
        let closure = Closure::wrap(Box::new(move || {
            let recover_rate = g.borrow()
                .recover_rate_controller
                .slider
                .value()
                .parse::<f32>()
                .unwrap_or(0.0);
            g.borrow().recover_rate_controller
                .text
                .set_value(&format!("{:.2}", recover_rate));
            universe.borrow_mut().recover_rate = recover_rate;
        }) as Box<dyn FnMut()>);
        (game.borrow().recover_rate_controller.slider.as_ref() as &web_sys::EventTarget)
            .add_event_listener_with_callback("input", closure.as_ref().unchecked_ref())
            .unwrap();
        closure.forget();
    }

    { // input lose immunity rate
        let g = Rc::clone(&game);
        let universe = Rc::clone(&g.borrow().universe);
        let closure = Closure::wrap(Box::new(move || {
            let lose_immunity_rate = g.borrow()
                .lose_immunity_rate_controller
                .slider
                .value()
                .parse::<f32>()
                .unwrap_or(0.0);
            g.borrow().lose_immunity_rate_controller
                .text
                .set_value(&format!("{:.2}", lose_immunity_rate));
            universe.borrow_mut().lose_immunity_rate = lose_immunity_rate;
        }) as Box<dyn FnMut()>);
        (game.borrow().lose_immunity_rate_controller.slider.as_ref() as &web_sys::EventTarget)
            .add_event_listener_with_callback("input", closure.as_ref().unchecked_ref())?;
        closure.forget();
    }

    { // change a clicked cell state
        let g = Rc::clone(&game);
        let canvas = Rc::clone(&g.borrow().canvas);
        let universe = Rc::clone(&g.borrow().universe);
        let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
            let bounding_rect = (canvas.borrow().as_ref() as &web_sys::Element).get_bounding_client_rect();

            let scale_x = canvas.borrow().width() as f64 / bounding_rect.width();
            let scale_y = canvas.borrow().height() as f64 / bounding_rect.height();

            let canvas_left = (event.client_x() as f64 - bounding_rect.left()) * scale_x;
            let canvas_top  = (event.client_y() as f64 - bounding_rect.top()) * scale_y;

            let row = min((canvas_top / CELL_SIZE as f64) as u32, universe.borrow().height);
            let col = min((canvas_left / CELL_SIZE as f64) as u32, universe.borrow().width);

            universe.borrow_mut().change_cell(row, col);
            g.borrow_mut().draw();
        }) as Box<dyn FnMut(_)>);

        (game.borrow().canvas.borrow().as_ref() as &web_sys::EventTarget)
            .add_event_listener_with_callback("click", closure.as_ref().unchecked_ref())?;
        closure.forget();
    }

    game.borrow_mut().play();
    Ok(())
}

終わりに

WebAPIとの連携は想像してた以上に大変でした。このあたりはJSの関数をRustから回りくどく書いている感じが正直否めません。各感染状態数の遷移を表すのにChart.jsなんかとの連携も考えたのですが、そこまでくると内部ロジックだけRustで書いて描画などは全部JSに任せたほうが現状は楽な気がします。

49
37
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
49
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?