都道府県タイピングのソースコード全文 — JavaScript・HTML・CSS

▶ 都道府県タイピングで遊ぶ 📖 作り方を1から学ぶ ✏️ このコードを書きかえて動かす

このページでは、ひなテックGamesで実際に動いている「都道府県タイピング」のソースコードを丸ごと公開しています。ここに載っているのは説明用に書き直したコードではなく、いまあなたが遊んでいるゲームそのものを動かしている本物のファイルです。

このゲームは、47都道府県を最後まで打ち切るタイムアタック(RTA)です。北海道から沖縄へ順に打つ「順番どおり」、出題順をシャッフルする「ランダム」、県庁所在地の読みを打つ「県庁所在地」の3つのモードがあり、記録とランキングはモードごとに別々です。モードが3つあってもゲームの芯は1つで、「配列を順に進める」「時間を計る」「進み具合を見せる」という、タイムアタックゲームの基本3点セットがきれいに詰まっています。タイピングゲームの中でもいちばん読みやすい構成なので、コードリーディングの入門にもおすすめです。

コードはコピーして自分のパソコンで自由に動かせます。出題リストを世界の国名や歴史用語に差し替えれば、自分だけの学習タイピングも作れますよ。

都道府県タイピングは3つのファイルでできている

都道府県タイピングは、次のファイルが役割を分担して動いています。それぞれの中身は下で1つずつ解説します。

ファイル役割
index.htmlゲーム画面の骨組み。盤面・スコア表示・ボタンなどの部品をHTMLで配置する
style.css見た目のデザイン。色・大きさ・レイアウト・アニメーション
game.jsゲームのルールを動かす頭脳。操作の受け付け・判定・スコア計算など

この「HTMLで構造、CSSで見た目、JavaScriptで動き」という分担は、ほとんどのWebページ・Webゲームに共通する考え方です。

index.html — ゲーム画面の骨組み

index.htmlは、ゲームの「部品」をHTMLで並べるファイルです。中心はお題を表示するpref-stageで、地方名(region-label)・漢字(word-jp)・かな(word-kana)・ローマ字ガイド(word-romaji)の4段で1つのお題を表示します。県庁所在地モードのときだけ、漢字の上に「北海道の道庁所在地」のような問題文(word-pref)が加わります。

その下のpref-chipsは空っぽですが、ここにJavaScriptが日本地図を読みこんで入れます。スコアエリアにはタイム・進み・ミス・ベストの4つの数字が並びます。モードを選ぶmode-switchの3つのボタンは、data-mode属性にモード名(order・random・capital)を持っていて、game.js はこの値を見るだけでどのモードが選ばれたかがわかります。

※ 下のコードでは、ゲームの動きに関係のないタグ(広告・アクセス解析・検索エンジン向けの記述)を省いています。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>都道府県タイピング 無料|47都道府県と県庁所在地を覚える社会の勉強タイピングゲーム|ひなテックGames</title>
  <meta name="description" content="都道府県タイピングを無料でプレイ!北海道から沖縄まで47都道府県を順にローマ字で打ち切るタイムアタック。県庁所在地モードでは、札幌・盛岡・那覇などの読みを打って県庁所在地も覚えられます。打つたびに日本列島が地方の色に塗られ、県名と場所を自然に覚えられます。ダウンロードなし・登録不要でブラウザからすぐ。小学生の社会の勉強・ローマ字練習にもぴったり。最速タイムでランキングに挑戦!">

  <link rel="stylesheet" href="../../css/style.css?v=20260927">
  <link rel="stylesheet" href="style.css?v=20260914">
  <link rel="stylesheet" href="../../css/leaderboard.css">

</head>
<body>
  <div id="header"></div>

  <div class="game-page">
    <h1 class="game-page-title">都道府県タイピング</h1>
    <div class="game-page-tags">
      <a href="../../tags/typing/" class="tag">タイピング</a>
      <a href="../../tags/learning/" class="tag">学習</a>
    </div>
    <!-- タイピング練習コースの「あそぶ」段に置いた導線。基本から学びたい人を練習エンジンへ送る -->
    <div class="course-banner">
      <p>⌨️ このゲームは「<a href="../../typing/" data-course-link="hub">タイピング練習コース</a>」の<strong>あそぶ</strong>ページです。「とっとり」「ほっかいどう」でつまずくときは <a href="../typing-lesson/" data-course-link="lesson">はじめてのタイピング練習</a> へ(1キーずつ・時間制限なし)。</p>
      <p>くわしく: <a href="../../typing/small-tsu/" data-course-link="small-tsu">小さい「っ」の打ち方</a>・<a href="../../typing/romaji/" data-course-link="romaji">ローマ字入力の早見表</a></p>
    </div>

    <div class="game-container">
      <!-- スコアエリア -->
      <div class="score-area">
        <div class="score-box timer-box">
          <span class="score-label">タイム</span>
          <span class="score-value" id="timer">0.0</span>
        </div>
        <div class="score-box">
          <span class="score-label">進み</span>
          <span class="score-value" id="progress">0/47</span>
        </div>
        <div class="score-box">
          <span class="score-label">ミス</span>
          <span class="score-value" id="misses">0</span>
        </div>
        <div class="score-box">
          <span class="score-label">ベスト</span>
          <span class="score-value" id="best-score">--</span>
        </div>
      </div>

      <!-- お題ステージ -->
      <div class="pref-stage" id="pref-stage">
        <div class="region-label" id="region-label"></div>
        <!-- 県庁所在地モードのときだけ表示する問題(県名) -->
        <div class="word-pref" id="word-pref"></div>
        <div class="word-jp" id="word-jp">スタートで出発!</div>
        <div class="word-kana" id="word-kana">北海道から沖縄まで、最速で日本一周しよう</div>
        <div class="word-romaji" id="word-romaji"></div>
        <div class="countdown" id="countdown"></div>
        <input class="mobile-input" id="mobile-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" aria-hidden="true" tabindex="-1">
      </div>

      <!-- 日本地図(打ち終えた県から地方色で塗られていく) -->
      <div class="pref-chips" id="pref-chips" aria-hidden="true"></div>
      <p class="map-credit">地図データ: <a href="https://github.com/geolonia/japanese-prefectures" target="_blank" rel="noopener">geolonia/japanese-prefectures</a>(GFDL)</p>

      <!-- モード切り替え(ベストタイム・ランキングはモード別) -->
      <div class="mode-switch" role="group" aria-label="モード">
        <button class="btn-mode" type="button" data-mode="order" aria-pressed="true">📍 順番どおり</button>
        <button class="btn-mode" type="button" data-mode="random" aria-pressed="false">🔀 ランダム</button>
        <button class="btn-mode" type="button" data-mode="capital" aria-pressed="false">🏛️ 県庁所在地</button>
      </div>

      <!-- アクション -->
      <div class="game-actions">
        <button class="btn-primary" id="btn-start">スタート</button>
        <button class="btn-mute" id="btn-mute" type="button" aria-label="サウンド切り替え">🔊</button>
      </div>

      <!-- 結果オーバーレイ -->
      <div class="result-overlay" id="result-overlay">
        <div class="result-content">
          <h2>🎌 47都道府県 制覇!</h2>
          <div class="result-time-big" id="result-time">0.0秒</div>
          <div class="result-stats">
            <div class="result-stat">
              <span class="result-stat-label">KPM</span>
              <span class="result-stat-value" id="result-kpm">0</span>
            </div>
            <div class="result-stat">
              <span class="result-stat-label">正確さ</span>
              <span class="result-stat-value" id="result-accuracy">100%</span>
            </div>
            <div class="result-stat">
              <span class="result-stat-label">ミス</span>
              <span class="result-stat-value" id="result-misses">0回</span>
            </div>
          </div>
          <div class="result-best" id="result-best" style="display:none;">
            ベストタイム更新!
          </div>
          <button class="btn-primary" id="btn-restart">もう一周する</button>
        </div>
      </div>
    </div>

    <!-- ゲーム枠直下の広告(hinata-game-below) -->

    <div class="game-instructions">
      <h3>遊び方</h3>
      <ul>
        <li>「スタート」を押すと北海道から出発。<strong>47都道府県を順にローマ字でタイプ</strong>して、沖縄までのタイムを競います</li>
        <li>出題順は北から南へ固定なので、<strong>練習するほどタイムが縮む</strong>RTA(リアルタイムアタック)形式</li>
        <li>スタートの上のボタンで<strong>🔀 ランダムモード</strong>に切替可能。出題順がシャッフルされ、ベストタイムとランキングは順番モードとは別になります</li>
        <li><strong>🏛️ 県庁所在地モード</strong>では、都道府県名と地図の場所を見ながら<strong>その県庁所在地の読み</strong>を打ちます(北海道→札幌=<code>sapporo</code>、沖縄県→那覇=<code>naha</code>)。漢字と読みが画面に出るので、覚えていなくても打ちながら覚えられます。「市」は打たなくてOK。ベストタイムとランキングはこのモード専用です</li>
        <li>打ち終えた県は日本地図が<strong>地方ごとの色</strong>に塗られていきます(東北=青、関東=赤…)</li>
        <li><strong>「し」は si / shi、「っ」は子音重ね(hokkaidou の kk)、「ん」は n / nn</strong>、どの打ち方でもOK</li>
        <li>ミスタイプのペナルティはありませんが、その分タイムをロスします。正確さも結果に表示</li>
        <li>タイムは<strong>1/10秒単位</strong>でランキングに登録できます。目指せ全国1位!</li>
        <li>都道府県の場所もいっしょに覚えたい人は<a href="../prefecture-quiz/">都道府県クイズ</a>もおすすめ</li>
        <li>キーボード付きのPC・タブレットでのプレイがおすすめです</li>
      </ul>
    </div>

    <div class="game-instructions class-note">
      <h3>🏫 授業で使うなら</h3>
      <dl class="class-note-grid">
        <dt>教科・学年のめやす</dt><dd>社会 小4〜(都道府県の名称と位置)/国語 小4〜(都道府県の漢字)</dd>
        <dt>所要時間</dt><dd>1周(47都道府県)約5〜10分。導入や帯活動でのくり返しに</dd>
        <dt>身につく力</dt><dd>都道府県名のつづりと地方ごとの位置・ローマ字入力の正確さ(物理キーボードが必要)</dd>
        <dt>配るURL</dt><dd><code>https://hinata-ya.tech/games/learn/prefectures/</code>(<a href="../../learn/prefectures/">都道府県の学習ページ</a>。1回10〜15分×3回:8つの地方/県の名前と場所/県庁所在地。めあて → 地図で知る → このゲームでやってみる → 5問でたしかめる → ふりかえりを提出用にコピー の順に進み、広告なし・ふりかえりは送信しません)。ゲームだけ配るときは <code>https://hinata-ya.tech/games/games/prefecture-typing/#capital</code>(県庁所在地モードで開く。末尾を <code>#order</code>(北から順)/<code>#random</code>(ランダム)にかえられます)</dd>
        <dt>クラスの順位表</dt><dd><a href="../../for-teachers/class-ranking/?game=prefecture-typing-capital">教室ランキングを作る</a>と、コードを配ったクラスの記録だけで順位表が出せます。電子黒板に映せて、全国ランキングには載りません</dd>
      </dl>
      <p class="class-note-copy"><button type="button" class="btn-copy" data-copy-id="prefecture-typing-learn" data-copy-text="都道府県の 学習ページを ひらいて、先生が 言った 回を やろう。①から⑤の じゅんに すすんで、さいごに ふりかえりを コピーして 出してね。\nhttps://hinata-ya.tech/games/learn/prefectures/">📋 課題文をコピー(学習ページ)</button><button type="button" class="btn-copy" data-copy-id="prefecture-typing" data-copy-text="都道府県タイピング(県庁所在地)を 3回 やって、いちばん はやい タイムを 記録カードに 書こう。\nhttps://hinata-ya.tech/games/games/prefecture-typing/#capital">📋 ゲームだけ配る</button><a class="btn-copy" href="../../for-teachers/pack/prefectures/">🎒 47都道府県パック(進め方・ワークシート)</a></p>
      <p class="class-note-more">第1〜3回を1回ずつ配るURLは<a href="../../learn/prefectures/#for-teachers">学習ページの「先生へ」</a>にあります。<a href="../../for-teachers/pack/prefectures/">47都道府県パック</a>に5分・10分・45分の進め方・ワークシート・評価の観点があります。スコアは評価には使わないでください。</p>
    </div>

    <div class="game-instructions">
      <h3>都道府県タイピングとは?</h3>
      <p>「都道府県タイピング」は、<strong>北海道から沖縄まで47都道府県の名前を順番にローマ字で打っていく</strong>タイムアタック型のタイピングゲームです。ふつうのタイピング練習と違うのは、打ったそばから<strong>日本地図が地方ごとの色に塗られていく</strong>こと。「次は東北だから青が増える」と目で追いながら打つうちに、県名のつづりと場所が自然に結びついて覚えられます。</p>
      <p>出題順は北から南へ固定なので、繰り返すほどタイムが縮む<strong>RTA(リアルタイムアタック)形式</strong>。小学生の社会(都道府県の暗記)とローマ字入力の練習を一度にこなせるので、<strong>おうち学習や夏休みの自由時間</strong>にも向いています。もちろん完全無料・ダウンロードなし・登録不要で、ブラウザを開けばすぐ始められます。</p>
    </div>

    <div class="game-instructions">
      <h3>都道府県タイピング 速く打つコツ・攻略</h3>
      <ul>
        <li><strong>短い県名から確実に</strong>:三重(mie)・奈良(nara)・佐賀(saga)など3〜4打で終わる県はノーミスで抜けたい。ここで詰まるとロスが大きい</li>
        <li><strong>長い県名は分けて覚える</strong>:鹿児島(kagosima)は「kago+sima」、和歌山(wakayama)は「waka+yama」と2ブロックで捉えると指が迷わない</li>
        <li><strong>「し」は si が1打得</strong>:shi でも正解ですが si なら1打少ない。広島・徳島・島根など「島」「し」を含む県で効いてきます</li>
        <li><strong>「っ」は子音重ね</strong>:北海道(ほっかいどう)は <code>hokkaidou</code> で、「っ」が <code>kk</code>。最初の1県なので、ここで詰まらないよう先に練習しておく</li>
        <li><strong>難所は東北と九州</strong>:似た響きが続く区間(宮城・宮崎、福島・福井・福岡)は取り違えやすい。地図の色を頼りに「いま何地方か」を意識すると迷いが減る</li>
        <li><strong>まずは順番モードで型を作る</strong>:固定順で指が覚えてから🔀ランダムモードに挑戦すると、実力が伸びたことを実感しやすい</li>
      </ul>
    </div>

    <!-- PREF:table -->
    <div class="game-instructions pref-table-wrap">
      <h3>47都道府県 一覧(読み方・ローマ字・県庁所在地)</h3>
      <p>タイピング中につづりが分からなくなったとき、社会の勉強で県庁所在地を確かめたいときにどうぞ。ローマ字はこのゲームで実際に打てる綴りです(「し」は si でも shi でも正解など、他の打ち方も受け付けます)。</p>
      <h4>北海道・東北(7)</h4>
      <div class="pref-table-scroll">
      <table class="pref-table">
        <thead><tr><th>都道府県</th><th>読み</th><th>ローマ字</th><th>県庁所在地</th></tr></thead>
        <tbody>
          <tr><th scope="row">北海道</th><td>ほっかいどう</td><td><code>hokkaidou</code></td><td>札幌市</td></tr>
          <tr><th scope="row">青森県</th><td>あおもり</td><td><code>aomori</code></td><td>青森市</td></tr>
          <tr><th scope="row">岩手県</th><td>いわて</td><td><code>iwate</code></td><td>盛岡市</td></tr>
          <tr><th scope="row">宮城県</th><td>みやぎ</td><td><code>miyagi</code></td><td>仙台市</td></tr>
          <tr><th scope="row">秋田県</th><td>あきた</td><td><code>akita</code></td><td>秋田市</td></tr>
          <tr><th scope="row">山形県</th><td>やまがた</td><td><code>yamagata</code></td><td>山形市</td></tr>
          <tr><th scope="row">福島県</th><td>ふくしま</td><td><code>fukusima</code></td><td>福島市</td></tr>
        </tbody>
      </table>
      </div>
      <h4>関東(7)</h4>
      <div class="pref-table-scroll">
      <table class="pref-table">
        <thead><tr><th>都道府県</th><th>読み</th><th>ローマ字</th><th>県庁所在地</th></tr></thead>
        <tbody>
          <tr><th scope="row">茨城県</th><td>いばらき</td><td><code>ibaraki</code></td><td>水戸市</td></tr>
          <tr><th scope="row">栃木県</th><td>とちぎ</td><td><code>totigi</code></td><td>宇都宮市</td></tr>
          <tr><th scope="row">群馬県</th><td>ぐんま</td><td><code>gunma</code></td><td>前橋市</td></tr>
          <tr><th scope="row">埼玉県</th><td>さいたま</td><td><code>saitama</code></td><td>さいたま市</td></tr>
          <tr><th scope="row">千葉県</th><td>ちば</td><td><code>tiba</code></td><td>千葉市</td></tr>
          <tr><th scope="row">東京都</th><td>とうきょう</td><td><code>toukyou</code></td><td>新宿区</td></tr>
          <tr><th scope="row">神奈川県</th><td>かながわ</td><td><code>kanagawa</code></td><td>横浜市</td></tr>
        </tbody>
      </table>
      </div>
      <h4>中部(9)</h4>
      <div class="pref-table-scroll">
      <table class="pref-table">
        <thead><tr><th>都道府県</th><th>読み</th><th>ローマ字</th><th>県庁所在地</th></tr></thead>
        <tbody>
          <tr><th scope="row">新潟県</th><td>にいがた</td><td><code>niigata</code></td><td>新潟市</td></tr>
          <tr><th scope="row">富山県</th><td>とやま</td><td><code>toyama</code></td><td>富山市</td></tr>
          <tr><th scope="row">石川県</th><td>いしかわ</td><td><code>isikawa</code></td><td>金沢市</td></tr>
          <tr><th scope="row">福井県</th><td>ふくい</td><td><code>fukui</code></td><td>福井市</td></tr>
          <tr><th scope="row">山梨県</th><td>やまなし</td><td><code>yamanasi</code></td><td>甲府市</td></tr>
          <tr><th scope="row">長野県</th><td>ながの</td><td><code>nagano</code></td><td>長野市</td></tr>
          <tr><th scope="row">岐阜県</th><td>ぎふ</td><td><code>gifu</code></td><td>岐阜市</td></tr>
          <tr><th scope="row">静岡県</th><td>しずおか</td><td><code>sizuoka</code></td><td>静岡市</td></tr>
          <tr><th scope="row">愛知県</th><td>あいち</td><td><code>aiti</code></td><td>名古屋市</td></tr>
        </tbody>
      </table>
      </div>
      <h4>近畿(7)</h4>
      <div class="pref-table-scroll">
      <table class="pref-table">
        <thead><tr><th>都道府県</th><th>読み</th><th>ローマ字</th><th>県庁所在地</th></tr></thead>
        <tbody>
          <tr><th scope="row">三重県</th><td>みえ</td><td><code>mie</code></td><td>津市</td></tr>
          <tr><th scope="row">滋賀県</th><td>しが</td><td><code>siga</code></td><td>大津市</td></tr>
          <tr><th scope="row">京都府</th><td>きょうと</td><td><code>kyouto</code></td><td>京都市</td></tr>
          <tr><th scope="row">大阪府</th><td>おおさか</td><td><code>oosaka</code></td><td>大阪市</td></tr>
          <tr><th scope="row">兵庫県</th><td>ひょうご</td><td><code>hyougo</code></td><td>神戸市</td></tr>
          <tr><th scope="row">奈良県</th><td>なら</td><td><code>nara</code></td><td>奈良市</td></tr>
          <tr><th scope="row">和歌山県</th><td>わかやま</td><td><code>wakayama</code></td><td>和歌山市</td></tr>
        </tbody>
      </table>
      </div>
      <h4>中国(5)</h4>
      <div class="pref-table-scroll">
      <table class="pref-table">
        <thead><tr><th>都道府県</th><th>読み</th><th>ローマ字</th><th>県庁所在地</th></tr></thead>
        <tbody>
          <tr><th scope="row">鳥取県</th><td>とっとり</td><td><code>tottori</code></td><td>鳥取市</td></tr>
          <tr><th scope="row">島根県</th><td>しまね</td><td><code>simane</code></td><td>松江市</td></tr>
          <tr><th scope="row">岡山県</th><td>おかやま</td><td><code>okayama</code></td><td>岡山市</td></tr>
          <tr><th scope="row">広島県</th><td>ひろしま</td><td><code>hirosima</code></td><td>広島市</td></tr>
          <tr><th scope="row">山口県</th><td>やまぐち</td><td><code>yamaguti</code></td><td>山口市</td></tr>
        </tbody>
      </table>
      </div>
      <h4>四国(4)</h4>
      <div class="pref-table-scroll">
      <table class="pref-table">
        <thead><tr><th>都道府県</th><th>読み</th><th>ローマ字</th><th>県庁所在地</th></tr></thead>
        <tbody>
          <tr><th scope="row">徳島県</th><td>とくしま</td><td><code>tokusima</code></td><td>徳島市</td></tr>
          <tr><th scope="row">香川県</th><td>かがわ</td><td><code>kagawa</code></td><td>高松市</td></tr>
          <tr><th scope="row">愛媛県</th><td>えひめ</td><td><code>ehime</code></td><td>松山市</td></tr>
          <tr><th scope="row">高知県</th><td>こうち</td><td><code>kouti</code></td><td>高知市</td></tr>
        </tbody>
      </table>
      </div>
      <h4>九州・沖縄(8)</h4>
      <div class="pref-table-scroll">
      <table class="pref-table">
        <thead><tr><th>都道府県</th><th>読み</th><th>ローマ字</th><th>県庁所在地</th></tr></thead>
        <tbody>
          <tr><th scope="row">福岡県</th><td>ふくおか</td><td><code>fukuoka</code></td><td>福岡市</td></tr>
          <tr><th scope="row">佐賀県</th><td>さが</td><td><code>saga</code></td><td>佐賀市</td></tr>
          <tr><th scope="row">長崎県</th><td>ながさき</td><td><code>nagasaki</code></td><td>長崎市</td></tr>
          <tr><th scope="row">熊本県</th><td>くまもと</td><td><code>kumamoto</code></td><td>熊本市</td></tr>
          <tr><th scope="row">大分県</th><td>おおいた</td><td><code>ooita</code></td><td>大分市</td></tr>
          <tr><th scope="row">宮崎県</th><td>みやざき</td><td><code>miyazaki</code></td><td>宮崎市</td></tr>
          <tr><th scope="row">鹿児島県</th><td>かごしま</td><td><code>kagosima</code></td><td>鹿児島市</td></tr>
          <tr><th scope="row">沖縄県</th><td>おきなわ</td><td><code>okinawa</code></td><td>那覇市</td></tr>
        </tbody>
      </table>
      </div>
    </div>
    <!-- /PREF:table -->

    <div class="game-instructions">
      <h3>都道府県タイピング よくある質問</h3>
      <ul>
        <li><strong>Q. 無料で遊べますか?</strong><br>A. 完全無料です。ダウンロード・インストール・会員登録はいりません。ブラウザで開くだけで遊べます。</li>
        <li><strong>Q. 都道府県を覚えるのに役立ちますか?</strong><br>A. 役立ちます。打った県が日本地図に色で塗られていくので、名前のつづりと場所を同時に覚えられます。場所を重点的に覚えたいときは<a href="../prefecture-quiz/">都道府県クイズ</a>と組み合わせるのがおすすめです。</li>
        <li><strong>Q. ローマ字の打ち方は1通りだけですか?</strong><br>A. 複数の打ち方に対応しています。「し」は si / shi、「ち」は ti / chi、「ん」は n / nn、「っ」は子音重ねのどれでも正解になります。</li>
        <li><strong>Q. スマホでも遊べますか?</strong><br>A. 遊べますが、47県を打ち切るゲームなので<strong>キーボードのあるPC・タブレット</strong>を強くおすすめします。</li>
        <li><strong>Q. 順番がいつも同じで飽きませんか?</strong><br>A. 🔀ランダムモードに切り替えると出題順がシャッフルされます。ベストタイムとランキングは順番モードと別々に記録されるので、順番どおりとは別の記録に挑戦できます(県庁所在地モードもさらに別の記録です)。</li>
        <li><strong>Q. 県庁所在地の練習もできますか?</strong><br>A. できます。🏛️県庁所在地モードに切り替えると、都道府県名と地図の場所を見ながら、その県庁所在地の読みをローマ字で打ちます(北海道なら札幌=sapporo、沖縄県なら那覇=naha)。漢字と読みが画面に出るので、覚えていなくても打ちながら覚えられます。「市」は打たなくてOKで、ベストタイムとランキングは他のモードとは別に記録されます。</li>
        <li><strong>Q. 他のタイピングゲームもありますか?</strong><br>A. あります。物語を進める<a href="../typing-quest/">タイピングクエスト</a>、レースで競う<a href="../typing-gp/">タイピングGP</a>、基礎練習向けの<a href="../typing-dojo/">タイピング道場</a>、英単語で練習する<a href="../word-typing/">英単語タイピング</a>など、目的に合わせて選べます。</li>
      </ul>
    </div>

    <div class="game-promo">
      <h3>このゲーム、自分でも作れるよ!</h3>
      <p>
        都道府県タイピングは、ローマ字の判定エンジンと<br>
        タイム計測の仕組みで作られています。<br><br>
        ひなテックでは、こうしたゲームの<br>
        作り方を楽しく学べます!
      </p>
      <div class="promo-links">
        <a href="tutorial/" class="btn-primary">このゲームの作り方を見る →</a>
        <a href="https://hinata-ya.tech/online/" class="btn-primary" data-class-link="game-promo">オンラインで習ってみる(全国OK)→</a>
        <a href="source/" class="btn-secondary">ソースコードを見る →</a>
      </div>
    </div>

    <div class="other-games">
      <a href="../../">&larr; 他のゲームも遊ぶ</a>
    </div>
  </div>

  <div id="footer"></div>

  <script src="../../js/common.js?v=20260914" data-base="../../"></script>
  <script src="../../js/teach-tools.js?v=20260927" defer></script>
  <script src="../../js/leaderboard.js"></script>
  <script src="../../js/game-audio.js?v=20260807"></script>
  <script src="../../js/typing-ime.js?v=20260828"></script>
  <script src="game.js?v=20260926"></script>
</body>
</html>

style.css — 緑の盤面と地方色の日本地図

style.cssは、深緑のグラデーション背景に白い県名を大きく出す、すっきりした見た目を作ります。タイムはfont-variant-numeric: tabular-numsで数字の幅を揃え、0.1秒ごとに動いてもガタつかないようにしています。

見どころは日本地図の色分けです。.region-0〜.region-6の7クラスにそれぞれ地方の色(北海道・東北=青、関東=赤、九州・沖縄=ピンク…)を割り当て、打ち終えた県にdoneクラスと一緒に付けるだけで、日本列島がカラフルに塗り上がっていきます。いま打っている県はcurrentクラスで黄色く光ります。

県庁所在地モードでは盤面にmode-capitalクラスを付け、ふだんはdisplay: noneで隠している問題文の行(.word-pref)を表示します。問題文の黄色は地図で光っている県と同じ色で、「この県の所在地を打つ」と目で結びつけられるようにしています。所在地の「市」「区」は打たなくてよいので、.word-suffixで小さく添えるだけにしています。

style.css
/* ============================================
   都道府県タイピング - 固有スタイル
   ============================================ */

/* スコアエリア */
.score-area {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 1rem;
  flex-wrap: wrap;
}

.score-box {
  background: #2E7D6B;
  border-radius: 8px;
  padding: 0.4rem 0.75rem;
  text-align: center;
  min-width: 70px;
  flex: 1;
}

.score-label {
  display: block;
  font-size: 0.65rem;
  color: #C8F2E5;
  letter-spacing: 0.05em;
}

.score-value {
  display: block;
  font-size: 1.1rem;
  font-weight: 700;
  color: #fff;
}

/* タイマーは大きく */
.timer-box .score-value {
  font-size: 1.4rem;
  font-variant-numeric: tabular-nums;
}

/* ============================================
   ステージ(お題ボード)
   ============================================ */
.pref-stage {
  position: relative;
  background: linear-gradient(180deg, #1B4D43 0%, #2E7D6B 100%);
  border-radius: 12px;
  padding: 1.4rem 1rem 1.6rem;
  margin-bottom: 1rem;
  text-align: center;
  overflow: hidden;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}

.pref-stage.shake {
  animation: stage-shake 0.18s ease;
}

@keyframes stage-shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-5px); }
  75% { transform: translateX(5px); }
}

.region-label {
  font-size: 0.85rem;
  font-weight: 600;
  color: #A8E6CF;
  min-height: 1.3rem;
  letter-spacing: 0.1em;
}

/* 県庁所在地モードの問題(県名)。地図で光っている県と同じ黄色にして結びつける */
.word-pref {
  display: none;
  font-size: 1.1rem;
  font-weight: 700;
  color: #FFD93D;
  min-height: 1.6rem;
}

.pref-stage.mode-capital .word-pref {
  display: block;
}

/* 所在地の「市」「区」は打たないので小さく添える */
.word-suffix {
  font-size: 0.6em;
  opacity: 0.75;
  margin-left: 0.05em;
}

.word-jp {
  font-size: 2.1rem;
  font-weight: 800;
  color: #fff;
  min-height: 3rem;
  line-height: 1.4;
  text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}

.word-kana {
  font-size: 1rem;
  font-weight: 600;
  color: #C8F2E5;
  min-height: 1.5rem;
}

.word-romaji {
  font-family: 'Courier New', 'SF Mono', 'Consolas', monospace;
  font-size: 1.5rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  min-height: 2rem;
  margin-top: 0.3rem;
  word-break: break-all;
}

.word-romaji .r-done {
  color: #FFD93D;
}

.word-romaji .r-rest {
  color: #EAFBF4;
}

/* カウントダウン */
.countdown {
  position: absolute;
  inset: 0;
  display: none;
  align-items: center;
  justify-content: center;
  font-size: 3.5rem;
  font-weight: 800;
  color: #fff;
  background: rgba(0, 0, 0, 0.45);
  text-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
  z-index: 5;
}

.countdown.active {
  display: flex;
}

/* スマホ用の隠し入力 */
.mobile-input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  border: none;
  padding: 0;
}

/* ============================================
   日本地図(打ち終えた県から地方色で塗られる)
   ============================================ */
.pref-chips {
  max-width: 440px;
  margin: 0 auto 0.25rem;
}

.pref-chips svg {
  display: block;
  width: 100%;
  height: auto;
}

.pref-chips .prefecture {
  fill: #ECE9E3;
  stroke: #B8B2A8;
  stroke-width: 1;
  transition: fill 0.3s;
}

/* いま打っている県 */
.pref-chips .prefecture.current {
  fill: #FFD93D;
  stroke: #E6B800;
}

/* 打ち終えた県は地方の色に塗られる */
.pref-chips .prefecture.done.region-0 { fill: #5C9CE5; }  /* 北海道・東北 */
.pref-chips .prefecture.done.region-1 { fill: #E57373; }  /* 関東 */
.pref-chips .prefecture.done.region-2 { fill: #66BB6A; }  /* 中部 */
.pref-chips .prefecture.done.region-3 { fill: #FFA726; }  /* 近畿 */
.pref-chips .prefecture.done.region-4 { fill: #AB7CC8; }  /* 中国 */
.pref-chips .prefecture.done.region-5 { fill: #4DB6AC; }  /* 四国 */
.pref-chips .prefecture.done.region-6 { fill: #F06292; }  /* 九州・沖縄 */

.map-credit {
  text-align: center;
  font-size: 0.65rem;
  color: #B0AAA0;
  margin: 0 0 1rem;
}

.map-credit a { color: inherit; }

/* アクションボタン */
.game-actions {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.game-actions .btn-primary {
  min-width: 160px;
  font-size: 1.1rem;
  padding: 0.8rem 2.5rem;
}

/* モード切り替え(順番どおり / ランダム / 県庁所在地)。狭い画面では折り返す */
.mode-switch {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 0.75rem;
}

.btn-mode {
  background: #fff;
  border: 2px solid #EEE4DA;
  border-radius: 999px;
  padding: 0.6rem 1rem;
  font-family: inherit;
  font-size: 0.9rem;
  font-weight: 600;
  color: #555;
  cursor: pointer;
  transition: border-color 0.2s, color 0.2s;
}

.btn-mode:hover {
  border-color: #E67E22;
}

.btn-mode[aria-pressed="true"] {
  border-color: #E67E22;
  color: #E67E22;
}

.btn-mute {
  background: #fff;
  border: 2px solid #EEE4DA;
  border-radius: 50%;
  width: 46px;
  height: 46px;
  font-size: 1.2rem;
  cursor: pointer;
  transition: border-color 0.2s;
}

.btn-mute:hover {
  border-color: #E67E22;
}

/* ============================================
   結果オーバーレイ
   ============================================ */
.result-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 200;
  padding: 1rem;
  animation: overlay-appear 0.3s ease;
}

.result-overlay.active {
  display: flex;
}

@keyframes overlay-appear {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

.result-content {
  background: #fff;
  border-radius: 16px;
  padding: 2rem 1.5rem;
  max-width: 420px;
  width: 100%;
  text-align: center;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  animation: result-pop 0.4s ease;
}

@keyframes result-pop {
  0% { transform: scale(0.8); opacity: 0; }
  100% { transform: scale(1); opacity: 1; }
}

.result-content h2 {
  font-size: 1.5rem;
  color: #2E7D6B;
  margin-bottom: 0.75rem;
}

/* 完走タイムは特大表示 */
.result-time-big {
  font-size: 2.4rem;
  font-weight: 800;
  color: #1B4D43;
  margin-bottom: 1rem;
  font-variant-numeric: tabular-nums;
}

.result-stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0.75rem;
  margin-bottom: 1.25rem;
}

.result-stat {
  background: #EFF8F4;
  border-radius: 8px;
  padding: 0.75rem 0.5rem;
}

.result-stat-label {
  display: block;
  font-size: 0.75rem;
  color: #999;
  margin-bottom: 0.25rem;
}

.result-stat-value {
  display: block;
  font-size: 1.2rem;
  font-weight: 700;
  color: #2E7D6B;
}

.result-best {
  color: #E67E22;
  font-weight: 700;
  font-size: 1rem;
  margin-bottom: 1rem;
  animation: best-glow 1s ease-in-out infinite alternate;
}

@keyframes best-glow {
  0% { opacity: 0.7; }
  100% { opacity: 1; text-shadow: 0 0 10px rgba(230, 126, 34, 0.3); }
}

.result-content .btn-primary {
  margin-top: 0.5rem;
}

/* レスポンシブ */
@media (min-width: 600px) {
  .word-jp { font-size: 2.6rem; }
  .word-romaji { font-size: 1.7rem; }
}

@media (max-width: 420px) {
  .score-area { gap: 0.35rem; }
  .score-box { min-width: 52px; padding: 0.35rem 0.4rem; }
  .score-label { font-size: 0.58rem; }
  .score-value { font-size: 0.95rem; }
  .timer-box .score-value { font-size: 1.15rem; }
  .word-jp { font-size: 1.6rem; }
  .word-romaji { font-size: 1.15rem; }
  .word-pref { font-size: 0.95rem; }
  .mode-switch { gap: 0.35rem; }
  .mode-switch .btn-mode { padding: 0.5rem 0.7rem; font-size: 0.82rem; }
  .result-stats { grid-template-columns: repeat(2, 1fr); gap: 0.5rem; }
  .result-time-big { font-size: 1.9rem; }
}

/* キーボード推奨の注意書き(小さい画面のみ) */
@media (max-width: 600px) {
  .pref-stage::before {
    content: '※ キーボード付きの端末で遊ぶのがおすすめです';
    display: block;
    text-align: center;
    font-size: 0.7rem;
    color: rgba(255, 255, 255, 0.6);
    margin-bottom: 0.3rem;
  }
}

game.js — タイム計測と順送りの頭脳

game.jsの前半は、si/shi など複数の打ち方を受け付けるローマ字判定エンジンです(仕組みのくわしい解説は回転寿司タイピングのソースページへ)。「ほっかいどう」の「っか」が kk でも xtuka でも打てるのはこのエンジンのおかげです。

ゲーム本体の主役はPREFS配列です。47都道府県が北から南の順に、漢字・かな・地方番号・県庁所在地・その読みの5つ({ d: '北海道', k: 'ほっかいどう', r: 0, c: '札幌市', ck: 'さっぽろ' })で並んでいて、indexという変数が「いま何問目か」を指します。1県打ち切るたびにindex++、47になったらfinishRun()でゴール。ゲームの進行はこれだけです。

3つのモードの違いは、MODESオブジェクトと小さな関数2つに閉じこめてあります。MODESにはモードごとのランキングのslug(prefecture-typing・prefecture-typing-random・prefecture-typing-capital)とベスト記録を保存するキーが入っていて、記録を読み書きするときはここを引くだけ。打つ読みはtargetKana()が、県庁所在地モードならck、それ以外ならkを返します。ランダムモードはスタート時に添字をシャッフルしたorder配列を作り、prefIndex()が「何問目か」を「PREFSの何番目か」に変換します。おかげで進行のコードはモードを気にせずindex++するだけで済みます。

地図はjapan-map.svgをfetch()で読みこんでそのまま差しこみ、各県のdata-code(1〜47のJISコード)とPREFSの添字を対応させます。SVGの要素はclassNameで書き換えられないので、setAttribute('class', …)でdoneやcurrentを付け替えています。地図が読めなかったときも、地図を空にするだけでゲーム自体は遊べるようにしてあります。

タイム計測はperformance.now()で、スタート時刻との差を毎フレーム表示します。ゴール時はMath.round(elapsedMs / 100)で1/10秒単位の整数(123 = 12.3秒)に変換してから、ベスト記録の比較・保存とランキング送信を行います。小数のまま扱わないことで、保存も比較も誤差なくシンプルになります。地方をまたいだ瞬間に鳴るファンファーレは、打ち終えた県と次の県の地方番号を比べて鳴らし分けています。

game.js
// ============================================
// 都道府県タイピング ロジック
// ============================================
//
// 北海道から沖縄まで、47都道府県を順にローマ字で打ち切る
// タイムアタック(RTA)。タイムは1/10秒単位で競う。
// 打ち終えた都道府県は下の一覧で地方の色に塗られていく。
//
// モードは3つ: 順番どおり(北→南)/ ランダム(出題順シャッフル)/
// 県庁所在地(県名と地図を見て、その県庁所在地の読みを打つ)。
// ベストタイムとランキングはモードごとに別管理する。
//
// ローマ字判定は「si / shi」「っ=子音重ね」「ん=n / nn」など
// 複数の打ち方を全て受け付ける。

(function () {
  'use strict';

  // ============================================
  // ローマ字変換テーブル(かな → 打ち方の候補)
  // ============================================
  var KANA = {
    'あ': ['a'], 'い': ['i', 'yi'], 'う': ['u', 'wu', 'whu'], 'え': ['e'], 'お': ['o'],
    'か': ['ka', 'ca'], 'き': ['ki'], 'く': ['ku', 'cu', 'qu'], 'け': ['ke'], 'こ': ['ko', 'co'],
    'さ': ['sa'], 'し': ['si', 'shi', 'ci'], 'す': ['su'], 'せ': ['se', 'ce'], 'そ': ['so'],
    'た': ['ta'], 'ち': ['ti', 'chi'], 'つ': ['tu', 'tsu'], 'て': ['te'], 'と': ['to'],
    'な': ['na'], 'に': ['ni'], 'ぬ': ['nu'], 'ね': ['ne'], 'の': ['no'],
    'は': ['ha'], 'ひ': ['hi'], 'ふ': ['fu', 'hu'], 'へ': ['he'], 'ほ': ['ho'],
    'ま': ['ma'], 'み': ['mi'], 'む': ['mu'], 'め': ['me'], 'も': ['mo'],
    'や': ['ya'], 'ゆ': ['yu'], 'よ': ['yo'],
    'ら': ['ra'], 'り': ['ri'], 'る': ['ru'], 'れ': ['re'], 'ろ': ['ro'],
    'わ': ['wa'], 'を': ['wo'], 'ん': ['nn', 'xn'],
    'が': ['ga'], 'ぎ': ['gi'], 'ぐ': ['gu'], 'げ': ['ge'], 'ご': ['go'],
    'ざ': ['za'], 'じ': ['zi', 'ji'], 'ず': ['zu'], 'ぜ': ['ze'], 'ぞ': ['zo'],
    'だ': ['da'], 'ぢ': ['di'], 'づ': ['du'], 'で': ['de'], 'ど': ['do'],
    'ば': ['ba'], 'び': ['bi'], 'ぶ': ['bu'], 'べ': ['be'], 'ぼ': ['bo'],
    'ぱ': ['pa'], 'ぴ': ['pi'], 'ぷ': ['pu'], 'ぺ': ['pe'], 'ぽ': ['po'],
    'きゃ': ['kya'], 'きゅ': ['kyu'], 'きょ': ['kyo'],
    'しゃ': ['sya', 'sha'], 'しゅ': ['syu', 'shu'], 'しょ': ['syo', 'sho'],
    'ちゃ': ['tya', 'cha', 'cya'], 'ちゅ': ['tyu', 'chu', 'cyu'], 'ちょ': ['tyo', 'cho', 'cyo'],
    'にゃ': ['nya'], 'にゅ': ['nyu'], 'にょ': ['nyo'],
    'ひゃ': ['hya'], 'ひゅ': ['hyu'], 'ひょ': ['hyo'],
    'みゃ': ['mya'], 'みゅ': ['myu'], 'みょ': ['myo'],
    'りゃ': ['rya'], 'りゅ': ['ryu'], 'りょ': ['ryo'],
    'ぎゃ': ['gya'], 'ぎゅ': ['gyu'], 'ぎょ': ['gyo'],
    'じゃ': ['zya', 'ja', 'jya'], 'じゅ': ['zyu', 'ju', 'jyu'], 'じょ': ['zyo', 'jo', 'jyo'],
    'びゃ': ['bya'], 'びゅ': ['byu'], 'びょ': ['byo'],
    'ぴゃ': ['pya'], 'ぴゅ': ['pyu'], 'ぴょ': ['pyo'],
    'ふぁ': ['fa'], 'ふぃ': ['fi'], 'ふぇ': ['fe'], 'ふぉ': ['fo'],
    'てぃ': ['thi'], 'でぃ': ['dhi'], 'でゅ': ['dhu'],
    'うぃ': ['wi'], 'うぇ': ['we'],
    'ぁ': ['xa', 'la'], 'ぃ': ['xi', 'li'], 'ぅ': ['xu', 'lu'], 'ぇ': ['xe', 'le'], 'ぉ': ['xo', 'lo'],
    'ゃ': ['xya', 'lya'], 'ゅ': ['xyu', 'lyu'], 'ょ': ['xyo', 'lyo'],
    'っ': ['xtu', 'ltu', 'ltsu'],
    'ー': ['-']
  };

  var N_NEEDS_DOUBLE = 'aiueony';

  // ============================================
  // 47都道府県(北から南へ)。r は地方の番号
  // c / ck は県庁所在地(県庁所在地モード用)。c はページの一覧表と同じ表記
  // (一覧表は都道府県クイズの PREFECTURES から生成)で、ck は打つ読み。
  // 「市」「区」まで打たせると全問に同じ語尾が付いて覚える部分がぼやけるので、ck には含めない
  // ============================================
  var REGIONS = ['北海道・東北', '関東', '中部', '近畿', '中国', '四国', '九州・沖縄'];

  var PREFS = [
    { d: '北海道', k: 'ほっかいどう', r: 0, c: '札幌市', ck: 'さっぽろ' },
    { d: '青森県', k: 'あおもり', r: 0, c: '青森市', ck: 'あおもり' },
    { d: '岩手県', k: 'いわて', r: 0, c: '盛岡市', ck: 'もりおか' },
    { d: '宮城県', k: 'みやぎ', r: 0, c: '仙台市', ck: 'せんだい' },
    { d: '秋田県', k: 'あきた', r: 0, c: '秋田市', ck: 'あきた' },
    { d: '山形県', k: 'やまがた', r: 0, c: '山形市', ck: 'やまがた' },
    { d: '福島県', k: 'ふくしま', r: 0, c: '福島市', ck: 'ふくしま' },
    { d: '茨城県', k: 'いばらき', r: 1, c: '水戸市', ck: 'みと' },
    { d: '栃木県', k: 'とちぎ', r: 1, c: '宇都宮市', ck: 'うつのみや' },
    { d: '群馬県', k: 'ぐんま', r: 1, c: '前橋市', ck: 'まえばし' },
    { d: '埼玉県', k: 'さいたま', r: 1, c: 'さいたま市', ck: 'さいたま' },
    { d: '千葉県', k: 'ちば', r: 1, c: '千葉市', ck: 'ちば' },
    { d: '東京都', k: 'とうきょう', r: 1, c: '新宿区', ck: 'しんじゅく' },
    { d: '神奈川県', k: 'かながわ', r: 1, c: '横浜市', ck: 'よこはま' },
    { d: '新潟県', k: 'にいがた', r: 2, c: '新潟市', ck: 'にいがた' },
    { d: '富山県', k: 'とやま', r: 2, c: '富山市', ck: 'とやま' },
    { d: '石川県', k: 'いしかわ', r: 2, c: '金沢市', ck: 'かなざわ' },
    { d: '福井県', k: 'ふくい', r: 2, c: '福井市', ck: 'ふくい' },
    { d: '山梨県', k: 'やまなし', r: 2, c: '甲府市', ck: 'こうふ' },
    { d: '長野県', k: 'ながの', r: 2, c: '長野市', ck: 'ながの' },
    { d: '岐阜県', k: 'ぎふ', r: 2, c: '岐阜市', ck: 'ぎふ' },
    { d: '静岡県', k: 'しずおか', r: 2, c: '静岡市', ck: 'しずおか' },
    { d: '愛知県', k: 'あいち', r: 2, c: '名古屋市', ck: 'なごや' },
    { d: '三重県', k: 'みえ', r: 3, c: '津市', ck: 'つ' },
    { d: '滋賀県', k: 'しが', r: 3, c: '大津市', ck: 'おおつ' },
    { d: '京都府', k: 'きょうと', r: 3, c: '京都市', ck: 'きょうと' },
    { d: '大阪府', k: 'おおさか', r: 3, c: '大阪市', ck: 'おおさか' },
    { d: '兵庫県', k: 'ひょうご', r: 3, c: '神戸市', ck: 'こうべ' },
    { d: '奈良県', k: 'なら', r: 3, c: '奈良市', ck: 'なら' },
    { d: '和歌山県', k: 'わかやま', r: 3, c: '和歌山市', ck: 'わかやま' },
    { d: '鳥取県', k: 'とっとり', r: 4, c: '鳥取市', ck: 'とっとり' },
    { d: '島根県', k: 'しまね', r: 4, c: '松江市', ck: 'まつえ' },
    { d: '岡山県', k: 'おかやま', r: 4, c: '岡山市', ck: 'おかやま' },
    { d: '広島県', k: 'ひろしま', r: 4, c: '広島市', ck: 'ひろしま' },
    { d: '山口県', k: 'やまぐち', r: 4, c: '山口市', ck: 'やまぐち' },
    { d: '徳島県', k: 'とくしま', r: 5, c: '徳島市', ck: 'とくしま' },
    { d: '香川県', k: 'かがわ', r: 5, c: '高松市', ck: 'たかまつ' },
    { d: '愛媛県', k: 'えひめ', r: 5, c: '松山市', ck: 'まつやま' },
    { d: '高知県', k: 'こうち', r: 5, c: '高知市', ck: 'こうち' },
    { d: '福岡県', k: 'ふくおか', r: 6, c: '福岡市', ck: 'ふくおか' },
    { d: '佐賀県', k: 'さが', r: 6, c: '佐賀市', ck: 'さが' },
    { d: '長崎県', k: 'ながさき', r: 6, c: '長崎市', ck: 'ながさき' },
    { d: '熊本県', k: 'くまもと', r: 6, c: '熊本市', ck: 'くまもと' },
    { d: '大分県', k: 'おおいた', r: 6, c: '大分市', ck: 'おおいた' },
    { d: '宮崎県', k: 'みやざき', r: 6, c: '宮崎市', ck: 'みやざき' },
    { d: '鹿児島県', k: 'かごしま', r: 6, c: '鹿児島市', ck: 'かごしま' },
    { d: '沖縄県', k: 'おきなわ', r: 6, c: '那覇市', ck: 'なは' }
  ];

  // ============================================
  // ローマ字エンジン(回転寿司タイピングと同じ仕組み)
  // ============================================

  function dedupe(arr) {
    var seen = {};
    return arr.filter(function (s) {
      if (seen[s]) return false;
      seen[s] = true;
      return true;
    });
  }

  function tokenize(kana) {
    var units = [];
    var i = 0;
    while (i < kana.length) {
      var two = kana.substr(i, 2);
      if (two.length === 2 && KANA[two]) {
        units.push(two);
        i += 2;
      } else {
        units.push(kana.charAt(i));
        i += 1;
      }
    }

    var tokens = [];
    for (var j = 0; j < units.length; j++) {
      var u = units[j];
      var next = units[j + 1];
      var cands;

      if (u === 'っ' && next && KANA[next]) {
        cands = [];
        KANA[next].forEach(function (s) {
          var c = s.charAt(0);
          if ('aiueon'.indexOf(c) === -1) cands.push(c + s);
        });
        KANA['っ'].forEach(function (p) {
          KANA[next].forEach(function (s) { cands.push(p + s); });
        });
        tokens.push({ kana: 'っ' + next, candidates: dedupe(cands) });
        j++;
      } else if (u === 'ん' && next && KANA[next]) {
        cands = [];
        KANA[next].forEach(function (s) {
          if (N_NEEDS_DOUBLE.indexOf(s.charAt(0)) === -1) cands.push('n' + s);
        });
        KANA[next].forEach(function (s) {
          cands.push('nn' + s);
          cands.push('xn' + s);
        });
        tokens.push({ kana: 'ん' + next, candidates: dedupe(cands) });
        j++;
      } else {
        tokens.push({ kana: u, candidates: (KANA[u] || [u]).slice() });
      }
    }
    return tokens;
  }

  function bestCandidate(token, typed) {
    for (var i = 0; i < token.candidates.length; i++) {
      if (token.candidates[i].indexOf(typed) === 0) return token.candidates[i];
    }
    return token.candidates[0];
  }

  function createWordState(kana) {
    return { tokens: tokenize(kana), ti: 0, typed: '' };
  }

  function acceptKey(state, ch) {
    var token = state.tokens[state.ti];
    if (!token) return false;
    var pref = state.typed + ch;
    var alive = token.candidates.some(function (c) { return c.indexOf(pref) === 0; });
    if (!alive) return false;
    state.typed = pref;
    if (token.candidates.indexOf(pref) !== -1) {
      token.done = pref;
      state.ti++;
      state.typed = '';
    }
    return true;
  }

  function isWordComplete(state) {
    return state.ti >= state.tokens.length;
  }

  function romajiParts(state) {
    var done = '';
    var rest = '';
    for (var i = 0; i < state.tokens.length; i++) {
      var t = state.tokens[i];
      if (i < state.ti) {
        done += t.done;
      } else if (i === state.ti) {
        var cand = bestCandidate(t, state.typed);
        done += state.typed;
        rest += cand.slice(state.typed.length);
      } else {
        rest += t.candidates[0];
      }
    }
    return [done, rest];
  }

  // Node.js でのテスト用エクスポート(ブラウザでは無視される)
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = {
      createWordState: createWordState,
      acceptKey: acceptKey,
      isWordComplete: isWordComplete,
      romajiParts: romajiParts,
      PREFS: PREFS
    };
    return;
  }

  // ============================================
  // ここからブラウザ用のゲーム本体
  // ============================================

  var els = {
    timer: document.getElementById('timer'),
    progress: document.getElementById('progress'),
    misses: document.getElementById('misses'),
    best: document.getElementById('best-score'),
    stage: document.getElementById('pref-stage'),
    wordPref: document.getElementById('word-pref'),
    wordJp: document.getElementById('word-jp'),
    wordKana: document.getElementById('word-kana'),
    wordRomaji: document.getElementById('word-romaji'),
    regionLabel: document.getElementById('region-label'),
    chips: document.getElementById('pref-chips'),
    countdown: document.getElementById('countdown'),
    btnStart: document.getElementById('btn-start'),
    modeButtons: document.querySelectorAll('.mode-switch [data-mode]'),
    btnMute: document.getElementById('btn-mute'),
    btnRestart: document.getElementById('btn-restart'),
    overlay: document.getElementById('result-overlay'),
    rTime: document.getElementById('result-time'),
    rKpm: document.getElementById('result-kpm'),
    rAccuracy: document.getElementById('result-accuracy'),
    rMisses: document.getElementById('result-misses'),
    rBest: document.getElementById('result-best'),
    mobileInput: document.getElementById('mobile-input')
  };

  var phase = 'idle'; // idle / countdown / playing / over
  var rafId = null;

  var index = 0;          // 何県目か
  var wordState = null;
  var correctKeys = 0;
  var missKeys = 0;
  var startTime = 0;
  var chipEls = [];

  // モード: order(北→南の順番どおり)/ random(出題順シャッフル)/ capital(県庁所在地)。
  // 打つ語や暗記の効き方でタイムの土俵が違うので、ベスト・ランキングはモードごとに分ける
  var MODES = {
    order:   { slug: 'prefecture-typing',         label: '順番どおり', bestKey: 'prefTypingBestDeci',        intro: '北海道から沖縄まで、最速で日本一周しよう' },
    random:  { slug: 'prefecture-typing-random',  label: 'ランダム',   bestKey: 'prefTypingBestDeciRandom',  intro: '出題順はシャッフル。47都道府県を打ち切ろう' },
    capital: { slug: 'prefecture-typing-capital', label: '県庁所在地', bestKey: 'prefTypingBestDeciCapital', intro: '県庁所在地を打って、北海道から沖縄まで日本一周' }
  };
  var mode = 'order';
  var order = null; // ランダム時の出題順(PREFS の添字配列)。null なら北→南の固定順

  // いま打つ読み。県庁所在地モードは所在地の読み(札幌市→さっぽろ)
  function targetKana(p) { return mode === 'capital' ? p.ck : p.k; }

  function prefIndex(i) { return order ? order[i] : i; }
  function pref(i) { return PREFS[prefIndex(i)]; }

  function shuffledIndices(n) {
    var a = [];
    for (var i = 0; i < n; i++) a.push(i);
    for (var j = a.length - 1; j > 0; j--) {
      var k = Math.floor(Math.random() * (j + 1));
      var t = a[j]; a[j] = a[k]; a[k] = t;
    }
    return a;
  }

  // ============================================
  // サウンド(WebAudio)
  // ============================================
  var muted = GameAudio.init('prefecture-typing');
  GameAudio.setBgm('../../assets/bgm/youki.mp3', 0.1);

  function ensureAudio() { GameAudio.unlock(); }

  var sounds = {
    type: function () { GameAudio.sfx('type'); },
    miss: function () { GameAudio.sfx('miss'); },
    next: function () { GameAudio.sfx('get'); },
    region: function () { GameAudio.sfx('combo'); },
    count: function () { GameAudio.sfx('count'); },
    go: function () { GameAudio.sfx('go'); },
    finish: function () { GameAudio.sfx('finish'); }
  };

  function updateMuteButton() {
    els.btnMute.textContent = muted ? '🔇' : '🔊';
    els.btnMute.setAttribute('aria-label', muted ? 'サウンドをオンにする' : 'サウンドをオフにする');
  }

  // ============================================
  // ベストタイム(1/10秒の整数)
  // ============================================
  function bestKeyName() { return MODES[mode].bestKey; }
  function getBest() { return parseInt(localStorage.getItem(bestKeyName()) || '0', 10); }
  function setBest(v) { localStorage.setItem(bestKeyName(), String(v)); }

  function formatDeci(deci) {
    var sec = deci / 10;
    if (sec < 60) return sec.toFixed(1) + '秒';
    var m = Math.floor(sec / 60);
    return m + '分' + (sec % 60).toFixed(1) + '秒';
  }

  // ============================================
  // 日本地図の生成と更新(打ち終えた県から塗られていく)
  // 地図: geolonia/japanese-prefectures(GFDL)
  // 各県の <g> は data-code="1〜47"(JISコード)。PREFS も同順なので
  // data-code = index + 1 で対応する
  // ============================================
  function buildChips() {
    fetch('japan-map.svg?v=20260613')
      .then(function (res) {
        if (!res.ok) throw new Error('Failed to load japan-map.svg');
        return res.text();
      })
      .then(function (svg) {
        els.chips.innerHTML = svg;
        chipEls = PREFS.map(function (p, i) {
          var el = els.chips.querySelector('[data-code="' + (i + 1) + '"]');
          if (el) el.setAttribute('data-base-class', el.getAttribute('class') || '');
          return el;
        });
        updateChips();
      })
      .catch(function () {
        // 地図が読めなくてもゲーム自体は遊べるようにする
        els.chips.innerHTML = '';
        chipEls = [];
      });
  }

  function updateChips() {
    // chipEls は JIS コード順(PREFS の添字 = data-code - 1)で固定。
    // ランダムモードでは出題順と地図上の県がずれるので、出題順 i を
    // prefIndex(i) で PREFS の添字に変換してから塗る。
    // SVG要素の class は setAttribute で書き換える(el.className は使えない)
    chipEls.forEach(function (el) {
      if (el) el.setAttribute('class', el.getAttribute('data-base-class'));
    });
    for (var i = 0; i < index && i < PREFS.length; i++) {
      var pi = prefIndex(i);
      var done = chipEls[pi];
      if (done) done.setAttribute('class', done.getAttribute('data-base-class') + ' done region-' + PREFS[pi].r);
    }
    if (phase === 'playing' && index < PREFS.length) {
      var cur = chipEls[prefIndex(index)];
      if (cur) cur.setAttribute('class', cur.getAttribute('data-base-class') + ' current');
    }
  }

  // ============================================
  // 表示
  // ============================================
  function renderWord() {
    var p = pref(index);
    if (!p || !wordState) {
      els.wordPref.textContent = '';
      els.wordJp.textContent = '';
      els.wordKana.textContent = '';
      els.wordRomaji.textContent = '';
      els.regionLabel.textContent = '';
      return;
    }
    els.regionLabel.textContent = REGIONS[p.r] + '(' + (index + 1) + '/47)';
    if (mode === 'capital') {
      // 問題は県名(地図で光っている県)。所在地の漢字と読みも出して、
      // 知らなくても打ちながら覚えられるようにする。
      // 「北海道の道庁所在地」「東京都の都庁所在地」のように県名の末尾に合わせる
      els.wordPref.textContent = p.d + 'の' + p.d.slice(-1) + '庁所在地';
      var m = p.c.match(/^(.+)([市区])$/);
      els.wordJp.innerHTML = m ? m[1] + '<span class="word-suffix">' + m[2] + '</span>' : p.c;
    } else {
      els.wordPref.textContent = '';
      els.wordJp.textContent = p.d;
    }
    els.wordKana.textContent = targetKana(p);
    var parts = romajiParts(wordState);
    els.wordRomaji.innerHTML =
      '<span class="r-done">' + parts[0] + '</span>' +
      '<span class="r-rest">' + parts[1] + '</span>';
  }

  function updateHud() {
    els.progress.textContent = index + '/47';
    els.misses.textContent = missKeys;
    var best = getBest();
    els.best.textContent = best > 0 ? formatDeci(best) : '--';
    if (phase !== 'playing') {
      els.timer.textContent = phase === 'over' ? els.timer.textContent : '0.0';
    }
  }

  function tickTimer() {
    if (phase !== 'playing') return;
    els.timer.textContent = ((performance.now() - startTime) / 1000).toFixed(1);
    rafId = requestAnimationFrame(tickTimer);
  }

  // ============================================
  // 入力処理
  // ============================================
  function handleChar(ch) {
    if (phase !== 'playing' || !wordState) return;
    ch = ch.toLowerCase();
    if (!/^[a-z\-]$/.test(ch)) return;

    if (acceptKey(wordState, ch)) {
      correctKeys++;
      sounds.type();
      if (isWordComplete(wordState)) {
        var finishedRegion = pref(index).r;
        index++;
        updateChips();
        if (index >= PREFS.length) {
          finishRun();
          return;
        }
        // 地方をまたいだらファンファーレ
        if (pref(index).r !== finishedRegion) sounds.region(); else sounds.next();
        wordState = createWordState(targetKana(pref(index)));
      }
      renderWord();
    } else {
      missKeys++;
      sounds.miss();
      els.stage.classList.remove('shake');
      void els.stage.offsetWidth; // アニメを毎回最初から再生する
      els.stage.classList.add('shake');
    }
    updateHud();
  }

  document.addEventListener('keydown', function (e) {
    if (e.ctrlKey || e.metaKey || e.altKey) return;
    if (phase === 'playing') {
      // IMEがオンのままでも打てるように、物理キーから文字を取り出す(js/typing-ime.js)
      var ch = window.TypingIME ? TypingIME.charFrom(e) : (e.key.length === 1 ? e.key : null);
      if (ch) {
        e.preventDefault();
        handleChar(ch);
      }
      return;
    }
    if (e.key === 'Enter' && phase === 'idle' && !els.overlay.classList.contains('active')) {
      e.preventDefault();
      startGame();
    }
    if (e.key === 'Escape' && els.overlay.classList.contains('active')) {
      els.overlay.classList.remove('active');
    }
  });

  els.stage.addEventListener('click', function () {
    if (phase === 'playing') els.mobileInput.focus({ preventScroll: true });
  });
  els.mobileInput.addEventListener('input', function (e) {
    // IME変換中に value を消すと入力が壊れる。確定後のかなも打鍵ではないので数えない
    // (keydown 側で物理キーから拾っているので、ここで通すと同じ打鍵を二重に処理してしまう)
    if (e && e.isComposing) return;
    var v = els.mobileInput.value;
    els.mobileInput.value = '';
    for (var i = 0; i < v.length; i++) {
      var c = v.charAt(i);
      if (c.charCodeAt(0) < 0x80) handleChar(c);
    }
  });

  // IMEのお知らせはステージの上端に重ねて出す(js/typing-ime.js)
  if (window.TypingIME) {
    TypingIME.setAnchor(els.stage);
    TypingIME.watchInput(els.mobileInput);
  }

  // ============================================
  // ゲーム進行
  // ============================================
  function startGame() {
    ensureAudio();
    phase = 'countdown';

    index = 0;
    correctKeys = 0;
    missKeys = 0;
    // 県庁所在地モードは北→南の固定順。地図の塗り・ページの一覧表と同じ並びで覚えられるように
    order = mode === 'random' ? shuffledIndices(PREFS.length) : null;
    wordState = createWordState(targetKana(pref(0)));

    els.overlay.classList.remove('active');
    els.btnStart.disabled = true;
    els.btnStart.textContent = 'プレイ中...';
    els.wordPref.textContent = '';
    els.wordJp.textContent = '';
    els.wordKana.textContent = '';
    els.wordRomaji.textContent = '';
    els.regionLabel.textContent = '';
    updateChips();
    updateHud();

    var count = 3;
    els.countdown.textContent = count;
    els.countdown.classList.add('active');
    sounds.count();
    var iv = setInterval(function () {
      count--;
      if (count > 0) {
        els.countdown.textContent = count;
        sounds.count();
      } else {
        clearInterval(iv);
        els.countdown.textContent = 'スタート!';
        sounds.go();
        setTimeout(function () {
          els.countdown.classList.remove('active');
          phase = 'playing';
          GameAudio.startBgm();
          startTime = performance.now();
          updateChips();
          renderWord();
          tickTimer();
        }, 500);
      }
    }, 700);
  }

  function accuracy() {
    var total = correctKeys + missKeys;
    if (total === 0) return 100;
    return Math.round((correctKeys / total) * 100);
  }

  function finishRun() {
    phase = 'over';
    GameAudio.stopBgm(1.2);
    if (rafId) cancelAnimationFrame(rafId);
    sounds.finish();

    var elapsedMs = performance.now() - startTime;
    var deci = Math.round(elapsedMs / 100); // 1/10秒の整数
    els.timer.textContent = (deci / 10).toFixed(1);

    var best = getBest();
    var isNewBest = best === 0 || deci < best;
    if (isNewBest) setBest(deci);

    var kpm = Math.round(correctKeys / (elapsedMs / 60000));

    els.btnStart.disabled = false;
    els.btnStart.textContent = 'スタート';
    els.wordPref.textContent = '';
    els.wordJp.textContent = mode === 'capital' ? '47の県庁所在地 制覇!' : '47都道府県 制覇!';
    els.wordKana.textContent = '';
    els.wordRomaji.textContent = '';
    els.regionLabel.textContent = '';

    els.rTime.textContent = formatDeci(deci);
    els.rKpm.textContent = kpm;
    els.rAccuracy.textContent = accuracy() + '%';
    els.rMisses.textContent = missKeys + '回';
    els.rBest.style.display = isNewBest ? 'block' : 'none';

    els.overlay.classList.add('active');
    updateHud();
    updateChips();

    if (window.Leaderboard) {
      // ランダムは暗記が効かず順番モードとタイムの土俵が違う。県庁所在地は打つ語そのものが
      // 別物(sapporo・naha など)なので、どちらも別ランキングにする(slug は MODES を参照)
      Leaderboard.show(MODES[mode].slug, deci, { order: 'asc', format: 'time-deci' });
    }
    phase = 'idle';
  }

  // ============================================
  // UIイベント
  // ============================================
  els.btnStart.addEventListener('click', function () {
    if (phase === 'idle') startGame();
  });

  els.btnRestart.addEventListener('click', function () {
    els.overlay.classList.remove('active');
    startGame();
  });

  els.overlay.addEventListener('click', function (e) {
    if (e.target === els.overlay) els.overlay.classList.remove('active');
  });

  els.btnMute.addEventListener('click', function () {
    muted = GameAudio.toggleMute();
    updateMuteButton();
  });

  function updateModeButtons() {
    Array.prototype.forEach.call(els.modeButtons, function (btn) {
      btn.setAttribute('aria-pressed', btn.getAttribute('data-mode') === mode ? 'true' : 'false');
    });
    // 県名の行は県庁所在地モードのときだけ場所をとる(style.css の .mode-capital)
    els.stage.classList.toggle('mode-capital', mode === 'capital');
  }

  // モードを切り替えて待機画面を整える。ボタンのクリックと URL ハッシュ起動で同じ処理を通す
  function selectMode(m) {
    if (!Object.prototype.hasOwnProperty.call(MODES, m)) return false;
    mode = m;
    updateModeButtons();
    // 待機中の案内を、何を打つモードかが分かる文に差し替える
    els.wordPref.textContent = mode === 'capital' ? '🏛️ 県庁所在地モード' : '';
    els.wordJp.textContent = 'スタートで出発!';
    els.wordKana.textContent = MODES[mode].intro;
    els.wordRomaji.textContent = '';
    els.regionLabel.textContent = '';
    updateHud(); // ベスト表示をモード別に切り替える
    // ランキング枠も選んだモードの表にする。ともだちのコードはこの枠のゲームで作るので、
    // 「順番どおり」のままだと、ランダムで作ったコードに記録が入らない
    if (window.Leaderboard && window.Leaderboard.setInlineGame) {
      window.Leaderboard.setInlineGame(MODES[mode].slug, MODES[mode].label);
    }
    return true;
  }

  // URL に #モード を残す(先生がリンクをコピーして配れるように)。ソースページのプレイグラウンドは
  // sandbox の iframe なので replaceState が SecurityError を投げる。そこでは URL を変えずに遊べればよい
  function rememberHash(h) {
    try { history.replaceState(null, '', '#' + h); } catch (e) { /* sandbox 内では変えられない */ }
  }
  Array.prototype.forEach.call(els.modeButtons, function (btn) {
    btn.addEventListener('click', function () {
      if (phase !== 'idle') return;
      if (!selectMode(btn.getAttribute('data-mode'))) return;
      // 先生が「このモードで開くリンク」をそのままコピーして配れるよう、URL にモードを残す
      rememberHash(mode);
    });
  });

  // 初期表示
  buildChips();
  updateChips();
  updateMuteButton();
  updateModeButtons();
  updateHud();
  // #order / #random / #capital で開いたら、そのモードを選んだ状態で待機する(スタートは子どもが押す)
  selectMode((location.hash || '').replace(/^#/, ''));
  // 開いたままハッシュだけ変わったとき(先生が配った別モードのリンクを同じタブで開き直す)はページが
  // 読み直されないので、待機中に限ってモードを選び直す。プレイ中は触らない
  window.addEventListener('hashchange', function () {
    if (phase === 'idle') selectMode((location.hash || '').replace(/^#/, ''));
  });

})();

このコードで学べるポイント

自分のパソコンで動かすには

上のコードをそれぞれのファイル名で同じフォルダに保存し、index.htmlをブラウザで開けばゲームが動きます。各コードブロックのファイル名バーにある「コピー」ボタンを使うと便利です。

このゲームはサイト全体で共通して使うファイル(共通CSSやヘッダー)も少し利用しているため、ソースのファイルだけで開くとヘッダーなどサイト共通の部分は表示されません。でもゲーム本体はちゃんと動くので、仕組みを学んだり改造してためすには十分です。

慣れてきたら、色を変えたりルールを少しいじったり、自由に改造してみてください。コードを「読む」次は「いじってみる」のがいちばんの上達法です。

このコードは使っていいの?

このソースコードは、プログラミングの勉強のために自由に読んだり、コピーして動かしたり、改造したりして大丈夫です。学校の授業や自由研究で参考にするのも歓迎します。ぜひ「自分だったらどう作るか」を考えるきっかけにしてください。

ただし、コードをそのまままるごとコピーして自分の作品やサービスとして公開・配布するのはご遠慮ください。あくまで学習用としてお使いください。

もっと学びたい人へ

コードを読んで「自分でも作ってみたい!」と思ったら、都道府県タイピングの作り方ガイドがおすすめです。このページが「完成したコード」を見せるのに対して、作り方ガイドは何もない状態から1ステップずつ組み立てていく流れを解説しています。

ひなテックでは、こうしたゲームづくりを先生といっしょに楽しく学べる教室を開いています。2026年9月からはオンライン授業も始まり、全国どこからでも受講できます(土曜12:00と、月・水・金の20:00)。「コードを読むだけじゃなく、ちゃんと作れるようになりたい」という人は、のぞいてみてください。