百人一首オンライン対戦かるたのソースコード全文 — JavaScript・HTML・CSS

▶ 百人一首オンライン対戦かるたで遊ぶ 📖 作り方を1から学ぶ

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

全国のプレイヤーと札を取り合うリアルタイム対戦ですが、WebSocketは使っていません。見どころは3つ。1秒ごとに状態を聞きに行くショートポーリング方式の対戦、サーバーの時計に合意して「開始から何ミリ秒で打ち終えたか」を競う通信遅延に左右されない公平な設計、そして経過時間から毎フレーム計算し直すカラオケ風の読み上げ表示と決まり字フラッシュです。

百人一首100首のデータとローマ字判定エンジンは、姉妹ゲーム「百人一首タイピング」と同じものを使っています。対戦の勝敗やレート計算はサーバー側(PHP)が行うため、このページのコードはクライアント(ブラウザ側)のすべてです。

百人一首オンライン対戦かるたは3つのファイルでできている

百人一首オンライン対戦かるたは、次のファイルが役割を分担して動いています。それぞれの中身は下で1つずつ解説します。

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

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

index.html — 4つの画面を持つ対戦場の骨組み

index.htmlは、ブラウザが最初に読み込むファイルです。このゲームはソロゲームと違って画面が切り替わるので、(1)ニックネーム登録、(2)ロビー(先取本数の選択・マッチング方法・週間/全期間ランキング)、(3)マッチング待機、(4)対戦ステージ、の4つのsectionを並べておき、JavaScriptがactiveクラスの付け替えで1つだけ表示します。

対戦ステージには、カラオケ歌詞になる読み札(karaoke-kamikaraoke-shimo)、縦書きの取り札(torifuda)、ローマ字ガイド、エモートバー、そして決まり字の瞬間に光るvignette(画面フチの発光レイヤー)が置かれています。

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

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="百人一首のオンライン対戦かるた!上の句がカラオケのように流れて読まれ、先に下の句をタイプした方が札を取る本格リアルタイム対戦。決まり字で画面が光る演出、レートと段位、週間ランキング、合言葉ルーム、コンピュータ対戦も。無料ブラウザゲーム。">
  <link rel="stylesheet" href="../../css/style.css">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Yuji+Syuku&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="style.css?v=20260815j">
</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>
      <span class="tag">学習</span>
    </div>

    <div class="game-container">

      <!-- ニックネーム登録 -->
      <section class="screen active" id="screen-register">
        <div class="register-box">
          <h2>歌会に参加する</h2>
          <p>対戦で使うニックネームを決めてね(12文字まで)</p>
          <div class="register-row">
            <input type="text" id="nick-input" maxlength="12" placeholder="ニックネーム" autocomplete="off">
            <button class="btn-primary" id="btn-register">参加する</button>
          </div>
          <p class="register-error" id="register-error"></p>
          <p class="register-note">ニックネームとレートはこのブラウザに保存されます。</p>
        </div>
      </section>

      <!-- ロビー(モード選択・ランキング) -->
      <section class="screen" id="screen-lobby">
        <div class="profile-bar">
          <span class="rank-badge tier-kyu" id="profile-rank">10級</span>
          <span class="profile-nick" id="profile-nick"></span>
          <span class="profile-rating" id="profile-rating">レート 1200</span>
          <span class="profile-record" id="profile-record">0勝0敗</span>
          <button class="link-btn" id="btn-rename" type="button">名前を変える</button>
          <button class="btn-mute" id="btn-mute" type="button" aria-label="サウンド切り替え">🔊</button>
        </div>

        <p class="touch-note" id="touch-note" hidden>
          スマホ・タブレットではタイピングの対人マッチングに参加できません。
          <strong>コンピュータと対戦(下の句3択かるた方式)</strong>で遊べます。
          対人戦はPCのキーボードで挑戦してね!
        </p>

        <div class="mode-area">
          <span class="mode-label">先取本数:</span>
          <div class="mode-buttons">
            <button class="mode-btn active" data-mode="10" type="button">10本先取<small>約10分</small></button>
            <button class="mode-btn" data-mode="20" type="button">20本先取<small>じっくり</small></button>
            <button class="mode-btn" data-mode="51" type="button">51本先取<small>百枚の過半数</small></button>
          </div>
        </div>

        <div class="match-menu">
          <div id="pc-match-menu">
            <button class="btn-primary match-big" id="btn-match-random" type="button">🎌 だれかと対戦</button>
            <div class="room-row">
              <input type="text" id="room-input" inputmode="numeric" pattern="\d{4}" maxlength="4" placeholder="数字4桁" autocomplete="off">
              <button class="btn-secondary" id="btn-match-room" type="button">🔑 合言葉ルームで対戦</button>
            </div>
          </div>
          <div class="bot-row">
            <span class="bot-label">🤖 コンピュータと対戦:</span>
            <button class="bot-btn" data-bot="easy" type="button">つくし<small>みならい</small></button>
            <button class="bot-btn" data-bot="kyu" type="button">かなた<small>二級</small></button>
            <button class="bot-btn" data-bot="normal" type="button">ひな子<small>五段</small></button>
            <button class="bot-btn" data-bot="dan7" type="button">あかね<small>七段</small></button>
            <button class="bot-btn" data-bot="hard" type="button">いつき<small>名人</small></button>
          </div>
        </div>

        <div class="cards-link-row">
          <button class="btn-secondary" id="btn-cards" type="button">📖 札を確認する(100枚一覧)</button>
        </div>

        <div class="ranking-box">
          <div class="ranking-tabs">
            <button class="rk-tab active" data-period="week" type="button">週間ランキング</button>
            <button class="rk-tab" data-period="all" type="button">全期間</button>
          </div>
          <ol class="ranking-list" id="ranking-list"></ol>
          <p class="ranking-note" id="ranking-note">勝つとレートと週間ポイントが増えるよ(勝利+10pt / 参加+2pt)</p>
        </div>
      </section>

      <!-- 札確認(100枚一覧・クリックで裏返し) -->
      <section class="screen" id="screen-cards">
        <div class="cards-head">
          <button class="btn-secondary" id="btn-cards-back" type="button">← ロビーへ戻る</button>
          <h2 class="cards-title">札確認</h2>
          <p class="cards-hint">札をクリック(タップ)すると裏返って<strong>下の句</strong>が見えるよ。<span class="cards-kim-note">赤い文字は決まり字</span><br><a class="cards-study-link" href="../../hyakunin/">📖 現代語訳・解説・朗詠つきの100首一覧はこちら →</a></p>
        </div>
        <div class="cards-tools">
          <button class="btn-secondary btn-small" id="btn-cards-flip-all" type="button">ぜんぶ裏返す</button>
          <button class="btn-secondary btn-small" id="btn-cards-reset" type="button">ぜんぶ表に戻す</button>
        </div>
        <div class="cards-grid" id="cards-grid"></div>
      </section>

      <!-- マッチング待機 -->
      <section class="screen" id="screen-matching">
        <div class="matching-box">
          <div class="matching-spinner" aria-hidden="true"></div>
          <div class="matching-mode" id="matching-mode">10本先取</div>
          <p id="matching-text">対戦相手を探しています…</p>
          <div class="bot-offer" id="bot-offer" hidden>
            <p>なかなか相手が見つからないみたい。<br>コンピュータと対戦しますか?</p>
            <button class="btn-primary" id="btn-offer-bot" type="button">🤖 ひな子(五段)と対戦する</button>
          </div>
          <button class="btn-secondary" id="btn-cancel-match" type="button">やめる</button>
        </div>
      </section>

      <!-- 対戦 -->
      <section class="screen" id="screen-battle">
        <div class="battle-stage" id="battle-stage">
          <div class="battle-header">
            <div class="bh-info">
              <span id="battle-mode-label">10本先取</span>
              <span id="battle-round">第1首</span>
            </div>
            <div class="bh-score">
              <span class="bh-side bh-me"><span class="bh-name" id="my-name">じぶん</span><b id="my-score">0</b></span>
              <span class="bh-dash">-</span>
              <span class="bh-side bh-opp"><b id="opp-score">0</b><span class="bh-name" id="opp-name">あいて</span></span>
            </div>
            <div class="bh-status"><span id="opp-status"></span></div>
          </div>

          <!-- 読み上げエリア(カラオケ歌詞) -->
          <div class="karaoke-card">
            <div class="karaoke-kami" id="karaoke-kami"></div>
            <div class="karaoke-shimo" id="karaoke-shimo"></div>
            <div class="battle-poet" id="battle-poet"></div>
          </div>

          <div class="round-msg" id="round-msg">相手と札の準備をしています…</div>

          <!-- 取り札(縦書き・下の句のみ) -->
          <div class="fuda-wrap">
            <div class="emote-bubble opp-bubble" id="opp-emote" hidden></div>
            <div class="torifuda hidden" id="torifuda">
              <div class="fuda-text" id="fuda-text"></div>
            </div>
            <div class="emote-bubble my-bubble" id="my-emote" hidden></div>
          </div>

          <!-- ローマ字ガイド(PC) / 3択(スマホ) -->
          <div class="romaji-guide" id="romaji-guide"></div>
          <div class="choice-area" id="choice-area"></div>

          <!-- エモートバー -->
          <div class="emote-bar" id="emote-bar"></div>

          <div class="battle-actions">
            <button class="btn-secondary btn-leave" id="btn-leave" type="button">退出する</button>
          </div>

          <div class="pop-layer" id="pop-layer"></div>
          <div class="vignette" id="vignette" aria-hidden="true"></div>
        </div>
      </section>

      <!-- 結果オーバーレイ -->
      <div class="result-overlay" id="result-overlay">
        <div class="result-content">
          <h2 id="result-title">勝利!</h2>
          <div class="result-score" id="result-score">10 - 8</div>
          <div class="result-delta delta-up" id="result-delta">レート変動 +16 ↗</div>
          <div class="result-rank-row">
            <span class="rank-badge big tier-kyu" id="result-rank">10級</span>
            <span class="result-rating" id="result-rating">レート 1216</span>
          </div>
          <p class="result-note" id="result-note"></p>
          <div class="result-buttons">
            <button class="btn-primary" id="btn-rematch" type="button">もう一度対戦</button>
            <button class="btn-secondary" id="btn-to-lobby" type="button">ロビーへ戻る</button>
          </div>
        </div>
      </div>
    </div>

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

    <div class="game-instructions">
      <h3>遊び方</h3>
      <ul>
        <li>ニックネームを登録して、<strong>10本 / 20本 / 51本先取</strong>から対戦モードを選ぼう</li>
        <li><strong>だれかと対戦</strong>で全国のプレイヤーとマッチング。<strong>合言葉ルーム</strong>(4桁の数字)なら友達と対戦できるよ</li>
        <li>上の句が<strong>カラオケのように流れて</strong>読まれ、続けて下の句も読まれます。<strong>読み始めと同時に入力OK</strong>!</li>
        <li>入力するのは<strong>下の句のローマ字だけ</strong>。先に打ち終えた方がその札を取ります</li>
        <li>読みが<strong>決まり字</strong>(その歌が確定する文字)に達した瞬間、画面が金色に光るよ</li>
        <li>勝つと<strong>レート</strong>が上がって段位(10級〜名人)が昇格。週間/全期間ランキングで全国順位もわかる</li>
        <li>相手がいない時間帯は<strong>5人のコンピュータ対戦相手</strong>(つくし・かなた・ひな子・あかね・いつき/みならい〜名人)といつでも対戦できます</li>
        <li>スマホ・タブレットはコンピュータ戦のみ(<strong>下の句3択かるた方式</strong>)。対人戦はPCでどうぞ</li>
        <li>読み手の<strong>朗詠つき</strong>。カラオケ表示と決まり字の光は朗詠の実際の発声に同期します(音声: Synthesizer V AI 重音テト。🔊ボタンで消せます)</li>
        <li>本物のかるたと同じく、<strong>取り札は上の句を読み終えると現れ、どちらかが取ると読みは止まります</strong>(暗記していれば表示前から打ってOK)。歴史的仮名遣い(てふ=tefu 等)や聞こえたままの「わ」でも打てます</li>
      </ul>
    </div>

    <div class="game-promo">
      <h3>このゲーム、自分でも作れるよ!</h3>
      <p>
        オンライン対戦かるたは、PHPとデータベースを使った<br>
        「ショートポーリング」方式のリアルタイム対戦です。<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>
  <!-- ランキングを持たず leaderboard.js を読まないゲームなので、プレイ履歴計測を直接読み込む -->
  <script src="../../js/play-track.js?v=20260725" async></script>
  <script src="../../assets/voice/hyakunin/yomi.js?v=20260815b"></script>
  <script src="game.js?v=20260815o"></script>
</body>
</html>

style.css — 縦書きの取り札と金色のフラッシュ

style.cssは見た目を作っているファイルです。畳はrepeating-linear-gradientのしま模様、取り札はwriting-mode: vertical-rlで本物と同じ縦書きの下の句にして、明朝体(serif)のクリーム地で描いています。入力が進むと札の文字が.f-doneクラスで朱色に染まっていきます。

いちばんの見せ場は決まり字フラッシュ。.vignetteは画面全体を覆う透明なレイヤーで、内側に金色のbox-shadow(inset)を持っています。ふだんはopacity: 0で見えず、flash-activeクラスが付いた0.4秒だけ@keyframesでふわっと光ります。勝ち負けの演出は、取り札が自分の陣へ飛ぶfly-winと相手陣へ飛ぶfly-loseのアニメーションです。

style.css
/* ============================================
   百人一首 オンライン対戦かるた - 固有スタイル
   ============================================ */

/* 画面切り替え */
.screen { display: none; }
.screen.active { display: block; }

/* ============================================
   登録画面
   ============================================ */
.register-box {
  background: linear-gradient(180deg, #F7EDD5 0%, #F0E2C0 100%);
  border: 2px solid #C9B78C;
  border-radius: 12px;
  max-width: 480px;
  margin: 1.5rem auto;
  padding: 1.5rem 1.25rem;
  text-align: center;
}

.register-box h2 {
  color: #8E3B46;
  font-size: 1.3rem;
  margin-bottom: 0.5rem;
}

.register-box p {
  color: #7A6845;
  font-size: 0.9rem;
  margin-bottom: 0.9rem;
}

.register-row {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
  flex-wrap: wrap;
}

#nick-input {
  border: 2px solid #C9B78C;
  border-radius: 8px;
  padding: 0.6rem 0.8rem;
  font-size: 1rem;
  font-family: inherit;
  width: 220px;
  max-width: 100%;
}

#nick-input:focus {
  outline: none;
  border-color: #8E3B46;
}

.register-error {
  color: #C0392B;
  font-weight: 700;
  min-height: 1.2em;
  margin-top: 0.6rem;
}

.register-note {
  font-size: 0.75rem !important;
  color: #A6946B !important;
  margin-bottom: 0 !important;
}

/* ============================================
   ロビー
   ============================================ */
.profile-bar {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
  background: #FAF8F0;
  border: 1px solid #E6D9B8;
  border-radius: 10px;
  padding: 0.6rem 0.9rem;
  margin-bottom: 1rem;
}

.profile-nick {
  font-weight: 700;
  color: #5B4A2F;
}

.profile-rating,
.profile-record {
  font-size: 0.85rem;
  color: #7A6845;
}

.link-btn {
  background: none;
  border: none;
  color: #8E3B46;
  font-size: 0.8rem;
  cursor: pointer;
  text-decoration: underline;
  font-family: inherit;
  margin-left: auto;
}

/* 段位バッジ */
.rank-badge {
  display: inline-block;
  border-radius: 100px;
  padding: 0.15rem 0.6rem;
  font-size: 0.75rem;
  font-weight: 800;
  color: #fff;
  letter-spacing: 0.05em;
  white-space: nowrap;
}

.rank-badge.tier-kyu { background: linear-gradient(135deg, #7B8D6E, #5F7355); }
.rank-badge.tier-dan { background: linear-gradient(135deg, #8E3B46, #C0392B); }
.rank-badge.tier-meijin {
  background: linear-gradient(135deg, #B8860B, #E6B422);
  box-shadow: 0 0 8px rgba(230, 180, 34, 0.6);
}

.rank-badge.big {
  font-size: 1.05rem;
  padding: 0.3rem 1rem;
}

/* タッチ端末向けの案内 */
.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;
}

/* モード選択 */
.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;
}

/* 対戦メニュー */
.match-menu {
  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;
  margin-bottom: 1.25rem;
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
}

.match-big {
  display: block;
  width: 100%;
  max-width: 420px;
  margin: 0 auto;
  font-size: 1.15rem;
  padding: 0.9rem 1rem;
}

.room-row {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
  flex-wrap: wrap;
  margin-top: 0.75rem;
}

#room-input {
  border: 2px solid #C9B78C;
  border-radius: 8px;
  padding: 0.5rem 0.7rem;
  font-size: 1.05rem;
  font-family: inherit;
  width: 130px;
  text-align: center;
  letter-spacing: 0.3em;
}

#room-input:focus {
  outline: none;
  border-color: #F1C40F;
}

.bot-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  flex-wrap: wrap;
  border-top: 1px dashed rgba(255, 255, 255, 0.25);
  padding-top: 0.9rem;
}

.bot-label {
  color: #E8DFC8;
  font-size: 0.85rem;
}

.bot-btn {
  background: linear-gradient(180deg, #F7EDD5 0%, #EEDFBB 100%);
  border: 2px solid #C9B78C;
  border-radius: 10px;
  padding: 0.4rem 0.9rem;
  font-size: 0.9rem;
  font-weight: 700;
  color: #3B2A1A;
  cursor: pointer;
  font-family: inherit;
  line-height: 1.25;
  transition: transform 0.1s;
}

.bot-btn small {
  display: block;
  font-size: 0.68rem;
  font-weight: 600;
  color: #8E3B46;
}

.bot-btn:hover { transform: translateY(-2px); }

/* ランキング */
.cards-link-row {
  text-align: center;
  margin: 0.2rem 0 1rem;
}

.ranking-box {
  background: #FAF8F0;
  border: 1px solid #E6D9B8;
  border-radius: 10px;
  padding: 0.9rem;
}

/* ============================================
   札確認(100枚一覧・クリックで裏返し)
   ============================================ */
.cards-head {
  display: flex;
  align-items: center;
  gap: 0.8rem;
  flex-wrap: wrap;
  margin-bottom: 0.5rem;
}

.cards-title {
  margin: 0;
  font-size: 1.2rem;
  color: #5B4A2F;
}

.cards-hint {
  margin: 0;
  font-size: 0.85rem;
  color: #776E65;
}

.cards-kim-note { color: #C0392B; font-weight: 700; }

.cards-tools {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 0.7rem;
}

.btn-small {
  padding: 0.35rem 0.9rem;
  font-size: 0.82rem;
}

.cards-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);   /* 5列×20行 */
  gap: 0.35rem;
}

/* ミニ札: 3D裏返し。表=上の句(縦書き)、裏=下の句+歌人 */
.mini-fuda {
  position: relative;
  /* Safariはbuttonのaspect-ratioを無視することがあるため、
     padding-bottom(幅基準%)で 2:2.75 の高さを作る */
  height: 0;
  padding: 0 0 137.5% 0;
  appearance: none;
  -webkit-appearance: none;
  background: none;
  border: none;
  cursor: pointer;
  perspective: 600px;
  font-family: inherit;
}

.mini-fuda .mf-inner {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transition: transform 0.45s cubic-bezier(0.2, 0.7, 0.3, 1);
}

.mini-fuda.flipped .mf-inner { transform: rotateY(180deg); }

/* filter は 3D を平面化して backface-visibility を壊すので box-shadow で光らせる */
.mini-fuda:hover .mf-face { box-shadow: 0 0 10px rgba(230, 180, 34, 0.65); }

.mf-face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  border-radius: 6px;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}

.mf-front {
  background: #FDFDF9;
  border: 3px solid #2F6B3C;
  /* 表は5・7・5の3行固定なので、天(上端)を揃える。中央揃えだと行の長さの差で頭がガタつく */
  align-items: flex-start;
}

.mf-front .mf-text { margin-top: 0.95rem; /* 右上の歌番号を避ける */ }

.mf-back {
  transform: rotateY(180deg);
  background: #FDFDF9;
  border: 3px solid #2F6B3C;
}

.mf-text {
  writing-mode: vertical-rl;
  white-space: nowrap;   /* 改行はJSの<br>のみ(表=句切れ3行・裏=5文字3行) */
  /* 親が<button>で text-align:center を継承し、縦書きでは行が縦センタリングされて
     頭がガタつく。行の起点を天(上端)に固定する */
  text-align: start;
  /* /hyakunin/ の札と同じ毛筆書体・サイズ感 */
  font-family: "Yuji Syuku", "Hiragino Mincho ProN", "Yu Mincho", "YuMincho", "Noto Serif JP", serif;
  font-size: 1.15rem;
  font-weight: 400;
  letter-spacing: 0.04em;
  line-height: 1.45;
  color: #1F1F1F;
}

.mf-kim { color: #C0392B; }

.mf-back .mf-text { color: #1F1F1F; font-size: 1.35rem; }

.mf-no {
  position: absolute;
  top: 3px;
  right: 5px;
  font-size: 0.62rem;
  font-weight: 700;
  color: #B49A6A;
}

.mf-poet {
  position: absolute;
  left: 4px;
  bottom: 4px;
  top: 4px;
  writing-mode: vertical-rl;
  white-space: nowrap;      /* 長い名前で列が折り返して本文に被らないように */
  overflow: hidden;
  display: flex;
  align-items: flex-end;
  font-family: "Hiragino Mincho ProN", "Yu Mincho", "YuMincho", "Noto Serif JP", serif;
  font-size: 0.58rem;
  color: #9A8557;
  overflow: hidden;
}

.ranking-tabs {
  display: flex;
  gap: 0.4rem;
  margin-bottom: 0.6rem;
}

.rk-tab {
  background: #EEE4DA;
  border: none;
  border-radius: 100px;
  padding: 0.35rem 1rem;
  font-size: 0.85rem;
  font-weight: 700;
  color: #776E65;
  cursor: pointer;
  font-family: inherit;
}

.rk-tab.active {
  background: #8E3B46;
  color: #fff;
}

.ranking-list {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 320px;
  overflow-y: auto;
}

.rk-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.35rem 0.3rem;
  border-bottom: 1px dashed #E6D9B8;
  font-size: 0.88rem;
}

.rk-pos {
  width: 2em;
  text-align: right;
  font-weight: 800;
  color: #B8860B;
  flex-shrink: 0;
}

.rk-name {
  font-weight: 700;
  color: #5B4A2F;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  flex: 1;
}

.rk-main {
  font-weight: 700;
  color: #8E3B46;
  white-space: nowrap;
}

.rk-wins {
  font-size: 0.75rem;
  color: #A6946B;
  white-space: nowrap;
}

.rk-loading {
  color: #A6946B;
  font-size: 0.85rem;
  padding: 0.5rem;
}

.ranking-note {
  font-size: 0.72rem;
  color: #A6946B;
  margin: 0.5rem 0 0;
}

/* ============================================
   マッチング待機
   ============================================ */
.matching-box {
  text-align: center;
  padding: 2.5rem 1rem;
}

.matching-spinner {
  width: 46px;
  height: 46px;
  border: 5px solid #EEE4DA;
  border-top-color: #8E3B46;
  border-radius: 50%;
  margin: 0 auto 1rem;
  animation: spin 0.9s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

.matching-mode {
  display: inline-block;
  background: #8E3B46;
  color: #fff;
  border-radius: 100px;
  padding: 0.25rem 1rem;
  font-size: 0.85rem;
  font-weight: 700;
  margin-bottom: 0.6rem;
}

#matching-text {
  color: #776E65;
  font-weight: 600;
  margin-bottom: 1.2rem;
}

.bot-offer {
  background: #FDF6E3;
  border: 1px solid #E6D9B8;
  border-radius: 10px;
  padding: 0.9rem;
  max-width: 380px;
  margin: 0 auto 1.2rem;
}

.bot-offer p {
  font-size: 0.9rem;
  color: #776E65;
  margin-bottom: 0.7rem;
  line-height: 1.6;
}

/* ============================================
   対戦ステージ
   ============================================ */
.battle-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: 0.9rem 0.9rem 1.1rem;
  overflow: hidden;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}

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

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

/* ヘッダ(モード・スコア・相手の状態) */
.battle-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-bottom: 0.7rem;
}

.bh-info {
  display: flex;
  gap: 0.6rem;
  font-size: 0.78rem;
  color: #E8DFC8;
  font-weight: 600;
}

.bh-score {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  color: #fff;
}

.bh-side {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

.bh-name {
  font-size: 0.8rem;
  color: #E8DFC8;
  max-width: 9em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.bh-score b {
  font-size: 1.5rem;
  font-weight: 800;
  color: #F1C40F;
}

.bh-dash { color: #E8DFC8; }

.bh-status {
  min-width: 7em;
  text-align: right;
  font-size: 0.8rem;
  color: #FFD54F;
  font-weight: 700;
  min-height: 1.2em;
}

#opp-status.opp-took {
  animation: opp-took-blink 0.6s ease infinite alternate;
}

@keyframes opp-took-blink {
  0% { opacity: 0.6; }
  100% { opacity: 1; }
}

/* 読み上げエリア(カラオケ) */
.karaoke-card {
  position: relative;
  background:
    url("yomifuda-bg.svg") center / 100% 100% no-repeat,
    linear-gradient(180deg, #F7EDD5 0%, #F0E2C0 100%);
  border: 2px solid #B9A473;
  border-radius: 10px;
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
  max-width: 620px;
  margin: 0 auto 0.7rem;
  padding: 0.8rem 1rem 0.6rem;
  text-align: center;
  min-height: 5.6rem;
}

/* 読み札風の内枠(細線1本) */
.karaoke-card::before {
  content: "";
  position: absolute;
  inset: 4px;
  border: 1px solid rgba(150, 110, 55, 0.4);
  border-radius: 7px;
  pointer-events: none;
}

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

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

.karaoke-shimo { font-size: 1.15rem; }

/* カラオケの文字: 読み上げ前は非表示、読まれた瞬間に出現する
   (spanは最初から置いてあるため行の折返しは変わらず、文字だけが増えていく) */
.kchar {
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 0.12s ease-out, transform 0.12s ease-out;
  border-bottom: 3px solid transparent;
}

.kchar.sung {
  opacity: 1;
  transform: translateY(0);
  color: #3B2A1A;
}

.karaoke-shimo .kchar.sung {
  color: #8E3B46;
}

/* 決まり字マーカー: 出現した文字にだけ下線が付く(先読み防止) */
.kchar.kimariji-mark.sung {
  border-bottom-color: #E6B422;
  color: #8E3B46;
  text-shadow: 0 0 8px rgba(230, 180, 34, 0.7);
}

.battle-poet {
  margin-top: 0.3rem;
  font-size: 0.8rem;
  color: #7A6845;
  min-height: 1.1rem;
}

/* ラウンド間メッセージ */
.round-msg {
  text-align: center;
  color: #E8DFC8;
  font-size: 1.05rem;
  font-weight: 700;
  margin: 0.4rem 0;
  min-height: 1.5em;
}

/* 取り札(縦書き・下の句のみ・クリーム地・serif) */
.fuda-wrap {
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 0.75rem;
  margin: 0.5rem 0;
  min-height: 15rem;
}

/* 実物の標準かるたに寄せた「白地に緑枠」(/hyakunin/ の札と同デザイン) */
.torifuda {
  position: relative;
  background: #FDFDF9;
  border: 4px solid #2F6B3C;
  border-radius: 8px;
  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.35);
  padding: 0.6rem 0.9rem;
  min-height: 14.5rem;
  display: flex;
  align-items: center;        /* 5文字3行の塊を札の中央に置く */
  justify-content: center;
  transition: opacity 0.3s;
}

.torifuda.hidden { opacity: 0; }

.torifuda.sparkle {
  animation: fuda-sparkle 0.45s ease;
}

/* box-shadow を上書きすると内側の陰影が消えるので filter で光らせる */
@keyframes fuda-sparkle {
  0%, 100% { filter: none; }
  50% { filter: drop-shadow(0 0 16px rgba(241, 196, 15, 0.9)); }
}

/* 勝ち: 自分の陣(手前)へ飛ぶ / 負け: 相手陣(奥)へ飛ぶ */
.torifuda.fly-win {
  animation: fly-win 0.9s ease forwards;
}

.torifuda.fly-lose {
  animation: fly-lose 0.9s ease forwards;
}

@keyframes fly-win {
  0% { transform: translateY(0) rotate(0) scale(1); opacity: 1; }
  100% { transform: translateY(130px) rotate(-9deg) scale(1.15); opacity: 0; }
}

@keyframes fly-lose {
  0% { transform: translateY(0) rotate(0) scale(1); opacity: 1; }
  100% { transform: translateY(-130px) rotate(9deg) scale(0.7); opacity: 0; }
}

/* 縦書きの下の句。入力進捗で朱色に染まる */
.fuda-text {
  writing-mode: vertical-rl;
  /* 改行はJS側が5文字組(<br>)で入れる。自動折返しはさせない */
  white-space: nowrap;
  /* 札確認・/hyakunin/ と同じ毛筆書体。枠いっぱいの大きな字 */
  font-family: "Yuji Syuku", "Hiragino Mincho ProN", "Yu Mincho", "YuMincho", "Noto Serif JP", serif;
  font-size: 1.9rem;
  font-weight: 400;
  letter-spacing: 0.06em;
  line-height: 1.5;
  color: #1F1F1F;
}

.fuda-text .f-done { color: #C0392B; }
.fuda-text .f-cur {
  color: #3B2A1A;
  text-decoration: underline;
  text-decoration-color: #C0392B;
  text-decoration-thickness: 3px;
  text-underline-offset: 4px;
}
.fuda-text .f-rest { color: #3B2A1A; }

/* ローマ字ガイド */
.romaji-guide {
  text-align: center;
  font-family: 'Courier New', 'SF Mono', 'Consolas', monospace;
  font-size: 1.2rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  min-height: 1.7rem;
  word-break: break-all;
  margin-bottom: 0.4rem;
}

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

/* 3択かるた(スマホのボット戦) */
.choice-area {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
  max-width: 480px;
  margin: 0 auto 0.5rem;
}

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

/* スマホではローマ字ガイドと取り札の f-cur 下線は不要 */
.touch-mode .romaji-guide { display: none; }

/* エモート */
.emote-bar {
  display: flex;
  justify-content: center;
  gap: 0.4rem;
  margin: 0.4rem 0 0.6rem;
}

.emote-btn {
  background: rgba(255, 255, 255, 0.12);
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 50%;
  width: 42px;
  height: 42px;
  font-size: 1.25rem;
  cursor: pointer;
  transition: transform 0.1s, background 0.2s;
  -webkit-tap-highlight-color: transparent;
}

.emote-btn:hover {
  background: rgba(255, 255, 255, 0.25);
  transform: translateY(-2px);
}

.emote-bubble {
  position: absolute;
  background: #fff;
  border-radius: 14px;
  padding: 0.3rem 0.6rem;
  font-size: 1.6rem;
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
  z-index: 6;
}

.emote-bubble::after {
  content: '';
  position: absolute;
  border: 7px solid transparent;
}

/* 相手の吹き出しは札の左上(相手陣側) */
.opp-bubble { top: 0; left: 12%; }
.opp-bubble::after {
  bottom: -13px;
  left: 14px;
  border-top-color: #fff;
}

/* 自分の吹き出しは札の右下(自分の陣側) */
.my-bubble { bottom: 0; right: 12%; }
.my-bubble::after {
  top: -13px;
  right: 14px;
  border-bottom-color: #fff;
}

.emote-bubble.bubble-pop {
  animation: bubble-pop 0.25s ease;
}

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

.battle-actions {
  display: flex;
  justify-content: center;
}

.btn-leave { font-size: 0.85rem; }

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

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

.score-pop.pop-miss { color: #FF8A80; }
.score-pop.pop-take { color: #FFD54F; font-size: 1.6rem; }
.score-pop.pop-even { color: #E8DFC8; }

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

/* 決まり字フラッシュ: 画面フチが金色に光る vignette glow(0.4秒) */
.vignette {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 10;
  opacity: 0;
  border-radius: 8px;
  box-shadow: inset 0 0 60px 18px rgba(241, 196, 15, 0.9),
              inset 0 0 120px 40px rgba(230, 180, 34, 0.45);
}

.vignette.flash-active {
  animation: vignette-flash 0.4s ease-out;
}

@keyframes vignette-flash {
  0% { opacity: 0; }
  25% { opacity: 1; }
  100% { opacity: 0; }
}

/* トースト通知 */
.toast {
  position: fixed;
  left: 50%;
  bottom: 12%;
  transform: translateX(-50%);
  background: rgba(59, 42, 26, 0.92);
  color: #fff;
  border-radius: 100px;
  padding: 0.6rem 1.3rem;
  font-size: 0.9rem;
  font-weight: 600;
  z-index: 300;
  animation: toast-in 0.25s ease;
  max-width: 90vw;
  text-align: center;
}

@keyframes toast-in {
  0% { opacity: 0; transform: translate(-50%, 10px); }
  100% { opacity: 1; transform: translate(-50%, 0); }
}

/* ミュートボタン */
.btn-mute {
  background: #fff;
  border: 2px solid #EEE4DA;
  border-radius: 50%;
  width: 38px;
  height: 38px;
  font-size: 1rem;
  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.7rem;
  color: #8E3B46;
  margin-bottom: 0.4rem;
}

.result-score {
  font-size: 2rem;
  font-weight: 800;
  color: #776E65;
  margin-bottom: 0.6rem;
}

.result-delta {
  font-size: 1.15rem;
  font-weight: 800;
  margin-bottom: 0.8rem;
}

.result-delta.delta-up { color: #27AE60; }
.result-delta.delta-down { color: #C0392B; }
.result-delta.delta-flat { color: #999; }

.result-rank-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  margin-bottom: 0.8rem;
}

.result-rating {
  font-weight: 700;
  color: #776E65;
}

.result-note {
  font-size: 0.8rem;
  color: #999;
  min-height: 1.2em;
  margin-bottom: 0.8rem;
}

.result-buttons {
  display: flex;
  justify-content: center;
  gap: 0.6rem;
  flex-wrap: wrap;
}

/* レスポンシブ */
@media (min-width: 600px) {
  .karaoke-kami { font-size: 1.55rem; }
  .karaoke-shimo { font-size: 1.3rem; }
  .fuda-text { font-size: 1.5rem; }
}

@media (max-width: 768px) {
  .cards-grid { grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); }
  .mf-text { font-size: 1rem; }
  .mf-back .mf-text { font-size: 1.2rem; }
  .mf-front .mf-text { margin-top: 0.8rem; }
}

@media (max-width: 420px) {
  .karaoke-kami { font-size: 1.1rem; }
  .karaoke-shimo { font-size: 1rem; }
  .fuda-text { font-size: 1.4rem; }
  .torifuda { min-height: 12rem; padding: 0.8rem 0.6rem; }
  .fuda-wrap { min-height: 12.5rem; }
  .bh-score b { font-size: 1.2rem; }
  .bh-name { max-width: 5.5em; }
  .mode-btn { padding: 0.35rem 0.7rem; font-size: 0.8rem; }
  .emote-btn { width: 38px; height: 38px; font-size: 1.1rem; }
  .romaji-guide { font-size: 1rem; }
  .cards-grid { grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 0.3rem; }
  .mf-text { font-size: 0.95rem; }
}

game.js — ポーリング対戦・時計合わせ・カラオケの頭脳

game.jsがこのゲームの心臓部です。前半の100首データ(POEMS)・ローマ字判定エンジン・決まり字の自動計算(computeKimariji())は百人一首タイピングからの流用なので、解説はそちらのソースページに譲ります。

対戦の要は時計合わせです。syncClock()がサーバーに時刻を3回聞き、「サーバー時刻 − 往復の中間点」の中央値を自分の時計とのズレ(offset)として採用します。ラウンド開始時刻はサーバー時計の絶対時刻で届くので、両プレイヤーの画面で同じ瞬間に読み上げが始まり、打ち終えた所要ミリ秒をaction=submitで報告して小さい方が札を取ります。通信の速さは勝敗に影響しません。

カラオケ表示はkaraokeCharsAt()が担当。「経過ミリ秒 ÷ 380ms/字」で灯っている文字数を毎フレーム計算し直すだけなので、タイマーのズレが積み重なりません。決まり字の最終文字が灯る時刻も(決まり字の長さ−1)×380msと前もって計算でき、その瞬間にvignetteを光らせます。状態の取得はpollState()のショートポーリングで、待機中・相手待ちは1秒間隔、入力中は3秒間隔と、場面に応じて頻度を変えてサーバーにやさしくしています。レートから10級〜名人を求めるrankOf()のような純粋な関数は、Node.jsからも読み込めるようにmodule.exportsで公開して自動テストの対象にしています。

game.js
// ============================================
// 百人一首 オンライン対戦かるた ロジック
// ============================================
//
// PHP+ショートポーリング方式のリアルタイム対戦かるた(docs/hyakunin-battle-design.md)。
// - ラウンド開始時刻はサーバー時計で合意(action=time を3回サンプリングして offset の中央値)
// - 上の句→下の句をカラオケのように 380ms/字 で1文字ずつ出現させながら読み上げ(Web Speech API 併用)
// - 入力は読み始め(T_r)から可能。先に下の句を打ち終えた方が勝ち
// - 決まり字の最終文字に達した瞬間、画面フチが金色に光る
// - ポーリングは待機中・相手待ち・ラウンド間のみ1秒、カラオケ/入力中は3秒に1回
//
// 100首データ・ローマ字判定エンジン・決まり字計算は games/hyakunin-typing/game.js から
// そのままコピーして流用(ゲームは自己完結が方針)。

(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/hyakunin-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';

  // ============================================
  // ローマ字エンジン(hyakunin-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) {
    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];
  }

  // かな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];
  }

  // ============================================
  // 決まり字の計算(最短一意接頭辞・hyakunin-typing から流用)
  // ============================================
  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;
  }

  // ============================================
  // 対戦かるた固有の純粋ロジック(vitest 対象)
  // ============================================

  var CHAR_MS = 380;   // カラオケの1文字あたりの時間(ふつう固定・両者同一)
  var GAP_MS = 600;    // 上の句と下の句の間

  // カラオケの時間割。i 文字目(0始まり)は開始から i*CHAR_MS で灯る。
  // kamiEnd = 上の句を読み終える時刻 / shimoStart = 下の句1文字目が灯る時刻
  function karaokeTimeline(kamiLen, shimoLen, charMs, gapMs) {
    var c = charMs || CHAR_MS;
    var g = (gapMs === undefined || gapMs === null) ? GAP_MS : gapMs;
    var kamiEnd = kamiLen * c;
    var shimoStart = kamiEnd + g;
    return {
      kamiEnd: kamiEnd,
      shimoStart: shimoStart,
      total: shimoStart + shimoLen * c
    };
  }

  // ラウンド開始からの経過 t(ms) 時点で灯っている文字数
  function karaokeCharsAt(t, kamiLen, shimoLen, charMs, gapMs) {
    var c = charMs || CHAR_MS;
    var tl = karaokeTimeline(kamiLen, shimoLen, charMs, gapMs);
    var kami = t < 0 ? 0 : Math.min(Math.floor(t / c) + 1, kamiLen);
    var shimo = t < tl.shimoStart ? 0 : Math.min(Math.floor((t - tl.shimoStart) / c) + 1, shimoLen);
    return { kami: kami, shimo: shimo };
  }

  // 決まり字フラッシュの時刻: カラオケが決まり字の最終文字に達した瞬間
  function kimarijiFlashMs(kimarijiLen, charMs) {
    return (kimarijiLen - 1) * (charMs || CHAR_MS);
  }

  // 段位マップ: 10級(〜1099) → 9級〜1級(1100〜1599・50刻み) → 初段(1600)〜八段 → 名人(2400+)
  var DAN_NAMES = ['初段', '二段', '三段', '四段', '五段', '六段', '七段', '八段'];
  function rankOf(rating) {
    if (rating < 1100) return '10級';
    if (rating < 1600) {
      var kyu = 9 - Math.floor((rating - 1100) / 50);
      if (kyu < 1) kyu = 1; // 1級は1500台まるごと
      return kyu + '級';
    }
    if (rating < 2400) return DAN_NAMES[Math.floor((rating - 1600) / 100)];
    return '名人';
  }

  // バッジの色分け用
  function rankTier(rating) {
    if (rating < 1600) return 'kyu';
    if (rating < 2400) return 'dan';
    return 'meijin';
  }

  // 時計合わせ: offset サンプルの中央値(3回サンプリング想定・偶数個は中央2つの平均)
  function medianOffset(samples) {
    var a = samples.slice().sort(function (x, y) { return x - y; });
    var mid = Math.floor(a.length / 2);
    if (a.length % 2 === 1) return a[mid];
    return (a[mid - 1] + a[mid]) / 2;
  }

  // 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,
      CHAR_MS: CHAR_MS,
      GAP_MS: GAP_MS,
      karaokeTimeline: karaokeTimeline,
      karaokeCharsAt: karaokeCharsAt,
      kimarijiFlashMs: kimarijiFlashMs,
      rankOf: rankOf,
      rankTier: rankTier,
      medianOffset: medianOffset
    };
    return;
  }

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

  var API = '../../api/battle.php';
  var KIMARIJI = computeKimariji(POEMS);
  var EMOTES = ['👏', '😆', '🔥', '🙏', '⚡', '🎉'];  // emoteId 1〜6
  var BOT_NAMES = {
    easy: 'つくし(みならい)', kyu: 'かなた(二級)', normal: 'ひな子(五段)',
    dan7: 'あかね(七段)', hard: 'いつき(名人)'
  };
  var SPEECH_RATE = 0.65;          // 380ms/字のカラオケに概ね合わせたゆっくりめ(朗詠mp3が使えない時の予備)

  // ---- 朗詠音声(Synthesizer V 重音テトで生成したmp3)----
  // タイミング実測データは assets/voice/hyakunin/yomi.js。無い環境では従来の
  // 一定速カラオケ+Web Speech にフォールバックする
  var VOICE_BASE = '../../assets/voice/hyakunin/';
  var YOMI = window.HYAKU_YOMI || null;
  var voiceEl = null;

  // 朗詠の実測タイムライン。1文字ごとの発声時刻でカラオケを灯す
  function audioTimeline(no) {
    var y = YOMI && YOMI[no];
    if (!y) return null;
    return { audio: true, kamiTimes: y.k, shimoTimes: y.s, kamiEnd: y.ke, shimoStart: y.s[0], total: y.e };
  }

  // t(ms)時点で灯っている文字数(実測タイムライン用)
  function audioCharsAt(t, tl) {
    var k = 0, s = 0;
    while (k < tl.kamiTimes.length && t >= tl.kamiTimes[k]) k++;
    while (s < tl.shimoTimes.length && t >= tl.shimoTimes[s]) s++;
    return { kami: k, shimo: s };
  }

  function prepareVoice(no) {
    disposeVoice();
    if (!audioTimeline(no)) return;
    voiceEl = new Audio(VOICE_BASE + ('00' + no).slice(-3) + '.mp3');
    voiceEl.preload = 'auto';
    voiceEl.muted = muted;
    voiceEl.onerror = function () { voiceEl = null; };  // 読み込み失敗 → Web Speechへ
  }

  function playVoice(tMs) {
    if (!voiceEl) return false;
    try {
      if (tMs > 200) voiceEl.currentTime = tMs / 1000;  // 途中復帰時は追いかける
      var p = voiceEl.play();
      if (p && p.catch) p.catch(function () { /* 自動再生拒否などは無音で続行 */ });
      return true;
    } catch (e) { return false; }
  }

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

  // 下の句にかかわる表示(カラオケ下段・取り札・ローマ字・3択)の隠し/出し。
  // 本物のかるたと同じく、上の句を読んでいる間は取り札が見えない
  function setShimoHidden(hidden) {
    var v = hidden ? 'hidden' : '';
    els.karaokeShimo.style.visibility = v;
    els.torifuda.style.visibility = v;
    els.romajiGuide.style.visibility = v;
    els.choiceArea.style.visibility = v;
  }

  // どちらかが取った(または自分が提出した)ら読みを止め、カラオケもそこで凍結する
  function stopReading() {
    if (battle && battle.freezeT == null && battle.roundStart != null) {
      battle.freezeT = serverNow() - battle.roundStart;
    }
    stopSpeech();
  }
  var CHOICE_PENALTY_MS = 1000;    // 3択のお手つき1回 = +1秒
  var BOT_OFFER_MS = 15000;        // ランダム待機がこの時間続いたらボット提案
  var RACE_GRACE_MS = 15000;       // 読み上げ終了後、この時間で強制締切(設計書どおり)

  var isTouch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;

  // ---- プレイヤー情報(localStorage) ----
  var TOKEN_KEY = 'hyakuninBattleToken';
  var NICK_KEY = 'hyakuninBattleNick';

  function genToken() {
    var bytes = new Uint8Array(16);
    crypto.getRandomValues(bytes);
    var hex = '';
    for (var i = 0; i < bytes.length; i++) hex += ('0' + bytes[i].toString(16)).slice(-2);
    return hex;
  }

  var token = localStorage.getItem(TOKEN_KEY);
  if (!token || !/^[0-9a-f]{32}$/.test(token)) {
    token = genToken();
    localStorage.setItem(TOKEN_KEY, token);
  }
  var nickname = localStorage.getItem(NICK_KEY) || '';
  var myRating = 1200;
  var myWins = 0;
  var myLosses = 0;

  // ---- DOM ----
  function $(id) { return document.getElementById(id); }
  var els = {
    screens: {
      register: $('screen-register'),
      lobby: $('screen-lobby'),
      cards: $('screen-cards'),
      matching: $('screen-matching'),
      battle: $('screen-battle')
    },
    btnCards: $('btn-cards'),
    btnCardsBack: $('btn-cards-back'),
    btnCardsFlipAll: $('btn-cards-flip-all'),
    btnCardsReset: $('btn-cards-reset'),
    cardsGrid: $('cards-grid'),
    nickInput: $('nick-input'),
    btnRegister: $('btn-register'),
    registerError: $('register-error'),
    profileRank: $('profile-rank'),
    profileNick: $('profile-nick'),
    profileRating: $('profile-rating'),
    profileRecord: $('profile-record'),
    btnRename: $('btn-rename'),
    touchNote: $('touch-note'),
    pcMenu: $('pc-match-menu'),
    roomInput: $('room-input'),
    btnRandom: $('btn-match-random'),
    btnRoom: $('btn-match-room'),
    matchingText: $('matching-text'),
    matchingMode: $('matching-mode'),
    botOffer: $('bot-offer'),
    btnOfferBot: $('btn-offer-bot'),
    btnCancelMatch: $('btn-cancel-match'),
    rankingList: $('ranking-list'),
    rankingNote: $('ranking-note'),
    battleModeLabel: $('battle-mode-label'),
    battleRound: $('battle-round'),
    myName: $('my-name'),
    myScore: $('my-score'),
    oppName: $('opp-name'),
    oppScore: $('opp-score'),
    oppStatus: $('opp-status'),
    karaokeKami: $('karaoke-kami'),
    karaokeShimo: $('karaoke-shimo'),
    poetName: $('battle-poet'),
    roundMsg: $('round-msg'),
    torifuda: $('torifuda'),
    fudaText: $('fuda-text'),
    romajiGuide: $('romaji-guide'),
    choiceArea: $('choice-area'),
    emoteBar: $('emote-bar'),
    myEmote: $('my-emote'),
    oppEmote: $('opp-emote'),
    btnLeave: $('btn-leave'),
    popLayer: $('pop-layer'),
    vignette: $('vignette'),
    stage: $('battle-stage'),
    overlay: $('result-overlay'),
    rTitle: $('result-title'),
    rScore: $('result-score'),
    rDelta: $('result-delta'),
    rRank: $('result-rank'),
    rRating: $('result-rating'),
    rNote: $('result-note'),
    btnRematch: $('btn-rematch'),
    btnToLobby: $('btn-to-lobby'),
    btnMute: $('btn-mute')
  };
  var modeBtns = document.querySelectorAll('.mode-btn');
  var botBtns = document.querySelectorAll('.bot-btn');
  var rkTabs = document.querySelectorAll('.rk-tab');

  // ---- サウンド(WebAudio・音声ファイルは使わない) ----
  var audioCtx = null;
  var muted = localStorage.getItem('hyakuninBattleMuted') === '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);
      }
    },
    // 相手の取り: ピッチを上げて短く軽い「パシッ」。自分の重い「ばしーん」と明確に区別する
    // (ローパスだけだと低音の胴が残って同じ音に聞こえた)
    oppTake: function () {
      if (muted || !audioCtx) return;
      if (takeBuf && takeBuf !== LOADING) {
        var s = audioCtx.createBufferSource();
        s.buffer = takeBuf;
        s.playbackRate.value = 1.5;   // 高く・短く=軽い音
        var f = audioCtx.createBiquadFilter();
        f.type = 'highpass';          // 胴の低音を切って「遠くの軽い音」に
        f.frequency.value = 500;
        var g = audioCtx.createGain();
        g.gain.value = 0.45;
        s.connect(f);
        f.connect(g);
        g.connect(audioCtx.destination);
        s.start();
      } else {
        blip(440, 0.12, 'triangle', 0.06);
      }
    },
    lose: function () { blip(330, 0.15, 'triangle', 0.05); blip(220, 0.25, 'triangle', 0.05, 0.12); },
    // 札確認のめくり: 紙をめくる軽い音(取りの音とは別物)
    flip: function () { blip(950, 0.04, 'triangle', 0.05); blip(1400, 0.05, 'triangle', 0.04, 0.035); },
    kimariji: function () { blip(880, 0.1, 'sine', 0.06); blip(1320, 0.18, 'sine', 0.06, 0.09); },
    // 琴風の開始音(ボイスが無い環境でも合図になる)
    koto: function () { blip(587, 0.3, 'triangle', 0.05); blip(880, 0.4, 'triangle', 0.04, 0.15); },
    win: 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 ? 'サウンドをオンにする' : 'サウンドをオフにする');
  }

  // ---- APIクライアント ----
  function api(params) {
    var body = new URLSearchParams();
    Object.keys(params).forEach(function (k) { body.append(k, params[k]); });
    return fetch(API, { method: 'POST', body: body }).then(function (res) {
      return res.json().catch(function () { return null; }).then(function (data) {
        if (!res.ok) {
          var err = new Error((data && data.error) || 'HTTP ' + res.status);
          err.status = res.status;
          throw err;
        }
        return data;
      });
    });
  }

  // ---- 時計合わせ ----
  // offset = サーバー時刻 - performance.now()。3回サンプリングして中央値を採用
  var clockOffset = null;

  function serverNow() {
    return performance.now() + clockOffset;
  }

  function syncClock() {
    var samples = [];
    function once() {
      var t0 = performance.now();
      return api({ action: 'time' }).then(function (r) {
        var t1 = performance.now();
        // サーバー時刻は往復の中間点に対応するとみなす(= serverTime + rtt/2 - t0 と同値)
        samples.push(r.now - (t0 + t1) / 2);
      });
    }
    return once().then(once).then(once).then(function () {
      clockOffset = medianOffset(samples);
    });
  }

  function ensureClock() {
    if (clockOffset !== null) return Promise.resolve();
    return syncClock();
  }

  // ---- 画面切り替え ----
  function showScreen(name) {
    Object.keys(els.screens).forEach(function (k) {
      els.screens[k].classList.toggle('active', k === name);
    });
  }

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

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

  // ============================================
  // 登録・ロビー
  // ============================================
  var selectedMode = 10;

  function updateProfile() {
    els.profileNick.textContent = nickname;
    els.profileRank.textContent = rankOf(myRating);
    els.profileRank.className = 'rank-badge tier-' + rankTier(myRating);
    els.profileRating.textContent = 'レート ' + myRating;
    els.profileRecord.textContent = myWins + '勝' + myLosses + '敗';
  }

  function doRegister(nick) {
    els.registerError.textContent = '';
    els.btnRegister.disabled = true;
    return api({ action: 'register', token: token, nickname: nick })
      .then(function (r) {
        nickname = nick;
        localStorage.setItem(NICK_KEY, nick);
        myRating = r.rating;
        myWins = r.wins;
        myLosses = r.losses;
        updateProfile();
        showScreen('lobby');
        loadRankings(currentRankingPeriod);
      })
      .catch(function (e) {
        els.registerError.textContent = e.message || '登録に失敗しました';
      })
      .then(function () { els.btnRegister.disabled = false; });
  }

  els.btnRegister.addEventListener('click', function () {
    var nick = els.nickInput.value.trim();
    if (!nick) { els.registerError.textContent = 'ニックネームを入力してね'; return; }
    if (nick.length > 12) { els.registerError.textContent = '12文字以内で入力してね'; return; }
    ensureAudio();
    doRegister(nick);
  });

  els.nickInput.addEventListener('keydown', function (e) {
    if (e.key === 'Enter') { e.preventDefault(); els.btnRegister.click(); }
  });

  els.btnRename.addEventListener('click', function () {
    els.nickInput.value = nickname;
    showScreen('register');
  });

  modeBtns.forEach(function (btn) {
    btn.addEventListener('click', function () {
      modeBtns.forEach(function (b) { b.classList.remove('active'); });
      btn.classList.add('active');
      selectedMode = parseInt(btn.getAttribute('data-mode'), 10);
    });
  });

  // ---- ランキングタブ ----
  var currentRankingPeriod = 'week';

  function loadRankings(period) {
    currentRankingPeriod = period;
    rkTabs.forEach(function (t) {
      t.classList.toggle('active', t.getAttribute('data-period') === period);
    });
    els.rankingList.innerHTML = '<li class="rk-loading">読み込み中…</li>';
    api({ action: 'rankings', period: period }).then(function (r) {
      if (!r.list || r.list.length === 0) {
        els.rankingList.innerHTML = '<li class="rk-loading">まだ記録がありません。1位を狙うチャンス!</li>';
        return;
      }
      var html = '';
      r.list.forEach(function (row, i) {
        var rating = parseInt(row.rating, 10);
        var main = period === 'week'
          ? parseInt(row.points, 10) + 'pt'
          : 'レート ' + rating;
        html += '<li class="rk-row">' +
          '<span class="rk-pos">' + (i + 1) + '</span>' +
          '<span class="rank-badge tier-' + rankTier(rating) + '">' + escapeHtml(rankOf(rating)) + '</span>' +
          '<span class="rk-name">' + escapeHtml(row.nickname) + '</span>' +
          '<span class="rk-main">' + main + '</span>' +
          '<span class="rk-wins">' + parseInt(row.wins, 10) + '勝</span>' +
          '</li>';
      });
      els.rankingList.innerHTML = html;
    }).catch(function () {
      els.rankingList.innerHTML = '<li class="rk-loading">ランキングを取得できませんでした</li>';
    });
  }

  rkTabs.forEach(function (t) {
    t.addEventListener('click', function () { loadRankings(t.getAttribute('data-period')); });
  });

  // ============================================
  // 札確認(100枚一覧・クリックで裏返して下の句)
  // ============================================
  var cardsBuilt = false;

  // 上の句の句切れ(1句目・2句目のモーラ数)。字余りで5・7からずれる歌だけ列挙し、
  // 他は 5・7・残り。3句目の字余り(1番とまをあらみ 等)はデフォルトのままでよい
  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;
  }

  // 上の句を句ごとの3つの文字列に分ける
  function kamiPhrases(poem) {
    var units = moraUnits(poem.kami);
    var counts = KAMI_KUGIRE[poem.no] || [5, 7];
    var out = [], p = 0;
    for (var j = 0; j < 2; j++) {
      out.push(units.slice(p, p + counts[j]).join(''));
      p += counts[j];
    }
    out.push(units.slice(p).join(''));
    return out;
  }

  // 下の句を句ごとの2つの文字列に分ける
  function shimoPhrases(poem) {
    var units = moraUnits(poem.shimo);
    var n = SHIMO_KUGIRE[poem.no] || 7;
    return [units.slice(0, n).join(''), units.slice(n).join('')];
  }

  function buildCardsGrid() {
    if (cardsBuilt) return;
    cardsBuilt = true;
    var frag = document.createDocumentFragment();
    POEMS.forEach(function (poem) {
      var kim = KIMARIJI[poem.no];
      var card = document.createElement('button');
      card.type = 'button';
      card.className = 'mini-fuda';
      card.setAttribute('aria-pressed', 'false');
      card.setAttribute('aria-label', '第' + poem.no + '首 ' + poem.kami);

      var inner = document.createElement('span');
      inner.className = 'mf-inner';

      // 表: 歌番号+上の句(決まり字は赤)
      var front = document.createElement('span');
      front.className = 'mf-face mf-front';
      var no = document.createElement('span');
      no.className = 'mf-no';
      no.textContent = poem.no;
      // 5・7・5(字余りは実際の句切れ)で3行に分ける。決まり字の赤は行をまたいでも続ける
      var kamiEl = document.createElement('span');
      kamiEl.className = 'mf-text';
      var off = 0;
      kamiPhrases(poem).forEach(function (ph, pi) {
        if (pi > 0) kamiEl.appendChild(document.createElement('br'));
        var kimPart = Math.max(0, Math.min(ph.length, kim.length - off));
        if (kimPart > 0) {
          var kEl = document.createElement('span');
          kEl.className = 'mf-kim';
          kEl.textContent = ph.slice(0, kimPart);
          kamiEl.appendChild(kEl);
        }
        if (kimPart < ph.length) kamiEl.appendChild(document.createTextNode(ph.slice(kimPart)));
        off += ph.length;
      });
      // 実物の読み札と同じく、表にも詠み人を入れる
      var poetFront = document.createElement('span');
      poetFront.className = 'mf-poet';
      poetFront.textContent = poem.poet;
      front.appendChild(no);
      front.appendChild(kamiEl);
      front.appendChild(poetFront);

      // 裏: 下の句+歌人
      var back = document.createElement('span');
      back.className = 'mf-face mf-back';
      var shimoEl = document.createElement('span');
      shimoEl.className = 'mf-text';
      // 実物の取り札と同じ、句切れを無視した5文字ずつの3行(/hyakunin/ と同じ組み方)
      var toriUnits = moraUnits(poem.shimo);
      for (var li = 0; li < toriUnits.length; li += 5) {
        if (li > 0) shimoEl.appendChild(document.createElement('br'));
        shimoEl.appendChild(document.createTextNode(toriUnits.slice(li, li + 5).join('')));
      }
      var poetEl = document.createElement('span');
      poetEl.className = 'mf-poet';
      poetEl.textContent = poem.poet;
      var noBack = document.createElement('span');
      noBack.className = 'mf-no';
      noBack.textContent = poem.no;
      back.appendChild(shimoEl);
      back.appendChild(poetEl);
      back.appendChild(noBack);

      inner.appendChild(front);
      inner.appendChild(back);
      card.appendChild(inner);
      card.addEventListener('click', function () {
        var flipped = card.classList.toggle('flipped');
        card.setAttribute('aria-pressed', flipped ? 'true' : 'false');
        ensureAudio();
        sounds.flip();
      });
      frag.appendChild(card);
    });
    els.cardsGrid.appendChild(frag);
  }

  function setAllCards(flipped) {
    els.cardsGrid.querySelectorAll('.mini-fuda').forEach(function (card) {
      card.classList.toggle('flipped', flipped);
      card.setAttribute('aria-pressed', flipped ? 'true' : 'false');
    });
  }

  els.btnCards.addEventListener('click', function () {
    buildCardsGrid();
    showScreen('cards');
  });
  els.btnCardsBack.addEventListener('click', function () { showScreen('lobby'); });
  els.btnCardsFlipAll.addEventListener('click', function () { setAllCards(true); });
  els.btnCardsReset.addEventListener('click', function () { setAllCards(false); });

  // ============================================
  // マッチング
  // ============================================
  var match = null;      // { id, role, kind: 'random'|'room'|'bot', room, bot }
  var pollTimer = null;
  var botOfferTimer = null;

  function stopPolling() {
    if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
  }

  function schedulePoll(ms) {
    stopPolling();
    pollTimer = setTimeout(pollState, ms);
  }

  function startMatch(kind, opts) {
    opts = opts || {};
    ensureAudio();
    ensureClock().then(function () {
      var params = { action: 'join', token: token, mode: selectedMode };
      if (kind === 'room') params.room = opts.room;
      if (kind === 'bot') params.bot = opts.bot;
      return api(params);
    }).then(function (r) {
      match = { id: r.matchId, role: r.role, kind: kind, room: opts.room || null, bot: opts.bot || null };
      resetBattleState();
      if (r.status === 'playing') {
        enterBattle();
      } else {
        showMatchingScreen(kind);
      }
      schedulePoll(300);
    }).catch(function (e) {
      alertMsg(e.message || 'マッチングに失敗しました');
      showScreen('lobby');
    });
  }

  function showMatchingScreen(kind) {
    showScreen('matching');
    els.botOffer.hidden = true;
    els.matchingMode.textContent = selectedMode + '本先取';
    els.matchingText.textContent = kind === 'room'
      ? '合言葉「' + match.room + '」で相手を待っています…'
      : '対戦相手を探しています…';
    if (botOfferTimer) clearTimeout(botOfferTimer);
    if (kind === 'random') {
      // 15秒マッチしなければコンピュータ対戦を提案
      botOfferTimer = setTimeout(function () { els.botOffer.hidden = false; }, BOT_OFFER_MS);
    }
  }

  function cancelMatching() {
    if (botOfferTimer) { clearTimeout(botOfferTimer); botOfferTimer = null; }
    stopPolling();
    var mid = match ? match.id : 0;
    match = null;
    if (mid) api({ action: 'cancel', token: token, matchId: mid }).catch(function () {});
    showScreen('lobby');
    loadRankings(currentRankingPeriod);
  }

  els.btnRandom.addEventListener('click', function () { startMatch('random'); });

  // 全角数字(1234)も半角に直して受け付ける
  function normalizeRoomCode(v) {
    return v.trim().replace(/[0-9]/g, function (c) {
      return String.fromCharCode(c.charCodeAt(0) - 0xFEE0);
    });
  }

  els.roomInput.addEventListener('blur', function () {
    els.roomInput.value = normalizeRoomCode(els.roomInput.value);
  });

  els.btnRoom.addEventListener('click', function () {
    var room = normalizeRoomCode(els.roomInput.value);
    els.roomInput.value = room;
    if (!/^\d{4}$/.test(room)) {
      alertMsg('合言葉は数字4桁で入力してね(例: 1234)');
      return;
    }
    startMatch('room', { room: room });
  });

  botBtns.forEach(function (btn) {
    btn.addEventListener('click', function () {
      startMatch('bot', { bot: btn.getAttribute('data-bot') });
    });
  });

  els.btnOfferBot.addEventListener('click', function () {
    // ランダム待機をキャンセルして五段ボットと対戦
    if (!match) return;
    var mid = match.id;
    els.botOffer.hidden = true;
    stopPolling();
    api({ action: 'cancel', token: token, matchId: mid }).catch(function () {}).then(function () {
      match = null;
      startMatch('bot', { bot: 'normal' });
    });
  });

  els.btnCancelMatch.addEventListener('click', cancelMatching);

  function alertMsg(msg) {
    // ライトな通知(ダイアログを乱発しない)
    var el = document.createElement('div');
    el.className = 'toast';
    el.textContent = msg;
    document.body.appendChild(el);
    setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200);
  }

  // ============================================
  // 対戦本体
  // ============================================
  // battle.phase: between(ラウンド間) / countdown(次の札…) / race(カラオケ+入力) /
  //               waiting(提出済み・相手待ち) / reveal(結果演出) / done
  var battle = null;

  function resetBattleState() {
    battle = {
      phase: 'between',
      round: 0,
      poem: null,
      roundStart: null,
      timeline: null,
      flashMs: 0,
      flashDone: false,
      deadline: 0,
      wordState: null,
      penaltyMs: 0,
      missCount: 0,
      submitted: false,
      myScore: 0,
      oppScore: 0,
      mode: selectedMode,
      oppName: null,
      lastOppEmoteAt: 0,
      lastOppEmoteId: null,
      lastEmoteSentAt: 0,
      speechTimer: null,
      revealTimer: null,
      pendingStart: false,
      rafActive: false,
      finished: false
    };
  }

  function enterBattle() {
    if (botOfferTimer) { clearTimeout(botOfferTimer); botOfferTimer = null; }
    showScreen('battle');
    els.overlay.classList.remove('active');
    els.battleModeLabel.textContent = battle.mode + '本先取';
    els.myName.textContent = nickname;
    els.myScore.textContent = '0';
    els.oppScore.textContent = '0';
    els.oppStatus.textContent = '';
    els.karaokeKami.innerHTML = '';
    els.karaokeShimo.innerHTML = '';
    els.poetName.textContent = '';
    els.fudaText.innerHTML = '';
    els.romajiGuide.innerHTML = '';
    els.choiceArea.innerHTML = '';
    els.roundMsg.textContent = '相手と札の準備をしています…';
    els.roundMsg.hidden = false;
    els.torifuda.className = 'torifuda hidden';
  }

  // ---- 状態ポーリング ----
  function pollState() {
    pollTimer = null;
    if (!match) return;
    api({ action: 'state', token: token, matchId: match.id })
      .then(handleState)
      .catch(function (e) {
        if (e.status === 404 || e.status === 403) {
          // 試合が消えた/自分のものでない → ロビーへ
          leaveToLobby('対戦が見つかりませんでした。ロビーに戻ります');
          return;
        }
        // 通信エラーは次のポーリングでリトライ
        if (match) schedulePoll(1500);
      });
  }

  function handleState(s) {
    if (!match) return;

    if (s.status === 'waiting') {
      schedulePoll(1000);
      return;
    }

    if (s.status === 'aborted') {
      leaveToLobby('対戦が中断されました');
      return;
    }

    // マッチ成立直後
    if (els.screens.battle && !els.screens.battle.classList.contains('active') && s.status === 'playing') {
      battle.mode = s.mode;
      enterBattle();
    }

    if (s.oppName && s.oppName !== battle.oppName) {
      battle.oppName = s.oppName;
      els.oppName.textContent = s.oppName;
    }

    // 相手のエモート(4秒以内の新着のみ返ってくる)
    if (s.oppEmote) {
      var nowLocal = performance.now();
      if (s.oppEmote !== battle.lastOppEmoteId || nowLocal - battle.lastOppEmoteAt > 4500) {
        battle.lastOppEmoteId = s.oppEmote;
        battle.lastOppEmoteAt = nowLocal;
        showEmoteBubble(els.oppEmote, EMOTES[s.oppEmote - 1]);
      }
    }

    // ラウンドが進んだ(or 初回ラウンド)
    if (s.round > battle.round) {
      if (battle.round > 0) {
        onRoundResolved(s);
      } else {
        battle.myScore = s.myScore;
        battle.oppScore = s.oppScore;
      }
      battle.round = s.round;
      battle.poem = POEMS[s.poemId];
      battle.roundStart = null;
      battle.submitted = false;
      battle.flashDone = false;
      battle.penaltyMs = 0;
      battle.missCount = 0;
      battle.wordState = null;
      els.battleRound.textContent = '第' + s.round + '首';
    }

    if (s.status === 'done') {
      onMatchDone(s);
      return;
    }

    // ラウンド開始時刻の合意
    if (s.roundStart !== null && battle.roundStart === null && battle.poem) {
      battle.roundStart = s.roundStart;
      if (battle.phase === 'reveal') {
        battle.pendingStart = true;  // 結果演出が終わってからカウントダウンへ
      } else {
        startCountdown();
      }
    }

    // レース中の相手の状態表示。相手が取ったら読みを止め、一呼吸おいて次の札へ
    if (battle.phase === 'race' || battle.phase === 'waiting') {
      els.oppStatus.textContent = s.oppFinished ? '相手が取った!' : '';
      els.oppStatus.classList.toggle('opp-took', !!s.oppFinished);
      if (s.oppFinished && !battle.oppTook) {
        battle.oppTook = true;
        stopReading();
        // ずっと待たされないよう自動で締める(この間に打ち切れば滑り込み提出は可能)
        battle.oppNextTimer = setTimeout(function () {
          if (battle && battle.phase === 'race' && !battle.submitted) {
            submitResult(9999999);
            els.roundMsg.textContent = '相手が取った!';
          }
        }, 1800);
      }
    }

    // ポーリング規律: カラオケ/入力中は3秒、それ以外は1秒
    schedulePoll(battle.phase === 'race' ? 3000 : 1000);
  }

  // ---- ラウンド進行 ----
  function startCountdown() {
    battle.phase = 'countdown';
    var poem = battle.poem;
    battle.timeline = audioTimeline(poem.no) || karaokeTimeline(poem.kami.length, poem.shimo.length);
    // 決まり字フラッシュ: 朗詠なら「決まり字の次の文字を発声する瞬間」=読み切った瞬間
    var kimLen = KIMARIJI[poem.no].length;
    battle.flashMs = battle.timeline.audio
      ? (battle.timeline.kamiTimes[kimLen] !== undefined
          ? battle.timeline.kamiTimes[kimLen]
          : battle.timeline.kamiTimes[kimLen - 1] + CHAR_MS)
      : kimarijiFlashMs(kimLen);
    battle.deadline = battle.timeline.total + RACE_GRACE_MS;
    battle.wordState = mergeAltShimo(createWordState(poem.shimo), poem.no);
    battle.freezeT = null;
    battle.shimoShown = false;
    battle.oppTook = false;
    if (battle.oppNextTimer) { clearTimeout(battle.oppNextTimer); battle.oppNextTimer = null; }
    setShimoHidden(true);    // 上の句を読み終えるまで取り札は伏せる
    prepareVoice(poem.no);   // カウントダウン中に読み込んでおく

    // カラオケ歌詞(決まり字部分にマーカー)と取り札を組み立てる。
    // 句ごとのかたまり(.kphrase)で組み、句の途中で行が折れないようにする
    var kim = KIMARIJI[poem.no];
    var html = '';
    var off = 0;
    kamiPhrases(poem).forEach(function (ph) {
      var inner = '';
      for (var i = 0; i < ph.length; i++) {
        var cls = 'kchar' + ((off + i) < kim.length ? ' kimariji-mark' : '');
        inner += '<span class="' + cls + '">' + escapeHtml(ph[i]) + '</span>';
      }
      html += '<span class="kphrase">' + inner + '</span> ';
      off += ph.length;
    });
    els.karaokeKami.innerHTML = html;
    html = '';
    shimoPhrases(poem).forEach(function (ph) {
      var inner = '';
      for (var j = 0; j < ph.length; j++) {
        inner += '<span class="kchar">' + escapeHtml(ph[j]) + '</span>';
      }
      html += '<span class="kphrase">' + inner + '</span> ';
    });
    els.karaokeShimo.innerHTML = html;
    els.poetName.textContent = '';

    renderFuda();
    renderRomaji();
    els.torifuda.className = 'torifuda';
    els.choiceArea.innerHTML = '';
    els.roundMsg.hidden = false;

    if (!battle.rafActive) {
      battle.rafActive = true;
      requestAnimationFrame(raceLoop);
    }
  }

  function beginRace() {
    battle.phase = 'race';
    els.roundMsg.hidden = true;
    els.oppStatus.textContent = '';
    sounds.koto();
    if (!playVoice(serverNow() - battle.roundStart)) speakPoem(battle.poem, battle.timeline);
    if (isTouch) renderChoices(battle.poem);
    // レース突入直後にも1回状態を見ておく(以後は3秒間隔)
    schedulePoll(3000);
  }

  function raceLoop() {
    if (!battle || (battle.phase !== 'countdown' && battle.phase !== 'race' && battle.phase !== 'waiting')) {
      if (battle) battle.rafActive = false;
      return;
    }
    var t = serverNow() - battle.roundStart;

    if (battle.phase === 'countdown') {
      if (t >= 0) {
        beginRace();
      } else {
        var sec = Math.ceil(-t / 1000);
        els.roundMsg.textContent = '次の札… ' + sec;
      }
    }

    if (battle.phase === 'race' || battle.phase === 'waiting') {
      // 上の句を読み終えたら(または読みが止まったら)取り札を出す
      if (!battle.shimoShown && (t >= (battle.timeline.kamiEnd || battle.timeline.shimoStart) || battle.freezeT != null)) {
        battle.shimoShown = true;
        setShimoHidden(false);
      }
      updateKaraoke(t);
      if (!battle.flashDone && t >= battle.flashMs) fireKimarijiFlash();
      if (battle.phase === 'race' && !battle.submitted && t >= battle.deadline) {
        // 締切: 未提出はタイムアウト扱いで提出(両者未提出なら流れ札になる)
        submitResult(9999999);
        els.roundMsg.textContent = '時間切れ…';
        els.roundMsg.hidden = false;
      }
    }

    requestAnimationFrame(raceLoop);
  }

  function updateKaraoke(t) {
    var poem = battle.poem;
    if (battle.freezeT != null && t > battle.freezeT) t = battle.freezeT;  // 取られたらそこで止まる
    var lit = battle.timeline.audio
      ? audioCharsAt(t, battle.timeline)
      : karaokeCharsAt(t, poem.kami.length, poem.shimo.length);
    var kamiSpans = els.karaokeKami.querySelectorAll('.kchar');  // .kphrase入れ子のため文字spanを直接拾う
    for (var i = 0; i < kamiSpans.length; i++) {
      kamiSpans[i].classList.toggle('sung', i < lit.kami);
    }
    var shimoSpans = els.karaokeShimo.querySelectorAll('.kchar');
    for (var j = 0; j < shimoSpans.length; j++) {
      shimoSpans[j].classList.toggle('sung', j < lit.shimo);
    }
    if (t >= battle.timeline.total && els.poetName.textContent === '') {
      els.poetName.textContent = poem.poet;
    }
  }

  // 決まり字の最終文字に達した瞬間: 画面フチが金色に光る(0.4秒)+札がきらめく
  function fireKimarijiFlash() {
    battle.flashDone = true;
    sounds.kimariji();
    els.vignette.classList.remove('flash-active');
    void els.vignette.offsetWidth;  // アニメを毎回最初から
    els.vignette.classList.add('flash-active');
    els.torifuda.classList.add('sparkle');
    setTimeout(function () {
      els.vignette.classList.remove('flash-active');
      els.torifuda.classList.remove('sparkle');
    }, 450);
  }

  // ---- 読み上げ(Web Speech API。ボイス不在ならカラオケのみ) ----
  function speakPoem(poem, timeline) {
    if (!window.speechSynthesis || typeof SpeechSynthesisUtterance === 'undefined') return;
    try {
      window.speechSynthesis.cancel();
      var u1 = new SpeechSynthesisUtterance(poem.kami);
      u1.lang = 'ja-JP';
      u1.rate = SPEECH_RATE;
      window.speechSynthesis.speak(u1);
      battle.speechTimer = setTimeout(function () {
        try {
          window.speechSynthesis.cancel();  // 上の句が長引いていても下の句はカラオケに同期
          var u2 = new SpeechSynthesisUtterance(poem.shimo);
          u2.lang = 'ja-JP';
          u2.rate = SPEECH_RATE;
          window.speechSynthesis.speak(u2);
        } catch (e) { /* ボイス不在などは無視 */ }
      }, timeline.shimoStart);
    } catch (e) { /* 無視: カラオケ表示のみで続行 */ }
  }

  function stopSpeech() {
    disposeVoice();
    if (battle && battle.speechTimer) { clearTimeout(battle.speechTimer); battle.speechTimer = null; }
    if (window.speechSynthesis) {
      try { window.speechSynthesis.cancel(); } catch (e) { /* 無視 */ }
    }
  }

  // ---- 取り札(縦書き・下の句のみ)とローマ字ガイド ----
  function renderFuda() {
    var ws = battle.wordState;
    // 実物の取り札と同じ、句切れを無視した5文字ずつの3行(札確認・/hyakunin/ と同じ組み方)。
    // っ/ん結合トークン(2モーラ)が5の境界をまたぐ場合はその行が6文字になる(許容)
    var html = '';
    var acc = 0;
    for (var i = 0; i < ws.tokens.length; i++) {
      var t = ws.tokens[i];
      var cls = i < ws.ti ? 'f-done' : (i === ws.ti ? 'f-cur' : 'f-rest');
      html += '<span class="' + cls + '">' + escapeHtml(t.kana) + '</span>';
      acc += moraUnits(t.kana).length;
      if (acc % 5 === 0 && i < ws.tokens.length - 1) html += '<br>';
    }
    els.fudaText.innerHTML = html;
  }

  function renderRomaji() {
    if (isTouch) { els.romajiGuide.innerHTML = ''; return; }
    var parts = romajiParts(battle.wordState);
    els.romajiGuide.innerHTML =
      '<span class="r-done">' + escapeHtml(parts[0]) + '</span>' +
      '<span class="r-rest">' + escapeHtml(parts[1]) + '</span>';
  }

  // ---- キー入力(PC・T_r から可能) ----
  document.addEventListener('keydown', function (e) {
    if (e.ctrlKey || e.metaKey || e.altKey) return;
    if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
    if (!battle || battle.phase !== 'race' || battle.submitted || isTouch) return;
    if (e.key.length !== 1) return;
    e.preventDefault();
    var ch = e.key.toLowerCase();
    if (!/^[a-z\-]$/.test(ch)) return;

    if (acceptKey(battle.wordState, ch)) {
      sounds.type();
      renderFuda();
      renderRomaji();
      if (isWordComplete(battle.wordState)) {
        onRaceComplete();
      }
    } else {
      battle.missCount++;
      sounds.miss();
      els.stage.classList.remove('shake');
      void els.stage.offsetWidth;
      els.stage.classList.add('shake');
    }
  });

  // ---- 3択かるた(スマホ: ボット戦のみ) ----
  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();
        if (battle.phase !== 'race' || battle.submitted) return;
        if (p.no === poem.no) {
          btn.classList.add('choice-correct');
          // 取り札も染めて完了扱い
          battle.wordState.ti = battle.wordState.tokens.length;
          renderFuda();
          onRaceComplete();
        } else {
          if (btn.classList.contains('choice-wrong')) return;
          btn.classList.add('choice-wrong');
          battle.penaltyMs += CHOICE_PENALTY_MS;
          battle.missCount++;
          sounds.miss();
          popText('お手つき +1秒', 'pop-miss');
        }
      });
      els.choiceArea.appendChild(btn);
    });
  }

  // ---- 入力完了 → 提出 ----
  function onRaceComplete() {
    var elapsed = Math.round(serverNow() - battle.roundStart) + battle.penaltyMs;
    // 音はここでは鳴らさない(勝敗確定時 onRoundResolved で鳴らす)
    submitResult(elapsed);
  }

  function submitResult(ms) {
    if (battle.submitted) return;
    battle.submitted = true;
    battle.phase = 'waiting';
    if (ms < 0) ms = 0;
    if (ms > 9999999) ms = 9999999;
    stopReading();
    els.roundMsg.textContent = '相手を待っています…';
    els.roundMsg.hidden = false;
    // 3択ボタンはここでは消さずに無効化する。pointerdown 中に innerHTML を消すと
    // レイアウトが詰まり、指を離した瞬間の click が下にずれた「退出する」に当たるため
    // (消すのは次ラウンドの startCountdown で行う)
    var choiceBtns = els.choiceArea.querySelectorAll('button');
    for (var i = 0; i < choiceBtns.length; i++) choiceBtns[i].disabled = true;
    api({
      action: 'submit', token: token, matchId: match.id,
      round: battle.round, ms: ms, misses: battle.missCount
    }).catch(function () { /* 次のポーリングで回収 */ });
    schedulePoll(400);  // 提出後は1秒間隔ポーリングへ(初回だけ即時気味に)
  }

  // ---- ラウンド決着の演出 ----
  function onRoundResolved(s) {
    var dMy = s.myScore - battle.myScore;
    var dOpp = s.oppScore - battle.oppScore;
    battle.myScore = s.myScore;
    battle.oppScore = s.oppScore;
    stopSpeech();
    battle.phase = 'reveal';
    els.roundMsg.hidden = true;
    els.oppStatus.textContent = '';
    els.oppStatus.classList.remove('opp-took');

    if (dMy > 0) {
      // 勝ち: 自分の「ばしーん」。どちらが取ったか確定してから鳴らす
      els.torifuda.classList.add('fly-win');
      popText('取った! +1', 'pop-take');
      sounds.take();
    } else if (dOpp > 0) {
      // 負け: 札が相手陣(奥)へ飛ぶ
      els.torifuda.classList.add('fly-lose');
      popText('相手に取られた…', 'pop-miss');
      sounds.oppTake();   // 相手の軽い「パシッ」
    } else {
      popText('流れ札(引き分け)', 'pop-even');
    }
    els.myScore.textContent = s.myScore;
    els.oppScore.textContent = s.oppScore;

    if (battle.revealTimer) clearTimeout(battle.revealTimer);
    battle.revealTimer = setTimeout(function () {
      if (!battle || battle.finished) return;
      els.torifuda.className = 'torifuda hidden';
      battle.phase = 'between';
      els.roundMsg.textContent = '次の札…';
      els.roundMsg.hidden = false;
      if (battle.pendingStart && battle.roundStart !== null) {
        battle.pendingStart = false;
        startCountdown();
      }
    }, 1400);
  }

  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); }, 1100);
  }

  // ---- 試合終了 ----
  function onMatchDone(s) {
    if (battle.finished) return;
    battle.finished = true;
    stopPolling();
    stopSpeech();

    // 最終ラウンドの札の行方も見せてから結果へ
    var delay = 0;
    if (s.myScore !== battle.myScore || s.oppScore !== battle.oppScore) {
      onRoundResolved(s);
      delay = 1200;
    }
    battle.phase = 'done';

    // どちらも先取本数に達していない終わり方 = 不戦勝/不戦敗(切断・離脱)
    var walkover = s.myScore < s.mode && s.oppScore < s.mode;

    setTimeout(function () {
      var delta = s.myDelta === null || s.myDelta === undefined ? 0 : s.myDelta;
      myRating += delta;
      if (s.iWon) myWins++; else myLosses++;
      updateProfile();

      if (s.iWon) {
        els.rTitle.textContent = walkover ? '不戦勝!' : '勝利!';
        els.rNote.textContent = walkover ? '相手が切断・退出しました' : '';
        sounds.win();
      } else {
        els.rTitle.textContent = walkover ? '不戦敗…' : '敗北…';
        els.rNote.textContent = '';
        sounds.lose();
      }
      els.rScore.textContent = s.myScore + ' - ' + s.oppScore;
      var sign = delta > 0 ? '+' : '';
      var arrow = delta > 0 ? ' ↗' : (delta < 0 ? ' ↘' : '');
      els.rDelta.textContent = 'レート変動 ' + sign + delta + arrow;
      els.rDelta.className = 'result-delta ' + (delta > 0 ? 'delta-up' : delta < 0 ? 'delta-down' : 'delta-flat');
      els.rRank.textContent = rankOf(myRating);
      els.rRank.className = 'rank-badge big tier-' + rankTier(myRating);
      els.rRating.textContent = 'レート ' + myRating;
      if (s.vsBot && !walkover) {
        els.rNote.textContent = 'コンピュータ戦はレート変動が半分になります';
      }
      els.overlay.classList.add('active');
    }, delay);
  }

  function leaveToLobby(msg) {
    stopPolling();
    stopSpeech();
    if (botOfferTimer) { clearTimeout(botOfferTimer); botOfferTimer = null; }
    match = null;
    battle = null;
    els.overlay.classList.remove('active');
    if (msg) alertMsg(msg);
    showScreen('lobby');
    loadRankings(currentRankingPeriod);
  }

  els.btnLeave.addEventListener('click', function () {
    if (!match) return;
    var isDone = battle && battle.finished;
    if (!isDone && !window.confirm('対戦から退出しますか?(途中退出は負けになります)')) return;
    var mid = match.id;
    api({ action: 'leave', token: token, matchId: mid }).catch(function () {});
    leaveToLobby();
  });

  // ---- エモート ----
  EMOTES.forEach(function (emo, i) {
    var btn = document.createElement('button');
    btn.className = 'emote-btn';
    btn.type = 'button';
    btn.textContent = emo;
    btn.addEventListener('click', function () {
      if (!match) return;
      var now = performance.now();
      if (now - battle.lastEmoteSentAt < 1500) return;  // 連打防止
      battle.lastEmoteSentAt = now;
      showEmoteBubble(els.myEmote, emo);
      api({ action: 'emote', token: token, matchId: match.id, emoteId: i + 1 }).catch(function () {});
    });
    els.emoteBar.appendChild(btn);
  });

  function showEmoteBubble(el, emo) {
    el.textContent = emo;
    el.hidden = false;
    el.classList.remove('bubble-pop');
    void el.offsetWidth;
    el.classList.add('bubble-pop');
    clearTimeout(el._hideTimer);
    el._hideTimer = setTimeout(function () { el.hidden = true; }, 2500);
  }

  // ---- 結果画面のボタン ----
  els.btnRematch.addEventListener('click', function () {
    els.overlay.classList.remove('active');
    var kind = match.kind;
    var opts = { room: match.room, bot: match.bot };
    match = null;
    startMatch(kind, opts);
  });

  els.btnToLobby.addEventListener('click', function () {
    leaveToLobby();
  });

  els.btnMute.addEventListener('click', function () {
    muted = !muted;
    localStorage.setItem('hyakuninBattleMuted', muted ? '1' : '0');
    if (voiceEl) voiceEl.muted = muted;
    updateMuteButton();
  });

  // ============================================
  // 初期化
  // ============================================
  if (isTouch) {
    // スマホ・タブレット: 対人マッチ不可の案内を出し、コンピュータ対戦のみ開放
    els.touchNote.hidden = false;
    els.pcMenu.hidden = true;
    document.body.classList.add('touch-mode');
  }

  updateMuteButton();

  if (nickname) {
    // 登録済み: 自動ログイン(レート等を取得してロビーへ)
    els.nickInput.value = nickname;
    doRegister(nickname);
  } else {
    showScreen('register');
  }

  // 進行中の試合があれば復帰できるよう、時計だけ先に合わせておく
  ensureClock().catch(function () { /* 初回対戦時にリトライされる */ });

})();

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

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

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

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

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

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

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

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

もっと学びたい人へ

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

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