第8回 タイピングWebアプリ制作|段別計測・成績保存編

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

このシリーズについて

第7回では、通常練習に加えてLevel 1~10のチャレンジモードを作りました。今回はそこから一歩進めて、練習した結果をブラウザへ保存し、自分の上達を数字で確認できるようにしていきます。

さらに、チャレンジモードには自分の過去記録と競えるランキングを追加します。前回確認していたテンキーの長桁表示も、この第8回で修正します。

今回から画面上部へ「タイピングラボ Ver.0.8」を表示します。第7回までの公開版はそのまま残し、第8回以降は記事回数とアプリのバージョンを対応させていきます。

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

今回も、どんな成績を残したいか、ランキングをどう見せるか、どの数字なら練習に役立つかは私が考え、HTML・CSS・JavaScriptのソースコード作成はAIのノンに担当してもらいました。

完成したコードを実際にブラウザで動かし、表示が窮屈になった部分や結果画面の並び、ランキングの見え方を確認しながら調整しています。コードをAIへ丸投げするというより、人が仕様と使い勝手を決め、AIが実装を担当する分担制作です。

今回作るもの

第8回では「段別計測・成績保存編」として、通常練習を分析しやすくし、チャレンジモードは自分のハイスコアと競えるようにします。

  • 画面上部へ「タイピングラボ Ver.0.8」を表示する
  • 通常練習の打数・ミス・正確率・速度を計測する
  • 数字段・上段・ホーム段・下段・テンキーごとの成績を保存する
  • 正常終了したプレイをlocalStorageへ保存する
  • Escで途中終了したプレイは成績へ残さない
  • チャレンジモードの記録をランキング化する
  • 段別チャレンジとテンキーチャレンジを別ランキングにする
  • 全LevelとLevel別のTOP10を表示する
  • 保存した成績をまとめて削除できるようにする
  • テンキーチャレンジで最大14桁になっても中央枠からはみ出さないようにする
  • 成績・ランキングを別画面にまとめ、メイン画面を詰め込みすぎないようにする

第7回から追加する機能

第7回までは、その場で打数やスコアを確認するところまででした。第8回では、練習が終わったあとも記録を残せるようにします。

通常練習は「どの段が得意で、どの段が遅いか」を確認する練習用、チャレンジモードは「何点まで伸ばせるか」を楽しむゲーム用として、計測方法を分けました。

通常練習は1文字ごとの実入力時間を使って速度を計測します。チャレンジモードは複数文字が同時に出るため、60秒全体の打数から速度を計算します。

ファイル構成

今回も基本の3ファイル構成です。成績データは別ファイルへ保存するのではなく、ブラウザのlocalStorageへ保存します。

  • index.html:画面の構造
  • style.css:画面デザイン、成績画面、ランキング、長桁表示の調整
  • script.js:タイピング処理、計測、localStorage保存、ランキング処理

完成したソースコード

まず、今回完成した3ファイルをそのまま掲載します。前回からどこが変わったかを確認したい場合も、完成版を先に見ておくと追いやすくなります。

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.0.8</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="app">
      <header class="app__header">
        <div>
          <div class="brand-line"><p class="app__eyebrow">のんびり研究所</p><p class="app-version">タイピングラボ Ver.0.8</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>
            </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="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--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.0.8</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></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>
        <footer><button id="delete-records" class="delete-records" type="button">成績データをすべて削除</button></footer>
      </div>
    </section>
    <script src="script.js"></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: 2/4;
}
.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.8:バージョン表示・成績画面 */
.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; gap: 8px; 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; }

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

.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: 1/3;
  }
  .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;
}
@media (max-width: 720px) {
  .screen-numpad {
    grid-template-columns: repeat(4, 56px);
  }
}
@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: "テンキー",
};
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 = "0.8",
  STORAGE_KEY = "typingLabRecords",
  SCHEMA_VERSION = 1,
  STAT_IDS = ["number", "top", "home", "bottom", "numpad"];

function sortSelectedRows(rows) {
  const selected = new Set(rows);
  return STAT_IDS.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 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,
  soundEnabled = true,
  audioContext = null,
  pausedAt = 0,
  pausedState = "playing";
let currentNumber = "",
  nextNumber = "",
  numberQueue = [],
  inputIndex = 0,
  transitionSerial = 0;
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"),
  rankingList = $("ranking-list");
let playStartedAt = 0,
  questionStartedAt = 0,
  playStats = createEmptyStats(),
  lastSavedPlay = 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"));
$("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"));
$("ranking-mode").addEventListener("change", renderRanking);
$("ranking-level").addEventListener("change", renderRanking);
$("delete-records").addEventListener("click", deleteAllRecords);
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") {
    selectedMode = "numpad";
    selectedRows.clear();
    modeInputs.forEach((item) => (item.checked = item === input));
  } else {
    if (selectedMode === "numpad") {
      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";
  practiceKeys = isNumpad
    ? []
    : 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 = isNumpad
    ? `NumLockをONにして、数字の後にEnterを押しましょう${challengeEnabled ? `(3~${challengeLevel + 4}桁)` : ""}`
    : challengeEnabled
      ? `60秒チャレンジ:画面内に最大${challengeLevel}文字`
      : "60秒間、表示された文字を入力しましょう";
  document.body.classList.toggle("is-challenge", challengeEnabled);
  $("app-title").textContent = challengeEnabled
    ? "段別・テンキー チャレンジモード"
    : "タイピング練習";
  $("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"]) + 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;
  message.textContent = selectedRows.size || isNumpad
    ? `${getModeLabel()}${challengeEnabled ? "チャレンジ" : ""}。Spaceキーを押してください`
    : "練習する段を選んでください";
}
function updateGuideTitle() {
  $("guide-title").textContent = state === "waiting"
    ? "光った指を、光ったキーに置いてみましょう"
    : "光っているキーを、光っている指で押しましょう";
}
function getModeLabel() {
  return selectedMode === "numpad"
    ? MODE_LABELS.numpad
    : KEYBOARD_ROWS.filter((row) => selectedRows.has(row.id))
        .map((row) => MODE_LABELS[row.id])
        .join("+");
}
function toggleChallenge() {
  challengeEnabled = !challengeEnabled;
  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 !== "numpad" && 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 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 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);
  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;
  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 {
    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 !== "numpad" && 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 !== "numpad" && 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を押してください"
      : "表示された文字を、光っている指で押してください";
  animationId = requestAnimationFrame(update);
}
function finishPractice(early = false) {
  state = "finished";
  updateGuideTitle();
  transitionSerial++;
  clearTimeout(nextKeyTimerId);
  cancelAnimationFrame(animationId);
  timeDisplay.textContent = early
    ? Math.max(0, Math.ceil((finishTime - pausedAt) / 1000))
    : "0";
  fallingKey.hidden = true;
  numpadQuestion.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"] : 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 isRecords = tab === "records";
  recordsContent.hidden = !isRecords;
  rankingContent.hidden = isRecords;
  $("records-tab").classList.toggle("is-active", isRecords);
  $("ranking-tab").classList.toggle("is-active", !isRecords);
  if (isRecords) renderRecords(); else renderRanking();
}
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 deleteAllRecords() {
  if (!confirm("練習履歴・チャレンジ履歴・ランキングをすべて削除します。よろしいですか?")) return;
  if (!confirm("削除した成績は元に戻せません。本当にすべて削除しますか?")) return;
  localStorage.removeItem(STORAGE_KEY);
  renderRecords(); renderRanking();
}
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));
}

「のんびり研究所」の右側へVer.0.8を表示する

第8回から、画面左上の「のんびり研究所」の右側へ「タイピングラボ Ver.0.8」を表示しました。

記事の第8回とアプリのVer.0.8を対応させることで、「今使っているのがどの完成版なのか」を画面だけでも確認しやすくなります。

成績とランキングは別画面で確認する

画面上部へ「成績を見る」と「ランキングはこちら」を追加しました。

タイピング画面は、上にモード選択、中央に練習エリア、下に画面キーボードと指ガイドがあり、すでにかなり情報量が多くなっています。そこで成績一覧やTOP10までメイン画面へ並べず、ボタンを押したときだけ専用画面を開く構成にしました。

成績画面では「練習成績」と「ランキング」をタブで切り替えられます。

正常終了した成績をlocalStorageへ保存する

今回から、60秒を最後まで練習した結果をブラウザのlocalStorageへ保存します。ページを閉じても同じブラウザの保存領域に記録が残るため、次に開いたときも過去の成績を確認できます。

1プレイごとに日時、アプリバージョン、選択した段、打数、ミス、入力時間などを記録します。チャレンジモードではLevel、スコア、最大コンボ数なども保存します。

アプリのバージョンとは別に、保存データ側にもschemaVersionを持たせています。将来、保存形式を変更するときに旧データと区別しやすくするためです。

Escで途中終了した成績は保存しない

練習中にEscキーを押すと、これまでどおり途中終了できます。ただし、第8回では途中終了したプレイを成績へ保存しないようにしました。

途中でやめた10秒や20秒の結果まで混ざると、平均速度や正確率を見るときに分かりにくくなります。正常に60秒を完走した記録だけを残すことで、比較しやすい成績にしています。

途中終了した場合は、結果画面にも「途中終了のため、この成績は保存されません」と表示します。

打数・ミス・正確率・速度を記録する

今回の「打数」は、正しく入力できた打鍵数です。ミスは別にカウントしているため、総打鍵数が必要な場合は「打数+ミス」で求められます。

正確率は「打数 ÷(打数+ミス)」で計算します。速度は、見やすいように秒あたりと分あたりを両方表示します。

速度は「2.00打/秒(120打/分)」のように表示します。「文字/秒」ではなく「打/秒」としたのは、今後入力方法が増えても物理的な打鍵速度として扱いやすくするためです。

通常練習は1文字ごとの実入力時間を段別に計測する

通常練習では、文字が表示された瞬間から正解キーを押すまでの時間を計測します。ミスした場合も、正解するまでにかかった時間を含めます。

そして、その文字が数字段・上段・ホーム段・下段・テンキーのどこに属するかを確認し、該当する段へ打数・ミス・入力時間を加算します。

複数段を選んで練習した場合でも、どの段で速度が落ちているのかをあとから確認できます。

通常練習では「問題表示→正解」までの実入力時間を使います。レーザーや文字破壊など、入力を待たせない演出は速度計測の邪魔をしない構成です。

保存された成績を段別に集計する

「成績を見る」を開くと、これまで正常終了した通常練習をまとめて集計します。

全体の打数・ミス・正確率・速度・練習回数に加えて、実際に記録がある数字段・上段・ホーム段・下段・テンキーをカード形式で表示します。

さらに下には練習履歴を新しい順に表示します。今回は保存した生データを残し、画面には直近100件までの履歴を表示する構成にしています。

チャレンジモードは60秒全体から速度を計算する

チャレンジモードでは、Levelによって複数の文字が同時に表示されます。

1文字ごとに「表示されてから正解するまで」を測ると、後回しにした文字だけ何十秒もかかった記録になってしまいます。そこでチャレンジモードでは、60秒間に正解した打数から全体速度を計算する方式にしました。

チャレンジの結果には、スコア・打数・ミス・最大コンボ数・速度を表示します。

段別チャレンジとテンキーを別ランキングにする

チャレンジモードのランキングは、通常の段別チャレンジとテンキーチャレンジを別扱いにしました。

段別チャレンジでは、ランキング記録に選択した段も表示します。テンキーは選択段がないため、Levelとスコアなどを表示します。

順位はスコアを基準にします。同点だった場合は「ミスが少ない → 最大コンボが多い → 打数が多い」の順で比較します。

ランキングは今のところ同じブラウザ内に保存された自分の記録だけです。将来サーバー側へ記録する仕組みを作れば、ほかの利用者と競うランキングへ拡張できます。

全LevelとLevel別のTOP10を表示する

ランキング画面では、種別とLevelを切り替えられるようにしました。

Levelは「全Level」とLevel 1~10から選択できます。全Levelでは難易度を混ぜた自分のベスト記録、Level別では同じ条件だけのTOP10を確認できます。

Levelが高いほど必ず遅くなるとは限りません。複数文字が見えている方が次の文字を先読みしやすく、Level 1よりテンポよく打てる人もいるかもしれません。全Levelランキングを見ると、自分がどのLevelでスコアを伸ばしやすいかも見えてきます。

結果画面から今回の順位を確認する

チャレンジを正常終了すると、今回の記録が保存され、その場で全Levelランキングと同Levelランキングの順位を表示します。

終了直後に「今回 全Levelランキング○位! Level ○ランキング○位」と分かるので、もう一度挑戦する目標を作りやすくなりました。

結果画面からそのまま「ランキングを見る」を押してTOP10を確認できます。

テンキーの最大14桁を中央枠へ収める

第7回の公開後、高Levelのテンキーチャレンジを試したところ、桁数が多い数字が中央の枠からはみ出す問題が見つかりました。

テンキーチャレンジはLevel 10で最大14桁になります。そこで数字の桁数に応じてフォントサイズを小さくし、カンマとEnterまで含めても中央枠へ収まりやすいようにCSSを調整しました。

数字リールの上下の問題表示も同じ仕組みを使うため、長い数字でも全体のレイアウトを崩しにくくしています。

結果画面のレイアウトも整理する

成績項目が増えたため、結果画面のレイアウトも調整しました。

打数・ミス・最大コンボ・正確率・速度などができるだけ横一列で確認できるようにし、画面幅が狭い場合だけ折り返します。

また、「もう一度練習するときはSpaceキー」の右側へ「練習モード変更」ボタンを配置しました。すぐ同じ条件でもう一度遊ぶ場合と、段やチャレンジ設定を変える場合を分けています。

選択した段の並び順は固定する

複数段を選択する順番によって、保存データや表示上の段順が変わらないようにしました。

内部では数字段 → 上段 → ホーム段 → 下段 → テンキーの固定順で並べ直してから保存・表示します。

あとからランキングや成績一覧を見たときに、「1・3・4」と「4・1・3」が別の条件のように見えないための調整です。

成績データをすべて削除できるようにする

知り合いが少し遊んだ、テスト中の記録が大量に残った、最初から自分の成績を取り直したい、といった場合に備えて「成績データをすべて削除」を追加しました。

誤操作を防ぐため、削除ボタンを押すと確認を2回表示します。削除すると通常練習の履歴、チャレンジ履歴、ランキングに使う記録がすべて消えます。

削除した成績は元に戻せません。CSVによるバックアップ機能はまだ作っていないため、必要な記録がある場合は削除しないようにします。

これまでの通常練習とチャレンジはそのまま使える

第8回は成績保存が中心ですが、第7回までに作った通常練習、複数段選択、テンキー、チャレンジ、コンボ、サウンド、Esc途中終了、指ガイド、レーザー・文字破壊などはそのまま残しています。

新しい保存機能を追加しても、タイピング中の操作方法を大きく変えないようにしました。

今回の完成形

これで、ただ60秒練習して終わるだけではなく、「前より速くなったか」「どの段が遅いか」「チャレンジの自己ベストを更新できたか」をあとから確認できるようになりました。

通常練習は上達を数字で見るモード、チャレンジは自分の記録と競って何度も遊ぶモードとして、役割も少しずつ分かれてきました。

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

下の画面から第8回の完成版を試せます。記事内では画面サイズの影響を受ける場合があるため、しっかり練習する場合は別画面で開いて使ってください。

第8回完成版を別画面で開く

まとめ

第8回では、タイピングラボ Ver.0.8として、段別計測と成績保存を追加しました。

  • のんびり研究所の右側へ「タイピングラボ Ver.0.8」を表示した
  • 通常練習の打数・ミス・正確率・速度を保存できるようにした
  • 各段の実入力時間を使って段別成績を集計できるようにした
  • Escで途中終了したプレイは保存しないようにした
  • チャレンジは60秒全体の速度とスコアを保存した
  • 段別チャレンジとテンキーを分けてTOP10を表示した
  • 全LevelランキングとLevel別ランキングを切り替えられるようにした
  • 同点時はミス・最大コンボ・打数で順位を決めるようにした
  • Level 10の最大14桁でもテンキー表示が枠へ収まるようにした
  • 成績・ランキングを別画面へまとめた
  • 成績データをすべて削除できるようにした

記録が残るようになったことで、同じ60秒練習でも「昨日の自分より速く打てたか」を確認できるようになりました。チャレンジも、ランキングがあるだけでついもう一回やりたくなります。

次回は、この保存データをどう活用していくかを考えながら、タイピングラボをさらに育てていきます。