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

CSS 게임 만들기 — 미니게임 9종 복붙 코드

CSS 게임은 브라우저가 원래 갖고 있는 선택자·키프레임·transform 만으로 규칙과 화면을 함께 굴리는 놀이판입니다. 게임엔진 없이 HTML·CSS·바닐라 JS 로 아홉 개를 짰고, 아래 격자에서 지금 돌아갑니다.

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

순서는 인기순이 아니라 판정을 누가 쥐고 있느냐의 순서입니다. 앞의 셋은 판정이 거의 다 CSS 쪽에 있고(01 선택자 연쇄, 02 뒤집기 전환, 03 솟기 전환), 가운데 셋은 좌표와 포커스를 JS 가 읽어 CSS 가 그리며(04 커서 추적, 05 애니메이션 시계, 06 포커스 이동), 뒤의 셋은 프레임과 시간을 JS 가 직접 셉니다(07 낙하 회피, 08 왕복 정지, 09 반응 시간). 그래서 01 은 형제 결합자 하나로 순서를 만들기 때문에 css 선택자 게임을 찾다가 들어온 사람이 제일 먼저 열어 볼 항목이고, 09 로 갈수록 밀리초 단위 계산이 늘어납니다. 뒤집는 면 자체를 더 보고 싶다면 CSS 카드 뒤집기 애니메이션 9종, 커서를 따라오는 연출은 마우스를 따라오는 CSS 3D 회전 장면 9종, 키 입력 화면 부품은 키보드 단축키 UI 9종이 이웃입니다.

01불 켜지는 패드 누르기

지금 불이 켜진 패드 하나만 눌리고, 누를 때마다 다음 패드로 불이 옮겨 가는, 로딩 대신 30초를 때우거나 404 페이지에 볼거리로 얹을 놀이판입니다.

불 켜지는 패드 누르기
새 창에서 열기

01 · 불 켜지는 패드 누르기 — 측정 누적면적 47.79% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

:checkedcounter-incrementcontent

형제 결합자로 만든 순서 잠금

// 꺼진(아직 못 누른) 패드는 pointer-events:none 이라 클릭이 닿지 않고, 켜진 패드만 auto 로 살아난다
#rpP1:not(:checked) ~ .rp__grid .rp__pad:nth-child(1),
#rpP1:checked ~ .rp__grid .rp__pad:nth-child(2),
#rpP2:checked ~ .rp__grid .rp__pad:nth-child(3),
#rpP3:checked ~ .rp__grid .rp__pad:nth-child(4),
#rpP4:checked ~ .rp__grid .rp__pad:nth-child(5),
#rpP5:checked ~ .rp__grid .rp__pad:nth-child(6),
#rpP6:checked ~ .rp__grid .rp__pad:nth-child(7),
#rpP7:checked ~ .rp__grid .rp__pad:nth-child(8),
#rpP8:checked ~ .rp__grid .rp__pad:nth-child(9) {
  pointer-events: auto; cursor: pointer;
  background-color: $subject-cream; border-color: $subject-cream;
  box-shadow: 0 0 0 4px rgba($subject-cream, .16), 0 0 22px rgba($subject-cream, .3);
  .rp__no { color: $subject-ink; }
}

앞 번호가 :checked 가 되어야 다음 번호에 불이 들어오므로 순서를 어기는 클릭은 규칙이 아니라 구조로 막히고, MDN :checked 문서가 말하는 그 의사 클래스 하나가 판정 전체를 맡습니다.

점수는 CSS 카운터가 센다

.rp {
  display: flex; flex-direction: column; gap: $sp-2;
  width: 100%; max-width: 340px; height: 100%;
  counter-reset: rpTaps;
}
// 체크박스는 화면에 그리지 않지만 카운터와 키보드 초점을 위해서 상자를 유지한다(display:none 은 카운터를 끊는다)
.rp__check { position: absolute; width: 1px; height: 1px; margin: -1px; opacity: 0; pointer-events: none; }
.rp__check:checked { counter-increment: rpTaps; }
.rp__count::after { content: counter(rpTaps) " / 9"; }
// 완료 — #rpP9 가 체크됐다면 1부터 8까지 전부 지나온 상태다
.rp__done { position: absolute; inset: 0; z-index: 3; display: none; place-items: center; margin: 0; }
#rpP9:checked ~ .rp__done { display: grid; }

점수를 세는 JS 는 한 줄도 없고 애니메이션 36개가 얹힌 이 판의 누적면적은 47.79%, 움직인 프레임은 23/23 으로 아홉 중 가장 촘촘하게 움직입니다.

02짝 맞추기 카드

뒤집은 두 장이 같으면 그대로 남고 다르면 도로 덮이는, 심심풀이로도 쓰고 카드 앞면에 문제를 넣으면 학습 카드로도 쓰는 판입니다.

짝 맞추기 카드
새 창에서 열기

02 · 짝 맞추기 카드 — 측정 누적면적 1.367% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

rotateY(preserve-3dbackface-visibility

앞뒤 두 면을 한 상자에 겹쳐 두기

.mm__inner {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
  transition: transform $dur-base $easing;
}
.mm__card.is-open .mm__inner,
.mm__card.is-matched .mm__inner { transform: rotateY(180deg); }

.mm__face {
  position: absolute; inset: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  display: grid; place-items: center;
  border-radius: $r-sm;
}

앞뒷면을 같은 자리에 포개고 backface-visibility: hidden 으로 등을 돌린 면을 지운 뒤 상자만 180도 돌리므로, 뒤집기에 필요한 JS 는 클래스 한 개를 붙였다 떼는 일뿐입니다.

같으면 고정 다르면 700ms 뒤 복귀

if (a.getAttribute('data-face') === btn.getAttribute('data-face')) {
  setTimeout(function () {
    a.classList.add('is-matched'); btn.classList.add('is-matched');
    a.disabled = true; btn.disabled = true;
    left--; pairsEl.textContent = String(left);
    lock = false;
    if (left === 0) { finish(); }
  }, 350);
} else {
  setTimeout(function () {
    a.classList.remove('is-open'); btn.classList.remove('is-open');
    a.setAttribute('aria-pressed', 'false'); btn.setAttribute('aria-pressed', 'false');
    lock = false;
  }, 700);
}

두 장이 열려 있는 동안 lock 이 세 번째 클릭을 막아 주고, 뒤집기가 컷 연출이라 이 판의 누적면적은 1.367%로 아홉 중 가장 낮습니다.

03두더지 잡기

3×3 구멍에서 두더지가 무작위로 솟고, 솟아 있는 동안 눌러야 점수가 오르는, 이벤트 페이지의 경품 룰렛 대용이나 아이 눈길을 끄는 섹션에 맞는 판입니다.

두더지 잡기
새 창에서 열기

03 · 두더지 잡기 — 측정 누적면적 33.16% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

@keyframestranslateYpointerdown

솟고 들어가는 건 transition 한 줄

// 두더지 몸통은 CSS(알약 반쪽)이고 얼굴만 Phosphor Duotone 아이콘이다
.wg__mole {
  position: absolute; left: 16%; right: 16%; bottom: 0; height: 76%;
  display: grid; place-items: center;
  border-radius: $r-pill $r-pill 0 0;
  background-color: $color; color: $subject-ink;
  transform: translateY(100%);
  transition: transform $dur-quick $easing;
}
.wg__mole svg { width: 66%; height: 66%; }
.wg__hole.is-up .wg__mole { transform: translateY(0); }
@keyframes wgMoleRise {
  0%   { transform: translateY(100%); }
  8%   { transform: translateY(0); }
  20%  { transform: translateY(0); }
  30%  { transform: translateY(100%); }
  100% { transform: translateY(100%); }
}

실제 게임은 .is-up 을 붙였다 떼면 transition 이 그려 주고, 격자 미리보기만 같은 움직임을 @keyframes 로 다시 만드는데, 구멍마다 음수 지연을 달아 위상을 어긋나게 했기 때문에 애니메이션 18개가 한 바퀴에서 매끄럽게 이어집니다.

같은 구멍이 연달아 나오지 않게

function spawn() {
  if (!playing) { return; }
  sink();
  var i = lastHole;
  while (i === lastHole) { i = Math.floor(Math.random() * holes.length); }   // 같은 구멍 연속 스폰은 피한다
  lastHole = i; upIndex = i;
  holes[i].classList.add('is-up');
  downId = setTimeout(sink, UP_MS);
  spawnId = setTimeout(spawn, 700 + Math.random() * 300);
}

한 번 솟으면 850ms 뒤 스스로 들어가고 다음 스폰은 700~1000ms 뒤라 화면에 두더지가 겹쳐 서는 일이 없으며, 누적면적 33.16%에 강도 평균 129.4 로 세 번째로 요란한 판이 됩니다.

04시선 피하기

화면의 눈이 커서를 계속 쫓아오고, 겹친 채로 버티면 벌점이 쌓이는, 포인터 이벤트를 가르치거나 커서 인터랙션 포트폴리오에 넣기 좋은 판입니다.

시선 피하기
새 창에서 열기

04 · 시선 피하기 — 측정 누적면적 27.768% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermovegetBoundingClientRecttranslate3d

좌표는 놀이판 왼쪽 위를 0으로

function arenaRect() { return arena.getBoundingClientRect(); }
function placeTarget(x, y) {
  var r = arenaRect();
  targetPos.x = Math.max(8, Math.min(r.width - 8, x));
  targetPos.y = Math.max(8, Math.min(r.height - 8, y));
}
function centerOf(el) {
  var r = el.getBoundingClientRect();
  var a = arenaRect();
  return { x: r.left - a.left + r.width / 2, y: r.top - a.top + r.height / 2 };
}
function paint() {
  eyeWrap.style.transform = 'translate3d(' + (eyePos.x - eyeWrap.offsetWidth / 2) + 'px,' + (eyePos.y - eyeWrap.offsetHeight / 2) + 'px,0)';
  targetWrap.style.transform = 'translate3d(' + (targetPos.x - targetWrap.offsetWidth / 2) + 'px,' + (targetPos.y - targetWrap.offsetHeight / 2) + 'px,0)';
}

getBoundingClientRect 로 창 기준 좌표를 놀이판 기준으로 한 번 옮겨 두면 그 뒤로는 translate3d 두 줄이 전부라 left·top 을 건드릴 일이 없습니다.

매 프레임 5.5%씩 다가오는 눈

function tick(now) {
  var dt = now - lastNow;
  lastNow = now;
  var demo = root.classList.contains('is-demo');
  if (!demo) {
    // lerp 추적 — 눈은 표적을 향해 매 프레임 조금씩 다가온다
    eyePos.x += (targetPos.x - eyePos.x) * LERP;
    eyePos.y += (targetPos.y - eyePos.y) * LERP;
    paint();
    var ex = eyePos.x, ey = eyePos.y;
    var near = Math.hypot(targetPos.x - ex, targetPos.y - ey) < DANGER_DIST;
    root.classList.toggle('is-near', near);
    if (running) {
      var sec = (now - startT) / 1000;
      scoreNum.textContent = fmtTime(sec);
      if (near) {
        dangerAcc += dt;
        if (dangerAcc >= DANGER_MS) { dangerAcc = 0; addPenalty(); }
      } else {
        dangerAcc = Math.max(0, dangerAcc - dt * 2);
      }
    }
  }
  window.requestAnimationFrame(tick);
}

남은 거리의 LERP 배만큼만 좁히니 눈은 절대 따라잡지 못하면서도 계속 가까워지고, 60px 안에 450ms 동안 머물러야 벌점 하나가 붙고, 벗어나면 두 배 속도로 게이지가 빠집니다.

05리듬 탭

세 줄로 떨어지는 노트가 판정선을 지나는 순간 J·K·L 을 눌러 맞히는, 키보드 연습이나 대기 화면의 손가락 놀이로 쓰는 판입니다.

리듬 탭
새 창에서 열기

05 · 리듬 탭 — 측정 누적면적 22.402% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

animation-play-stategetAnimationstranslateY

판정 기준은 애니메이션 자신의 시계

// 곡 시작부터 흐른 시각을 그 노트의 낙하 시간으로 환산한 벽시계 값
function wallNow(note) {
  return (performance.now() - st.t0) - Number(note.getAttribute('data-due')) + st.fallMs;
}
// 판정에 쓰는 시각 — 노트가 실제로 그려진 위치가 곧 판정 기준이므로 낙하 애니메이션의
// 시계(currentTime 에서 delay 를 뺀 값 = 낙하가 시작된 뒤 흐른 시간)를 읽는다.
function noteNow(note) {
  if (REDUCE) { return wallNow(note); }
  var a = fallAnim(note);
  if (a && a.effect && a.playState !== 'finished') {
    var ct = a.effect.getComputedTiming();
    var delay = a.effect.getTiming().delay || 0;
    if (ct.localTime != null) { return ct.localTime - delay; }
  }
  return wallNow(note);
}

벽시계로 채점하면 탭이 느려진 브라우저에서 눈에 보이는 위치와 판정이 어긋나므로, getAnimations 로 잡은 낙하 애니메이션의 localTime 을 기준으로 삼아 그려진 자리가 곧 점수가 되게 했습니다.

맞힌 노트는 그 자리에 멈춘다

.rtl__note {
  animation-name: rtlFall; animation-duration: $duration; animation-timing-function: linear;
  animation-iteration-count: 1; animation-fill-mode: both;
}
// 명중 — 낙하를 그 자리에 멈추고(판정 순간의 위치) 알맹이가 자기 중심에서 튀어오른다
.rtl__note.is-hit { animation-play-state: paused; }
.rtl__note.is-hit::before {
  animation-name: rtlPop; animation-duration: 240ms; animation-timing-function: $easing;
  animation-iteration-count: 1; animation-fill-mode: forwards;
}
@keyframes rtlFall {
  from { transform: translateY(-16px); }
  to   { transform: translateY(var(--fall-h)); }
}

MDN animation-play-state 문서대로 paused 는 진행 상태를 버리지 않고 그대로 얼리므로 노트는 맞은 높이에 박히고, 판정 창은 115ms 안이면 퍼펙트, 240ms 안이면 좋음으로 갈립니다.

06포커스 미로 탈출

방향키로 칸을 옮겨 다니며 출구를 찾는 미로인데 벽은 애초에 포커스를 못 받아 막히는, 키보드 접근성을 보여 주거나 탭 이동을 가르치는 온보딩에 쓰는 판입니다.

포커스 미로 탈출
새 창에서 열기

06 · 포커스 미로 탈출 — 측정 누적면적 7.31% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

keydown:focus-visiblegrid-template-areas

미로는 문자 다섯 줄

// 7×5 미로 — . 은 통로, # 은 벽, S 는 출발, G 는 출구.
// grid-template-areas 문자와 같은 배치다 (아래 style.scss 의 격자 이름이 이 표를 따른다).
var MAZE = [
  'S.#...G',
  '.#..#.#',
  '...#..#',
  '#.##..#',
  '......#'
];
var COLS = 7, ROWS = 5;
function at(c, r) { return (MAZE[r] || '')[c] || '#'; }
function isOpen(c, r) { return c >= 0 && c < COLS && r >= 0 && r < ROWS && at(c, r) !== '#'; }

통로는 button 으로, 벽은 div 로 만들기 때문에 "막힌다" 는 규칙을 따로 쓸 필요가 없고, 미로를 바꾸고 싶으면 이 문자 다섯 줄만 고치면 됩니다.

캐릭터를 그리는 건 :focus 선택자

.fme__maze {
  display: grid; gap: $sp-1; justify-content: center;
  grid-template-columns: repeat(7, clamp(22px, 6.4vw, 32px));
  grid-template-areas:
    "c0r0 c1r0 c2r0 c3r0 c4r0 c5r0 c6r0"
    "c0r1 c1r1 c2r1 c3r1 c4r1 c5r1 c6r1"
    "c0r2 c1r2 c2r2 c3r2 c4r2 c5r2 c6r2"
    "c0r3 c1r3 c2r3 c3r3 c4r3 c5r3 c6r3"
    "c0r4 c1r4 c2r4 c3r4 c4r4 c5r4 c6r4";
  border-radius: $r-card; padding: $sp-1; background: rgba($subject-ink, .12);
  justify-self: center;
}
// 캐릭터 — 포커스를 받은 칸에서만 보인다. 벽은 button 이 아니라 포커스를 못 받으므로 갈 수 없다
.fme__char { width: 68%; height: 68%; opacity: 0; transform: scale(.6); color: $color; }
.fme__cell:focus .fme__char {
  opacity: 1; transform: scale(1);
  transition: opacity $duration $easing, transform $duration $easing;
}
.fme__cell:focus-visible { outline: 2px solid $subject-ink; outline-offset: -2px; }

칸마다 숨어 있던 캐릭터가 포커스를 받은 칸에서만 나타나므로 JS 가 옮기는 것은 포커스 하나뿐이고, :focus-visible 을 따로 둔 덕에 테두리는 키보드로 짚었을 때만 섭니다.

07떨어지는 것 피하기

바닥의 로켓을 좌우로 몰아 떨어지는 장애물을 피하고 세 번 맞으면 끝나는, 이벤트 랜딩의 놀이 코너나 반응 속도를 내세우는 UI 에 맞는 판입니다.

떨어지는 것 피하기
새 창에서 열기

07 · 떨어지는 것 피하기 — 측정 누적면적 14.249% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

keydowntranslateXrequestAnimationFrame

이동은 프레임마다 낙하는 CSS 가

function tick(now) {
  if (!running) { return; }
  var dt = Math.min(64, now - lastT) / 1000;
  lastT = now;
  var dir = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
  if (dir !== 0) {
    pointerX = null;
    px += dir * PLAYER_SPEED * dt;
  } else if (pointerX !== null) {
    var d = pointerX - px;
    var step = PLAYER_SPEED * dt;
    px += Math.abs(d) <= step ? d : Math.sign(d) * step;
  }
  placePlayer();
  if (now >= invulnUntil) {
    var pr = player.getBoundingClientRect();
    var hit = { left: pr.left + 5, right: pr.right - 5, top: pr.top + 6, bottom: pr.bottom - 4 };
    for (var i = rocks.length - 1; i >= 0; i--) {
      if (rectsOverlap(hit, rocks[i].getBoundingClientRect())) { hitRock(rocks[i]); break; }
    }
  }
  rafId = requestAnimationFrame(tick);
}

로켓만 requestAnimationFrame 으로 옮기고 장애물 낙하는 @keyframes dfFall 에 맡겨 두면 돌 열 개가 동시에 떨어져도 JS 가 세는 것은 로켓 좌표 하나뿐이고, 한 프레임이 64ms 를 넘으면 그 값으로 잘라 탭으로 돌아왔을 때 로켓이 순간이동하지 않습니다.

히트박스는 보이는 상자보다 작게

function rectsOverlap(a, b) {
  return a.left < b.right - 2 && a.right > b.left + 2 && a.top < b.bottom - 4 && a.bottom > b.top + 4;
}
function hitRock(rock) {
  rock.remove();
  var i = rocks.indexOf(rock);
  if (i >= 0) { rocks.splice(i, 1); }
  lives -= 1;
  paintLives();
  invulnUntil = performance.now() + 900;
  player.classList.remove('is-hit');
  void player.offsetWidth; // 피격 깜빡임 애니메이션을 처음부터 다시 트는 재정렬 트릭
  player.classList.add('is-hit');
  if (lives <= 0) { gameOver(); }
}

사각형 네 변을 2~6px 안쪽으로 당겨 두어야 "스친 것 같은데 죽었다" 는 억울함이 사라지고, 맞은 뒤 900ms 무적이 붙어 같은 돌에 두 번 깎이지 않습니다.

08탑 쌓기 타이밍

좌우로 왕복하는 블록을 멈춰 쌓되 삐져나온 만큼 잘려 나가는, 타이밍으로 미는 프로모션이나 대기 화면 경쟁 점수판에 쓰는 판입니다.

탑 쌓기 타이밍
새 창에서 열기

08 · 탑 쌓기 타이밍 — 측정 누적면적 5.453% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramescaleXtransition

폭을 바꾸지 않고 폭을 줄이는 법

.st__tower { position: absolute; left: 50%; bottom: 12px; will-change: transform; }
// 칸·움직이는 블록·잘린 조각은 같은 규격: left -64px (기준 블록 128px 를 탑 중심에 정렬) +
// translateX(--lx) + scaleX(--sx). lx 는 기준 블록 왼쪽을 0 으로 둔 오프셋(JS BASE_W·HALF 참조).
.st__row, .st__mover, .st__chip {
  position: absolute; left: -64px; bottom: var(--by, 0px);
  width: 128px; height: 18px;
  border-radius: $r-xs;
  transform: translateX(var(--lx, 0px)) scaleX(var(--sx, 1));
  transform-origin: left center; // 왼쪽 변을 기준으로 폭이 줄어든다 — 쌓임 정렬의 기준 변
  will-change: transform;
}

폭을 width 로 줄이면 단이 쌓일 때마다 배치가 다시 계산되지만 scaleX 는 그리기만 바꾸므로, 아홉 개 중 애니메이션 수가 2개로 가장 적은데도 움직인 프레임은 13/23 이 나옵니다.

겹친 만큼만 남기는 세 값

function setGeom(el, lx, w, by) {
  el.style.setProperty('--lx', lx + 'px');
  el.style.setProperty('--sx', String(w / BASE_W));
  el.style.setProperty('--by', by + 'px');
}
function paintMover() { setGeom(moverEl, mover.lx, mover.w, rows.length * STRIDE); }
function addRowEl(row) {
  var el = document.createElement('div');
  el.className = 'st__row is-new';
  setGeom(el, row.lx, row.w, (rows.length - 1) * STRIDE);
  el.addEventListener('animationend', function () { el.classList.remove('is-new'); });
  tower.appendChild(el);
}

가로 위치·가로 배율·바닥 높이 세 개만 사용자 정의 속성으로 넘기면 새 단도 잘린 조각도 같은 규칙으로 그려지고, 위아래 두 단의 변이 4px 안쪽으로 맞으면 폭이 기준값 128px 로 되살아나는 보너스가 붙습니다.

09반응속도 재기

신호가 켜지는 순간 최대한 빨리 눌러 걸린 시간을 재는, 팀 페이지의 놀이나 반응속도 벤치마크 소재로 쓰는 판입니다.

반응속도 재기
새 창에서 열기

09 · 반응속도 재기 — 측정 누적면적 48.163% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

performance.nowtransitionclassList

대기와 신호 사이를 가르는 상태기계

function arm() {
  root.classList.remove('is-demo'); // 격자 미리보기 연출을 떼고 실제 게임으로
  waitTitleEl.textContent = ARM_TITLE;
  waitSubEl.textContent = ARM_SUB;
  setState('armed');
  clearTimeout(armTimer);
  armTimer = setTimeout(fire, WAIT_MIN + Math.random() * WAIT_RANGE);
}
function fire() {
  signalAt = performance.now();
  setState('signal');
}
function act() {
  if (state === 'armed') { penalize(); return; }
  if (state === 'signal') { settle(); return; }
  arm(); // idle · result · penalty 에서는 다음 판 시작
}

대기 시간이 1.5~4초 사이 난수라 외워서 누를 수 없고, 같은 입력이 상태에 따라 벌점·측정·다음 판 시작으로 갈리므로 버튼을 따로 만들 필요가 없습니다.

네 얼굴을 같은 자리에 겹쳐 두기

.rt__panel {
  position: relative; overflow: hidden;
  height: clamp(104px, 52vh, 180px);
  border: 2px solid rgba($subject-cream, .16);
  border-radius: $r-card;
  background: $tick-panel;
  // 상태별 배경 전환 — 대비가 큰 색끼리 transition 하나로 잇는다(신호만이 연출이다)
  transition: background-color $dur-quick $easing;
  touch-action: manipulation;
  cursor: pointer;
}
.rt.is-signal .rt__panel { background: $color; }
.rt.is-penalty .rt__panel { background: $error-soft; }
.rt.is-idle .rt__face--wait,
.rt.is-armed .rt__face--wait,
.rt.is-signal .rt__face--go,
.rt.is-result .rt__face--result,
.rt.is-penalty .rt__face--penalty { opacity: 1; }

패널 전체가 한 번에 색을 갈아입는 판이라 면적 최대가 48.045%로 아홉 중 가장 크고, 대신 움직이는 순간이 신호 한 번뿐이라 움직인 프레임은 11/23 로 가장 적습니다.

css 연습 게임으로 쓰려면 무엇을 고정해야 하나?

아홉 개는 값 세 개만 공유하고 나머지는 항목마다 따로 두었기 때문에, 손댈 자리와 건드리면 안 되는 자리가 뚜렷하게 갈립니다.

바꿔도 아홉 개가 같이 따라오는 값

이름 바꾼 값 느낌
duration $dur-loop 2s → 1s 노트 낙하와 두더지 점멸이 두 배로 빨라져 판정 난이도가 올라간다
easing $ease-out → $ease-pop 두더지와 패드의 튀어오름이 한 번 더 튀어 명중 순간이 눈에 박힌다
color $subject-blue → $stage-orange 차분한 놀이판이 축제 분위기로 바뀌어 이벤트 페이지에 얹기 쉬워진다

짝이 맞아야만 도는 값

06 의 미로 문자표는 grid-template-areas 의 칸 이름과 같은 좌표계라 한쪽만 늘리면 벽이 엉뚱한 자리에 서므로, css grid 게임을 만들 생각으로 미로를 키운다면 두 곳을 같이 고쳐야 합니다. 08 은 JS 의 BASE_W 128 과 .st__row 의 width: 128px 가 같은 값일 때만 잘린 폭이 맞고, 05 는 한 박을 480ms 로 두고 판정 창 115·240·400ms 를 그 위에 얹었으니 박만 줄이면 창도 같이 줄여야 합니다.

어디서 깨지는가 — 함정

아홉 개를 다 만들고 나서 깨진 자리는 애니메이션이 아니라 입력과 타이머였습니다.

키를 누르고 있으면 한 판이 통째로 삼켜진다

키를 꾹 누르면 브라우저가 keydown 을 자동으로 되풀이해 보냅니다. 05 에서는 같은 레인의 다음 노트까지 연달아 판정되어 콤보가 혼자 올라갔고, 09 에서는 대기 상태에서 되풀이 입력이 들어와 성급 클릭 벌점이 무한히 찍혔습니다. 두 곳 모두 처리 맨 앞에 if (e.repeat) { return; } 한 줄을 두어 사람이 실제로 누른 첫 번째 입력만 받게 했습니다. 압축 파일 아홉 개의 비밀번호는 cj2ffvjv 이고, 여는 순간 이 한 줄이 05 와 09 의 keydown 처리 첫 줄에 그대로 있는 것을 볼 수 있습니다.

대기 타이머를 끄지 않으면 벌점 뒤에 신호가 켜진다

09 는 신호가 켜지기 전에 누르면 벌점인데, 이때 걸어 둔 대기 타이머가 그대로 살아 있으면 벌점 화면을 보고 있는 도중에 신호가 켜집니다. 그래서 penalize() 와 resetIdle() 둘 다 첫 줄이 clearTimeout(armTimer) 입니다.

top 이라는 이름 하나로 스크립트 전체가 죽는다

08 에서 꼭대기 칸을 돌려주는 함수를 top 이라고 지었더니 판이 아예 그려지지 않았습니다. window.top 은 브라우저가 예약해 둔 전역 이름이라 같은 이름의 함수 선언이 통째로 무시되고, 그다음 호출부터 전부 무너집니다. 이름을 topRow 로 바꾼 것이 수정의 전부였습니다.

미리보기 코드와 실제 코드가 갈라져 있다

격자 썸네일에서 움직이는 것은 .is-demo 가 붙었을 때만 도는 @keyframes 이고, 사람이 손댄 뒤의 움직임은 대부분 transition 입니다. 02 의 뒤집기 미리보기를 steps(1, end) 컷으로 만든 것도 3D 회전 중간의 모서리 각도가 대표 이미지로 잡히는 것을 막기 위해서였는데, 그 대가로 이 판의 누적면적이 1.367%까지 내려갔습니다. 한쪽만 살아 있어도 측정은 통과하므로 아홉 개 전부 .is-demo 를 뗀 상태에서 실제 입력으로 따로 눌러 봤습니다.

접근성

움직임을 줄여 달라는 설정에서도 게임 자체는 멈추지 않는 것이 이 편의 기준입니다.

reduced-motion 에서 끄는 것과 남기는 것

WCAG 2.2.2 정지·중지·숨김이 요구하는 것은 저절로 움직이는 연출을 멈추는 것이지 기능을 빼앗는 것이 아닙니다. 그래서 prefers-reduced-motion: reduce 에서는 아홉 개 모두 격자 미리보기 연출과 전환이 꺼지되 판정은 그대로 돕니다. 04 는 보간 계수를 1로 올려 눈이 즉시 따라오고, 05 는 노트를 판정선 자리에 점등식으로 세우며, 07 의 낙하는 steps(8, end) 단계 이동으로 바뀌고, 08 은 왕복 대신 입력마다 놓을 자리를 안쪽 범위에서 정합니다.

키보드와 보조기기에서 읽히는 자리

아홉 개 모두 다시 시작 버튼을 달았고, 01 은 켜진 패드로 포커스를 옮겨 스페이스만으로 끝까지 갈 수 있으며 06 은 방향키가 통로 칸으로 포커스를 옮깁니다. 07·08·09 의 놀이판은 role="application" 과 설명 문구를 붙여 화면 낭독기가 방향키를 게임에 넘기게 했고, 06 의 미로는 role="group", 01·02·03 의 결과 문구는 role="status", 05 의 판정 글자는 aria-live="polite" 로 알립니다.

FAQ

css 연습 게임으로 처음 열어 볼 항목은 어느 것인가요

01 이 가장 얕습니다. 형제 결합자와 counter-increment 만 읽으면 점수판까지 이해되고 JS 는 재시작과 포커스 이동뿐이라, 선택자 공부와 게임 제작이 같은 파일에서 끝납니다. 반대로 05·08·09 는 밀리초 계산이 있어 두 번째 주에 여는 편이 낫습니다.

게임 로직을 CSS 로만 짜도 실제 서비스에 올릴 수 있나요

점수를 되돌릴 필요가 없고 상태가 한 방향으로만 가는 놀이라면 올려도 됩니다. 01 이 그 경우인데, 체크박스 상태가 곧 진행도라서 새로 고치면 초기화되고 서버에 보낼 것도 없습니다. 순위표나 부정 방지가 필요해지는 순간부터는 상태를 JS 가 쥐어야 합니다.

모바일 터치에서도 아홉 개가 그대로 도나요

누르는 판인 01·02·03·07·08·09 는 터치에서 그대로 돕니다. 04 는 포인터를 끌어야 해서 방향키 조작을 같이 넣었고, 05·06 은 키 입력이 본체라 물리 키보드가 있을 때 제맛이 납니다. 09 의 패널에는 touch-action: manipulation 을 두어 연속 탭이 확대로 새지 않게 했습니다.

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

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