百人一首タイピングのソースコード全文 — JavaScript・HTML・CSS

▶ 百人一首タイピングで遊ぶ 📖 作り方を1から学ぶ ✏️ このコードを書きかえて動かす

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

上の句を見て下の句をローマ字で打つタイピングゲームで、コードの見どころは3つ。100首を現代仮名遣いのかなだけで持つデータ設計、かるた経験者が暗記する決まり字を「最短の一意な接頭辞」としてプログラムで自動計算するアルゴリズム、そして「し=si/shi」「っ=子音重ね」など複数の打ち方を全部受け付けるローマ字判定エンジンです。

コードはコピーして自分のパソコンで自由に動かせます。出題データを都道府県名やことわざに差し替えれば、まったく別のタイピングゲームにも改造できます。

百人一首タイピングは3つのファイルでできている

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

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

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

index.html — 歌会場の骨組み

index.htmlは、ブラウザが最初に読み込むファイルです。ゲームの「置き場所」をHTMLで用意しているだけで、動きの処理は書かれていません。

大きく分けると、(1)かるた10首・決まり字・通し100首を選ぶmode-area、(2)タイム・ミス・ペナルティを出すscore-area、(3)読み札(上の句)と下の句の入力表示が並ぶkaruta-stage、(4)タッチ端末用の3択ボタンが入るchoice-area、(5)結果のオーバーレイ、でできています。下の句のかなやローマ字はJavaScriptがspanとして毎回作り直します。

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

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>百人一首タイピング|無料ブラウザゲーム|ひなテックGames</title>
  <meta name="description" content="上の句を見て下の句をローマ字で打つ百人一首タイピング!かるた10首のタイムアタック・決まり字の練習モード・通し100首を収録。決まり字は全100首から自動計算。無料ブラウザゲーム。">
  <link rel="stylesheet" href="../../css/style.css">
  <link rel="stylesheet" href="style.css?v=20260815">
  <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">
      <span class="tag">タイピング</span>
      <span class="tag">学習</span>
    </div>

    <div class="game-container">
      <!-- モード選択 -->
      <div class="mode-area" id="mode-area">
        <span class="mode-label">モード:</span>
        <div class="mode-buttons">
          <button class="mode-btn active" data-mode="karuta10">かるた10首<small>ランキング対象</small></button>
          <button class="mode-btn" data-mode="kimariji">決まり字<small>1文字ずつ読み上げ</small></button>
          <button class="mode-btn" data-mode="all100">通し100首<small>進捗セーブあり</small></button>
        </div>
      </div>

      <!-- タッチ端末向けの案内 -->
      <p class="touch-note" id="touch-note" hidden>
        タッチ端末のため<strong>「下の句3択かるた」</strong>で遊べます。
        全国ランキングへの登録は<strong>PCキーボード入力(かるた10首)のみ</strong>です。
      </p>

      <!-- スコアエリア -->
      <div class="score-area">
        <div class="score-box">
          <span class="score-label">タイム(秒)</span>
          <span class="score-value" id="time">0.0</span>
        </div>
        <div class="score-box">
          <span class="score-label">首</span>
          <span class="score-value" id="poem-count">-</span>
        </div>
        <div class="score-box">
          <span class="score-label">ミス</span>
          <span class="score-value" id="miss">0</span>
        </div>
        <div class="score-box">
          <span class="score-label">ペナルティ</span>
          <span class="score-value" id="penalty">+0.0</span>
        </div>
        <div class="score-box">
          <span class="score-label">ベスト</span>
          <span class="score-value" id="best-score">--</span>
        </div>
      </div>

      <!-- かるたステージ -->
      <div class="karuta-stage" id="karuta-stage">
        <div class="stage-title">〜 ひなテック歌会 〜</div>

        <div class="kami-card">
          <div class="kami-text" id="kami-text"></div>
          <div class="poet-name" id="poet-name"></div>
        </div>

        <p class="stage-message" id="stage-message">「スタート」で歌会がはじまります</p>

        <div class="typing-area" id="typing-area">
          <div class="shimo-kana" id="shimo-kana"></div>
          <div class="shimo-romaji" id="shimo-romaji"></div>
        </div>

        <div class="choice-area" id="choice-area"></div>

        <div class="pop-layer" id="pop-layer"></div>
      </div>

      <!-- アクション -->
      <div class="game-actions">
        <button class="btn-primary" id="btn-start">スタート</button>
        <button class="btn-secondary btn-reset-progress" id="btn-reset-progress" style="display:none;">最初から</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 id="result-title">競技終了!</h2>
          <div class="result-mode-badge" id="result-mode">かるた10首</div>
          <div class="result-stats">
            <div class="result-stat">
              <span class="result-stat-label">合計タイム</span>
              <span class="result-stat-value" id="result-time">0.0秒</span>
            </div>
            <div class="result-stat">
              <span class="result-stat-label">ミス</span>
              <span class="result-stat-value" id="result-miss">0回</span>
            </div>
            <div class="result-stat">
              <span class="result-stat-label">ペナルティ</span>
              <span class="result-stat-value" id="result-penalty">+0.0秒</span>
            </div>
            <div class="result-stat" id="result-kimariji-row" style="display:none;">
              <span class="result-stat-label">決まり字で取った</span>
              <span class="result-stat-value" id="result-kimariji">0/10首</span>
            </div>
          </div>
          <div class="result-best" id="result-best" style="display:none;">
            ベストタイム更新!
          </div>
          <p class="result-note" id="result-note"></p>
          <button class="btn-primary" id="btn-restart">もう一度遊ぶ</button>
        </div>
      </div>
    </div>

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

    <div class="game-instructions">
      <h3>遊び方</h3>
      <ul>
        <li>上の句(と歌人名)が読み札として表示されます。<strong>下の句をローマ字でタイプ</strong>しよう</li>
        <li><strong>かるた10首</strong>: ランダム10首の合計タイム勝負。ミスタイプ1打ごとに<strong>+0.3秒のペナルティ</strong>。全国ランキング対象!</li>
        <li><strong>決まり字モード</strong>: 上の句が0.5秒に1文字ずつ現れます。<strong>決まり字</strong>(下線の文字)が出た瞬間に打ちはじめられたら「⚡決まり字で取った!」</li>
        <li><strong>通し100首</strong>: 1番の天智天皇から100番の順徳院まで読み切りに挑戦。進捗は自動保存され、続きから再開できます</li>
        <li>「し」は si / shi、「ちょう」は tyou / chou など<strong>複数の打ち方に全対応</strong>。おすすめの打ち方が薄く表示されます</li>
        <li>読みはすべて現代仮名遣い(「けふ」→「きょう」など)。上の句の下線部分がその歌の<strong>決まり字</strong>です</li>
        <li>スマホ・タブレットでは<strong>下の句3択かるた</strong>に自動で切り替わります(ランキング登録はPCキーボード入力のみ)</li>
        <li>読み手の<strong>朗詠つき</strong>。本物のかるたと同じく、<strong>下の句は上の句を読み終えると表示</strong>されます(暗記していれば表示前から打ってOK)。決まり字モードの文字送りは朗詠に同期。音声: Synthesizer V AI 重音テト(🔊で消せます)</li>
        <li>「ころもほすてふ(tefu)」のような<strong>歴史的仮名遣いの打ち方</strong>や、聞こえたまま「わ」(わがころもで<strong>わ</strong>)と打っても正解になります</li>
      </ul>
    </div>

    <div class="game-promo">
      <h3>このゲーム、自分でも作れるよ!</h3>
      <p>
        百人一首タイピングは、100首のかなデータと<br>
        ローマ字判定エンジンで作られています。<br><br>
        決まり字も「最短の一意な接頭辞」として<br>
        プログラムで自動計算しているよ。<br><br>
        ひなテックでは、こうしたゲームの<br>
        作り方を楽しく学べます!
      </p>
      <div class="promo-links">
        <a href="tutorial/" class="btn-primary">このゲームの作り方を見る →</a>
        <a href="https://hinata-ya.tech/contact/" class="btn-primary">体験レッスンに申し込む →</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=20260613c" data-base="../../"></script>
  <script src="../../js/leaderboard.js"></script>
  <script src="../../assets/voice/hyakunin/yomi.js?v=20260815b"></script>
  <script src="game.js?v=20260815g"></script>
</body>
</html>

style.css — 畳の緑とかるた札

style.cssは見た目を作っているファイルです。画像は1枚も使わず、畳はrepeating-linear-gradientの細いしま模様、読み札はクリーム色のグラデーションと枠線で描いています。

タイピングの進み具合は色で見せます。下の句のかなは「打ち終えた字=金色」「いま打つ字=白+下線」「残り=半透明」、ローマ字は「入力済み=緑」「残り=薄い白」。上の句の決まり字部分には赤い下線.kimariji-mark)を引いて、どこまで聞けば札が確定するかが一目で分かるようにしています。ミスしたときは@keyframesでステージ全体を小さく揺らします。

style.css
/* ============================================
   百人一首タイピング - 固有スタイル
   ============================================ */

/* モード選択 */
.mode-area {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 1rem;
  flex-wrap: wrap;
}

.mode-label {
  font-size: 0.9rem;
  font-weight: 600;
  color: #776E65;
}

.mode-buttons {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.mode-btn {
  background: #CDC1B4;
  color: #776E65;
  border: 2px solid transparent;
  border-radius: 100px;
  padding: 0.4rem 1rem;
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s, border-color 0.2s, color 0.2s;
  font-family: inherit;
  line-height: 1.3;
}

.mode-btn small {
  display: block;
  font-size: 0.65rem;
  font-weight: 500;
  opacity: 0.85;
}

.mode-btn:hover {
  background: #BBADA0;
  color: #fff;
}

.mode-btn.active {
  background: #8E3B46;
  color: #fff;
  border-color: #6E2934;
}

.mode-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

/* タッチ端末向けの案内 */
.touch-note {
  background: #FDF6E3;
  border: 1px solid #E6D9B8;
  border-radius: 8px;
  padding: 0.6rem 0.8rem;
  font-size: 0.8rem;
  color: #776E65;
  margin-bottom: 1rem;
  line-height: 1.6;
}

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

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

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

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

/* ============================================
   かるたステージ(畳の上の読み札・取り札)
   ============================================ */
.karuta-stage {
  position: relative;
  background:
    repeating-linear-gradient(90deg,
      rgba(255, 255, 255, 0.035) 0px, rgba(255, 255, 255, 0.035) 2px,
      transparent 2px, transparent 8px),
    linear-gradient(180deg, #2D5A3D 0%, #24492F 100%);
  border: 4px solid #1B3A24;
  border-radius: 12px;
  padding: 1.1rem 1rem 1.4rem;
  margin-bottom: 1rem;
  overflow: hidden;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}

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

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

.stage-title {
  text-align: center;
  font-size: 0.8rem;
  color: #E8DFC8;
  letter-spacing: 0.35em;
  margin-bottom: 0.7rem;
  opacity: 0.7;
  user-select: none;
}

/* 読み札(上の句) */
.kami-card {
  background: linear-gradient(180deg, #F7EDD5 0%, #F0E2C0 100%);
  border: 2px solid #C9B78C;
  border-radius: 10px;
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
  max-width: 560px;
  margin: 0 auto 0.9rem;
  padding: 0.9rem 1rem 0.7rem;
  text-align: center;
  min-height: 5.4rem;
}

.kami-text {
  font-size: 1.5rem;
  font-weight: 700;
  color: #3B2A1A;
  line-height: 1.5;
  min-height: 2.2rem;
}

/* 句のかたまり。行の折返しは5・7・5の句の切れ目でだけ起きる */
.kphrase { display: inline-block; }

.kami-text .kimariji-mark {
  color: #8E3B46;
  border-bottom: 3px solid #C0392B;
}

.kami-text .kami-waiting {
  color: #B7A57C;
  letter-spacing: 0.2em;
}

.poet-name {
  margin-top: 0.35rem;
  font-size: 0.85rem;
  color: #7A6845;
  min-height: 1.2rem;
}

.stage-message {
  text-align: center;
  color: #E8DFC8;
  font-size: 0.95rem;
  margin: 0.5rem 0 0.75rem;
}

/* 取り札(下の句の入力表示) */
.typing-area {
  text-align: center;
  min-height: 4.6rem;
}

.shimo-kana {
  font-size: 1.35rem;
  font-weight: 700;
  min-height: 2rem;
  line-height: 1.5;
}

.shimo-kana .k-done { color: #F1C40F; }
.shimo-kana .k-cur {
  color: #fff;
  border-bottom: 2px solid #F1C40F;
}
.shimo-kana .k-rest { color: rgba(255, 255, 255, 0.45); }

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

.shimo-romaji .r-done { color: #2ECC71; }
.shimo-romaji .r-rest { color: rgba(255, 255, 255, 0.55); }

/* タッチ端末(3択モード)ではタイピング表示を使わない */
.touch-mode .typing-area {
  display: none;
}

/* 3択かるた(タッチ端末) */
.choice-area {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
  max-width: 480px;
  margin: 0 auto;
}

.choice-btn {
  background: linear-gradient(180deg, #F7EDD5 0%, #EEDFBB 100%);
  border: 2px solid #C9B78C;
  border-radius: 10px;
  padding: 0.7rem 0.9rem;
  font-size: 1.05rem;
  font-weight: 700;
  color: #3B2A1A;
  cursor: pointer;
  font-family: inherit;
  line-height: 1.4;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25);
  transition: transform 0.1s;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}

.choice-btn:active {
  transform: scale(0.98);
}

.choice-btn.choice-correct {
  background: #2ECC71;
  border-color: #27AE60;
  color: #fff;
}

.choice-btn.choice-wrong {
  background: #B03A2E;
  border-color: #922B21;
  color: #F5B7B1;
  opacity: 0.75;
}

/* ミス・ボーナスのポップ表示 */
.pop-layer {
  position: absolute;
  top: 16%;
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  pointer-events: none;
  z-index: 4;
}

.score-pop {
  position: absolute;
  font-size: 1.2rem;
  font-weight: 800;
  color: #F1C40F;
  text-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
  animation: pop-rise 0.9s ease forwards;
}

.score-pop.pop-miss {
  color: #FF8A80;
}

.score-pop.pop-kimariji {
  color: #FFD54F;
  font-size: 1.45rem;
}

@keyframes pop-rise {
  0% { transform: translateY(0); opacity: 0; }
  15% { opacity: 1; }
  100% { transform: translateY(-44px); opacity: 0; }
}

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

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

.btn-reset-progress {
  font-size: 0.9rem;
}

.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: #8E3B46;
}

/* ============================================
   結果オーバーレイ
   ============================================ */
.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: #776E65;
  margin-bottom: 0.5rem;
}

.result-mode-badge {
  display: inline-block;
  background: linear-gradient(135deg, #8E3B46, #C0392B);
  color: #fff;
  font-size: 1rem;
  font-weight: 800;
  border-radius: 100px;
  padding: 0.4rem 1.4rem;
  margin-bottom: 1rem;
  letter-spacing: 0.1em;
  box-shadow: 0 3px 10px rgba(142, 59, 70, 0.35);
}

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

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

.result-stat:last-child:nth-child(odd) {
  grid-column: 1 / -1;
}

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

.result-stat-value {
  display: block;
  font-size: 1.3rem;
  font-weight: 700;
  color: #776E65;
}

.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-note {
  font-size: 0.78rem;
  color: #999;
  margin-bottom: 0.75rem;
  min-height: 1em;
}

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

/* レスポンシブ */
@media (min-width: 600px) {
  .kami-text { font-size: 1.75rem; }
  .shimo-kana { font-size: 1.55rem; }
  .shimo-romaji { font-size: 1.4rem; }
}

@media (max-width: 420px) {
  .score-area { gap: 0.35rem; }
  .score-box { min-width: 48px; padding: 0.35rem 0.4rem; }
  .score-label { font-size: 0.58rem; }
  .score-value { font-size: 0.95rem; }
  .kami-text { font-size: 1.15rem; }
  .shimo-kana { font-size: 1.05rem; }
  .shimo-romaji { font-size: 1rem; }
  .mode-btn { padding: 0.35rem 0.7rem; font-size: 0.8rem; }
  .choice-btn { font-size: 0.95rem; }
  .result-stats { gap: 0.5rem; }
  .result-stat-value { font-size: 1.1rem; }
}

game.js — 100首データ・決まり字計算・ローマ字判定の頭脳

game.jsがこのゲームの心臓部です。全体が(function () { ... })();という「即時実行関数」で囲まれ、変数が他のコードとぶつからないようになっています。

先頭のPOEMSが百人一首100首のデータです。1首は{ no, kami, shimo, poet }のオブジェクトで、読みは「けふ」→「きょう」のように現代仮名遣いに正規化したひらがなだけ。漢字を持たないのは、タイピングに必要なのが「打つ音」だけだからです。

computeKimariji()が見どころのアルゴリズム。各歌の上の句について「先頭1文字で他の歌と被らないか?被るなら2文字では?」と伸ばしていき、他のどの歌とも被らない最短の接頭辞=決まり字を求めます。決まり字をデータに書き込まず計算で出すので、データを直せば決まり字も自動で正しくなります。「む・す・め・ふ・さ・ほ・せ」の7首だけが1字決まりになるという百人一首の有名な性質は、自動テストでデータ誤りを見つける検出器として使っています。

ローマ字判定はKANAテーブル(かな→打ち方候補の配列)とacceptKey()のペアです。1キー打たれるたびに「その入力で始まる候補が残っているか」を調べ、残っていなければミス。「し」は si/shi/ci、「っ」は子音重ねと xtu、「ん」は次の文字によって n 1打を許すかを判定します。かるた10首モードはミス1打を+0.3秒に換算し、10首の合計タイムを1/10秒の整数にしてLeaderboard.show()でランキングに登録します(短いほど上位なのでorder: 'asc')。タッチ端末はmatchMedia('(pointer: coarse)')で判定して下の句3択かるたに自動で切り替え、こちらはランキング登録なしです。

game.js
// ============================================
// 百人一首タイピング ロジック
// ============================================
//
// 上の句を見て下の句をローマ字で打つタイピングゲーム。
// - かるた10首: ランダム10首の合計タイム勝負(ランキング対象・ミス1打=+0.3秒)
// - 決まり字モード: 上の句が1文字ずつ現れる。決まり字で取れたら演出(練習用)
// - 通し100首: 1番から100番まで。進捗はlocalStorageに保存
// - タッチ端末では「下の句3択かるた」に自動切替(ランキング登録なし)
//
// ローマ字判定は games/sushi-typing/game.js のエンジンを流用。
// 「si / shi」「ja / zya / jya」「っ=子音重ね」「ん=n / nn」など
// 複数の打ち方を全て受け付ける。
//
// 百人一首の読みは現代仮名遣いに正規化してある(けふ→きょう 等)。
// 決まり字(何文字目で1首に確定するか)はデータに持たせず、
// 全100首の上の句から「最短一意接頭辞」として起動時に計算する。

(function () {
  'use strict';

  // ============================================
  // 百人一首データ(現代仮名遣い・すべてひらがな)
  // no: 歌番号 / kami: 上の句 / shimo: 下の句 / poet: 歌人
  // ============================================
  var POEMS = [
    { no: 1, kami: 'あきのたのかりほのいおのとまをあらみ', shimo: 'わがころもではつゆにぬれつつ', poet: '天智天皇' },
    { no: 2, kami: 'はるすぎてなつきにけらししろたえの', shimo: 'ころもほすちょうあまのかぐやま', poet: '持統天皇' },
    { no: 3, kami: 'あしびきのやまどりのおのしだりおの', shimo: 'ながながしよをひとりかもねむ', poet: '柿本人麻呂' },
    { no: 4, kami: 'たごのうらにうちいでてみればしろたえの', shimo: 'ふじのたかねにゆきはふりつつ', poet: '山部赤人' },
    { no: 5, kami: 'おくやまにもみじふみわけなくしかの', shimo: 'こえきくときぞあきはかなしき', poet: '猿丸大夫' },
    { no: 6, kami: 'かささぎのわたせるはしにおくしもの', shimo: 'しろきをみればよぞふけにける', poet: '中納言家持' },
    { no: 7, kami: 'あまのはらふりさけみればかすがなる', shimo: 'みかさのやまにいでしつきかも', poet: '阿倍仲麻呂' },
    { no: 8, kami: 'わがいおはみやこのたつみしかぞすむ', shimo: 'よをうじやまとひとはいうなり', poet: '喜撰法師' },
    { no: 9, kami: 'はなのいろはうつりにけりないたずらに', shimo: 'わがみよにふるながめせしまに', poet: '小野小町' },
    { no: 10, kami: 'これやこのゆくもかえるもわかれては', shimo: 'しるもしらぬもおうさかのせき', poet: '蝉丸' },
    { no: 11, kami: 'わたのはらやそしまかけてこぎいでぬと', shimo: 'ひとにはつげよあまのつりぶね', poet: '参議篁' },
    { no: 12, kami: 'あまつかぜくものかよいじふきとじよ', shimo: 'おとめのすがたしばしとどめむ', poet: '僧正遍昭' },
    { no: 13, kami: 'つくばねのみねよりおつるみなのがわ', shimo: 'こいぞつもりてふちとなりぬる', poet: '陽成院' },
    { no: 14, kami: 'みちのくのしのぶもじずりたれゆえに', shimo: 'みだれそめにしわれならなくに', poet: '河原左大臣' },
    { no: 15, kami: 'きみがためはるののにいでてわかなつむ', shimo: 'わがころもでにゆきはふりつつ', poet: '光孝天皇' },
    { no: 16, kami: 'たちわかれいなばのやまのみねにおうる', shimo: 'まつとしきかばいまかえりこむ', poet: '中納言行平' },
    { no: 17, kami: 'ちはやぶるかみよもきかずたつたがわ', shimo: 'からくれないにみずくくるとは', poet: '在原業平朝臣' },
    { no: 18, kami: 'すみのえのきしによるなみよるさえや', shimo: 'ゆめのかよいじひとめよくらむ', poet: '藤原敏行朝臣' },
    { no: 19, kami: 'なにわがたみじかきあしのふしのまも', shimo: 'あわでこのよをすぐしてよとや', poet: '伊勢' },
    { no: 20, kami: 'わびぬればいまはたおなじなにわなる', shimo: 'みをつくしてもあわむとぞおもう', poet: '元良親王' },
    { no: 21, kami: 'いまこむといいしばかりにながつきの', shimo: 'ありあけのつきをまちいでつるかな', poet: '素性法師' },
    { no: 22, kami: 'ふくからにあきのくさきのしおるれば', shimo: 'むべやまかぜをあらしというらむ', poet: '文屋康秀' },
    { no: 23, kami: 'つきみればちぢにものこそかなしけれ', shimo: 'わがみひとつのあきにはあらねど', poet: '大江千里' },
    { no: 24, kami: 'このたびはぬさもとりあえずたむけやま', shimo: 'もみじのにしきかみのまにまに', poet: '菅家' },
    { no: 25, kami: 'なにしおわばおうさかやまのさねかずら', shimo: 'ひとにしられでくるよしもがな', poet: '三条右大臣' },
    { no: 26, kami: 'おぐらやまみねのもみじばこころあらば', shimo: 'いまひとたびのみゆきまたなむ', poet: '貞信公' },
    { no: 27, kami: 'みかのはらわきてながるるいずみがわ', shimo: 'いつみきとてかこいしかるらむ', poet: '中納言兼輔' },
    { no: 28, kami: 'やまざとはふゆぞさびしさまさりける', shimo: 'ひとめもくさもかれぬとおもえば', poet: '源宗于朝臣' },
    { no: 29, kami: 'こころあてにおらばやおらむはつしもの', shimo: 'おきまどわせるしらぎくのはな', poet: '凡河内躬恒' },
    { no: 30, kami: 'ありあけのつれなくみえしわかれより', shimo: 'あかつきばかりうきものはなし', poet: '壬生忠岑' },
    { no: 31, kami: 'あさぼらけありあけのつきとみるまでに', shimo: 'よしののさとにふれるしらゆき', poet: '坂上是則' },
    { no: 32, kami: 'やまがわにかぜのかけたるしがらみは', shimo: 'ながれもあえぬもみじなりけり', poet: '春道列樹' },
    { no: 33, kami: 'ひさかたのひかりのどけきはるのひに', shimo: 'しずごころなくはなのちるらむ', poet: '紀友則' },
    { no: 34, kami: 'たれをかもしるひとにせむたかさごの', shimo: 'まつもむかしのともならなくに', poet: '藤原興風' },
    { no: 35, kami: 'ひとはいさこころもしらずふるさとは', shimo: 'はなぞむかしのかににおいける', poet: '紀貫之' },
    { no: 36, kami: 'なつのよはまだよいながらあけぬるを', shimo: 'くものいずこにつきやどるらむ', poet: '清原深養父' },
    { no: 37, kami: 'しらつゆにかぜのふきしくあきののは', shimo: 'つらぬきとめぬたまぞちりける', poet: '文屋朝康' },
    { no: 38, kami: 'わすらるるみをばおもわずちかいてし', shimo: 'ひとのいのちのおしくもあるかな', poet: '右近' },
    { no: 39, kami: 'あさじうのおののしのはらしのぶれど', shimo: 'あまりてなどかひとのこいしき', poet: '参議等' },
    { no: 40, kami: 'しのぶれどいろにいでにけりわがこいは', shimo: 'ものやおもうとひとのとうまで', poet: '平兼盛' },
    { no: 41, kami: 'こいすちょうわがなはまだきたちにけり', shimo: 'ひとしれずこそおもいそめしか', poet: '壬生忠見' },
    { no: 42, kami: 'ちぎりきなかたみにそでをしぼりつつ', shimo: 'すえのまつやまなみこさじとは', poet: '清原元輔' },
    { no: 43, kami: 'あいみてののちのこころにくらぶれば', shimo: 'むかしはものをおもわざりけり', poet: '権中納言敦忠' },
    { no: 44, kami: 'あうことのたえてしなくはなかなかに', shimo: 'ひとをもみをもうらみざらまし', poet: '中納言朝忠' },
    { no: 45, kami: 'あわれともいうべきひとはおもおえで', shimo: 'みのいたずらになりぬべきかな', poet: '謙徳公' },
    { no: 46, kami: 'ゆらのとをわたるふなびとかじをたえ', shimo: 'ゆくえもしらぬこいのみちかな', poet: '曽禰好忠' },
    { no: 47, kami: 'やえむぐらしげれるやどのさびしきに', shimo: 'ひとこそみえねあきはきにけり', poet: '恵慶法師' },
    { no: 48, kami: 'かぜをいたみいわうつなみのおのれのみ', shimo: 'くだけてものをおもうころかな', poet: '源重之' },
    { no: 49, kami: 'みかきもりえじのたくひのよるはもえ', shimo: 'ひるはきえつつものをこそおもえ', poet: '大中臣能宣朝臣' },
    { no: 50, kami: 'きみがためおしからざりしいのちさえ', shimo: 'ながくもがなとおもいけるかな', poet: '藤原義孝' },
    { no: 51, kami: 'かくとだにえやはいぶきのさしもぐさ', shimo: 'さしもしらじなもゆるおもいを', poet: '藤原実方朝臣' },
    { no: 52, kami: 'あけぬればくるるものとはしりながら', shimo: 'なおうらめしきあさぼらけかな', poet: '藤原道信朝臣' },
    { no: 53, kami: 'なげきつつひとりぬるよのあくるまは', shimo: 'いかにひさしきものとかはしる', poet: '右大将道綱母' },
    { no: 54, kami: 'わすれじのゆくすえまではかたければ', shimo: 'きょうをかぎりのいのちともがな', poet: '儀同三司母' },
    { no: 55, kami: 'たきのおとはたえてひさしくなりぬれど', shimo: 'なこそながれてなおきこえけれ', poet: '大納言公任' },
    { no: 56, kami: 'あらざらむこのよのほかのおもいでに', shimo: 'いまひとたびのあうこともがな', poet: '和泉式部' },
    { no: 57, kami: 'めぐりあいてみしやそれともわかぬまに', shimo: 'くもがくれにしよわのつきかな', poet: '紫式部' },
    { no: 58, kami: 'ありまやまいなのささはらかぜふけば', shimo: 'いでそよひとをわすれやはする', poet: '大弐三位' },
    { no: 59, kami: 'やすらわでねなましものをさよふけて', shimo: 'かたぶくまでのつきをみしかな', poet: '赤染衛門' },
    { no: 60, kami: 'おおえやまいくののみちのとおければ', shimo: 'まだふみもみずあまのはしだて', poet: '小式部内侍' },
    { no: 61, kami: 'いにしえのならのみやこのやえざくら', shimo: 'きょうここのえににおいぬるかな', poet: '伊勢大輔' },
    { no: 62, kami: 'よをこめてとりのそらねははかるとも', shimo: 'よにおうさかのせきはゆるさじ', poet: '清少納言' },
    { no: 63, kami: 'いまはただおもいたえなむとばかりを', shimo: 'ひとづてならでいうよしもがな', poet: '左京大夫道雅' },
    { no: 64, kami: 'あさぼらけうじのかわぎりたえだえに', shimo: 'あらわれわたるせぜのあじろぎ', poet: '権中納言定頼' },
    { no: 65, kami: 'うらみわびほさぬそでだにあるものを', shimo: 'こいにくちなむなこそおしけれ', poet: '相模' },
    { no: 66, kami: 'もろともにあわれとおもえやまざくら', shimo: 'はなよりほかにしるひともなし', poet: '前大僧正行尊' },
    { no: 67, kami: 'はるのよのゆめばかりなるたまくらに', shimo: 'かいなくたたむなこそおしけれ', poet: '周防内侍' },
    { no: 68, kami: 'こころにもあらでうきよにながらえば', shimo: 'こいしかるべきよわのつきかな', poet: '三条院' },
    { no: 69, kami: 'あらしふくみむろのやまのもみじばは', shimo: 'たつたのかわのにしきなりけり', poet: '能因法師' },
    { no: 70, kami: 'さびしさにやどをたちいでてながむれば', shimo: 'いずこもおなじあきのゆうぐれ', poet: '良暹法師' },
    { no: 71, kami: 'ゆうさればかどたのいなばおとずれて', shimo: 'あしのまろやにあきかぜぞふく', poet: '大納言経信' },
    { no: 72, kami: 'おとにきくたかしのはまのあだなみは', shimo: 'かけじやそでのぬれもこそすれ', poet: '祐子内親王家紀伊' },
    { no: 73, kami: 'たかさごのおのえのさくらさきにけり', shimo: 'とやまのかすみたたずもあらなむ', poet: '前権中納言匡房' },
    { no: 74, kami: 'うかりけるひとをはつせのやまおろしよ', shimo: 'はげしかれとはいのらぬものを', poet: '源俊頼朝臣' },
    { no: 75, kami: 'ちぎりおきしさせもがつゆをいのちにて', shimo: 'あわれことしのあきもいぬめり', poet: '藤原基俊' },
    { no: 76, kami: 'わたのはらこぎいでてみればひさかたの', shimo: 'くもいにまがうおきつしらなみ', poet: '法性寺入道前関白太政大臣' },
    { no: 77, kami: 'せをはやみいわにせかるるたきがわの', shimo: 'われてもすえにあわむとぞおもう', poet: '崇徳院' },
    { no: 78, kami: 'あわじしまかようちどりのなくこえに', shimo: 'いくよねざめぬすまのせきもり', poet: '源兼昌' },
    { no: 79, kami: 'あきかぜにたなびくくものたえまより', shimo: 'もれいずるつきのかげのさやけさ', poet: '左京大夫顕輔' },
    { no: 80, kami: 'ながからむこころもしらずくろかみの', shimo: 'みだれてけさはものをこそおもえ', poet: '待賢門院堀河' },
    { no: 81, kami: 'ほととぎすなきつるかたをながむれば', shimo: 'ただありあけのつきぞのこれる', poet: '後徳大寺左大臣' },
    { no: 82, kami: 'おもいわびさてもいのちはあるものを', shimo: 'うきにたえぬはなみだなりけり', poet: '道因法師' },
    { no: 83, kami: 'よのなかよみちこそなけれおもいいる', shimo: 'やまのおくにもしかぞなくなる', poet: '皇太后宮大夫俊成' },
    { no: 84, kami: 'ながらえばまたこのごろやしのばれむ', shimo: 'うしとみしよぞいまはこいしき', poet: '藤原清輔朝臣' },
    { no: 85, kami: 'よもすがらものおもうころはあけやらで', shimo: 'ねやのひまさえつれなかりけり', poet: '俊恵法師' },
    { no: 86, kami: 'なげけとてつきやはものをおもわする', shimo: 'かこちがおなるわがなみだかな', poet: '西行法師' },
    { no: 87, kami: 'むらさめのつゆもまだひぬまきのはに', shimo: 'きりたちのぼるあきのゆうぐれ', poet: '寂蓮法師' },
    { no: 88, kami: 'なにわえのあしのかりねのひとよゆえ', shimo: 'みをつくしてやこいわたるべき', poet: '皇嘉門院別当' },
    { no: 89, kami: 'たまのおよたえなばたえねながらえば', shimo: 'しのぶることのよわりもぞする', poet: '式子内親王' },
    { no: 90, kami: 'みせばやなおじまのあまのそでだにも', shimo: 'ぬれにぞぬれしいろはかわらず', poet: '殷富門院大輔' },
    { no: 91, kami: 'きりぎりすなくやしもよのさむしろに', shimo: 'ころもかたしきひとりかもねむ', poet: '後京極摂政前太政大臣' },
    { no: 92, kami: 'わがそではしおひにみえぬおきのいしの', shimo: 'ひとこそしらねかわくまもなし', poet: '二条院讃岐' },
    { no: 93, kami: 'よのなかはつねにもがもななぎさこぐ', shimo: 'あまのおぶねのつなでかなしも', poet: '鎌倉右大臣' },
    { no: 94, kami: 'みよしののやまのあきかぜさよふけて', shimo: 'ふるさとさむくころもうつなり', poet: '参議雅経' },
    { no: 95, kami: 'おおけなくうきよのたみにおおうかな', shimo: 'わがたつそまにすみぞめのそで', poet: '前大僧正慈円' },
    { no: 96, kami: 'はなさそうあらしのにわのゆきならで', shimo: 'ふりゆくものはわがみなりけり', poet: '入道前太政大臣' },
    { no: 97, kami: 'こぬひとをまつほのうらのゆうなぎに', shimo: 'やくやもしおのみもこがれつつ', poet: '権中納言定家' },
    { no: 98, kami: 'かぜそよぐならのおがわのゆうぐれは', shimo: 'みそぎぞなつのしるしなりける', poet: '従二位家隆' },
    { no: 99, kami: 'ひともおしひともうらめしあじきなく', shimo: 'よをおもうゆえにものおもうみは', poet: '後鳥羽院' },
    { no: 100, kami: 'ももしきやふるきのきばのしのぶにも', shimo: 'なおあまりあるむかしなりけり', poet: '順徳院' }
  ];

  // ============================================
  // ローマ字変換テーブル(かな → 打ち方の候補)
  // games/sushi-typing/game.js からコピーして流用。
  // 候補の先頭が画面に表示される「おすすめの打ち方」
  // ============================================
  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'],
    'ゐ': ['wi'], 'ゑ': ['we'],
    'ー': ['-']
  };

  // 「ん」を1打の n で打てない(nn が必要な)次の文字の頭
  var N_NEEDS_DOUBLE = 'aiueony';

  // ============================================
  // ローマ字エンジン(sushi-typing と同じ判定方式)
  // ============================================

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

  // かな文字列をトークン列に分解する。
  // 「っ」「ん」は次のかなと結合した1トークンにして、
  // 「子音重ね」「1打のn」を候補として正しく扱う。
  function tokenize(kana) {
    // まず拗音(2文字)優先でかなを切り出す
    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]) {
        // っ: 次の候補の頭の子音を重ねる(ppo など)か xtu/ltu/ltsu
        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]) {
        // ん: 次が母音・な行・や行でなければ1打の n も許可
        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];
  }

  // かな1句ぶんの入力状態
  function createWordState(kana) {
    return {
      kana: kana,
      tokens: tokenize(kana),
      ti: 0,       // 何トークン目か
      typed: ''    // 現在トークンへの入力
    };
  }

  // 1キー入力を処理。true=正解 / false=ミス

  // ============================================
  // 下の句の別表記(打ち方の拡張)
  // 百人一首は歴史的仮名遣い(てふ⇔ちょう・おもふ⇔おもう 等)や、
  // 朗詠の発音(助詞は⇔わ・あうこと⇔おうこと)でも打てるようにする。
  // データは scratchpad/koten.mjs で機械検証済み(モーラ数・トークン数・仮名対応ペア)
  // ============================================
  var ALT_SHIMO = {
    1: ['わがころもでわつゆにぬれつつ'],
    2: ['ころもほすてふあまのかぐやま'],
    4: ['ふじのたかねにゆきわふりつつ'],
    5: ['こゑきくときぞあきはかなしき', 'こえきくときぞあきわかなしき'],
    8: ['よをうぢやまとひとはいふなり', 'よをうじやまとひとわいうなり'],
    10: ['しるもしらぬもあふさかのせき'],
    11: ['ひとにわつげよあまのつりぶね'],
    12: ['をとめのすがたしばしとどめむ'],
    13: ['こひぞつもりてふちとなりぬる'],
    15: ['わがころもでにゆきわふりつつ'],
    16: ['まつとしきかばいまかへりこむ'],
    17: ['からくれなゐにみづくくるとは', 'からくれないにみずくくるとわ'],
    18: ['ゆめのかよひぢひとめよくらむ'],
    19: ['あはでこのよをすぐしてよとや'],
    20: ['みをつくしてもあはむとぞおもふ'],
    22: ['むべやまかぜをあらしといふらむ'],
    23: ['わがみひとつのあきにわあらねど'],
    24: ['もみぢのにしきかみのまにまに'],
    27: ['いつみきとてかこひしかるらむ'],
    28: ['ひとめもくさもかれぬとおもへば'],
    29: ['おきまどはせるしらぎくのはな'],
    30: ['あかつきばかりうきものわなし'],
    32: ['ながれもあへぬもみぢなりけり'],
    33: ['しづごころなくはなのちるらむ'],
    35: ['はなぞむかしのかににほひける'],
    36: ['くものいづこにつきやどるらむ'],
    38: ['ひとのいのちのをしくもあるかな'],
    39: ['あまりてなどかひとのこひしき'],
    40: ['ものやおもふとひとのとふまで'],
    41: ['ひとしれずこそおもひそめしか'],
    42: ['すゑのまつやまなみこさじとは', 'すえのまつやまなみこさじとわ'],
    43: ['むかしはものをおもはざりけり', 'むかしわものをおもわざりけり'],
    45: ['みのいたづらになりぬべきかな'],
    46: ['ゆくへもしらぬこひのみちかな'],
    47: ['ひとこそみえねあきわきにけり'],
    48: ['くだけてものをおもふころかな'],
    49: ['ひるはきえつつものをこそおもへ', 'ひるわきえつつものをこそおもえ'],
    50: ['ながくもがなとおもひけるかな'],
    51: ['さしもしらじなもゆるおもひを'],
    52: ['なほうらめしきあさぼらけかな'],
    53: ['いかにひさしきものとかわしる'],
    54: ['けふをかぎりのいのちともがな'],
    55: ['なこそながれてなほきこえけれ'],
    56: ['いまひとたびのあふこともがな', 'いまひとたびのおうこともがな'],
    57: ['くもがくれにしよはのつきかな'],
    58: ['いでそよひとをわすれやわする'],
    61: ['けふここのへににほひぬるかな'],
    62: ['よにあふさかのせきはゆるさじ', 'よにおうさかのせきわゆるさじ'],
    63: ['ひとづてならでいふよしもがな'],
    64: ['あらはれわたるせぜのあじろぎ'],
    65: ['こひにくちなむなこそをしけれ'],
    67: ['かひなくたたむなこそをしけれ'],
    68: ['こひしかるべきよはのつきかな'],
    69: ['たつたのかはのにしきなりけり'],
    70: ['いづこもおなじあきのゆふぐれ'],
    74: ['はげしかれとわいのらぬものを'],
    75: ['あはれことしのあきもいぬめり'],
    76: ['くもゐにまがふおきつしらなみ'],
    77: ['われてもすゑにあはむとぞおもふ'],
    79: ['もれいづるつきのかげのさやけさ'],
    80: ['みだれてけさはものをこそおもへ', 'みだれてけさわものをこそおもえ'],
    82: ['うきにたへぬはなみだなりけり', 'うきにたえぬわなみだなりけり'],
    84: ['うしとみしよぞいまはこひしき', 'うしとみしよぞいまわこいしき'],
    85: ['ねやのひまさへつれなかりけり'],
    86: ['かこちがほなるわがなみだかな'],
    87: ['きりたちのぼるあきのゆふぐれ'],
    88: ['みをつくしてやこひわたるべき'],
    90: ['ぬれにぞぬれしいろはかはらず', 'ぬれにぞぬれしいろわかわらず'],
    92: ['ひとこそしらねかはくまもなし'],
    93: ['あまのをぶねのつなでかなしも'],
    96: ['ふりゆくものわわがみなりけり'],
    97: ['やくやもしほのみもこがれつつ'],
    99: ['よをおもふゆゑにものおもふみは', 'よをおもうゆえにものおもうみわ'],
    100: ['なほあまりあるむかしなりけり']
  };

  // 別表記のローマ字候補を取り込む。トークン数が食い違う場合は何もしない(安全側)
  function mergeAltShimo(state, no) {
    var alts = ALT_SHIMO[no];
    if (!alts) return state;
    for (var a = 0; a < alts.length; a++) {
      var altTokens = tokenize(alts[a]);
      if (altTokens.length !== state.tokens.length) continue;
      for (var i = 0; i < state.tokens.length; i++) {
        if (altTokens[i].kana !== state.tokens[i].kana) {
          state.tokens[i].candidates = dedupe(state.tokens[i].candidates.concat(altTokens[i].candidates));
        }
      }
    }
    return state;
  }

  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];
  }

  // ============================================
  // 決まり字の計算
  // 各歌の上の句について「他のどの歌とも被らない最短の接頭辞」を求める。
  // 戻り値: { 歌番号: '決まり字文字列' }
  // ============================================
  function computeKimariji(poems) {
    var map = {};
    poems.forEach(function (p) {
      var len = 1;
      for (; len < p.kami.length; len++) {
        var pre = p.kami.slice(0, len);
        var dup = poems.some(function (q) {
          return q.no !== p.no && q.kami.slice(0, len) === pre;
        });
        if (!dup) break;
      }
      map[p.no] = p.kami.slice(0, len);
    });
    return map;
  }

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

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

  var KIMARIJI = computeKimariji(POEMS);
  var PENALTY_MS = 300;          // タイピングのミス1打 = +0.3秒
  var CHOICE_PENALTY_MS = 1000;  // 3択のお手つき1回 = +1秒
  var REVEAL_INTERVAL = 500;     // 決まり字モード: 朗詠が使えない時の予備(0.5秒に1文字)
  var KARUTA_COUNT = 10;         // かるた10首・決まり字・3択で読む首数

  // ---- 朗詠音声(Synthesizer V 重音テトで生成したmp3)----
  // 実測タイミングは assets/voice/hyakunin/yomi.js。決まり字モードの文字送りは
  // 朗詠の発声時刻に同期する。読み込めない環境では従来の一定速表示で続行
  var VOICE_BASE = '../../assets/voice/hyakunin/';
  var YOMI = window.HYAKU_YOMI || null;
  var voiceEl = null;
  var serveAt = 0;   // 出題時刻(音声が使えない時の文字送りの基準)

  function playVoice(no) {
    stopVoice();
    if (!YOMI || !YOMI[no]) return;
    voiceEl = new Audio(VOICE_BASE + ('00' + no).slice(-3) + '.mp3');
    voiceEl.muted = muted;
    var p = voiceEl.play();
    if (p && p.catch) p.catch(function () { /* 自動再生拒否などは無音で続行 */ });
  }

  function stopVoice() {
    if (voiceEl) { try { voiceEl.pause(); } catch (e) { /* 無視 */ } voiceEl = null; }
  }

  // 次の歌の音声を先読みしておく(歌の切り替わりで頭が欠けないように)
  function preloadVoice(no) {
    if (!no || !YOMI || !YOMI[no]) return;
    var a = new Audio(VOICE_BASE + ('00' + no).slice(-3) + '.mp3');
    a.preload = 'auto';
  }

  // 本物のかるたと同じく、上の句を読んでいる間は下の句(入力欄・3択)を伏せる
  var shimoTimer = null;

  function setShimoHidden(hidden) {
    var v = hidden ? 'hidden' : '';
    els.typingArea.style.visibility = v;
    els.choiceArea.style.visibility = v;
  }

  function scheduleShimoReveal(no) {
    if (shimoTimer) { clearTimeout(shimoTimer); shimoTimer = null; }
    var y = YOMI && YOMI[no];
    if (y) {
      setShimoHidden(true);
      shimoTimer = setTimeout(function () { setShimoHidden(false); }, y.ke);  // 上の句を読み終えたら公開
    } else {
      setShimoHidden(false);  // 朗詠データが無い環境では従来どおりすぐ見せる
    }
  }

  // タッチデバイスは「下の句3択かるた」に自動切替(設計で確定)
  var isTouch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;

  // DOM要素
  var els = {
    time: document.getElementById('time'),
    poemCount: document.getElementById('poem-count'),
    miss: document.getElementById('miss'),
    penalty: document.getElementById('penalty'),
    best: document.getElementById('best-score'),
    stage: document.getElementById('karuta-stage'),
    kamiText: document.getElementById('kami-text'),
    poetName: document.getElementById('poet-name'),
    typingArea: document.getElementById('typing-area'),
    shimoKana: document.getElementById('shimo-kana'),
    shimoRomaji: document.getElementById('shimo-romaji'),
    choiceArea: document.getElementById('choice-area'),
    popLayer: document.getElementById('pop-layer'),
    stageMsg: document.getElementById('stage-message'),
    touchNote: document.getElementById('touch-note'),
    modeArea: document.getElementById('mode-area'),
    btnStart: document.getElementById('btn-start'),
    btnResetProgress: document.getElementById('btn-reset-progress'),
    btnMute: document.getElementById('btn-mute'),
    btnRestart: document.getElementById('btn-restart'),
    overlay: document.getElementById('result-overlay'),
    rTitle: document.getElementById('result-title'),
    rMode: document.getElementById('result-mode'),
    rTime: document.getElementById('result-time'),
    rMiss: document.getElementById('result-miss'),
    rPenalty: document.getElementById('result-penalty'),
    rKimarijiRow: document.getElementById('result-kimariji-row'),
    rKimariji: document.getElementById('result-kimariji'),
    rBest: document.getElementById('result-best'),
    rNote: document.getElementById('result-note')
  };
  var modeBtns = document.querySelectorAll('.mode-btn');

  var MODE_LABELS = {
    karuta10: 'かるた10首',
    kimariji: '決まり字モード',
    all100: '通し100首',
    choice: '下の句3択かるた'
  };

  // ゲーム状態
  var mode = isTouch ? 'choice' : 'karuta10';
  var phase = 'idle'; // idle / playing / over
  var queue = [];     // 出題する歌の配列
  var idx = 0;        // 何首目か
  var wordState = null;
  var rafId = null;

  var playStart = 0;      // 今セッションの開始時刻
  var baseElapsedMs = 0;  // 通し100首の再開時に引き継ぐ経過時間
  var penaltyMs = 0;
  var missCount = 0;
  var correctKeys = 0;

  // 決まり字モード用
  var revealCount = 0;
  var revealTimer = null;
  var firstKeyReveal = null; // 最初の打鍵時に何文字表示されていたか
  var poemMiss = false;      // この歌でお手つきしたか
  var kimarijiCatches = 0;   // 決まり字で取れた数

  var PROGRESS_KEY = 'hyakuninTyping100';

  // ============================================
  // サウンド(WebAudio・音声ファイルは使わない)
  // ============================================
  var audioCtx = null;
  var muted = localStorage.getItem('hyakuninTypingMuted') === '1';


  // 札取り効果音の読み込み(初回のensureAudioで開始)
  var LOADING = 'loading';
  var takeBuf = null;
  function loadTakeSound() {
    if (takeBuf !== null || !audioCtx) return;
    takeBuf = LOADING;
    fetch('../../assets/se/karuta-take.mp3')
      .then(function (r) { return r.arrayBuffer(); })
      .then(function (ab) { return audioCtx.decodeAudioData(ab); })
      .then(function (b) { takeBuf = b; })
      .catch(function () { takeBuf = null; });
  }

  function ensureAudio() {
    if (!audioCtx) {
      var AC = window.AudioContext || window.webkitAudioContext;
      if (AC) audioCtx = new AC();
    }
    if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
    loadTakeSound();
  }

  function blip(freq, dur, type, gain, delay) {
    if (muted || !audioCtx) return;
    var t = audioCtx.currentTime + (delay || 0);
    var osc = audioCtx.createOscillator();
    var g = audioCtx.createGain();
    osc.type = type || 'square';
    osc.frequency.value = freq;
    g.gain.setValueAtTime(gain || 0.04, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    osc.connect(g).connect(audioCtx.destination);
    osc.start(t);
    osc.stop(t + dur);
  }

  var sounds = {
    type: function () { blip(1800, 0.045, 'square', 0.09); },   // 朗詠中でも聞こえる音量・高さ
    miss: function () { blip(130, 0.2, 'sawtooth', 0.18); },    // ミスと即分かる強さ
    // 札を取った「すぱーん」: 効果音ラボの打撃2+ハリセン(-3半音)を重ねたmp3。
    // WebAudio合成では質感が出なかったため、この音だけファイル再生(assets/se/karuta-take.mp3)
    take: function () {
      if (muted || !audioCtx) return;
      if (takeBuf && takeBuf !== LOADING) {
        var s = audioCtx.createBufferSource();
        s.buffer = takeBuf;
        var g = audioCtx.createGain();
        g.gain.value = 0.9;
        s.connect(g);
        g.connect(audioCtx.destination);
        s.start();
      } else {
        // 読み込み前・失敗時の予備音
        blip(660, 0.08, 'triangle', 0.06);
        blip(990, 0.12, 'triangle', 0.06, 0.07);
      }
    },
    kimariji: function () { blip(880, 0.1, 'sine', 0.06); blip(1320, 0.18, 'sine', 0.06, 0.09); },
    finish: function () {
      blip(660, 0.12, 'triangle', 0.06);
      blip(880, 0.12, 'triangle', 0.06, 0.12);
      blip(1100, 0.25, 'triangle', 0.06, 0.24);
    }
  };

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

  // ============================================
  // ユーティリティ
  // ============================================
  function shuffle(arr) {
    var a = arr.slice();
    for (var i = a.length - 1; i > 0; i--) {
      var j = Math.floor(Math.random() * (i + 1));
      var t = a[i]; a[i] = a[j]; a[j] = t;
    }
    return a;
  }

  function escapeHtml(str) {
    return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }

  // 1/10秒の整数 → 「X分Y.Y秒」表示
  function formatDeci(deci) {
    var min = Math.floor(deci / 600);
    var sec = (deci % 600) / 10;
    var secStr = sec.toFixed(1) + '秒';
    return min > 0 ? min + '分' + secStr : secStr;
  }

  function totalMs() {
    var live = phase === 'playing' ? performance.now() - playStart : 0;
    return baseElapsedMs + live + penaltyMs;
  }

  // ベストタイム(1/10秒整数)をモード別に保存
  function bestKey() { return 'hyakuninTypingBest_' + mode; }
  function getBest() { return parseInt(localStorage.getItem(bestKey()) || '0', 10); }
  function setBest(v) { localStorage.setItem(bestKey(), String(v)); }

  // 通し100首の進捗
  function loadProgress() {
    try {
      var raw = localStorage.getItem(PROGRESS_KEY);
      if (!raw) return null;
      var p = JSON.parse(raw);
      if (typeof p.idx !== 'number' || p.idx < 1 || p.idx >= POEMS.length) return null;
      return p;
    } catch (e) {
      return null;
    }
  }

  function saveProgress() {
    localStorage.setItem(PROGRESS_KEY, JSON.stringify({
      idx: idx,
      elapsedMs: baseElapsedMs + (performance.now() - playStart),
      penaltyMs: penaltyMs,
      miss: missCount
    }));
  }

  function clearProgress() {
    localStorage.removeItem(PROGRESS_KEY);
  }

  // ============================================
  // 表示
  // ============================================
  function updateHud() {
    var deci = Math.round(totalMs() / 100);
    els.time.textContent = (deci / 10).toFixed(1);
    els.poemCount.textContent = phase === 'idle' ? '-' : (idx + 1) + '/' + queue.length;
    els.miss.textContent = missCount;
    els.penalty.textContent = '+' + (penaltyMs / 1000).toFixed(1);
    var best = getBest();
    els.best.textContent = best > 0 ? formatDeci(best) : '--';
  }

  function loop() {
    if (phase !== 'playing') return;
    updateHud();
    rafId = requestAnimationFrame(loop);
  }

  // 上の句を表示。決まり字部分に下線マーク。
  // 決まり字モードでは revealCount 文字だけ見せる。
  // 上の句の句切れ(1句目・2句目のモーラ数)。字余りで5・7からずれる歌だけ列挙
  var KAMI_KUGIRE = {
    4: [6, 8], 9: [6, 7], 25: [6, 7], 29: [6, 7], 48: [6, 7], 55: [6, 7], 57: [6, 7], 75: [6, 7],
    15: [5, 8], 24: [5, 8], 31: [5, 8], 40: [5, 8], 70: [5, 8], 76: [5, 8], 85: [5, 8]
  };
  // 下の句の句切れ(1句目のモーラ数)。字余りで7からずれる歌だけ列挙
  var SHIMO_KUGIRE = { 21: 8, 79: 8, 99: 8 };

  function moraUnits(kana) {
    var units = [];
    for (var i = 0; i < kana.length; i++) {
      var c = kana.charAt(i);
      if ('ゃゅょぁぃぅぇぉ'.indexOf(c) !== -1 && units.length) units[units.length - 1] += c;
      else units.push(c);
    }
    return units;
  }

  function kamiPhrases(poem) {
    var units = moraUnits(poem.kami);
    var counts = KAMI_KUGIRE[poem.no] || [5, 7];
    return [
      units.slice(0, counts[0]).join(''),
      units.slice(counts[0], counts[0] + counts[1]).join(''),
      units.slice(counts[0] + counts[1]).join('')
    ];
  }

  // 上の句は句ごとのかたまり(.kphrase)で組む。句の途中で行が折れないように
  function renderKami(poem) {
    var kimariji = KIMARIJI[poem.no];
    var shownLen = mode === 'kimariji' ? revealCount : poem.kami.length;
    var phrases = kamiPhrases(poem);
    var html = '';
    var off = 0;
    for (var p = 0; p < phrases.length && off < shownLen; p++) {
      var ph = phrases[p];
      var span = '';
      for (var i = 0; i < ph.length && off + i < shownLen; i++) {
        var cls = (off + i) < kimariji.length ? 'kimariji-mark' : '';
        span += cls ? '<span class="' + cls + '">' + escapeHtml(ph[i]) + '</span>' : escapeHtml(ph[i]);
      }
      if (span) html += '<span class="kphrase">' + span + '</span> ';
      off += ph.length;
    }
    els.kamiText.innerHTML = html || '<span class="kami-waiting">・・・</span>';
    els.poetName.textContent = mode === 'kimariji' && revealCount < poem.kami.length ? '' : poem.poet;
  }

  // 下の句のかな+ローマ字(未入力=グレー / 入力済=色付き)。7・7の句のかたまりで組む
  function renderShimo() {
    if (!wordState) return;
    var breakAt = SHIMO_KUGIRE[queue[idx].no] || 7;
    var html = '<span class="kphrase">';
    var acc = 0;
    for (var i = 0; i < wordState.tokens.length; i++) {
      var t = wordState.tokens[i];
      var cls = i < wordState.ti ? 'k-done' : (i === wordState.ti ? 'k-cur' : 'k-rest');
      html += '<span class="' + cls + '">' + escapeHtml(t.kana) + '</span>';
      acc += moraUnits(t.kana).length;
      if (acc === breakAt && i < wordState.tokens.length - 1) html += '</span> <span class="kphrase">';
    }
    html += '</span>';
    els.shimoKana.innerHTML = html;

    var parts = romajiParts(wordState);
    els.shimoRomaji.innerHTML =
      '<span class="r-done">' + escapeHtml(parts[0]) + '</span>' +
      '<span class="r-rest">' + escapeHtml(parts[1]) + '</span>';
  }

  function popText(text, cls) {
    var el = document.createElement('div');
    el.className = 'score-pop ' + (cls || '');
    el.textContent = text;
    els.popLayer.appendChild(el);
    setTimeout(function () {
      if (el.parentNode) el.parentNode.removeChild(el);
    }, 900);
  }

  function shakeStage() {
    els.stage.classList.remove('shake');
    void els.stage.offsetWidth; // アニメを毎回最初から再生する
    els.stage.classList.add('shake');
  }

  // ============================================
  // 出題
  // ============================================
  function servePoem() {
    var poem = queue[idx];
    poemMiss = false;
    firstKeyReveal = null;
    playVoice(poem.no);
    preloadVoice(queue[idx + 1] && queue[idx + 1].no);
    serveAt = performance.now();
    scheduleShimoReveal(poem.no);

    if (mode === 'choice') {
      wordState = null;
      renderKami(poem);
      renderChoices(poem);
    } else {
      wordState = mergeAltShimo(createWordState(poem.shimo), poem.no);
      if (mode === 'kimariji') {
        revealCount = 1; // 1文字目はすぐ見せる
        if (revealTimer) clearInterval(revealTimer);
        var yk = YOMI && YOMI[poem.no] && YOMI[poem.no].k;
        revealTimer = setInterval(function () {
          if (phase !== 'playing') return;
          var target;
          if (yk) {
            // 朗詠の発声時刻に同期して文字を出す(音声が止まっていたら経過時間で代用)
            var t = (voiceEl && !voiceEl.paused && voiceEl.currentTime > 0)
              ? voiceEl.currentTime * 1000
              : performance.now() - serveAt;
            target = 1;
            while (target < poem.kami.length && t >= yk[target]) target++;
          } else {
            target = revealCount + 1; // 従来: 0.5秒に1文字
          }
          if (target > revealCount) {
            revealCount = Math.min(target, poem.kami.length);
            renderKami(poem);
          }
          if (revealCount >= poem.kami.length) {
            clearInterval(revealTimer);
            revealTimer = null;
          }
        }, yk ? 100 : REVEAL_INTERVAL);
      }
      renderKami(poem);
      renderShimo();
    }
    updateHud();
  }

  // 3択の選択肢(正解の下の句+他の歌からランダム2つ)
  function renderChoices(poem) {
    var others = shuffle(POEMS.filter(function (p) { return p.no !== poem.no; })).slice(0, 2);
    var options = shuffle([poem].concat(others));
    els.choiceArea.innerHTML = '';
    options.forEach(function (p) {
      var btn = document.createElement('button');
      btn.className = 'choice-btn';
      btn.type = 'button';
      btn.textContent = p.shimo;
      // click は離した瞬間で数十ms遅いので pointerdown で計測
      btn.addEventListener('pointerdown', function (e) {
        e.preventDefault();
        handleChoice(btn, p, poem);
      });
      els.choiceArea.appendChild(btn);
    });
  }

  function handleChoice(btn, picked, poem) {
    if (phase !== 'playing') return;
    if (picked.no === poem.no) {
      btn.classList.add('choice-correct');
      sounds.take();
      poemDone();
    } else {
      if (btn.classList.contains('choice-wrong')) return; // 同じ札の連打は1回だけ
      btn.classList.add('choice-wrong');
      missCount++;
      penaltyMs += CHOICE_PENALTY_MS;
      sounds.miss();
      popText('お手つき +1.0秒', 'pop-miss');
      shakeStage();
      updateHud();
    }
  }

  // 1首打ち切った
  function poemDone() {
    // 決まり字モード: 決まり字が出た瞬間(またはそれ以前)に入力開始して
    // お手つきなしで取れたらボーナス演出
    if (mode === 'kimariji' && !poemMiss &&
        firstKeyReveal !== null && firstKeyReveal <= KIMARIJI[queue[idx].no].length) {
      kimarijiCatches++;
      sounds.kimariji();
      popText('⚡ 決まり字で取った!', 'pop-kimariji');
    } else if (mode !== 'choice') {
      sounds.take();
    }

    if (revealTimer) { clearInterval(revealTimer); revealTimer = null; }
    idx++;

    if (mode === 'all100') {
      if (idx < queue.length) saveProgress();
      else clearProgress();
    }

    if (idx >= queue.length) {
      endGame();
    } else {
      servePoem();
    }
  }

  // ============================================
  // キー入力(PCタイピングモード)
  // ============================================
  function handleChar(ch) {
    if (phase !== 'playing' || !wordState) return;
    ch = ch.toLowerCase();
    if (!/^[a-z\-]$/.test(ch)) return;

    if (firstKeyReveal === null) firstKeyReveal = revealCount;

    if (acceptKey(wordState, ch)) {
      correctKeys++;
      sounds.type();
      renderShimo();
      if (isWordComplete(wordState)) poemDone();
    } else {
      missCount++;
      poemMiss = true;
      penaltyMs += PENALTY_MS;
      sounds.miss();
      popText('ミス +0.3秒', 'pop-miss');
      shakeStage();
    }
    updateHud();
  }

  document.addEventListener('keydown', function (e) {
    if (e.ctrlKey || e.metaKey || e.altKey) return;

    if (phase === 'playing' && mode !== 'choice') {
      if (e.key.length === 1) {
        e.preventDefault();
        handleChar(e.key);
      }
      return;
    }
    // Enterでスタート(結果画面が出ていないとき)
    if (e.key === 'Enter' && phase !== 'playing' && !els.overlay.classList.contains('active')) {
      e.preventDefault();
      startGame();
    }
    if (e.key === 'Escape' && els.overlay.classList.contains('active')) {
      els.overlay.classList.remove('active');
    }
  });

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

    idx = 0;
    baseElapsedMs = 0;
    penaltyMs = 0;
    missCount = 0;
    correctKeys = 0;
    kimarijiCatches = 0;

    if (mode === 'all100') {
      queue = POEMS.slice(); // 1番から100番まで順に
      var progress = loadProgress();
      if (progress) {
        idx = progress.idx;
        baseElapsedMs = progress.elapsedMs || 0;
        penaltyMs = progress.penaltyMs || 0;
        missCount = progress.miss || 0;
      }
    } else {
      queue = shuffle(POEMS).slice(0, KARUTA_COUNT);
    }

    phase = 'playing';
    playStart = performance.now();

    els.overlay.classList.remove('active');
    els.btnStart.disabled = true;
    els.btnStart.textContent = 'プレイ中...';
    modeBtns.forEach(function (b) { b.disabled = true; });
    els.stageMsg.style.display = 'none';
    els.stage.classList.add('playing');

    servePoem();
    loop();
  }

  function endGame() {
    // 経過時間を確定させてから phase を変える(totalMs は playing 中しか実時間を足さないため)
    baseElapsedMs += performance.now() - playStart;
    phase = 'over';
    if (rafId) cancelAnimationFrame(rafId);
    if (revealTimer) { clearInterval(revealTimer); revealTimer = null; }
    if (shimoTimer) { clearTimeout(shimoTimer); shimoTimer = null; }
    setShimoHidden(false);
    stopVoice();
    sounds.finish();

    var deci = Math.round(totalMs() / 100);

    els.btnStart.disabled = false;
    modeBtns.forEach(function (b) { b.disabled = false; });
    els.stage.classList.remove('playing');
    els.kamiText.textContent = 'お見事!';
    els.poetName.textContent = '';
    els.shimoKana.textContent = '';
    els.shimoRomaji.textContent = '';
    els.choiceArea.innerHTML = '';

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

    els.rTitle.textContent = mode === 'all100' ? '百首読み切り!' : '競技終了!';
    els.rMode.textContent = MODE_LABELS[mode];
    els.rTime.textContent = formatDeci(deci);
    els.rMiss.textContent = missCount + '回';
    els.rPenalty.textContent = '+' + (penaltyMs / 1000).toFixed(1) + '秒';
    els.rKimarijiRow.style.display = mode === 'kimariji' ? '' : 'none';
    els.rKimariji.textContent = kimarijiCatches + '/' + queue.length + '首';
    els.rBest.style.display = isNewBest ? 'block' : 'none';

    if (mode === 'karuta10') {
      els.rNote.textContent = '';
      // かるた10首だけがランキング対象(1/10秒の整数・小さいほど上位)
      if (window.Leaderboard) {
        Leaderboard.show('hyakunin-typing', deci, { order: 'asc', format: 'time-deci' });
      }
    } else if (mode === 'choice') {
      els.rNote.textContent = '3択かるたはランキング登録対象外です(ランキングはPCキーボード入力のみ)';
    } else {
      els.rNote.textContent = 'このモードは練習用のためランキング登録はありません';
    }

    els.overlay.classList.add('active');
    updateStartButton();
    updateHud();
    phase = 'idle';
  }

  // ============================================
  // UIイベント
  // ============================================
  function updateStartButton() {
    if (mode === 'all100') {
      var progress = loadProgress();
      if (progress) {
        els.btnStart.textContent = 'つづきから(' + (progress.idx + 1) + '首目〜)';
        els.btnResetProgress.style.display = '';
        updateHud();
        return;
      }
    }
    els.btnStart.textContent = 'スタート';
    els.btnResetProgress.style.display = 'none';
    updateHud();
  }

  modeBtns.forEach(function (btn) {
    btn.addEventListener('click', function () {
      if (phase === 'playing') return;
      modeBtns.forEach(function (b) { b.classList.remove('active'); });
      btn.classList.add('active');
      mode = btn.getAttribute('data-mode');
      els.stageMsg.style.display = '';
      els.kamiText.textContent = '';
      els.poetName.textContent = '';
      els.shimoKana.textContent = '';
      els.shimoRomaji.textContent = '';
      updateStartButton();
    });
  });

  els.btnStart.addEventListener('click', function () {
    if (phase !== 'playing') startGame();
  });

  els.btnResetProgress.addEventListener('click', function () {
    if (phase === 'playing') return;
    clearProgress();
    updateStartButton();
  });

  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 = !muted;
    localStorage.setItem('hyakuninTypingMuted', muted ? '1' : '0');
    if (voiceEl) voiceEl.muted = muted;
    updateMuteButton();
  });

  // ============================================
  // 初期化
  // ============================================
  if (isTouch) {
    // タッチ端末: 3択かるた固定。モード選択を隠して案内を出す
    mode = 'choice';
    els.modeArea.style.display = 'none';
    els.touchNote.hidden = false;
    document.body.classList.add('touch-mode');
  }

  updateMuteButton();
  updateStartButton();
  updateHud();

})();

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

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

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

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

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

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

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

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

もっと学びたい人へ

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

ひなテックでは、こうしたゲームづくりを先生といっしょに楽しく学べる教室を開いています。「コードを読むだけじゃなく、ちゃんと作れるようになりたい」という人は、体験レッスンにぜひ来てみてください。