「召喚士は通す」
「ガードも通す」
「キマリは通さない」
——FF10屈指の名シーン。プレイしていなくても、なぜか知っているレベルの場面だ。私はこれを久しぶりに思い出して、アルゴリズムの説明をするのにちょうどいいなとふと思った。
あなたはどうだろう。これはキャラクターの感情の話だと思う派? それとも条件分岐の話だと思う派?
私は後者で読みたい。名前を見て通す・通さないを決めているだけなら、コードにするとかなり短い。そして短く書けるからこそ、あとで困る形になっているのが見えてくる。
「キマリは通さない」をif文にする
まず素直に書いてみる。門番が判定しているのは「誰が来たか」だけなので、こうなる。
function canPass(name: string): boolean {
if (name === "ユウナ") return true; // 召喚士は通す
if (name === "キマリ") return false; // キマリは通さない
return true; // ガードも通す
}
3行で終わる。ここで大事なのは、キマリだけが名指しで弾かれているという構造だ。役割で判定しているわけではない。「弱いロンゾだから」という理由が、コード上では name === "キマリ" という1行に潰れている。
そしてゲーム中には続きがある。力を示せば通れる。ビランとエンケをボコせばいいわけだ。つまり判定に状態がひとつ増える。
function canPass(name: string, defeatedGuardians: boolean): boolean {
if (name === "ユウナ") return true;
if (name === "キマリ") return defeatedGuardians; // 倒していれば通れる
return true;
}
これで「キマリの問題」が表現できた。ほかの誰も defeatedGuardians を要求されていない。同じ門を通るのに、キマリだけ追加条件が乗っている。早い話、これは門のルールではなくキマリ個人に貼られた例外というわけだ。
力を示すとは何か──サイコロで定義する
defeatedGuardians を true にする条件、つまり「勝てるかどうか」を決めないと、この関数は動かない。実際のゲームには戦闘のアルゴリズムがあるが、ここではサイコロバトルに置き換えて考える。
前提はこうだ。イベントバトルであり、キマリの成長でパラメータが変わるタイプなので、順当に育っていれば負ける要素は薄い。なので次のように設定する。
- ロンゾ(ビラン・エンケ)は2以上を出せない。1d6を振っても最大2に丸められる
- キマリは強いので2未満を出さない。1d6を振っても最小2に持ち上げられる
- ロンゾ2人の出目の合計に、キマリが1回の出目で並べば勝ち
const roll = () => 1 + Math.floor(Math.random() * 6);
const kimahriRoll = (floor: number) => Math.max(roll(), floor);
const ronsoRoll = (cap: number) => Math.min(roll(), cap);
function kimahriWins(floor = 2, cap = 2): boolean {
return kimahriRoll(floor) >= ronsoRoll(cap) + ronsoRoll(cap);
}
ロンゾ側の合計は最大4にしかならない。キマリは最大6を出せる。感覚的には五分五分くらいだろうと思っていたが、6×6×6の216通りを全部数えると56.0%になった。
なぜ半々からずれるかというと、合計4(両方が2)になる確率が25/36とかなり高いからだ。そのうえでキマリが4以上を出す確率は1/2。ここが勝率の大半を決めている。
ちなみに判定を >= から > に変えると、つまり同点を負け扱いにすると38.9%まで落ちる。引き分けをどちらに寄せるかだけで17ポイント動くわけで、仕様の曖昧な一文がいかに効くかがわかる。
効いているのはキマリの下限ではない
せっかく変数にしたので、数字を動かしてみる。まずキマリ側の下限(floor)を変えた場合。
| キマリの下限 | 勝率 |
|---|---|
| 1(制限なし) | 55.6% |
| 2 | 56.0% |
| 3 | 65.3% |
| 4 | 100% |
下限1と2で、勝率がほとんど変わらない。「キマリはつよつよだから2未満は出さない」という設定を入れたのに、0.4ポイントしか動いていないわけだ。理由は単純で、ロンゾの合計が2になるのは1/36しかないから、下限2が効く場面がほぼ来ない。
次にロンゾ側の上限(cap)を変えてみる。キマリの下限は2で固定した。
| ロンゾの上限 | 勝率 |
|---|---|
| 2 | 56.0% |
| 3 | 33.8% |
| 4 | 21.3% |
こちらは一気に動く。上限を2から3にしただけで勝率が22ポイント落ちる。
ということは、だよ。この勝負を決めているのはキマリの強さではなく、ロンゾ側にどれだけ蓋をしたかのほうだった。「キマリを強くする」つもりで下限をいじっても数字は動かない。バランス調整をするなら触るべきパラメータは別にある、というのがコードにしてはじめて見える。
理論上は五分五分だろうと思っていたものが、数えてみると効いている変数が違った。この手のズレは、頭の中だけで考えていると気づけない部分だと思っている。
if文の列をやめて、ルールをデータにする
さて、最初のコードに戻る。あのままだと通行人が増えるたびに if が伸びていくし、「なぜ通さないのか」という理由がコードのどこにも残らない。
そこで、判定の中身を書くのをやめて、ルールをデータとして並べる形にする。
type Traveler = {
name: string;
role: "summoner" | "guard" | "ronso";
provenStrength: boolean; // 力を示したか
};
type Rule = {
match: (t: Traveler) => boolean;
allow: boolean;
reason: string;
};
const gateRules: Rule[] = [
{
match: (t) => t.role === "summoner",
allow: true,
reason: "召喚士は通す",
},
{
match: (t) => t.role === "ronso" && !t.provenStrength,
allow: false,
reason: "力を示していないロンゾは通さない",
},
{
match: () => true,
allow: true,
reason: "それ以外は通す",
},
];
function canPass(t: Traveler): { allow: boolean; reason: string } {
const rule = gateRules.find((r) => r.match(t))!;
return { allow: rule.allow, reason: rule.reason };
}
先頭から順に評価して、最初に一致したルールが勝つ。ファイアウォールやルーティングの設定と同じ考え方だ。
この形にすると変わることが3つある。
1つ目は、name === "キマリ" という名指しが消えたこと。判定しているのは「力を示していないロンゾかどうか」という条件であって、個人ではない。同じ立場の誰が来ても同じ結果になるし、キマリが力を示せば同じルールのまま通れる。
2つ目は、理由が返り値に乗ること。通さなかったときに「なぜ」が一緒に返るので、門番のセリフをそのままログにできる。条件分岐の結果だけを返す関数は、あとからデバッグするときに何も教えてくれない。
3つ目は、ルールの追加が配列への1要素追加で済むこと。関数本体を触らなくてよくなる。順序に意味がある点だけ注意が必要で、match: () => true を上に置いた瞬間に全員通ることになる。
よくある質問
なぜif文の列のままだと困るのか?
条件が個人名に結びついたままだと、同じ立場の相手が増えるたびに分岐を足すことになる。さらに「なぜ通さないのか」という理由がコードから消えるため、あとで読んだ人が仕様の意図を復元できない。ルールをデータとして持てば、条件と理由をセットで保持できる。
サイコロの勝率はなぜ50%にならないのか?
ロンゾ2人の出目の合計が4になる確率が25/36と高く、そこでキマリが4以上を出す確率が1/2だからだ。この組み合わせが全体の大半を占めるため、勝率は56.0%に落ち着く。同点を負け扱いにすると38.9%まで下がる。
バランス調整をするなら、どのパラメータを触るべきか?
キマリ側の下限ではなくロンゾ側の上限を触る。下限を1から2に上げても勝率は55.6%から56.0%にしか動かないが、ロンゾの上限を2から3にすると56.0%から33.8%へ22ポイント落ちる。数値の効き方は実際に全パターンを数えないと見誤る。
まとめ:条件はデータで持つ
「キマリは通さない」は、コードにすると name === "キマリ" という名指しの1行になる。短く書けるし、その場では正しく動く。ただしそこにはなぜ通さないのかという理由が残らないわけだ。
サイコロに置き換えて数えてみたら、勝率は56.0%で、効いていたのはキマリの下限ではなくロンゾ側の上限のほうだった。強さをいじったつもりで0.4ポイントしか動かないパラメータを触る、というのはコードでも現場でもよくある話だと思う。
ルールをデータとして並べて、理由を返り値に乗せる。それだけで判定は個人から条件へ移るし、力を示したキマリは同じルールのまま通れるようになる。名指しの分岐を書きたくなったときほど、その条件に名前をつけられないか考えたほうがいいだろうなと思っている。