GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

타자 연습 9종 — 분당 타수 계산부터 오타 대조와 키 히트맵까지

타자 연습은 키를 누른 기록을 어떻게 속도와 정확도로 바꿀까 — 01번은 최근 몇 초의 글자 수만 세어 분당 타수를 구하고, 03번은 정답 글자와 나란히 비교해 오타 칸을 짚으며, 07번은 지난 기록의 걸음을 유령 커서로 다시 달린다.

타자 연습 게임은 글자를 치는 그림이 아니라 누른 시각과 정답 글자 두 값으로 속도·정확도·약한 키를 모두 계산하는 장치다 — 타자 연습 프로그램은 어느 값이 점수를 정하는지 아는 데서 시작한다

자동 재생 · 눌러서 해당 항목으로 · 전부 한 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 로 커서가 끝 세 칸에 들어올 때만 한 쪽씩 넘겨 눈이 자꾸 튄다. 고정폭 한 칸을 7.7px 로 가정하면 중심에서 벗어난 평균이 16.4px 대 47.7px 이고, 오른쪽 판은 한 판에 4번 넘어간다.

커서 앞으로 밀리는 줄 스크롤
새 창에서 열기

02 · 커서 앞으로 밀리는 줄 스크롤 — 측정 누적면적 10.049% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 은 확정된 글자만 세는데, 오른쪽 판은 키마다 하나씩 올려 조합 중에도 센다. 직접 칠 때는 입력기가 켜지면 e.key 가 글자가 아니라 Process 로 오는 브라우저가 있어 눌린 자리 e.code 를 두벌식 표 DUBEOL 로 자모에 옮기고, 26번의 입력으로 공백 3개를 포함한 열한 글자를 만들면 확정은 11자인 반면 키마다 세는 판은 24 로 2.2배가 된다.

한글 조합 중 글자 상태
새 창에서 열기

05 · 한글 조합 중 글자 상태 — 측정 누적면적 21.652% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 와 난수 호출은 아홉 장면 소스에 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 는 눌린 자리를 두벌식 표로 옮겨 자모로 쓴다. 실제 서비스에서는 compositionstart·compositionupdate·compositionend 로 조합 구간을 가려 타수를 compositionend 에서만 올리고, 입력기가 만든 글자는 input 이벤트로 받는 것이 정석이다.

어디에 붙이나

타자 연습 사이트의 본 화면에는 01 의 분당 타수와 03 의 오타 칸이 먼저 들어가고, 연습이 끝난 뒤의 약점 분석에는 04 의 열지도와 08 의 간격 분포가 맞는다. 입력 속도를 재는 입사 시험·온라인 수업 화면은 06 의 콤보처럼 점수 규칙을 얹지 않고 01 과 정확도만 보여도 된다. 온보딩에서 단축키를 익히게 할 때는 09 의 접두어 고르기와 07 의 지난 기록 겨루기를 쓸 수 있다. 아홉 장면 소스에는 외부 의존이 없어 복사한 파일 하나로 돌아간다.

연습 글을 문자열로 두는 법

연습 글은 TARGET_TEXT 처럼 문자열 하나로 두고, 정답 비교는 친 글자열의 같은 자리 글자와 견주는 일뿐이라 글만 바꾸면 규칙은 그대로 쓸 수 있다. 데모의 영문 연습 글은 모두 자체 작성한 문장이다.

결과는 언제 보여 줄까

분당 타수는 연습 중에 계속 바뀌어도 되지만, 정확도와 최종 점수는 판이 끝났을 때 한 번 보여 주는 편이 읽기 좋다. 데모도 상태 줄은 매 프레임 바뀌고 결과 안내는 판 끝에 한 칸에만 한 번 쓴다.

타자 연습이 깨지는 순간 — 처음 몇 초에 분당 타수가 터무니없이 튀는 함정·오타를 지운 뒤 정확도가 거꾸로 오르는 어긋남·한글 조합 중에 글자 수가 두 번 세지는 경우

처음 몇 초에 분당 값이 튄다

첫 키가 0.85초, 둘째가 1.30초, 셋째가 1.77초에 눌렸을 때 MIN_SPAN 없이 첫 키부터의 경과(0.92초)로 나누면 분당 39 가 나온다. 바닥을 2초로 두면 18 이고, 이 사람의 실제 처음 속도는 약 24 였다. 세 글자만으로 값을 내면 시작 직후 지표가 흔들린다.

오타를 지운 뒤 정확도가 거꾸로 오른다

정확도를 지금 화면의 글자열로 세면 오타를 지우고 고쳐 치는 순간 값이 올라 틀린 기록이 사라진다. 이 데모는 okCount() ÷ keyCount() 로 세므로 03 에서 틀린 칸 3칸을 모두 고친 뒤에도 정확도가 87.0% 로 남는다.

한글 조합 중에 글자 수가 두 번 센다

키를 누를 때마다 하나씩 올리면 열한 글자를 24 로 센다. 받침이 다음 글자로 넘어가는 순간마다 한 번 더 세기 때문이며, 05 는 확정된 글자 수만 센다. 한글 글자 수를 점수에 쓰면 이 차이가 곧 부풀려진 속도다.

또 하나는 오타마다 이어 친 수를 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 로 높게 남는다
콤보 단계를 촘촘하게 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 를 버린다.

타자 연습 FAQ — 라이브러리 없이 만들기·분당 타수는 어떤 기준으로 세는지·웹에 올릴 때 필요한 것

타자 연습 사이트에 올릴 때 이 데모의 시간 원천을 그대로 써도 되나요

그대로 쓰면 안 된다. 데모의 sceneT 는 시연이 재현되도록 프레임 간격을 쌓은 값이라, 실제 사이트에서는 keydown 에서 읽은 performance.now() 를 키 시각으로 쓰고 이 소스의 계산 함수를 이어 붙여야 한다.

타자 연습에 라이브러리가 필요한가요

필요하지 않다. 캔버스 2D 만으로 그려지고 아홉 장면의 소스에 라이브러리·WebGL·CDN 이 0건이며, 난수 호출도 0건이라 같은 시드는 같은 기록으로 재생된다.

분당 타수는 어떤 기준으로 세나요

CHARS_PER_WORD 5 글자를 단어 하나로 보고 맞은 글자만 셀 뿐 오타와 지우기 키는 세지 않는다. 구간은 최근 WINDOW_SEC 초이고 시작 직후에는 나누는 시간이 MIN_SPAN 2초 밑으로 내려가지 않는다.

더 읽을거리로는 입력 중 상태를 알리는 입력 중 표시 UI 패턴, 글자가 쳐지는 CSS 효과를 다룬 CSS 타이핑 글자 효과, 시간을 정수로 세는 스톱워치 9종, 키 안내를 다룬 단축키 안내 UI 가 이웃하고, 캔버스 조작은 MDN 의 Page Visibility API 문서를 본다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.