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

css captcha 9パターン — 歪んだ文字・回転合わせ・ノイズの中の文字で人を確かめる場面

css captcha は人の確認と呼ばれますが、画面の中身は計算です — 01番は文字の列をサイン波でずらし、07番はノイズのしきい値を上げて隠れた文字を拾い上げます。ここで動く九つの場面は、確認画面がどう動くかを見せるための実演で、セキュリティ機能ではありません。

九つの確認の場面をこの画面でそのまま解いてみる

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

css captcha 9パターン — 列のずらし・角度の吸着・しきい値の引き上げで作る確認の場面

九つの場面はどれも <canvas> の2Dに描いています。文字の曲がり具合やタイルの縞の角度、ノイズの値のように画素や座標が一つずつ変わるものは、キーフレーム数枚では書き表せません。そのため CSS には舞台の縦横比・角丸・touch-action・フォーカス枠といった外側の枠だけを残しました。

骨組みは九つのファイルで共通の mount 関数です。requestAnimationFrame から受け取るタイムスタンプを FIXED_DT の16msティックに刻んで場面を進め、乱数は種を受け取る lcg と hash2 からしか出てきません。Math.random・Date.now・fetch は九つのソースのどこにもないので、種が同じなら同じ問題がもう一度出ます。ただし06だけは lcg を定義していても、問題づくりに種を使いません。

グリッドの場面がひとりでに正解したり間違えたりするのは、録画を流しているからではありません。入力が150ティック(2.4秒)途切れると、実演スクリプトが人の手と同じ入口(down・move・up・key)から入力を送って問題を解いてみせ、人が触れた瞬間に実演は止まります。

もう一つ、ここではっきり線を引いておきます。許容角・正解マスの集合・合格点・失敗回数といった判定はこのデモの中だけの実演ルールで、サーバー・トークン・検証リクエストは一行も入っていません。ブラウザに置いた正解はソースを開けばそのまま読めるので、実際のサービスで問題を出して答えを判定するのはサーバーの役目です。ここの場面は、その結果を見せる画面の位置に収まります。

使う場所から出発点を選ぶと早く済みます。会員登録フォームの確認欄なら01か06、写真の向きを合わせてもらう段階なら02、絵を選ぶ盤なら03、順番に押す短いパズルなら04が合います。署名欄の前のなぞり書きは05で、captcha css game のように点数が出る画面を探しているなら05と04を並べて見てください。ノイズから文字を拾ってもらう画面は07、ログイン失敗が重なるほど問題を難しく見せる画面は08、有効時間のある問題ウィジェットは09が近いです。

captcha css animation を技法別のコードで見る

コード片は各場面の index.html から途切れずに続く行を切り出したもので、どれも25行に収まります。確認の動作はたいてい片の中の関数一つが決め、描画のコードはその関数が返した値を受け取って塗るだけです。

01文字画像が列ごとにサイン波でずれて歪んで見える歪み文字盤

240×150の作業キャンバスに文字と斜めの縞を一度焼いて getImageData で受け取っておき、毎フレーム warpPixels が36000画素を回って、縦位置に応じた横のずれと列位置に応じた縦のずれをサインで求めて元の画素を引いてきます。盤の外に出た座標は端の画素に留められ、結果は putImageData で書いたあと480×300へ2倍に引き伸ばし、その上に揺れるベジェ線4本を重ねます。レンダーでは九つの中で三番目に広い57.5%を覆い、23/23フレームがすべて違っていました(強度44.3)。

文字画像が列ごとにサイン波でずれて歪んで見える歪み文字盤
新しいタブで開く

01 · 文字画像が列ごとにサイン波でずれて歪んで見える歪み文字盤 — 計測 累積面積 57.532% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WARP_AMPWARP_FREQNOISE_LINESGLYPH_PX
    function warpPixels(src, out, lw, lh, amp, freq, ph) {
        for (let y = 0; y < lh; y += 1) {
            const dx = Math.round(amp * 0.6 * Math.sin(y * freq * 1.3 + ph * 1.2));
            for (let x = 0; x < lw; x += 1) {
                const dy = Math.round(amp * Math.sin(x * freq + ph));
                let sx = x + dx;
                let sy = y + dy;
                sx = sx < 0 ? 0 : (sx >= lw ? lw - 1 : sx);
                sy = sy < 0 ? 0 : (sy >= lh ? lh - 1 : sy);
                const si = (sy * lw + sx) * 4;
                const di = (y * lw + x) * 4;
                out[di] = src[si];
                out[di + 1] = src[si + 1];
                out[di + 2] = src[si + 2];
                out[di + 3] = 255;
            }
        }
    }

02ダイヤルを回して傾いた風景を立て直すと許容角の中でバネで吸い付く確認

ダイヤルの角度に DIAL_GAIN 1.5を掛けて風景ごと回し、手を離すと snapCorrection が角度を−180〜180に折りたたんでから、SNAP_DEG 10°以内なら補正量を、外なら null を返します。補正は springStep が16msの一歩ごとに SPRING_K 190・DAMP 15で0まで引き戻すもので、Node で8°を入れると8.86°まで振れて目標を越え(オーバーシュート10.7%)、512msで落ち着きます。この場面は23フレーム中21フレームで変化が捉えられ、累積面積21.3%・強度56.5でした。

ダイヤルを回して傾いた風景を立て直すと許容角の中でバネで吸い付く確認
新しいタブで開く

02 · ダイヤルを回して傾いた風景を立て直すと許容角の中でバネで吸い付く確認 — 計測 累積面積 21.267% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SNAP_DEGDIAL_GAINSPRING_KDAMP
    // 角度を -180 以上 180 未満に折りたたむ
    function wrapDeg(d) {
        let r = (d + 180) % 360;
        if (r < 0) {
            r += 360;
        }
        return r - 180;
    }
    // 許容角の内側なら真っすぐ立たせる補正量(度)、外側なら null
    function snapCorrection(theta, snapDeg) {
        const d = wrapDeg(theta);
        return Math.abs(d) <= snapDeg ? -d : null;
    }
    // バネの一歩(半陰的オイラー) - 位置と速度を返す
    function springStep(pos, vel, target, k, damp, dt) {
        const a = -k * (pos - target) - damp * vel;
        const v = vel + a * dt;
        return [pos + v * dt, v];
    }

033×3のタイルで同じ模様を選ぶとタイルが裏返って印が付く選択盤

tileAngles は種から基準角を15°刻みの12通りのうち一つに決め、正解の3マスには基準から±6°以内の角度を、残りの6マスには ANGLE_TOL × 1.6 に0〜60°を足した分だけ離した角度を与えます。種1〜300をすべて回すと正解マスの最大差は5.99°、不正解マスの最小差は19.20°で、その隙間に許容値12°が収まり、押したマスは scaleY = |cos(πp)| で平たくなってから裏面へ返ります。動いたフレームは15/23で九つの中で最も少なかったものの、裏返るときの強度平均は104.2、累積面積は35.2%でした。

3×3のタイルで同じ模様を選ぶとタイルが裏返って印が付く選択盤
新しいタブで開く

03 · 3×3のタイルで同じ模様を選ぶとタイルが裏返って印が付く選択盤 — 計測 累積面積 35.173% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TILE_NFLIP_MSMATCH_MINSHIMMER
        const angles = [];
        const match = [];
        for (let i = 0; i < total; i += 1) {
            angles.push(0);
            match.push(false);
        }
        for (let k = 0; k < total; k += 1) {
            const i = order[k];
            if (k < nMatch) {
                angles[i] = target + (r() - 0.5) * tol;
                match[i] = true;
            }
            else {
                const sign = r() < 0.5 ? -1 : 1;
                angles[i] = target + sign * (tol * 1.6 + r() * 60);
            }
        }
        return { target: target, angles: angles, match: match };
    }

04散らばった文字を順番に押すと線がつながって描かれる確認

文字4つは placeGlyphs が互いに92px以上離して置き、上の案内行の順番どおりに押すと、線の先が growStep で残り距離の16%に0.012を足しながら伸びて15ティック(240ms)で0.99に届きます。順番を外すとピンクの点線が出て戻り、それまでつないだ線が端から1ティックに0.1ずつ巻き戻されて最初の文字まで消えます。累積面積は17.9%にとどまりましたが、強度平均は111.4で九つの場面の中で最も高く、23/23フレームが動きました。

散らばった文字を順番に押すと線がつながって描かれる確認
新しいタブで開く

04 · 散らばった文字を順番に押すと線がつながって描かれる確認 — 計測 累積面積 17.943% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GLYPH_NHIT_RJITTERLINE_LERP
        function click(i) {
            if (rewind || i < 0) {
                return;
            }
            if (done) {
                // つなぎ終えた盤をもう一度押すと新しい盤へ進む
                seed += 1;
                setup();
                return;
            }
            const want = order[chain.length];
            if (i === want) {
                chain.push(i);
                if (chain.length > 1) {
                    seg.push(0);
                }
                if (chain.length === GLYPH_N) {
                    done = true;
                }
            }
            else if (!chain.includes(i)) {
                att = { from: chain.length ? chain[chain.length - 1] : -1, to: i, t: 1 };
                rewind = true;
            }
        }

05うねる案内線をなぞったポインターの軌跡が案内線との平均距離で点数になる確認

案内線は二つのサインを足した曲線を横40〜440に4px間隔の101点で打っておき、ポインターが6px以上動くたびに標本を残して、nearest で一番近い点までの距離を測ります。scoreTrail はその平均を round(100 × (1 − 平均 / (2 × TOL_PX))) に変えるので、合格ラインの60点は平均距離が TOL_PX の0.8倍にあたる9.6px以下という意味です。線が通った範囲は12.9%と狭く、強度91.6で23/23フレームに線が引かれていく様子が写りました。

うねる案内線をなぞったポインタの軌跡が案内線との平均距離で点数になる確認
新しいタブで開く

05 · うねる案内線をなぞったポインタの軌跡が案内線との平均距離で点数になる確認 — 計測 累積面積 12.908% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GUIDE_AMPTOL_PXSCORE_PASSSAMPLE_STEP
    function nearest(px, py, guide) {
        let best = 1e9;
        for (let i = 0; i < guide.length; i += 1) {
            const dx = guide[i][0] - px;
            const dy = guide[i][1] - py;
            const d = dx * dx + dy * dy;
            if (d < best) {
                best = d;
            }
        }
        return Math.sqrt(best);
    }
    // 軌跡の標本ごとに最も近い案内線までの距離を平均して点数に変える(平均が許容値の2倍なら0点)
    function scoreTrail(samples, guide, tol) {
        if (samples.length === 0) {
            return { mean: 0, score: 0 };
        }
        let sum = 0;
        for (let i = 0; i < samples.length; i += 1) {
            sum += nearest(samples[i][0], samples[i][1], guide);
        }
        const mean = sum / samples.length;
        const score = Math.round(100 * Math.min(1, Math.max(0, 1 - mean / (tol * 2))));
        return { mean: mean, score: score };
    }

06チェック欄がリングに変わって回り、チェック印へつながる確認ボタン

一行は待機140 → 欄からリングへ360 → 弧の回転560 → 静止180 → リングからチェックへ360msを通り、rowShape はこの区間の累積時刻t1〜t5とイージング値 e 一つで角の半径(12→32px)と回転角(0→90°)を同時に決めます。そのため変化がちょうど半分のe = 0.5で半径22px・回転45°になり、半径が一辺64pxの半分の32pxに達すると四角が円になります。覆った面積は12.2%で九つの中で二番目に狭かったのに強度は102.6あり、23/23フレームに変化がありました。

チェック欄がリングに変わって回り、チェック印へつながる確認ボタン
新しいタブで開く

06 · チェック欄がリングに変わって回り、チェック印へつながる確認ボタン — 計測 累積面積 12.18% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RING_RSPIN_MSMORPH_EASEPASS_DELAY
        else if (u < t2) {
            e = easeInOutPow((u - t1) / morphMs, ease);
            phase = 1;
        }
        else if (u < t3) {
            e = 1;
            spin = (u - t2) / spinMs;
            phase = 1;
        }
        else if (u < t4) {
            e = 1;
            phase = 1;
        }
        else if (u < t5) {
            e = 1 - easeInOutPow((u - t4) / morphMs, ease);
            phase = 1;
            check = clamp01((u - t4) / morphMs - 0.35) / 0.65;
        }
        else {
            e = 0;
            check = 1;
            phase = 2;
        }
        return { r: boxR + (ringR - boxR) * e, rot: e * Math.PI * 0.5, e: e, spin: spin, check: check, phase: phase, total: t5 };
    }

07ノイズの中に隠れた文字がしきい値を上げるほどくっきり浮かぶ確認

160×100のマスごとに値ノイズ3層を合わせた fbm に文字マスク × BOOST 0.42を足し、litAmount がその値のしきい値を超えた度合いを GRAIN 0.045の幅の中で0〜1へなめらかに上げて明るさに使います。Node で4万標本を試すと fbm が0.86を超える割合は0.14%、0.44を超える割合は66.4%なので、しきい値が0.86まで上がると外側のノイズはほぼ消え、マスクの内側は3分の2ほどが点いたまま残ります。画面の広さでは九つの中で最大の75.7%を覆い、23/23フレームが動きました(強度87.1)。

ノイズの中に隠れた文字がしきい値を上げるほどくっきり浮かぶ確認
新しいタブで開く

07 · ノイズの中に隠れた文字がしきい値を上げるほどくっきり浮かぶ確認 — 計測 累積面積 75.716% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

THRESHNOISE_SCALEOCTAVESGRAIN
    function fbm(x, y, seed, oct) {
        let sum = 0;
        let amp = 1;
        let norm = 0;
        let f = 1;
        for (let o = 0; o < oct; o += 1) {
            sum += amp * vnoise(x * f + o * 17.3, y * f - o * 9.1, seed + o);
            norm += amp;
            amp *= 0.5;
            f *= 2;
        }
        return sum / norm;
    }
    // 値がしきい値を超えた度合いを 0〜1 に(grain の幅の中でなめらかに上がる)
    function litAmount(v, thresh, grain) {
        const k = (v - thresh) / grain + 0.5;
        return k <= 0 ? 0 : (k >= 1 ? 1 : k * k * (3 - 2 * k));
    }

08間違えるたびに歪みの振幅と線の本数が1段階ずつ上がる段階式の確認

distortionFor は振幅を min(13, 1.5 + 3 × 失敗)、線の本数を min(6, 1 + 失敗) に決め、文字盤は4px幅の切れ端75枚を二つのサインの和だけ上下にずらして drawImage で貼り直します。描かれる振幅 ampNow は目標へ1ティックごとに20%ずつ近づくので、失敗しても盤が一気に跳ねることはなく、回を重ねるごとに強く曲がっていきます。失敗が積み重なる実演のあいだ23/23フレームが変わり、面積59.7%・強度79.5と記録されました。

間違えるたびに歪みの振幅と線の本数が1段階ずつ上がる段階式の確認
新しいタブで開く

08 · 間違えるたびに歪みの振幅と線の本数が1段階ずつ上がる段階式の確認 — 計測 累積面積 59.694% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ESCALATE_STEPAMP_MAXLINES_MAXFAIL_LIMIT
                const d = distortionFor(fails, ESCALATE_STEP, AMP_MAX, LINES_MAX);
                const ox = shake > 0 ? Math.sin(shake * 1.7) * shake * 0.5 : 0;
                // 文字盤 - 縦 4px の切れ端ごとにサインのずれを変えて貼り直す
                ctx.save();
                ctx.beginPath();
                rrect(ctx, BX + ox, BY, BW, BH, 16);
                ctx.clip();
                ctx.fillStyle = rgba(PANEL, 1);
                ctx.fillRect(BX + ox, BY, BW, BH);
                for (let x = 0; x < BW; x += 4) {
                    const dy = ampNow * Math.sin(x * 0.05 + ph) + ampNow * 0.35 * Math.sin(x * 0.13 - ph * 1.7);
                    ctx.drawImage(src, x, 0, 4, BH, BX + ox + x, BY + dy, 4, BH);
                }

09問題の有効時間がリングで縮み、尽きると更新アイコンが回って新しい問題に替わる確認

ringFrac は 1 − 経過 / TTL_MS を0〜1に切ってリングの弧の長さに使い、warnMix は残りの割合が WARN_FRAC 0.3を下回ったあと0から1へ上がってミントをピンクに混ぜます。0に届くと更新アイコンが360msかけて半回転し、そのあと種が一つ進んでリングが220msで満ちるので、問題が出てから期限が切れて新しい文字盤に替わるまで、一周で1300 + 360 = 1660msかかります。期限が切れると文字盤が globalAlpha 0.28 まで暗くなってピンクの「期限切れ」が重なり、更新後は新しい文字に替わりますが、動きの大半はリングとアイコンなので面積は最小の11.8%で、強度86.4のまま23/23フレームが捉えられました。

問題の有効時間がリングで縮み、尽きると更新アイコンが回って新しい問題に替わる確認
新しいタブで開く

09 · 問題の有効時間がリングで縮み、尽きると更新アイコンが回って新しい問題に替わる確認 — 計測 累積面積 11.785% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TTL_MSWARN_FRACREFRESH_SPIN_MSRING_W
    // 残り時間の割合 - リングの弧の長さになる
    function ringFrac(elapsedMs, ttlMs) {
        const f = 1 - elapsedMs / ttlMs;
        return f < 0 ? 0 : (f > 1 ? 1 : f);
    }
    // 警告区間の中で 0 から 1 へ上がる色の混ぜ率
    function warnMix(frac, warn) {
        if (frac >= warn) {
            return 0;
        }
        const k = 1 - frac / warn;
        return k > 1 ? 1 : k;
    }

値が確認の難しさを変えるところ — 振幅・許容角・しきい値

難しさを決めているのは、数えるほどの定数です。下の表はレンダーの測定で、480×300で撮った24枚のあいだに起きた変化23回を数えています。その下にある h3 の数字は場面の関数だけを切り出して Node で呼んだ結果なので、表の値と混ぜて読まないでください。

場面 累積面積 強度平均 動いたフレーム
01 歪み文字盤 57.5% 44.3 23/23
02 ダイヤル吸着 21.3% 56.5 21/23
03 タイル選択盤 35.2% 104.2 15/23
04 順番つなぎ 17.9% 111.4 23/23
05 なぞり点数 12.9% 91.6 23/23
06 リングのチェック 12.2% 102.6 23/23
07 ノイズしきい値 75.7% 87.1 23/23
08 段階の歪み 59.7% 79.5 23/23
09 期限リング 11.8% 86.4 23/23

WARP_AMP を3から12に上げると縞の画素の56.6%が位置を変える

幅8画素の縦縞を240×150に敷き、同じ位相で warpPixels を呼ぶと、元の値と違う画素は振幅3で14.9%、12で56.6%です。最も遠くへずれる距離も3画素から12画素へ4倍になり、2倍に引き伸ばした画面では6pxが24pxになります。デモでポインターと方向キーから変えられる倍率はどちらも0.1〜2.5に切られるので、画面で直接動かして確かめられる振幅は7.5が上限です。

許容角10°の境目は10°で吸着し10.5°で外れる

snapCorrection に10°を入れると−10が返って吸着し、10.5°では null が返ります。角度を先に折りたたむため350°は−10°と同じ位置として読まれて+10が返り、−8°からは+8が返ります。実演は偶数周に24°(SNAP_DEG × 2.4)で離して失敗を、奇数周に5.5°で離して吸着を交互に見せます。

TOL_PX を12から36に広げると±24pxで揺れた軌跡も合格する

案内線から±14px揺らして引いた軌跡(平均距離8.2px)は、TOL_PX 12で66点、36で89点です。±24pxで揺らした軌跡(平均13.8px)は12では43点で基準の60点に届きませんが、36では81点になります。許容値が式の分母に入っているので、同じ手の揺れでも点数から削られる幅が3分の1に縮みます。

しきい値0.86では文字の外側が0.14%しか点かない

07のしきい値を下げると、文字の外側のノイズも一緒に息を吹き返します。4万標本の試験で外側の画素が点く確率は、しきい値0.86で0.14%、0.7で8.16%、0.6で25.62%でした。実演はしきい値を0.36から0.86まで72ティックかけて上げ、28ティックとどまってから22ティックで下げるので、文字がノイズから浮かんでまた沈む流れが一周に収まっています。

TTL_MS 1300はレンダーの中に期限切れを入れるために縮めた値

既定値の1300は、2秒のレンダーの中で期限切れが一度見えるよう短く取った実演用の値で、フォームに付けるときは延ばして使います。5200に延ばすと1000ms時点のリングの残り割合が0.23から0.81に上がり、警告色が始まる時刻は TTL_MS × 0.7 なので910msから3640msへ遅れます。

どこで壊れるか — キャンバスが大きいとき・タブが止まるとき・動きを減らす設定

captcha css animation を値だけ変えて使い回していると、崩れる場所は画素数・判定の範囲・時間の計算の三つに集まってきます。ここに挙げる数値はソースの関数を Node で直接呼んだ結果で、ブラウザでのフレーム時間は別に測っていません。

作業解像度をキャンバスの大きさまで上げると歪みの計算が4倍になる

01の warpPixels はティックごとに240×150 = 36000画素を回り、画素ごとにサインと範囲の固定を通ります。振幅12で呼んだときの0.55msは、作業マシンの Node で一度呼んだ時間で、画面上のフレーム時間ではないことに気をつけてください。作業解像度を480×300に上げると画素は144000個になり、同じループが4倍回ります。逆に今のように低い解像度を2倍に引き伸ばすと文字の輪郭が階段状に見え、振幅を大きくすると端で留められた画素が一列ににじみます。07も160×100で計算して3倍に引き伸ばすため、文字の縁が3画素単位でつぶれます。

ダイヤルをキャンバスの外まで引っ張ると座標が端で切られる

02は pointerdown で setPointerCapture をかけているので、ポインターがキャンバスから出ても移動イベントは途切れません。ただ point() が座標をキャンバスの端で切ってから渡すため、外で回した分がそのまま角度に反映されるわけではありません。バネも今の値ですでに10.7%行き過ぎているので、SPRING_K を上げて DAMP を下げると、目標をまたいで揺れる時間が長くなります。

基準角が0°や90°の近くだと裏返ったタイルを見分けにくい

03の裏面は、表の縞の角度の符号を反転して描きます。基準が0°の近くなら符号を変えてもほぼ同じ角度で、90°の近くなら−90°が180°周期でまた90°と重なるので、表と裏の縞がほとんど変わりません。12通りの基準のうち2通りがここに当たり、その二つでは裏面の淡い色だけが見分ける手がかりになります。

文字を7つに増やすと種300個のうち37個で置き場が埋まらない

04の置き場の領域は横392 × 縦152で、placeGlyphs は最小距離92pxを守りながら400回まで試します。種300個で数えると GLYPH_N 5までは足りない種がなく、6で5個、7で37個が最後まで置き場を埋められません。文字の揺れはサイン二つの和で最大8.4pxと押下判定の半径26pxより小さいので、揺れている瞬間に押しても判定はぶれません。

05の点数は向きと完走を見ていない

scoreTrail は標本ごとに一番近い案内線の点までの距離を平均するだけなので、案内線を逆向きにたどっても、途中で止めても、平均さえ小さければ合格します。なぞり書きを実際の画面で使うつもりなら、始点と終点に触れたか、横方向の進みが一方向に続いたかといった条件を別に載せる必要があります。

06の時間を延ばすと最後の行が終わる前に周回が来る

06の一行の全体時間は五つの定数を足した1600msで、実演の一周は126ティック(2016ms)です。最後の行はティック17に始まって117ティックで終わり、余裕が9ティックしかないので、SPIN_MS のような値を大きくするとその行がチェックに届く前に reset() が来ます。MORPH_EASE を1に下げるとイージングが線形になり、欄がリングへふくらむ手ざわりが消えます。

08は5回目の失敗から段階がそれ以上上がらない

振幅の上限13には4回目の失敗で、線の本数の上限6には5回目で届き、それ以降の失敗は回数だけを増やします。右の目盛り5マスも失敗5以上では同じ形なので、目盛り(振幅)だけでは5回と10回を見分けられず、回数は状態の行の「ミス 10 回」のような表示で読み取ります。切れ端の幅を4pxから1pxに縮めると drawImage の呼び出しが75回から300回に増え、文字盤の上下には振幅の分だけ空いた帯がパネルの色で現れます。

タブがバックグラウンドにあった時間は09の時計に入らない

九つの場面の枠は、フレーム間隔を Math.min(ts - lastTs, 160) で切っています。タブが隠れて戻ってくると、そのあいだの時間は160msに縮められ、09のリングは実際に流れた時間より遅れて期限切れになります。期限を正確に守る必要がある画面なら、問題を出した時刻をサーバーが持っておいてその基準で判定し、このリングは残り時間を見せる用途にだけ使います。

動きを減らす設定では09の時間がまったく流れない

動きを減らす設定を有効にすると、09のリングはひとりでに縮まず、「次の場面」ボタンと更新の入力でだけ、最初 → 警告の直前 → 更新の場面を順に見せます。この設定の人に時間制限をそのままかけると残り時間が画面に出ないことになるので、実際の画面ではその人に時間を多めに渡す扱いを別に決めておく必要があります。

振幅や許容角を自分で動かして、判定がどの値で崩れるのかを確かめるには元のファイルが手元に要ります。zip を開くパスワードは t8786fwz で、中には九つの場面の元ファイルと、tsx に移した React 版が一緒に入っています。

歪み・許容幅・有効時間を変える変形三つとアクセシビリティ

名前 値 感じ
歪みの振幅を変える 01 WARP_AMP 3 → 12 縞の試験で変わる画素が14.9%から56.6%に増え、最も大きくずれる距離が画面上で6pxから24pxになります。
許容幅を変える 05 TOL_PX 12 → 36 ±14pxで揺れた軌跡が66点から89点へ、±24pxの軌跡は43点の不合格から81点の合格に変わります。
有効時間を変える 09 TTL_MS 1300 → 5200 1000ms時点の残り割合が0.23から0.81に上がり、警告色は910msではなく3640msで点きます。

三つの変形の数値も、Node で warpPixels・scoreTrail・ringFrac・warnMix を直接呼んで数えたものです。

確認画面は、ポインターを使えない人をたやすく締め出してしまいます。そこで九つのキャンバスはすべて tabindex="0" でTabのフォーカスを受け、aria-label に項目名を持ち、キャンバスとボタンには :focus-visible の枠があります。キー入力は場面ごとに違います — 01・07は方向キーで振幅やしきい値を調整し、02は←/→で5°ずつ回して最後の入力から30ティック経つと自動で離し、03は矢印でマスを移ってSpaceで選びEnterで確定します。04は方向キーで文字を選び、05はSpaceでペンを上げ下げして矢印で9pxずつ引き、06は↑/↓とEnterまたはSpace、08はBackspace・Escapeで最初に戻り、09はRでも更新します。preventDefault はその場面が実際に使うキーにだけかけているので、Tabで次の要素へ移る道はふさぎません。キャンバスには touch-action: none があり、スマートフォンでドラッグしているあいだページが一緒にスクロールすることもありません(ポインターイベント)。

状態の文言は aria-live を持つ <p> 一つに出ます。実演がひとりで回っているあいだは aria-live="off" なので読み上げソフトは黙っていて、人が触り始めると polite に切り替わり、文言は44ティック(約0.7秒)に一度だけ更新されます。

prefers-reduced-motion が有効なら、rAF のループは回しません。開始時に REDUCE_TICKS 分のティックをまとめて回して代表の場面を一枚描き、入力があると30ティックをまとめて回した結果だけを描き直すので、途中の場面はありません。一時停止ボタンは「次の場面」に替わり、06は行を押すと終わりの状態と最初の状態が交互に出て、03・04は合格した盤をもう一度押すと新しい盤が開くので入力が行き詰まりません。

captcha code html css だけで足りるか?

画面の枠だけなら HTML と CSS で足ります。舞台の .cp は width: min(92vw, calc(160vh - 12.8vw)) に aspect-ratio: 480 / 300 を合わせ、あとは16pxの角丸と overflow: hidden だけです。キャンバスは論理座標480×300に devicePixelRatio を最大2まで掛けた解像度で取られるため、レンダーのキャプチャでは約419×262px、320×200の画面では約279×174pxに見えても場面の計算は変わりません。けれども画素を動かし、ノイズのしきい値を測り、点数を出す部分はスクリプトの仕事で、画素の読み取りは getImageData を、01は文字盤を、07は文字マスクを焼くときに一度ずつ呼ぶだけです。

もっと大きな理由は別にあります。HTML・CSS・JS のどれで書いても、ブラウザで動くコードは正解を隠しておけないので、この記事の場面は確認の結果を見せる見た目としてだけ使います。失敗回数に応じて問題を難しく見せる08は ログイン失敗ロック9選 のロック画面と組になり、フォーム全体の流れは ログイン画面デザイン9選 で、入力中に強度を見せる欄は パスワード強度チェック UI 9選 で続けて見られます。時間の計算の根拠は requestAnimationFrame の文書で確かめました。

captcha css game のコードを使い回す前によくある質問

この場面を会員登録フォームにそのまま入れれば人の確認になりますか?

なりません。判定のルールと正解がブラウザのスクリプトの中にあり、ソースを開けば全部見えてしまいます。問題を作って答えを調べる仕事はサーバーに置き、この画面はサーバーが出した問題を見せて結果を表示する場所として使うのが筋です。

captcha css game のように点数を出す05は、どんな軌跡に60点を付けますか?

TOL_PX 12では、標本の平均距離が9.6px以下なら60点以上です。±14pxで揺らしながら引いた軌跡は66点で合格し、±24pxの軌跡は43点で落ちます。向きと完走は見ていないので、点数だけで判断しないでください。

同じ種を入れると同じ問題がもう一度出ますか?

出ます。乱数が lcg・hash2 からしか出ず Math.random もないので、06を除く八つの場面では種一つが文字・角度・置き場・案内線を決めます。実演は一周が終わるたびに、たいてい種を1上げて次の問題へ進みます。

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

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