読めない地名が、向かってくる
全国の地名が奥から迫ってくる。読みは伏せてあり、ローマ字で打ち切ると弾ける。近づくにつれて読みが1文字ずつ開くので待てば誰でも打てるが、開くほど点は減り、打ち間違えるほどその地名で入る点も下がる。問われているのは打鍵の速さではなく、読めるかどうか。難読度は外部の漢字辞典を使わず、全国12万件の地名だけから連立方程式で解いて決めている
制作メモ
- AIに任せた
- Day018のローマ字判定エンジン(lib/romaji.js・204行)をそのまま流用する判断。かな汎用で isSupported() による足切りまで入っていたので、書き直しは一切していない。新しく書いたのは難読度の計算・ヒントの段階開示・奥行きの描画・地方ブロック・ライフ制の進行/『読みを隠す』と決めた時点で、読めない人が1文字も打てずに終わる問題が確定することの指摘。Day018で実測した『遅い人ほど消える寸前の皿に縛られ続ける死のループ』と同じ構造なので、同じ性質の手当て(ヒントの段階開示+降参)を実装前に入れた/得点を『開いた文字数が少ないほど高い』に置いた設計。速さで点を付けると、読みを隠した意味がなくなり Day018 と同じゲームになる/打ち間違いの減点を『総スコアから引く』ではなく『その地名の持ち点に0.85を掛ける(複利・下限は満点の2割)』に置いた設計。読みを伏せている以上プレイヤーは当てずっぽうで打つので、総額を削ると「読めないなら手を出すな」というゲームになって伏せた意味と引っ張り合う。持ち点だけを削れば、迷って連打するほど静かに損をするだけで済む。ミスの回数はプレイ中には出さない(出すと答えの手がかりになる)/開き切っても2割は点が入るようにした判断。0点にすると、開き切ったあとに打つ理由が消えて『見ているだけ』になる/打ち間違えたときに『押してほしかったキー』を返さない実装。Day018はミスの瞬間にそれを出していたが、読みを伏せている最中に出すと答えが漏れる。記録だけ残して結果画面で使う。テストで固定した/難読度を外部の漢字辞典なしで出す2段構えの設計。第1段は『かなの総数=各漢字のかな数の和』という式を全国12万件ぶん立てて、漢字約2,250種の期待かな長を最小二乗(Jacobi反復)で解く。第2段はその期待長を使って漢字とかなを動的計画法で対応づけ、漢字ごとの読み出現頻度を数える。難読度は珍しさ(−log 出現確率)の平均+かな長の意外さ/対応づけを2周まわして精度を上げたこと(1周目は長さだけ、2周目は1周目の頻度表を費用に足して切り直す)/ユニットテスト24本(開示の単調性、接近が速い回でも着弾前に開き切ること、得点の単調減少、ライフの減り方、正解を見せてから次に進むこと、降参した地名の再出題、在庫を使い切ったあとの巡回、答えを漏らさないこと)/出題の難易度を式ではなく実測で決めたこと。全件から等間隔に採ると1問目が「板山=いたやま」級で退屈だったので、東北の在庫を順位ごとに並べて中身を目で確かめ、全国上位20%かつ「少なくとも1文字は珍しい当て方をしている」を条件にした/E2Eを7本追加したこと。とくに『打ち間違えても押してほしかったキーを画面に出さない』は、破れると遊びが成立しなくなるので固定した/画面幅320〜1280pxで地名のはみ出しを実測する検査を書き、字の大きさを「どの文字数でも枠幅の約8割」になる式に直したこと/音を『見えていないものだけ』に絞った設計。打鍵そのものには音を付けていない——プレイヤーの目は漢字と伏せ字に釘付けなので、見えているものに音を重ねても情報は増えない。鳴らすのは①読みが1文字開いた(開くほど音が下がる=持ち点が減っている)②着弾が近い(心拍が速くなる)③打ち間違い④弾けた(上行・伏せたまま残せたぶんだけ高い)⑤失敗(下行)の5つだけ。Day018のlib/sound.jsと同じく、決める部分(純関数)と鳴らす部分(AudioContext)に割ってユニットテスト6本を当てた/デモ動画の音を『別の効果音を被せる』のではなく『アプリが実際に鳴らした音を控えて組み立て直す』方式にしたこと(?sound=log → tools/render-demo-audio.mjs)。位置合わせは動画の末尾から逆算するので、頭を切ったあとでも合う
- 自分でやった
- 『難しい地名が近づいてきて、打てたらはじけて次に行く』というゲームの指定と、参照作(漢字でGO!)の提示/読めないときの逃げ道を『ヒント段階開示+降参』にする選択/出題の舞台を地方ブロック(東北・関東など)で選べるようにする指定/郵便番号データのダウンロード実行(Claude側は許可が下りず実行できなかった)
- 失敗と修正
- 郵便番号データのダウンロードURLが間違っていた。WebFetchが返した絶対URL(/zipcode/dl/utf/zip/utf_ken_all.zip)をそのまま使ったが、これは移転前のパスで404を返す。しかも落ちてきた72KBのHTMLを zip だと思い込んだまま本人に実行を頼み、本人の環境でも同じHTMLが落ちた → 配布ページのリンクが相対パス(utf/zip/utf_ken_all.zip)で書かれていることを確認し、基準ディレクトリと結合して正しい絶対URLを組み立てた。あわせて、外部から取ったファイルは file かサイズで中身を確かめてから次に進む手順にした。72KBという明らかに小さいサイズが最初から出ていたのに見ていなかった
難読度の上位に『御幸町(みゆき)』のような地名が混ざった。漢字3文字に対して読みが3かなしかなく、読まれない『町』にも1かなが割り当てられて、実在しない読みが珍しい読みとして数えられていた。実在の難読ではなく対応づけの失敗 → 『町』『村』『島』で終わるのに読みがそれを含まない地名を除外した。除外後の上位は上八(こうじょう)・石原(いさ)・東風平(こちんだ)・大豆谷(まめざく)・月出里(すだち)と、実在の難読地名だけが並ぶようになった
地方ごとの読めなさを『上位200件の平均』で比べていたが、これは母数が大きい地方ほど有利になる統計だった。中部(母数25,083)が1位、四国(4,070)と北海道(4,815)が下位という、母数の順とほぼ同じ並びが出ていた → 母数に対する割合(上位1%)と中央値で比べるようにした。並びは入れ替わり、九州・沖縄が1位、北海道が最下位になった。統計の取り方を変えると順位が変わることそのものを、画面に出す予定
実機で打鍵が1つも入らなかった。盤面のどこかを押すと入力欄へフォーカスを戻す作りにしていたが、押した既定の動作でフォーカスが盤面側へ移り、直後に入力欄から外れていた → pointerdown で preventDefault してから focus するようにした。あわせて、それでも外れたときのために、打ち始めた時点で入力欄へ戻す受け皿を document 側に置いた
4文字の地名(南外若林)が画面幅を超えた。字の大きさを固定にしていたため。文字数で割る式に変えたら今度は幅が文字数によらず一定になり、枠の半分(179px / 356px)しか使わず小さくなった → 上限3つの min() にした。①1〜2文字が大きくなりすぎない上限 ②横幅640pxで頭打ちになる枠に合わせた上限 ③それより狭い画面のための実測幅に合わせた上限。320〜1280pxのどの幅でも枠幅の約78%を占め、はみ出さないことを測って確認した
一覧用スクショが、地名が弾けている最中を写していた。burst の演出は字を2.1倍に拡大するので、枠で切れて何の画面か分からなくなっていた。振り付けの待ち条件が「読みに伏せ字でない文字がある」だけで、打ち切った直後の全開示にも当たっていた → 「向かってきている最中(incoming)」かつ「半分まで近づいた」かつ「読みが一部だけ開いている」の3つを待つようにした
npm run precheck が NG を1件出した。出題データに、非公開にしている語と偶然一致する実在の地名が含まれていた。全国12万件もあれば当たる → ビルド時に .precheck-ng.txt を読んで、都道府県・市区町村・町域名のどれかが当たる地名を除外するようにした(48件)。NGリストはgitignoreされているので、手元に無い環境では素通しになり、そのときは precheck 側が止める
meta.json に screenshot を書き忘れ、shared-share の『リンクを貼ったときに中身が出る(OGPとcanonical)』が落ちた。og:image はビルドが meta.json から入れるので、ファイルを置くだけでは足りない → screenshot: screenshot.webp を足した。ビルドの出力が『スクショ 18』のままだったのが手がかりだった
E2Eを1本ずつ流すと通るのに、全体(306件)で流したときだけ『読みを打ち切ると弾けて点が入る』が落ちた。テスト側が盤面の開始後に出題データ465KBを取りに行っており、混んでいるときはその間に地名が手前まで来てしまう。次の地名に変わったあとで古い読みを打っていたので、点が入らなかった → 出題データとローマ字変換を開始前に読み込んでおき、盤面が出てからは通信なしで引くだけにした。1本あたり5.2秒→0.2秒になり、3回繰り返しても落ちない。アプリ側の不具合ではなくテストの競合だった
テストの競合をもう1つ踏んだ。盤面が見えた時点で地名を読み取っていたが、地名が入るのは最初の描画(rAF)が回ったあと。混んでいると読み取りのほうが先に来て、出題データの引き当てが空になり、そこから先が全部おかしくなっていた → 盤面が出るのを待つだけでなく、地名が入るまで待つようにした。引き当てが空だったときはその場で落ちるようにもした(空のまま進むと、原因から遠いところで落ちて読み解けない)。5回繰り返しても通る
公開後、本人の実機で「打っても反応しないし、打つところもない」。原因は2つあった。①打鍵を受ける入力欄を1px・opacity:0 の隠し要素にしていたため、盤面を触っても入力欄に当たらず、スマホで仮想キーボードが出なかった。さらに盤面のpointerdownでpreventDefault()してからfocus()していたのが致命傷で、iOS/Androidは入力欄への直接の操作でしか仮想キーボードを開かないため、既定の動作を止めると二度と出ない。このpreventDefault()は制作中に踏んだデスクトップのフォーカス外れを直すために入れたもので、直した先で別の端末を壊していた。②日本語入力がONのとき、compositionend(確定した瞬間)でしか警告を出しておらず、確定せずに打ち続ける人には完全な無反応に見えた → Day018(同じタイピングゲーム)の作りをそのまま移植した。入力欄(#keys)を盤面と読みの全面に広げ、盤面を触れば入力欄に当たるようにした(親の .play-area を position:relative にして、#keys を inset:0 で敷く)。font-sizeは16px(iOSの自動ズーム防止)。preventDefault()は入力欄の外を触ったときだけに限定。タッチ端末には「ここをタップしてキーボードを出す」を1打も受け取れていないあいだ出す(focus()を呼んでも仮想キーボードを開かない端末があるので、フォーカスの有無だけでは消さない)。日本語入力はcompositionstartで即座に知らせる
この不具合をE2E 369件が1件も捕まえていなかった。Playwrightはキーイベントを直接注入するので、仮想キーボードが出ているかもフォーカスがどこにあるかも関係なく打ててしまう。テストは全部緑なのに、実機では遊べない状態で公開していた → 回帰テストを「打てたか」ではなく『実機でキーボードが出るための条件』に向けた。入力欄が盤面の全面を覆っているか・font-sizeが16px以上か・タップでフォーカスが入るか・変換開始で警告が出るか、の4本。壊すと落ちることをミューテーションで確認した
デモの音を最初に書き出したら −50 LUFS(ピーク0.034)で、Xでは何も聞こえない大きさだった。ブラウザ側のマスター音量(lib/sound.js の 0.22)まで再現に掛けていたのが原因。アプリはスピーカー側の音量で補われるが、動画にはそれが無い → マスター音量は掛けず、音符のgainをそのまま頂点として書き出し、全体を1つの係数でピーク0.7へ持ち上げるようにした。係数が1つなので音どうしの大小の関係は変わらない。−23.7 LUFS になり、既存の音付きDay(−19.5〜−22.2)と同じ帯に入った。day-012も同じくマスターを掛けていない






















