81
44

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.

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

Last updated at Posted at 2020-12-18

はじめに

この記事はRuby Advent Calendar 2020 19日目の記事です。

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

99871598-582ec300-2c1f-11eb-9614-7eec8f152823.jpg

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

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

そこで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
〜中略〜
Screen Shot 2020-12-18 at 8.47.50.png

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

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

本題:このままさくっとWebに移植したい!

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

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

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

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

Opalがあるじゃない!
Screen_Shot_2020-12-14_at_11.22.01.png

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

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

Screen_Shot_2020-12-16_at_19.31.32.png

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

1i05tRJxCc.gif

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

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

$ opal -v
Opal v1.0.4

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

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

シミュレーション結果を生成する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でコンパイルできます。

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のロジック部分だけを抜き出すとこんな感じになっています。

/* 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用のテンプレートを書いています。

<!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">もう1回試す</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">もう1回試す</button>
        </div>
      </div>
    </div>
    <!-- 省略 -->
  </body>
</html>

dist/app.js

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

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()の中に書いたこの部分です。

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の相互変換に関するの詳しい話は以下の公式ドキュメントを参照してください。

requireの順番に注意

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

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もお楽しみに😭

81
44
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
81
44

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?