第10回 タイピングWebアプリ制作|日本語ローマ字入力・Ver.1.0完成編

この記事は約217分で読めます。

このシリーズについて

前回の第9回では、保存した練習成績を折れ線グラフ・棒グラフで確認できるようにし、直近5回の移動平均や分析メモも追加しました。さらに、成績データをBOM付きCSVへバックアップし、別の環境へ復元できるところまで進めました。

第10回は、タイピングラボ Ver.1.0です。今回は、これまでの英数字1文字・テンキー練習に加えて、日本語の読みをローマ字で入力できる「日本語単語」モードを追加します。

今回の目的
日本語教材を大量に登録することではなく、さまざまなローマ字入力方法を正しく判定できる仕組みを作ることです。今回の日本語単語は、入力エンジンを確認するための20語に絞っています。

AIと作業を分担して作ります

このシリーズでは、必要な機能、画面の動き、入力方法、修正内容をきょろぽんが考え、HTML・CSS・JavaScriptのソースコード作成をAIのノンが担当しています。

作ったソースコードは、そのまま完成扱いにはしません。ブラウザで実際に動かし、いろいろな入力方法を試しながら、不具合や分かりにくい部分を修正していきます。

第10回でも、ローマ字入力の仕様を先に決めたあと、実際に「ちょっと」を何通りもの方法で入力するなど、何度も動作確認を繰り返しました。

今回作るもの

  • スタート画面へ「日本語単語」モードを追加する
  • 日本語と読み、ローマ字ガイドを表示する
  • Microsoft IMEの一般的なローマ字入力を基準にする
  • si / shi、ti / chi、tu / tsuなど複数の入力方法を受け付ける
  • 「ん」をnとnnの両方で扱えるようにする
  • 小さい「っ」を子音重ね、xtu、ltuで入力できるようにする
  • 拗音、小書き文字、カタカナ、長音、外来語系の入力にも対応する
  • 入力途中で別の有効なローマ字経路へ進んだ場合は、ガイド表示を切り替える
  • 大文字でも入力でき、Caps Lockがオンの可能性がある場合は警告する
  • 試験用20語を使って日本語入力を確認する
  • 開発者向けに、日本語教材CSVを自動検査する「教材チェック」を追加する

第9回から追加する機能

追加・変更内容
日本語単語モード日本語の読みをローマ字で入力する60秒練習
ローマ字候補生成読みから複数の有効な入力経路を自動生成
分岐ガイド次に複数のキーを入力できる場合、その候補を画面へ表示
Caps Lock警告大文字入力は受け付けながら、Caps Lockの可能性を案内
教材チェックCSVを本番と同じローマ字入力エンジンで自動検査
Ver.1.0表記画面上部を「タイピングラボ Ver.1.0」へ更新

ファイル構成

第10回も、基本の3ファイルで作ります。日本語20語の試験データとローマ字判定処理も、今回はscript.jsの中へ入れています。

ファイル役割
index.html日本語単語モード、ローマ字表示、教材チェック画面などの土台
style.css日本語表示、入力ガイド、分岐案内、Caps Lock警告、教材チェック画面の見た目
script.jsローマ字入力候補の生成、入力判定、日本語20語、教材CSVチェックなど

今回は20語だけなので、試験データはscript.js内に置いています。今後、大量の教材データを扱う段階では、入力エンジンと教材データを分離して管理しやすい形へ発展させられます。

完成したソースコード

最初に、第10回の完成版ソースコードをまとめて掲載します。前回のVer.0.9へ日本語ローマ字入力と教材チェックを追加したVer.1.0です。

index.html のソースコードを表示
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>タイピングラボ Ver.1.0</title>
    <link rel="stylesheet" href="style.css?v=20260811-2" />
  </head>
  <body>
    <main class="app">
      <header class="app__header">
        <div>
          <div class="brand-line"><p class="app__eyebrow">のんびり研究所</p><p class="app-version">タイピングラボ Ver.1.0</p></div>
          <div class="title-line">
            <h1 id="app-title">タイピング練習</h1>
            <nav class="record-links" aria-label="成績メニュー">
              <button id="open-records" type="button">成績を見る</button>
              <button id="open-ranking" type="button">ランキング</button>
              <button id="open-charts" type="button">グラフを見る</button>
              <button id="open-csv" type="button">CSV</button>
            </nav>
          </div>
        </div>
        <dl class="status" aria-label="練習状況">
          <div class="status__item"><dt>残り時間</dt><dd><span id="time">60</span><small>秒</small></dd></div>
          <div class="status__item"><dt id="score-label">打数</dt><dd id="score">0</dd></div>
          <div id="hits-status" class="status__item" hidden><dt>打数</dt><dd id="hits">0</dd></div>
          <div class="status__item"><dt>ミス</dt><dd id="misses">0</dd></div>
        </dl>
      </header>

      <section id="play-area" class="play-area" aria-label="タイピング練習エリア">
        <div id="falling-key" class="falling-key" aria-live="off" hidden>A</div>
        <div id="challenge-keys" class="challenge-keys" aria-live="off"></div>
        <div id="effect-layer" class="effect-layer" aria-hidden="true"></div>
        <div id="combo-display" class="combo-display" hidden><strong><span id="combo-count">0</span> COMBO</strong><small>スコア ×<span id="combo-rate">1.0</span></small></div>
        <div id="combo-flash" class="combo-flash" aria-hidden="true"></div>
        <div id="numpad-question" class="numpad-question" hidden>
          <div class="number-reel" aria-label="テンキー入力問題"><div class="number-reel__frame" aria-hidden="true"></div><div id="number-track" class="number-track" aria-live="polite"></div></div>
          <p class="comma-note">カンマは打ちません</p>
        </div>
        <div id="japanese-question" class="japanese-question" hidden>
          <p id="japanese-word" class="japanese-word">車</p>
          <p id="japanese-reading" class="japanese-reading">くるま</p>
          <p id="romaji-guide" class="romaji-guide" aria-live="polite"></p>
          <p id="romaji-branch-hint" class="romaji-branch-hint" aria-live="polite" hidden></p>
          <p id="caps-warning" class="caps-warning" role="status" hidden>Caps Lock がオンになっている可能性があります</p>
        </div>

        <div id="start-panel" class="panel start-panel">
          <p class="panel__label">練習モードを選びましょう</p>
          <fieldset class="mode-selector">
            <legend class="visually-hidden">練習モード</legend>
            <label class="mode-option"><input type="checkbox" name="practice-mode" value="number" /><span>数字段<small>1 2 3 4 5 6 7 8 9 0</small></span></label>
            <label class="mode-option"><input type="checkbox" name="practice-mode" value="top" /><span>上段<small>Q W E R T Y U I O P</small></span></label>
            <label class="mode-option"><input type="checkbox" name="practice-mode" value="home" checked /><span>ホーム段<small>A S D F G H J K L ;</small></span></label>
            <label class="mode-option"><input type="checkbox" name="practice-mode" value="bottom" /><span>下段<small>Z X C V B N M , . /</small></span></label>
            <div class="mode-divider" aria-hidden="true"></div>
            <label class="mode-option mode-option--japanese"><input type="checkbox" name="practice-mode" value="japanese" /><span>日本語単語<small>読みをローマ字で入力</small></span></label>
            <label class="mode-option mode-option--numpad"><input type="checkbox" name="practice-mode" value="numpad" /><span>テンキー<small>3~5桁+Enter</small></span></label>
          </fieldset>
          <div class="challenge-controls">
            <button id="challenge-toggle" class="challenge-toggle" type="button" aria-pressed="false">チャレンジモード</button>
            <label id="level-control" class="level-control" hidden><span>Level <strong id="level-value">1</strong></span><input id="level-slider" type="range" min="1" max="10" value="1" /></label>
          </div>
          <p id="selection-warning" class="selection-warning" role="alert" hidden>練習する段を1つ以上選択してください</p>
          <div class="start-actions"><p class="panel__main"><kbd>Space</kbd> キーでスタート</p><div class="sound-setting"><span>SE</span><button id="sound-toggle" class="sound-toggle" type="button" aria-pressed="true">ON</button><small>※音が出ます</small></div></div>
          <p id="start-description" class="panel__sub">60秒間、表示された文字を入力しましょう</p>
        </div>
        <div id="countdown" class="countdown" aria-live="assertive" hidden></div>
        <div id="exit-panel" class="panel exit-panel" role="dialog" aria-modal="true" aria-labelledby="exit-title" hidden>
          <p class="panel__label">練習を一時停止しています</p><h2 id="exit-title">練習を途中で終了しますか?</h2>
          <div class="exit-panel__buttons"><button id="exit-practice" class="dialog-button dialog-button--danger" type="button">終了する</button><button id="continue-practice" class="dialog-button" type="button">続ける</button></div>
        </div>
        <div id="result-panel" class="panel result" hidden>
          <p class="panel__label">練習終了!</p><h2>今回の結果</h2>
          <div class="result__scores">
            <p><span id="final-score-label">打数</span><strong id="final-score">0</strong></p>
            <p id="final-hits-item" hidden>打数<strong id="final-hits">0</strong></p>
            <p>ミス<strong id="final-misses">0</strong></p>
            <p id="final-max-combo-item" hidden>最大コンボ数<strong id="final-max-combo">0</strong></p>
            <p id="final-accuracy-item" hidden>正確率<strong id="final-accuracy">0.0%</strong></p>
            <p id="final-speed-item" hidden>速度<strong id="final-speed">0.00打/秒</strong><small id="final-speed-minute">0打/分</small></p>
          </div>
          <div id="result-details" class="result-details"></div>
          <p id="ranking-result" class="ranking-result" hidden></p>
          <div class="result-actions"><button id="result-record-link" type="button">成績を見る</button><button id="result-ranking-link" type="button" hidden>ランキングを見る</button></div>
          <div class="result-retry">
            <p class="panel__sub">もう一度練習するときは <kbd>Space</kbd> キー</p>
            <button id="change-mode" class="change-mode" type="button">練習モード変更</button>
          </div>
        </div>
      </section>

      <section class="guide" aria-label="キーと担当指ガイド">
        <div class="guide__header"><p id="guide-title" class="guide__title">光った指を、光ったキーに置いてみましょう</p><p id="exit-hint" class="exit-hint" hidden><kbd>Esc</kbd>:途中終了</p></div>
        <div id="standard-guide"><div class="keyboard-viewport"><div id="screen-keyboard" class="screen-keyboard" aria-label="4段の画面キーボード"></div></div><div id="hands" class="hands" aria-label="左右の担当指"></div></div>
        <div id="numpad-guide" class="numpad-guide" hidden><div id="screen-numpad" class="screen-numpad" aria-label="テンキー"></div><div id="numpad-hand" class="numpad-hand" aria-label="テンキーの右手担当指"></div></div>
      </section>
      <svg id="placement-line" class="placement-line" aria-hidden="true"><line x1="0" y1="0" x2="0" y2="0"></line></svg>
      <p id="message" class="message" aria-live="polite">練習モードを選び、Spaceキーを押してください</p>
    </main>

    <section id="records-screen" class="records-screen" role="dialog" aria-modal="true" aria-labelledby="records-title" hidden>
      <div class="records-window"><header><div><p class="panel__label">タイピングラボ Ver.1.0</p><h2 id="records-title">保存された成績</h2></div><button id="close-records" class="close-records" type="button" aria-label="閉じる">×</button></header>
        <div class="records-tabs" role="tablist"><button id="records-tab" class="is-active" type="button">練習成績</button><button id="ranking-tab" type="button">ランキング</button><button id="charts-tab" type="button">分析グラフ</button><button id="csv-tab" type="button">CSV管理</button><button id="checker-tab" type="button">教材チェック</button></div>
        <div id="records-content" class="records-content"></div>
        <div id="ranking-content" class="records-content" hidden><div class="ranking-filters"><label>種別<select id="ranking-mode"><option value="standard">段別チャレンジ</option><option value="numpad">テンキーチャレンジ</option></select></label><label>Level<select id="ranking-level"><option value="all">全Level</option></select></label></div><div id="ranking-list"></div></div>
        <div id="charts-content" class="records-content" hidden>
          <div class="chart-toolbar"><label>表示範囲<select id="chart-range"><option value="10">直近10回</option><option value="30" selected>直近30回</option><option value="100">直近100回</option><option value="all">すべて</option></select></label><span id="chart-count"></span></div>
          <div id="analysis-comment" class="analysis-comment"></div>
          <section class="chart-card"><h3>速度・上達グラフ</h3><p>各プレイの速度と、直近5回の移動平均です。</p><div id="speed-chart" class="chart-frame"></div></section>
          <section class="chart-card"><h3>段別速度</h3><p>選択中の範囲に含まれるデータを段ごとに集計しています。</p><div id="row-chart" class="chart-frame"></div></section>
          <section class="chart-card"><h3>正確率</h3><p>各プレイの正確率の推移です。</p><div id="accuracy-chart" class="chart-frame"></div></section>
        </div>
        <div id="csv-content" class="records-content" hidden>
          <section class="csv-card"><h3>CSVへエクスポート</h3><p>通常練習・チャレンジ・ランキング元データを、すべてBOM付きCSVへ保存します。</p><button id="export-csv" class="csv-button csv-button--primary" type="button">バックアップCSVを保存</button></section>
          <section class="csv-card"><h3>CSVからインポート</h3><p>タイピングラボで書き出したバックアップCSVを選択してください。1件でも読み込めない項目がある場合は、全体を中止します。</p><label class="file-picker"><span>CSVファイルを選ぶ</span><input id="import-file" type="file" accept=".csv,text/csv" /></label><div id="import-summary" class="import-summary" aria-live="polite">CSVファイルを選ぶと、内容を検証して件数を表示します。</div><div class="import-actions"><button id="import-add" class="csv-button csv-button--primary" type="button" disabled>追加インポート</button><button id="import-replace" class="csv-button csv-button--danger" type="button" disabled>全消去してインポート</button></div></section>
        </div>
        <div id="checker-content" class="records-content" hidden>
          <section class="csv-card checker-card">
            <h3>日本語教材CSVチェッカー</h3>
            <p>本番と同じローマ字入力エンジンで、教材データと全候補経路を自動検査します。CSVの必須列は <code>表示語,読み</code> です。</p>
            <label class="file-picker"><span>教材CSVを選ぶ</span><input id="checker-file" type="file" accept=".csv,text/csv" /></label>
            <button id="download-sample-csv" class="csv-button" type="button">試験用20語CSVを保存</button>
            <div id="checker-summary" class="checker-summary" aria-live="polite">CSVファイルを選ぶと、OK・警告・エラーを表示します。</div>
            <div class="checker-filter" role="group" aria-label="チェック結果の絞り込み">
              <button type="button" data-check-filter="all" class="is-active">すべて</button>
              <button type="button" data-check-filter="warning">警告</button>
              <button type="button" data-check-filter="error">エラー</button>
            </div>
            <div id="checker-results" class="checker-results"></div>
          </section>
        </div>
        <footer><button id="delete-records" class="delete-records" type="button">成績データをすべて削除</button></footer>
      </div>
    </section>
    <script src="script.js?v=20260811-2"></script>
  </body>
</html>
style.css のソースコードを表示
:root {
  color-scheme: light;
  --ink: #253044;
  --muted: #68758b;
  --line: #dbe5ed;
  --accent: #1c92a9;
  --accent-dark: #126b7d;
  --glow: #40e6ff;
  --back: #eef7f8;
  font-family: "Yu Gothic UI", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
}
* {
  box-sizing: border-box;
}
body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  color: var(--ink);
  background:
    radial-gradient(circle at 15% 10%, #dff4f6 0, transparent 34rem),
    linear-gradient(145deg, #f8fbfc, var(--back));
}
button,
input {
  font: inherit;
}
.app {
  width: min(1100px, calc(100% - 32px));
  margin: auto;
  padding: 26px 0 20px;
}
.app__header {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 24px;
  margin-bottom: 14px;
}
.app__eyebrow {
  margin: 0 0 4px;
  color: var(--accent-dark);
  font-size: 0.82rem;
  font-weight: 700;
  letter-spacing: 0.12em;
}
h1 {
  margin: 0;
  color: #126b7d;
  font-size: clamp(1.35rem, 3vw, 2rem);
}
.title-line {
  display: flex;
  align-items: center;
  gap: 12px;
}
.is-challenge h1 { color: #c35d0b; }
.status {
  display: flex;
  gap: 8px;
  margin: 0;
}
.status__item {
  min-width: 92px;
  padding: 9px 14px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: #ffffffe0;
  text-align: center;
  box-shadow: 0 5px 18px #29535e0f;
}
.status dt {
  color: var(--muted);
  font-size: 0.75rem;
  font-weight: 700;
}
.status dd {
  margin: 2px 0 0;
  color: var(--accent-dark);
  font-size: 1.55rem;
  font-weight: 800;
  line-height: 1;
}
.status small {
  font-size: 0.72rem;
}
.play-area {
  position: relative;
  height: clamp(420px, 48vh, 480px);
  overflow: hidden;
  border: 3px solid #b9dbe0;
  border-radius: 22px;
  background:
    linear-gradient(#ffffffb8, #ffffffe8),
    repeating-linear-gradient(90deg, transparent 0 79px, #d9eef0 80px);
  box-shadow: 0 18px 45px #254c581f;
}
.falling-key,
.effect-key,
.challenge-key {
  position: absolute;
  z-index: 4;
  top: 0;
  left: 0;
  width: 1.25em;
  color: var(--accent-dark);
  font:
    900 clamp(4rem, 10vw, 6.5rem) / 1 Arial,
    sans-serif;
  text-align: center;
  text-shadow: 0 5px 0 #d4edf0;
  will-change: transform, opacity;
}
.challenge-keys {
  position: absolute;
  z-index: 4;
  inset: 0;
  pointer-events: none;
}
.challenge-key {
  will-change: transform, opacity;
}
.challenge-key.is-hit {
  animation: challenge-key-hit 0.2s ease-out forwards;
}
.combo-display {
  position: absolute;
  z-index: 7;
  top: 16px;
  right: 18px;
  min-width: 150px;
  padding: 9px 14px;
  border: 2px solid #ffbf47;
  border-radius: 14px;
  color: #813b00;
  background: #fff8dced;
  text-align: center;
  box-shadow: 0 7px 24px #dc750035;
  pointer-events: none;
}
.combo-display strong,
.combo-display small { display: block; }
.combo-display strong { font: 900 1.45rem/1 Arial, sans-serif; }
.combo-display small { margin-top: 4px; font-weight: 800; }
.combo-display.is-milestone { animation: combo-milestone .48s ease-out; }
.combo-flash {
  position: absolute;
  z-index: 6;
  inset: 0;
  background: #ef334433;
  opacity: 0;
  pointer-events: none;
}
.combo-flash.is-active { animation: combo-break .25s ease-out; }
.effect-key.is-hit {
  animation: effect-key-hit 0.18s ease-out forwards;
}
.effect-layer {
  position: absolute;
  z-index: 3;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
}
.panel {
  position: absolute;
  z-index: 8;
  top: 50%;
  left: 50%;
  width: min(760px, calc(100% - 32px));
  padding: clamp(18px, 3vw, 28px);
  border: 1px solid var(--line);
  border-radius: 20px;
  background: #fffffff5;
  text-align: center;
  box-shadow: 0 20px 50px #254c5826;
  transform: translate(-50%, -50%);
}
.start-panel {
  padding-block: 16px;
}
.start-panel .mode-selector {
  gap: 6px 8px;
  margin-top: 8px;
}
.start-panel .mode-option span {
  min-height: 54px;
  padding-block: 6px;
}
.start-panel .mode-divider {
  margin-block: 2px 0;
}
.start-panel .challenge-controls {
  margin-top: 8px;
}
.start-panel .panel__main {
  margin: 0;
}
.start-panel .sound-setting {
  margin: 0;
}
.panel__label,
.panel__sub {
  margin: 0;
  color: var(--muted);
}
.panel__main {
  margin: 14px 0 9px;
  font-size: clamp(1.1rem, 3vw, 1.45rem);
  font-weight: 800;
}
kbd {
  display: inline-block;
  padding: 0.28em 0.65em;
  border: 1px solid #9eb8bf;
  border-bottom-width: 4px;
  border-radius: 8px;
  color: var(--accent-dark);
  background: #f8ffff;
  font: inherit;
}
.mode-selector {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
  margin: 14px 0 0;
  padding: 0;
  border: 0;
}
.mode-option {
  cursor: pointer;
}
.mode-option--numpad {
  grid-column: 3/5;
}
.mode-option--japanese {
  grid-column: 1/3;
}
.mode-option input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.mode-option span {
  position: relative;
  display: grid;
  place-items: center;
  min-height: 62px;
  padding: 8px 5px;
  border: 2px solid #c7dade;
  border-radius: 12px;
  color: #40576a;
  background: #f7fbfc;
  font-weight: 800;
  transition: 0.15s;
}
.mode-option small {
  display: block;
  margin-top: 4px;
  color: var(--muted);
  font:
    600 0.62rem Arial,
    sans-serif;
}
.mode-option input:checked + span {
  color: #07404c;
  border-color: #29c5db;
  background: #d8fbff;
  box-shadow:
    0 0 0 3px #40e6ff2e,
    0 7px 18px #1c92a91f;
}
.mode-option input:checked + span::before {
  content: "✓";
  position: absolute;
  top: 3px;
  left: 7px;
  color: #08798d;
  font-size: 1rem;
  line-height: 1;
}
.mode-divider {
  grid-column: 1/-1;
  height: 1px;
  margin: 5px 0 1px;
  background: linear-gradient(90deg, transparent, #aacbd1 12%, #aacbd1 88%, transparent);
}
.challenge-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  margin-top: 12px;
}
.challenge-toggle {
  padding: 8px 15px;
  border: 2px solid #b7c8ce;
  border-radius: 999px;
  color: #526477;
  background: #f7fbfc;
  font-weight: 800;
  cursor: pointer;
}
.challenge-toggle.is-on {
  color: #6a2500;
  border-color: #ff9c3d;
  background: #fff0d9;
  box-shadow: 0 0 0 3px #ffae4933;
}
.level-control {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #813b00;
  font-weight: 800;
}
.level-control input { width: min(180px, 24vw); accent-color: #f08022; }
.selection-warning {
  margin: 9px 0 -5px;
  color: #b32635;
  font-weight: 800;
}
.start-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(14px, 3vw, 30px);
  margin: 10px 0 7px;
}
.placement-line {
  position: fixed;
  z-index: 18;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  pointer-events: none;
}
.placement-line.is-visible { opacity: 1; }
.placement-line line {
  stroke: #55becb;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-dasharray: 5 7;
  filter: drop-shadow(0 0 4px #71eafa);
}
.is-challenge .mode-option input:checked + span {
  color: #672d00;
  border-color: #ff9c3d;
  background: #fff0d9;
  box-shadow: 0 0 0 3px #ffae4933, 0 7px 18px #d66e161f;
}
.is-challenge .mode-option input:checked + span::before { color: #c35d0b; }
.sound-setting {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin: 0 0 9px;
  color: var(--muted);
  font-weight: 700;
}
.sound-setting small {
  font-weight: 500;
}
.sound-toggle {
  min-width: 54px;
  padding: 4px 10px;
  border: 1px solid #75bdc9;
  border-radius: 99px;
  color: #07404c;
  background: #d8fbff;
  font-weight: 800;
  cursor: pointer;
}
.sound-toggle[aria-pressed="false"] {
  color: #68758b;
  border-color: #becbd0;
  background: #edf1f2;
}
.countdown {
  position: absolute;
  z-index: 9;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--accent-dark);
  background: #ffffffd6;
  font:
    900 clamp(6rem, 22vw, 13rem) / 1 Arial,
    sans-serif;
  text-shadow: 0 8px 0 #d4edf0;
}
.exit-panel h2,
.result h2 {
  margin: 8px 0 20px;
}
.exit-panel__buttons {
  display: flex;
  justify-content: center;
  gap: 10px;
}
.result__scores {
  display: flex;
  flex-wrap: nowrap;
  justify-content: center;
  gap: 10px;
}
.dialog-button,
.change-mode {
  padding: 10px 18px;
  border: 1px solid #9eb8bf;
  border-radius: 99px;
  color: var(--accent-dark);
  background: #f6fcfd;
  font-weight: 800;
  cursor: pointer;
}
.dialog-button--danger {
  color: #9b2833;
  border-color: #dfaab0;
  background: #fff1f2;
}
.result__scores {
  margin-bottom: 18px;
}
.result__scores p {
  flex: 0 1 150px;
  min-width: 0;
  margin: 0;
  padding: 14px;
  border-radius: 12px;
  color: var(--muted);
  background: #edf8f9;
  font-size: 0.82rem;
  font-weight: 700;
}
.result__scores strong {
  display: block;
  margin-top: 4px;
  color: var(--accent-dark);
  font-size: 2.2rem;
}
.result__scores small {
  display: block;
  margin-top: 2px;
  color: var(--muted);
  font-size: .7rem;
  font-weight: 700;
}
#final-speed {
  white-space: nowrap;
  font-size: 1.45rem;
}
.result-retry {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  flex-wrap: wrap;
  margin-top: 12px;
}
.result-retry .panel__sub {
  margin: 0;
}
.result-retry .change-mode {
  flex: 0 0 auto;
  padding: 7px 14px;
}
.guide {
  margin-top: 12px;
  padding: 12px 14px 9px;
  border: 1px solid #cfe2e6;
  border-radius: 18px;
  background: #ffffffe0;
  box-shadow: 0 10px 30px #254c5814;
}
.guide__header {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 24px;
  margin-bottom: 9px;
}
.guide__title {
  margin: 0;
  color: var(--muted);
  font-size: 0.78rem;
  font-weight: 700;
}
.exit-hint {
  position: absolute;
  right: 0;
  margin: 0;
  color: var(--muted);
  font-size: 0.78rem;
}
.exit-hint kbd {
  padding: 0.12em 0.45em;
  border-bottom-width: 2px;
}
.keyboard-viewport {
  overflow-x: auto;
  padding: 4px 3px 7px;
}
.screen-keyboard {
  --key-width: clamp(43px, 6.3vw, 68px);
  width: calc(var(--key-width) * 10 + 63px);
  min-width: 493px;
  margin: auto;
}
.keyboard-row {
  display: grid;
  grid-template-columns: repeat(10, var(--key-width));
  gap: 7px;
  width: max-content;
  margin-bottom: 6px;
  transition: 0.18s;
}
.keyboard-row[data-row="top"] {
  margin-left: 15px;
}
.keyboard-row[data-row="home"] {
  margin-left: 29px;
}
.keyboard-row[data-row="bottom"] {
  margin-left: 43px;
}
.keyboard-row.is-inactive-row {
  opacity: 0.48;
  filter: saturate(0.65);
}
.keycap,
.numpad-key {
  position: relative;
  min-width: 0;
  min-height: 48px;
  padding: 0;
  border: 1px solid #a9c0c6;
  border-bottom: 5px solid #819ba2;
  border-radius: 9px;
  color: #33475b;
  background: linear-gradient(#fff, #eaf1f3);
  font:
    800 clamp(0.95rem, 2vw, 1.28rem) / 1 Arial,
    sans-serif;
  box-shadow: 0 3px 6px #254c581f;
  transition: 0.08s;
}
.keycap:before,
.numpad-key:before {
  content: "";
  position: absolute;
  inset: 3px;
  z-index: -1;
  border-radius: 7px;
  opacity: 0.28;
}
.keycap[data-zone*="little"]:before,
.numpad-key[data-finger="little"]:before {
  background: #f6acc1;
}
.keycap[data-zone*="ring"]:before,
.numpad-key[data-finger="ring"]:before {
  background: #efc57f;
}
.keycap[data-zone*="middle"]:before,
.numpad-key[data-finger="middle"]:before {
  background: #d9dc75;
}
.keycap[data-zone*="index"]:before,
.numpad-key[data-finger="index"]:before {
  background: #8edcc4;
}
.numpad-key[data-finger="thumb"]:before {
  background: #b6c8ef;
}
.keycap[data-bump="true"]:after,
.numpad-key[data-bump="true"]:after {
  content: "";
  position: absolute;
  left: 33%;
  right: 33%;
  bottom: 7px;
  height: 3px;
  border-radius: 2px;
  background: #718c94;
}
.is-zone-target {
  opacity: 1 !important;
  border-color: #68cbd8 !important;
  background: #e5fbfd !important;
  box-shadow: 0 0 10px #40e6ff4d !important;
}
.is-target {
  z-index: 2;
  color: #073e4b !important;
  background: #bdf8ff !important;
  box-shadow:
    0 0 0 2px #5edbec,
    0 0 18px #40e6ffd9 !important;
  transform: translateY(-1px);
}
.is-correct {
  color: #fff !important;
  background: #23cde6 !important;
  transform: translateY(2px) scale(0.98);
  box-shadow:
    0 0 0 3px #fff,
    0 0 30px var(--glow) !important;
}
.is-wrong {
  z-index: 3;
  color: #fff !important;
  background: #ef5360 !important;
  border-color: #b32635 !important;
  box-shadow: 0 0 18px #ef3243d9 !important;
  animation: wrong-shake 0.24s linear;
}
.hands {
  display: flex;
  justify-content: center;
  gap: clamp(34px, 11vw, 130px);
  margin: 7px auto 0;
}
.hand {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  padding: 5px 13px 0;
  border-bottom: 7px solid #b8c9ce;
  border-radius: 0 0 24px 24px;
}
.finger,
.numpad-finger {
  display: grid;
  place-items: start center;
  width: clamp(22px, 4vw, 34px);
  height: var(--finger-height);
  padding-top: 6px;
  border: 2px solid #a9bcc2;
  border-radius: 15px 15px 8px 8px;
  color: #60727a;
  background: linear-gradient(#edf5f6, #dce9eb);
  font-size: clamp(0.56rem, 1.4vw, 0.68rem);
  font-weight: 800;
  transition: 0.1s;
}
.finger.is-target,
.numpad-finger.is-target {
  transform: translateY(-4px);
}
.numpad-question {
  position: absolute;
  z-index: 5;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding-bottom: 30px;
}
.current-question {
  position: relative;
  z-index: 4;
  display: flex;
  align-items: center;
  min-height: 1.2em;
  color: #33475b;
  font:
    900 clamp(3.1rem, 9vw, 6rem) / 1 Arial,
    sans-serif;
  letter-spacing: 0.04em;
  text-shadow: 0 4px 0 #e3eff1;
}
.current-question span {
  transition: 0.08s;
}
.current-question span.is-entered {
  color: #1cb9cf;
  text-shadow: 0 0 14px #40e6ff99;
}
.current-question span.is-current {
  text-decoration: underline;
  text-decoration-color: #ef9f24;
  text-underline-offset: 8px;
}
.current-question .comma {
  margin-right: 0.05em;
  color: #738595;
  font-style: normal;
}
.question-enter {
  margin-left: 0.35em;
  font-size: 0.27em;
  letter-spacing: 0;
  text-shadow: none;
}
.question-enter.is-ready {
  color: #073e4b;
  border-color: #29c5db;
  background: #bdf8ff;
  box-shadow: 0 0 18px #40e6ffd9;
}
.next-question {
  position: relative;
  z-index: 4;
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 24px;
  padding: 10px 20px;
  border: 1px solid #b9dbe0;
  border-radius: 13px;
  background: #edf8f9d9;
  color: var(--muted);
}
.next-question span {
  font-size: 0.8rem;
  font-weight: 800;
}
.next-question strong {
  color: #40576a;
  font:
    800 clamp(1.3rem, 4vw, 2rem) / 1 Arial,
    sans-serif;
  letter-spacing: 0.05em;
}
.comma-note {
  z-index: 4;
  margin: 8px 0 0;
  color: var(--muted);
  font-size: 0.76rem;
}
.current-question.is-promoting {
  animation: promote 0.3s ease-out;
}
.question-shatter {
  position: absolute;
  z-index: 6;
  top: 39%;
  animation: shatter 0.28s ease-out forwards;
  pointer-events: none;
}
.numpad-guide {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(34px, 8vw, 100px);
  padding: 4px 0 10px;
}
.screen-numpad {
  display: grid;
  grid-template-columns: repeat(3, 62px);
  grid-template-rows: repeat(4, 52px);
  gap: 7px;
}
.numpad-key {
  font-size: 1.1rem;
}
.numpad-key[data-numpad-key="0"] {
  grid-column: 1/3;
}
.numpad-key[data-numpad-key="."] {
  grid-column: 3;
  opacity: 0.32;
  filter: grayscale(1);
}
.numpad-key[data-numpad-key="Enter"] {
  grid-column: 1/4;
}

/* Ver.0.9:成績・ランキング・分析・CSV管理 */
.brand-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.brand-line .app__eyebrow { margin: 0; }
.app-version { margin: 0; padding-left: 10px; border-left: 1px solid #9fc5cc; color: #496a73; font-size: .72rem; font-weight: 800; letter-spacing: .04em; }
.record-links { display: flex; flex-wrap: wrap; gap: 7px; margin: 0; }
.record-links button, .result-actions button { padding: 5px 11px; border: 1px solid #9ecbd2; border-radius: 99px; color: #176273; background: #effcfe; font-size: .72rem; font-weight: 800; cursor: pointer; }
.is-challenge .record-links button, .is-challenge .result-actions button { border-color: #e8b783; color: #9b4d0a; background: #fff6ed; }
.result { width: min(940px, calc(100% - 28px)); max-height: calc(100% - 20px); overflow: auto; }
.result-details { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 8px; width: min(850px, 100%); margin: 0 auto 12px; }
.result-card { padding: 10px; border: 1px solid #cce3e7; border-radius: 12px; background: #f8feff; text-align: left; }
.result-card h3 { margin: 0 0 6px; color: #155b69; font-size: .9rem; }
.result-card p { margin: 2px 0; color: #536c74; font-size: .78rem; }
.ranking-result { margin: 8px 0; color: #b55208; font-weight: 900; }
.result-actions { display: flex; justify-content: center; gap: 8px; margin: 8px 0; }

.records-screen { position: fixed; z-index: 100; inset: 0; padding: 20px; overflow: auto; background: #16333bd9; }
.records-window { width: min(980px, 100%); min-height: min(680px, calc(100vh - 40px)); margin: 0 auto; padding: 22px; border-radius: 22px; background: #f8fcfd; box-shadow: 0 20px 80px #0007; }
.records-window > header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid #cce0e4; }
.records-window h2 { margin: 4px 0 14px; color: #164c58; }
.close-records { width: 40px; height: 40px; border: 0; border-radius: 50%; color: #49656b; background: #e6f1f3; font-size: 1.5rem; cursor: pointer; }
.records-tabs { display: flex; gap: 8px; margin: 18px 0; }
.records-tabs button { padding: 9px 18px; border: 1px solid #b6d0d5; border-radius: 99px; color: #49656b; background: #fff; font-weight: 800; cursor: pointer; }
.records-tabs button.is-active { color: #fff; border-color: #19869a; background: #19869a; }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 10px; }
.summary-card { padding: 14px; border: 1px solid #cce0e4; border-radius: 14px; background: #fff; }
.summary-card h3 { margin: 0 0 8px; color: #1b6674; font-size: .9rem; }
.summary-card strong { display: block; color: #163e47; font-size: 1.35rem; }
.summary-card small { color: #6b7e83; }
.history-title { margin: 24px 0 10px; color: #345b64; }
.history-list { display: grid; gap: 8px; }
.history-item, .ranking-item { padding: 12px 14px; border: 1px solid #d4e4e7; border-radius: 12px; background: #fff; }
.history-item h4, .ranking-item h3 { margin: 0 0 5px; color: #215d69; }
.history-item p, .ranking-item p { margin: 2px 0; color: #586f75; font-size: .82rem; }
.ranking-filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
.ranking-filters label { color: #45636a; font-size: .82rem; font-weight: 800; }
.ranking-filters select { margin-left: 6px; padding: 7px 10px; border: 1px solid #b5ced3; border-radius: 8px; background: #fff; }
#ranking-list { display: grid; gap: 8px; }
.ranking-item { border-left: 5px solid #ef912e; }
.empty-records { padding: 50px 20px; color: #71858a; text-align: center; }
.records-window footer { margin-top: 24px; padding-top: 16px; border-top: 1px solid #d4e4e7; text-align: right; }
.delete-records { padding: 8px 14px; border: 1px solid #dba4aa; border-radius: 9px; color: #9b2833; background: #fff1f2; font-weight: 800; cursor: pointer; }

.chart-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; padding: 10px 12px; border: 1px solid #c6dde1; border-radius: 12px; background: #fff; color: #45636a; font-size: .84rem; font-weight: 800; }
.chart-toolbar select { margin-left: 7px; padding: 7px 10px; border: 1px solid #acc9cf; border-radius: 8px; background: #fff; }
.analysis-comment { margin-bottom: 14px; padding: 14px 18px; border: 1px solid #bce0d6; border-left: 5px solid #27a47f; border-radius: 13px; background: #f2fcf8; color: #345c53; }
.analysis-comment h3 { margin: 0 0 7px; font-size: .95rem; }
.analysis-comment ul { margin: 0; padding-left: 1.3em; }
.analysis-comment li { margin: 4px 0; font-size: .84rem; }
.chart-card { margin: 12px 0; padding: 14px; border: 1px solid #d1e2e5; border-radius: 15px; background: #fff; }
.chart-card h3 { margin: 0 0 3px; color: #215d69; }
.chart-card > p { margin: 0 0 8px; color: #71858a; font-size: .78rem; }
.chart-frame { min-height: 220px; }
.data-chart { display: block; width: 100%; height: auto; overflow: visible; }
.chart-grid-line { stroke: #dbe8ea; stroke-width: 1; }
.chart-axis-label { fill: #6b7e83; font: 11px sans-serif; }
.chart-value-label { fill: #236878; font: 700 12px sans-serif; }
.chart-line { fill: none; stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; }
.chart-line--main { stroke: #238fa5; }
.chart-line--average { stroke: #ef922f; stroke-width: 4; }
.chart-dot { fill: #fff; stroke: #238fa5; stroke-width: 3; }
.chart-bar { fill: #43a9bb; }
.chart-bar:hover, .chart-dot:hover { filter: brightness(.9); }
.chart-legend { display: flex; justify-content: center; gap: 18px; margin: -3px 0 0; color: #5d7479; font-size: .75rem; }
.chart-legend span::before { display: inline-block; width: 20px; height: 4px; margin-right: 6px; border-radius: 5px; vertical-align: middle; content: ""; }
.legend-main::before { background: #238fa5; }
.legend-average::before { background: #ef922f; }
.chart-empty { display: grid; min-height: 220px; place-items: center; margin: 0; color: #71858a; }

.csv-card { margin-bottom: 14px; padding: 18px; border: 1px solid #d0e2e5; border-radius: 15px; background: #fff; }
.csv-card h3 { margin: 0 0 6px; color: #215d69; }
.csv-card p { margin: 0 0 13px; color: #60787e; font-size: .84rem; line-height: 1.7; }
.csv-button, .file-picker span { display: inline-block; padding: 9px 15px; border: 1px solid #a9cbd1; border-radius: 9px; color: #176273; background: #effcfe; font-weight: 800; cursor: pointer; }
.csv-button--primary { color: #fff; border-color: #19869a; background: #19869a; }
.csv-button--danger { color: #a22c37; border-color: #d7a3a9; background: #fff1f2; }
.csv-button:disabled { cursor: not-allowed; opacity: .45; }
.file-picker input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; }
.import-summary { display: grid; gap: 4px; margin: 14px 0; padding: 13px; border: 1px dashed #b9d0d5; border-radius: 11px; color: #657b80; background: #f8fbfc; font-size: .82rem; }
.import-summary.is-valid { border-style: solid; border-color: #a8d9c9; color: #286451; background: #f0fbf7; }
.import-summary.is-error { border-style: solid; border-color: #e0aeb4; color: #8f2934; background: #fff3f4; }
.import-actions { display: flex; flex-wrap: wrap; gap: 9px; }

/* 14桁+カンマ+Enterでも中央枠に収める */
.number-row { box-sizing: border-box; padding: 0 10px; white-space: nowrap; font-size: clamp(1.45rem, 5.2vw, 3.4rem); letter-spacing: clamp(.01em, .15vw, .04em); }
.number-row[data-length="10"], .number-row[data-length="11"] { font-size: clamp(1.35rem, 4.1vw, 2.65rem); }
.number-row[data-length="12"], .number-row[data-length="13"], .number-row[data-length="14"] { font-size: clamp(1.12rem, 3.45vw, 2.15rem); letter-spacing: 0; }
.number-row .question-enter { flex: 0 0 auto; }

@media (max-width: 680px) {
  .records-screen { padding: 8px; }
  .records-window { min-height: calc(100vh - 16px); padding: 14px; border-radius: 15px; }
  .result__scores { gap: 5px; }
  .result__scores p { flex-basis: 118px; padding: 9px 5px; }
  .result__scores strong { font-size: clamp(1.15rem, 5.4vw, 2.2rem); }
  #final-speed { font-size: clamp(.9rem, 3.8vw, 1.45rem); }
  .title-line { align-items: flex-start; flex-direction: column; gap: 7px; }
  .record-links { margin-bottom: 8px; }
  .records-tabs { flex-wrap: wrap; }
  .records-tabs button { flex: 1 1 120px; }
  .chart-toolbar { align-items: flex-start; flex-direction: column; }
  .chart-frame { min-height: 150px; overflow-x: auto; }
  .data-chart { min-width: 610px; }
}

.numpad-hand {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  padding: 6px 10px 0;
  border-bottom: 7px solid #b8c9ce;
  border-radius: 0 0 24px 24px;
}
.numpad-finger {
  width: 34px;
}
.numpad-finger[data-finger="thumb"] {
  height: 42px;
}
.numpad-finger[data-finger="index"] {
  height: 57px;
}
.numpad-finger[data-finger="middle"] {
  height: 63px;
}
.numpad-finger[data-finger="ring"] {
  height: 57px;
}
.numpad-finger[data-finger="little"] {
  height: 46px;
}
.laser {
  position: fixed;
  z-index: 2;
  height: 9px;
  border-radius: 99px;
  background: linear-gradient(
    #0877bd 0 20%,
    #69d8ff 20% 40%,
    #fff 40% 60%,
    #69d8ff 60% 80%,
    #0877bd 80%
  );
  box-shadow:
    0 0 5px #0877bd,
    0 0 12px #39c8ff,
    0 0 24px #1e9de2cc;
  transform-origin: 0 50%;
  pointer-events: none;
  animation: laser-shot 0.2s ease-out forwards;
}
.wrong-laser {
  position: fixed;
  z-index: 20;
  height: 5px;
  border-radius: 99px;
  background: linear-gradient(#665978, #93869e, #554b61);
  box-shadow: 0 0 5px #5c4c6dbf;
  transform-origin: 0 50%;
  pointer-events: none;
  animation: wrong-laser-shot 0.11s ease-out forwards;
}
.spark {
  position: absolute;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 12px var(--glow);
  animation: spark-burst 0.28s ease-out forwards;
}
.message {
  min-height: 1.5em;
  margin: 10px 0 0;
  color: var(--muted);
  text-align: center;
  font-size: 0.92rem;
}
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
[hidden] {
  display: none !important;
}
@keyframes wrong-shake {
  0%,
  100% {
    transform: translateX(0);
  }
  20% {
    transform: translateX(-5px);
  }
  40% {
    transform: translateX(5px);
  }
  60% {
    transform: translateX(-4px);
  }
  80% {
    transform: translateX(3px);
  }
}
@keyframes laser-shot {
  from {
    opacity: 1;
    clip-path: inset(0 100% 0 0);
  }
  45% {
    clip-path: inset(0);
  }
  to {
    opacity: 0;
    clip-path: inset(0);
  }
}
@keyframes wrong-laser-shot {
  from {
    opacity: 0.9;
    clip-path: inset(0 100% 0 0);
  }
  70% {
    opacity: 0.8;
    clip-path: inset(0);
  }
  to {
    opacity: 0;
  }
}
@keyframes effect-key-hit {
  to {
    opacity: 0;
    filter: blur(5px);
    transform: translate(var(--effect-x, 0), var(--effect-y, 0)) scale(1.65)
      rotate(12deg);
  }
}
@keyframes challenge-key-hit {
  to { opacity: 0; filter: blur(5px); transform: translate(var(--hit-x, 0), var(--hit-y, 0)) scale(1.65) rotate(12deg); }
}
@keyframes combo-break {
  0% { opacity: 0; }
  35% { opacity: 1; }
  100% { opacity: 0; }
}
@keyframes combo-milestone {
  0%,100% { transform: scale(1) translate(0); }
  25% { transform: scale(1.3) translate(-3px,2px) rotate(-2deg); }
  40% { transform: scale(1.24) translate(4px,-2px) rotate(2deg); }
  55% { transform: scale(1.18) translate(-3px,1px); }
}
@keyframes spark-burst {
  from {
    opacity: 1;
    transform: translate(-50%, -50%) scale(0.5);
  }
  to {
    opacity: 0;
    transform: translate(
        calc(-50% + var(--spark-x)),
        calc(-50% + var(--spark-y))
      )
      scale(0.1);
  }
}
@keyframes promote {
  from {
    opacity: 0.45;
    transform: translateY(58px) scale(0.65);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
@keyframes shatter {
  to {
    opacity: 0;
    filter: blur(5px);
    transform: scale(1.55) rotate(6deg);
  }
}
@media (max-width: 720px) {
  .app {
    width: min(100% - 20px, 1100px);
    padding-top: 16px;
  }
  .app__header {
    align-items: stretch;
    flex-direction: column;
    gap: 10px;
  }
  .status {
    width: 100%;
  }
  .status__item {
    flex: 1;
    min-width: 0;
    padding: 8px 4px;
  }
  .play-area {
    height: 410px;
    border-radius: 16px;
  }
  .panel {
    padding: 16px 12px;
  }
  .mode-selector {
    grid-template-columns: repeat(2, 1fr);
  }
  .mode-option--numpad {
    grid-column: 2/3;
  }
  .mode-option--japanese { grid-column: 1/2; }
  .mode-option span {
    min-height: 49px;
  }
  .challenge-controls { flex-direction: column; gap: 7px; }
  .level-control input { width: 180px; }
  .combo-display { top: 10px; right: 10px; min-width: 125px; }
  .combo-display strong { font-size: 1.2rem; }
  .screen-keyboard {
    --key-width: 43px;
    margin-left: 0;
  }
  .keycap {
    min-height: 43px;
  }
  .hands {
    gap: 18px;
  }
  .hand {
    gap: 2px;
    padding-inline: 4px;
  }
  .guide__header {
    justify-content: space-between;
    gap: 8px;
  }
  .exit-hint {
    position: static;
  }
  .numpad-guide {
    flex-direction: column;
    gap: 16px;
  }
  .screen-numpad {
    grid-template-columns: repeat(3, 56px);
    grid-template-rows: repeat(4, 48px);
  }
  .numpad-question {
    padding-bottom: 0;
  }
  .question-enter {
    display: block;
  }
  .next-question {
    margin-top: 20px;
  }
}

@keyframes promote {
  from {
    transform: translate(var(--promote-x), var(--promote-y))
      scale(var(--promote-scale));
    transform-origin: center;
  }
  to {
    transform: none;
    transform-origin: center;
  }
}

/* テンキー:視線を固定したまま一段ずつ送る数字リール */
.numpad-question {
  padding-bottom: 0;
}
.number-reel {
  --row-step: 88px;
  position: relative;
  width: min(620px, calc(100% - 32px));
  height: 286px;
  overflow: hidden;
}
.number-reel__frame {
  position: absolute;
  z-index: 2;
  top: 50%;
  left: 50%;
  width: min(520px, calc(100% - 24px));
  height: 76px;
  border: 3px solid #55d8e8;
  border-radius: 16px;
  background: #dffbff47;
  box-shadow:
    0 0 0 5px #fff9,
    0 8px 28px #1c92a92e,
    0 0 24px #40e6ff45;
  transform: translate(-50%, -50%);
  pointer-events: none;
}
.number-track {
  position: absolute;
  inset: 0;
}
.number-row {
  position: absolute;
  z-index: 3;
  top: 50%;
  left: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  width: min(500px, calc(100% - 44px));
  height: 70px;
  color: #526477;
  font:
    850 clamp(2rem, 6vw, 3.4rem) / 1 Arial,
    sans-serif;
  letter-spacing: 0.04em;
  opacity: 0.58;
  transform: translate(-50%, calc(-50% + var(--row-offset) * var(--row-step)))
    scale(0.82);
  transition:
    transform 0.28s cubic-bezier(0.22, 0.75, 0.25, 1),
    opacity 0.28s,
    color 0.28s;
  will-change: transform, opacity;
}
.number-row[data-offset="-1"] {
  --row-offset: -1;
  opacity: 0.18;
}
.number-row[data-offset="0"] {
  --row-offset: 0;
  z-index: 4;
  color: #253044;
  opacity: 1;
  transform: translate(-50%, calc(-50% + var(--row-offset) * var(--row-step)))
    scale(1);
}
.number-row[data-offset="1"] {
  --row-offset: 1;
}
.number-row[data-offset="2"] {
  --row-offset: 2;
  opacity: 0.3;
}
.number-row.is-new {
  opacity: 0;
}
.number-row span {
  transition:
    color 0.08s,
    text-shadow 0.08s;
}
.number-row span.is-entered {
  color: #1cb9cf;
  text-shadow: 0 0 14px #40e6ff99;
}
.number-row span.is-current {
  text-decoration: underline;
  text-decoration-color: #ef9f24;
  text-underline-offset: 8px;
}
.number-row .comma {
  margin-right: 0.05em;
  color: #738595;
  font-style: normal;
}
.number-row .question-enter {
  margin-left: 0.55em;
  font-size: 0.3em;
  letter-spacing: 0;
  text-shadow: none;
}
.comma-note {
  position: absolute;
  z-index: 5;
  bottom: 12px;
  margin: 0;
}
@media (max-width: 720px) {
  .number-reel {
    --row-step: 82px;
    width: 100%;
    height: 280px;
  }
  .number-reel__frame {
    width: calc(100% - 20px);
    height: 72px;
  }
  .number-row {
    width: calc(100% - 30px);
    font-size: clamp(1.8rem, 9vw, 3rem);
  }
  .number-row .question-enter {
    margin-left: 0.35em;
  }
}

/* 実物のテンキー配置:Enterは3とドットの右側で縦2段 */
.screen-numpad {
  grid-template-columns: repeat(4, 62px);
}
.numpad-key[data-numpad-key="7"] {
  grid-area: 1/1;
}
.numpad-key[data-numpad-key="8"] {
  grid-area: 1/2;
}
.numpad-key[data-numpad-key="9"] {
  grid-area: 1/3;
}
.numpad-key[data-numpad-key="4"] {
  grid-area: 2/1;
}
.numpad-key[data-numpad-key="5"] {
  grid-area: 2/2;
}
.numpad-key[data-numpad-key="6"] {
  grid-area: 2/3;
}
.numpad-key[data-numpad-key="1"] {
  grid-area: 3/1;
}
.numpad-key[data-numpad-key="2"] {
  grid-area: 3/2;
}
.numpad-key[data-numpad-key="3"] {
  grid-area: 3/3;
}
.numpad-key[data-numpad-key="0"] {
  grid-area: 4/1/5/3;
}
.numpad-key[data-numpad-key="."] {
  grid-area: 4/3;
}
.numpad-key[data-numpad-key="Enter"] {
  grid-area: 3/4/5/5;
}

/* Ver.1.0 日本語ローマ字入力 */
.japanese-question {
  position: absolute;
  z-index: 5;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 28px;
  text-align: center;
}
.japanese-word {
  margin: 0;
  color: #263c50;
  font-size: clamp(3rem, 8vw, 5.8rem);
  font-weight: 900;
  line-height: 1.15;
  text-shadow: 0 4px 0 #e3eff1;
}
.japanese-reading {
  margin: 8px 0 20px;
  color: #65798a;
  font-size: clamp(1rem, 2.5vw, 1.35rem);
  font-weight: 800;
  letter-spacing: .12em;
}
.romaji-guide {
  min-height: 1.25em;
  margin: 0;
  color: #33475b;
  font: 900 clamp(2.2rem, 7vw, 4.7rem)/1 Arial, sans-serif;
  letter-spacing: .06em;
}
.romaji-guide span { transition: color .08s, text-shadow .08s; }
.romaji-guide .is-entered { color: #1cb9cf; text-shadow: 0 0 14px #40e6ff99; }
.romaji-guide .is-next { color: #c2660d; text-decoration: underline; text-decoration-color: #ef9f24; text-underline-offset: 9px; }
.romaji-branch-hint {
  min-height: 1.5em;
  margin: 13px 0 0;
  color: #526c7e;
  font-size: clamp(.9rem, 2vw, 1.05rem);
  font-weight: 800;
}
.romaji-branch-hint strong {
  color: #c2660d;
  letter-spacing: .12em;
}
.caps-warning {
  margin: 18px 0 0;
  padding: 8px 14px;
  border: 1px solid #e8b44e;
  border-radius: 999px;
  color: #7c4800;
  background: #fff6d9;
  font-weight: 800;
}
.challenge-toggle:disabled { cursor: not-allowed; opacity: .45; }
.checker-card code { padding: 2px 5px; border-radius: 5px; background: #edf5f6; }
.checker-summary { margin: 14px 0; padding: 13px; border: 1px solid #b9d0d5; border-radius: 11px; color: #657b80; background: #f8fbfc; font-size: .84rem; font-weight: 800; }
.checker-summary.is-valid { border-color: #a8d9c9; color: #286451; background: #f0fbf7; }
.checker-summary.is-warning { border-color: #e5c36f; color: #79520a; background: #fff9e9; }
.checker-summary.is-error { border-color: #e0aeb4; color: #8f2934; background: #fff3f4; }
.checker-filter { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 12px; }
.checker-filter button { padding: 7px 13px; border: 1px solid #b8d0d5; border-radius: 99px; color: #526b70; background: #fff; font-weight: 800; cursor: pointer; }
.checker-filter button.is-active { color: #fff; border-color: #19869a; background: #19869a; }
.checker-results { display: grid; gap: 9px; max-height: 430px; overflow: auto; }
.checker-result { padding: 12px 14px; border-left: 5px solid #60ae91; border-radius: 10px; background: #f4fbf8; }
.checker-result.is-warning { border-left-color: #d4a12d; background: #fffaf0; }
.checker-result.is-error { border-left-color: #ce5360; background: #fff4f5; }
.checker-result h4 { margin: 0 0 5px; font-size: .93rem; }
.checker-result p { margin: 3px 0; color: #5b7075; font-size: .8rem; line-height: 1.55; }
@media (max-width: 720px) {
  .screen-numpad {
    grid-template-columns: repeat(4, 56px);
  }
  .japanese-question { padding: 18px; }
  .japanese-reading { margin-bottom: 14px; }
}
@media (prefers-reduced-motion: reduce) {
  *,
  *:before,
  *:after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}
script.js のソースコードを表示
"use strict";

const KEYBOARD_ROWS = [
  {
    id: "number",
    label: "数字段",
    keys: [
      ["1", "1"],
      ["2", "2"],
      ["3", "3"],
      ["4", "4"],
      ["5", "5"],
      ["6", "6"],
      ["7", "7"],
      ["8", "8"],
      ["9", "9"],
      ["0", "0"],
    ],
  },
  {
    id: "top",
    label: "上段",
    keys: [
      ["q", "Q"],
      ["w", "W"],
      ["e", "E"],
      ["r", "R"],
      ["t", "T"],
      ["y", "Y"],
      ["u", "U"],
      ["i", "I"],
      ["o", "O"],
      ["p", "P"],
    ],
  },
  {
    id: "home",
    label: "ホームポジション段",
    keys: [
      ["a", "A"],
      ["s", "S"],
      ["d", "D"],
      ["f", "F"],
      ["g", "G"],
      ["h", "H"],
      ["j", "J"],
      ["k", "K"],
      ["l", "L"],
      [";", ";"],
    ],
  },
  {
    id: "bottom",
    label: "下段",
    keys: [
      ["z", "Z"],
      ["x", "X"],
      ["c", "C"],
      ["v", "V"],
      ["b", "B"],
      ["n", "N"],
      ["m", "M"],
      [",", ","],
      [".", "."],
      ["/", "/"],
    ],
  },
];
const FINGERS = [
  { hand: "left", finger: "little", label: "小指" },
  { hand: "left", finger: "ring", label: "薬指" },
  { hand: "left", finger: "middle", label: "中指" },
  { hand: "left", finger: "index", label: "人差" },
  { hand: "left", finger: "index", label: "人差" },
  { hand: "right", finger: "index", label: "人差" },
  { hand: "right", finger: "index", label: "人差" },
  { hand: "right", finger: "middle", label: "中指" },
  { hand: "right", finger: "ring", label: "薬指" },
  { hand: "right", finger: "little", label: "小指" },
];
const KEY_GUIDE = KEYBOARD_ROWS.flatMap((row) =>
  row.keys.map(([key, label], column) => ({
    key,
    label,
    row: row.id,
    ...FINGERS[column],
  })),
);
const MODE_LABELS = {
  number: "数字段",
  top: "上段",
  home: "ホームポジション段",
  bottom: "下段",
  numpad: "テンキー",
  japanese: "日本語単語",
};
const ROMAJI_TABLE = {
  あ:["a"],い:["i"],う:["u"],え:["e"],お:["o"],
  か:["ka"],き:["ki"],く:["ku"],け:["ke"],こ:["ko"],
  さ:["sa"],し:["si","shi"],す:["su"],せ:["se"],そ:["so"],
  た:["ta"],ち:["ti","chi"],つ:["tu","tsu"],て:["te"],と:["to"],
  な:["na"],に:["ni"],ぬ:["nu"],ね:["ne"],の:["no"],
  は:["ha"],ひ:["hi"],ふ:["hu","fu"],へ:["he"],ほ:["ho"],
  ま:["ma"],み:["mi"],む:["mu"],め:["me"],も:["mo"],
  や:["ya"],ゆ:["yu"],よ:["yo"],
  ら:["ra"],り:["ri"],る:["ru"],れ:["re"],ろ:["ro"],
  わ:["wa"],を:["wo"],
  が:["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","jya","ja"],じゅ:["zyu","jyu","ju"],じょ:["zyo","jyo","jo"],
  びゃ:["bya"],びゅ:["byu"],びょ:["byo"],ぴゃ:["pya"],ぴゅ:["pyu"],ぴょ:["pyo"],
  ぁ:["xa","la"],ぃ:["xi","li"],ぅ:["xu","lu"],ぇ:["xe","le"],ぉ:["xo","lo"],
  ゃ:["xya","lya"],ゅ:["xyu","lyu"],ょ:["xyo","lyo"],
  ふぁ:["fa","huxa","hula"],ふぃ:["fi","huxi","huli"],ふぇ:["fe","huxe","hule"],ふぉ:["fo","huxo","hulo"],
  てぃ:["thi","texi","teli"],でぃ:["dhi","dexi","deli"],とぅ:["twu","toxu","tolu"],どぅ:["dwu","doxu","dolu"],
  うぃ:["wi","uxi","uli"],うぇ:["we","uxe","ule"],うぉ:["who","uxo","ulo"],
  しぇ:["she","sye"],じぇ:["je","jye","zye"],ちぇ:["che","tye"],
  ゔ:["vu"],ゔぁ:["va"],ゔぃ:["vi"],ゔぇ:["ve"],ゔぉ:["vo"],ゔゅ:["vyu"],
  "ー":["-"]
};
const ROMAJI_KEYS = Object.keys(ROMAJI_TABLE).sort((a, b) => b.length - a.length);
const JAPANESE_WORDS = [
  ["車","くるま"],["桜","さくら"],["寿司","すし"],["つくし","つくし"],["地図","ちず"],
  ["富士","ふじ"],["写真","しゃしん"],["今日","きょう"],["ちょっと","ちょっと"],["学校","がっこう"],
  ["切手","きって"],["雑誌","ざっし"],["発表会","はっぴょうかい"],["簡単","かんたん"],["散歩","さんぽ"],
  ["今夜","こんや"],["信用","しんよう"],["全員","ぜんいん"],["ファッション","ファッション"],["コンピューター","コンピューター"]
].map(([word, reading]) => ({ word, reading }));

function normalizeKana(value) {
  return [...String(value).normalize("NFKC")].map((char) => {
    const code = char.charCodeAt(0);
    return code >= 0x30a1 && code <= 0x30f6 ? String.fromCharCode(code - 0x60) : char;
  }).join("");
}
function tokenizeKana(reading) {
  const source = normalizeKana(reading), tokens = [];
  for (let index = 0; index < source.length;) {
    if (source[index] === "っ" || source[index] === "ん") {
      tokens.push(source[index++]);
      continue;
    }
    const token = ROMAJI_KEYS.find((key) => source.startsWith(key, index));
    if (!token) throw new Error(`未対応文字「${source[index]}」`);
    tokens.push(token);
    index += token.length;
  }
  return tokens;
}
function choicesForToken(tokens, index) {
  const token = tokens[index];
  if (token === "ん") {
    if (index === tokens.length - 1) return ["n", "nn"];
    const next = choicesForToken(tokens, index + 1);
    const ambiguous = next.some((value) => /^[aiueoy]/.test(value));
    return ambiguous ? ["nn"] : ["n", "nn"];
  }
  if (token === "っ") {
    if (index === tokens.length - 1) return ["xtu", "ltu"];
    const heads = choicesForToken(tokens, index + 1)
      .map((value) => value[0])
      .filter((value) => value && !/[aiueonxl-]/.test(value));
    return [...new Set([...heads, "xtu", "ltu"])];
  }
  return ROMAJI_TABLE[token] || [];
}
function createRomajiPaths(reading, limit = 20000) {
  const tokens = tokenizeKana(reading);
  let paths = [""];
  let truncated = false;
  tokens.forEach((token, index) => {
    const choices = choicesForToken(tokens, index);
    const next = [];
    paths.forEach((path) => choices.forEach((choice) => {
      if (next.length < limit) next.push(path + choice);
      else truncated = true;
    }));
    paths = [...new Set(next)];
  });
  if (!paths.length) throw new Error("ローマ字入力経路を生成できません");
  return { tokens, paths, truncated };
}
function virtualInputTest(path, allPaths) {
  let prefix = "", candidates = allPaths;
  for (const key of path) {
    prefix += key;
    candidates = candidates.filter((candidate) => candidate.startsWith(prefix));
    if (!candidates.length) return false;
  }
  return candidates.includes(prefix);
}
window.TypingLabRomaji = { normalizeKana, tokenizeKana, createRomajiPaths, virtualInputTest, table: ROMAJI_TABLE };
const NUMPAD_FINGERS = {
  1: "index",
  4: "index",
  7: "index",
  2: "middle",
  5: "middle",
  8: "middle",
  3: "ring",
  6: "ring",
  9: "ring",
  0: "thumb",
  Enter: "little",
};
const PRACTICE_TIME = 60,
  FALL_SPEED = 16;
const APP_VERSION = "1.0",
  STORAGE_KEY = "typingLabRecords",
  SCHEMA_VERSION = 1,
  STAT_IDS = ["number", "top", "home", "bottom", "numpad"];
const MODE_ORDER = [...STAT_IDS, "japanese"];

function sortSelectedRows(rows) {
  const selected = new Set(rows);
  return MODE_ORDER.filter((id) => selected.has(id));
}

const $ = (id) => document.getElementById(id);
const playArea = $("play-area"),
  fallingKey = $("falling-key"),
  effectLayer = $("effect-layer"),
  startPanel = $("start-panel"),
  countdown = $("countdown"),
  resultPanel = $("result-panel");
const timeDisplay = $("time"),
  scoreDisplay = $("score"),
  hitsDisplay = $("hits"),
  missesDisplay = $("misses"),
  finalScore = $("final-score"),
  finalHits = $("final-hits"),
  finalMisses = $("final-misses"),
  finalMaxCombo = $("final-max-combo"),
  message = $("message");
const screenKeyboard = $("screen-keyboard"),
  hands = $("hands"),
  standardGuide = $("standard-guide"),
  numpadGuide = $("numpad-guide"),
  screenNumpad = $("screen-numpad"),
  numpadHand = $("numpad-hand");
const numpadQuestion = $("numpad-question"),
  numberTrack = $("number-track"),
  soundToggle = $("sound-toggle"),
  exitPanel = $("exit-panel"),
  exitHint = $("exit-hint");
const japaneseQuestion = $("japanese-question"),
  japaneseWord = $("japanese-word"),
  japaneseReading = $("japanese-reading"),
  romajiGuide = $("romaji-guide"),
  romajiBranchHint = $("romaji-branch-hint"),
  capsWarning = $("caps-warning");
const modeInputs = [
  ...document.querySelectorAll('input[name="practice-mode"]'),
];
let state = "waiting",
  selectedMode = "standard",
  selectedRows = new Set(["home"]),
  practiceKeys = [],
  currentKey = "",
  score = 0,
  hits = 0,
  misses = 0,
  keyX = 0,
  keyY = 0,
  previousFrameTime = 0,
  finishTime = 0;
let animationId = 0,
  countdownTimerId = 0,
  nextKeyTimerId = 0,
  japaneseCompletionTimerId = 0,
  soundEnabled = true,
  audioContext = null,
  pausedAt = 0,
  pausedState = "playing";
let currentNumber = "",
  nextNumber = "",
  numberQueue = [],
  inputIndex = 0,
  transitionSerial = 0;
let japaneseWordIndex = -1,
  japanesePrefix = "",
  japaneseCandidates = [],
  japaneseGuidePath = "",
  uppercaseStreak = 0,
  checkerRows = [],
  checkerFilter = "all";
let challengeEnabled = false,
  challengeLevel = 1,
  combo = 0,
  maxCombo = 0,
  challengeLetters = [],
  placementTimerId = 0,
  placementIndex = 0;
const challengeToggle = $("challenge-toggle"),
  levelControl = $("level-control"),
  levelSlider = $("level-slider"),
  challengeKeys = $("challenge-keys"),
  comboDisplay = $("combo-display"),
  placementLine = $("placement-line");
const recordsScreen = $("records-screen"),
  recordsContent = $("records-content"),
  rankingContent = $("ranking-content"),
  chartsContent = $("charts-content"),
  csvContent = $("csv-content"),
  checkerContent = $("checker-content"),
  rankingList = $("ranking-list");
let playStartedAt = 0,
  questionStartedAt = 0,
  playStats = createEmptyStats(),
  lastSavedPlay = null,
  pendingImport = null;

buildKeyboard();
buildHands();
buildNumpad();
buildNumpadHand();
updateModeSelection();
startPlacementGuide();
document.addEventListener("keydown", handleKeydown);
modeInputs.forEach((input) =>
  input.addEventListener("change", () => handleModeChange(input)),
);
$("change-mode").addEventListener("click", returnToSelection);
soundToggle.addEventListener("click", toggleSound);
challengeToggle.addEventListener("click", toggleChallenge);
levelSlider.addEventListener("input", () => {
  challengeLevel = Number(levelSlider.value);
  $("level-value").textContent = challengeLevel;
  updateModeSelection();
});
$("exit-practice").addEventListener("click", () => finishPractice(true));
$("continue-practice").addEventListener("click", resumePractice);
$("open-records").addEventListener("click", () => openRecords("records"));
$("open-ranking").addEventListener("click", () => openRecords("ranking"));
$("open-charts").addEventListener("click", () => openRecords("charts"));
$("open-csv").addEventListener("click", () => openRecords("csv"));
$("result-record-link").addEventListener("click", () => openRecords("records"));
$("result-ranking-link").addEventListener("click", () => openRecords("ranking"));
$("close-records").addEventListener("click", closeRecords);
$("records-tab").addEventListener("click", () => switchRecordsTab("records"));
$("ranking-tab").addEventListener("click", () => switchRecordsTab("ranking"));
$("charts-tab").addEventListener("click", () => switchRecordsTab("charts"));
$("csv-tab").addEventListener("click", () => switchRecordsTab("csv"));
$("checker-tab").addEventListener("click", () => switchRecordsTab("checker"));
$("ranking-mode").addEventListener("change", renderRanking);
$("ranking-level").addEventListener("change", renderRanking);
$("chart-range").addEventListener("change", renderCharts);
$("export-csv").addEventListener("click", () => exportRecordsCsv());
$("import-file").addEventListener("change", handleImportFile);
$("import-add").addEventListener("click", importAdditionalRecords);
$("import-replace").addEventListener("click", replaceAllRecords);
$("delete-records").addEventListener("click", deleteAllRecords);
$("checker-file").addEventListener("change", handleCheckerFile);
$("download-sample-csv").addEventListener("click", downloadSampleCsv);
document.querySelectorAll("[data-check-filter]").forEach((button) => button.addEventListener("click", () => {
  checkerFilter = button.dataset.checkFilter;
  document.querySelectorAll("[data-check-filter]").forEach((item) => item.classList.toggle("is-active", item === button));
  renderCheckerResults();
}));
for (let level = 1; level <= 10; level++) {
  const option = document.createElement("option");
  option.value = String(level);
  option.textContent = `Level ${level}`;
  $("ranking-level").appendChild(option);
}

function buildKeyboard() {
  KEYBOARD_ROWS.forEach((rowData) => {
    const row = document.createElement("div");
    row.className = "keyboard-row";
    row.dataset.row = rowData.id;
    row.setAttribute("aria-label", rowData.label);
    rowData.keys.forEach(([keyName, label], column) => {
      const guide = KEY_GUIDE.find((item) => item.key === keyName),
        key = document.createElement("button");
      key.type = "button";
      key.className = "keycap";
      key.dataset.key = keyName;
      key.dataset.zone = `${guide.hand}-${guide.finger}`;
      key.dataset.bump = String(keyName === "f" || keyName === "j");
      key.textContent = label;
      key.tabIndex = -1;
      row.appendChild(key);
    });
    screenKeyboard.appendChild(row);
  });
}
function buildHands() {
  const order = {
      left: ["little", "ring", "middle", "index"],
      right: ["index", "middle", "ring", "little"],
    },
    labels = { little: "小", ring: "薬", middle: "中", index: "人差" },
    heights = { little: "43px", ring: "55px", middle: "62px", index: "57px" };
  ["left", "right"].forEach((side) => {
    const hand = document.createElement("div");
    hand.className = "hand";
    order[side].forEach((name) => {
      const finger = document.createElement("span");
      finger.className = "finger";
      finger.dataset.hand = side;
      finger.dataset.finger = name;
      finger.style.setProperty("--finger-height", heights[name]);
      finger.textContent = labels[name];
      hand.appendChild(finger);
    });
    hands.appendChild(hand);
  });
}
function buildNumpad() {
  [
    ["7", "7"],
    ["8", "8"],
    ["9", "9"],
    ["4", "4"],
    ["5", "5"],
    ["6", "6"],
    ["1", "1"],
    ["2", "2"],
    ["3", "3"],
    ["0", "0"],
    [".", "."],
    ["Enter", "Enter"],
  ].forEach(([name, label]) => {
    const key = document.createElement("button");
    key.type = "button";
    key.tabIndex = -1;
    key.className = "numpad-key";
    key.dataset.numpadKey = name;
    key.dataset.finger = NUMPAD_FINGERS[name] || "none";
    key.dataset.bump = String(name === "5");
    key.textContent = label;
    if (name === ".") key.classList.add("is-disabled");
    screenNumpad.appendChild(key);
  });
}
function buildNumpadHand() {
  [
    ["thumb", "親"],
    ["index", "人差"],
    ["middle", "中"],
    ["ring", "薬"],
    ["little", "小"],
  ].forEach(([name, label]) => {
    const finger = document.createElement("span");
    finger.className = "numpad-finger";
    finger.dataset.finger = name;
    finger.textContent = label;
    numpadHand.appendChild(finger);
  });
}
function handleModeChange(input) {
  if (input.value === "numpad" || input.value === "japanese") {
    selectedMode = input.value;
    selectedRows.clear();
    modeInputs.forEach((item) => (item.checked = item === input));
    if (selectedMode === "japanese" && challengeEnabled) setChallengeEnabled(false);
  } else {
    if (selectedMode === "numpad" || selectedMode === "japanese") {
      selectedMode = "standard";
      selectedRows = new Set([input.value]);
    } else if (input.checked) selectedRows.add(input.value);
    else selectedRows.delete(input.value);
    modeInputs.forEach(
      (item) =>
        (item.checked =
          item.value !== "numpad" && selectedRows.has(item.value)),
    );
  }
  updateModeSelection();
  startPlacementGuide();
}
function updateModeSelection() {
  const isNumpad = selectedMode === "numpad";
  const isJapanese = selectedMode === "japanese";
  practiceKeys = isNumpad || isJapanese
    ? []
    : KEY_GUIDE.filter((item) => selectedRows.has(item.row)).map(
        (item) => item.key,
      );
  standardGuide.hidden = isNumpad;
  numpadGuide.hidden = !isNumpad;
  updateGuideTitle();
  document.querySelectorAll(".keyboard-row").forEach((row) => {
    const active = selectedRows.has(row.dataset.row);
    row.classList.toggle("is-active-row", active);
    row.classList.toggle("is-inactive-row", !active);
  });
  $("start-description").textContent = isJapanese
    ? "60秒間、日本語の読みをローマ字で入力しましょう"
    : isNumpad
    ? `NumLockをONにして、数字の後にEnterを押しましょう${challengeEnabled ? `(3~${challengeLevel + 4}桁)` : ""}`
    : challengeEnabled
      ? `60秒チャレンジ:画面内に最大${challengeLevel}文字`
      : "60秒間、表示された文字を入力しましょう";
  document.body.classList.toggle("is-challenge", challengeEnabled);
  $("app-title").textContent = challengeEnabled
    ? "段別・テンキー チャレンジモード"
    : isJapanese ? "日本語ローマ字入力" : "タイピング練習";
  $("score-label").textContent = challengeEnabled ? "スコア" : "打数";
  $("final-score-label").textContent = challengeEnabled ? "スコア" : "打数";
  $("hits-status").hidden = !challengeEnabled;
  $("final-hits-item").hidden = !challengeEnabled;
  $("final-max-combo-item").hidden = !challengeEnabled;
  document.querySelectorAll('.mode-option input:not([value="numpad"]):not([value="japanese"]) + span').forEach((span) => {
    const base = span.dataset.baseLabel || span.firstChild.textContent.trim();
    span.dataset.baseLabel = base;
    span.firstChild.textContent = challengeEnabled ? `${base}(チャレンジモード)` : base;
  });
  levelControl.hidden = !challengeEnabled;
  challengeToggle.disabled = isJapanese;
  challengeToggle.title = isJapanese ? "日本語単語モードでは使用しません" : "";
  message.textContent = selectedRows.size || isNumpad || isJapanese
    ? `${getModeLabel()}${challengeEnabled ? "チャレンジ" : ""}。Spaceキーを押してください`
    : "練習する段を選んでください";
}
function updateGuideTitle() {
  $("guide-title").textContent = state === "waiting"
    ? "光った指を、光ったキーに置いてみましょう"
    : "光っているキーを、光っている指で押しましょう";
}
function getModeLabel() {
  return selectedMode === "numpad"
    ? MODE_LABELS.numpad
    : selectedMode === "japanese"
      ? MODE_LABELS.japanese
    : KEYBOARD_ROWS.filter((row) => selectedRows.has(row.id))
        .map((row) => MODE_LABELS[row.id])
        .join("+");
}
function toggleChallenge() {
  if (selectedMode === "japanese") return;
  setChallengeEnabled(!challengeEnabled);
}
function setChallengeEnabled(enabled) {
  challengeEnabled = enabled;
  challengeToggle.setAttribute("aria-pressed", String(challengeEnabled));
  challengeToggle.classList.toggle("is-on", challengeEnabled);
  challengeToggle.textContent = `${challengeEnabled ? "✓ " : ""}チャレンジモード`;
  updateModeSelection();
}
function handleKeydown(event) {
  if (!recordsScreen.hidden) {
    if (event.code === "Escape") closeRecords();
    return;
  }
  if (event.code === "Escape" && (state === "playing" || state === "hit")) {
    event.preventDefault();
    pausePractice();
    return;
  }
  if (event.code === "Escape" && state === "paused") {
    event.preventDefault();
    resumePractice();
    return;
  }
  if (event.code === "Space" && (state === "waiting" || state === "finished")) {
    event.preventDefault();
    if (selectedMode === "standard" && selectedRows.size === 0) {
      $("selection-warning").hidden = false;
      message.textContent = "練習する段を1つ以上選択してください";
      return;
    }
    startCountdown();
    return;
  }
  if (
    state !== "playing" ||
    event.repeat ||
    event.ctrlKey ||
    event.altKey ||
    event.metaKey
  )
    return;
  if (selectedMode === "numpad") handleNumpadInput(event);
  else if (selectedMode === "japanese") handleJapaneseInput(event);
  else handleStandardInput(event);
}
function handleStandardInput(event) {
  const typed = event.key.toLowerCase();
  if (challengeEnabled) {
    const candidates = challengeLetters.filter((item) => item.key === typed && !item.hit);
    if (candidates.length) {
      event.preventDefault();
      hitChallengeLetter(candidates[Math.floor(Math.random() * candidates.length)]);
    } else if (event.key.length === 1) registerMiss(typed, getStandardKey(typed));
    return;
  }
  if (typed === currentKey) {
    event.preventDefault();
    handleCorrectStandard();
  } else if (event.key.length === 1) {
    registerMiss(typed, getStandardKey(typed) || getStandardKey(currentKey));
  }
}
function handleJapaneseInput(event) {
  if (event.key.length !== 1) return;
  const raw = event.key;
  const typed = raw.toLowerCase();
  if (!/^[a-z-]$/.test(typed)) {
    registerMiss(typed, getStandardKey(currentKey));
    return;
  }
  event.preventDefault();
  const nextPrefix = japanesePrefix + typed;
  const remaining = japaneseCandidates.filter((path) => path.startsWith(nextPrefix));
  const capsOn = typeof event.getModifierState === "function" && event.getModifierState("CapsLock");
  uppercaseStreak = /^[A-Z]$/.test(raw) ? uppercaseStreak + 1 : 0;
  capsWarning.hidden = !(capsOn || uppercaseStreak >= 2);
  if (!remaining.length) {
    registerMiss(typed, getStandardKey(typed) || getStandardKey(currentKey));
    return;
  }
  clearTimeout(japaneseCompletionTimerId);
  const source = getStandardKey(typed);
  const target = romajiGuide.querySelector(".is-next");
  recordCorrect(getRowForKey(typed));
  registerHit();
  addScore(1);
  japanesePrefix = nextPrefix;
  japaneseCandidates = remaining;
  if (!japaneseGuidePath.startsWith(japanesePrefix)) japaneseGuidePath = remaining[0];
  source?.classList.add("is-correct");
  shootLaser(source, target);
  playSound("key10");
  setTimeout(() => source?.classList.remove("is-correct"), 100);
  questionStartedAt = performance.now();
  if (remaining.includes(japanesePrefix)) {
    const longer = remaining.filter((path) => path.length > japanesePrefix.length);
    if (longer.length) {
      japaneseGuidePath = longer[0];
      renderRomajiGuide();
      const completedPrefix = japanesePrefix;
      japaneseCompletionTimerId = setTimeout(() => {
        if (state === "playing" && japanesePrefix === completedPrefix) {
          playSound("shift7");
          showNextJapaneseWord();
        }
      }, 240);
    } else {
      playSound("shift7");
      showNextJapaneseWord();
    }
  } else renderRomajiGuide();
}
function showNextJapaneseWord() {
  if (state !== "playing") return;
  japaneseWordIndex = (japaneseWordIndex + 1) % JAPANESE_WORDS.length;
  const item = JAPANESE_WORDS[japaneseWordIndex];
  const generated = createRomajiPaths(item.reading);
  japanesePrefix = "";
  japaneseCandidates = generated.paths;
  japaneseGuidePath = generated.paths[0];
  japaneseWord.textContent = item.word;
  japaneseReading.textContent = item.reading;
  uppercaseStreak = 0;
  capsWarning.hidden = true;
  questionStartedAt = performance.now();
  renderRomajiGuide();
}
function renderRomajiGuide() {
  romajiGuide.replaceChildren();
  [...japaneseGuidePath].forEach((char, index) => {
    const span = document.createElement("span");
    span.textContent = char;
    if (index < japanesePrefix.length) span.className = "is-entered";
    else if (index === japanesePrefix.length) span.className = "is-next";
    romajiGuide.appendChild(span);
  });
  const nextKeys = [...new Set(japaneseCandidates
    .map((path) => path[japanesePrefix.length])
    .filter(Boolean))];
  romajiBranchHint.replaceChildren();
  romajiBranchHint.hidden = nextKeys.length < 2;
  if (nextKeys.length >= 2) {
    romajiBranchHint.append("次のキーは ");
    const keys = document.createElement("strong");
    keys.textContent = nextKeys.map((key) => key.toUpperCase()).join(" / ");
    romajiBranchHint.append(keys, " のどちらでも入力できます");
  }
  currentKey = japaneseGuidePath[japanesePrefix.length] || "";
  highlightStandard(currentKey);
}
function handleNumpadInput(event) {
  const typed = event.code.startsWith("Numpad")
    ? event.code === "NumpadEnter"
      ? "Enter"
      : event.code.replace("Numpad", "")
    : "";
  const expected =
    inputIndex < currentNumber.length ? currentNumber[inputIndex] : "Enter";
  if (typed === expected) {
    event.preventDefault();
    if (expected === "Enter") completeNumpadQuestion();
    else acceptNumpadDigit(typed);
  } else if (typed || event.key.length === 1 || event.key === "Enter")
    registerMiss(
      typed || event.key,
      getNumpadKey(typed) || getNumpadKey(expected),
    );
}
function startCountdown() {
  if (soundEnabled) getAudioContext();
  clearTimeout(countdownTimerId);
  clearTimeout(nextKeyTimerId);
  clearTimeout(japaneseCompletionTimerId);
  cancelAnimationFrame(animationId);
  transitionSerial++;
  stopPlacementGuide();
  state = "countdown";
  updateGuideTitle();
  score = hits = misses = 0;
  combo = 0;
  maxCombo = 0;
  playStats = createEmptyStats();
  lastSavedPlay = null;
  challengeLetters = [];
  challengeKeys.replaceChildren();
  updateComboDisplay();
  $("selection-warning").hidden = true;
  timeDisplay.textContent = PRACTICE_TIME;
  scoreDisplay.textContent = hitsDisplay.textContent = missesDisplay.textContent = "0";
  startPanel.hidden = true;
  resultPanel.hidden = true;
  fallingKey.hidden = true;
  numpadQuestion.hidden = true;
  japaneseQuestion.hidden = true;
  effectLayer.replaceChildren();
  clearGuide();
  exitPanel.hidden = true;
  exitHint.hidden = true;
  countdown.hidden = false;
  message.textContent = `${getModeLabel()}のカウントダウン中です`;
  modeInputs.forEach((i) => (i.disabled = true));
  const labels = ["3", "2", "1", "START"];
  let index = 0;
  const show = () => {
    countdown.textContent = labels[index++];
    countdownTimerId = setTimeout(
      index < labels.length ? show : beginPractice,
      index < labels.length ? 1000 : 700,
    );
  };
  show();
}
function beginPractice() {
  state = "playing";
  countdown.hidden = true;
  exitHint.hidden = false;
  finishTime = performance.now() + PRACTICE_TIME * 1000;
  playStartedAt = performance.now();
  questionStartedAt = playStartedAt;
  previousFrameTime = performance.now();
  if (selectedMode === "numpad") {
    numberQueue = Array.from({ length: 4 }, randomNumber);
    currentNumber = numberQueue[0];
    nextNumber = numberQueue[1];
    inputIndex = 0;
    numpadQuestion.hidden = false;
    renderNumberReel();
    message.textContent =
      "数字を順番に入力し、最後にテンキーのEnterを押してください";
  } else if (selectedMode === "japanese") {
    japaneseWordIndex = Math.floor(Math.random() * JAPANESE_WORDS.length) - 1;
    japaneseQuestion.hidden = false;
    message.textContent = "日本語の読みを、表示されたローマ字経路で入力してください";
    showNextJapaneseWord();
  } else {
    message.textContent = "表示された文字を、光っている指で押してください";
    if (challengeEnabled) fillChallengeLetters();
    else showNextStandardKey();
  }
  animationId = requestAnimationFrame(update);
}
function showNextStandardKey() {
  if (state !== "playing") return;
  fallingKey.classList.remove("is-hit");
  currentKey = practiceKeys[Math.floor(Math.random() * practiceKeys.length)];
  questionStartedAt = performance.now();
  fallingKey.textContent = currentKey.toUpperCase();
  fallingKey.hidden = false;
  const margin = 24,
    max = Math.max(
      margin,
      playArea.clientWidth - fallingKey.offsetWidth - margin,
    );
  keyX = margin + Math.random() * (max - margin);
  keyY = 54;
  drawKey();
  highlightStandard(currentKey);
}
function handleCorrectStandard() {
  recordCorrect(getRowForKey(currentKey));
  registerHit();
  score++;
  scoreDisplay.textContent = score;
  const key = getStandardKey(currentKey),
    oldKey = document.createElement("span");
  oldKey.className = "effect-key";
  oldKey.textContent = fallingKey.textContent;
  oldKey.style.setProperty("--effect-x", `${keyX}px`);
  oldKey.style.setProperty("--effect-y", `${keyY}px`);
  oldKey.style.transform = `translate(var(--effect-x),var(--effect-y))`;
  effectLayer.appendChild(oldKey);
  key?.classList.add("is-correct");
  shootLaser(key, oldKey);
  createSparks(oldKey);
  requestAnimationFrame(() => oldKey.classList.add("is-hit"));
  setTimeout(() => playSound("break"), 75);
  setTimeout(() => oldKey.remove(), 240);
  setTimeout(() => key?.classList.remove("is-correct"), 100);
  if (performance.now() >= finishTime) return finishPractice();
  previousFrameTime = performance.now();
  showNextStandardKey();
}
function randomNumber() {
  const maxLength = challengeEnabled ? challengeLevel + 4 : 5;
  const length = 3 + Math.floor(Math.random() * (maxLength - 2));
  let value = String(1 + Math.floor(Math.random() * 9));
  while (value.length < length) value += Math.floor(Math.random() * 10);
  return value;
}
function formatNumber(value) {
  return Number(value).toLocaleString("ja-JP");
}
function createNumberRow(value, offset) {
  const row = document.createElement("div");
  row.className = "number-row";
  row.dataset.offset = String(offset);
  row.dataset.value = value;
  row.dataset.length = String(value.length);
  row.setAttribute(
    "aria-label",
    offset === 0
      ? `現在の問題 ${formatNumber(value)}`
      : `次の問題 ${formatNumber(value)}`,
  );
  fillNumberRow(row, value, offset === 0);
  return row;
}
function fillNumberRow(row, value, isCurrent) {
  row.replaceChildren();
  [...value].forEach((digit, index) => {
    const span = document.createElement("span");
    span.textContent = digit;
    if (isCurrent)
      span.className =
        index < inputIndex
          ? "is-entered"
          : index === inputIndex
            ? "is-current"
            : "";
    row.appendChild(span);
    const remaining = value.length - index - 1;
    if (remaining > 0 && remaining % 3 === 0) {
      const comma = document.createElement("i");
      comma.textContent = ",";
      comma.className = "comma";
      row.appendChild(comma);
    }
  });
  const enter = document.createElement("kbd");
  enter.textContent = "Enter";
  enter.className = "question-enter";
  enter.classList.toggle("is-ready", isCurrent && inputIndex === value.length);
  row.appendChild(enter);
}
function renderNumberReel() {
  numberTrack.replaceChildren(
    ...numberQueue
      .slice(0, 3)
      .map((value, index) => createNumberRow(value, index)),
  );
  highlightNumpad(
    inputIndex < currentNumber.length ? currentNumber[inputIndex] : "Enter",
  );
}
function refreshCurrentRow() {
  const row = numberTrack.querySelector('.number-row[data-offset="0"]');
  if (row) fillNumberRow(row, currentNumber, true);
  highlightNumpad(
    inputIndex < currentNumber.length ? currentNumber[inputIndex] : "Enter",
  );
}
function acceptNumpadDigit(digit) {
  if (!challengeEnabled) recordCorrect("numpad");
  if (challengeEnabled) registerChallengeCorrect();
  else addScore(1);
  const row = numberTrack.querySelector('.number-row[data-offset="0"]'),
    source = getNumpadKey(digit),
    target = [...row.querySelectorAll("span")][inputIndex];
  source?.classList.add("is-correct");
  shootLaser(source, target);
  playSound("key10");
  setTimeout(() => source?.classList.remove("is-correct"), 100);
  inputIndex++;
  questionStartedAt = performance.now();
  refreshCurrentRow();
}
function completeNumpadQuestion() {
  if (!challengeEnabled) recordCorrect("numpad");
  if (challengeEnabled) registerChallengeCorrect();
  else addScore(1);
  playSound("shift7");
  const serial = ++transitionSerial,
    rows = [...numberTrack.querySelectorAll(".number-row")];
  numberQueue.shift();
  numberQueue.push(randomNumber());
  currentNumber = numberQueue[0];
  nextNumber = numberQueue[1];
  inputIndex = 0;
  questionStartedAt = performance.now();
  rows.forEach((row) => {
    const offset = Number(row.dataset.offset) - 1;
    if (offset < -1) {
      row.remove();
      return;
    }
    row.dataset.offset = String(offset);
    row.setAttribute(
      "aria-label",
      offset === 0
        ? `現在の問題 ${formatNumber(row.dataset.value)}`
        : `次の問題 ${formatNumber(row.dataset.value)}`,
    );
  });
  const incoming = createNumberRow(numberQueue[2], 2);
  incoming.classList.add("is-new");
  numberTrack.appendChild(incoming);
  requestAnimationFrame(() => incoming.classList.remove("is-new"));
  refreshCurrentRow();
  setTimeout(() => {
    if (serial !== transitionSerial) return;
    numberTrack.querySelector('.number-row[data-offset="-1"]')?.remove();
  }, 310);
}
function update(now) {
  if (["finished", "waiting", "countdown", "paused"].includes(state)) return;
  const remaining = finishTime - now;
  if (remaining <= 0) return finishPractice();
  timeDisplay.textContent = Math.ceil(remaining / 1000);
  if (selectedMode === "standard" && state === "playing" && challengeEnabled) {
    const dt = Math.min((now - previousFrameTime) / 1000, 0.1);
    challengeLetters.forEach((item) => {
      if (item.hit || item.stopped) return;
      item.y = Math.min(item.y + FALL_SPEED * dt, playArea.clientHeight - item.el.offsetHeight - 20);
      item.stopped = item.y >= playArea.clientHeight - item.el.offsetHeight - 20;
      item.el.style.transform = `translate(${item.x}px,${item.y}px)`;
    });
    previousFrameTime = now;
  } else if (selectedMode === "standard" && state === "playing") {
    keyY = Math.min(
      keyY + FALL_SPEED * Math.min((now - previousFrameTime) / 1000, 0.1),
      playArea.clientHeight - fallingKey.offsetHeight - 20,
    );
    previousFrameTime = now;
    drawKey();
  }
  animationId = requestAnimationFrame(update);
}
function drawKey() {
  fallingKey.style.transform = `translate(${keyX}px,${keyY}px)`;
}
function highlightStandard(name) {
  clearGuide();
  const guide = KEY_GUIDE.find((item) => item.key === name);
  getStandardKey(name)?.classList.add("is-target");
  if (!guide) return;
  document
    .querySelector(
      `.finger[data-hand="${guide.hand}"][data-finger="${guide.finger}"]`,
    )
    ?.classList.add("is-target");
  document
    .querySelectorAll(`.keycap[data-zone="${guide.hand}-${guide.finger}"]`)
    .forEach((key) => key.classList.add("is-zone-target"));
}
function highlightNumpad(name) {
  clearGuide();
  const key = getNumpadKey(name),
    finger = NUMPAD_FINGERS[name];
  key?.classList.add("is-target");
  if (finger) {
    document
      .querySelector(`.numpad-finger[data-finger="${finger}"]`)
      ?.classList.add("is-target");
    document
      .querySelectorAll(`.numpad-key[data-finger="${finger}"]`)
      .forEach((k) => k.classList.add("is-zone-target"));
  }
}
function clearGuide() {
  document
    .querySelectorAll(".keycap,.finger,.numpad-key,.numpad-finger")
    .forEach((el) =>
      el.classList.remove(
        "is-target",
        "is-zone-target",
        "is-correct",
        "is-wrong",
      ),
    );
}
function registerMiss(name, source) {
  misses++;
  if (!challengeEnabled) {
    const statId = selectedMode === "numpad" ? "numpad" : getRowForKey(currentKey);
    if (playStats[statId]) playStats[statId].misses++;
  }
  missesDisplay.textContent = misses;
  if (challengeEnabled) {
    addScore(-20);
    breakCombo();
  }
  if (source) {
    source.classList.remove("is-wrong");
    void source.offsetWidth;
    source.classList.add("is-wrong");
    setTimeout(() => source.classList.remove("is-wrong"), 260);
  }
  playSound("wrong");
  shootWrongLaser(source);
}
function getStandardKey(name) {
  return document.querySelector(`.keycap[data-key="${CSS.escape(name)}"]`);
}
function getNumpadKey(name) {
  return document.querySelector(
    `.numpad-key[data-numpad-key="${CSS.escape(name)}"]`,
  );
}
function shootLaser(source, target) {
  if (!source || !target) return;
  const a = source.getBoundingClientRect(),
    b = target.getBoundingClientRect(),
    sx = a.left + a.width / 2,
    sy = a.top + 3,
    ex = b.left + b.width / 2,
    ey = b.top + b.height / 2,
    d = Math.hypot(ex - sx, ey - sy),
    angle = (Math.atan2(ey - sy, ex - sx) * 180) / Math.PI,
    laser = document.createElement("span");
  laser.className = "laser";
  laser.style.cssText = `left:${sx}px;top:${sy}px;width:${d}px;transform:rotate(${angle}deg)`;
  document.body.appendChild(laser);
  setTimeout(() => laser.remove(), 220);
}
function shootWrongLaser(source) {
  if (!source) return;
  const r = source.getBoundingClientRect(),
    sx = r.left + r.width / 2,
    sy = r.top + 3,
    angle = -155 + Math.random() * 130,
    distance = 75 + Math.random() * 85,
    laser = document.createElement("span");
  laser.className = "wrong-laser";
  laser.style.cssText = `left:${sx}px;top:${sy}px;width:${distance}px;transform:rotate(${angle}deg)`;
  document.body.appendChild(laser);
  setTimeout(() => {
    laser.remove();
    playSound("fizzle");
  }, 105);
}
function createSparks(target) {
  if (!target) return;
  const area = playArea.getBoundingClientRect(),
    rect = target.getBoundingClientRect(),
    x = rect.left + rect.width / 2 - area.left,
    y = rect.top + rect.height / 2 - area.top;
  for (let i = 0; i < 8; i++) {
    const angle = (Math.PI * 2 * i) / 8,
      s = document.createElement("span");
    s.className = "spark";
    s.style.left = `${x}px`;
    s.style.top = `${y}px`;
    s.style.setProperty("--spark-x", `${Math.cos(angle) * 45}px`);
    s.style.setProperty("--spark-y", `${Math.sin(angle) * 45}px`);
    effectLayer.appendChild(s);
    setTimeout(() => s.remove(), 330);
  }
}
function pausePractice() {
  pausedAt = performance.now();
  pausedState = state;
  state = "paused";
  cancelAnimationFrame(animationId);
  exitPanel.hidden = false;
  $("continue-practice").focus();
  message.textContent = "練習を一時停止しています";
}
function resumePractice() {
  if (state !== "paused") return;
  const pausedDuration = performance.now() - pausedAt;
  finishTime += pausedDuration;
  questionStartedAt += pausedDuration;
  exitPanel.hidden = true;
  state = pausedState;
  previousFrameTime = performance.now();
  message.textContent =
    selectedMode === "numpad"
      ? "数字を順番に入力し、最後にテンキーのEnterを押してください"
      : selectedMode === "japanese"
        ? "日本語の読みを、ローマ字で入力してください"
      : "表示された文字を、光っている指で押してください";
  animationId = requestAnimationFrame(update);
}
function finishPractice(early = false) {
  state = "finished";
  updateGuideTitle();
  transitionSerial++;
  clearTimeout(nextKeyTimerId);
  clearTimeout(japaneseCompletionTimerId);
  cancelAnimationFrame(animationId);
  timeDisplay.textContent = early
    ? Math.max(0, Math.ceil((finishTime - pausedAt) / 1000))
    : "0";
  fallingKey.hidden = true;
  numpadQuestion.hidden = true;
  japaneseQuestion.hidden = true;
  effectLayer.replaceChildren();
  challengeKeys.replaceChildren();
  challengeLetters = [];
  comboDisplay.hidden = true;
  clearGuide();
  exitPanel.hidden = true;
  exitHint.hidden = true;
  finalScore.textContent = score;
  finalHits.textContent = hits;
  finalMisses.textContent = misses;
  finalMaxCombo.textContent = maxCombo;
  lastSavedPlay = early ? null : saveCompletedPlay();
  renderCurrentResult(early, lastSavedPlay);
  resultPanel.hidden = false;
  message.textContent = `${early ? "途中終了しました。" : ""}${getModeLabel()}をもう一度練習するときはSpaceキーを押してください`;
}
function returnToSelection() {
  state = "waiting";
  updateGuideTitle();
  resultPanel.hidden = true;
  startPanel.hidden = false;
  exitHint.hidden = true;
  modeInputs.forEach((i) => (i.disabled = false));
  timeDisplay.textContent = PRACTICE_TIME;
  scoreDisplay.textContent = hitsDisplay.textContent = missesDisplay.textContent = "0";
  updateModeSelection();
  startPlacementGuide();
}
function createEmptyStats() {
  return Object.fromEntries(STAT_IDS.map((id) => [id, { hits: 0, misses: 0, inputMs: 0 }]));
}
function getRowForKey(key) {
  return KEY_GUIDE.find((item) => item.key === key)?.row || "home";
}
function recordCorrect(statId) {
  const stat = playStats[statId];
  if (!stat) return;
  stat.hits++;
  stat.inputMs += Math.max(0, performance.now() - questionStartedAt);
}
function accuracy(hitCount, missCount) {
  const total = hitCount + missCount;
  return total ? (hitCount / total) * 100 : 0;
}
function speedData(hitCount, inputMs) {
  const seconds = inputMs / 1000;
  const perSecond = seconds ? hitCount / seconds : 0;
  return { perSecond, perMinute: perSecond * 60 };
}
function speedText(hitCount, inputMs) {
  const speed = speedData(hitCount, inputMs);
  return `${speed.perSecond.toFixed(2)}打/秒(${Math.round(speed.perMinute)}打/分)`;
}
function createPlayId() {
  return `play-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function loadRecords() {
  try {
    const data = JSON.parse(localStorage.getItem(STORAGE_KEY));
    if (data && data.schemaVersion === SCHEMA_VERSION && Array.isArray(data.plays)) return data;
  } catch (error) {
    console.warn("成績データを読み込めませんでした。", error);
  }
  return { schemaVersion: SCHEMA_VERSION, plays: [] };
}
function writeRecords(data) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
    return true;
  } catch (error) {
    console.warn("成績データを保存できませんでした。", error);
    message.textContent = "成績を保存できませんでした。ブラウザの保存設定を確認してください";
    return false;
  }
}
function saveCompletedPlay() {
  const playedAt = new Date().toISOString();
  let play;
  if (challengeEnabled) {
    play = {
      schemaVersion: SCHEMA_VERSION,
      playId: createPlayId(),
      appVersion: APP_VERSION,
      playedAt,
      category: "challenge",
      challengeType: selectedMode === "numpad" ? "numpad" : "standard",
      selectedRows: selectedMode === "numpad" ? ["numpad"] : sortSelectedRows(selectedRows),
      level: challengeLevel,
      durationSeconds: PRACTICE_TIME,
      score,
      hits,
      misses,
      maxCombo,
      inputMs: PRACTICE_TIME * 1000,
      future: null,
    };
  } else {
    const rows = Object.fromEntries(
      STAT_IDS.map((id) => [id, { ...playStats[id] }]),
    );
    const totals = Object.values(rows).reduce(
      (sum, stat) => ({ hits: sum.hits + stat.hits, misses: sum.misses + stat.misses, inputMs: sum.inputMs + stat.inputMs }),
      { hits: 0, misses: 0, inputMs: 0 },
    );
    play = {
      schemaVersion: SCHEMA_VERSION,
      playId: createPlayId(),
      appVersion: APP_VERSION,
      playedAt,
      category: "practice",
      practiceType: selectedMode,
      selectedRows: selectedMode === "numpad" ? ["numpad"] : selectedMode === "japanese" ? ["japanese"] : sortSelectedRows(selectedRows),
      durationSeconds: PRACTICE_TIME,
      hits: totals.hits,
      misses: totals.misses,
      inputMs: totals.inputMs,
      rows,
      future: null,
    };
  }
  const records = loadRecords();
  records.plays.push(play);
  return writeRecords(records) ? play : null;
}
function renderCurrentResult(early, play) {
  const details = $("result-details");
  details.replaceChildren();
  $("final-accuracy-item").hidden = true;
  $("final-speed-item").hidden = true;
  $("ranking-result").hidden = true;
  $("result-ranking-link").hidden = !challengeEnabled;
  $("result-record-link").hidden = challengeEnabled;
  if (early || !play) {
    const note = document.createElement("p");
    note.className = "result-card";
    note.textContent = early ? "途中終了のため、この成績は保存されません。" : "成績を保存できませんでした。";
    details.appendChild(note);
    return;
  }
  if (play.category === "challenge") {
    setResultSpeed(play.hits, play.inputMs);
    const allRank = getChallengeRank(play, "all"), levelRank = getChallengeRank(play, play.level);
    const rank = $("ranking-result");
    rank.textContent = `今回 全Levelランキング ${allRank}位! Level ${play.level}ランキング ${levelRank}位`;
    rank.hidden = false;
    return;
  }
  $("final-accuracy").textContent = `${accuracy(play.hits, play.misses).toFixed(1)}%`;
  $("final-accuracy-item").hidden = false;
  setResultSpeed(play.hits, play.inputMs);
}
function setResultSpeed(hitCount, inputMs) {
  const seconds = inputMs > 0 ? hitCount / (inputMs / 1000) : 0;
  $("final-speed").textContent = `${seconds.toFixed(2)}打/秒`;
  $("final-speed-minute").textContent = `${Math.round(seconds * 60)}打/分`;
  $("final-speed-item").hidden = false;
}
function makeResultCard(title, lines) {
  const card = document.createElement("section"), heading = document.createElement("h3");
  card.className = "result-card";
  heading.textContent = title;
  card.appendChild(heading);
  lines.forEach((line) => { const p = document.createElement("p"); p.textContent = line; card.appendChild(p); });
  return card;
}
function makeStatCard(title, hitCount, missCount, inputMs) {
  return makeResultCard(title, [
    `打数:${hitCount} ミス:${missCount}`,
    `正確率:${accuracy(hitCount, missCount).toFixed(1)}%`,
    `速度:${speedText(hitCount, inputMs)}`,
  ]);
}
function compareChallenge(a, b) {
  return b.score - a.score || a.misses - b.misses || b.maxCombo - a.maxCombo || b.hits - a.hits || new Date(b.playedAt) - new Date(a.playedAt);
}
function matchingChallenges(type, level = "all") {
  return loadRecords().plays.filter((play) => play.category === "challenge" && play.challengeType === type && (level === "all" || play.level === Number(level))).sort(compareChallenge);
}
function getChallengeRank(play, level = "all") {
  return matchingChallenges(play.challengeType, level).findIndex((item) => item.playId === play.playId) + 1;
}
function openRecords(tab) {
  recordsScreen.hidden = false;
  switchRecordsTab(tab);
  $("close-records").focus();
}
function closeRecords() { recordsScreen.hidden = true; }
function switchRecordsTab(tab) {
  const tabs = ["records", "ranking", "charts", "csv", "checker"];
  const selected = tabs.includes(tab) ? tab : "records";
  recordsContent.hidden = selected !== "records";
  rankingContent.hidden = selected !== "ranking";
  chartsContent.hidden = selected !== "charts";
  csvContent.hidden = selected !== "csv";
  checkerContent.hidden = selected !== "checker";
  tabs.forEach((id) => $(`${id}-tab`).classList.toggle("is-active", id === selected));
  if (selected === "records") renderRecords();
  if (selected === "ranking") renderRanking();
  if (selected === "charts") renderCharts();
}
function aggregatePractice(plays) {
  return plays.reduce((sum, play) => {
    sum.hits += play.hits || 0; sum.misses += play.misses || 0; sum.inputMs += play.inputMs || 0;
    STAT_IDS.forEach((id) => { const stat = play.rows?.[id]; if (!stat) return; sum.rows[id].hits += stat.hits; sum.rows[id].misses += stat.misses; sum.rows[id].inputMs += stat.inputMs; });
    return sum;
  }, { hits: 0, misses: 0, inputMs: 0, rows: createEmptyStats() });
}
function renderRecords() {
  const plays = loadRecords().plays.filter((play) => play.category === "practice").sort((a, b) => new Date(b.playedAt) - new Date(a.playedAt));
  recordsContent.replaceChildren();
  if (!plays.length) { const empty = document.createElement("p"); empty.className = "empty-records"; empty.textContent = "まだ練習成績はありません。正常終了したプレイがここに保存されます。"; recordsContent.appendChild(empty); return; }
  const totals = aggregatePractice(plays), grid = document.createElement("div");
  grid.className = "summary-grid";
  grid.appendChild(makeSummaryCard("全体", totals.hits, totals.misses, totals.inputMs, `${plays.length}回`));
  STAT_IDS.forEach((id) => { const stat = totals.rows[id]; if (stat.hits || stat.misses) grid.appendChild(makeSummaryCard(MODE_LABELS[id], stat.hits, stat.misses, stat.inputMs)); });
  recordsContent.appendChild(grid);
  const title = document.createElement("h3"); title.className = "history-title"; title.textContent = "練習履歴"; recordsContent.appendChild(title);
  const list = document.createElement("div"); list.className = "history-list";
  plays.slice(0, 100).forEach((play) => { const item = document.createElement("article"); item.className = "history-item"; const h = document.createElement("h4"); h.textContent = `${formatDate(play.playedAt)} ${sortSelectedRows(play.selectedRows || []).map((id) => MODE_LABELS[id]).join("+")}`; const p = document.createElement("p"); p.textContent = `打数 ${play.hits} ミス ${play.misses} 正確率 ${accuracy(play.hits, play.misses).toFixed(1)}% 速度 ${speedText(play.hits, play.inputMs)}`; item.append(h, p); list.appendChild(item); });
  recordsContent.appendChild(list);
}
function makeSummaryCard(title, hitCount, missCount, inputMs, extra = "") {
  const card = document.createElement("section"); card.className = "summary-card";
  const h = document.createElement("h3"); h.textContent = title;
  const strong = document.createElement("strong"); strong.textContent = `${hitCount.toLocaleString()}打`;
  const small = document.createElement("small"); small.textContent = `ミス ${missCount.toLocaleString()}|正確率 ${accuracy(hitCount, missCount).toFixed(1)}%|${speedText(hitCount, inputMs)}${extra ? `|${extra}` : ""}`;
  card.append(h, strong, small); return card;
}
function renderRanking() {
  const type = $("ranking-mode").value, level = $("ranking-level").value, plays = matchingChallenges(type, level).slice(0, 10);
  rankingList.replaceChildren();
  if (!plays.length) { const empty = document.createElement("p"); empty.className = "empty-records"; empty.textContent = "この条件のランキング記録はまだありません。"; rankingList.appendChild(empty); return; }
  plays.forEach((play, index) => { const item = document.createElement("article"); item.className = "ranking-item"; const h = document.createElement("h3"); h.textContent = `${index + 1}位 ${play.score.toLocaleString()}点`; const p1 = document.createElement("p"); p1.textContent = `速度:${speedText(play.hits, play.inputMs)} 打数 ${play.hits} ミス ${play.misses} 最大コンボ ${play.maxCombo}`; const p2 = document.createElement("p"); p2.textContent = `Level ${play.level}${type === "standard" ? ` 選択段:${sortSelectedRows(play.selectedRows || []).map((id) => MODE_LABELS[id]).join("・")}` : ""} ${formatDate(play.playedAt)}`; item.append(h, p1, p2); rankingList.appendChild(item); });
}
function formatDate(value) { return new Intl.DateTimeFormat("ja-JP", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); }

function selectedChartPlays() {
  const all = loadRecords().plays
    .filter((play) => play.category === "practice")
    .sort((a, b) => new Date(a.playedAt) - new Date(b.playedAt));
  const range = $("chart-range").value;
  return range === "all" ? all : all.slice(-Number(range));
}
function movingAverage(values, size) {
  return values.map((_, index) => {
    if (index < size - 1) return null;
    const sample = values.slice(index - size + 1, index + 1);
    return sample.reduce((sum, value) => sum + value, 0) / size;
  });
}
function svgElement(name, attributes = {}) {
  const element = document.createElementNS("http://www.w3.org/2000/svg", name);
  Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
  return element;
}
function addSvgText(svg, text, x, y, attributes = {}) {
  const node = svgElement("text", { x, y, ...attributes });
  node.textContent = text;
  svg.appendChild(node);
  return node;
}
function showChartEmpty(container, text = "表示できる練習成績がまだありません。") {
  container.replaceChildren();
  const empty = document.createElement("p");
  empty.className = "chart-empty";
  empty.textContent = text;
  container.appendChild(empty);
}
function renderLineChart(container, values, averageValues, options) {
  container.replaceChildren();
  if (!values.length) { showChartEmpty(container); return; }
  const width = 760, height = 280, left = 54, right = 18, top = 20, bottom = 42;
  const plotWidth = width - left - right, plotHeight = height - top - bottom;
  const maximum = options.maximum || Math.max(1, ...values, ...averageValues.filter((value) => value !== null));
  const yMax = options.fixedMaximum || Math.max(options.minimumMaximum || 10, Math.ceil(maximum / 10) * 10);
  const svg = svgElement("svg", { viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": options.label });
  svg.classList.add("data-chart");
  for (let step = 0; step <= 4; step++) {
    const y = top + (plotHeight * step) / 4;
    svg.appendChild(svgElement("line", { x1: left, y1: y, x2: width - right, y2: y, class: "chart-grid-line" }));
    addSvgText(svg, `${Math.round(yMax * (1 - step / 4))}${options.unit}`, left - 8, y + 4, { class: "chart-axis-label", "text-anchor": "end" });
  }
  const pointX = (index) => left + (values.length === 1 ? plotWidth / 2 : (plotWidth * index) / (values.length - 1));
  const pointY = (value) => top + plotHeight - (Math.max(0, Math.min(yMax, value)) / yMax) * plotHeight;
  const makePolyline = (series, className) => {
    const points = series.map((value, index) => value === null ? null : `${pointX(index)},${pointY(value)}`).filter(Boolean).join(" ");
    if (points) svg.appendChild(svgElement("polyline", { points, class: className }));
  };
  makePolyline(values, "chart-line chart-line--main");
  makePolyline(averageValues, "chart-line chart-line--average");
  values.forEach((value, index) => {
    const dot = svgElement("circle", { cx: pointX(index), cy: pointY(value), r: 4, class: "chart-dot" });
    const title = svgElement("title");
    title.textContent = `${index + 1}回目 ${options.tooltips[index]} ${options.valueText(value)}`;
    dot.appendChild(title);
    svg.appendChild(dot);
  });
  const labelStep = Math.max(1, Math.ceil(values.length / 10));
  values.forEach((_, index) => {
    if (index % labelStep !== 0 && index !== values.length - 1) return;
    addSvgText(svg, String(index + 1), pointX(index), height - 17, { class: "chart-axis-label", "text-anchor": "middle" });
  });
  container.appendChild(svg);
  if (averageValues.some((value) => value !== null)) {
    const legend = document.createElement("p"); legend.className = "chart-legend";
    legend.innerHTML = '<span class="legend-main">各プレイ</span><span class="legend-average">直近5回平均</span>';
    container.appendChild(legend);
  }
}
function renderRowChart(container, plays) {
  container.replaceChildren();
  if (!plays.length) { showChartEmpty(container); return; }
  const totals = aggregatePractice(plays);
  const rows = STAT_IDS.map((id) => ({ id, value: speedData(totals.rows[id].hits, totals.rows[id].inputMs).perMinute, hits: totals.rows[id].hits }));
  if (!rows.some((row) => row.hits)) { showChartEmpty(container); return; }
  const width = 760, height = 300, left = 54, right = 18, top = 22, bottom = 56;
  const plotWidth = width - left - right, plotHeight = height - top - bottom;
  const maximum = Math.max(10, Math.ceil(Math.max(...rows.map((row) => row.value)) / 10) * 10);
  const svg = svgElement("svg", { viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": "段別速度の棒グラフ" });
  svg.classList.add("data-chart");
  for (let step = 0; step <= 4; step++) {
    const y = top + (plotHeight * step) / 4;
    svg.appendChild(svgElement("line", { x1: left, y1: y, x2: width - right, y2: y, class: "chart-grid-line" }));
    addSvgText(svg, `${Math.round(maximum * (1 - step / 4))}`, left - 8, y + 4, { class: "chart-axis-label", "text-anchor": "end" });
  }
  const slot = plotWidth / rows.length, barWidth = Math.min(76, slot * 0.58);
  rows.forEach((row, index) => {
    const barHeight = row.hits ? (row.value / maximum) * plotHeight : 0;
    const x = left + slot * index + (slot - barWidth) / 2, y = top + plotHeight - barHeight;
    const bar = svgElement("rect", { x, y, width: barWidth, height: barHeight, rx: 7, class: "chart-bar" });
    const title = svgElement("title"); title.textContent = `${MODE_LABELS[row.id]} ${Math.round(row.value)}打/分`;
    bar.appendChild(title); svg.appendChild(bar);
    addSvgText(svg, MODE_LABELS[row.id].replace("ポジション", ""), x + barWidth / 2, height - 24, { class: "chart-axis-label", "text-anchor": "middle" });
    if (row.hits) addSvgText(svg, `${Math.round(row.value)}`, x + barWidth / 2, Math.max(top + 13, y - 7), { class: "chart-value-label", "text-anchor": "middle" });
  });
  addSvgText(svg, "打/分", 8, 15, { class: "chart-axis-label" });
  container.appendChild(svg);
}
function renderAnalysisComment(plays) {
  const box = $("analysis-comment"); box.replaceChildren();
  const heading = document.createElement("h3"); heading.textContent = "今回の分析メモ"; box.appendChild(heading);
  const list = document.createElement("ul");
  if (plays.length < 5) {
    const item = document.createElement("li"); item.textContent = `あと${5 - plays.length}回練習すると、直近5回の平均を使った分析を表示できます。`; list.appendChild(item);
  } else {
    const speeds = plays.map((play) => speedData(play.hits, play.inputMs).perMinute);
    const recent = speeds.slice(-5).reduce((sum, value) => sum + value, 0) / 5;
    if (speeds.length >= 10) {
      const previous = speeds.slice(-10, -5).reduce((sum, value) => sum + value, 0) / 5;
      const item = document.createElement("li");
      item.textContent = recent > previous * 1.02 ? "直近5回の平均速度が、その前の5回より上がっています。" : recent < previous * .98 ? "直近5回は少しゆっくりめです。正確さを保ちながら続けてみましょう。" : "直近5回の平均速度は安定しています。";
      list.appendChild(item);
    } else {
      const item = document.createElement("li"); item.textContent = `直近5回の平均速度は${Math.round(recent)}打/分です。`; list.appendChild(item);
    }
    const totals = aggregatePractice(plays);
    const rowValues = STAT_IDS.map((id) => ({ id, hits: totals.rows[id].hits, speed: speedData(totals.rows[id].hits, totals.rows[id].inputMs).perMinute })).filter((row) => row.hits > 0);
    if (rowValues.length >= 2) {
      const fastest = [...rowValues].sort((a, b) => b.speed - a.speed)[0], slower = [...rowValues].sort((a, b) => a.speed - b.speed)[0];
      const best = document.createElement("li"); best.textContent = `${MODE_LABELS[fastest.id]}が最も速く入力できています。`; list.appendChild(best);
      if (slower.speed < fastest.speed * .9) { const note = document.createElement("li"); note.textContent = `最近は${MODE_LABELS[slower.id]}の速度が少し低めです。`; list.appendChild(note); }
    }
    const recentAccuracy = plays.slice(-5).map((play) => accuracy(play.hits, play.misses));
    if (Math.max(...recentAccuracy) - Math.min(...recentAccuracy) <= 3) { const item = document.createElement("li"); item.textContent = "直近5回の正確率が安定しています。"; list.appendChild(item); }
  }
  box.appendChild(list);
}
function renderCharts() {
  const plays = selectedChartPlays();
  $("chart-count").textContent = `表示中:${plays.length}回`;
  renderAnalysisComment(plays);
  const speedValues = plays.map((play) => speedData(play.hits, play.inputMs).perMinute);
  const dateLabels = plays.map((play) => formatDate(play.playedAt));
  renderLineChart($("speed-chart"), speedValues, movingAverage(speedValues, 5), { label: "速度と直近5回平均の折れ線グラフ", unit: "", minimumMaximum: 60, tooltips: dateLabels, valueText: (value) => `${Math.round(value)}打/分` });
  renderRowChart($("row-chart"), plays);
  const accuracyValues = plays.map((play) => accuracy(play.hits, play.misses));
  renderLineChart($("accuracy-chart"), accuracyValues, [], { label: "正確率の折れ線グラフ", unit: "%", fixedMaximum: 100, tooltips: dateLabels, valueText: (value) => `${value.toFixed(1)}%` });
}

const CSV_HEADERS = [
  "schemaVersion", "playId", "appVersion", "playedAt", "category", "practiceType", "challengeType", "selectedRows", "level", "durationSeconds", "hits", "misses", "inputMs", "score", "maxCombo", "futureJson",
  ...STAT_IDS.flatMap((id) => [`${id}_hits`, `${id}_misses`, `${id}_inputMs`]),
];
function csvEscape(value) {
  const text = value === null || value === undefined ? "" : String(value);
  return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function recordsToCsv(plays) {
  const lines = [CSV_HEADERS.join(",")];
  plays.forEach((play) => {
    const values = [play.schemaVersion, play.playId, play.appVersion, play.playedAt, play.category, play.practiceType, play.challengeType, (play.selectedRows || []).join("|"), play.level, play.durationSeconds, play.hits, play.misses, play.inputMs, play.score, play.maxCombo, JSON.stringify(play.future ?? null)];
    STAT_IDS.forEach((id) => { const stat = play.rows?.[id]; values.push(stat?.hits ?? "", stat?.misses ?? "", stat?.inputMs ?? ""); });
    lines.push(values.map(csvEscape).join(","));
  });
  return `\ufeff${lines.join("\r\n")}`;
}
function backupFilename(prefix = "typing-lab-backup") {
  const now = new Date(), pad = (value) => String(value).padStart(2, "0");
  return `${prefix}-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}.csv`;
}
function downloadCsv(plays, prefix) {
  const blob = new Blob([recordsToCsv(plays)], { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob), link = document.createElement("a");
  link.href = url; link.download = backupFilename(prefix); document.body.appendChild(link); link.click(); link.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportRecordsCsv(prefix = "typing-lab-backup") { downloadCsv(loadRecords().plays, prefix); }
function parseCsv(text) {
  const source = text.replace(/^\ufeff/, ""), rows = [];
  let row = [], field = "", quoted = false;
  for (let index = 0; index < source.length; index++) {
    const char = source[index];
    if (quoted) {
      if (char === '"' && source[index + 1] === '"') { field += '"'; index++; }
      else if (char === '"') quoted = false;
      else field += char;
    } else if (char === '"' && field === "") quoted = true;
    else if (char === ",") { row.push(field); field = ""; }
    else if (char === "\n") { row.push(field.replace(/\r$/, "")); rows.push(row); row = []; field = ""; }
    else field += char;
  }
  if (quoted) throw new Error("引用符が閉じられていません");
  if (field !== "" || row.length) { row.push(field.replace(/\r$/, "")); rows.push(row); }
  return rows.filter((values) => values.some((value) => value !== ""));
}
function requiredNumber(value, name, rowNumber, options = {}) {
  if (value === "" && options.optional) return null;
  const number = Number(value);
  if (!Number.isFinite(number) || number < (options.minimum ?? 0) || (options.integer && !Number.isInteger(number))) throw new Error(`${rowNumber}行目:${name}の形式が正しくありません`);
  return number;
}
function validateImportedCsv(text) {
  const rows = parseCsv(text);
  if (rows.length < 1) throw new Error("CSVに見出し行がありません");
  const headers = rows[0];
  CSV_HEADERS.forEach((header) => { if (!headers.includes(header)) throw new Error(`必須列「${header}」がありません`); });
  const index = Object.fromEntries(headers.map((header, position) => [header, position]));
  const plays = rows.slice(1).map((values, offset) => {
    const rowNumber = offset + 2, get = (name) => values[index[name]] ?? "";
    const schemaVersion = requiredNumber(get("schemaVersion"), "schemaVersion", rowNumber, { integer: true });
    if (schemaVersion !== SCHEMA_VERSION) throw new Error(`${rowNumber}行目:対応していないschemaVersionです`);
    const playId = get("playId").trim(), appVersion = get("appVersion").trim(), playedAt = get("playedAt").trim(), category = get("category").trim();
    if (!playId) throw new Error(`${rowNumber}行目:playIdがありません`);
    if (!appVersion) throw new Error(`${rowNumber}行目:appVersionがありません`);
    if (!playedAt || Number.isNaN(Date.parse(playedAt))) throw new Error(`${rowNumber}行目:プレイ日時の形式が正しくありません`);
    if (!['practice', 'challenge'].includes(category)) throw new Error(`${rowNumber}行目:モードの形式が正しくありません`);
    const selectedRows = get("selectedRows").split("|").filter(Boolean);
    if (!selectedRows.length || selectedRows.some((id) => !MODE_ORDER.includes(id))) throw new Error(`${rowNumber}行目:選択段の形式が正しくありません`);
    const base = { schemaVersion, playId, appVersion, playedAt: new Date(playedAt).toISOString(), category, selectedRows, durationSeconds: requiredNumber(get("durationSeconds"), "練習時間", rowNumber), hits: requiredNumber(get("hits"), "打数", rowNumber, { integer: true }), misses: requiredNumber(get("misses"), "ミス", rowNumber, { integer: true }), inputMs: requiredNumber(get("inputMs"), "実入力時間", rowNumber) };
    try { base.future = JSON.parse(get("futureJson") || "null"); } catch (error) { throw new Error(`${rowNumber}行目:futureJsonの形式が正しくありません`); }
    if (category === "practice") {
      const practiceType = get("practiceType");
      if (!['standard', 'numpad', 'japanese'].includes(practiceType)) throw new Error(`${rowNumber}行目:通常練習種別の形式が正しくありません`);
      base.practiceType = practiceType; base.rows = {};
      STAT_IDS.forEach((id) => { base.rows[id] = { hits: requiredNumber(get(`${id}_hits`), `${MODE_LABELS[id]}の打数`, rowNumber, { integer: true }), misses: requiredNumber(get(`${id}_misses`), `${MODE_LABELS[id]}のミス`, rowNumber, { integer: true }), inputMs: requiredNumber(get(`${id}_inputMs`), `${MODE_LABELS[id]}の実入力時間`, rowNumber) }; });
    } else {
      const challengeType = get("challengeType");
      if (!['standard', 'numpad'].includes(challengeType)) throw new Error(`${rowNumber}行目:チャレンジ種別の形式が正しくありません`);
      base.challengeType = challengeType;
      base.level = requiredNumber(get("level"), "Level", rowNumber, { integer: true, minimum: 1 });
      if (base.level > 10) throw new Error(`${rowNumber}行目:Levelが範囲外です`);
      base.score = requiredNumber(get("score"), "スコア", rowNumber, { integer: true });
      base.maxCombo = requiredNumber(get("maxCombo"), "最大コンボ", rowNumber, { integer: true });
    }
    return base;
  });
  if (!plays.length) throw new Error("インポートできる成績がありません");
  return plays;
}
function prepareImport(plays, fileName) {
  const existingIds = new Set(loadRecords().plays.map((play) => play.playId)), seen = new Set(), unique = [], newPlays = [];
  let duplicates = 0;
  plays.forEach((play) => {
    if (seen.has(play.playId)) { duplicates++; return; }
    seen.add(play.playId); unique.push(play);
    if (existingIds.has(play.playId)) duplicates++; else newPlays.push(play);
  });
  pendingImport = { fileName, total: plays.length, unique, newPlays, duplicates };
  const summary = $("import-summary"); summary.className = "import-summary is-valid"; summary.replaceChildren();
  [fileName, `読み込み対象:${plays.length}件`, `新規追加:${newPlays.length}件`, `重複:${duplicates}件`, "エラー:0件"].forEach((text, index) => {
    const node = document.createElement(index === 0 ? "strong" : "span"); node.textContent = text; summary.appendChild(node);
  });
  $("import-add").disabled = newPlays.length === 0;
  $("import-replace").disabled = false;
}
async function handleImportFile(event) {
  pendingImport = null; $("import-add").disabled = true; $("import-replace").disabled = true;
  const file = event.target.files[0]; if (!file) return;
  try { prepareImport(validateImportedCsv(await file.text()), file.name); }
  catch (error) {
    $("import-summary").className = "import-summary is-error";
    $("import-summary").textContent = `CSVデータに読み込めない項目が見つかりました(${error.message})。安全のためインポートを中止しました。現在の成績データは変更されていません。`;
  }
}
function finishImportMessage(text) {
  $("import-summary").className = "import-summary is-valid"; $("import-summary").textContent = text;
  $("import-add").disabled = true; $("import-replace").disabled = true; $("import-file").value = ""; pendingImport = null;
}
function importAdditionalRecords() {
  if (!pendingImport || !pendingImport.newPlays.length) return;
  if (!confirm(`${pendingImport.newPlays.length}件の成績を現在の履歴へ追加します。`)) return;
  const records = loadRecords(); records.plays.push(...pendingImport.newPlays);
  if (writeRecords(records)) finishImportMessage(`${pendingImport.newPlays.length}件の成績を追加しました。重複した成績は追加していません。`);
}
function replaceAllRecords() {
  if (!pendingImport) return;
  if (!confirm("現在保存されている練習成績・チャレンジ成績をすべて削除して、CSVの内容へ置き換えます。")) return;
  if (!confirm("本当に全消去してインポートしますか? この操作は元に戻せません。")) return;
  const current = loadRecords().plays;
  if (current.length) downloadCsv(current, "typing-lab-auto-backup");
  if (writeRecords({ schemaVersion: SCHEMA_VERSION, plays: pendingImport.unique })) finishImportMessage(`${pendingImport.unique.length}件の成績へ置き換えました。置き換え前の成績は自動バックアップしました。`);
}

function deleteAllRecords() {
  if (!confirm("練習履歴・チャレンジ履歴・ランキングをすべて削除します。よろしいですか?")) return;
  if (!confirm("削除した成績は元に戻せません。本当にすべて削除しますか?")) return;
  localStorage.removeItem(STORAGE_KEY);
  renderRecords(); renderRanking();
}
async function handleCheckerFile(event) {
  const file = event.target.files[0];
  if (!file) return;
  try {
    checkerRows = check教材Csv(await file.text());
    renderCheckerResults(file.name);
  } catch (error) {
    checkerRows = [];
    $("checker-summary").className = "checker-summary is-error";
    $("checker-summary").textContent = `CSVを確認できませんでした:${error.message}`;
    $("checker-results").replaceChildren();
  }
}
function check教材Csv(text) {
  const rows = parseCsv(text);
  if (!rows.length) throw new Error("見出し行がありません");
  const headers = rows[0].map((value) => value.trim());
  const wordIndex = headers.findIndex((value) => value === "表示語" || value.toLowerCase() === "word");
  const readingIndex = headers.findIndex((value) => value === "読み" || value.toLowerCase() === "reading");
  if (wordIndex < 0) throw new Error("必須列「表示語」がありません");
  if (readingIndex < 0) throw new Error("必須列「読み」がありません");
  const seen = new Set();
  return rows.slice(1).map((values, offset) => {
    const line = offset + 2, word = (values[wordIndex] || "").trim(), reading = (values[readingIndex] || "").trim();
    const messages = [];
    let status = "ok", pathCount = 0, standard = "";
    const fail = (text) => { status = "error"; messages.push(text); };
    const warn = (text) => { if (status !== "error") status = "warning"; messages.push(text); };
    if (!word) fail("表示語が空欄です");
    if (!reading) fail("読みが空欄です");
    const duplicateKey = `${word}\u0000${normalizeKana(reading)}`;
    if (seen.has(duplicateKey)) warn("同じ表示語・読みが重複しています");
    seen.add(duplicateKey);
    if (reading) {
      try {
        if (/っ$/.test(normalizeKana(reading))) warn("読みが促音「っ」で終わっています");
        const generated = createRomajiPaths(reading, 20001);
        pathCount = generated.paths.length;
        standard = generated.paths[0];
        if (generated.truncated || pathCount > 1000) warn(`入力候補が多すぎます(${pathCount}${generated.truncated ? "+" : ""}経路)`);
        if (!generated.paths.every((path) => virtualInputTest(path, generated.paths))) fail("自動入力試験に失敗しました");
      } catch (error) { fail(error.message); }
    }
    if (!messages.length) messages.push("本番エンジンで全候補を完走しました");
    return { line, word, reading, status, messages, pathCount, standard };
  });
}
function renderCheckerResults(fileName = "") {
  const visible = checkerRows.filter((row) => checkerFilter === "all" || row.status === checkerFilter);
  const counts = { ok: 0, warning: 0, error: 0 };
  checkerRows.forEach((row) => counts[row.status]++);
  const summary = $("checker-summary");
  summary.className = `checker-summary ${counts.error ? "is-error" : counts.warning ? "is-warning" : "is-valid"}`;
  summary.textContent = `${fileName ? `${fileName} ` : ""}全${checkerRows.length}件:OK ${counts.ok}件/警告 ${counts.warning}件/エラー ${counts.error}件`;
  const container = $("checker-results");
  container.replaceChildren();
  visible.forEach((row) => {
    const item = document.createElement("article");
    item.className = `checker-result is-${row.status}`;
    const heading = document.createElement("h4");
    heading.textContent = `${row.line}行目 ${row.word || "(表示語なし)"} [${row.status === "ok" ? "OK" : row.status === "warning" ? "警告" : "エラー"}]`;
    const detail = document.createElement("p");
    detail.textContent = `読み:${row.reading || "(空欄)"}${row.standard ? ` 標準:${row.standard} 候補:${row.pathCount}経路` : ""}`;
    const note = document.createElement("p");
    note.textContent = row.messages.join("/");
    item.append(heading, detail, note);
    container.appendChild(item);
  });
  if (!visible.length) {
    const empty = document.createElement("p");
    empty.className = "chart-empty";
    empty.textContent = "この条件に該当する行はありません。";
    container.appendChild(empty);
  }
}
function downloadSampleCsv() {
  const lines = ["表示語,読み", ...JAPANESE_WORDS.map((item) => `${csvEscape(item.word)},${csvEscape(item.reading)}`)];
  const blob = new Blob([`\ufeff${lines.join("\r\n")}`], { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob), link = document.createElement("a");
  link.href = url; link.download = "typing-lab-japanese-sample-20.csv"; document.body.appendChild(link); link.click(); link.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function addScore(points) {
  score += points;
  scoreDisplay.textContent = score;
}
function comboMultiplier() {
  return combo < 5 ? 1 : 1 + combo / 10;
}
function registerChallengeCorrect() {
  registerHit();
  combo++;
  maxCombo = Math.max(maxCombo, combo);
  addScore(combo < 5 ? 10 : 10 + combo);
  updateComboDisplay(combo % 10 === 0);
}
function registerHit() {
  hits++;
  hitsDisplay.textContent = hits;
}
function updateComboDisplay(milestone = false) {
  comboDisplay.hidden = !challengeEnabled || combo < 5;
  $("combo-count").textContent = combo;
  $("combo-rate").textContent = comboMultiplier().toFixed(1);
  if (milestone) {
    comboDisplay.classList.remove("is-milestone");
    void comboDisplay.offsetWidth;
    comboDisplay.classList.add("is-milestone");
  }
}
function breakCombo() {
  const wasVisible = combo >= 5;
  combo = 0;
  updateComboDisplay();
  if (!wasVisible) return;
  const flash = $("combo-flash");
  flash.classList.remove("is-active");
  void flash.offsetWidth;
  flash.classList.add("is-active");
}
function fillChallengeLetters() {
  while (state === "playing" && challengeLetters.length < challengeLevel) {
    const el = document.createElement("span"),
      slot = Array.from({ length: challengeLevel }, (_, i) => i).find((i) => !challengeLetters.some((item) => item.lane === i)) ?? 0,
      key = practiceKeys[Math.floor(Math.random() * practiceKeys.length)],
      laneWidth = playArea.clientWidth / challengeLevel;
    el.className = "challenge-key";
    el.textContent = key.toUpperCase();
    challengeKeys.appendChild(el);
    const width = el.offsetWidth || 70,
      x = Math.max(8, slot * laneWidth + (laneWidth - width) / 2),
      item = { id: `${performance.now()}-${Math.random()}`, key, el, lane: slot, x, y: 48, stopped: false, hit: false };
    el.style.transform = `translate(${x}px,${item.y}px)`;
    challengeLetters.push(item);
  }
  highlightChallengeChoices();
}
function highlightChallengeChoices() {
  clearGuide();
  const keys = new Set(challengeLetters.filter((item) => !item.hit).map((item) => item.key));
  keys.forEach((key) => {
    const guide = KEY_GUIDE.find((item) => item.key === key);
    getStandardKey(key)?.classList.add("is-target");
    if (guide)
      document.querySelector(`.finger[data-hand="${guide.hand}"][data-finger="${guide.finger}"]`)?.classList.add("is-target");
  });
}
function hitChallengeLetter(item) {
  item.hit = true;
  registerChallengeCorrect();
  const source = getStandardKey(item.key);
  source?.classList.add("is-correct");
  shootLaser(source, item.el);
  createSparks(item.el);
  item.el.style.setProperty("--hit-x", `${item.x}px`);
  item.el.style.setProperty("--hit-y", `${item.y}px`);
  item.el.classList.add("is-hit");
  setTimeout(() => playSound("break"), 75);
  setTimeout(() => source?.classList.remove("is-correct"), 100);
  setTimeout(() => {
    item.el.remove();
    challengeLetters = challengeLetters.filter((letter) => letter !== item);
    fillChallengeLetters();
  }, 210);
}
function startPlacementGuide() {
  stopPlacementGuide();
  if (state !== "waiting") return;
  placementIndex = 0;
  const step = () => {
    if (state !== "waiting") return;
    clearGuide();
    const sequence = selectedMode === "numpad"
      ? ["4", "5", "6", "0", "Enter"]
      : ["f", "d", "s", "a", "j", "k", "l", ";"];
    const name = sequence[placementIndex++ % sequence.length];
    if (selectedMode === "numpad") highlightNumpadPlacement(name);
    else highlightStandard(name);
    drawPlacementLine(selectedMode === "numpad" ? getNumpadKey(name) : getStandardKey(name), getPlacementFinger(name));
  };
  step();
  placementTimerId = setInterval(step, 500);
}
function highlightNumpadPlacement(name) {
  clearGuide();
  getNumpadKey(name)?.classList.add("is-target");
  const finger = NUMPAD_FINGERS[name];
  if (finger) {
    document
      .querySelector(`.numpad-finger[data-finger="${finger}"]`)
      ?.classList.add("is-target");
  }
}
function getPlacementFinger(name) {
  if (selectedMode === "numpad")
    return document.querySelector(`.numpad-finger[data-finger="${NUMPAD_FINGERS[name]}"]`);
  const guide = KEY_GUIDE.find((item) => item.key === name);
  return guide && document.querySelector(`.finger[data-hand="${guide.hand}"][data-finger="${guide.finger}"]`);
}
function drawPlacementLine(key, finger) {
  if (!key || !finger) return;
  const a = key.getBoundingClientRect(), b = finger.getBoundingClientRect(), line = placementLine.querySelector("line");
  line.setAttribute("x1", a.left + a.width / 2);
  line.setAttribute("y1", a.bottom);
  line.setAttribute("x2", b.left + b.width / 2);
  line.setAttribute("y2", b.top);
  placementLine.classList.add("is-visible");
}
function stopPlacementGuide() {
  clearInterval(placementTimerId);
  placementLine.classList.remove("is-visible");
  clearGuide();
}
let noiseBuffer = null;
function getAudioContext() {
  if (!audioContext) {
    audioContext = new (window.AudioContext || window.webkitAudioContext)();
    noiseBuffer = audioContext.createBuffer(
      1,
      audioContext.sampleRate * 0.25,
      audioContext.sampleRate,
    );
    const data = noiseBuffer.getChannelData(0);
    for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  }
  if (audioContext.state === "suspended") audioContext.resume();
  return audioContext;
}
function tone({
  frequency,
  endFrequency = frequency,
  duration = 0.05,
  volume = 0.035,
  type = "square",
  delay = 0,
}) {
  if (!soundEnabled) return;
  const c = getAudioContext(),
    start = c.currentTime + delay,
    o = c.createOscillator(),
    g = c.createGain();
  o.type = type;
  o.frequency.setValueAtTime(frequency, start);
  o.frequency.exponentialRampToValueAtTime(
    Math.max(20, endFrequency),
    start + duration,
  );
  g.gain.setValueAtTime(0.0001, start);
  g.gain.exponentialRampToValueAtTime(volume, start + 0.004);
  g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
  o.connect(g).connect(c.destination);
  o.start(start);
  o.stop(start + duration + 0.01);
}
function noise({
  duration = 0.025,
  volume = 0.035,
  delay = 0,
  highpass = 680,
  lowpass = 2310,
}) {
  if (!soundEnabled) return;
  const c = getAudioContext(),
    start = c.currentTime + delay,
    s = c.createBufferSource(),
    hp = c.createBiquadFilter(),
    lp = c.createBiquadFilter(),
    g = c.createGain();
  s.buffer = noiseBuffer;
  hp.type = "highpass";
  hp.frequency.value = highpass;
  lp.type = "lowpass";
  lp.frequency.value = lowpass;
  g.gain.setValueAtTime(0.0001, start);
  g.gain.exponentialRampToValueAtTime(volume, start + 0.002);
  g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
  s.connect(hp).connect(lp).connect(g).connect(c.destination);
  s.start(start);
  s.stop(start + duration + 0.01);
}
function playSound(name) {
  if (name === "wrong") {
    tone({
      frequency: 150,
      endFrequency: 75,
      duration: 0.105,
      volume: 0.055,
      type: "triangle",
    });
    tone({
      frequency: 95,
      endFrequency: 55,
      duration: 0.09,
      volume: 0.025,
      delay: 0.012,
    });
  }
  if (name === "break")
    [1500, 2150, 2850].forEach((f, i) =>
      tone({
        frequency: f,
        endFrequency: f * 0.65,
        duration: 0.055 + i * 0.012,
        volume: 0.018,
        type: "triangle",
        delay: i * 0.009,
      }),
    );
  if (name === "key10") {
    noise({ duration: 0.025, volume: 0.035, highpass: 680, lowpass: 2310 });
    tone({
      frequency: 1050,
      endFrequency: 756,
      duration: 0.035,
      volume: 0.0245,
      type: "triangle",
    });
  }
  if (name === "shift7") {
    noise({ duration: 0.14, volume: 0.075, highpass: 700, lowpass: 12000 });
    [1500, 1150, 850, 620, 430].forEach((f, i) =>
      tone({
        frequency: f,
        endFrequency: f * 0.72,
        duration: 0.045,
        volume: 0.04,
        type: "square",
        delay: i * 0.025,
      }),
    );
  }
  if (name === "fizzle")
    tone({
      frequency: 340,
      endFrequency: 85,
      duration: 0.075,
      volume: 0.03,
      type: "sawtooth",
    });
}
function toggleSound() {
  soundEnabled = !soundEnabled;
  soundToggle.textContent = soundEnabled ? "ON" : "OFF";
  soundToggle.setAttribute("aria-pressed", String(soundEnabled));
}

「日本語単語」モードを追加する

スタート画面では、通常の数字段・上段・ホーム段・下段とは別に、「日本語単語」を選べるようにしました。テンキーと同じように、日本語単語は独立した練習モードとして扱います。

日本語単語モードを選ぶと、画面中央へ「表示する単語」「読み」「ローマ字ガイド」を表示します。

日本語単語モードでは、チャレンジモードは使用しません。今回の目的はスコア競争ではなく、ローマ字入力を正しく判定できる仕組みの確認です。現在は成績にも影響しません。

Microsoft IMEを基準にローマ字入力を考える

日本語のローマ字入力には、同じ文字でも複数の入力方法があります。今回は、Microsoft IMEの一般的なローマ字入力を基準として作りました。

標準ガイドには、できるだけ短く、行の子音を意識しやすい入力方法を表示します。たとえば「し」はsi、「ち」はti、「つ」はtuを最初に表示します。

かな標準表示別の入力として許可
しsishi
ちtichi
つtutsu
ふhufu
じziji
ぢdi今回はdiのみ
づdu今回はduのみ
をwo今回はwoのみ

たとえば「つくし」は、最初はtukusiと表示します。ただし、利用者がtsuやshiの入力方法を選んでも、ミスにはしません。

入力した方法に合わせてローマ字ガイドを切り替える

最初に表示したローマ字だけを正解にすると、普段使っている入力方法が違う人は練習できません。そこで、入力途中で別の有効な経路へ進んだ場合は、画面のローマ字ガイドも切り替えるようにしました。

状態表示例
最初つくし → tukusi
最初にtsと入力tsukusiへ切り替え
後半をshで入力tsukushiへ切り替え

さらに、次に入力できるキーが複数ある場合は、「次のキーは T / C のどちらでも入力できます」のように候補を表示します。

正解を1本に固定せず、有効な入力経路が残っているかで判定する

今回の日本語入力では、単語ごとに1本の正解ローマ字を用意して、それと一致するかを調べる方法にはしていません。

読みから複数の入力経路を作り、キーを1文字入力するたびに「この入力から最後まで成立する経路が1本以上残っているか」を確認します。

有効な経路が1本以上残っていれば正しい入力として続行し、すべての経路がなくなったときだけミスとして扱います。

この仕組みにしたことで、単語ごとにtukusi、tsukusi、tukushi、tsukushi……と全部の正解を手作業で登録する必要がなくなりました。

「ん」はnとnnの両方を扱う

「ん」は、ローマ字入力の中でも判断が難しい文字です。次に続く文字によって、n1回で入力できる場合と、nnで「ん」を確定させる必要がある場合があります。

例確認する入力
かんたんkantanのようにn1回で入力できる
かんたんkanntannのようにnnで入力する方法も受け付ける
こんや「ん」と「や」の境界を判定する
しんよう「ん」の後ろにや行が続く場合を確認する
ぜんいん「ん」の後ろに母音が続く場合を確認する

今回はアポストロフィを使う入力方法は採用せず、nの個数で処理する形にしました。

小さい「っ」は3種類の入力方法を受け付ける

小さい「っ」も、入力方法が1つではありません。次の子音を重ねる一般的な方法に加えて、xtuとltuで直接入力する方法も受け付けます。

「がっこう」の例入力
子音を重ねるgakkou
Xを使うgaxtukou
Lを使うgaltukou

さらに、「ざっし」のように「っ」の後ろにも複数のローマ字入力がある場合、それぞれの経路を組み合わせて入力できるようにしています。

「ちょっと」はtyotto・chotto・cyottoを全部通す

実際の動作確認中に気になったのが「ちょっと」です。「ちょ」はtyoだけでなく、choやcyoでも入力できます。

入力結果
tyotto入力できる
chotto入力できる
cyotto入力できる

最初はこの分岐がうまく動かない状態もありましたが、入力候補を追加し、3種類とも何度も繰り返して正常に入力できることを確認しました。

同じ考え方で、「ちゃ・ちゅ・ちょ」はtya / tyu / tyo、cha / chu / cho、cya / cyu / cyoを扱います。

拗音・小書き文字・外来語にも対応する

「きゃ」「しゃ」「ぴょ」などの拗音、小さい「ぁぃぅぇぉゃゅょ」、外来語で使う「ふぁ」「てぃ」「しぇ」なども、今回のローマ字入力表へ追加しました。

種類例
拗音きゃ → kya、しゃ → sya / sha、ぴょ → pyo
小書き文字ぁ → xa / la、ゃ → xya / lya
外来語ふぁ → fa、てぃ → thi、しぇ → she / sye
ヴ系ヴァ → va、ヴィ → vi
長音ー → -

カタカナは、内部で対応するひらがなへそろえてから同じローマ字入力エンジンへ渡します。そのため、ひらがな用とカタカナ用で別々の巨大な入力表を作る必要はありません。

大文字でも入力できるようにする

知らないうちにCaps Lockがオンになっていると、ローマ字がすべて大文字になってしまうことがあります。そこで今回は、大文字と小文字を同じ入力として受け付けるようにしました。

kuruma、KURUMA、Kurumaのように入力しても、判定上は同じキーとして扱います。

ただし、Caps Lockがオンになっている可能性がある場合は、「Caps Lock がオンになっている可能性があります」と画面へ警告を表示します。入力を止めず、原因だけ分かるようにしました。

試験用20語でローマ字入力を繰り返し確認する

今回は日本語教材を大量に作る回ではないため、入力エンジンを確認しやすい20語を用意しました。

試験語主な確認
車・桜基本的なローマ字入力
寿司・つくし・地図・富士si / shi、tu / tsu、ti / chi、hu / fuなど
写真・今日拗音
ちょっと・学校・切手・雑誌促音と複数入力
発表会促音+拗音の組み合わせ
簡単・散歩・今夜・信用・全員「ん」の前後関係
ファッションカタカナ+外来語+促音+「ん」
コンピューターカタカナ+小書き文字+長音

20語を1回だけ通して終わりにはせず、同じ単語を別のローマ字入力方法で何度も繰り返しました。

大量の教材を追加する前に「教材チェック」で自動検査する

将来、単語が何百語・何千語と増えると、すべてを人間が1語ずつ入力して確認するのは現実的ではありません。そこで、開発者向けの「教材チェック」も追加しました。

CSVへ「表示語」と「読み」を入れて読み込むと、本番と同じローマ字入力エンジンを使って、データを自動検査します。

  • 表示語や読みが空欄ではないか
  • 未対応文字が含まれていないか
  • 読みからローマ字入力経路を生成できるか
  • 同じ表示語と読みが重複していないか
  • 入力候補が異常に増えていないか
  • 生成した候補を本番と同じ入力判定へ1文字ずつ通し、最後まで完走できるか

結果は「OK・警告・エラー」で表示し、問題がある行だけ絞り込めます。

教材チェックは、入力エンジンそのものが正しいことを人間の手動テストで確認したあと、大量データを追加するときの見落としを減らすための仕組みです。

20語だけでもVer.1.0にした理由

今回登録した日本語は20語なので、日本語教材として見ればまだ小さな試験版です。ただし、Ver.1.0で完成させたかったのは単語数ではありません。

Ver.1.0で完成させたのは、日本語の読みから複数のローマ字入力経路を作り、利用者の入力方法に合わせて正しく判定する仕組みです。

つまり、「20語しか入力できない仕組み」ではなく、これから単語を増やすための入力エンジンを20語で試験した形です。

タイピングラボの中心は、これまで作ってきた英数字1文字・段別・複数段・テンキーなどのタイピング練習です。日本語単語モードは、Ver.1.0で追加した新しい練習方法として、まず入力判定の仕組みを完成させました。

これまでの練習モードもそのまま使える

日本語単語モードを追加しても、数字段・上段・ホーム段・下段・テンキー、通常練習、チャレンジモード、成績保存、ランキング、分析グラフ、CSVバックアップなど、これまで作った機能はそのまま利用できます。

第1回のホームポジション10キーから始め、少しずつ機能を追加してきたタイピングラボは、Ver.1.0でひとまず基本形が完成しました。

今回の完成形

  • 数字段・上段・ホーム段・下段を選んで練習できる
  • 複数段をまとめて練習できる
  • 画面キーボードと担当指を確認できる
  • テンキーを数字リール方式で練習できる
  • 通常練習とチャレンジモードを切り替えられる
  • 成績をブラウザへ保存できる
  • ランキング・分析グラフを確認できる
  • 成績をBOM付きCSVへバックアップ・復元できる
  • 日本語の読みを複数のローマ字入力方法で入力できる
  • 日本語教材CSVを本番と同じ入力エンジンで自動検査できる

完成したタイピング練習を使ってみる

完成した第10回版は、次のページで動作確認できます。

タイピングラボ Ver.1.0を別画面で開く

記事内へ埋め込む場合は、次の位置へ完成版のiframeを追加します。

【ここへ第10回完成版のiframeを挿入】

日本語がローマ字で打てるようになりました

きょろぽん
きょろぽん

第10回までで、ソースコードもかなり大きくなったね。今回の記事なんて、ソースコード込みで13万文字くらいあるし(笑)

ノン
ノン

ここから単語データや学習機能を追加していくと、ソースコードもさらに爆発的に大きくなっていきそうだね(笑)。でも、第1回から第10回までで、基本的なタイピング練習から日本語ローマ字入力の仕組みまで、かなりの部分を公開できたと思うよ。

きょろぽん
きょろぽん

じゃあ、ここから先の魔改造はひっそりと進めようか(笑)。ソースコードの公開はVer.1.0で一区切りにして、これからは更新したタイピングラボを実際に遊べる形で公開していけばいいね。

ノン
ノン

そうしよう(笑)。ここからはVer.1.0を土台に、ひっそり魔改造しながら育てていこう。新しくなったタイピングラボは、これからも実際に触って遊べるように公開していくよ。

まとめ

第10回では、タイピングラボをVer.1.0へ更新し、日本語単語をローマ字で入力できるようにしました。

  • Microsoft IMEの一般的なローマ字入力を基準にした
  • 標準ガイドは短い入力を表示し、一般的な別入力も受け付ける
  • 入力方法に合わせてローマ字ガイドを動的に切り替える
  • 「ん」「っ」、拗音、小書き文字、外来語、カタカナ、長音を扱えるようにした
  • tyotto / chotto / cyottoなど複数経路を実際に確認した
  • 大文字入力も受け付け、Caps Lockの可能性を警告する
  • 試験用20語で繰り返し動作確認した
  • 大量の教材データを追加する前に確認できるCSV教材チェッカーを追加した
  • 第1回から続けてきた公開ソース版をVer.1.0で一区切りにした

今回の日本語20語は、教材そのものを完成させるためではなく、さまざまな入力方法を正しく判定できる仕組みを作るための試験データです。これで、タイピングラボの基本形はひとまず完成です。