都道府県クイズに地図モードを4つ追加してずんだもん音声をR2配信に乗せた

開発mdx-playground

やったこと

都道府県クイズに地図系モードを4つ追加した。Phase 5(県→特徴 逆引き)、Phase 6A(白地図クリック)、Phase 6B(地形図モード)、Phase 7(study-map)まで Claude Code に作らせて、最後に Phase 8 でずんだもん音声を VOICEVOX ローカルエンジン直接呼び出しから事前生成 MP3 + Cloudflare R2 配信に切り替えた。途中で「神経衰弱を作らせたら自分の意図は地図トグルだった」「VOICEVOX 直接呼びは開発でしか動かない」という方針転換が2回挟まった。

Phase 5: 県→特徴の逆引きクイズ

左に日本地図ハイライト、右に「○○県の特徴は?」の4択。最初に Wikimedia の SVG を拾わせたら path がひとかたまりで、県別ハイライトができない。仕方なく Financial Times 風の簡略タイル地図で出させたら見た目に納得がいかず、「もうちょっと頑張ってほしい」と差し戻した。

Geolonia の japan-prefectures.svg に差し替えさせて解決した。30KB、viewBox は 0 0 1000 1000、各県が <g class="hokkaido prefecture" data-code="01"> の単位でグループ化されていて、CSS で .active を当てるだけでハイライトできる。GFDL ライセンスなので帰属表記を入れさせた。地図サイズも画面いっぱいまで拡大させた。

カバー範囲は 43 県。神奈川・大阪・福岡・佐賀は「日本一」級の確実な出典が取れず、無理に出さず外した。

Phase 6A: 白地図クリック(find-prefecture)

「○○県はどこ?」と問題が出て、地図上の県をクリックして当てるモード。HUD(問題文・タイマー・誤答数・ベスト)は position: absolute で地図に重ねて、PC では地図を画面いっぱいに表示するように指示した。

正解は緑、誤答は赤で永続ハイライトする。ハードモード(正解するまで開示しない)とイージーモード(1回間違えたら正解県を緑+ラベル表示)の切替を入れさせた。

履歴管理は自分の独自指示で組み込ませた。直近3回の正誤を localStorage['o', 'x', 'o'] で保存して、各県の履歴ドットを表示する。

type PrefHistory = Record<string, ('o' | 'x')[]>
// 「○が2連続できれば卒業」のメンタルモデルで
// フィルター: 全47県 / 直近1回× / 2連続× / 3連続×

フィルターを切り替えると出題範囲が絞り込まれる。最終的にベスト記録はフィルター=all のときだけ更新するよう finalize() でガードさせた。HUD には地方名「東北」を縦書き rowspan で1列目に置き、2列目に県名と履歴ドットを並べたテーブルを表示する設計にした。地方ごとに行がまとまって眺められる。

Phase 6B: 地形図モード

当初は背景に PNG の地形図を重ねる案で進めていたが、座標を SVG の viewBox と合わせるのは現実的でないと途中で気付いた。各県の平均標高で CSS 段彩する方式に解釈変更させた。

長野(標高 1132m)と山梨(995m)が濃茶、関東平野が緑、と地理感が一目でわかる。標高テーブルは静的データとして持たせ、<g> の塗りを段階別に切り替えるだけで済む。

Phase 7: 神経衰弱 → 却下 → study-map

最初「県と特徴をマッチングする神経衰弱」を一通り作らせたが、動くものを見せてもらったところ自分の意図と違った。自分が欲しかったのは「地図クリックで県名カードを開閉する学習モード」だった。memory-game.vue を全削除して、study-map.vue を新規に書き直してもらった。

JapanMap.vue には2つの機能を追加させた。複数ハイライト(multiActivePrefIds)と SVG ラベル表示(labels)。県の中心座標は getBBox() で取り、親 chain の transform を累積して絶対 viewBox 座標に変換するのが肝だった。

// 県の SVG 内中心座標を求める
const getPrefCenter = (el: SVGGraphicsElement) => {
  const bbox = el.getBBox()
  const ctm = el.getCTM()  // 親 transform を累積
  // ... bbox を viewBox 座標に投影
}

地域別カラーは「全部同じ薄マゼンタじゃなく地域で色分けしてほしい」と指示して実装させた。.multi-active.tohoku.multi-active.kanto のような CSS セレクタで 8 地方区分の色を上書きする。北海道=青、東北=緑、関東=黄、中部=オレンジ、近畿=ピンク、中国=紫、四国=ティール、九州沖縄=赤。

Phase 8: VOICEVOX 直接呼び → 事前生成 MP3 + R2

最初は useVoicevox.ts を作らせて、ローカルの VOICEVOX エンジン(http://localhost:50021)に直接 fetch でリクエストするコードにしていた。開発機ではずんだもんが「ちがうのだ」と返してきて気持ちよく動いた。

本番デプロイ直前で気付いた。自分が VOICEVOX を起動していないと音は鳴らない。Cloudflare Pages 上で localhost:50021 に投げても何も帰ってこない。

Python スクリプトを書かせて wav → mp3 を 100 ファイル(合計 0.8MB)一括生成させた。useVoiceClips.tsruntimeConfig.public.voiceBaseUrl を切り替える設計に直して、Cloudflare R2 の oto-assets バケットに prefecture-quiz/ プレフィックスでアップロードした。最終的に https://assets.otoarchive.com/prefecture-quiz/{clip}.mp3 で公開される。

ずんだもんのセリフが長すぎて次の問題読み上げと被る事故に気付いた。「ちがうのだ」「おしいのだ」「すごいのだ」など短いセリフだけに絞らせ、「ここがせいかいなのだ」系の長いものは削らせた。

音声 3 重再生バグも踏んだ。連打すると古いリクエストの合成結果が遅れて返ってきて、新しい音と重なって鳴る。pendingRequestId を使って「synthesize 中の古いリクエストは破棄する」設計に直させたら止まった。

let pendingRequestId = 0
const play = async (clip: string) => {
  const myId = ++pendingRequestId
  const audio = await fetchClip(clip)
  if (myId !== pendingRequestId) return  // 古いリクエストは破棄
  audio.play()
}

クリア演出の紙吹雪

全問正解時の演出は CSS のみで 18 ピースの紙吹雪を降らせた。confetti ライブラリを足すほどではないと判断して、Math.sin(seed * 9999) の擬似ランダムで色・角度・落下時間を決定的にばらして、ピースごとに違う動きをさせた。

Codex レビューで 4 件の致命的指摘

最後に Codex に通したら 4 件返ってきた。

  • pnpm build が未確認のまま push しようとしていた
  • 未追跡ファイルが多数残っていた
  • 音声本番 404 リスク(R2 アップロード前に URL がリンクされていた)
  • ずんだもんセリフのフォールバック未実装

全件反映させて締めた。

学んだこと

  • 「神経衰弱を作ってほしい」と頼んだつもりが、自分の意図は地図トグルだった。動くものを早めに見せてもらうと認識ズレが即修正できる
  • ローカル開発で動く音声合成は本番では動かない。localhost に投げているコードは、デプロイ前に必ず疑う
  • SVG ハイライトは Geolonia のように path がグループ化された素材を使うと、CSS だけでモードを増やせる
  • 連打バグの解決には「最新のリクエスト ID 以外は破棄」が効く。AbortController でも同じ思想

明日やること

  • 神奈川・大阪・福岡・佐賀の「日本一」級の出典を探して 47 県カバーに戻す
  • study-map の地域別カラーをライト/ダークテーマ両方で見直す(ダークだと近畿のピンクが浮く)
  • R2 の oto-assets バケットに CORS 設定を入れて、別ドメインからの prefetch を有効化する