golangでの確率処理とreactにおけるsetIntervalの話
こんにちは。CURUCURUエンジニアの長尾です。 今回は先日CURUCURUセレクトサイトにて開催していたルーレットの実装において golangでの確率処理とreactにおけるsetIntervalでつまづきがありましたので、それについてお話したいと思います。
ルーレットとは
先日弊社CURUCURUセレクトのサイトにおいてルーレットキャンペーンが開催していました。 特設ページにてルーレットを回して、当たりに応じてクーポンがもらえるというものです。
実装にあたって考えたこと
- ルーレットを回したときのログをどう取得するのか
- ルーレットの当たり処理はどうするのか
- ルーレットの回る演出はどうするのか
ルーレットを回したときのログをどう取得するのか
ルーレットの当たり判定はサーバ側にて行うことを検討していたので、当たりログはデータベースに入れることにしました。 ログをデータベースにいれることで、同じ人が1度しか回せないようにという処理にも使えて一石二鳥ですね。
ルーレットにおける処理の流れは以下のようにしました。
- ルーレットを回すボタン押下
- ルーレットの当たり処理を行うAPIをサーバに実行
- ルーレットの演出実行
- 演出中に当たり処理が終わる
- 結果の演出表示
ルーレットの当たり処理
ルーレットの当たり処理はサーバ側で実行しているため以下はgolangでの話になります。
乱数を生成し、1等、2等などで当たりの確率があるのでそれらの境界線より下かどうかで判定しています。
注意点としては
- golangの乱数生成は0〜1までが生成されるので、それを利用。
- 当たり確率を0.1%まで作成したかったため確率マスターには1000分率としてDBに値を保存。
- 小数点を扱うため、floatだと間違いが起きそう(丸め誤差)なのでdecimalを利用。
- テストは入念に行いましょう!!
実装のイメージは以下のようになります。
type RateMaster struct {
Threshold decimal.Decimal // 閾値で利用
Rate decimal.Decimal // それぞれの確率を定義
HitKey string // 当たりのキー
}
func TestRunGacha(t *testing.T) {
rate1 := &RateMaster{
Threshold: decimal.Zero,
Rate: decimal.NewFromFloat(0.50), // 50%
HitKey: "hit_1",
}
rate2 := &RateMaster{
Threshold: decimal.Zero,
Rate: decimal.NewFromFloat(0.40), // 40%
HitKey: "hit_2",
}
rate3 := &RateMaster{
Threshold: decimal.Zero,
Rate: decimal.NewFromFloat(0.09), // 9%
HitKey: "hit_3",
}
rate4 := &RateMaster{
Threshold: decimal.Zero,
Rate: decimal.NewFromFloat(0.01), // 1%
HitKey: "hit_4",
}
var slices []*RateMaster
// テストのためソート処理をしていないが実際は確率が大きい順にソートする必要がある
slices = append(slices, rate4, rate3, rate2, rate1)
totalWeight := decimal.Zero
// ここで境界値の計算を実施
for _, v := range slices {
v.Threshold = totalWeight
totalWeight = totalWeight.Add(v.Rate)
}
doGacha := func() string {
rand.Seed(time.Now().UnixNano())
// 乱数生成 0-1までの乱数が生成される
random := decimal.NewFromFloat(rand.Float64())
hit := ""
for i := len(slices) - 1; i >= 0; i-- {
// 境界値と比較して当たりかどうかを判定
if slices[i].Threshold.LessThanOrEqual(random) {
hit = slices[i].HitKey
break
}
}
return hit
}
// 結果表示
counter := 0
gachaResults := map[string]int{}
for {
result := doGacha()
if _, ok := gachaResults[result]; ok {
gachaResults[result] += 1
} else {
gachaResults[result] = 1
}
counter += 1
if counter == 1000 {
break
}
}
fmt.Println(gachaResults)
}
// 1000回回した結果
map[hit_1:491 hit_2:405 hit_3:90 hit_4:14]
ルーレットの回る演出はどうするのか
ルーレットの演出はフロント側のため以下はtypescriptにて記載しています。
ここが今回のルーレット実装において一番苦労した点かもしれません。 useEffectとsetIntervalの使い方をイマイチ理解していなかったがためにハマってしまいました。
以下に簡単な例を出します。
// カスタムフックとして定義
const useRoulette = () => {
// setIntervalの中で変更されるstate
const [timer, setTimer] = useState(0);
// useRefを使いsetIntervalの参照先を作る
const interval = useRef<NodeJS.Timer>();
// setInterval関数を定義
const startRoulette = () => {
// refにsetIntervalを入れる.
// useRefで生成されるオブジェクトはコンポーネントの全体から参照可能
interval.current = setInterval(() => {
// 以下の書き方はNG。timerが永遠に0になってしまう。
// setTimer(prev+1);
// 以下のようにするとsetStateでは前回の値を引き継いで使うことができる。
setTimer(prev => {
return prev + 1;
});
}, 100);
};
useEffect(() => {
if (timer === 1000) {
// useRefを使うことで参照可能になったintervalIDに対してclearInterval実行
clearInterval(interval.current);
}
if (timer === 0) {
// timer起動
startRoulette();
}
, [timer]}
}
上記は簡単な例ですがポイントとしては以下の2つです。
- setStateにおいて前回の値を使って更新する
setTimer(prev => {
return prev + 1;
});
- setIntervalをuseRefの.currentプロパティに入れることでclearIntervalできるようにする
interval.current = setInterval(() => {
// do something...
}, 1000);
他にも違う形での実装方法はありますので適宜実装方法を選定してください。
timer部分ができればあとはHTML、CSSを使ってルーレットの演出は出来ます。 演出方法もやり方はたくさんありますが今回は以下の流れで実装をしました。
実装の流れ
- ルーレットが6コマあるとした場合、それぞれが光っている画像を用意
- それぞれが光っている画像を大きな1枚の画像で生成
- CSSのbackground-imageで大きな1枚の画像を設定
- ルーレット処理で1~6までの数字によってbackground-positionを設定
まとめ
今回はルーレットの実装についてお届けしました。 コードを交えてお届けしましたので少しでも誰かの助けになれば幸いです。
実装の手順、流れ、考え方は色んなやり方があるので都度自分にあったやり方で実装してみてください。
以上、長尾がお届けしました。
メンバー募集
CURUCURUでは開発メンバーを募集中です。 CURUCURUの開発に興味があったり、モダンな開発環境で挑戦してみたいという方がいましたら、ぜひこちらも覗いてみてください!