08A Stepped Check Where Warp Amplitude and Line Count Rise One Step per Miss
Misses multiplied by a step factor set the amplitude and the line count, and neither rises past its cap. The scale beside the board shows the current step from the same miss count.
Best forRaising check difficulty as failed logins pile up
ESCALATE_STEPAMP_MAXLINES_MAXFAIL_LIMIT

<main class="stage">
<div class="cp is-demo" id="cp">
<canvas class="cp__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A Stepped Check Where Warp Amplitude and Line Count Rise One Step per Miss"></canvas>
<p class="cp__status" data-role="status" aria-live="polite">Starting the demo</p>
<button class="cp__btn" data-role="btn" type="button">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.cp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.cp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.cp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.cp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.cp__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-family: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.cp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cp__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.cp__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.cp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.cp__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.cp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.cp__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.cp__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-family: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.cp__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cp__btn {
font-size: 10px;
padding: 4px 8px;
}
.cp__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp {
cursor: default;
}
}(function () {
const root = document.getElementById("cp");
if (!root)
return;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_TICKS = 150;
const BTN_PAUSE = 'Pause';
const BTN_PLAY = 'Play';
const BTN_NEXT = 'Next scene';
// 파스텔 팔레트 - 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const PANEL = [44, 40, 68];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const CREAM = [255, 244, 224];
const CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZabdefghmnqrt23456789';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutBack(x) { const c = clamp(x, 0, 1) - 1; return 1 + c * c * (2.7 * c + 1.7); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 - 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x, y, seed) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 시드로 뜻 없는 글자열을 만든다
function glyphStr(seed, n) {
const r = lcg(seed * 7919 + 13);
let s = '';
for (let i = 0; i < n; i += 1) {
s += CHARSET.charAt(Math.floor(r() * CHARSET.length));
}
return s;
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function mount(root, make) {
const canvas = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx = canvas.getContext('2d');
if (!ctx) {
return function () { return; };
}
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { idle: 99999, focus: false, key: false };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
let lastLabelTick = -999;
let lastLive = 'polite';
let tickN = 0;
let clk = 0;
let wasAuto = true;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw(ctx);
// 시연이 혼자 돌 때는 낭독을 끄고, 사람이 만지는 동안은 값이 바뀌어도 0.7초에 한 번만 갱신한다
const live = (reduce || inp.idle <= IDLE_TICKS) ? 'polite' : 'off';
if (live !== lastLive) {
lastLive = live;
statusEl.setAttribute('aria-live', live);
}
const t = scene.label();
if (t !== lastLabel && (reduce || live === 'off' || tickN - lastLabelTick >= 44)) {
lastLabel = t;
lastLabelTick = tickN;
statusEl.textContent = t;
}
}
// 고정 간격 한 틱 - 입력이 없으면 시연 스크립트가 같은 입구로 입력을 대신 넣는다
function advance() {
inp.idle += 1;
tickN += 1;
const a = inp.idle > IDLE_TICKS;
if (a && !wasAuto) {
scene.reset();
clk = 0;
}
wasAuto = a;
if (a) {
clk += 1;
scene.auto(clk);
if (clk >= scene.CYCLE) {
clk = 0;
scene.reset();
}
}
scene.step();
}
// 움직임 줄이기: 틱을 몰아서 돌린 뒤 한 번만 그린다 (중간 보간 장면은 화면에 나오지 않는다)
function settle(n) {
for (let i = 0; i < n; i += 1) {
inp.idle = 0;
tickN += 1;
scene.step();
}
show();
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
advance();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
function point(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
for (let i = 0; i < scene.REDUCE_TICKS; i += 1) {
advance();
}
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
if (scene.next) {
scene.next();
show();
return;
}
const n = Math.round(scene.CYCLE / 3);
inp.idle = 99999;
wasAuto = true;
for (let i = 0; i < n; i += 1) {
advance();
}
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { inp.focus = true; if (reduce) {
show();
} });
listen(canvas, 'blur', function () { inp.focus = false; inp.key = false; if (reduce) {
show();
} });
listen(canvas, 'pointerdown', function (e) {
const p = point(e);
inp.idle = 0;
inp.key = false;
try {
canvas.setPointerCapture(e.pointerId);
}
catch (err) { /* 캡처가 안 되는 환경은 그냥 넘어간다 */ }
scene.down(p[0], p[1]);
if (reduce) {
settle(30);
}
});
listen(canvas, 'pointermove', function (e) {
const p = point(e);
inp.idle = 0;
inp.key = false;
scene.move(p[0], p[1]);
if (reduce) {
settle(30);
}
});
listen(canvas, 'pointerup', function (e) {
const p = point(e);
inp.idle = 0;
scene.up(p[0], p[1]);
if (reduce) {
settle(30);
}
});
listen(canvas, 'keydown', function (e) {
inp.idle = 0;
inp.key = true;
if (scene.key(e.key)) {
e.preventDefault();
}
if (reduce) {
settle(30);
}
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const ESCALATE_STEP = 3; // 실패 한 번마다 늘어나는 진폭 (px)
const AMP_MAX = 13; // 진폭 상한 (px)
const LINES_MAX = 6; // 선 수 상한
const FAIL_LIMIT = 5; // 눈금이 가득 차는 실패 횟수
const BASE_AMP = 1.5;
const BX = 16;
const BY = 16;
const BW = 300;
const BH = 268;
const TXT_FAILS = 'Misses {f} · amplitude {a}, {n} lines';
const TXT_CAP = 'Misses {f} · amplitude stops at the cap {m}';
// PURE-BEGIN
// 실패 수에 단계 계수를 곱해 진폭과 선 수를 정한다 - 둘 다 상한에서 멈춘다
function distortionFor(fails, stepPx, ampMax, linesMax) {
const amp = Math.min(ampMax, 1.5 + fails * stepPx);
const lines = Math.min(linesMax, 1 + fails);
return { amp: amp, lines: lines };
}
// PURE-END
const make = (inp, reduce) => {
const src = document.createElement('canvas');
src.width = BW;
src.height = BH;
const sctx = src.getContext('2d');
if (!sctx) {
return { CYCLE: 1, REDUCE_TICKS: 0, reset() { return; }, auto() { return; }, step() { return; }, draw() { return; }, label() { return ''; }, down() { return; }, move() { return; }, up() { return; }, key() { return false; } };
}
let seed = 21;
let fails = 0;
let ampNow = BASE_AMP;
let ph = 0;
let shake = 0;
let t = 0;
function bake() {
const text = glyphStr(seed, 5);
const r = lcg(seed * 41 + 7);
const g = sctx.createLinearGradient(0, 0, BW, BH);
g.addColorStop(0, rgba(PANEL, 1));
g.addColorStop(1, rgba(mix(PANEL, LILAC, 0.2), 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, BW, BH);
sctx.strokeStyle = rgba(SKY, 0.16);
sctx.lineWidth = 3;
for (let y = 10; y < BH; y += 20) {
sctx.beginPath();
sctx.moveTo(0, y);
sctx.lineTo(BW, y);
sctx.stroke();
}
sctx.font = 'bold 76px "Segoe UI", "Noto Sans KR", sans-serif';
sctx.textAlign = 'center';
sctx.textBaseline = 'middle';
const cols = [MINT, PEACH, SKY, PINK, CREAM];
for (let i = 0; i < 5; i += 1) {
sctx.save();
sctx.translate(36 + i * 57, BH / 2 + (r() - 0.5) * 26);
sctx.rotate((r() - 0.5) * 0.5);
sctx.fillStyle = rgba(cols[(i + seed) % cols.length], 1);
sctx.fillText(text.charAt(i), 0, 0);
sctx.restore();
}
}
bake();
function miss() {
fails += 1;
seed += 1;
bake();
shake = 18;
}
const scene = {
CYCLE: 130,
REDUCE_TICKS: 100,
reset() { fails = 0; ampNow = BASE_AMP; seed += 1; bake(); },
auto(clk) {
if (clk === 10 || clk === 28 || clk === 46 || clk === 64 || clk === 82 || clk === 100) {
miss();
}
},
step() {
t += 1;
ph += 0.12;
const d = distortionFor(fails, ESCALATE_STEP, AMP_MAX, LINES_MAX);
ampNow += (d.amp - ampNow) * (reduce ? 1 : 0.2);
if (shake > 0) {
shake -= 1;
}
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
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);
}
const r = lcg(seed * 3 + 1);
for (let i = 0; i < d.lines; i += 1) {
const y0 = BY + 20 + r() * (BH - 40);
const y1 = BY + 20 + r() * (BH - 40);
const wob = Math.sin(ph * 0.8 + i) * 10;
ctx.strokeStyle = rgba(i % 2 === 0 ? CREAM : PINK, 0.85);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(BX + ox, y0 + wob);
ctx.bezierCurveTo(BX + ox + BW * 0.3, y0 - 50 + wob, BX + ox + BW * 0.65, y1 + 50 - wob, BX + ox + BW, y1 - wob);
ctx.stroke();
}
ctx.restore();
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(shake > 0 ? PINK : CREAM, shake > 0 ? 1 : 0.4);
rrect(ctx, BX + ox, BY, BW, BH, 16);
ctx.stroke();
// 눈금 - 실패 횟수만큼 아래서부터 차오른다
for (let k = 0; k < FAIL_LIMIT; k += 1) {
const y = BY + BH - 62 - k * 48;
const on = k < fails;
const col = mix(MINT, PINK, k / (FAIL_LIMIT - 1));
ctx.fillStyle = on ? rgba(col, 1) : rgba(CREAM, 0.18);
rrect(ctx, 344, y, 120, 38, 12);
ctx.fill();
if (on && k === Math.min(fails, FAIL_LIMIT) - 1 && shake > 0) {
ctx.strokeStyle = rgba(CREAM, shake / 18);
ctx.lineWidth = 3;
rrect(ctx, 340, y - 4, 128, 46, 14);
ctx.stroke();
}
}
if (inp.key && inp.focus) {
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
ctx.lineWidth = 2.5;
ctx.strokeRect(6, 6, W - 12, H - 12);
}
},
label() {
const d = distortionFor(fails, ESCALATE_STEP, AMP_MAX, LINES_MAX);
if (d.amp >= AMP_MAX) {
return TXT_CAP.replace('{f}', String(fails)).replace('{m}', String(AMP_MAX));
}
return TXT_FAILS.replace('{f}', String(fails)).replace('{a}', String(Math.round(d.amp * 10) / 10)).replace('{n}', String(d.lines));
},
down() { miss(); },
move() { return; },
up() { return; },
key(k) {
if (k === 'Enter' || k === ' ') {
miss();
return true;
}
if (k === 'Backspace' || k === 'Escape') {
fails = 0;
seed += 1;
bake();
return true;
}
return false;
},
};
return scene;
};
return mount(root, make);
})();// CapFailEscalateDistortionMeter - vanilla 판(08-cap-fail-escalate-distortion-meter) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./CapFailEscalateDistortionMeter.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CapFailEscalateDistortionMeter() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
type RGB = [number, number, number];
interface Input { idle: number; focus: boolean; key: boolean; }
interface Scene {
CYCLE: number;
REDUCE_TICKS: number;
reset(): void;
auto(clk: number): void;
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
down(x: number, y: number): void;
move(x: number, y: number): void;
up(x: number, y: number): void;
key(k: string): boolean;
next?(): void;
}
type Make = (inp: Input, reduce: boolean) => Scene;
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 팔레트 - 어두운 판 위에 밝은 색을 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [44, 40, 68];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const CREAM: RGB = [255, 244, 224];
const CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZabdefghmnqrt23456789';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function easeOutBack(x: number): number { const c = clamp(x, 0, 1) - 1; return 1 + c * c * (2.7 * c + 1.7); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 시드 고정 난수 - 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 두 개와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash2(x: number, y: number, seed: number): number {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 시드로 뜻 없는 글자열을 만든다
function glyphStr(seed: number, n: number): string {
const r = lcg(seed * 7919 + 13);
let s = '';
for (let i = 0; i < n; i += 1) {
s += CHARSET.charAt(Math.floor(r() * CHARSET.length));
}
return s;
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function mount(root: HTMLElement, make: Make): () => void {
const canvas = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement;
const btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement;
if (!canvas || !statusEl || !btnEl) {
return function () { return; };
}
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
if (!ctx) {
return function () { return; };
}
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { idle: 99999, focus: false, key: false };
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
let lastLabelTick = -999;
let lastLive = 'polite';
let tickN = 0;
let clk = 0;
let wasAuto = true;
const cleaners: Array<() => void> = [];
function listen<K extends keyof HTMLElementEventMap>(target: HTMLElement, type: K, fn: (e: HTMLElementEventMap[K]) => void): void {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw(ctx);
// 시연이 혼자 돌 때는 낭독을 끄고, 사람이 만지는 동안은 값이 바뀌어도 0.7초에 한 번만 갱신한다
const live = (reduce || inp.idle <= IDLE_TICKS) ? 'polite' : 'off';
if (live !== lastLive) {
lastLive = live;
statusEl.setAttribute('aria-live', live);
}
const t = scene.label();
if (t !== lastLabel && (reduce || live === 'off' || tickN - lastLabelTick >= 44)) {
lastLabel = t;
lastLabelTick = tickN;
statusEl.textContent = t;
}
}
// 고정 간격 한 틱 - 입력이 없으면 시연 스크립트가 같은 입구로 입력을 대신 넣는다
function advance(): void {
inp.idle += 1;
tickN += 1;
const a = inp.idle > IDLE_TICKS;
if (a && !wasAuto) {
scene.reset();
clk = 0;
}
wasAuto = a;
if (a) {
clk += 1;
scene.auto(clk);
if (clk >= scene.CYCLE) {
clk = 0;
scene.reset();
}
}
scene.step();
}
// 움직임 줄이기: 틱을 몰아서 돌린 뒤 한 번만 그린다 (중간 보간 장면은 화면에 나오지 않는다)
function settle(n: number): void {
for (let i = 0; i < n; i += 1) {
inp.idle = 0;
tickN += 1;
scene.step();
}
show();
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
advance();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
function point(e: PointerEvent): number[] {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
for (let i = 0; i < scene.REDUCE_TICKS; i += 1) {
advance();
}
show();
}
listen(btnEl, 'click', function () {
if (reduce) {
if (scene.next) {
scene.next();
show();
return;
}
const n = Math.round(scene.CYCLE / 3);
inp.idle = 99999;
wasAuto = true;
for (let i = 0; i < n; i += 1) {
advance();
}
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'focus', function () { inp.focus = true; if (reduce) { show(); } });
listen(canvas, 'blur', function () { inp.focus = false; inp.key = false; if (reduce) { show(); } });
listen(canvas, 'pointerdown', function (e) {
const p = point(e);
inp.idle = 0;
inp.key = false;
try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* 캡처가 안 되는 환경은 그냥 넘어간다 */ }
scene.down(p[0], p[1]);
if (reduce) { settle(30); }
});
listen(canvas, 'pointermove', function (e) {
const p = point(e);
inp.idle = 0;
inp.key = false;
scene.move(p[0], p[1]);
if (reduce) { settle(30); }
});
listen(canvas, 'pointerup', function (e) {
const p = point(e);
inp.idle = 0;
scene.up(p[0], p[1]);
if (reduce) { settle(30); }
});
listen(canvas, 'keydown', function (e) {
inp.idle = 0;
inp.key = true;
if (scene.key(e.key)) {
e.preventDefault();
}
if (reduce) { settle(30); }
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const ESCALATE_STEP = 3; // 실패 한 번마다 늘어나는 진폭 (px)
const AMP_MAX = 13; // 진폭 상한 (px)
const LINES_MAX = 6; // 선 수 상한
const FAIL_LIMIT = 5; // 눈금이 가득 차는 실패 횟수
const BASE_AMP = 1.5;
const BX = 16;
const BY = 16;
const BW = 300;
const BH = 268;
const TXT_FAILS = '실패 {f}회 — 진폭 {a}, 선 {n}개';
const TXT_CAP = '실패 {f}회 — 진폭 상한 {m}에서 멈춤';
// PURE-BEGIN
// 실패 수에 단계 계수를 곱해 진폭과 선 수를 정한다 - 둘 다 상한에서 멈춘다
function distortionFor(fails: number, stepPx: number, ampMax: number, linesMax: number): { amp: number; lines: number } {
const amp = Math.min(ampMax, 1.5 + fails * stepPx);
const lines = Math.min(linesMax, 1 + fails);
return { amp: amp, lines: lines };
}
// PURE-END
const make: Make = (inp, reduce) => {
const src = document.createElement('canvas');
src.width = BW;
src.height = BH;
const sctx = src.getContext('2d') as CanvasRenderingContext2D;
if (!sctx) {
return { CYCLE: 1, REDUCE_TICKS: 0, reset() { return; }, auto() { return; }, step() { return; }, draw() { return; }, label() { return ''; }, down() { return; }, move() { return; }, up() { return; }, key() { return false; } };
}
let seed = 21;
let fails = 0;
let ampNow = BASE_AMP;
let ph = 0;
let shake = 0;
let t = 0;
function bake(): void {
const text = glyphStr(seed, 5);
const r = lcg(seed * 41 + 7);
const g = sctx.createLinearGradient(0, 0, BW, BH);
g.addColorStop(0, rgba(PANEL, 1));
g.addColorStop(1, rgba(mix(PANEL, LILAC, 0.2), 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, BW, BH);
sctx.strokeStyle = rgba(SKY, 0.16);
sctx.lineWidth = 3;
for (let y = 10; y < BH; y += 20) {
sctx.beginPath();
sctx.moveTo(0, y);
sctx.lineTo(BW, y);
sctx.stroke();
}
sctx.font = 'bold 76px "Segoe UI", "Noto Sans KR", sans-serif';
sctx.textAlign = 'center';
sctx.textBaseline = 'middle';
const cols = [MINT, PEACH, SKY, PINK, CREAM];
for (let i = 0; i < 5; i += 1) {
sctx.save();
sctx.translate(36 + i * 57, BH / 2 + (r() - 0.5) * 26);
sctx.rotate((r() - 0.5) * 0.5);
sctx.fillStyle = rgba(cols[(i + seed) % cols.length], 1);
sctx.fillText(text.charAt(i), 0, 0);
sctx.restore();
}
}
bake();
function miss(): void {
fails += 1;
seed += 1;
bake();
shake = 18;
}
const scene: Scene = {
CYCLE: 130,
REDUCE_TICKS: 100,
reset() { fails = 0; ampNow = BASE_AMP; seed += 1; bake(); },
auto(clk) {
if (clk === 10 || clk === 28 || clk === 46 || clk === 64 || clk === 82 || clk === 100) { miss(); }
},
step() {
t += 1;
ph += 0.12;
const d = distortionFor(fails, ESCALATE_STEP, AMP_MAX, LINES_MAX);
ampNow += (d.amp - ampNow) * (reduce ? 1 : 0.2);
if (shake > 0) { shake -= 1; }
},
draw(ctx) {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
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);
}
const r = lcg(seed * 3 + 1);
for (let i = 0; i < d.lines; i += 1) {
const y0 = BY + 20 + r() * (BH - 40);
const y1 = BY + 20 + r() * (BH - 40);
const wob = Math.sin(ph * 0.8 + i) * 10;
ctx.strokeStyle = rgba(i % 2 === 0 ? CREAM : PINK, 0.85);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(BX + ox, y0 + wob);
ctx.bezierCurveTo(BX + ox + BW * 0.3, y0 - 50 + wob, BX + ox + BW * 0.65, y1 + 50 - wob, BX + ox + BW, y1 - wob);
ctx.stroke();
}
ctx.restore();
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(shake > 0 ? PINK : CREAM, shake > 0 ? 1 : 0.4);
rrect(ctx, BX + ox, BY, BW, BH, 16);
ctx.stroke();
// 눈금 - 실패 횟수만큼 아래서부터 차오른다
for (let k = 0; k < FAIL_LIMIT; k += 1) {
const y = BY + BH - 62 - k * 48;
const on = k < fails;
const col = mix(MINT, PINK, k / (FAIL_LIMIT - 1));
ctx.fillStyle = on ? rgba(col, 1) : rgba(CREAM, 0.18);
rrect(ctx, 344, y, 120, 38, 12);
ctx.fill();
if (on && k === Math.min(fails, FAIL_LIMIT) - 1 && shake > 0) {
ctx.strokeStyle = rgba(CREAM, shake / 18);
ctx.lineWidth = 3;
rrect(ctx, 340, y - 4, 128, 46, 14);
ctx.stroke();
}
}
if (inp.key && inp.focus) {
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
ctx.lineWidth = 2.5;
ctx.strokeRect(6, 6, W - 12, H - 12);
}
},
label() {
const d = distortionFor(fails, ESCALATE_STEP, AMP_MAX, LINES_MAX);
if (d.amp >= AMP_MAX) { return TXT_CAP.replace('{f}', String(fails)).replace('{m}', String(AMP_MAX)); }
return TXT_FAILS.replace('{f}', String(fails)).replace('{a}', String(Math.round(d.amp * 10) / 10)).replace('{n}', String(d.lines));
},
down() { miss(); },
move() { return; },
up() { return; },
key(k) {
if (k === 'Enter' || k === ' ') { miss(); return true; }
if (k === 'Backspace' || k === 'Escape') { fails = 0; seed += 1; bake(); return true; }
return false;
},
};
return scene;
};
return mount(root, make);
}, []);
return (
<div className={cx("cp") + " is-demo"} id="cp" ref={rootRef}>
<canvas className={cx("cp__canvas")} data-role="canvas" width="480" height="300" tabIndex={0} aria-label="틀릴 때마다 왜곡 진폭과 선 수가 한 단계씩 올라가는 단계형 확인"></canvas>
<p className={cx("cp__status")} data-role="status" aria-live="polite">시연 준비 중</p>
<button className={cx("cp__btn")} data-role="btn" type="button">멈춤</button>
</div>
);
}08 A Stepped Check Where Warp Amplitude and Line Count Rise One Step per Miss — Measured area 59.694% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Captcha: 9 Warped Text and Rotate Checks
The post this demo lives in
CSS Captcha: 9 Warped Text and Rotate ChecksCall it a human check, but a css captcha is arithmetic on screen — scene 01 slides glyph columns along a sine wave and s See all demos in this post
If you like this effect - similar demos
Auth UI04 Captcha step-up — the captcha cell-count value grows each time the failure count crosses a threshold valueToo Many Failed Login Attempts: 9 Patterns
Auth UI02 Signup steps — a stepped form that divides its own progress ratio to set bar length and color9 Login UI Design Patterns (Copy-Paste)
Auth UI05 Pressure ripple — as a press-pressure value rises, a concentric ripple's radius and amplitude values grow togetherFingerprint Scan Animation: 9 Biometric UI Demos