はじめに
この記事はRuby Advent Calendar 2020 19日目の記事です。
さて、突然ですが、最近僕の娘がハイキュー!!というアニメのカードを集めるのにハマり始めました。
このカードは1枚110円で、全部で55種類あります。
当然ながら娘は全種類集めたい!と言います。
僕が調べた範囲では特にレアカードの設定はないようなので、「どのカードも出てくる確率は同じ」と仮定した上でカードを買い続けたら、いったいどれくらいの枚数を買うことになるんだろう?と思いました。
そこで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
このプログラムをターミナルで実行すると次のような結果になります。
実行結果はランダムに変わりますが、ここでは全部集めるのに268枚購入して2万9480円かかりました。
さて、ここまでは前フリで、以下が本題です。
本題:このままさくっとWebに移植したい!
このプログラムはターミナルでしか動かないので、実行結果を見せようと思ったら僕がパソコンを持ち出さなくてはなりません。
これだとちょっと不便だということで、ブラウザ上で見られるようにしようと思いました。
僕が普段使い慣れているのはRuby on Railsですが、たったこれだけのコードを動かすためにRailsを持ち出すのはちょっとオーバーキルな気がします。
この程度のロジックならHTMLとJavaScriptの静的なファイルだけでさくっと完結させたいところです。
が、僕はJSがそこまで得意ではありません。
もちろん、ちょっと時間をかければ同等のコードを書けるとは思いますが、気分的にちょっと億劫です。
もしRubyのコードをひょいっとそのままブラウザ上で動かせれば、それがベストです。
ブラウザ上でRubyで動かす?
そのときふと思いました。
「Opalがあるじゃない!」
OpalはRubyで書いたコードをJSに変換してくれるライブラリです。
これを使えばきっと、さっき書いたコードをさくっとブラウザ上で動かせるはず・・・!!
というわけでやってみたのがこちらです。
ターミナル版とは異なり、Web版ではカードの種類や1枚あたりの価格を変更できるようになっています。
(デフォルトはハイキュー!!のカードにあわせて全55種類、1枚110円)
コードはこちらに置いてあります。
使用した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
でコンパイルできます。
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もお楽しみに😭