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

ブロック崩しゲーム 9種 — パドルの反射角から高速球のスイープ衝突と連鎖爆発まで

ブロック崩しはボールが当たった位置をどうやって向きに変えるのでしょうか。01番はパドル中心からの距離で反射角を決め、02番は一コマの移動を刻んで速い球がブロックを飛び越えないようにし、08番は壁の反射を展開して落下地点を先に計算します。

ブロック崩しは球が跳ねる絵ではなく、当たった位置と移動の区間という二つの値から反射の向きも衝突の順番もブロックの状態もすべて計算する仕組みです — ブロック崩しゲームを作るには、どの値が球の向きを決めるのかを知ることから始まります

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

九つの場面は同じ計算の枠を使い、場面ごとに違うのはスイッチが一つ二つと定数がいくつかだけです。球は位置・速度・回転、ブロックは耐久と時刻、パドルは横位置と速度という値にすぎず、キャンバスはその値を塗る仕事だけを受け持ちます。

球・パドル・ブロックは一つの計算の枠からだけ出ます

World が Ball・Brick・Mode を持ち、一歩の stepWorld がパドルの操作、球ごとの moveBall、ブロックの reflectBricks の順に値を変えます。drawWorld は値を読んで描くだけなので、場面が違っても衝突の結果は同じ関数から出て、右の対比盤は Mode のスイッチだけを変えて動かした同じ計算です。

固定の歩み 1/120秒と DT_MAX 0.05

rAF が知らせる経過時間は DT_MAX の0.05秒で切り、たまった時間を STEP_H の1/120秒ずつの歩みに刻んで stepWorld を呼びます。モニターが60Hzでも144Hzでも一歩の計算時間が同じなので球の軌跡が揃い、隠れたタブから戻っても一度に0.05秒より先へは進みません。ただし02番だけは w.step を FRAME_H の1/60秒にして、advanceWorld(w, dt × SLOW) で場面を遅く見せています。

    // 時間を固定の歩みに刻んで呼ぶ — 一度に DT_MAX を超える時間は足さない
    function advanceWorld(w, dt) {
        w.acc += Math.min(dt, DT_MAX);
        while (w.acc >= w.step - 1e-9) {
            stepWorld(w, w.step);
            w.acc -= w.step;
        }
    }

乱数はひび割れの形にだけ使います

球の動きに乱数はなく、発射角は LAUNCH_DEG、パドルに当たる位置は AIMS と aims、分裂の角は ANGLE_TABLE というあらかじめ書いた表から順に取ります。乱数は掛け算1664525・足し算1013904223の種を固定した生成器 seeded ひとつだけで、ブロックの行と列から種を決めてひび線の形にだけ使います。

    function crackLines(k, n) {
        const rng = seeded(k.row * 131 + k.col * 17 + 7);
        for (let i = 0; i < n; i += 1) {
            const sx = k.x + k.w * (0.2 + 0.6 * rng());
            const top = (i % 2) === 0;
            let cx = sx;
            let cy = top ? k.y : k.y + k.h;
            const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
            const steps = 3;
            for (let s = 0; s < steps; s += 1) {
                const nx = cx + (rng() - 0.5) * k.w * 0.34;
                const ny = cy + (ty - cy) / (steps - s);
                seg(cx, cy, nx, ny, 1.4, INK, 0.62);
                cx = nx;
                cy = ny;
            }
        }
    }

ブロック崩し 9種 — パドルの位置で決める反射角・一コマを刻むスイープ衝突・角の法線選び・パドルの速さが作る曲がる軌道・固定角度表のマルチボール・耐久ごとのひび・速度段階と残像・落下地点を予測するパドル・文字列の格子レベルと連鎖爆発

九つの場面を描画して測った面積・フレーム・強度

場面 累計面積 動いたフレーム 強度の平均
01 反射角 11.2% 23/23 90.7
02 スイープ衝突 29.8% 22/23 62.5
03 角の法線 11.3% 23/23 87.4
04 回転の伝わり 15.1% 23/23 67.9
05 マルチボール 12.9% 23/23 88.9
06 耐久のひび 7.9% 21/23 86.8
07 速度段階 9.1% 23/23 93.9
08 落下予測 14.8% 23/23 87.4
09 文字列レベル 12.4% 23/23 86.8

累計面積がいちばん広いのは02の29.8%で、いちばん狭いのは06の7.9%です。動いたフレームは02が22/23、06が21/23で、残り七つの場面は23/23、強度の平均は07の93.9が最も高くなっています。

01パドルに当たった位置で決める反射角

パドルで球の向きを狙う決まりを最初に覚えるときに触る値で、パドル中心から球までの距離を半幅で割った hitOffset が−1から1になり、反射角は u × MAX_ANGLE の58度です。右の盤は vy = −|vy| の鏡の反射だけを使うので同じ筋を上下するだけになります。当たった位置−1・−0.5・0・0.5・1は−58.0度・−29.0度・0度・29.0度・57.9度で、12秒に左は11枚、右は4枚を壊しました。

パドルに当たった位置で決める反射角
新しいタブで開く

01 · パドルに当たった位置で決める反射角 — 計測 累積面積 11.156% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PADDLE_WhitOffsetMAX_ANGLE
                b.y = w.py - BALL_R;
                const u = hitOffset(w, b);
                if (m.angle === 0) {
                    const a = u * MAX_ANGLE;
                    b.vx = Math.sin(a) * w.speed;
                    b.vy = -Math.cos(a) * w.speed;
                }
                else {
                    b.vy = -Math.abs(b.vy);
                }

02速い球を捕まえるスイープ衝突

球が速くなってもブロックを飛び越えないための衝突処理で、一コマの移動を STEP_MAX の4px以下に切り上げて刻み、歩みごとにブロックを調べます。この場面は一コマ42pxで11の欠片になり、ブロックの厚さ6に球の直径10を足した16pxより長いと、コマ末の位置だけを見る盤は一列を飛び越えます。出発の位相を200通りにして撃つとスイープは0%、コマ末だけの盤は62.0%が通り抜け、式の(移動−16)÷移動の61.9%と合い、画面の12秒では右の盤だけが12回通り抜けました。

速い球を捕まえるスイープ衝突
新しいタブで開く

02 · 速い球を捕まえるスイープ衝突 — 計測 累積面積 29.834% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STEP_MAXFRAME_HBALL_SPEED
    // 一コマで球が進む長さ(px) = BALL_SPEED × FRAME_H。ブロックの厚さ + 球の直径より長いと一列を丸ごと飛び越える
    const FRAME_H = 1 / 60;
    const BALL_SPEED = 2520;
    const BRICK_H = 6;
    // スイープは一コマの移動を STEP_MAX 以下の欠片(切り上げ(移動 ÷ STEP_MAX) 個)に刻む
    const STEP_MAX = 4;
    // 場面の時間を遅くして見せる — 球が速すぎて目で追えない
    const SLOW = 0.3;

03角で反射する面を選ぶ法線

ブロックの角をかすめた球が見当違いの向きへ行かないようにする反射の決まりで、重なりの深さを横の ox と縦の oy で測り、浅いほうの軸の面で押し出してその軸の速度だけを反転します。右の盤は vy = −vy しか使わないので、横腹に当たると球がブロックの中へ入り込みます。柱ひとつを5か所×41通りの角度で205回撃ち、球の中心が柱の中に6歩以上とどまった回数を数えると、面を選ぶ盤は0回、速度yだけの反転は66回でした。

角で反射する面を選ぶ法線
新しいタブで開く

03 · 角で反射する面を選ぶ法線 — 計測 累積面積 11.336% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

reflectBricksLAUNCH_DEGface
        if (w.mode.face === 0) {
            // 重なりの浅い軸の面が当たった面 — その面の外側へ押し出して、その軸の速度だけを反転する
            const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
            const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
            if (ox < oy) {
                const dir = b.x < cx ? -1 : 1;
                b.x += dir * ox;
                b.vx = dir * Math.abs(b.vx);
            }
            else {
                const dir = b.y < cy ? -1 : 1;
                b.y += dir * oy;
                b.vy = dir * Math.abs(b.vy);
            }
        }
        else {
            // 対比盤 — 面を選ばず上下の速度だけを反転する。横腹に当たるとブロックの中へ入り込む
            if (w.t - k.hitT < h * 1.5) {
                w.stuck += 1;
            }
            b.vy = -b.vy;
        }

04パドルの速さが球を曲げる回転の伝わり

パドルを動かしながら打つ技に意味を持たせるため、当たる瞬間にパドルの速さへ SPIN_GAIN の0.15を掛けて回転に渡し、回転は歩みごとに SIDE_PUSH ぶん球を横へ押して SPIN_DECAY で減ります。左のパドルは−300px/秒で走って回転−45を受け取り、右は止まったままで回転0です。同じ位置 x 111に横速度86.7px/秒で当たっても、0.5秒後の横の移動は+19.3pxと+43.4pxに分かれました。

パドルの速さが球を曲げる回転の伝わり
新しいタブで開く

04 · パドルの速さが球を曲げる回転の伝わり — 計測 累積面積 15.136% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPIN_GAINSPIN_DECAYSIDE_PUSH
        const m = w.mode;
        if (m.spin > 0) {
            // 回転はコマごとに横へ押す力になり、少しずつ減る
            b.vx += b.spin * m.push * h;
            b.spin *= Math.exp(-m.decay * h);
        }

05固定の角度表で分かれるマルチボール

アイテムで球が増える決まりを乱数なしで作るため、+ のブロックが壊れたとき ANGLE_TABLE の次の値を新しい球 SPLIT_N 個の角にして、BALL_MAX で止めます。同じ盤を二度回して球の位置と壊れた数を一つにまとめて比べると、12秒のあいだ毎秒12/12が一致しました。12秒で分裂は5回、球は最大5個、壊れたブロックは23枚で、一歩の検査回数は球ごとに同じです。

固定の角度表で分かれるマルチボール
新しいタブで開く

05 · 固定の角度表で分かれるマルチボール — 計測 累積面積 12.935% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ANGLE_TABLESPLIT_NBALL_MAX
    function onSplit(w, k) {
        if (k.kind !== 1) {
            return;
        }
        for (let i = 0; i < SPLIT_N; i += 1) {
            if (w.balls.length >= BALL_MAX) {
                return;
            }
            const b = newBall(w, w.balls.length + splits * 10);
            const a = ANGLE_TABLE[tableAt % ANGLE_TABLE.length] * Math.PI / 180;
            tableAt += 1;
            b.x = k.x + k.w / 2;
            b.y = k.y + k.h + BALL_R;
            b.vx = Math.sin(a) * w.speed;
            b.vy = Math.cos(a) * w.speed;
            b.st = 0;
            w.balls.push(b);
        }
        splits += 1;
    }

06耐久に応じて広がるブロックのひび

何度も当てないと壊れない硬いブロックを整数ひとつの耐久で表し、hurt が当たるたびに hp -= 1 をして、残りの耐久は色の段階、当たった回数はひび線の本数に変わります。上から hp 1・2・3・4で積んだ10×4の盤40枚は、hp 1が16枚、2・3・4が各8枚なので、すべて壊すには88回当てることになります。12秒では28回当たってブロック9枚が壊れ、同時にひびが入ったブロックは最大6枚でした。

耐久に応じて広がるブロックのひび
新しいタブで開く

06 · 耐久に応じて広がるブロックのひび — 計測 累積面積 7.872% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HP_MAXCRACK_NTINT_STEP
    function hurt(w, k) {
        k.hitT = w.t;
        w.hits += 1;
        if (k.hp >= 90) {
            return;
        }
        k.hp -= 1;
        if (k.hp <= 0) {
            k.deadT = w.t;
            w.broken += 1;
            w.lastBreak = w.t;
            if (w.onBreak) {
                w.onBreak(w, k);
            }
        }
    }

07速度段階に応じて伸びる残像

進むほど緊張が高まる速度の曲線は、speedFor が壊した数を CLEAR_N の3で割った商の数だけ SPEED_STEP の70を足し、SPEED_MAX の590で切って作り、残像の長さは round(速度 × TRAIL_LEN) + 2 で追って伸ばします。壊した数0なら速度190で残像16、18枚なら速度590で残像46です。12秒で24枚を壊し、上限の速度には10.0秒で届き、歩みが STEP_MAX の欠片に刻まれるので、この場面で通り抜けは0回でした。

速度段階に応じて伸びる残像
新しいタブで開く

07 · 速度段階に応じて伸びる残像 — 計測 累積面積 9.067% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPEED_STEPTRAIL_LENCLEAR_N
    function update(dt) {
        const w = WS[0];
        // 段階が上がると速度と残像の長さが一緒に変わる — 一歩の移動は4px以下の欠片に刻まれる(02番のスイープ衝突と同じ)
        w.speed = speedFor(w.broken);
        w.trailN = Math.round(w.speed * TRAIL_LEN) + 2;
        advanceWorld(w, dt);
    }

08壁の反射を展開して落下地点を予測するパドル

自動プレイのデモや練習相手のパドルは、壁の反射を展開して落下地点を求めます。球がパドルの高さに届く時刻 t の b.x + b.vx × t を foldW で盤の幅に折りたたみ、パドルは FOLLOW_EASE でその地点を追い、右の盤は球の今の横位置だけを追います。24秒のあいだの取りこぼしは0回と7回で、落下の0.25秒前に出した予測と実際に当たった横位置の差は17回で平均1.36px、最大2.67pxでした。

壁の反射を展開して落下地点を予測するパドル
新しいタブで開く

08 · 壁の反射を展開して落下地点を予測するパドル — 計測 累積面積 14.792% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

foldWlandXFOLLOW_EASE
    function foldW(v, lo, hi) {
        const len = hi - lo;
        const per = 2 * len;
        const u = ((v - lo) % per + per) % per;
        return lo + (u <= len ? u : per - u);
    }
    // 球がパドルの高さに届くまでの残り時間(秒)とそのときの横位置 — ブロックは無視して壁だけを展開する
    function landing(w, b) {
        const down = w.py - BALL_R;
        let t = 0;
        if (b.vy > 0) {
            t = (down - b.y) / b.vy;
        }
        else {
            t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
        }
        return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
    }

09文字列の格子レベルと連鎖爆発の波

レベル編集と連鎖演出、ランディングのヒーローイントロには、レベルを LEVEL_ROWS の文字列六行で書く方式が合います。爆発ブロックが壊れると BLAST_R の1.25マス以内のブロックへ、距離(マス)×WAVE_DELAY の0.08×2だけ遅らせた導火線をかけ、導火線の時刻が過ぎれば耐久に関係なく壊れます。ブロック60枚のうち爆発は8枚で、12秒に爆発2回(0.80秒・0.97秒)、導火線で壊れた9枚が0.87〜1.14秒にすべて壊れました。

文字列の格子レベルと連鎖爆発の波
新しいタブで開く

09 · 文字列の格子レベルと連鎖爆発の波 — 計測 累積面積 12.41% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LEVEL_ROWSWAVE_DELAYBLAST_R
    function onBlast(w, k) {
        if (k.kind !== 2) {
            return;
        }
        const cx = k.x + k.w / 2;
        const cy = k.y + k.h / 2;
        blasts.push([cx, cy, w.t]);
        const cell = k.w + 2;
        for (const o of w.bricks) {
            if (o.deadT >= 0 || o === k) {
                continue;
            }
            const d = Math.hypot(o.x + o.w / 2 - cx, o.y + o.h / 2 - cy) / cell;
            if (d > BLAST_R) {
                continue;
            }
            const f = w.t + d * WAVE_DELAY * 2;
            if (o.fuse < 0 || f < o.fuse) {
                o.fuse = f;
            }
        }
    }

ブロック崩しをコードで組むと、ボール一つの一コマはどんな値いくつで決まるのでしょうか?

球ひとつが持つ値

球は位置 x・y、速度 vx・vy、回転 spin、状態 st、軌跡の履歴 trail に分かれます。状態は0が飛行中、1がパドルの上、2が落下で、速度の大きさは球ではなく盤の w.speed が決めます。

値 意味 変える場所
vx・vy 向き パドルに当たった位置・壁・ブロックの面
spin 横へ押す力 パドルの速さ(04)
st 飛行中・パドルの上・落下 発射・床
hp・deadT・fuse ブロックの耐久・壊れた時刻・導火線 hurt・onBlast(06・09)

スイッチひとつが対比盤を作ります

Mode の sweep・face・angle・spin は、計算のたった一か所だけを変えるスイッチです。01の angle、02の sweep、03の face、04の spin が右の盤で違うほうに入り、ほかの計算は同じなので、二つの盤の差はその決まりひとつのぶんです。

ボールの位置と速度を受け取り、移動の区間を刻んで、最初に触れるブロックを探し、当たった面の法線で速度を反射する — ブロック崩し一コマが計算される順番

一歩の順番

moveBall は回転を横へ押す力として足し、速度の大きさを w.speed に合わせ、ほぼ水平なら縦の成分を20%まで戻します。続いて移動の長さを STEP_MAX 以下の欠片に刻み、欠片ごとに壁の反射、パドル上面の線を通ったかの検査、reflectBricks を順に行います。いちばん深く重なったブロックひとつだけを選ぶので、一つの欠片で処理するブロックはひとつです。

欠片の数は切り上げで決まります

欠片の数は、移動の長さを STEP_MAX で割って切り上げた値です。02の一コマ42pxは11の欠片、07の上限速度590px/秒は一歩1/120秒で4.92pxなので2欠片になります。欠片が小さいほど球がブロックの箱を飛び越える隙は減りますが、計算はそのぶん増え、移動の区間を刻む考え方は MDN の 2D collision detection が扱う衝突検査の延長です。

ブロック崩しゲームの無料版のようにルールを重ねて実際のゲームに付けるとき — ライブラリなしで作る・レベルを文字列で書く方法・球の速度を上げる時点

ライブラリなしで作る

必要なのはキャンバス2Dだけで、九つの場面はライブラリ・WebGL・CDNを使いません。ブロック崩し web のページに得点や残機やメニューを付けるのはこの計算の枠の外の仕事なので、World の値を読む側で足せば済みます。

レベルを文字列で書く方法

09は . が空きマス、1〜3 が耐久、B が爆発ブロックの12マス×6行の文字列で盤を作ります。ブロック崩し ローグライク のように盤が頻繁に変わるゲームなら、行の文字列を差し替えるだけでレベルを増やせて、parseLevel は文字ひとつをブロック一マスとして読みます。

球の速度を上げる時点

速度は speedFor が壊した数から計算し、上げる瞬間に trailN も一緒に変えます。速度を上げても歩みの欠片の数がそれに合わせて増え、07の12秒の場面では通り抜けが0回だったので、難しさの曲線は SPEED_STEP と CLEAR_N の二つの値で調整します。

ピンボールの記事との違い

ピンボールの記事はフリッパーとバンパーを選ぶ仕組みを扱い、この記事はパドルの位置・移動の区間・ブロックの状態をひとつの計算の枠から取り出す構造を扱います。格子の決まりを時間表で見せる方法が気になるなら、マインスイーパーの記事も同じ枠を使っています。

ブロック崩しが壊れる瞬間 — 球が速くなるとブロックをすり抜ける落とし穴・角に当たって見当違いの向きに跳ねる反射・パドルの端で球が水平に閉じ込められる角度

球が速くなるとブロックをすり抜けます

02の右の盤のようにコマ末の位置だけを調べると、移動が16pxを超えた瞬間からブロック一列を飛び越えます。一コマ20pxで20.5%、30pxで46.5%、42pxで62.0%が通り抜け、10pxでは0%でした。直す方法は、移動を STEP_MAX 以下に刻むことひとつです。

角に当たって見当違いの向きに跳ねます

03の右の盤は速度yだけを反転するので、ブロックの横腹に当たった球がブロックの中で繰り返し当たります。画面の12秒に右は254回当たったうち、入り込んだ歩みが242でした。重なりの浅い軸を選べば、角をかすめても近い面で跳ね返ります。

パドルの端で球が水平に閉じ込められます

01の右の盤は鏡の反射だけを使い、同じ縦の筋のブロック四枚を壊して同じ筋を上下するだけで止まりました。球がほぼ水平になると一行に閉じ込められやすいので、moveBall は縦の成分を速度の20%より下にしません。

下の箱で受け取るzipには九つの場面の完成ソースと編集用のファイルが入っていて、開くときの圧縮パスワードはfj24x8mtです。

パドルの幅と球の速度段階と回転の伝わり方を変えると、感じはどれほど変わるのでしょうか — PADDLE_W・SPEED_STEP・SPIN_GAIN を変えた違い

名前 変えた値 感じ
パドルの幅を狭める 01 PADDLE_W 96 → 56 当たった位置の割合は同じなので反射角の表(−1〜1が±58度)はそのままです。変わるのはpxの幅で、同じ0.7の位置がパドルの上で33.6pxから19.6pxに狭まり、球を受けられる半幅は51pxから31pxに減ります。自動操縦は落下地点に立つので12秒の結果は左11枚・右4枚のままでしたが、人が動かすときの余裕はそのぶん減ります
速度段階を急にする 07 SPEED_STEP 70 → 150 速度が190 → 340 → 490 → 590(SPEED_MAX で切れる)と三段階で終わり、最高速度に届く時刻が10.0秒から5.3秒に早まり、12秒に壊すブロックは24枚から27枚に増えます。速度が高くても歩みが STEP_MAX の欠片に刻まれるので、07の12秒の場面では通り抜けが0回ですが、コマを1/30秒に伸ばすと590px/秒の球は一コマで19.7px進み、ブロックの厚さ+直径の16pxを超えるので02の式で19%が通り抜けます
回転の伝わりを大きくする 04 SPIN_GAIN 0.15 → 0.45 同じ最初の当たり(パドル速度300px/秒、球の横速度86.7px/秒)で回転が45から135になり、0.5秒後の横の移動が+19.3px(右へ少し進む)から−28.2px(当たった位置を過ぎて左へ戻る)に変わり、横速度は−4.8から−163px/秒になります。回転を渡さない右の盤は+43.4pxのまま真っ直ぐ進みます

値を変える前に見ること

三つの値は互いに触れません。PADDLE_W はpxの幅だけ、SPEED_STEP は速度の段階だけ、SPIN_GAIN は回転だけを変えるので、一度にひとつだけ変えれば、どの値が感じを変えたのかが見えます。

動きを減らす設定では止まった一枚のブロック盤です — 場面の名前と残りのブロック数を文で読み上げるアクセシビリティ

矢印キーでパドルを動かし Enter で発射します

舞台の箱は九つの場面とも role="application" で tabindex 0なのでフォーカスを受け、矢印キーを最初に押すと自動のデモが止まって人が動かす側になります。Enter・Space は止まった球を発射し、止まった球がなければ一時停止で、P は一時停止、N は0.1秒の一歩、R は最初からです。マウスやタッチは押すとパドルがその位置へ追いかけます。

状態の行は文が変わったときだけ書き直します

状態の行はデモ中は aria-live="off" で読まれず、人が動かす瞬間に polite へ変わり、文が変わったときだけ書き直します。一つの盤が終わる0.6秒前には、role="status" の隠れた段落に結果の文を一度だけ書きます。

動きを減らす設定と隠れたタブ

prefers-reduced-motion がオンならループを回さず、showRest が REST_S 秒の地点の止まった場面を一枚だけ描きます。タブが隠れたらループを止め、戻ったらまた始めます。この処理は MDN の Page Visibility API と prefers-reduced-motion の文書に従います。

    // タブが隠れたら止め、戻ったら時計を合わせ直して始める — 隠れていた数秒が一コマに集まらないように
    document.addEventListener('visibilitychange', function () {
        if (document.hidden) {
            stopLoop();
        }
        else if (!paused && !still) {
            startLoop();
        }
    }, { signal: off.signal });
    // 設定が変わったら — オンなら止まった場面を一枚、オフならまた動かす
    reduceQuery.addEventListener('change', function () {
        still = reduceQuery.matches;
        if (still) {
            showRest();
        }
        else {
            lap = 0;
            play();
        }
    }, { signal: off.signal });

ブロック崩しのよくある質問 — ライブラリなしで作る・球がブロックをすり抜けるときの対処・ウェブに載せるときに必要なこと

ブロック崩しゲームを載せるページに、ライブラリは必要ですか

キャンバス2Dと rAF だけで足ります。九つの場面はライブラリ・WebGL・CDNを使わず、場面のソースは一つのファイルに計算の枠と場面をいっしょに入れています。

球がブロックをすり抜けるときはどうすればよいですか

移動の区間を刻むのが答えです。STEP_MAX をブロックの厚さと球の直径の合計より小さくしておけば、02のスイープの12秒の場面では通り抜けが0回でした。

ウェブに載せるときは何が必要ですか

静的ファイルひと組で足ります。キャンバスは devicePixelRatio を2までしか掛けず、外部フォントもサーバー呼び出しもないので、どの静的ホスティングにも載せられます。

出典は MDN の requestAnimationFrame の文書です。

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

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