ポケモンSVが話題なので乱数に関して
ゲームボーイカラーぶりにポケモン本編プレイしてます。
その中でカジュアルの通信対戦の乱数が固定で命中率は低いが当たれば絶対に倒せる技一撃必殺の技を確実に当てることが出来るという致命的なものが発見された。
昔のゲームの乱数調節とは
なんでこんなことが起きるのか
このような視点で書いていこうと思う。
乱数について
まずコンピュータは思考をしているわけではないのでランダムというものが苦手。
自然界だとサイコロを振ることや、数字を書いた紙を入れた箱から1枚引くといった行動をすることで乱数列というものを作ることが出来る。
しかしコンピュータだとそういったことが出来ない為、一見乱数列に見える
が、計算によって作成される疑似乱数と呼ばれるものを使用している。
サイコロを振るってのを再現する場合、単純に[4→2→6→1→3→5→1→2→6→3→4→5]という中から順番に選んでいくのも疑似乱数。
この疑似乱数の実装方法というのは昔(PS以前の)のゲームだとアセンブラでの開発だったため、メーカーやタイトル、プログラマによって違うとかあると思う。
よく言われる乱数調整は、この乱数生成の計算式の内容、計算の中に使われている数字、ベースとなる数字等の法則性をゲームごとに見つけて、欲しい乱数を出すようにしているもの。
疑似乱数の生成には計算式が使われるというのが伝わりにくいかと思うので、説明するために線形合同法を使って説明する。
線形合同法
乱数生成アルゴリズムの中で一番有名なのが線形合同法だと個人的に思っている。
式は以下で
Xn+1 = (A * Xn + B) mod M
AとBとMが定数。M>A、M>B、A>0、B≥0というのが決まっている。
X0が乱数の種でいわゆるseedと呼ばれるもの。X1はX0をもとに計算。X2はX1をもとに計算と、前の値を次の計算に使用する。
A=4,B=5,C=6、X0=8とすると
X1 : (4 × 8 +5) mod 6 = 1
X2 : (4 × 1 +5) mod 6 = 3
X3 : (4 × 3 +5) mod 6 = 5
X4 : (4 × 5 +5) mod 6 = 1
上の答えを見るとわかるように同じ数字が出たら同じ数字が出ると1 → 3 → 5 →1 → 3…といったように周期性が決まってくる。周期性を最大にするには決まりがあるが、割愛する。
より知りたい人はwikipediaでも十分書いてある。線形合同法wikipedia
数学に馴染みがないかつエンジニア以外の人だとmodがわからないかと思うので説明するが、modは余りの計算。「5 ÷ 2 = 2 余り 1」と小学生の時答え書いていたともうが、modはこの余りの部分を出す計算。 「5 mod 2 = 1 」
これをコードにすると
let X: number = 8;
function LCGs: number(){
const A: number = 4;
const B: number = 5;
const M: number = 6;
return X = (A * X* B ) % M;
}
本来はもっと複雑になっていて周期が短くならない様に作られている。
let X: number = 8;
function LCGs: number(){
const A: number = 12345;
const B: number = 67890;
const M: number = 123456;
return X = (A * X* B ) % M;
}
伝えたいのはseed値が変わらない限り、生成される乱数列は同じだということ。
rand関数
最近よくゲーム制作で使用されているゲームエンジンのUnreal EngineはC++でコードを書けるがC++の標準ライブラリでの乱数は
rand
srand
の使用ができ、srandでseedを設定してからrandを使用する。
#include <iostream> // cout
#include <cstdlib> // srand,rand
using namespace std;
int main(int argc, char const* argv[])
{
std::srand(2); //seedの設定
// ↓10回rand関数を呼び出し結果を標準出力に出力
for (int i = 0; i < 10; ++i) {
cout << rand () << endl;
}
return 0;
}
seedを設定せずに使用した場合デフォルトのseedが2であり、生成される乱数列は同じものになる。
seedを設定する場合も定数で設定した場合は同じ数字が使いまわされるため
、同じ乱数列になる。
それを防ぐためにsrandnにtime関数でUNIX時間を渡すというのが簡単に乱数列を作成するために使用される。
random関数
次に数年前にスマホアプリだとほぼこれで作られていたって感じのUnityだとC#を使用してコードを書く。
C#の標準ライブラリだとrandom関数が用意されている。
using System;
public class Rand {
static void Main() {
Random rand = new Random();
for (int i = 0; i < 10; i++) {
Console.Write(rand.Next());
}
}
}
C#のrandom関数はseedを指定しない場合、自動でticktime (マシンを起動してからの経過時間)を設定するため、同じ乱数列が生成されることは少なくなる。
ただし、500ms程度の差だと同じticktimeになるようで時間差が少ないと同じ乱数列が生成される。
ポケモンの乱数の話
話は少し逸れたがポケモンの話に戻る。
ポケモンの乱数って意外と歴史深いみたいで、今までの過去作もみんな乱数の計算式を読み解いて、乱数調節様のツールを作成していたみたい。
ポケモンずっとしてなかったので知らなかった。
まあただ今回の通信対戦時の乱数に関しては、そんな難しい計算式を使っているというわけではないと思う。
↑で書いた通り、コンピューターは擬似乱数で、seedを同じにすると同じ乱数列が生成されるというのは、アルゴリズム変えようが疑似乱数では変わらない性質。
C++やC#の乱数生成の関数の話をしたのは、seedを同じにするのはランダム性が低くなるからseedを時間を使って一致しないようにするというのはよく使われたり、言語によってはデフォルトで入っているということを伝えたかった。
ポケモンの状況はカジュアルバトルだと乱数列の生成時に同じシードを使用してるということ。
1回目の乱数の値が命中90以下はハズレる数値、2回目の乱数の値が命中35でも命中する数値という風になっている。
ランクマッチでの状況は確認できてないのとランクマッチが出たタイミングで修正入っている可能性もある。
ただ、ランクマ初手指を振るで確定ハッピータイムがでるならほぼ間違えなく、カジュアルと同じ乱数列。
ハッピータイム以外でも同じ技しか出ないならカジュアルとは違うがシードが同じ乱数列と考えることができる。
もちろん修正入っている可能性があるというか入れていると信じたい。
スマホゲームの乱数
ただのおまけのコラム。
飛ばしても構わない、スマホゲームと乱数に関して軽く書いていく。
これらは一昔前の混沌の時代の話で、多分今は法整備や、前例が出た結果
まともに運用しているとことが多いだろうし、変わっていると思う。
ガチャ研究所の話
一昔前のスマホゲームでガチャ研究所といったサイトがあった。
ガチャを引いてレアリティが最高のキャラが今どれくらいの排出率で出ているのかというサイト。
実際に排出率が高いタイミングで引くと当たる傾向があった。
これまでの話でわかる人もいるかもしれないが、時間を乱数のシードにすることというのは手軽にできるため、以前だと時間から乱数列の生成をしていたのだと思う。
このガチャ研究所、しばらくして乱数の生成方法が変わったのか機能しなくなり、閉鎖されていてた。
余談だがC#のticktimeを使用していた例は多分あったと思われる。起動後、○秒で引くと当たりが出やすい見たいなのは使っていたのだと思われる。
当たりプレイヤーの話
都市伝説的に言われているのが当たりID。
当たりIDの人はガチャでレアキャラが当たりやすいといったもの。
当たりIDがあるかというと正確にはわからない。中身のアルゴリズム見てない為。
ただIDを乱数の生成のシードの一部に使っている可能性は0ではないと思うのでIDによって偏る可能性はあるだろうが、IDだけで生成してない場合は偏るということは少ないのじゃないかなというのが正直な感想。
1つあった事例としてはガチャの排出する順番が決まっているリストを2つ用意し、リストから順番に排出されるというのがあった例。
このような感じで課金する人のIDは出やすいガチャリスト側に振り分けってことはやろうと思えばできたのではないかと…。
最初に書いた通り、今は大手はまともに運営しているところがほとんどだと思うのでそんなことないと思う。
まとめ
技術的なこと書かないと思っていたが、流石にシード固定で乱数生成は酷すぎるなと思ったので…。
ゲーム作成する際は疑似乱数をどうするかは気にしましょう。
難しいことわからないって場合は、現状だとメルセンヌ・ツイスタを使用していればいいんじゃないかなと。