GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

タイピング練習 9種 — 毎分の打鍵数の計算からミスの照合とキーのヒートマップまで

タイピング練習は打鍵の記録をどうやって速さと正確さに変えるのでしょうか。01番は直近の数秒の文字数だけで毎分の打鍵数を出し、03番は正解の文字と並べて比べてミスの位置を示し、07番は過去の記録の歩みをゴーストのカーソルでたどり直します。

タイピング練習は文字を打つ絵ではなく、押した時刻と正解の文字という二つの値から速さも正確さも苦手なキーもすべて計算する仕組みです — タイピング練習ゲームを作るには、どの値が点数を決めるのかを知ることから始まります

自動再生 · タイルを押すとその項目へ · すべて1つのzip

九つの場面は同じ入力を受け取ります。キー一回は押した時刻 t、押したキー k、その位置の正解と同じかどうか ok の三つの値で、画面の毎分の打鍵数・ミス・ヒートマップ・コンボ・ゴーストは、この記録の一覧 strokes と打った文字列 buf からだけ計算されます。自動のデモは種から作った仮想の打ち手の記録を再生するだけなので、同じ種は同じ記録を作ります。

どの場面も左が計算を正しく行った盤で、右は同じ記録を受け取る対比版です。並びは速さから始まって間違いへ広がります。01・02は毎分の打鍵数と行の移動、03・04はミスのマスと苦手なキー、05・06はハングルの組み立てとコンボの規則、07・08は過去の記録とキーの間隔、09は落下する単語を扱います。

タイピング練習 9種 — 直近の区間で数える毎分の打鍵数・カーソルの先へ送られる行スクロール・正解と並べるミスの照合・キーの押下回数ヒートマップ・ハングル組み立て中の文字の状態・連続正打のコンボ倍率・過去の記録を走るゴーストのカーソル・キー間隔の分布バー・接頭辞で選ぶ落下単語

九つの場面の面積と動き

場面 累計面積 動いたフレーム 強度の平均
01 直近の区間の毎分の打鍵数 22.0% 23/23 76.4
02 行スクロール 10.0% 23/23 97.4
03 ミスの照合 38.9% 18/23 52.0
04 キーのヒートマップ 33.5% 22/23 53.8
05 ハングルの組み立て 21.7% 9/23 28.7
06 コンボの倍率 25.2% 23/23 83.4
07 ゴーストのカーソル 14.6% 23/23 73.0
08 間隔の分布 1.1% 15/23 76.2
09 落下単語 12.7% 23/23 53.0

二つの盤の地がいっしょに色づく03が38.9%でいちばん広く、棒と線だけが変わる08が1.1%でいちばん狭くなっています。動いたフレームは01・02・06・07・09が23/23で、05が9/23といちばん少なく、強度の平均は02が97.4で最も高い値です。

01直近の区間で数える毎分の打鍵数

合っていた文字数を CHARS_PER_WORD の5で割って単語とみなし、全体の経過時間ではなく直近の WINDOW_SEC の5秒に入った文字だけを毎分の値に直します。開始直後は割る時間が MIN_SPAN の2秒より小さくならず、右の盤は最初からの平均なので速さが変わってもだいぶ遅れてついてきます。同じ記録で8秒の時点は左が86で平均が69、速さが落ちた12秒の時点は左が43で平均が57です。

直近の区間で数える毎分の打鍵数
新しいタブで開く

01 · 直近の区間で数える毎分の打鍵数 — 計測 累積面積 21.981% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WINDOW_SECCHARS_PER_WORDrateNow
    function rateNow(t, win) {
        if (strokes.length === 0) {
            return 0;
        }
        let n = 0;
        for (let i = 0; i < strokes.length; i += 1) {
            const s = strokes[i];
            if (s.ok && s.t > t - win && s.t <= t) {
                n += 1;
            }
        }
        const span = clamp(t - strokes[0].t, MIN_SPAN, win);
        return (n / CHARS_PER_WORD) * (60 / span);
    }
    // 最初からの平均 — 全体の経過時間で割る
    function avgRate(t) {
        if (strokes.length === 0) {
            return 0;
        }
        const span = Math.max(t - strokes[0].t, 1);
        return (okCount() / CHARS_PER_WORD) * (60 / span);
    }

02カーソルの先へ送られる行スクロール

lineShift が今の文字を枠の幅の FOCUS_X の0.5の位置に置く目標の移動量を求め、followStep がコマごとにその差の一部だけ追いかけ、FOLLOW_EASE がその速さです。右の盤は pageShift でカーソルが右端の三マスに入ったときだけ一ページずつ送るので、目が何度も飛びます。等幅1マスを7.7pxと仮定すると、中心からのずれの平均は16.4px対47.7pxで、右の盤は一回の練習で4回切り替わります。

カーソルの先へ送られる行スクロール
新しいタブで開く

02 · カーソルの先へ送られる行スクロール — 計測 累積面積 10.049% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FOCUS_XFOLLOW_EASElineShift
    // 目標の移動量 — 今の文字が枠の幅の FOCUS_X の位置に来るようにする値。行の先頭付近では0より小さくならない
    function lineShift(caretX, viewW) {
        return Math.max(0, caretX - FOCUS_X * viewW);
    }
    // コマの長さと関係なく同じ速さで追いかける指数的な近づき方 — 差の (1 − e^(−FOLLOW_EASE·dt)) の分だけ縮める
    function followStep(cur, goal, dt) {
        return cur + (goal - cur) * (1 - Math.exp(-FOLLOW_EASE * dt));
    }
    // ページ送り — カーソルが右端の三マスに入ると一ページを丸ごと送る
    function pageShift(caretX, viewW, cw) {
        const page = viewW - 3 * cw;
        return Math.floor(caretX / page) * page;
    }

03正解と並べるミスの照合

missCells が打った文字列と正解の TARGET_TEXT を同じマスで比べて違うマスの番号を集め、違うマスはピンクの地の下に正解の文字を重ねて見せます。消して打ち直すとマスはすぐ元に戻りますが、右の盤は間違えた瞬間に0.7秒だけ盤全体が光るだけで、どのマスかは知らせません。一回の練習で違うマスは同時に最大3マスまで出て最後は0マスになり、キー46回のうち40回が合っているので正確さは87.0%のまま残ります。

正解と並べるミスの照合
新しいタブで開く

03 · 正解と並べるミスの照合 — 計測 累積面積 38.865% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TARGET_TEXTmissCellsMISS_TINT
    // 違うマス — 打った文字と同じ位置の正解の文字が異なるマスの番号の一覧
    function missCells() {
        const out = [];
        for (let i = 0; i < buf.length; i += 1) {
            if (buf.charAt(i) !== target.charAt(i)) {
                out.push(i);
            }
        }
        return out;
    }

04キーの押下回数ヒートマップ

countKey はキーごとに押した回数を増やし、間違えたときは押すべきだった正解のキーのミスの回数を増やします。heatLevel が最もミスの多いキーの値で割った比率を HEAT_STEP の4段階に塗り、topKeys が苦手なキーの上位 WEAK_TOP の3個を選びますが、右の盤は押した回数だけを塗るので苦手なキーが埋もれます。61文字の練習文でb・p・xはミスが各2回で4段階、q・z・jは1回で2段階に塗られ、いちばん多く押したキーはa・e・oの各5回です。

キーの押下回数ヒートマップ
新しいタブで開く

04 · キーの押下回数ヒートマップ — 計測 累積面積 33.541% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

KEY_ROWSHEAT_STEPWEAK_TOP
    // 色の段階 — 比率(0から1)を HEAT_STEP の段階に切り上げて分ける。一度でもあれば1段階より下がらない
    function heatLevel(v, vmax) {
        if (v <= 0 || vmax <= 0) {
            return 0;
        }
        return Math.max(1, Math.ceil((v / vmax) * HEAT_STEP));
    }
    // 値が大きい順(同じなら文字順)で上位 n 個のキー — スペースバーは除く
    function topKeys(m, n) {
        const ks = Object.keys(m).filter(function (k) { return m[k] > 0 && k !== ' '; });
        ks.sort(function (a, b) { return m[b] - m[a] || (a < b ? -1 : 1); });
        return ks.slice(0, n);
    }

05ハングル組み立て中の文字の状態

compKey が字母(ジャモ)を一つ受け取り、初声だけ、初声と中声、終声まで付いた文字へと形を変え、終声のある文字に母音が来ると終声を次の文字の初声へ送ります。組み立て中の文字は点線の下線で確定した文字と分け、commitLen は確定した文字だけを数えますが、右の盤はキーごとに一つ増やして組み立て中にも数えます。26回の入力で空白3つを含む11文字を作ると、確定は11字で、キーごとに数える盤は24と2.2倍に膨らみます。

ハングル組み立て中の文字の状態
新しいタブで開く

05 · ハングル組み立て中の文字の状態 — 計測 累積面積 21.652% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

JAMO_SEQCOMPOSE_MARKcommitLen
    const DUBEOL = {
        KeyR: 'ㄱ', KeyS: 'ㄴ', KeyE: 'ㄷ', KeyF: 'ㄹ', KeyA: 'ㅁ', KeyQ: 'ㅂ', KeyT: 'ㅅ', KeyD: 'ㅇ', KeyW: 'ㅈ', KeyC: 'ㅊ',
        KeyZ: 'ㅋ', KeyX: 'ㅌ', KeyV: 'ㅍ', KeyG: 'ㅎ', KeyK: 'ㅏ', KeyO: 'ㅐ', KeyI: 'ㅑ', KeyJ: 'ㅓ', KeyP: 'ㅔ', KeyU: 'ㅕ',
        KeyH: 'ㅗ', KeyY: 'ㅛ', KeyN: 'ㅜ', KeyB: 'ㅠ', KeyM: 'ㅡ', KeyL: 'ㅣ',
    };
    const DUBEOL_SHIFT = { KeyQ: 'ㅃ', KeyW: 'ㅉ', KeyE: 'ㄸ', KeyR: 'ㄲ', KeyT: 'ㅆ', KeyO: 'ㅒ', KeyP: 'ㅖ' };
    keyMap = function (e) {
        if (e.code === 'Backspace') {
            return BS;
        }
        if (e.code === 'Space') {
            return ' ';
        }
        if (e.shiftKey && DUBEOL_SHIFT[e.code]) {
            return DUBEOL_SHIFT[e.code];
        }
        return DUBEOL[e.code] || '';
    };

06連続正打のコンボ倍率

multOf が連続で打った数を COMBO_STEP の10で割った商に1を足して倍率を階段状に上げ、MULT_MAX の5で止まります。afterMiss はミス一回で MISS_DROP の1段階だけ下げ、右の盤はミスのたびに連続の数を0へ戻すので、一度の失敗で積んだ倍率が丸ごと消えます。ミスが3回入った同じキー記録で、左は1900点、右は1280点です。

連続正打のコンボ倍率
新しいタブで開く

06 · 連続正打のコンボ倍率 — 計測 累積面積 25.165% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COMBO_STEPMULT_MAXMISS_DROP
    // 倍率 — 連続で打った数 ÷ step の商 + 1、MULT_MAX で止まる
    function multOf(streak, step) {
        return Math.min(MULT_MAX, 1 + Math.floor(streak / step));
    }
    // ミスのあとの連続の数 — 今の倍率から drop 段階だけ下げ、その倍率が始まる値へ戻る
    function afterMiss(streak, step, drop) {
        const m = Math.max(1, multOf(streak, step) - drop);
        return (m - 1) * step;
    }

07過去の記録を走るゴーストのカーソル

ghostPos が過去の記録の文字ごとの押した時刻の一覧 pressTimes から今の時刻以下の個数を二分探索で探して半透明のカーソルを置き、linearPos は平均の速さで走る直線のカーソルです。自分のカーソルとゴーストの文字数の差が間隔の棒で見え、右の盤は平均の直線なので過去の記録で詰まった区間が消えます。72文字を過去の記録は13.65秒、今の記録は12.95秒で打ち終え、自分のカーソルは最初の7秒ほどで最大7文字遅れ、ゴーストが詰まった区間で追いついて最大5文字先に出ます。

過去の記録を走るゴーストのカーソル
新しいタブで開く

07 · 過去の記録を走るゴーストのカーソル — 計測 累積面積 14.556% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GHOST_GAINpressTimesGAP_BAR
    // ゴーストの位置 — 時刻 t × gain までに押された文字数を pressTimes から探す(二分探索)
    function ghostPos(t, gain) {
        const g = t * gain;
        let lo = 0;
        let hi = pressTimes.length;
        while (lo < hi) {
            const mid = (lo + hi) >> 1;
            if (pressTimes[mid] <= g) {
                lo = mid + 1;
            }
            else {
                hi = mid;
            }
        }
        return lo;
    }
    // 平均の速さのカーソル — 全体の文字数を最後の文字の時刻で割った速さで直線に進む
    function linearPos(t) {
        const n = pressTimes.length;
        return n === 0 ? 0 : Math.min(n, Math.floor((t / pressTimes[n - 1]) * n));
    }

08キー間隔の分布バー

gapList が隣り合うキーの時刻の差を集め、binCounts がその間隔を BIN_MS の50ミリ秒幅の12マスに振り分けて棒の高さで描きます。平均の間隔をA・Bとも0.256秒に合わせても、一定に打つAは山が一つ、まとめて打って止まるBは山が二つに分かれ、右の盤は平均の一本線だけなので二人が同じに見えます。いちばん長い停止はAが0.311秒、Bが0.972秒で、Aは隣り合う三つのマスに20回・22回・5回が集まり、Bは0.10から0.15秒のマス34回と0.55秒以上のマス9回に分かれます。

キー間隔の分布バー
新しいタブで開く

08 · キー間隔の分布バー — 計測 累積面積 1.08% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BIN_MSBIN_NgapList
    function gapList(ts) {
        const out = [];
        for (let i = 1; i < ts.length; i += 1) {
            out.push(ts[i] - ts[i - 1]);
        }
        return out;
    }
    function binCounts(gaps) {
        const c = [];
        for (let i = 0; i < BIN_N; i += 1) {
            c.push(0);
        }
        for (let i = 0; i < gaps.length; i += 1) {
            c[Math.min(BIN_N - 1, Math.floor((gaps[i] * 1000) / BIN_MS))] += 1;
        }
        return c;
    }

09接頭辞で選ぶ落下単語

単語が上から降りてくるあいだに最初の文字を押すと、lockIdx がその文字で始まるいちばん低い単語を選び、以降の文字は keyLeft がその単語にだけ積みます。右の盤の keyRight は最初の文字を見ずにいちばん低い単語だけを受け取り、別の単語を打ち始めた文字を捨てるので、この落下の場面はランディングのヒーローのイントロにも使えます。単語10個が FALL_SPEED の40で降りてくる同じキー40回で、左は10個すべてを捕まえ、狙いのない右は3個だけを捕まえて7個を逃がし、捨てたキーは28回です。

接頭辞で選ぶ落下単語
新しいタブで開く

09 · 接頭辞で選ぶ落下単語 — 計測 累積面積 12.7% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WORD_LISTFALL_SPEEDlockIdx
    // 左 — 狙いがなければ最初の文字で選び、狙いが付いたあとはその単語の次の文字だけを受け取る
    function keyLeft(s, k, t) {
        if (s.lock < 0) {
            const i = lockIdx(s, k, t);
            if (i < 0) {
                s.miss += 1;
                return;
            }
            s.lock = i;
        }
        const w = WORD_LIST[s.lock];
        if (w.charAt(s.prog[s.lock]) !== k) {
            s.miss += 1;
            return;
        }
        s.prog[s.lock] += 1;
        if (s.prog[s.lock] >= w.length) {
            finish(s, s.lock, t);
            s.lock = -1;
        }
    }

タイピング練習をコードで組むと、一回のキー入力はどんな値いくつで点数になるのでしょうか?

キー一つは時刻・キー・正解かどうかの三つの値です

press(k, t) は消すキーなら打った文字列の最後の文字を取り除き、そうでなければその位置の正解の文字と比べて ok を決めたうえで { t, k, ok } を strokes に積みます。正確さは合っていた文字キーの数を文字キーの数で割った値で、消すキーは数えません。

    // キーを一つ受け取る — 消す操作は最後の文字を取り除き、文字はその位置の正解と比べて ok を決める
    function press(k, t) {
        if (k === BS) {
            if (buf.length > 0) {
                buf = buf.slice(0, -1);
            }
            strokes.push({ t: t, k: k, ok: false });
            return;
        }
        if (buf.length >= target.length) {
            return;
        }
        const ok = k === target.charAt(buf.length);
        buf += k;
        strokes.push({ t: t, k: k, ok: ok });
    }

画面の数字は記録からだけ出ます

毎分の打鍵数・ミスのマス・ヒートマップ・コンボの倍率・ゴーストの位置は、別に保存したカウンターではなく strokes を走査して得た値です。04のキーごとの回数と06の点数・連続の数だけが、同じ入力から積み上がります。だから同じ記録を別の規則に与えると、対比版が同じ入力のどこでずれるかがそのまま見えます。

押された文字と時刻を受け取り、正解の文字と比べて、合っていれば区間の記録に入れ、違えばミスのマスに印を付ける — タイピング練習の一回の入力が計算される順番

一回の入力が計算される順番

一回の入力は四つの段階で終わります。

  1. press がキーと時刻を受け取って打った文字列を直し、ok を決めます。
  2. その値ひとつが strokes の一覧の末尾に積まれます。
  3. 場面の計算の関数(rateNow・missCells・multOf・ghostPos など)が、一覧と今の時刻 sceneT から値を求め直します。
  4. 画面はその値を描き、盤が終わったときだけ結果の案内が一度書かれます。

仮想の打ち手は種が同じなら記録も同じです

genStrokes は種つきの線形合同法の乱数で文字の間隔を pace(今の時刻) の0.8から1.2倍に決め、ミスは指定した位置でだけ出します。ミス一回は同じ段の隣のキー一つ以上と、気づく前にさらに打つ文字、0.26から0.42秒の停止、消す操作、打ち直しでつながります。時間は一コマの長さを DT_MAX の0.05秒で切って足した sceneT ひとつで、Date と Math.random の呼び出しは九つの場面のソースに0件なので、値が再現されます。

タイピング練習 無料サイトのようにルールを重ねて実際の練習ページに付けるとき — ライブラリなしで作る・練習の長文を文字列で書く方法・結果を見せる時点

実際のアプリでは keydown で performance.now を読みます

このデモは performance.now() も Date も使いません。実際の練習ページでは keydown で performance.now() を読んでキーの時刻にし、同じ原点の e.timeStamp を使ってもかまいません。直接打つときのデモは e.timeStamp と最後の rAF の時刻の差を DT_MAX の範囲で sceneT に足してキーの時刻にします。規格は MDN の KeyboardEvent と requestAnimationFrame のドキュメントにあります。

    // イベントの時刻 e.timeStamp と rAF の時刻は同じ原点なので、引くと最後のコマからの間隔が出る
    function liveKey(k, stamp) {
        let t = sceneT;
        if (still) {
            if (lastStamp >= 0) {
                sceneT += clamp((stamp - lastStamp) / 1000, 0, 2);
            }
            lastStamp = stamp;
            t = sceneT;
        }
        else if (lastTs >= 0) {
            t = sceneT + clamp((stamp - lastTs) / 1000, 0, DT_MAX);
        }
        onKey(k, t);
        if (still) {
            update(0);
            showNow();
        }
    }

ハングルは組み立てが終わったときだけ数えます

ハングルの入力方式が有効だと keydown の e.key が文字ではなく Process で届くブラウザがあるため、05は押された位置を二ボルシク配列の表 DUBEOL で字母に移して使います。実際のサービスでは compositionstart・compositionupdate・compositionend で組み立ての区間を見分け、打鍵数を compositionend でだけ増やし、入力方式が作った文字は input イベントで受け取るのが定石です。日本語のIMEで変換中の文字が確定の前に別の状態を通るのも同じ原理ですが、このデモが見せているのはハングルの二ボルシクだけです。

どこに付けるか

練習ページの本画面には01の毎分の打鍵数と03のミスのマスがまず入り、練習が終わったあとの弱点分析には04のヒートマップと08の間隔の分布が合います。入力の速さを測る入社試験やオンライン授業の画面では、06のコンボのように点数の規則を重ねず、01と正確さだけを見せても足ります。オンボーディングでショートカットを覚えさせるときは、09の接頭辞での選択と07の過去の記録との競争が使えます。九つの場面のソースには外部への依存が0件なので、コピーしたファイルひとつで動きます。

練習の長文を文字列で置く方法

練習の文は TARGET_TEXT のように文字列ひとつで置き、正解の比較は打った文字列の同じ位置の文字と比べるだけなので、文を替えれば規則はそのまま使えます。タイピング練習 長文のように段落を丸ごと入れる場合も同じで、02の TEXT_02 は105文字です。タイピング練習 小学生向けのページに載せるときも、替えるのは文だけで計算は変わりません。

結果はいつ見せるか

毎分の打鍵数は練習中に変わり続けてもかまいませんが、正確さと最終の点数は盤が終わったときに一度だけ見せるほうが読みやすくなります。デモも状態の行は毎コマ変わり、結果の案内は盤の終わりに一か所へ一度だけ書きます。

タイピング練習が壊れる瞬間 — 最初の数秒に毎分の打鍵数が途方もなく跳ねる落とし穴・ミスを消したあとに正確さが逆に上がるずれ・ハングルの組み立て中に文字数が二重に数えられる場合

最初の数秒で毎分の値が跳ねる

最初のキーが0.85秒、二つ目が1.30秒、三つ目が1.77秒に押されたとき、MIN_SPAN なしで最初のキーからの経過(0.92秒)で割ると毎分39になります。下限を2秒に置くと18で、この人の実際の最初の速さは約24でした。三文字だけで値を出すと、開始直後の指標が揺れます。

ミスを消したあとに正確さが逆に上がる

正確さを今の画面の文字列で数えると、ミスを消して打ち直した瞬間に値が上がり、間違えた記録が消えます。このデモは okCount() ÷ keyCount() で数えるので、03で違うマス3つをすべて直したあとも正確さは87.0%のまま残ります。

ハングルの組み立て中に文字数が二重に数えられる

キーを押すたびに一つ増やすと、11文字を24と数えます。終声が次の文字へ移る瞬間ごとにもう一度数えるからで、05は確定した文字数だけを数えます。ハングルの文字数を点数に使うと、この差がそのまま膨らんだ速さになります。

ミスのたびに連続の数を0へ戻す方式

もう一つは、ミスのたびに連続の数を0へ戻す方式です。06の同じ記録で点数が1900から1280に下がり、倍率のグラフが階段ではなくのこぎりの歯の形になります。

九つの場面のHTMLとReact版のファイルは下のzipに入っていて、開くときに入れるパスワードは5d7f5ctnです。

区間の長さとコンボの段階とゴーストの速さを変えると、感じはどれほど変わるのでしょうか — WINDOW_SEC・COMBO_STEP・GHOST_GAIN を変えた違い

名前 変えた値 手ざわり
区間を長くする 01 WINDOW_SEC 5 → 15 速い区間の頂点は毎分86.4から72.2に下がり、速さが落ちた12秒の時点の値は43でなく57と高く残ります。12秒の練習では15秒の区間がまだ埋まらないので、最初からの平均と同じ値になります
コンボの段階を細かくする 06 COMBO_STEP 10 → 5 同じキー記録で最終点数が1900から2800に上がり、最高倍率が×4から MULT_MAX の×5に届き、連続20での倍率は×3から×5になります
ゴーストを速くする 07 GHOST_GAIN 1.0 → 1.3 ゴーストが72文字を打ち終える時刻が13.65秒から10.55秒に早まり、自分のカーソルが先にいた時間の割合が41%から0%になり、いちばん遅れた差が7文字から18文字に広がります

値を変える前に見ること

三つの値はそれぞれ01・06・07の定数なので、場面ひとつで値をひとつだけ変えると比較が合います。12秒の練習では15秒の区間がまだ埋まらず、01の15秒の値が最初からの平均と同じになるため、区間を延ばした効果は練習が区間より長いときにきちんと見えます。上の数値はすべて、最初の盤の同じキー記録で定数だけを変えて回し直した値です。

動きを減らす設定では止まった一枚の練習画面です — 場面の名前と現在の毎分の打鍵数・正確さを文で読み上げるアクセシビリティです

ステージで直接打ち、Esc で最初から

ステージの枠は九つの場面とも role="application" で tabindex が0なので、フォーカスを受け取ります。文字キーはその場での直接の練習になり、最初のキーが来ると仮想の打ち手を外して空の盤から始まります。Backspace は消す、Esc は最初からで、一時停止ボタンで止めているあいだはキーを受け取らず、一歩ボタンは時間表の次のキーひとつまで進めます。

結果は盤が終わったときだけ読まれます

状態の行 data-role="status" はデモの間 aria-live="off" なので毎コマ変わっても読み上げられず、人が最初に触れた瞬間に polite へ変わります。結果の案内は data-role="done"(role="status")の一か所に、盤が終わったとき一度だけ書きます。描画の領域は role="img" で、場面の説明を aria-label に入れています。

動きを減らす設定と隠れたタブ

prefers-reduced-motion がオンならループを回さず、時間表を最後まで回した結果の画面を一枚だけ描きます。このときキーが来ると e.timeStamp どうしの間隔(最大2秒)を足して値を描き直します。九つの場面とも visibilitychange を受け取り、タブが隠れるとループを止め、戻ったら最初のコマの dt を捨てます。

タイピング練習のよくある質問 — ライブラリなしで作る・毎分の打鍵数をどんな基準で数えるか・ウェブに載せるときに必要なこと

タイピング練習ゲームのページに載せるとき、このデモの時間の元をそのまま使えますか

そのまま使うことはできません。デモの sceneT は演出が再現されるようにコマの間隔を足し上げた値なので、実際のページでは keydown で読んだ performance.now() をキーの時刻にして、このソースの計算の関数につなげます。

タイピング練習にライブラリは必要ですか

必要ありません。キャンバス2Dだけで描かれ、九つの場面のソースにライブラリ・WebGL・CDNが0件で、Math.random の呼び出しも0件なので、同じ種は同じ記録で再生されます。

毎分の打鍵数はどんな基準で数えますか

CHARS_PER_WORD の5文字を単語ひとつとみなし、合っていた文字だけを数え、ミスと消すキーは数えません。区間は直近の WINDOW_SEC 秒で、開始直後は割る時間が MIN_SPAN の2秒より小さくなりません。

ほかの読みものとして、入力中の状態を知らせる 入力中インジケーターのUIパターン、文字が打たれるCSSの効果を扱った CSSのタイピング文字効果、時間を整数で数える ストップウォッチ 9種、キーの案内を扱った キーボードショートカットのUI が隣にあり、タブが隠れたときの扱いは MDN の Page Visibility API のドキュメントを見てください。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。