第7回 タイピングWebアプリ制作|チャレンジモード編

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

このシリーズについて

今回は、これまで作ってきた段別・テンキーのタイピング練習へ「チャレンジモード」を追加します。

通常の練習モードは、表示された文字を1つずつ落ち着いて入力する基礎練習でした。第7回では、同じ練習範囲を使いながら、Levelを上げると画面内へ複数の文字が同時に出現し、コンボやスコアも加わるゲーム寄りの練習へ切り替えられるようにします。

前回の第6回では、複数段選択と入力テンポ改善まで完成しました。今回はその仕組みを残したまま、チャレンジ用の処理を上へ重ねていきます。

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

このタイピングWebアプリは、今回も人とAIで役割を分けて作っています。

  • どんな練習にしたいか、画面の動き、得点方法、使いにくい部分の判断は私が担当します。
  • HTML・CSS・JavaScriptの作成や修正はChatGPT(ノン)が担当します。
  • 完成したソースは実際にブラウザで動かし、遊びながら確認します。
  • 気になった部分はその場で修正し、納得できた状態になってから記事へまとめます。

AIへ全部任せるのではなく、「こうしたい」を人が決めて、実装をAIへ手伝ってもらう形で進めています。今回も実際に何度も遊びながら、表示や得点方法を調整しました。

今回作るもの

第7回で追加する中心機能は「チャレンジモード」です。通常練習と同じ画面から切り替えられ、Level 1~10を選択できます。

  • 通常の練習モードとチャレンジモードを切り替えられる
  • Level 1~10をスライダーで変更できる
  • 通常段のチャレンジでは、Levelに応じて複数文字を同時表示する
  • テンキーのチャレンジでは、Levelに応じて出題する数字の最大桁数を増やす
  • 正解を続けるとコンボが増える
  • 5コンボ以上でスコア倍率適用と、スコア倍率表示を表示する
  • ミスすると20点減点し、コンボを0へ戻す
  • 10コンボごとにコンボ表示を強調する
  • チャレンジ終了後は、スコア・打数・ミス・最大コンボ数を表示する
  • 開始前はキーと担当指を順番に光らせ、線でつないでホームポジションを確認できる

通常練習はこれまでどおり残します。チャレンジモードをOFFにすれば、従来の60秒練習としてそのまま使えます。

第6回から追加する機能

第6回では複数段を同時選択できるようにし、正解直後に次の文字を入力できるようテンポも改善しました。今回のチャレンジモードは、その仕組みを土台として追加しています。

チャレンジモードでは、通常練習と見分けやすいように選択カードやタイトルをオレンジ系へ変更しました。一方、通常練習では従来の青系を使っています。

また、通常練習では「スコア」ではなく「打数」と表示するよう整理しました。チャレンジモードだけ、得点計算を行うため「スコア」と表示します。

ファイル構成

今回もファイル構成はシンプルな3ファイルです。

  • index.html:画面の部品を作る
  • style.css:通常練習・チャレンジ・コンボなどの見た目を整える
  • script.js:入力判定、Level、複数文字、得点、コンボなどの動きを担当する

保存先は、第7回完成版として次のように分けます。

  • /tools/typing-lab/07/index.html
  • /tools/typing-lab/07/style.css
  • /tools/typing-lab/07/script.js

完成したソースコード

最初に、今回の完成版ソースコードをまとめて載せます。前回までのコードへ追加・修正した形なので、差分を確認したい場合にも使えます。

index.html のソースコードを表示
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>段別・テンキー タイピング練習</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="app">
      <header class="app__header">
        <div>
          <p class="app__eyebrow">のんびり研究所</p>
          <h1 id="app-title">タイピング練習</h1>
        </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>
          </div>
          <p class="panel__sub">もう一度練習するときは <kbd>Space</kbd> キー</p>
          <button id="change-mode" class="change-mode" type="button">
            練習モードを変更
          </button>
        </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>
    <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);
}
.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,
.result__scores {
  display: flex;
  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 {
  min-width: 130px;
  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;
}
.change-mode {
  margin-top: 15px;
}
.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;
}
.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 $ = (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");

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

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 (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;
  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;
  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)];
  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() {
  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.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) 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++;
  refreshCurrentRow();
}
function completeNumpadQuestion() {
  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;
  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++;
  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;
  finishTime += performance.now() - pausedAt;
  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;
  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 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));
}

チャレンジモードを切り替えられるようにする

スタート画面へ「チャレンジモード」ボタンを追加しました。押すたびに通常練習とチャレンジモードが切り替わります。

チャレンジモードがONになると、ボタンへチェックマークが付き、Levelスライダーも表示されます。さらにページ上部のタイトルも「段別・テンキー チャレンジモード」へ切り替わります。

モードそのものを別ページへ分けず、同じ段選択画面から切り替える形にしました。基礎練習からチャレンジへ移るときも、操作を覚え直す必要がありません。

Level 1~10をスライダーで選ぶ

チャレンジモードでは、Level 1~10をスライダーで選択できます。

通常段とテンキーでは、Levelの使い方を少し変えています。

  • 数字段・上段・ホーム段・下段:Levelと同じ数だけ、画面内へ文字を同時に表示する
  • テンキー:Levelが上がるほど、出題される数字の最大桁数を増やす

たとえば通常段のLevel 1なら1文字、Level 5なら最大5文字、Level 10なら最大10文字が画面内に存在します。

テンキーは通常時が3~5桁で、チャレンジ時は最大桁数を Level + 4 としています。そのためLevel 10では最大14桁まで出題されます。

同じ「Level」でも、通常段は同時処理する文字数、テンキーは数字列の長さとして難易度を上げています。

通常タイピングは複数文字を同時に出す

通常段のチャレンジでは、従来の falling-key 1個だけでは足りません。そこで、チャレンジ専用の文字を複数作り、配列で管理するようにしました。

Levelの数だけ横方向のレーンを作り、それぞれへ1文字ずつ配置します。正解した文字はレーザーと破壊演出を出しながら消え、そのレーンへ新しい文字を補充します。

文字は従来と同じようにゆっくり下へ移動し、画面下まで到達しても消えません。その場で停止します。

速く落として焦らせるゲームではなく、「同時に見る文字が増える」ことで難しくする方針にしました。タイピング練習として指の動きを崩しにくくするためです。

同じ文字が複数あっても入力できるようにする

複数文字を表示すると、たとえば画面内にAが2個存在することもあります。

入力されたキーと同じ文字を候補から探し、複数見つかった場合は、その中から1個を選んで正解処理します。これにより、同じ文字が同時に存在しても入力が止まりません。

チャレンジ中は「どのAを壊すか」を利用者へ選ばせる必要はなく、Aを押せば画面内のAのどれか1つが消えるシンプルな仕組みにしています。

正解した文字はすぐ補充する

第6回で採用した「演出は見せる。でも入力は待たせない」という考え方は、チャレンジモードでもそのまま使っています。

正解した文字はヒット状態にし、レーザー・火花・文字破壊を表示します。しかし、演出が終わるまで次の入力を待つことはありません。約0.21秒後に対象文字を削除し、すぐに不足分を補充します。

高速入力して複数の破壊演出が重なっても、それぞれ独立して消えていくため、次の入力を止めません。

テンキーのチャレンジは桁数を増やす

テンキーでは、通常段のように複数の問題を同時表示する方式にはしていません。

これまで作った数字リール方式を残し、Levelが高くなるほど長い数字を出題します。入力方法は従来と同じで、数字を順番に入力し、最後にテンキーのEnterを押します。

きょろぽん
きょろぽん

あ、Levelを上げて桁数が多くなると、数字が枠からはみ出すね(笑)

ノン
ノン

ほんとだね。短い数字では問題なかったけど、チャレンジモードで桁数を増やしたことで見つかった部分だね。ここは次回、桁数が増えても枠の中へ収まるように調整しよう。

きょろぽん
きょろぽん

今回はチャレンジモードまで完成したし、ここは次回の修正ポイントとして残しておこう。

テンキーのEnterは実物に近い位置になるよう、第5回公開後に配置も修正しています。3とドットの右側に縦2段で表示します。

チャレンジ用のスコアを作る

通常練習は何文字正しく打てたかをそのまま「打数」として表示します。チャレンジモードではコンボを加味するため、別に「スコア」を計算します。

基本点は正解1打につき10点です。4コンボまでは10点ずつ加算し、5コンボからはコンボ数に応じて得点が増えていきます。

  • 1~4コンボ:正解1打につき10点
  • 5コンボ:15点
  • 6コンボ:16点
  • 10コンボ:20点
  • 20コンボ:30点

内部では、5コンボ以降を「10+現在のコンボ数」で加算しています。表示上は倍率として見せるため、5コンボなら×1.5、10コンボなら×2.0、20コンボなら×3.0となります。

5コンボから倍率を表示する

1~4コンボまでは画面をすっきりさせ、5コンボに到達した時点で右上へコンボ表示を出します。

表示内容は現在のコンボ数とスコア倍率です。10、20、30……と10コンボ単位へ到達したときは、表示を一度大きくして目立たせます。

コンボは最初から常時表示せず、「続けて正解できている」と実感できる5コンボから表示する形にしました。

ミスは20点減点してコンボを切る

チャレンジモードでは、適当にキーを連打して当たった文字だけを消す方法が有利にならないよう、ミスへペナルティを入れました。

  • ミス1回でスコア−20点
  • 現在のコンボを0へ戻す
  • 5コンボ以上からミスした場合は、画面を短く赤く光らせる
  • これまでの赤発光・揺れ・失敗音・暗い失敗レーザーもそのまま使う

通常練習ではミスしても減点しません。−20点とコンボ解除はチャレンジモードだけです。

通常練習とチャレンジの表示を分ける

同じ画面を使うため、今どちらのモードなのか分かりやすいことも重要です。

通常練習は従来どおり青系、チャレンジモードはオレンジ系にしました。タイトル、選択中の練習カード、チャレンジボタンなどが連動して変化します。

上部の成績表示も、通常練習では「残り時間・打数・ミス」、チャレンジでは「残り時間・スコア・打数・ミス」へ切り替えます。

終了後に最大コンボ数も表示する

チャレンジモードの結果画面では、得点だけではなく練習内容も残るようにしました。

  • スコア
  • 打数
  • ミス
  • 最大コンボ数

通常練習では従来どおり「打数・ミス」を表示し、チャレンジ専用の項目は隠します。

スタート前に指とキーの位置を確認する

今回、スタート前の指ガイドも少し分かりやすくしました。

まだSpaceを押していない待機中は、ホームポジション周辺のキーと対応する指を約0.5秒ごとに順番に光らせます。さらに、光っているキーと指先を点線でつなぎます。

通常キーボードではF・D・S・A・J・K・L・;、テンキーでは4・5・6・0・Enterを順番に案内します。

ホームポジションを知らない人でも、スタート前の動きを見れば「この指をこのキーへ置く」という関係をつかみやすくするための補助です。練習開始後は通常の出題ガイドへ切り替わります。

これまでの通常練習はそのまま使える

チャレンジモードを追加しても、これまで作った基本練習を壊さないことを優先しました。

  • 数字段・上段・ホーム段・下段の複数選択
  • テンキーを別モードとして扱う仕組み
  • 非選択段の暗表示
  • 対象キー・担当指の発光
  • 正解時のレーザー・火花・文字破壊
  • ミス時の赤発光・揺れ・失敗レーザー
  • SE ON/OFF
  • Escによる途中終了
  • テンキーの数字リール
  • 60秒固定の練習時間

これらはそのまま残っています。チャレンジをOFFにすれば、前回までの練習モードとして利用できます。

今回の完成形

これで、基礎練習とゲーム寄りのチャレンジを同じタイピングWebアプリ内で使い分けられるようになりました。

最初は通常練習でホームポジションや担当指を確認し、慣れてきたらチャレンジモードへ切り替える、といった使い方ができます。

Levelを上げても落下速度そのものは速くしていないので、単純な反射神経ゲームではなく、「複数の候補を見ながら正しい指で打つ」練習として難易度を上げています。

【画像を挿入:第7回完成版の全体画面】

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

第7回の完成版は、次のページから別画面で開いて試せます。

第7回 タイピングWebアプリ完成版を別画面で開く

記事内へ埋め込んだ画面は、表示領域の大きさによって落下文字やレーザーが見えにくい場合があります。しっかり練習する場合は、上のリンクから別画面で開くのがおすすめです。

まとめ

第7回では、これまでの段別・テンキー練習へチャレンジモードを追加しました。

  • 通常練習とチャレンジモードを切り替えられるようにした
  • Level 1~10を追加した
  • 通常段ではLevelに応じて複数文字を同時表示した
  • テンキーではLevelに応じて最大桁数を増やした
  • 正解を続けるとコンボとスコア倍率が上がるようにした
  • ミスは−20点としてコンボを解除するようにした
  • 通常練習は「打数」、チャレンジは「スコア」と「打数」を分けて表示した
  • 結果画面へ最大コンボ数を追加した
  • スタート前にキーと担当指を順番に光らせ、線でつなぐガイドを追加した

ここまでで、タイピングの基礎練習だけでなく、少しゲーム感覚で繰り返せる練習にもなってきました。

次回以降は、今回の打数・ミス・スコアなどを土台にしながら、成績保存へつながる計測方法も少しずつ整理していく予定です。