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

CSS ゲーム 作成 — CSSだけで動くミニゲーム9選

css ゲームとは、セレクタとキーフレームと transform だけでルールと画面の両方をまかなう仕掛けのことです。この9つはゲームエンジンなしで、素の HTML・CSS・バニラ JS だけで組んであり、下のグリッドでそのまま遊べます。

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

並びは人気順ではなく、勝ち負けの判定を誰が握っているかの順です。最初の3つは判定のほとんどが CSS の中にあり(01 はセレクタの連鎖、02 は反転トランジション、03 はせり上がりのトランジション)、真ん中の3つは JavaScript が座標とフォーカスを読んで CSS がそれを描き(04 カーソル追跡、05 アニメーションの時計、06 フォーカス移動)、最後の3つは JavaScript がフレームとタイムスタンプを自分で数えます(07 落下回避、08 往復の停止、09 経過ミリ秒)。「CSS だけ ゲーム」で検索して行き着くのはたいてい 01 のような形で、兄弟結合子ひとつが進行のすべてを背負っています。09 まで来ると、仕事はもうミリ秒の計算です。めくる動きそのものが目当てなら隣にカード フリップ アニメーション 9選【コピペ20行】があり、カーソルに吸い寄せられる場面ならマウス 追従 アニメーション css で作る3D場面9選、キーキャップそのものならショートカットキー 表示 UI 9選 — キーキャップからヘルプシートまでが並んでいます。

01点灯パッドタップ

今点いているパッドだけが押せて、押すたびに光が次のパッドへ渡ります。スピナーの代わりに置く30秒の遊びにも、404ページの見せ場にもなる小さな盤面です。

点灯パッドタップ
新しいタブで開く

01 · 点灯パッドタップ — 計測 累積面積 47.79% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

: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; }

ここにはスコアを数える JavaScript が一行もありません。36個のアニメーションが重なるこの盤面は累積面積47.79%、動いたフレーム23/23 で、9つの中で最も動きの密度が高くなっています。

02神経衰弱カード

めくった2枚が同じなら表のまま残り、違えば裏へ戻ります。暇つぶしにもなり、表に問題を載せれば学習カードにもなる盤面です。

神経衰弱カード
新しいタブで開く

02 · 神経衰弱カード — 計測 累積面積 1.367% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rotateY(preserve-3dbackface-visibility

1つの箱に表と裏を重ねる

.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 が後ろを向いた面を消すので、JavaScript に残る仕事はクラスの付け外しだけです。

一致なら固定、不一致は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);
}

2枚が開いている間の3クリック目は lock フラグが飲み込みます。プレビューの反転が回転ではなくカットなので、この盤面の累積面積は1.367%と、9つの中で最も低い数字になりました。

03モグラたたき

3×3 の穴からモグラがランダムに顔を出し、出ている間に叩けば得点になります。景品ルーレットの代わりを置きたいイベントページや、子どもの目を引くセクションに向く盤面です。

モグラたたき
新しいタブで開く

03 · モグラたたき — 計測 累積面積 33.16% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

@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 で組み直しています。穴ごとに animation-delay を負の値へずらしてあるので、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 後に出てくるので2匹が同時に立つことはありません。おかげでこの盤面は累積面積33.16%、平均強度129.4 と、9つの中で3番目に賑やかになっています。

04視線避け

画面の目がカーソルを追い続け、重なった時間が伸びるとペナルティが溜まります。ポインターイベントを教える教材にも、カーソル演出のポートフォリオ枠にも使えます。

視線避け
新しいタブで開く

04 · 視線避け — 計測 累積面積 27.768% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 の2行が配置のすべてを担うので、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 とどまった時点で1つ入り、離れればゲージはその倍の速さで抜けていきます。

05リズムタップ

ノーツが3つのレーンを落ちてきて、判定ラインを通る瞬間に J・K・L を叩きます。キーボードの練習にも、待ち画面での指遊びにもちょうどいい大きさです。

リズムタップ
新しいタブで開く

05 · リズムタップ — 計測 累積面積 22.402% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

keydown:focus-visiblegrid-template-areas

迷路は文字5行

// 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 なので、壁が行く手を塞ぐというルールはどこにも書く必要がありません。迷路を作り替えたいときに触るのも、この5行だけです。

キャラクターを描くのは :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; }

すべてのマスに隠してあるキャラクターが、フォーカスを受けた1マスでだけ現れるので、JavaScript が動かすのはフォーカスだけになります。アウトラインが出るのはキーボードで入ったときだけで、これは :focus-visible を分けてあるおかげです。

07落下物回避

画面下のロケットを左右に動かして落下物をよけ、3回当たると終わりです。イベントページの遊びコーナーや、反応速度を売りにする UI のたたき台になります。

落下物回避
新しいタブで開く

07 · 落下物回避 — 計測 累積面積 14.249% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 が運びます。画面に岩が10個あっても JavaScript が追う座標は1つで済み、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 の無敵を置いてあるので、1つの岩が残機を二重に削ることもありません。

08タワー積みタイミング

左右に往復するブロックを止めて積み、はみ出した分は削り落とされます。タイミング勝負で人を引きつけるプロモーションや、待ち画面に置くスコア競争に向く形です。

タワー積みタイミング
新しいタブで開く

08 · タワー積みタイミング — 計測 累積面積 5.453% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 は描かれるものだけを変えます。だからこの盤面はアニメーションが9つの中で最少の2つしかないのに、動いたフレームは13/23 を記録しました。

重なった分だけを残す3つの値

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);
}

横のオフセット・横の倍率・階の高さがカスタムプロパティ3つで渡るので、新しい階も削れた欠片も同じ規則で描かれます。両端とも4px 以内に収まればボーナスが付き、基準の128px 幅が戻ってきます。

09反応速度テスト

信号が点いた瞬間に押して、そこまでにかかった時間を測ります。チームページの遊びにも、反応速度ベンチマークの素材にもなります。

反応速度テスト
新しいタブで開く

09 · 反応速度テスト — 計測 累積面積 48.163% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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秒の乱数なので、覚えて先回りすることはできません。同じ入力が状態によってペナルティにも計測にも次の回の開始にもなるため、ボタンを分ける必要がなくなりました。

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%と9つの中でいちばん大きくなります。動く瞬間が信号そのものしかないぶん、動いたフレームは11/23 と最も少ない数字です。

css 練習 ゲームとして使うには何を固定すべきか?

9つが共有しているのは値3つだけで、あとは項目ごとに閉じてあります。だから触っていい場所と、そのままにしておく場所がはっきり分かれます。

9つが一斉についてくる値

名前 変えた値 感じ
duration $dur-loop 2s → 1s ノーツの落下とモグラの点滅が倍の速さになり、判定の難度が上がる
easing $ease-out → $ease-pop モグラとパッドの跳ね上がりがもう一度跳ね、命中の瞬間が目に焼き付く
color $subject-blue → $stage-orange 落ち着いた盤面が祭りの色に変わり、イベントページに載せやすくなる

対でしか回らない値

CSS アニメーション ゲームが壊れるのはアニメーションの中ではなく、たいていこういう継ぎ目です。06 の迷路の文字表は grid-template-areas のマス名と同じ座標系にあるので、片方だけ伸ばすと壁が見当違いの場所に立ち、両方を一緒に育てることになります。08 は JS の BASE_W と .st__row の width: 128px が同じ数字でいる間だけ削り幅が合い、05 は1拍を480ms に置いて判定ウィンドウ115・240・400ms をその上に載せているので、拍を縮めるならウィンドウも一緒に縮めます。

どこで壊れるか — 落とし穴

9つを組み終えたあとに壊れていた場所は、アニメーションではなく入力とタイマーでした。

キーを押しっぱなしにすると1回分が丸ごと飲まれる

キーを押し続けるとブラウザが keydown を自動で繰り返し送ります。05 では同じレーンの次のノーツまで続けて判定されてコンボがひとりでに伸び、09 では待機中に繰り返しが届いてお手つきのペナルティが延々と表示されました。どちらも処理の先頭に if (e.repeat) { return; } の一行を置き、人が実際に押した最初の入力だけを通しています。9つの zip に共通のパスワードは 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%でした。片方だけ生きていても測定は通ってしまうので、9つとも .is-demo を外した状態で手で押して確かめています。

アクセシビリティ

動きを減らしてほしいという設定でも、ゲームそのものは取り上げません。それがこの記事の基準です。

reduced-motion で消すものと残すもの

WCAG 2.2.2 一時停止、停止、非表示が求めているのは、勝手に動く演出を止めることであって機能を奪うことではありません。そこで prefers-reduced-motion: reduce では9つともグリッドのプレビュー演出とトランジションが切れ、判定はそのまま動きます。04 は補間係数を1に上げて目が即座に追いつき、05 はノーツを判定ラインの位置に点灯式で立て、07 の落下は steps(8, end) の段階移動に変わり、08 は往復の代わりに入力ごとに置き場所を内側の範囲から決めます。

キーボードと支援技術が入る場所

9つとも再スタートの操作を備え、01 は点いたパッドへフォーカスを移すのでスペースキーだけで最後まで行けます。06 では矢印キーが通路のマスへフォーカスを移します。07・08・09 のプレイフィールドには role="application" と説明文を付けて、スクリーンリーダーが矢印キーをゲームへ渡すようにしました。06 の迷路は role="group"、01・02・03 の結果表示は role="status"、05 の判定文字は aria-live="polite" で読み上げます。

FAQ

css 練習 ゲームとして最初に開くならどれか

01 がいちばん浅いところにあります。兄弟結合子と counter-increment を読めばスコアボードまで理解でき、JavaScript は再スタートとフォーカス移動しか持たないので、セレクタの勉強とゲーム作りが同じファイルの中で終わります。逆に 05・08・09 はミリ秒の計算があるので、2周目に回すほうが気持ちよく読めます。

ロジックが CSS だけのゲームを本番のサービスに載せられますか

スコアを巻き戻す必要がなく、状態が一方向にしか進まない遊びなら載せられます。01 がその例で、チェックボックスの状態がそのまま進行度になり、再読み込みで初期化され、サーバーへ送るものもありません。順位表や不正対策が要る瞬間から、状態は JavaScript が握ることになります。

モバイルのタッチでも9つそのまま動きますか

押す盤面である 01・02・03・07・08・09 はタッチのままで遊べます。04 はポインターを引き回す必要があるので矢印キーの操作も用意し、05・06 はキー入力が本体なので物理キーボードがあると本領を発揮します。09 のパネルには touch-action: manipulation を置いて、連打が拡大に化けないようにしました。

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

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