【Opal】娘のために作ったRubyプログラムをブラウザ上で動かしてみた

## はじめに
この記事は[Ruby Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ruby) 19日目の記事です。

さて、突然ですが、最近僕の娘がハイキュー!!というアニメのカードを集めるのにハマり始めました。
このカードは1枚110円で、全部で55種類あります。

![99871598-582ec300-2c1f-11eb-9614-7eec8f152823.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/7e28a6d3-2c0c-e9b4-0edc-cf8a071a80e5.jpeg)

当然ながら娘は全種類集めたい！と言います。

僕が調べた範囲では特にレアカードの設定はないようなので、「どのカードも出てくる確率は同じ」と仮定した上でカードを買い続けたら、いったいどれくらいの枚数を買うことになるんだろう？と思いました。

そこでRubyを使って簡単なシミュレーションプログラムを作ってみました。

```ruby
require 'set'

class Simulator
  NUMBER_OF_TYPES = 55

  def self.simulate
    set_of_cards = Set[]
    all_types = (1..NUMBER_OF_TYPES).to_a
    count = 0
    while set_of_cards.size < NUMBER_OF_TYPES
      you_get = all_types.sample
      if set_of_cards.include?(you_get)
        print "No.#{you_get}がダブった！"
      else
        print "😄No.#{you_get}をゲットした！"
      end
      set_of_cards << you_get
      count += 1
      puts "（#{count * 110}円、#{set_of_cards.size}/#{NUMBER_OF_TYPES}ゲットなう）"
    end
    puts "total=#{count}"
  end
end

Simulator.simulate
```

このプログラムをターミナルで実行すると次のような結果になります。

![Screen Shot 2020-12-18 at 8.47.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/cc70d345-da23-30c0-c219-6324c57616b5.png)
〜中略〜
![Screen Shot 2020-12-18 at 8.47.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/cf29c4fa-2cf3-647f-2356-40a5fee7fbe2.png)

実行結果はランダムに変わりますが、ここでは全部集めるのに268枚購入して2万9480円かかりました。

さて、ここまでは前フリで、以下が本題です。

## 本題：このままさくっとWebに移植したい！

このプログラムはターミナルでしか動かないので、実行結果を見せようと思ったら僕がパソコンを持ち出さなくてはなりません。
これだとちょっと不便だということで、ブラウザ上で見られるようにしようと思いました。

僕が普段使い慣れているのはRuby on Railsですが、たったこれだけのコードを動かすためにRailsを持ち出すのはちょっとオーバーキルな気がします。
この程度のロジックならHTMLとJavaScriptの静的なファイルだけでさくっと完結させたいところです。

が、僕はJSがそこまで得意ではありません。
もちろん、ちょっと時間をかければ同等のコードを書けるとは思いますが、気分的にちょっと億劫です。
もしRubyのコードをひょいっとそのままブラウザ上で動かせれば、それがベストです。

ブラウザ上でRubyで動かす？
そのときふと思いました。

「**[Opal](https://opalrb.com/)があるじゃない！**」
![Screen_Shot_2020-12-14_at_11.22.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/cd78938a-d85b-0c92-f467-afb2c2cfbd81.png)

OpalはRubyで書いたコードをJSに変換してくれるライブラリです。
これを使えばきっと、さっき書いたコードをさくっとブラウザ上で動かせるはず・・・！！

というわけでやってみたのがこちらです。

https://card-sim.jnito.com/

![Screen_Shot_2020-12-16_at_19.31.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/bd8bafa0-a9c6-e597-f247-7ad131854db9.png)

ターミナル版とは異なり、Web版ではカードの種類や1枚あたりの価格を変更できるようになっています。
（デフォルトはハイキュー!!のカードにあわせて全55種類、1枚110円）

![1i05tRJxCc.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/de4da89c-5d40-6ab8-1762-023874c44394.gif)

コードはこちらに置いてあります。

https://github.com/JunichiIto/card-sim

使用したOpalのバージョンは1.0.4です。

```
$ opal -v
Opal v1.0.4
```

以下で簡単にコードの解説をしておきます。

### src/simulator.rbとコンパイル後のdist/simulator.js

シミュレーション結果を生成するRubyプログラムです。
冒頭で紹介したターミナル版のコードがベースになっています。
ブラウザ上で出力するために多少コードをいじっていますが、基本的なロジックはターミナル版と同じです。

```ruby
require 'opal'
require 'native'
require 'set'

class Simulator
  def self.simulate(number_of_types)
    set_of_cards = Set[]
    all_types = [*1..number_of_types]
    results = []
    while set_of_cards.size < number_of_types
      you_get = all_types.sample
      first_get = !set_of_cards.include?(you_get)
      set_of_cards << you_get
      results << { count_total: results.size.succ, you_get: you_get, first_get: first_get, count_distinct: set_of_cards.size }
    end
    results
  end
end
```

これをOpalでコンパイルして、`dist/simulator.js`に変換します。
コンパイルタスクはRakefileに定義しておいたので、`bundle exec rake`でコンパイルできます。

```rb:Rakefile
require 'opal'

desc "Build our app to dist/simulator.js"
task :build do
  Opal.append_path "src"
  File.binwrite "dist/simulator.js", Opal::Builder.build("simulator").to_s
end
task default: :build
```

ちなみにコンパイルされたJSのコードはかなり巨大なファイルになっていて、人間が読むためのコードとは言いがたいです（2万5000行ぐらいある）。
参考までに、僕が書いたRubyのロジック部分だけを抜き出すとこんな感じになっています。

```javascript
/* Generated by Opal 1.0.4 */
(function(Opal) {
  function $rb_lt(lhs, rhs) {
    return (typeof(lhs) === 'number' && typeof(rhs) === 'number') ? lhs < rhs : lhs['$<'](rhs);
  }
  var self = Opal.top, $nesting = [], nil = Opal.nil, $$$ = Opal.const_get_qualified, $$ = Opal.const_get_relative, $breaker = Opal.breaker, $slice = Opal.slice, $klass = Opal.klass, $truthy = Opal.truthy, $hash2 = Opal.hash2;

  Opal.add_stubs(['$require', '$[]', '$<', '$size', '$sample', '$!', '$include?', '$<<', '$succ']);
  
  self.$require("opal");
  self.$require("native");
  self.$require("set");
  return (function($base, $super, $parent_nesting) {
    var self = $klass($base, $super, 'Simulator');

    var $nesting = [self].concat($parent_nesting), $Simulator_simulate$1;

    return (Opal.defs(self, '$simulate', $Simulator_simulate$1 = function $$simulate(number_of_types) {
      var $a, self = this, set_of_cards = nil, all_types = nil, results = nil, you_get = nil, first_get = nil;

      
      set_of_cards = $$($nesting, 'Set')['$[]']();
      all_types = [].concat(Opal.to_a(Opal.Range.$new(1, number_of_types, false)));
      results = [];
      while ($truthy($rb_lt(set_of_cards.$size(), number_of_types))) {
        
        you_get = all_types.$sample();
        first_get = set_of_cards['$include?'](you_get)['$!']();
        set_of_cards['$<<'](you_get);
        results['$<<']($hash2(["count_total", "you_get", "first_get", "count_distinct"], {"count_total": results.$size().$succ(), "you_get": you_get, "first_get": first_get, "count_distinct": set_of_cards.$size()}));
      };
      return results;
    }, $Simulator_simulate$1.$$arity = 1), nil) && 'simulate'
  })($nesting[0], null, $nesting);
})(Opal);
```

### dist/index.html

ブラウザ表示用のHTMLファイルです。
`<script src="simulator.js"></script>`の部分で、上でコンパイルした`simulator.js`を読み込んでいます。

また、画面の描画にはVue.jsを使ってみました。
静的なHTMLだとコンポーネント分割が難しいので、HTML内にガチャガチャとVue.js用のテンプレートを書いています。

```html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <!-- 省略 -->
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.12"></script>
    <script src="simulator.js"></script>
    <script src="app.js"></script>
  </head>
  <body>
    <div class="container">
      <h3>カード収集!!シミュレータ</h3>
      <div id="app" v-cloak>
        <div class="input-container">
          全
          <select v-model="numberOfTypes" @change="simulate">
            <option v-for="option in typeOptions" v-bind:value="option" v-bind:key="option">
              {{ option }}
            </option>
          </select>
          種類 / 1枚
          <input type="number" min="0" max="1000" v-model="unitPrice" class="unit-price">
          円
        </div>
        <p>全部集めるのに{{ this.countTotal }}枚 / {{ this.totalCost }}円かかりました。</p>
        <div class="btn-container">
          <button @click="simulate" class="btn btn-light">もう１回試す</button>
        </div>
        <table align="center">
          <tr v-for="result in results">
            <td class="text-right">{{ result.count_total }}回目</td>
            <td>{{ formatFirstGet(result) }}</td>
            <td>{{ formatMessage(result) }}</td>
            <td class="text-right">{{ result.count_distinct }}/{{ numberOfTypes }}</td>
          </tr>
        </table>
        <div class="btn-container">
          <button @click="simulate" class="btn btn-light">もう１回試す</button>
        </div>
      </div>
    </div>
    <!-- 省略 -->
  </body>
</html>
```

### dist/app.js

Vue.js用のロジックを書いたJSファイルです。

```javascript
document.addEventListener("DOMContentLoaded", function() {
  new Vue({
    el: '#app',
    data: {
      results: [],
      numberOfTypes: 55,
      unitPrice: 110
    },
    computed: {
      totalCost() {
        return this.countTotal * this.normalizedUnitPrice;
      },
      countTotal() {
        return this.results.length;
      },
      typeOptions() {
        return [...Array(101).keys()].filter(n => n > 0);
      },
      normalizedUnitPrice() {
        const n = parseInt(this.unitPrice, 10);
        return isNaN(n) ? 0 : n;
      }
    },
    methods: {
      simulate() {
        this.results = Opal.Simulator.$simulate(this.numberOfTypes).$to_n();
        scrollTo(0, 0);
      },
      formatFirstGet(result) {
        return result.first_get ? '😄' : '';
      },
      formatMessage(result) {
        return result.first_get ? `No.${result.you_get}をゲットした！` : `No.${result.you_get}がダブった...`;
      }
    },
    created() {
      this.simulate();
    }
  })
});
```

本来ならもう少しコンポーネント分割したいところですが、上で述べたとおり静的HTMLなので1コンポーネントになっています。

### Rubyで書いたコードの実行結果をJSで受け取る

一番のポイントは`methods -> simulate()`の中に書いたこの部分です。

```ruby
this.results = Opal.Simulator.$simulate(this.numberOfTypes).$to_n();
```

ここでRubyで書いたコードの実行結果をJSに渡しています。

### RubyのハッシュをJSのオブジェクトに変換する

Rubyの`simulate`メソッドでは`[{count_total: 1, ...}, {count_total: 2, ...}, ...]`のようにハッシュを要素に含む配列を戻り値として返しています。
RubyのハッシュはJSでは`Opal.hash`というオブジェクトになるため、そのままではJSのnativeなオブジェクトと互換性がありません。
そのため、`$to_n()`を呼んでJSのnativeなオブジェクトに変換しています。
また、`$to_n()`メソッドを呼び出すためには、Ruby側で`require 'native'`しておく必要があります。

JSとRubyの相互変換に関するの詳しい話は以下の公式ドキュメントを参照してください。

https://opalrb.com/docs/guides/v1.0.3/compiled_ruby.html

### requireの順番に注意

`src/simulator.rb`では次のようにライブラリをrequireしていました。

```ruby
require 'opal'
require 'native'
require 'set'
```

このとき、`set`は必ず`opal`のあとにrequireしてください。
`opal`より先に`set`をrequireすると、`dist/simulator.js`を読み込んだ際に以下のエラーが出ます。

```
Uncaught ReferenceError: Opal is not defined
    at simulator.js:2
```

## まとめ 〜エピローグ〜

はい、こんなふうにすれば簡単にRubyで書いたコードをブラウザ上で動かすことができます！！・・・と言いたいところですが、ここまで来るのにめちゃくちゃハマりました😭
単純に時間だけにフォーカスすれば、使い慣れたRailsで作ったり、シミュレータをJSで全部書き直したりした方が圧倒的に速かったと思います。
でも、これはこれでOpalやVue.jsでいろいろ遊べたので、とてもいい勉強になりました。

そして、このWebアプリを娘が使って喜んでくれたら、この苦労もプライスレスです。
というわけで、 https://card-sim.jnito.com/ のURLを娘に教えてあげました。
このアプリを使ってシミュレーション結果を見た娘がひとこと、

「・・・絶望的な気持ちになった」

それでは明日の[Ruby Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ruby)もお楽しみに😭