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

スクラッチカード リビール 9パターン — ピクセル計算で仕上げる

観察記録 09:00 — 指先が触れた瞬間、03番の箔の破片が重力を受けて飛び散る。09:04 — 06番の進行リングが62%で止まる。09:07 — 09番の画面上のカードが全部同時に開く。9パターンの「スクラッチカード」セットです。

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

スクラッチカード 9パターン — 消した面積の値ひとつがリビールをどう変えるか

9本すべてが同じ問いに、それぞれ違う数値で答えます。今どれだけ箔が消されているか、です。01番はその比率をキャンバスのピクセルから直接読み取って閾値を超えると残りを自動でめくり、06番は同じ計算式をカードではなくリングの充填に使い、07番は同じ比率が100%に近づくのを待って紙吹雪を弾けさせます。9本のどれも物理演算ライブラリやトゥイーンプラグインには頼っていません — getImageData・lerp・シード付き擬似乱数生成器だけで完結しています。

01ピクセル計測で面積を測り自動的にめくれるカード

clearedPixelRatio() はキャンバスのアルファチャンネルを1ピクセルずつ走査し、不透明度40未満のピクセルを数えます。つまり「消した」割合はストロークの長さから推定するのではなく、実測です。その比率×100が SCRATCH_REVEAL_THRESHOLD_PCT を超えると autoPeelRemaining() が openCard() を呼び、残っていた箔を destination-out の一発の塗りで消し去ります。

ピクセル計測で面積を測り自動的にめくれるカード
新しいタブで開く

01 · ピクセル計測で面積を測り自動的にめくれるカード — 計測 累積面積 12.103% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

clearedPixelRatioSCRATCH_REVEAL_THRESHOLD_PCTautoPeelRemaining
  function clearedPixelRatio() {
    var data = ctx.getImageData(0, 0, foil.width, foil.height).data;
    var clear = 0, total = data.length / 4;
    for (var i = 3; i < data.length; i += 4) { if (data[i] < 40) clear += 1; }
    return clear / total;
  }

  function updateMeter(ratio) {
    meterFill.style.width = Math.round(ratio * 100) + '%';
  }

  function openCard() {
    if (opened) return;
    opened = true;
    ctx.globalCompositeOperation = 'destination-out';
    ctx.fillRect(0, 0, foil.width, foil.height);
    updateMeter(1);
    root.classList.add('is-open');
    live.textContent = '20%割引クーポンが開きました';
  }

  function autoPeelRemaining(ratio) {
    if (ratio * 100 >= SCRATCH_REVEAL_THRESHOLD_PCT) openCard();

01番はフレームの12.1%を塗り替え、平均強度40.4、測定した15/23 フレームで動きがありました。

02毎回異なる柄で描かれる箔の質感

buildNoiseFoil(seed) はカードごとに一度だけルックアップテーブルを作り、valueNoise2D() で4つの noiseAt() の角を双線形補間してサンプリングします。そのため異なるシードから描かれた2枚のカードで光沢の柄が二度と同じになりません。結果はCSSグラデーションではなく ImageData バッファに1ピクセルずつ書き込まれます。

毎回異なる柄で描かれる箔の質感
新しいタブで開く

02 · 毎回異なる柄で描かれる箔の質感 — 計測 累積面積 28.594% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

valueNoise2DbuildNoiseFoilnoiseAt
  function valueNoise2D(table, x, y) {
    var x0 = Math.floor(x), y0 = Math.floor(y);
    var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
    var v00 = noiseAt(table, x0, y0), v10 = noiseAt(table, x0 + 1, y0);
    var v01 = noiseAt(table, x0, y0 + 1), v11 = noiseAt(table, x0 + 1, y0 + 1);
    var a = v00 + (v10 - v00) * tx, b = v01 + (v11 - v01) * tx;
    return a + (b - a) * ty;
  }

  function buildNoiseFoil(seed) {
    var table = buildNoiseTable(seed);
    var crackRand = seedRand(seed + 1);
    var img = ctx.createImageData(FW, FH);
    var d = img.data;
    var scaleX = FOIL_NOISE_DIM / FW, scaleY = FOIL_NOISE_DIM / FH;
    for (var y = 0; y < FH; y += 1) {
      for (var x = 0; x < FW; x += 1) {
        var n = valueNoise2D(table, x * scaleX * 2.4, y * scaleY * 2.4);
        var lightness = 176 + n * 62;
        var idx = (y * FW + x) * 4;

02番はフレームの28.6%を塗り替え、平均強度37.2、測定した17/23 フレームで動きがありました。

03重力を受けて飛び散る箔の破片

spawnFlake(x, y, vx, vy) は剥がれた破片一つ一つにシード付き生成器で回転と自転速度を与え、stepFlakes() が毎ティック FLAKE_GRAVITY_PX_S2 を縦速度に加算し続けて、FLAKE_LIFE_MS を過ぎるかカードの外へ漂い出るまでその破片を生かし続けます。prefers-reduced-motion が有効なときは spawnFlake() が即座に戻り、破片は一つも生成されません。

重力を受けて飛び散る箔の破片
新しいタブで開く

03 · 重力を受けて飛び散る箔の破片 — 計測 累積面積 12.323% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

spawnFlakestepFlakesFLAKE_LIFE_MS
  function spawnFlake(x, y, vx, vy) {
    if (reduce) return;
    flakes.push({
      x: x, y: y, vx: vx, vy: vy,
      rot: flakeRand() * Math.PI * 2,
      angVel: (flakeRand() - .5) * 10,
      born: 0, size: 3 + flakeRand() * 3
    });
  }
  function stepFlakes(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    var alive = [];
    for (var i = 0; i < flakes.length; i += 1) {
      var f = flakes[i];
      f.vy += FLAKE_GRAVITY_PX_S2 * dtS;
      f.x += f.vx * dtS;
      f.y += f.vy * dtS;
      f.rot += f.angVel * dtS;
      f.born += dtMs;
      if (f.born < FLAKE_LIFE_MS && f.x > -8 && f.x < FW + 8 && f.y < FH + 8) alive.push(f);
    }
    flakes = alive;

03番はフレームの12.3%を塗り替え、平均強度82.1、測定した21/23 フレームで動きがありました。

04中心から円形に広がるリビール半径

指を押している間、press.r は毎フレーム WIPE_LERP_RATE の割合で WIPE_TARGET_R に近づいていきます — 直線的に大きくなるのではなくlerpで固定半径へ収束します。wipe() は毎ティックその時点の半径で円を消し、同じティックで clearedRatio() を確認して82%を超えればlerpの収束を待たずにカードを開きます。

中心から円形に広がるリビール半径
新しいタブで開く

04 · 中心から円形に広がるリビール半径 — 計測 累積面積 23.424% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

wipeWIPE_LERP_RATEWIPE_TARGET_R
  function tick(ts) {
    if (lastTs === null) lastTs = ts;
    var dt = Math.min(ts - lastTs, 48);
    var dtS = dt / 1000;
    lastTs = ts;
    if (holding && !opened && press) {
      press.r += (WIPE_TARGET_R - press.r) * Math.min(1, WIPE_LERP_RATE * dtS);
      wipe();
      if (clearedRatio() > .82) openCard();
    }
    if (!reduce && root.classList.contains('is-demo')) {
      elapsed += dt;
      var tMod = elapsed % DEMO_LOOP_MS;
      if (tMod < dt) resetDemo();
      runDemo(tMod, dtS);
    }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

04番はフレームの23.4%を塗り替え、平均強度69.0、測定した23/23 フレームで動きがありました。

05二本の指がそれぞれ消すマルチタッチスクラッチ

event.pointerId をキーにした pointers マップこそ、スクラッチカード 手作りでも複数の指が同時に触れて正しく動く理由です。pointermove はそのポインター専用の直前座標を取り出し、scratchLine() で新しい座標まで線を引いてから座標を上書きし、clearedRatio() はどのポインターについても同じハンドラーの中で独立して呼ばれます。

二本の指がそれぞれ消すマルチタッチスクラッチ
新しいタブで開く

05 · 二本の指がそれぞれ消すマルチタッチスクラッチ — 計測 累積面積 17.315% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointersscratchLineadvance
    if (opened) return;
    root.classList.remove('is-demo');
    var p = pos(e);
    pointers.set(e.pointerId, p);
    scratchAt(p.x, p.y);
    if (zone.setPointerCapture) zone.setPointerCapture(e.pointerId);
  });
  zone.addEventListener('pointermove', function (e) {
    if (opened || !pointers.has(e.pointerId)) return;
    var prev = pointers.get(e.pointerId);
    var p = pos(e);
    scratchLine(prev.x, prev.y, p.x, p.y);
    pointers.set(e.pointerId, p);
    if (clearedRatio() > .82) openCard();
  });
  function release(e) { pointers.delete(e.pointerId); }
  window.addEventListener('pointerup', release);

05番はフレームの17.3%を塗り替え、平均強度121.7、測定した10/23 フレームで動きがありました。

06削った割合に合わせて満ちる進行リング

01番と同じ計算式で測ったスクラッチ済みピクセル比率がドーナツ型の進行リングを更新します — ただし毎フレームではありません。getImageData の全体スキャンは重く、約60fpsの毎ティックで呼ぶと描画パイプラインが詰まり、実測では17/23 フレーム目を過ぎたところで完全に停止しました。そのため tick() は実際にこすったティックか3ティックに1回だけ再計測します。

削った割合に合わせて満ちる進行リング
新しいタブで開く

06 · 削った割合に合わせて満ちる進行リング — 計測 累積面積 24.568% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RING_SYNC_THRESHOLD_PCTdrawRingclearedPixelRatio
  function tick(ts) {
    if (lastTs === null) lastTs = ts;
    var dt = Math.min(ts - lastTs, 48);
    lastTs = ts;
    var scratched = false;
    if (!reduce && root.classList.contains('is-demo')) {
      elapsed += dt;
      var tMod = elapsed % DEMO_LOOP_MS;
      if (tMod < dt) resetDemo();
      scratched = runDemo(tMod);
    }
    // getImageDataの全体スキャンは重い — 毎rAFティック(約60fps)ごとに呼ぶとヘッドレス描画パイプラインが
    // 詰まり、ある時点でrAF自体が止まる現象が実測された(17/23 フレームから完全停止)。
    // 実際にこすったティックと3ティックに1回(約50ms、出力12fpsより細かい)だけ再計測して負荷を減らす。
    ringTick += 1;
    if (scratched || ringTick % 3 === 0) {
      var ratio = clearedPixelRatio();
      drawRing(ratio);
      // デモ中はリング・カードがキャプチャ区間の間ずっと動き続ける必要があるため、自動オープンでは止まらない —
      // 実際のユーザーがこすって(is-demo解除)閾値を超えたときだけカードが開く。
      if (!root.classList.contains('is-demo') && ratio * 100 >= RING_SYNC_THRESHOLD_PCT) openCard();
    }
    requestAnimationFrame(tick);

06番はフレームの24.6%を塗り替え、平均強度133.3、測定した19/23 フレームで動きがありました。

07完全にめくれると弾ける紙吹雪

紙吹雪は burst フラグで一度だけに制限され、消した比率が88%を超えた瞬間に発生します。そこから stepConfetti() が毎ティック CONFETTI_GRAVITY を各破片の縦速度に加算し、床を越えて着地した破片は -CONFETTI_BOUNCE 倍で跳ね返り、3回跳ねたところで止まります。

完全にめくれると弾ける紙吹雪
新しいタブで開く

07 · 完全にめくれると弾ける紙吹雪 — 計測 累積面積 14.252% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

burstConfettistepConfettiCONFETTI_BOUNCE
        vy: -180 - confettiRand() * 140,
        rot: confettiRand() * Math.PI * 2,
        angVel: (confettiRand() - .5) * 8,
        w: 5 + confettiRand() * 3,
        h: 3 + confettiRand() * 2,
        color: COLORS[i % COLORS.length],
        bounces: 0
      });
    }
  }
  function stepConfetti(dtMs) {
    var dtS = dtMs / 1000;
    for (var i = 0; i < confetti.length; i += 1) {
      var c = confetti[i];
      c.vy += CONFETTI_GRAVITY * dtS;
      c.x += c.vx * dtS;
      c.y += c.vy * dtS;
      c.rot += c.angVel * dtS;
      if (c.y > FH - 6 && c.vy > 0 && c.bounces < 3) {
        c.y = FH - 6;
        c.vy *= -CONFETTI_BOUNCE;
        c.vx *= 0.7;
        c.bounces += 1;
      }
    }

07番はフレームの14.3%を塗り替え、平均強度136.4、測定した16/23 フレームで動きがありました。

08矢印キーでも同じ結果を開く代替経路

マウス専用操作の埋め合わせとしての空のハンドラーはここにはありません。keydown は矢印キーで cursor.x・cursor.y を KEY_STEP ずつ動かし、Enter か Space はポインターでドラッグしたときとまったく同じ scratchAt() と clearedRatio() の経路を呼んでから、同じ82%の閾値でカードを開きます。

矢印キーでも同じ結果を開く代替経路
新しいタブで開く

08 · 矢印キーでも同じ結果を開く代替経路 — 計測 累積面積 4.137% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

placeCursorKEY_STEPcursor
  // キーボード経路 — 空のハンドラーなし。実際にカーソルを動かし(状態変化)、preventDefaultでスクロールを防ぐ。
  zone.addEventListener('keydown', function (e) {
    if (opened) return;
    var moved = false;
    if (e.key === 'ArrowUp') { cursor.y = Math.max(6, cursor.y - KEY_STEP); moved = true; }
    else if (e.key === 'ArrowDown') { cursor.y = Math.min(FH - 6, cursor.y + KEY_STEP); moved = true; }
    else if (e.key === 'ArrowLeft') { cursor.x = Math.max(6, cursor.x - KEY_STEP); moved = true; }
    else if (e.key === 'ArrowRight') { cursor.x = Math.min(FW - 6, cursor.x + KEY_STEP); moved = true; }
    else if (e.key === 'Enter' || e.key === ' ') {
      root.classList.remove('is-demo');
      scratchAt(cursor.x, cursor.y);
      var ratio = clearedRatio();
      live.textContent = Math.round(ratio * 100) + '%こすりました';
      if (ratio > .82) openCard();
      e.preventDefault();
      return;
    } else { return; }
    root.classList.remove('is-demo');
    placeCursor();
    e.preventDefault();
  });

08番はフレームの4.1%を塗り替え、平均強度84.9、測定した15/23 フレームで動きがありました。

09複数枚を一画面に並べたヒーロー場面

makeCard() が複数枚のカードを配置し、それぞれが01番のピクセル計算と03番の破片物理を独立して走らせます。カードの比率が HERO_REVEAL_THRESHOLD_PCT を超えると burstConfetti() が07番の重力・跳ね返りのステップを共有の stepParticles() ループで場面全体に再利用します。

複数枚を一画面に並べたヒーロー場面
新しいタブで開く

09 · 複数枚を一画面に並べたヒーロー場面 — 計測 累積面積 15.311% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

makeCardHERO_REVEAL_THRESHOLD_PCTstepParticles
    function burstConfetti() {
      for (var i = 0; i < HERO_CONFETTI_COUNT; i += 1) {
        particles.push({
          kind: 'confetti', x: FW / 2, y: FH * 0.3,
          vx: (fxRand() - .5) * 160, vy: -140 - fxRand() * 100,
          rot: fxRand() * Math.PI * 2, angVel: (fxRand() - .5) * 7,
          born: 0, size: 4 + fxRand() * 2, color: COLORS[i % COLORS.length], bounces: 0
        });
      }
    }
    function stepParticles(dtMs) {
      var dtS = dtMs / 1000;
      var alive = [];
      for (var i = 0; i < particles.length; i += 1) {
        var p = particles[i];
        p.vy += HERO_FLAKE_GRAVITY_PX_S2 * dtS;
        p.x += p.vx * dtS;
        p.y += p.vy * dtS;
        p.rot += p.angVel * dtS;
        p.born += dtMs;
        if (p.kind === 'confetti' && p.y > FH - 4 && p.vy > 0 && p.bounces < 2) {
          p.y = FH - 4; p.vy *= -0.4; p.bounces += 1;
        }

09番はフレームの15.3%を塗り替え、平均強度107.0、測定した17/23 フレームで動きがありました。

スクラッチカード9本の面積と強度をまとめる

すべてのデモを計測した24フレームのキャプチャ区間は、9本を横並びで比べる共通のものさしにもなります — 累積で塗り替えた面積・単一フレームでの最大面積・動いたピクセルの平均強度、そして測定した23個の差分のうち実際に動きがあったフレーム数です。

# パターン 面積 最大面積 平均強度 動いたフレーム
01 ピクセル閾値の自動めくれ 12.1% 1.2% 40.4 15/23
02 箔ノイズテクスチャ 28.6% 9.5% 37.2 17/23
03 破片重力パーティクル 12.3% 1.7% 82.1 21/23
04 円形ワイプlerp 23.4% 19.4% 69.0 23/23
05 マルチタッチポインター 17.3% 4.9% 121.7 10/23
06 進行リング同期 24.6% 1.8% 133.3 19/23
07 紙吹雪の爆発 14.3% 1.2% 136.4 16/23
08 キーボード代替経路 4.1% 0.8% 84.9 15/23
09 複数カードのヒーロー場面 15.3% 2.1% 107.0 17/23

08番が9本の中で最も面積が小さいのは計算式が違うからではありません — 01・04・05番と同じ clearedRatio() を共有しています。理由はキー入力のたびに固定の KEY_STEP だけカーソルが動くためで、自由なドラッグより同じキャプチャ区間で動くピクセルが少なくなるからです。

値がリビール判定を変える場所 — めくれ閾値・破片の重力加速度・紙吹雪の数

変形 値 感触
めくれ閾値を調整する 01 SCRATCH_REVEAL_THRESHOLD_PCT 50 → 80 値を下げると少し削るだけで残りがすぐ自動でめくれ、値を上げるとかなり多く削らないと自動めくれが起きない。
破片の重力加速度を調整する 03 FLAKE_GRAVITY_PX_S2 400 → 1200 値を小さくすると破片はゆっくり舞って長く漂い、値を大きくすると素早く落ちて一瞬で消える。
紙吹雪の数を調整する 07 CONFETTI_COUNT 24 → 60 値を小さくすると紙吹雪はまばらに数個だけ跳ね、値を大きくすると画面を埋めるほど密に弾ける。

この3つの値はどれもレイアウトや重力計算そのものには触れません — 同じ計算がどこで閾値を超えるかを動かすだけなので、上の表は作り直しではなく感触の変化として読めます。

どこで壊れるか — 動作を減らす設定・連続する高速スクラッチ・低スペック端末でのフレーム低下

9本すべてが起動時に一度だけ prefers-reduced-motion を確認し、同じ方法で自身のアイドルデモループを止めます(if (reduce) root.classList.remove('is-demo') と runDemo() の前の !reduce ガード)。03・07・09番はさらに一歩進んで、同じフラグの下で stepFlakes()・stepConfetti()・stepParticles() 自体も止めるため、自動再生しないだけでなく破片そのものが一つも発生しません。

9本の差がいちばん出るのは連続する高速スクラッチです。01・04・05・08番はポインターやキーのハンドラーの中でスロットルなしに毎回 clearedRatio() を呼びます — マウスのイベント頻度なら問題ありませんが、05番で二本指の速い動きを再現すると独立した pointermove ストリームが二つ走り、それぞれがキャンバス全体の getImageData スキャンを毎回実行します。06番だけがこの呼び出しを意図的にスロットルしており、スロットルなしの版が requestAnimationFrame を17/23 フレーム目を過ぎたところで止めていたと実測されたからです — その事実は上の06番のコード内コメントにそのまま残っています。低スペック端末では、破片や紙吹雪の物理計算が重くなるより先に、この毎回の getImageData コストが最初にフレームを落とす箇所になります。キャンバス全体のピクセルスキャンとフレーム予算の同じ綱引きは、別の9本を扱うhtml5 canvas アニメーション9種 — 水滴とブラックホールにも出てきます。

27qe6aea は9本の index.html・style.scss の組の中にあり、並べて読むことが06番だけスロットルが必要だった理由を確かめる一番早い方法です。

変形3つとアクセシビリティ

上の変形はどれも SCRATCH_REVEAL_THRESHOLD_PCT・FLAKE_GRAVITY_PX_S2・CONFETTI_COUNT という名前の付いた定数を一つだけ書き換え、そのパターンの他の行は一切触れません。つまりこの表は、スクラッチカードゲームのピクセル計算そのものを崩さずに感触だけを変える場所を示す地図にもなっています。

08番のキーボード経路は名ばかりの代替ではなく本物のキーボード対応です。矢印キーが目に見えるカーソルを動かし、Enter・Space はポインター操作のどの項目とも同じ clearedRatio() の呼び出し経路でその位置をこすり、各キー操作の e.preventDefault() がページのスクロールを止めます。上で説明した動作を減らす設定の挙動については、MDNの prefers-reduced-motion リファレンスとMDNのCanvas getImageData() リファレンスが、この9本が最も頼っているブラウザAPIを2つとも解説しています。

スクラッチカードとクーポンデザインは何が違うのか?

クーポン デザイン 9選 — スタンプからルーレットまではクーポンの静止した見た目についての編です — スタンプ・チケットの縁・回転するルーレットを主にCSSで組み、クラスが切り替わった瞬間に完成します。スクラッチカード 作り方という観点では、この9本はその逆です。クラスの切り替えでカードの見た目が変わることはありません。「リビール」はキャンバスのピクセル数が閾値を超える瞬間そのものを、1ピクセルずつリアルタイムで数えているからです。プロジェクトがクーポンをある見た目で「活性化した瞬間」だけ見せたいならクーポン編を、こすり取るという操作そのものが目的ならこの9パターンが向いています。

よくある質問 — スクラッチカードゲームを試す前に

この9パターンはビルド手順なしでそのままページに置けますか?

はい。9本それぞれが単一の index.html と style.scss だけで完結し、9つの <script> タグのどこにも外部ライブラリやCDNスクリプトはありません。Sassをコンパイルしてマークアップを貼り付けるだけです。

物理演算ライブラリを追加しなくても、実際のプロモーションに使って安全ですか?

9本すべての重力・跳ね返り・lerpの計算は requestAnimationFrame のティックの中の単純な算術です。9つのファイルのどこにも Math.random() はなく、シード付きの seedRand() だけが使われています。シードを固定すればリロードをまたいでも同じカードを再現できます。

06番はなぜ毎フレームではなく3ティックに1回しか進行リングを再計算しないのですか?

getImageData() がキャンバス全体のピクセルを走査するからです。06番自身のコード内コメントに、スロットルなしの版が17/23 フレーム目を過ぎたところで requestAnimationFrame を止めていたと記録されています。上のコードブロック内のスロットルは、リングのアニメーション自体を生かし続けるためにあります。

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

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