背景
ポケモンの名前だけでしりとりをするブラウザゲームを置いています。
コードにしようとすると「ピカチュウの次は何?」で手が止まりました…。
判定部分を実際のコードから読み直した備忘録です。
最後の文字を決める2つの関数
判定の中心は headChar(最初の文字)と tailChar(最後の文字)の2つだけだ。
どちらも、画面に出す名前ではなく「読み」を通してから1文字を取り出す。
下記がゲーム本体の定義です(SMALL だけ1行に詰めました)。
const YOMI = {
"ニドラン♀": "ニドランメス",
"ニドラン♂": "ニドランオス",
ポリゴン2: "ポリゴンツー",
ポリゴンZ: "ポリゴンゼット",
};
const SMALL = { ャ: "ヤ", ュ: "ユ", ョ: "ヨ", ッ: "ツ", ァ: "ア", ィ: "イ", ゥ: "ウ", ェ: "エ", ォ: "オ" };
const norm = (c) => SMALL[c] || c;
const reading = (n) => (YOMI[n] || n).replace(/[・:]/g, "");
const headChar = (n) => norm(reading(n)[0]);
function tailChar(n) {
const s = reading(n);
let i = s.length - 1;
while (i > 0 && s[i] === "ー") i--;
return norm(s[i]);
}
reading は、記号や数字を含む4匹だけを YOMI で読みに置き換え、「・」「:」を消す。
tailChar は末尾から「ー」を読み飛ばし、止まった文字を norm で大きい字に直す。
なぜ名前を直接いじらないかというと、画面の表示は元の名前のまま出したいからです。
読みの変換を関数の中に閉じておけば、表示用の文字列を壊さずに済む。
実際に動かして確かめる
ゲームの HTML から、const NAMES = [ から const ROW_COLORS の手前までを切り出して Node.js で評価しました。
この範囲に NAMES と上の関数が入っているので DOM 無しで動きます(index.html は公開中の HTML のコピー)。
const fs = require("fs");
const html = fs.readFileSync("index.html", "utf8");
const src = html.slice(html.indexOf("const NAMES = ["), html.indexOf("const ROW_COLORS"));
const { NAMES, headChar, tailChar } = new Function(src + "; return { NAMES, headChar, tailChar };")();
console.log("NAMES:", NAMES.length);
for (const n of ["ピカチュウ", "ミュウツー", "カイリュー", "リザードン", "ポリゴン2"])
console.log(n, "→", tailChar(n));
const uu = NAMES.filter((n) => headChar(n) === "ウ");
console.log("「ウ」ではじまるポケモン " + uu.length + "匹");
console.log("「ン」で終わる:", NAMES.filter((n) => tailChar(n) === "ン").length);
出力は下記でした。
NAMES: 1025
ピカチュウ → ウ
ミュウツー → ツ
カイリュー → ユ
リザードン → ン
ポリゴン2 → ツ
「ウ」ではじまるポケモン 22匹
「ン」で終わる: 143
カイリューは「ー」を飛ばして「ュ」、それを大きくして「ユ」になっている。
ポリゴン2は読み「ポリゴンツー」経由で「ツ」。
1025匹中143匹が「ン」終わりで、1割強が地雷です。
候補の数と「ン」の判定
画面の「〇〇ではじまるポケモン △匹」は、まだ使っていない名前の集合 pool(Set)から headChar が一致するものを集めた数だ。
function candidates(ch) {
const out = [];
for (const n of pool) if (headChar(n) === ch) out.push(n);
return out.sort((a, b) => a.length - b.length || a.localeCompare(b, "ja"));
}
ゲーム開始直後は pool が全1025匹なので、「ウ」なら上の実行結果と同じ 22匹 と出る。
選ばれた名前は pool.delete(name) で消えるので、同じポケモンは2回出せない。
tailChar(n) === "ン" の候補は縞模様のボタンにし、選んだ瞬間も同じ判定で負けにしている。
CPU の選び方
CPU は、まず「ン」で終わらない候補に絞る(無ければ仕方なく全候補)。
その先頭60件について、「その名前を出したら相手の候補が何匹残るか」を数え、一番少ない手を選ぶ。
for (const n of all.slice(0, 60)) {
const s = candidates(tailChar(n)).length + (tailChar(n) === "ン" ? 1000 : 0);
if (s < bestScore) { bestScore = s; best = n; }
}
const pick = Math.random() < 0.34 ? all[Math.floor(Math.random() * all.length)] : best;
all は上で絞った候補の配列で、candidates と同じく短い名前順に並んでいる。
同じ切り出しで開始直後の「ウ」を試すと、「ン」以外の19匹から ウールー(尻は「ル」、続く候補10匹)が最善手になりました。
ただし 34% の確率でわざと無作為に選ぶ。
毎回最善手だと勝ちにくすぎるので、ここで強さを調整している様子です。
ゲームはこちらで遊べます。
https://hashito.biz/tools/pokeshiritori/
しりとりを実装するなら、名前を直接比べずに「読みから頭と尻の1文字を返す関数」を先に作ると、候補の数も負け判定も CPU もその2関数の組み合わせで書けます。
本記事はAI補助で執筆した、個人開発の紹介記事です。