- このシリーズについて
- AIと作業を分担して作ります
- 今回作るもの
- 第6回から追加する機能
- ファイル構成
- 完成したソースコード
- チャレンジモードを切り替えられるようにする
- Level 1~10をスライダーで選ぶ
- 通常タイピングは複数文字を同時に出す
- 同じ文字が複数あっても入力できるようにする
- 正解した文字はすぐ補充する
- テンキーのチャレンジは桁数を増やす
- チャレンジ用のスコアを作る
- 5コンボから倍率を表示する
- ミスは20点減点してコンボを切る
- 通常練習とチャレンジの表示を分ける
- 終了後に最大コンボ数も表示する
- スタート前に指とキーの位置を確認する
- これまでの通常練習はそのまま使える
- 今回の完成形
- 完成したタイピング練習を使ってみる
- まとめ
このシリーズについて
- 第1回:タイピングWebアプリ制作|ホームポジション練習編
- 第2回:タイピングWebアプリ制作|画面キーボード・指ガイド編
- 第3回:タイピングWebアプリ制作|段別練習・4段キーボード編
- 第4回:タイピングWebアプリ制作|サウンド・途中終了編
- 第5回:タイピングWebアプリ制作|数字段・テンキー練習編
- 第6回:タイピングWebアプリ制作|複数段選択・入力テンポ改善編
- 第7回:タイピングWebアプリ制作|チャレンジモード編
- 第8回:タイピングWebアプリ制作|段別計測・成績保存編
- 第9回:タイピングWebアプリ制作|成績分析・CSVバックアップ編
- 第10回:タイピングWebアプリ制作|日本語ローマ字入力・Ver.1.0完成編
今回は、これまで作ってきた段別・テンキーのタイピング練習へ「チャレンジモード」を追加します。
通常の練習モードは、表示された文字を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回では、これまでの段別・テンキー練習へチャレンジモードを追加しました。
- 通常練習とチャレンジモードを切り替えられるようにした
- Level 1~10を追加した
- 通常段ではLevelに応じて複数文字を同時表示した
- テンキーではLevelに応じて最大桁数を増やした
- 正解を続けるとコンボとスコア倍率が上がるようにした
- ミスは−20点としてコンボを解除するようにした
- 通常練習は「打数」、チャレンジは「スコア」と「打数」を分けて表示した
- 結果画面へ最大コンボ数を追加した
- スタート前にキーと担当指を順番に光らせ、線でつなぐガイドを追加した
ここまでで、タイピングの基礎練習だけでなく、少しゲーム感覚で繰り返せる練習にもなってきました。
次回以降は、今回の打数・ミス・スコアなどを土台にしながら、成績保存へつながる計測方法も少しずつ整理していく予定です。


