クロスワード盤面をブラウザで動的生成する打ち切り付き探索
パズルゲームで「毎回ちがう盤面を出す」と決めた瞬間に、盤面を作るコードが要る。クロスワードのような交差する語の盤面は、真面目に作ろうとすると制約充足問題(CSP)になり、バックトラッキング付きのソルバを書くことになる。
ただし、ブラウザで遊ぶゲームの盤面生成に必要なのは「最適な盤面」ではない。「成立する盤面が1枚あればよい」である。この条件なら、探索を解かずに「ランダムに作ってから成立しているか確かめる」で十分に間に合う。
この記事では、hashito.biz のポケモンクロスワードが使っている生成器(public/tools/pokecross/generator.js)の実装を追いながら、生成 & 検査(generate-and-test)方式を実用に耐えさせるために何が要るかを整理する。要点は3つで、打ち切り予算・座標の後から正規化・盤面の「引き直し」である。最後に、この生成器を Node からそのまま呼んで盤面を出すスクリプトを置く。
前提: 生成器は DOM に触らない
このファイルは UMD でも ES モジュールでもなく、素の IIFE で window.PokeGen に関数を生やすだけの層である。冒頭のコメントにも「DOM非依存」と書かれている。
1 | |
DOM を触らないので、global.window = {} を用意すれば Node から require できる。盤面生成のように「目で見ても正しさが分からない」処理は、描画から切り離しておかないと検証手段が消える。実際このリポジトリには test/pokecross-generator.test.js があり、node --test で18件のテストが動く。
盤面の出力形式は次のとおりで、n の値が意味を持つ。
n === -2… 最初から見えているマス(ヒント)n === -1… 交差の空欄(入力するが答えではない)n >= 0… 答えのマス。値は答えの何文字目か
1. 「作って確かめる」の骨組み
生成の中心は crossword() である。やっていることは単純だ。
- 語のリストをシャッフルする
- 先頭から、答えにしたい長さ
[minL, maxL]に収まる語を1つ選ぶ(これが答え) - 答えの各文字について、その文字を含む語をリストから1本ずつ拾う
- 答えの文字数ぶん拾えたら成功。足りなければシャッフルからやり直す
1 | |
ここには枝刈りも順序付けもない。 失敗したら全部捨てて引き直す。CSP ソルバなら「この語を置くと以降が詰む」を検出して部分的に戻すが、この生成器はそれをやらない。語彙が数十〜数千語あって、答えが3〜6文字しかないなら、成功率が十分高いので引き直したほうが速くて短い。
2. 打ち切り予算がないと止まらない
生成 & 検査方式の弱点は明快で、成立しない要求を渡されると永久に回り続けることである。「9文字の答え」を要求されても、語彙に9文字の語が無ければ何度シャッフルしても見つからない。ブラウザのメインスレッドでこれをやると、タブが固まる。
このファイルはそこに2段の防御を入れている。1段目は、シャッフルする前の存在チェックである。
1 | |
O(n) を1回走らせるだけで、make(9, 10) のような「そもそも候補が無い」要求は即座に null を返す。minL > maxL のような壊れた引数もここで落ちる。
2段目は、シャッフル回数そのものの上限である。ソースのコメントに実測値が残っている。
1 | |
この決め方が実務的に大事なところである。 上限を「勘で大きめ」にすると、実際に詰んだときの待ち時間が読めない。ここでは (a) 正常系の最悪値を実測し、(b) その30倍を上限にし、(c) 1回あたりのコスト(約0.35ms)から打ち切り時の所要時間(1秒前後)を出している。上限値そのものより、上限に達したときに何秒で戻るかを見積もっているかどうかが、UI を固めないための分かれ目になる。
予算は budget オブジェクトで持ち回り、make() の外側ループと crossword() の内側ループで共有する。片方だけに上限を置くと、外側が回り続けてしまう。
1 | |
3. 座標は原点を気にせず置いて、後から正規化する
盤面を組み立てる build() は、最初の語をこの位置から置き始める。
1 | |
[100, 100] は画面上の意味を持たない。語をどちらへ伸ばしても座標が負にならないための「十分な余白」である。交差する語は上にも左にも伸びるので、[0, 0] から始めると負のインデックスが出る。
マス目は二次元配列ではなく "x,y" を鍵にした辞書で持つ。
1 | |
配列だと事前に大きさを決める必要があるが、辞書なら決めなくていい。そして全部置き終わったあとに、実際に使ったマスの最小・最大を取って原点へ寄せる。
1 | |
盤面の幅と高さもここで初めて決まる(maxx - minx + 1)。先に盤面サイズを決めて、その中に収まるように語を置く設計にすると「収まらないからやり直し」が増えるが、この順序なら置いた結果がそのまま盤面になる。生成 & 検査方式と相性がよい。
4. 「型に沿って伏せる」で難易度を変える
このゲームには10問で終わるチャレンジと、終わりのないエンドレスがある。エンドレスで難しくする方法として、ソースは答えの文字数を伸ばし続けることを意図的に避けている。コメントがその理由を書いている。要約すると、3→4→5→6 と伸ばしても「同じ作業が長くなるだけ」で、思い出し方が変わらないからである。
かわりに変えるのは、盤面のどこが見えているかのほうだ。交差する語のマス(n === -2)を、型に沿って空欄(n === -1)に落とす。
1 | |
tail… 各語の末尾を伏せる(頭から読んで最後を補う)head… 各語の先頭を伏せる(尻から逆に辿る)alt… 市松に伏せるmost… 答えマスの隣以外をほぼ伏せる
判定は隣接マスの有無だけで書かれている。語が横並びか縦並びかを別に保存していないのがうまいところで、隣にマスがあるかどうかから復元している。
1 | |
そして重要なのは、答えマス(n >= 0)には絶対に触らないことである。伏せる操作は n === -2 のマスだけを -1 に変える。正解判定は「すべての入力マスが正解文字と一致するか」なので、マスの種類を変えるだけで出題内容が変わる。判定側に手を入れていない。
出題方針は乱数を使わず、問題番号から決まる。
1 | |
文字数の帯は毎問ずれ、伏せ方の型は2問ごとにずれる。同じ番号なら必ず同じ方針になるので、テストが書けるし、順番を再現して不具合を追える。
伏せられない盤面が出る
型を選んでも1マスも伏せられない盤面がある。たとえば tail は「語の終わりのマス」を伏せる型なので、語の終わりがすべて答えマスだった盤面では何も起きない。画面には「語尾かくし」と型の名前が出るので、素の盤面のままその名前を出すと説明と中身が食い違う。
そこで makeEndless() は、伏せられる盤面が出るまで最大12回引き直す。
1 | |
この率は語彙に依存する。 ソースのコメントには実際の語彙で tail が 6.1%(3000枚中183枚)と記録されているが、後述のスクリプトで技術用語50語の語彙に差し替えて1000枚ずつ測ると tail が 17.9% になった。語が短く末尾の重なりが起きやすいほど上がる。自分の語彙に入れ替えるなら、この率は測り直す必要がある(12回の引き直しで足りるかがここで決まる)。
実際に試す
Node.js 20 以降があれば依存は要らない。リポジトリを持っていなくても、公開されている生成器を1本だけ取ってくれば動く。
1 | |
同じディレクトリに demo.js を置く。語彙は何でもよいので、ここでは技術用語のカタカナ語に差し替える(生成器は window.POKE_NAMES からカタカナの語だけを読む)。
1 | |
実行する。
1 | |
盤面は毎回変わる(生成器は Math.random を使う)ので、出力の形だけ見てほしい。2000回の所要時間も環境で変わり、手元の Node.js 20.17.0 では 3.9〜4.1 秒(1枚あたり約 2ms)だった。
1 | |
この盤面を読み解くと、生成器がやっていることが見える。5列目を縦に読むと、上から6マスが データ■ー□、6行目から下の5マスが □クリプ■ になっている。この2語は6行目のマスを共有していて、データベース の「ス」と スクリプト の「ス」が同じ1マスである。答えの ベクトル は4マスに散っていて、ベ(4行目・5列目)・ク(6行目の メトリ■)・ト(10行目・5列目)・ル(10行目の プロ■コ■ の末尾)に対応する。
make(9, 10) が null を返しているのが、hasAnswerCandidate が効いている証拠である。この行だけコメントアウトして走らせると、体感できるほど待たされてから null が返る(打ち切り予算のおかげで、固まりはせず戻ってはくる)。
伏せ方の型ごとの「1マスも伏せられない率」も測れる。上のファイルの末尾に足す。
1 | |
上の50語の語彙での結果はこうなった。
1 | |
tail だけが突出する。語の末尾は答えマスに使われやすいためで、語彙を差し替えたときに最初に確認すべき数字はここである。
まとめ
- 「成立する盤面が1枚あればよい」なら、CSP ソルバではなく生成 & 検査で十分に間に合う。枝刈りを書かないぶん実装が短い
- 生成 & 検査で必須なのは打ち切り予算。上限値は勘で決めず、正常系の最悪試行回数を実測し、その倍数を取り、打ち切り時に何秒で戻るかまで見積もる
- 候補の存在チェック(O(n) 1回)を先に置くと、成立しない要求を総当たり前に落とせる
- 座標は余白を取った位置から置き、最後に最小値を引いて正規化する。盤面サイズを先に決めない
- 難易度は「量を増やす」より「見えている情報を型に沿って削る」ほうが、問われ方そのものを変えられる。ただし型が空振りする盤面の率は語彙に依存するので測る
生成器の実物は ポケモンクロスワード で動いている。ほかのゲームは hashito.biz のゲーム一覧にある。本記事の出力例は Node.js 20.17.0 で実際に実行した結果である。