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

웹 미니게임 만들기 9종 — 계산기·타이머·퍼즐 복붙 코드

웹 미니게임 만들기는 브라우저에서 바로 손이 가는 작은 놀잇감과 업무 도구를 HTML·CSS·JS 로 짜는 일입니다. 라이브러리 없이 파일 하나로 도는 아홉 개를 아래 격자에 늘어놓았고, 지금 그대로 움직입니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

순서는 인기순이 아니라 화면이 사람에게 시키는 일이 무거워지는 순서입니다. 넷 중 하나를 고르게 하는 것(01)이 가장 가볍고, 숫자를 두드려 값을 만들고(02), 시간을 대신 지켜보고(03), 카드 여섯 장의 자리를 흔들고(04), 슬라이더로 디자인 값을 뽑고(05), 0.01초까지 재서 기록을 남기고(06), 문장 하나에 맞다와 아니다를 묻고(07), 마지막 둘은 결과를 사람 대신 정합니다(08·09). 슬라이더가 값과 코드를 함께 뱉는 05 는 css generator 9종과 한 갈래이고, 원판을 각도로 깎는 03 은 원형 프로그레스바 CSS 9종이 쓰던 기법을 타이머 쪽으로 돌려놓은 것입니다. 아홉 개 모두 한 폴더 안에 html·css·js 만 있고 빌드 도구를 요구하지 않습니다.

01로고 퍼즐 맞추기

진열 카드에 뜬 도형 마크와 같은 모양을 보기 넷에서 고르는 퀴즈판으로, 이벤트 페이지 첫 화면이나 학습 퀴즈 도입부에 놓습니다.

로고 퍼즐 맞추기
새 창에서 열기

01 · 로고 퍼즐 맞추기 — 측정 누적면적 17.351% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

clip-pathconic-gradientaria-live

마크 여섯은 그림 파일이 아니라 CSS 도형

.lp__mark[data-shape="hex"] { clip-path: polygon(50% 0, 94% 25%, 94% 75%, 50% 100%, 6% 75%, 6% 25%); }
.lp__mark[data-shape="diamond"] { clip-path: polygon(50% 2%, 98% 50%, 50% 98%, 2% 50%); }
.lp__mark[data-shape="tri"] { clip-path: polygon(50% 4%, 96% 92%, 4% 92%); }
.lp__mark[data-shape="chevron"] { clip-path: polygon(16% 0, 100% 50%, 16% 100%, 0 100%, 84% 50%, 0 0); }
.lp__mark[data-shape="wedge"] {
  background-color: transparent; border-radius: 50%;
  background-image: conic-gradient(from 205deg, currentColor 0deg 125deg, transparent 125deg 360deg);
}

육각·마름모·삼각·갈매기는 clip-path 좌표로, 부채꼴은 conic-gradient 한 줄로 그리므로 이 항목이 불러오는 이미지는 0개이고 격자 미리보기에서는 견줄 칸 23개 중 19개에서 움직임이 잡혔습니다.

틀리면 정답 칸을 대신 켠다

  function pick(i) {
    if (lock || !doneEl.hidden) { return; }
    lock = true;
    root.classList.remove('is-demo');
    var answer = QUESTIONS[qIndex].answer;
    var ok = tiles[i].firstElementChild.getAttribute('data-shape') === answer;
    tiles[i].classList.add(ok ? 'is-correct' : 'is-wrong');
    if (ok) { score++; scoreEl.textContent = String(score); }
    else {
      for (var k = 0; k < tiles.length; k++) {
        if (tiles[k].firstElementChild.getAttribute('data-shape') === answer) { tiles[k].classList.add('is-correct'); }
      }
    }
    window.setTimeout(next, NEXT_MS);
  }

고른 타일이 정답이면 is-correct, 틀리면 is-wrong 을 붙이고, 오답일 때만 정답 모양을 가진 타일을 찾아 함께 켠 뒤 0.8초 뒤에 다음 문제로 넘어갑니다.

02키패드 계산기

키보드의 숫자·연산자·Enter·Backspace 가 그대로 통하는 사칙연산 키패드로, 견적을 더하고 주문 수량을 정산하는 화면에 붙이는 계산기 만들기의 기본형입니다.

키패드 계산기
새 창에서 열기

02 · 키패드 계산기 — 측정 누적면적 3.732% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

grid-template-areasfont-variant-numericuser-select

키 스무 개의 자리는 이름표 한 장

.kc__pad {
  position: relative; flex: 1 1 auto; min-height: 0;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: repeat(5, 1fr);
  grid-template-areas:
    "clear back pct div"
    "n7 n8 n9 mul"
    "n4 n5 n6 sub"
    "n1 n2 n3 add"
    "n0 n0 dot eq";
  gap: $sp-2;
  --kc-space: #{$sp-2};
}

4×5 격자에 이름표를 먼저 그려 두고 키마다 grid-area 로 자기 자리를 받아 가기 때문에, 0 키만 두 칸을 차지하는 배치가 좌표 계산 없이 나옵니다.

연산자를 잇달아 눌러도 마지막 하나만

  function operator(o) {
    var v = Number(shown);
    if (op !== null && !fresh) {
      var r = calc(acc, v, op);
      if (r === null) { fail(); return; }
      acc = r; show(fmt(r));
    } else { acc = v; }
    op = o; fresh = true;
  }

대기 중인 연산자가 있고 그 뒤에 새 숫자가 들어와 있으면 먼저 계산해 누적값에 넣고, 아니면 값만 갈아 끼우므로 + 다음에 × 를 눌러도 나중 것 하나만 남습니다.

03포모도로 링 타이머

25분 집중과 5분 쉼을 도는 원형 타이머로, 개인 작업 시간이나 강의 진행판에 두는 뽀모도로 타이머 만들기의 완성형입니다.

포모도로 링 타이머
새 창에서 열기

03 · 포모도로 링 타이머 — 측정 누적면적 7.381% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

conic-gradientanimation-play-statearia-valuenow

각도 하나를 깎아 원판을 비운다

.pt__disc {
  position: absolute; inset: 0; border-radius: 50%;
  --pt-angle: 360deg;
  --pt-fill: #{$color};
  background-image: conic-gradient(var(--pt-fill) 0deg var(--pt-angle), rgba($subject-ink, .18) var(--pt-angle) 360deg);
  animation-name: ptDrain;
  animation-duration: 25s;                // JS 가 페이즈 길이 ÷ SPEED 로 다시 넣는다
  animation-timing-function: linear;      // 진짜 타이머는 등속이어야 한다
  animation-iteration-count: 1;
  animation-fill-mode: both;
  animation-play-state: paused;
}
.pt.is-running .pt__disc { animation-play-state: running; }
.pt.is-break .pt__disc { --pt-fill: #{$subject-blue-deep}; }
@keyframes ptDrain {
  from { --pt-angle: 360deg; }
  to { --pt-angle: 0deg; }
}

--pt-angle 을 @property 로 <angle> 등록해 두었기에 키프레임 사이가 보간되고, 멈춤과 재생은 animation-play-state 한 줄이 맡아 JS 는 시각을 다시 계산하지 않으며, 데모는 SPEED 가 60 이라 1초에 60초씩 줄어 25분 구간이 25초 만에 끝나므로 실제로 쓸 때는 그 값을 1 로 돌려놓으면 됩니다.

새로고침을 견디는 남은 시간

  try {
    var saved = JSON.parse(window.localStorage.getItem(KEY) || 'null');
    if (saved && typeof saved.left === 'number') {
      root.classList.remove('is-demo');
      phase = saved.phase === 'break' ? 'break' : 'focus';
      left = Math.min(Math.max(saved.left, 0), total());
      syncDisc();
      run(!!saved.running);
    }
  } catch (e) { /* 깨진 값은 무시하고 처음 상태로 */ }

남은 초와 지금이 집중인지 쉼인지를 Web Storage API 로 저장해 두었다가 창을 다시 열 때 되살리되, 값이 깨져 있으면 조용히 처음 상태로 돌아갑니다.

04카드 셔플·정렬

숫자 카드 여섯 장이 셔플을 누르면 자리를 바꾸고 정렬을 누르면 원래 줄로 돌아오는 진열대로, 추첨 순서를 정하거나 팀 편성을 흔들 때 씁니다.

카드 셔플·정렬
새 창에서 열기

04 · 카드 셔플·정렬 — 측정 누적면적 38.397% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

transitiontranslatepointer-events

카드의 DOM 순서는 끝까지 그대로

.cs__card {
  position: absolute; left: 0; bottom: 0;
  width: calc((100% - #{$sp-2} * 5) / 6); aspect-ratio: 3 / 4;
  --step: calc(100% + #{$sp-2});
  --slot: 0;
  --slotAlt: 0;
  --lift: 0%;
  translate: calc(var(--step) * var(--slot)) 0;
  transition: translate $dur-enter $easing;
  display: grid; place-items: center;
  border-radius: $r-control;
  background-color: $color; color: $subject-cream;   // 진파랑 면 + 크림 글자 5.98:1
  box-shadow: $shadow-press;
  font-weight: 800; font-size: clamp(14px, 4vw, 22px);
  user-select: none;
}

자리는 슬롯 번호 --slot 하나가 정하고 한 칸의 폭은 카드 한 장의 폭 100%에 사이 간격을 더한 값이라, 번호가 바뀌면 transition 이 그 사이를 이어 줍니다.

섞는 동안은 버튼을 잠근다

  function shuffle() {
    if (moving) { return; }
    root.classList.remove('is-demo');
    var before = slots.join('');
    for (var i = slots.length - 1; i > 0; i--) {            // Fisher-Yates
      var j = Math.floor(Math.random() * (i + 1));
      var t = slots[i]; slots[i] = slots[j]; slots[j] = t;
    }
    if (slots.join('') === before) { var h = slots[0]; slots[0] = slots[5]; slots[5] = h; }
    paint(); lock();
    stateEl.textContent = SHUFFLED;
  }

Fisher-Yates 로 슬롯을 섞고 결과가 원래 순서와 같으면 양 끝을 한 번 더 바꿔 반드시 달라지게 하며, 움직이는 0.6초 동안은 버튼이 포인터를 받지 않아 연타로 자리가 엉키지 않습니다.

05글래스 효과 제너레이터

흐림·투명도·채도·테두리 슬라이더가 미리보기 카드와 아래 코드 줄을 함께 바꾸는 도구로, 코딩 없이 유리 카드 값을 정할 때 꺼냅니다.

글래스 효과 제너레이터
새 창에서 열기

05 · 글래스 효과 제너레이터 — 측정 누적면적 17.794% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

backdrop-filterhslclamp

값을 만드는 곳은 한 함수뿐

  function code() {
    return [
      'backdrop-filter: blur(' + ranges.blur.value + 'px) saturate(' + ranges.saturate.value + '%);',
      'background: hsl(0 0% 100% / ' + alpha() + ');',
      'border: ' + ranges.border.value + 'px solid hsl(0 0% 100% / .5);'
    ];
  }
  function render() {
    var rows = code();
    glass.style.backdropFilter = 'blur(' + ranges.blur.value + 'px) saturate(' + ranges.saturate.value + '%)';
    glass.style.backgroundColor = 'hsl(0 0% 100% / ' + alpha() + ')';
    glass.style.borderWidth = ranges.border.value + 'px';
    outs.blur.textContent = ranges.blur.value + 'px';
    outs.opacity.textContent = ranges.opacity.value + '%';
    outs.saturate.textContent = ranges.saturate.value + '%';
    outs.border.textContent = ranges.border.value + 'px';
    for (var i = 0; i < lines.length; i++) { lines[i].textContent = rows[i]; }
  }

code() 가 만든 세 줄이 카드의 인라인 스타일과 코드 상자에 동시에 쓰이므로, 복사해 간 값과 눈으로 본 카드가 어긋날 자리가 없습니다.

backdrop-filter 는 뒤가 선명해야 보인다

.gg__scene { position: relative; overflow: hidden; border-radius: $r-card; background-color: rgba($subject-cream, .06); min-height: 120px; }
// 반점을 너무 흐리면 그 위 카드의 backdrop-filter 가 한 일이 안 보인다 — 반점은 덜 흐리게 둔다
.gg__blob { position: absolute; width: 64%; aspect-ratio: 1; border-radius: 50%; filter: blur(9px); }
.gg__blob--a { left: -12%; top: -10%; background-color: $subject-pink; }
.gg__blob--b { right: -14%; top: 18%; background-color: $subject-mint; }
.gg__blob--c { left: 18%; bottom: -18%; background-color: $subject-sky; }
.gg__glass {
  position: absolute; inset: 22% 12%;
  border-radius: $r-card;
  border-style: solid; border-width: 1px; border-color: hsl(0 0% 100% / .5);
  background-color: hsl(0 0% 100% / .18);
  backdrop-filter: blur(12px) saturate(160%);
  -webkit-backdrop-filter: blur(12px) saturate(160%);
}

뒤에 깔린 색 반점을 9px만 흐려 두는 이유가 여기 있는데, 배경을 미리 뭉개 놓으면 backdrop-filter 가 한 일이 화면에서 사라집니다.

06랩 스톱워치

0.01초 단위로 흐르고 스페이스로 멈추고 L 로 랩을 찍는 스톱워치로, 운동 기록을 남기거나 발표 시간을 잴 때 놓습니다.

랩 스톱워치
새 창에서 열기

06 · 랩 스톱워치 — 측정 누적면적 20.363% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

font-variant-numericrequestAnimationFramearia-keyshortcuts

자릿수가 바뀌어도 안 흔들리는 표시줄

.sw__time {
  margin: 0;
  font-weight: 800; font-size: clamp(28px, 9vw, 44px); line-height: 1;
  font-variant-numeric: tabular-nums;
  color: $subject-ink;
  letter-spacing: -.02em;
}
.sw__sep { color: rgba($subject-ink, .72); }
.sw__cs { position: relative; display: inline-block; }

font-variant-numeric: tabular-nums 가 숫자 폭을 하나로 묶어 두어 09 에서 10 으로 넘어가는 순간에도 초 자리가 좌우로 밀리지 않습니다.

시계는 한 줄기, 랩은 한 줄만

  function tick() {
    if (!running) { return; }
    elapsed = base + (Date.now() - startAt);
    paint();
    window.requestAnimationFrame(tick);
  }
  function toggle() {
    root.classList.remove('is-demo');
    running = !running;
    if (running) { base = elapsed; startAt = Date.now(); window.requestAnimationFrame(tick); } else { save(); }
    paint();
  }
  function lap() {
    root.classList.remove('is-demo');
    count++;
    laps.unshift({ n: count, at: fmt(elapsed) });
    laps = laps.slice(0, MAX);
    listEl.insertBefore(row(laps[0], true), listEl.firstChild);
    while (listEl.children.length > MAX) { listEl.removeChild(listEl.lastChild); }
    save();
  }

requestAnimationFrame 이 화면 새로고침에 맞춰 경과 시간을 다시 찍고, 랩은 목록을 통째로 그리지 않고 맨 앞에 한 줄을 끼워 넣어 새로 들어온 줄만 미끄러집니다.

07OX 퀴즈 패널

문장 하나에 O·X 로 답하면 맞은 쪽은 파랑, 틀린 쪽은 빨강으로 켜지는 패널로, 라이브 세션 퀴즈나 강의 슬라이드에 끼웁니다.

OX 퀴즈 패널
새 창에서 열기

07 · OX 퀴즈 패널 — 측정 누적면적 14.505% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-pressedtransitioncounter-increment

오답일 때만 정답 쪽에 힌트 테두리

  function pick(v) {
    if (lock) { return; }
    root.classList.remove('is-demo');
    if (step >= QUESTIONS.length) { reset(); return; }
    lock = true;
    var ok = QUESTIONS[step].a === v;
    var picked = v === 'O' ? oBtn : xBtn;
    var right = QUESTIONS[step].a === 'O' ? oBtn : xBtn;
    picked.setAttribute('aria-pressed', 'true');
    picked.classList.add(ok ? 'is-hit' : 'is-miss');
    if (!ok) { right.classList.add('is-hint'); }
    if (ok) { score++; scoreEl.textContent = String(score); }
    dots[step].classList.add(ok ? 'is-hit' : 'is-miss');
    msgEl.textContent = ok ? HIT : MISS;

누른 버튼에 aria-pressed 를 켜고 맞고 틀림에 따라 면색을 바꾸되 틀렸을 때만 정답 버튼에 안쪽 테두리를 얹는데, 색이 컷으로 갈리는 항목이라 움직임은 견줄 칸 23개 중 15개에서만 잡혔습니다.

진행 점의 번호는 CSS 가 센다

.ox__dots {
  display: flex; justify-content: center; gap: $sp-2;
  margin: 0; padding: 0; list-style: none;
  counter-reset: oxStep;
}
.ox__dot {
  counter-increment: oxStep;
  display: grid; place-items: center;
  width: clamp(16px, 5vw, 22px); aspect-ratio: 1;
  border-radius: 50%;
  background-color: $subject-cream; color: $subject-ink;
  font-weight: 700; font-size: clamp(8px, 2.2vw, 10px); line-height: 1;
}
.ox__dot::before { content: counter(oxStep); }
.ox__dot.is-now { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ox__dot.is-hit { background-color: $color; color: $subject-cream; }
.ox__dot.is-miss { background-color: $error; color: $subject-cream; }

counter-reset 으로 시작한 카운터를 점마다 하나씩 올려 1부터 5까지 매기므로 JS 는 색만 바꾸고 숫자에는 손대지 않습니다.

08룰렛 뽑기

여섯 칸짜리 룰렛이 길게 감속하며 돌아 화살표가 멈춘 칸을 뽑는 판으로, 경품 추첨이나 발표 순서를 정할 때 돌립니다.

룰렛 뽑기
새 창에서 열기

08 · 룰렛 뽑기 — 측정 누적면적 21.038% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

conic-gradienttransformtransition-timing-function

원판 한 장은 conic-gradient 하나

.rl__disc {
  // 라벨 자리 상자는 원판 크기 그대로를 칸 각도로 돌린 것이라 회전 경계 상자가 원판보다 크다 —
  // 그 넘침이 body.scrollHeight 로 전파돼 320px BD9 가 깨졌다(207px 실측). 원 안으로 잘라 막는다
  position: absolute; inset: 0; border-radius: 50%; overflow: hidden;
  box-shadow: 0 0 0 4px $subject-ink;
  // 6칸 + 칸 경계 1deg 잉크 선. 하드 스톱이라 색이 섞이지 않는다
  background-image: conic-gradient(
    $subject-ink 0deg 1deg,     $subject-blue-deep 1deg 60deg,
    $subject-ink 60deg 61deg,   $stage-yellow 61deg 120deg,
    $subject-ink 120deg 121deg, $stage-orange 121deg 180deg,
    $subject-ink 180deg 181deg, $subject-lilac 181deg 240deg,
    $subject-ink 240deg 241deg, $subject-cream 241deg 300deg,
    $subject-ink 300deg 301deg, $subject-ink 301deg 360deg
  );
  transform: rotate(var(--spin));
  transition-property: transform;
  transition-duration: 2400ms;
  transition-timing-function: $spin-ease;
}

칸 색과 1도짜리 경계선을 conic-gradient 의 하드 스톱으로 같이 그리고, 회전의 긴 여운은 transition-timing-function 에 넣은 전용 곡선이 냅니다.

멈출 칸을 먼저 정하고 각도를 만든다

  function spin() {
    if (busy) { return; }
    root.classList.remove('is-demo');
    busy = true;
    btn.disabled = true;
    out.textContent = SPINNING;
    var turns = 5 + Math.floor(Math.random() * 4);                       // 5~8 바퀴
    var idx = Math.floor(Math.random() * PRIZES.length);                 // 멈출 칸
    var target = (360 - (idx * SLICE + SLICE / 2) + 360) % 360;          // 그 칸의 한가운데가 위로 오는 각도
    angle += turns * 360 + ((target - (angle % 360)) + 360) % 360;
    root.style.setProperty('--spin', angle + 'deg');
  }

칸을 먼저 뽑은 뒤 그 칸 한가운데가 12시로 오는 각도를 계산해 다섯에서 여덟 바퀴를 얹으므로, 결과는 돌리기 전에 정해지고 화면은 그 값을 따라갈 뿐입니다.

09랜덤 슬롯 뽑기

이름 줄이 빠르게 흘러가다 멈춘 가운데 칸을 당첨으로 읽는 뽑기판으로, 댓글 이벤트 당첨자나 발표자를 정할 때 씁니다.

랜덤 슬롯 뽑기
새 창에서 열기

09 · 랜덤 슬롯 뽑기 — 측정 누적면적 2.549% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

animation-timing-functionstepsaria-live

창 높이는 칸 높이의 정수배

.sl__window {
  position: relative; flex: 0 0 auto;
  width: clamp(104px, 34vw, 160px); height: calc(var(--row) * 3);   // 창 높이는 칸 높이의 정수배
  overflow: hidden; border-radius: $r-card;
  background-color: $subject-cream;
}
// 실제 뽑기(감속 중)에는 칸이 창 경계에 걸린다 — 위아래를 크림으로 흐려 글자가 잘린 채 끝나지 않게
.sl__window::before,
.sl__window::after {
  content: ""; position: absolute; left: 0; right: 0; z-index: 1;
  height: 14%; pointer-events: none;
}
.sl__window::before { top: 0; background-image: linear-gradient($subject-cream, rgba($subject-cream, 0)); }
.sl__window::after { bottom: 0; background-image: linear-gradient(rgba($subject-cream, 0), $subject-cream); }

칸 높이 --row 하나가 창 높이와 강조 밴드와 이동 거리를 전부 정하고, 위아래 그라디언트가 경계에 걸친 글자를 덮어 잘린 이름이 보이지 않게 합니다.

멈춘 자리는 나머지로 되감는다

  track.addEventListener('transitionend', function (e) {
    if (e.propertyName !== 'transform') { return; }
    offset = offset % list.length;
    winner = list[(offset + 1) % list.length];
    track.style.transitionProperty = 'none';
    put(offset);
    void track.offsetHeight;                    // 리플로를 한 번 강제해 되돌림이 애니메이션되지 않게
    track.style.transitionProperty = 'transform';
    banner.textContent = WIN.replace('{n}', winner);
    banner.classList.add('is-win');
    busy = false;
    goBtn.disabled = false;
  });

멈춘 뒤 위치를 후보 수로 나눈 나머지로 되감고 그 사이에 리플로를 한 번 강제해, 트랙이 끝으로 밀려나지도 되감김이 눈에 보이지도 않게 했습니다.

html 게임 코드를 복붙할 때 무엇을 바꿔야 하나?

받은 파일은 열면 바로 도는 상태지만 내 페이지에 얹을 때 손댈 자리는 세 곳으로 정해져 있습니다. 첫째는 데이터로, 01 의 문제 다섯 개와 07 의 문장 다섯 줄, 09 의 이름 여덟 개가 파일 맨 위 상수에 그대로 있어 그 배열만 갈아 끼우면 되고, 출제는 배열 순서를 그대로 따르므로 01 의 「새 문제」 버튼도 같은 차례를 다시 돕니다. 둘째는 저장 키인데 03·06·09 가 각각 pomodoro080·p080-06-stopwatch·p080-09 를 쓰므로, 한 페이지에 둘 이상을 올릴 때는 키 이름을 갈라 두어야 서로의 기록을 덮어쓰지 않습니다. 셋째는 is-demo 클래스로, 이것은 격자에서 보여 주려고 얹은 미리보기 루프라 사람이 처음 누르는 순간 JS 가 떼어 내고 실제 화면에 붙일 때는 처음부터 빼도 됩니다.

어디서 깨지는가 — 함정

아홉 개는 격자에서 CSS 시계로 돌고 사람 손에서는 JS 경로로 도는데, 이 편에서 실제로 막아선 것은 그 둘이 아니라 회전과 정수였습니다.

돌린 글자는 거꾸로 서고 상자는 원판보다 커진다

08 은 처음에 라벨을 칸 각도로 통째 돌렸습니다. 위쪽 칸은 읽혔지만 아래쪽 칸의 글자가 거꾸로 서서 목록 썸네일에서 읽히지 않았습니다. 라벨 위치를 잡는 상자와 글자 상자를 갈라 놓고 글자 쪽에 rotate(calc(var(--a) * -1 - var(--spin))) 을 걸어 어느 각도에서도 수평이 되게 고쳤습니다. 같은 항목에서 회전한 라벨 상자의 경계가 원판보다 커지자 320px 폭에서 body.scrollHeight 가 207px 넘쳤는데, 요소의 rect 검사로는 잡히지 않아 원판에 overflow: hidden 을 넣어 원 안으로 잘라 막았습니다.

정수로 안 맞는 릴은 이름을 자른다

09 의 릴은 창 높이와 칸 높이가 정수로 떨어지지 않아, 멈춘 프레임마다 이름이 위아래로 잘린 채 찍혔습니다. 칸 수만큼 끊어 올리는 steps(var(--rows), end) 로 바꾸자 모든 프레임에서 칸이 창에 딱 맞았고, 아홉 개가 든 압축 파일을 여는 비밀번호는 eu89k2wk 입니다. 이 항목의 누적면적이 2.55% 로 아홉 개 중 가장 작은 것도 릴이 컷으로 끊어 돌기 때문입니다.

같은 줄에서 자리를 바꾸면 카드가 포개진다

04 는 카드가 이동하는 동안 서로 포개져 대표 이미지에서 한 장이 통째로 가려졌습니다. 줄 전체를 한 칸씩 미는 회전으로 바꾸고 맨 끝 한 장만 줄 위로 떠서 앞자리로 건너가게 하니, 어느 프레임에서도 여섯 장이 다 보입니다. 카드 여섯이 함께 움직이는 항목이라 누적면적 38.40% 로 아홉 개 중 가장 크게 나옵니다.

변형 세 가지와 접근성

값 세 개만 건드리면 아홉 개의 성격이 한꺼번에 바뀝니다.

이름 바꾼 값 느낌
duration $dur-loop 2s → 1s 셔플·룰렛·슬롯이 두 배로 빨라진다
easing $ease-out → $ease-pop 카드와 칩이 한 번 더 튄다
color $subject-blue → $stage-yellow 파란 주인공이 노랑으로 바뀌어 밝은 무대가 된다

조작은 마우스보다 키보드가 먼저입니다. 01 은 숫자 1~4, 02 는 숫자와 연산자에 Enter·Backspace·Escape, 04 는 S 와 R, 06 은 스페이스와 L·R 이 버튼과 같은 일을 하고 어느 키가 듣는지는 aria-keyshortcuts 로 버튼에 적혀 있으며, 07 은 O 와 X 키를 그대로 받습니다. 키캡과 도움말 시트를 화면에 세우는 방법은 키보드 단축키 UI 9종에 따로 모아 두었습니다. 점수와 결과 문장은 aria-live 와 role="status" 로 읽히고, 03 의 원판은 role="progressbar" 의 aria-valuenow 에 남은 비율을 숫자로 내보냅니다. prefers-reduced-motion: reduce 에서는 아홉 개 모두 격자 미리보기 루프가 멈추고 카드·릴·룰렛 원판의 미끄러짐이 즉시 끝나 결과 화면만 남으며, 03 의 원판은 아예 깎이지 않고 남은 시간이 숫자로만 줄어듭니다. 글자 대비는 아홉 화면의 52곳을 렌더 픽셀로 재서 AA 미달 0곳을 확인했습니다.

FAQ

html 게임 만들기에 캔버스가 꼭 필요한가요

아홉 개 전부 DOM 요소와 CSS 로만 그려서 <canvas> 를 한 번도 쓰지 않았습니다. 캔버스는 픽셀을 매 프레임 다시 칠해야 하는 화면에서 제값을 하고, 칸과 카드와 원판처럼 개수가 정해진 화면은 DOM 쪽이 접근성과 복사 붙여넣기에서 유리합니다.

티스토리나 워드프레스 글에 그대로 넣어도 되나요

한 항목의 html·css·js 를 그대로 넣으면 대체로 돌지만, 편집기가 <style> 이나 <script> 를 지우는 곳이 있어 iframe 으로 감싸는 편이 안전합니다. 클래스 이름은 항목마다 두 글자 접두를 달아 두어서 블로그 스킨의 CSS 와 부딪히지 않습니다.

저장된 시간이나 후보를 지우려면 어떻게 하나요

03·06·09 는 값을 브라우저의 localStorage 에 넣기 때문에 해당 키를 지우면 처음 상태로 돌아갑니다. 09 는 화면 안의 후보 되돌리기 버튼이 같은 일을 하고, 06 은 초기화 버튼이 기록과 랩을 함께 비웁니다.

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

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