ブラウザで動く風来のシレン型のターン制ローグライクを作った。地下100階まで潜る、いわゆる「99階ダンジョン」である。
配信されるのは index.html / style.css / main.js の3ファイルだけで、ライブラリもCDNも読み込まない。実行時依存はゼロ、ページを開けばすぐ動く(開発はTypeScript + esbuild。後述するCI/CD導入に合わせて素のJSから移植した)。
作ること自体は、ダンジョン生成・視界・戦闘・アイテムと順に積み上げれば形になる。本稿の読みどころはその後だ。一通り遊べるようになった段階で自動プレイのボットを書き、踏破を狙わせたところ、そもそもクリアが数学的に不可能だと判明した。そして最終的に、この「踏破できるか」の検証はGitHub Actionsのマージゲートになった。
作ったもの
- ダンジョン生成: マップを格子状の区画に割り、各区画に部屋を1つ置いて隣り合う区画を通路でつなぐ。全区画の連結はUnion-Findで保証し、そのうえで未使用の辺を数本足してループを作る(袋小路だけの構造だと逃げ場がなくなるため)
- 視界: レイキャストではなく風来のシレン方式。部屋にいるときは部屋全体、通路にいるときは隣接8マスだけ。ターン制の探索では「部屋に入った瞬間に全体が見える」方が判断しやすい
- 描画: Canvas 2D。画像を1枚も使わず、矩形と漢字グリフ(鼠・蝠・鬼・兵…)だけで描く。NetHackのようにフロア全体を1画面に収める
- 未識別アイテム: 草と巻物は「赤い草」「ミミズの巻物」など見た目の名前で出る。見た目と中身は1対1で対応し、その組み合わせは潜るたびにシャッフルされる
- 満腹度、装備の修正値(+n)、スコアとランキング(ゲームセンター方式の3文字ネーム入力つき)
自動プレイに踏破させようとしたら
一通り遊べるようになったので、階段への最短経路を辿るボットを書いて走らせた。平均到達階は5.3階。99階には遠く及ばない。
死因を追うと、序盤は「部屋で複数の敵に囲まれて溶ける」、中盤以降は「レベルが階層に追いつかない」だった。前者はボットの立ち回りの問題だが、後者は数値の問題である。そこで経験値の収支を計算してみて、青ざめた。
| 必要経験値 | その階までに得られる総経験値 | |
|---|---|---|
| 地下50階相当(Lv28) | 89,231 | 87,957 |
| 地下70階相当(Lv38) | 1,433,022 | 317,067 |
| 地下99階相当(Lv53) | 92,227,778 | 1,165,250 |
最下層に見合うレベルには9,200万expが必要なのに、全99階の敵を1体残らず倒しても116万expしか手に入らない。79倍足りない。誰がどうプレイしても地下100階には到達できなかった。
原因は自分で書いたこの3行だ。
const table = [0, 8, 20, 40, 70, 115, 180, 270, 390, 550, 760, 1050];
while (table.length < 70) {
table.push(Math.round(table[table.length - 1] * 1.32)); // 等比で伸ばす
}
手で調整したのはLv12まで。その先は「等比で伸ばしておけばいいだろう」と1.32倍を掛け続けていた。等比数列は指数関数的に伸びるので、線形に増える収入をあっという間に置き去りにする。手書きの範囲では気づかず、Lv30を超えたあたりから発散していた。
各階で得られる経験値の積み上げを実測し、それに沿う Lv⁴ / 15.7 のカーブへ差し替えた。4乗でも十分に急峻だが、指数関数とは伸び方の質が違う。
「敵を弱くする」は逆効果だった
経験値カーブを直しても踏破できない。自然回復が1ターンあたり固定1HPで、最大HPが数百になる深層では実質機能していなかったのを見つけて、最大HP比例に変えた。それでも届かない。
そこで「敵の数と強さを下げれば楽になるだろう」と緩和したところ、到達階が83階から44階に下がった。
理屈は単純で、敵が減ると経験値も減る。レベルが上がらず、結果的に弱くなる。難易度を下げたつもりが、プレイヤーの成長を削っていた。
難易度は敵側ではなくプレイヤーの成長側で調整するのが正しい。この検証で得た一番の教訓だった。実際、レベルアップ時の伸びを上げたら一発で踏破が出た。
装備を鍛える道を用意する
もう一つ効いたのが、装備の修正値(+n)である。武器の祝福・防具の祝福の巻物で鍛えられ、盾の修正値は被ダメージ軽減に全量効く(素の防御力は半減して効く)ようにした。鍛え上げれば被弾が1に張り付くので、深層攻略の正攻法が「鍛える」になる。
修正値はアイテム1個ごとに保持されるので、持ち替えると投資が消える。どれを育てるかの選択が生まれ、拾った装備が最初から+0〜+10を持っていることもあるため、床の装備を吟味する意味も出た。
調整の過程で分かったのは、あるパラメータを緩めたら別のパラメータで締め直す必要があることだ。修正値を導入したら踏破率100%、満腹度を楽にしたらまた100%と、緩和のたびに跳ね上がる。最終的にレベルの伸びと最深部2種(阿修羅・大魔王)の強さで釣り合いを取り、自動プレイの踏破率は 0% → 約40% に落ち着いた。未踏破でも80〜90階台までは到達する。
バランスそのものをCIのゲートにした
ここまでの検証はすべて、ブラウザのコンソールで自動プレイを走らせる手作業だった。せっかくなのでこれをCI/CDに組み込んだ。
まず素のJSをTypeScriptに移植した(型を付ける過程で、ステータス表示が装備の修正値を無視している既存バグが1つ見つかった)。そのうえでGitHub Actionsを2段構えにした。
- PR時: ESLint / 型チェック / 単体テスト / ビルド。それぞれ独立したジョブなので、Checksのどれが落ちたかで原因が一目で分かる
- masterマージ時: 上記に加えて踏破シミュレーションを実行し、全部合格したときだけ本番へ自動デプロイする
踏破シミュレーションは、生存重視の自動プレイを固定12シードで走らせ、「踏破2回以上・10回以下(簡単すぎ検出)・平均到達階55以上」を機械判定する。乱数はシード付きで決定的なので、このテストが落ちたら「揺らぎ」ではなく「バランスが変わった」ことを意味する。flakyにならない踏破テストが書けるのは、ローグライクの乱数設計がそのまま効いている。
単体テストには「必要経験値 ≦ 全99階で得られる総経験値」というバランスの成立条件そのものも入れた。冒頭の79倍事故は、もう機械が防いでくれる。
シミュレーション結果はActionsのJob Summaryに表として出る。seedごとの到達階・レベル・ターン数・結末(踏破/どの敵にやられたか)が、マージのたびにコミット履歴へ残っていく。
おまけ: メッセージが途中で止まるバグ
遊んでいて「2〜3階あたりからログが出なくなる」と指摘を受けた。原因はバッファ処理だった。
ログは60件を超えると古いものをshift()で捨てる作りで、描画側は「配列長 − 描画済み件数」で新着を判定していた。60件に達すると配列長が60で頭打ちになるため差が常に0になり、以降まったく表示されなくなる。
捨てた分も含む通算件数を別に持ち、リングバッファ(1000件)に置き換えて解決した。shift()は要素数に比例するコストもかかるので、一石二鳥だった。


コメント