第1回 タイピングWebアプリ制作|ホームポジション練習編

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

タイピングWebアプリ制作、第1回です。

今回は、ホームポジションの10キーだけを使った、とてもシンプルなタイピング練習を作ります。

画面上部から1文字ずつゆっくり落ちてくるので、表示された文字と同じキーを押します。正しく入力するとスコアが1点増え、すぐに次の文字が表示されます。

1セットは60秒。最初から速さを求めすぎず、ホームポジションに手を置いたまま、少しずつキーの位置を覚えていくところから始めます。

このシリーズについて

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

今回も、必要な機能や画面の動き、使い方、修正点は私が考え、HTML・CSS・JavaScriptのソースコード作成はAIのノンに担当してもらいます。

できあがったソースコードは私が実際にブラウザで動かし、使いにくいところや思っていた動きと違うところを確認しながら調整します。

AIに全部おまかせするのではなく、人間が「何を作りたいか」を考え、AIにコードを書いてもらい、最後は人間が実際に触って確認する共同制作です。

今回作るもの

今回作るのは、ホームポジションの10キーだけを使った60秒間のタイピング練習です。

  • 練習キーは A S D F G H J K L ; の10キー
  • 文字は1個ずつ表示
  • 画面上部のランダムな位置から、ゆっくり下へ落下
  • 正しいキーを押すとスコアが1点増える
  • 間違えた場合はミス数だけ増える
  • 1セット60秒
  • Spaceキーで開始
  • 開始前に3・2・1・STARTのカウントダウン
  • 60秒終了後にスコアとミス数を表示

練習中の画面

60秒終了後の結果画面

使用するもの

  • Windowsパソコン
  • Webブラウザ
  • Visual Studio Code
  • HTML
  • CSS
  • JavaScript

今回は特別なライブラリやフレームワークは使わず、HTML・CSS・JavaScriptだけで作ります。

HTML・CSS・JavaScriptの役割

今回も3つのファイルを使います。

ファイル主な役割
index.html画面の土台、文字、残り時間、スコア、結果画面などを配置
style.css色、文字サイズ、配置、落下エリア、結果画面などの見た目を調整
script.jsキー入力、ランダム出題、落下、60秒計測、スコア、ミス判定などの動作を担当

Visual Studio Codeについて

今回のソースコード編集にはVisual Studio Codeを使います。

Visual Studio Codeのインストール方法は、試験用キッチンタイマー制作の第1回で詳しく説明しています。まだインストールしていない場合は、先にこちらを参考にしてください。

第1回 試験用キッチンタイマー制作|基本機能編を別ウィンドウで開く

Visual Studio Codeをすでに使える状態なら、インストール作業は飛ばしてそのまま次へ進んで大丈夫です。

作業用フォルダを作る

タイピング練習用の作業フォルダを作ります。

今回は公開用フォルダ名を typing-lab としました。

第1回、第2回と完成状態を残していく場合は、その中へ 01、02 のように番号を付けて管理すると分かりやすくなります。次回以降もこの手順でフォルダやファイルを作成していきます。(キッチンタイマーの記事でも同じように作成しています)

Visual Studio Codeでフォルダを開く

Visual Studio Codeを起動し、先ほど作った作業用フォルダを開きます。

フォルダを開いておくと、HTML・CSS・JavaScriptの3ファイルを同じ場所で管理できます。

3つのファイルを作る

作業用フォルダの中へ、次の3ファイルを作ります。

  • index.html
  • style.css
  • script.js

ファイル名は半角英数字で作ります。

完成したソースコード

まずは今回完成した3ファイルのソースコードを掲載します。

先ほど作った3つのファイルへ、下記のソースコードをコピペして保存すれば今回のタイピング練習を動かせます。そのあとで、HTML・CSS・JavaScriptの中身を順番に見ていきます。

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>ホームポジション タイピング練習</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>スコア</dt>
          <dd id="score">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="タイピング練習エリア">
      <p class="keys">練習キー:A S D F G H J K L ;</p>

      <div id="falling-key" class="falling-key" aria-live="off" hidden>A</div>

      <div id="start-panel" class="panel">
        <p class="panel__label">ホームポジションに手を置いたら</p>
        <p class="panel__main"><kbd>Space</kbd> キーでスタート</p>
        <p class="panel__sub">60秒間、表示された文字を入力しましょう</p>
      </div>

      <div id="countdown" class="countdown" aria-live="assertive" hidden></div>

      <div id="result-panel" class="panel result" hidden>
        <p class="panel__label">練習終了!</p>
        <h2>今回の結果</h2>
        <div class="result__scores">
          <p>スコア<strong id="final-score">0</strong></p>
          <p>ミス<strong id="final-misses">0</strong></p>
        </div>
        <p class="panel__sub">もう一度練習するときは <kbd>Space</kbd> キー</p>
      </div>
    </section>

    <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;
  --paper: #ffffff;
  --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));
}

.app {
  width: min(1100px, calc(100% - 32px));
  margin: 0 auto;
  padding: 32px 0 24px;
}

.app__header {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 24px;
  margin-bottom: 18px;
}

.app__eyebrow {
  margin: 0 0 4px;
  color: var(--accent-dark);
  font-size: .82rem;
  font-weight: 700;
  letter-spacing: .12em;
}

h1 {
  margin: 0;
  font-size: clamp(1.35rem, 3vw, 2rem);
}

.status {
  display: flex;
  gap: 8px;
  margin: 0;
}

.status__item {
  min-width: 92px;
  padding: 9px 14px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: rgba(255, 255, 255, .88);
  text-align: center;
  box-shadow: 0 5px 18px rgba(41, 83, 94, .06);
}

.status dt {
  color: var(--muted);
  font-size: .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 {
  margin-left: 2px;
  font-size: .72rem;
}

.play-area {
  position: relative;
  height: clamp(430px, 66vh, 650px);
  overflow: hidden;
  border: 3px solid #b9dbe0;
  border-radius: 22px;
  background:
    linear-gradient(rgba(255, 255, 255, .72), rgba(255, 255, 255, .92)),
    repeating-linear-gradient(90deg, transparent 0 79px, #d9eef0 80px);
  box-shadow: 0 18px 45px rgba(37, 76, 88, .12);
}

.keys {
  position: absolute;
  z-index: 2;
  top: 14px;
  left: 18px;
  margin: 0;
  color: var(--muted);
  font-size: clamp(.75rem, 2vw, .92rem);
  font-weight: 700;
  letter-spacing: .08em;
}

.falling-key {
  position: absolute;
  z-index: 3;
  top: 0;
  left: 0;
  width: 1.25em;
  color: var(--accent-dark);
  font-family: Arial, sans-serif;
  font-size: clamp(4rem, 10vw, 6.5rem);
  font-weight: 900;
  line-height: 1;
  text-align: center;
  text-shadow: 0 5px 0 #d4edf0;
  will-change: transform;
}

.panel {
  position: absolute;
  z-index: 4;
  top: 50%;
  left: 50%;
  width: min(560px, calc(100% - 32px));
  padding: clamp(24px, 5vw, 42px);
  border: 1px solid var(--line);
  border-radius: 20px;
  background: rgba(255, 255, 255, .94);
  text-align: center;
  box-shadow: 0 20px 50px rgba(37, 76, 88, .15);
  transform: translate(-50%, -50%);
}

.panel__label,
.panel__sub {
  margin: 0;
  color: var(--muted);
}

.panel__main {
  margin: 16px 0;
  font-size: clamp(1.15rem, 3vw, 1.6rem);
  font-weight: 800;
}

kbd {
  display: inline-block;
  padding: .28em .65em;
  border: 1px solid #9eb8bf;
  border-bottom-width: 4px;
  border-radius: 8px;
  color: var(--accent-dark);
  background: #f8ffff;
  font: inherit;
}

.countdown {
  position: absolute;
  z-index: 5;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--accent-dark);
  background: rgba(255, 255, 255, .84);
  font-family: Arial, sans-serif;
  font-size: clamp(6rem, 22vw, 13rem);
  font-weight: 900;
  text-shadow: 0 8px 0 #d4edf0;
}

.result h2 {
  margin: 8px 0 20px;
  font-size: 1.8rem;
}

.result__scores {
  display: flex;
  justify-content: center;
  gap: 12px;
  margin-bottom: 24px;
}

.result__scores p {
  min-width: 130px;
  margin: 0;
  padding: 14px;
  border-radius: 12px;
  color: var(--muted);
  background: #edf8f9;
  font-size: .82rem;
  font-weight: 700;
}

.result__scores strong {
  display: block;
  margin-top: 4px;
  color: var(--accent-dark);
  font-size: 2.2rem;
}

.message {
  min-height: 1.5em;
  margin: 14px 0 0;
  color: var(--muted);
  text-align: center;
  font-size: .92rem;
}

[hidden] {
  display: none !important;
}

@media (max-width: 720px) {
  .app {
    width: min(100% - 20px, 1100px);
    padding-top: 18px;
  }

  .app__header {
    align-items: stretch;
    flex-direction: column;
    gap: 12px;
  }

  .status {
    width: 100%;
  }

  .status__item {
    flex: 1;
    min-width: 0;
    padding: 8px 4px;
  }

  .play-area {
    height: 62vh;
    min-height: 400px;
    border-radius: 16px;
  }

  .result__scores {
    gap: 8px;
  }

  .result__scores p {
    min-width: 0;
    flex: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .falling-key {
    will-change: auto;
  }
}
script.js のソースコードを表示
"use strict";

const PRACTICE_KEYS = ["a", "s", "d", "f", "g", "h", "j", "k", "l", ";"];
const PRACTICE_TIME = 60;
const FALL_SPEED = 16;

const playArea = document.getElementById("play-area");
const fallingKey = document.getElementById("falling-key");
const startPanel = document.getElementById("start-panel");
const countdown = document.getElementById("countdown");
const resultPanel = document.getElementById("result-panel");
const timeDisplay = document.getElementById("time");
const scoreDisplay = document.getElementById("score");
const missesDisplay = document.getElementById("misses");
const finalScore = document.getElementById("final-score");
const finalMisses = document.getElementById("final-misses");
const message = document.getElementById("message");

let state = "waiting";
let currentKey = "";
let score = 0;
let misses = 0;
let keyX = 0;
let keyY = 0;
let previousFrameTime = 0;
let finishTime = 0;
let animationId = 0;
let countdownTimerId = 0;

document.addEventListener("keydown", handleKeydown);

function handleKeydown(event) {
  if (event.code === "Space" && (state === "waiting" || state === "finished")) {
    event.preventDefault();
    startCountdown();
    return;
  }

  if (state !== "playing" || event.repeat || event.ctrlKey || event.altKey || event.metaKey) {
    return;
  }

  const typedKey = event.key.toLowerCase();

  if (typedKey === currentKey) {
    score += 1;
    scoreDisplay.textContent = score;
    showNextKey();
    return;
  }

  if (event.key.length === 1) {
    misses += 1;
    missesDisplay.textContent = misses;
  }
}

function startCountdown() {
  clearTimeout(countdownTimerId);
  cancelAnimationFrame(animationId);
  state = "countdown";
  score = 0;
  misses = 0;
  timeDisplay.textContent = PRACTICE_TIME;
  scoreDisplay.textContent = score;
  missesDisplay.textContent = misses;
  startPanel.hidden = true;
  resultPanel.hidden = true;
  fallingKey.hidden = true;
  countdown.hidden = false;
  message.textContent = "カウントダウン中です";

  const labels = ["3", "2", "1", "START"];
  let index = 0;

  const showCount = () => {
    countdown.textContent = labels[index];
    index += 1;

    if (index < labels.length) {
      countdownTimerId = window.setTimeout(showCount, 1000);
    } else {
      countdownTimerId = window.setTimeout(beginPractice, 700);
    }
  };

  showCount();
}

function beginPractice() {
  state = "playing";
  countdown.hidden = true;
  message.textContent = "表示された文字と同じキーを押してください";
  finishTime = performance.now() + PRACTICE_TIME * 1000;
  previousFrameTime = performance.now();
  showNextKey();
  animationId = requestAnimationFrame(update);
}

function showNextKey() {
  currentKey = PRACTICE_KEYS[Math.floor(Math.random() * PRACTICE_KEYS.length)];
  fallingKey.textContent = currentKey.toUpperCase();
  fallingKey.hidden = false;

  const sideMargin = 18;
  const maxX = Math.max(sideMargin, playArea.clientWidth - fallingKey.offsetWidth - sideMargin);
  keyX = sideMargin + Math.random() * (maxX - sideMargin);
  keyY = 54;
  drawKey();
}

function update(now) {
  if (state !== "playing") {
    return;
  }

  const remainingMilliseconds = finishTime - now;
  if (remainingMilliseconds <= 0) {
    finishPractice();
    return;
  }

  timeDisplay.textContent = Math.ceil(remainingMilliseconds / 1000);

  const elapsedSeconds = Math.min((now - previousFrameTime) / 1000, 0.1);
  const bottomMargin = 20;
  const maxY = playArea.clientHeight - fallingKey.offsetHeight - bottomMargin;
  keyY = Math.min(keyY + FALL_SPEED * elapsedSeconds, maxY);
  previousFrameTime = now;
  drawKey();

  animationId = requestAnimationFrame(update);
}

function drawKey() {
  fallingKey.style.transform = `translate(${keyX}px, ${keyY}px)`;
}

function finishPractice() {
  state = "finished";
  cancelAnimationFrame(animationId);
  timeDisplay.textContent = "0";
  fallingKey.hidden = true;
  finalScore.textContent = score;
  finalMisses.textContent = misses;
  resultPanel.hidden = false;
  message.textContent = "ホームポジションに手を置き、Spaceキーでもう一度練習できます";
}

HTMLで画面の土台を作る

index.htmlでは、画面に表示する要素を用意しています。

  • タイトル
  • 残り時間
  • スコア
  • ミス数
  • タイピング練習エリア
  • 落下する文字
  • 開始待ち画面
  • カウントダウン
  • 終了後の結果画面

特に今回の中心になるのが、play-areaとfalling-keyです。

<section id="play-area" class="play-area" aria-label="タイピング練習エリア">
  <p class="keys">練習キー:A S D F G H J K L ;</p>

  <div id="falling-key" class="falling-key" aria-live="off" hidden>A</div>
</section>

play-areaが文字の落下エリア、falling-keyが実際に動く1文字です。

CSSで見た目を整える

style.cssでは、全体の色や配置だけでなく、タイピング練習エリアと落下文字の見た目も設定しています。

.falling-key {
  position: absolute;
  z-index: 3;
  top: 0;
  left: 0;
  width: 1.25em;
  color: var(--accent-dark);
  font-family: Arial, sans-serif;
  font-size: clamp(4rem, 10vw, 6.5rem);
  font-weight: 900;
  line-height: 1;
  text-align: center;
  text-shadow: 0 5px 0 #d4edf0;
  will-change: transform;
}

落下する文字はかなり大きめにしています。第1回は速く打つことより、表示された文字を見て、正しい指を動かす練習が中心だからです。

スマートフォンなど画面幅が狭い場合は、メディアクエリでレイアウトを調整しています。

JavaScriptでタイピング練習を動かす

今回の動作の中心はscript.jsです。

まず、練習するキー、練習時間、文字の落下速度を定数として用意しています。

const PRACTICE_KEYS = ["a", "s", "d", "f", "g", "h", "j", "k", "l", ";"];
const PRACTICE_TIME = 60;
const FALL_SPEED = 16;

第1回では、練習キーをホームポジションの10キーだけに限定しています。最初から26文字全部を使わず、まずは指をホームポジションから動かす練習に集中します。

Spaceキーで開始する

開始はマウスクリックではなく、Spaceキーです。

ホームポジションへ手を置いてから始められるようにするため、練習開始のためにマウスへ手を移動しなくて済むようにしました。

if (event.code === "Space" && (state === "waiting" || state === "finished")) {
  event.preventDefault();
  startCountdown();
  return;
}

3・2・1・STARTを表示する

Spaceキーを押すと、すぐに文字が落ち始めるのではなく、3・2・1・STARTとカウントダウンします。

const labels = ["3", "2", "1", "START"];

START表示後に練習を開始し、60秒後の終了時刻を記録します。

1文字をランダムに選ぶ

次に表示する文字は、10個の練習キーからランダムに選びます。

currentKey = PRACTICE_KEYS[Math.floor(Math.random() * PRACTICE_KEYS.length)];

同じ文字が連続して選ばれることもあります。今回は完全なランダム出題のままにしています。

文字の横位置もランダムにする

文字は毎回同じ場所から落とさず、練習エリア上部のランダムな横位置に表示します。

ただし、画面枠外から出てくると文字が見えるまで待つことになるため、最初から文字全体が見える範囲に収まるようにしています。

const sideMargin = 18;
const maxX = Math.max(sideMargin, playArea.clientWidth - fallingKey.offsetWidth - sideMargin);
keyX = sideMargin + Math.random() * (maxX - sideMargin);
keyY = 54;

文字をゆっくり落下させる

文字の移動にはrequestAnimationFrame()を使っています。

今回の落下速度はかなりゆっくりです。第1回では、文字が落ちること自体をゲームオーバーの条件にはしません。

const bottomMargin = 20;
const maxY = playArea.clientHeight - fallingKey.offsetHeight - bottomMargin;
keyY = Math.min(keyY + FALL_SPEED * elapsedSeconds, maxY);

Math.min()で下端を超えないようにしているため、文字が一番下まで到達すると、その場で止まって正しく入力されるまで待ちます。

正解ならスコアを1点増やす

入力したキーと現在の文字が同じなら、スコアを1点増やし、すぐに次の文字を表示します。

if (typedKey === currentKey) {
  score += 1;
  scoreDisplay.textContent = score;
  showNextKey();
  return;
}

早く正しく入力できれば、それだけ60秒間のスコアが高くなります。

初回のスコアと、何日か練習した後のスコアを比べれば、タイピング速度の上達も確認できます。

間違えた場合はミス数だけ増やす

第1回では、ミスタイプによる減点はありません。

if (event.key.length === 1) {
  misses += 1;
  missesDisplay.textContent = misses;
}

スコアとは別にミス数を残すことで、「速くなったけれどミスが増えた」「スコアは同じだけれどミスが減った」といった変化も確認できます。

ブラウザで開いてみる

3つのファイルを保存したら、index.htmlをブラウザで開きます。

タイトル、残り時間、スコア、ミス、中央の開始パネルが表示されれば、まずは画面の読み込み成功です。

Spaceキーで練習を開始する

ホームポジションに手を置き、Spaceキーを押します。

3・2・1・STARTと表示されたあと、60秒間の練習が始まります。

60秒間タイピングしてみる

表示された文字と同じキーを押します。

正解すると、その文字は消えてすぐ次の文字が表示されます。文字は毎回ランダムな横位置からゆっくり落下します。

第1回では焦らなくて大丈夫です。文字が一番下まで落ちても消えず、その場で待ってくれます。

最初はスコアよりも、キーボードを見ずにホームポジションから指を動かせるかを意識してみてください。慣れてくると、自然に60秒間のスコアも伸びていきます。

スコアとミスを確認する

60秒経過すると練習終了です。

結果画面に今回のスコアとミス数が表示されます。

スコアは60秒間に正しく入力できた文字数です。今回は正解1文字につき1点、ミスタイプによる減点はありません。

もう一度挑戦するときは、ホームポジションに手を戻してSpaceキーを押します。

うまく動かないときの確認

  • index.html、style.css、script.jsが同じフォルダにあるか確認する
  • ファイル名の大文字・小文字やスペルが合っているか確認する
  • index.htmlのCSSとJavaScriptの読み込み先が正しいか確認する
  • ソースコードをコピーしたあと、保存してからブラウザを再読み込みする
  • Spaceキーを押しても始まらない場合は、ブラウザ画面を一度クリックしてから試す

ファイル名が style.css.txt や script.js.txt になっていると読み込めません。Windowsで拡張子を非表示にしている場合は注意してください。

今回の完成形

これで、ホームポジション10キーを使った60秒間のタイピング練習が完成しました。

  • Spaceキーで開始
  • 3・2・1・STARTのカウントダウン
  • ホームポジション10キーからランダム出題
  • 1文字ずつゆっくり落下
  • 正解でスコア+1
  • ミスタイプを別カウント
  • 60秒で終了
  • 結果画面からすぐ再挑戦

機能だけを見ると、とてもシンプルです。

でも、この第1回のソースコードが、この先追加していく画面キーボード、指ガイド、段選択、日本語ローマ字入力などの土台になります。

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

今回完成したホームポジション練習は、のんびり研究所のWebツールとして公開しています。

このページ内でも、そのまま試すことができます。

別ウィンドウで開く

まとめ

今回は、タイピングWebアプリ制作の第1回として、ホームポジション10キーだけを使った60秒間のタイピング練習を作りました。

最初から画面キーボードや指ガイドを付けず、まずは「キー入力を受け取る」「正解を判定する」「文字をランダムに表示する」「60秒計測する」という基本部分だけを作っています。

次回は、画面キーボードと両手の指ガイドを追加し、どのキーをどの指で押すのかを分かりやすくしていく予定です。

そして、正しく入力したときの文字の消え方も、少し面白くしていきたいと思います(笑)