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

マインスイーパ 作り方 9種 — 広がって開くマスから確率マップの推理ヒントまで

最初のクリックが地雷だと盤が台なしですが、02番は開いたあとで盤を組み、05番は数字二つを重ねて確実なマスを見つけ、06番は確実なマスがないときマスごとの確率を塗ります。マインスイーパ 作り方 9種は、盤の状態を計算するコードと、その状態だけを描くキャンバスを分けて作ったデモです。

マインスイーパはルールではなく計算で理解します — マインスイーパ 作り方は一つのマスの八つの隣を数えるところから始まります

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

九つの場面は同じ構造で動きます。地雷の位置・数字・開閉・旗は盤のロジックが作る値で、画面はその値と場面の時計を見比べて描くだけなので、見えているのは演出ではなく計算された盤です。ライブラリとCDNは使わず、乱数も種を固定した生成器一つだけなので、同じ種からは同じ盤ができます。

盤のロジックとキャンバスを分けた理由

盤は newBoard が作る配列の束です。placeMines・calcNums・revealFrom・chordList・pairHint・probMap はこの配列だけを読み書きする関数で、drawCells がマスごとの開いた時刻・爆ぜた時刻・旗の時刻を場面の時計 sceneT と比べて描きます。九つのファイルが同じ盤ロジックの関数を持っているので、画面を替えてもマインスイーパの決まりには手を触れません。

    function newBoard(cols, rows, count) {
        const b = { cols: cols, rows: rows, count: count, mine: [], num: [], st: [], openT: [], exT: [], flT: [], xT: [], placed: false };
        for (let i = 0; i < cols * rows; i += 1) {
            b.mine.push(false);
            b.num.push(0);
            b.st.push(0);
            b.openT.push(0);
            b.exT.push(-1);
            b.flT.push(-1);
            b.xT.push(-1);
        }
        return b;
    }

一盤は時間表で流れます

場面ごとに script へ(時刻, すること)の組を詰めておき、advance(dt) が時刻を過ぎたことを一つずつ呼びます。一盤の LAP_S が過ぎると種を lap % 2 で替えて二つの盤を交互に見せ、最初の盤は START_S だけ先の地点から始めるので、進行中の場面が最初の画面に出ます。

場面ごとに足した計算ひと手間

01は距離の順に遅れる広がり、02は最初のクリックの周りを空けて地雷を移す組み直し、03は隣の数を数えるスキャン、04は旗の数の条件を足しました。05は二つの数字の閉じた隣の集合の比べ合わせ、06は配置の全数計算と組み合わせの重み、07は距離による爆発の時刻と衝撃波の場、08は旗の数から残りの地雷を逆算するメーター、09は線が過ぎた角度で明るさを減らすソナーを足しています。

マインスイーパ 作り方 9種 — 広がって開く連鎖オープン・盤をあとで組む安全な初手・隣を数える数字スキャン・一度に開くコード開け・条件を重ねる推理ヒント・確率を塗るマップ・連鎖して爆ぜる衝撃波・ばねの旗メーター・ソナーがまわるヒーロー

九つの場面を描いて測った面積・フレーム・強さ

場面 累積面積 動いたフレーム 強さの平均
01 連鎖オープン 24.9% 16/23 31.3
02 安全な初手 34.6% 20/23 58.1
03 数字スキャン 34.8% 23/23 71.3
04 コード開け 6.3% 13/23 38.9
05 推理ヒント 20.1% 15/23 56.5
06 確率マップ 30.2% 14/23 50.6
07 衝撃波 63.4% 22/23 35.3
08 旗メーター 4.6% 22/23 78.1
09 ソナーヒーロー 38.9% 23/23 48.4

盤全体が揺れる07番の63.4%がいちばん広く、数字が一つ替わるメーター中心の08番は4.6%といちばん狭いのに、強さの平均は78.1で最大です。毎フレーム動いたのは03番と09番だけで、04番は13/23なので、コード開けを待つあいだは変化のないフレームが多くなります。

01空きマスを開くと波のように広がる連鎖オープン

マインスイーパ学習サイトの最初の盤面に置くと、空きマスを一つ開いたとき隣の空きマスが距離の順に時間差で開き、数字のマスで止まる広がりがすぐ見えます。revealFrom は距離のいちばん小さいマスから取り出して openT = t0 + d × RIP_DELAY で開く時刻を書き、開いたマスが0のときだけ隣を待ち行列に入れます。最初のクリックは0.5秒に57マスを開き、いちばん遠いマスが距離14なので広がりは1.4秒で終わります。

空きマスを開くと波のように広がる連鎖オープン
新しいタブで開く

01 · 空きマスを開くと波のように広がる連鎖オープン — 計測 累積面積 24.889% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RIP_DELAYrevealFromfloodSize
        const todo = src.slice();
        const out = [];
        while (todo.length > 0) {
            let k = 0;
            for (let j = 1; j < todo.length; j += 1) {
                if (todo[j].d < todo[k].d) {
                    k = j;
                }
            }
            const cur = todo.splice(k, 1)[0];
            if (b.st[cur.i] !== 0 || b.mine[cur.i]) {
                continue;
            }
            b.st[cur.i] = 1;
            b.openT[cur.i] = t0 + cur.d * delay;
            out.push(cur.i);
            if (b.num[cur.i] === 0) {
                for (const j of nbrs(b, cur.i)) {
                    if (b.st[j] === 0) {
                        todo.push({ i: j, d: cur.d + 1 });
                    }
                }
            }
        }
        return out;

02最初の一手で踏まないよう、開いたあとで盤を組む安全な初手

最初のクリックで負ける不公平をなくす決まりを説明するとき、盤が空のままで、最初のマスを開いた瞬間に組まれる点がこの場面の要です。placeMines は種で全マスを混ぜて前の count マスに地雷を置き、最初のマスと周りの八マスにかかった地雷は混ぜた順の後ろの空きマスへ移して [元のマス, 新しいマス] の一覧を返します。最初のクリックの55番は先に置いた盤で地雷でしたが、区域内の地雷3個が移ると区域内の地雷は0になり、開くマスは77マスです。

最初の一手で踏まないよう、開いたあとで盤を組む安全な初手
新しいタブで開く

02 · 最初の一手で踏まないよう、開いたあとで盤を組む安全な初手 — 計測 累積面積 34.646% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

placeMinesinZoneOPEN_DELAY
        if (safe >= 0) {
            zone[safe] = true;
            for (const j of nbrs(b, safe)) {
                zone[j] = true;
            }
        }
        const moves = [];
        let next = b.count;
        for (let i = 0; i < n; i += 1) {
            b.mine[i] = false;
        }
        for (let k = 0; k < b.count; k += 1) {
            const c = order[k];
            if (!zone[c]) {
                b.mine[c] = true;
                continue;
            }
            while (next < n && (zone[order[next]] || b.mine[order[next]])) {
                next += 1;
            }
            b.mine[order[next]] = true;
            moves.push([c, order[next]]);
            next += 1;
        }

03八つの隣の地雷を数えて数字を埋めるスキャン

数字がどう作られるかを見せるルール解説には、走査の枠がマスを一つずつなぞって隣を数える場面が向いています。calcNums が nbrs で盤の中の隣だけを選んで地雷を数え、場面は bornT(i) = SCAN_START + i / SCAN_RATE で数えた値が浮かぶ時刻を決めます。128マスを毎秒32マスでなぞると4.3秒で終わり、地雷でない106マスの数字は0が28、1が43、2が24、3が7、4が4マスです。

八つの隣の地雷を数えて数字を埋めるスキャン
新しいタブで開く

03 · 八つの隣の地雷を数えて数字を埋めるスキャン — 計測 累積面積 34.759% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SCAN_RATEnbrscalcNums
    function calcNums(b) {
        for (let i = 0; i < b.cols * b.rows; i += 1) {
            let n = 0;
            if (!b.mine[i]) {
                for (const j of nbrs(b, i)) {
                    if (b.mine[j]) {
                        n += 1;
                    }
                }
            }
            b.num[i] = n;
        }
    }

04数字と旗の数が合えば隣を一度に開くコード開け

上級者向けの近道操作を紹介するガイドでは、旗の数が数字と同じときだけ隣が開くという条件が要になります。chordList は開いた数字のマスで、数字が0でなく flagsAround が数字と等しいときだけ閉じた隣の一覧を返し、doChord はその一覧に地雷が混ざっていれば exT で爆ぜさせて間違った旗に×印を灯します。最初の場面は旗二本を0.9秒と1.35秒に立てて2.15秒に隣を開き、間違った旗の場面は6.6秒のコード開けで地雷を爆ぜさせ、そのとき開いているマスは61です。

数字と旗の数が合えば隣を一度に開くコード開け
新しいタブで開く

04 · 数字と旗の数が合えば隣を一度に開くコード開け — 計測 累積面積 6.338% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CHORD_DELAYchordListflagsAround
    function chordList(b, i) {
        if (b.st[i] !== 1 || b.num[i] === 0 || flagsAround(b, i) !== b.num[i]) {
            return [];
        }
        const out = [];
        for (const j of nbrs(b, i)) {
            if (b.st[j] === 0) {
                out.push(j);
            }
        }
        return out;
    }

05隣の数字の条件を重ねて確実なマスを見つける推理ヒント

詰まった盤面で次の一手を示すヒント機能には、二つの数字マスの条件を重ねて見る方法が向いています。consOf が開いた数字ごとに閉じた隣 u と残りの地雷 r を集めると、pairHint は一つの数字の u が別の数字の u にまるごと含まれるとき、差の集合の地雷数 r_B − r_A で安全か地雷かを分けます。16×8の盤150個から出たヒント3,536個を実際の地雷配置と突き合わせて食い違いは0件で、実演の最初の盤のヒント6個はすべて二つの数字によるヒントです。

隣の数字の条件を重ねて確実なマスを見つける推理ヒント
新しいタブで開く

05 · 隣の数字の条件を重ねて確実なマスを見つける推理ヒント — 計測 累積面積 20.097% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pairHintsubsetOfHINT_SHOW
    function pairHint(cs) {
        for (const B of cs) {
            if (B.r === 0 || B.r === B.u.length) {
                continue;
            }
            for (const A of cs) {
                if (A.c === B.c || A.u.length >= B.u.length || !subsetOf(A.u, B.u)) {
                    continue;
                }
                const diff = B.u.filter(function (v) { return A.u.indexOf(v) < 0; });
                const rd = B.r - A.r;
                if (rd === 0) {
                    return { kind: 0, cells: diff, a: A.c, b: B.c };
                }
                if (rd === diff.length) {
                    return { kind: 1, cells: diff, a: A.c, b: B.c };
                }
            }
        }
        return null;
    }

06確実なマスがないとき、マスごとの地雷確率を色の濃さで塗る確率マップ

運が分かれる場面でいちばん安全な手を選ぶ解説には、マスごとの確率を色の濃さで塗ったマップが向いています。probMap は開いた数字に接する境界のマスの配置を再帰ですべて数え、外側のマスは残りの地雷ごとに comb(外側のマス, 残りの地雷) の重みで分け合い、探索の節点が PROB_NODE_CAP を超えたら止めます。6×4の盤25個を配置まで直接数えて比べた最大の差は0で、実演でいちばんうすいマスはラウンド1が13.8%、ラウンド2が13.9%、ラウンド3が0%です。

確実なマスがないとき、マスごとの地雷確率を色の濃さで塗る確率マップ
新しいタブで開く

06 · 確実なマスがないとき、マスごとの地雷確率を色の濃さで塗る確率マップ — 計測 累積面積 30.206% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

probMapcombPROB_NODE_CAP
        const M = b.count - flags;
        let total = 0;
        let innerMines = 0;
        const pf = [];
        for (let k = 0; k < F.length; k += 1) {
            pf.push(0);
        }
        for (let m = 0; m <= F.length; m += 1) {
            if (sol[m] === 0) {
                continue;
            }
            const w = comb(inner.length, M - m);
            if (w === 0) {
                continue;
            }
            total += w * sol[m];
            innerMines += w * sol[m] * (M - m);
            for (let k = 0; k < F.length; k += 1) {
                pf[k] += w * cm[m][k];
            }
        }

07地雷を踏むと盤じゅうの地雷が連鎖して爆ぜる衝撃波

敗北の演出が必要なゲームの結果画面には、踏んだマスから広がる円が届く順に残りの地雷が爆ぜる場面が合います。startChain が地雷ごとに exT = sceneT + 距離 / RING_SPEED で爆ぜる時刻を書き、衝撃波の場は exp(−((d − r) / WAVE_W)²) に時間の減衰を掛けて、マスを外向きに PUSH だけ押して戻します。地雷18個のうち踏んだ地雷からいちばん遠いものは424pxの距離なので、最後の爆発は0.3 + 424/170で2.79秒に起こります。

地雷を踏むと盤じゅうの地雷が連鎖して爆ぜる衝撃波
新しいタブで開く

07 · 地雷を踏むと盤じゅうの地雷が連鎖して爆ぜる衝撃波 — 計測 累積面積 63.435% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RING_SPEEDWAVE_SPEEDPUSH
            const r = age * WAVE_SPEED;
            const amp = Math.exp(-age * 1.6);
            for (let i = 0; i < COLS * ROWS; i += 1) {
                const ddx = cellCx(i) - cellCx(s);
                const ddy = cellCy(i) - cellCy(s);
                const d = Math.hypot(ddx, ddy);
                if (d < 1) {
                    continue;
                }
                const q = (d - r) / WAVE_W;
                const e = Math.exp(-q * q) * amp;
                dxs[i] += ddx / d * e * PUSH;
                dys[i] += ddy / d * e * PUSH;
                glow[i] += e;
            }

08旗がばねで打ち込まれ、残りの地雷数が転がって減る旗メーター

モバイルゲーム上部のHUDで使うカウンターには、旗を立てるたびに数字が転がって減るメーターが合います。remain() は MINES − countSt(B, 2) で残りの地雷数を求め、値が変わると mT に時刻を書いて0.2秒かけて古い数字を下へ押し出し、新しい数字を上から降ろして pad3 で符号と三桁をそろえます。旗は正しいもの14本と間違ったもの6本の計20本で最後の旗は4.48秒に立ち、メーターは18から−2まで下がり、間違った旗を外すと4に戻ります。

旗がばねで打ち込まれ、残りの地雷数が転がって減る旗メーター
新しいタブで開く

08 · 旗がばねで打ち込まれ、残りの地雷数が転がって減る旗メーター — 計測 累積面積 4.63% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLAG_GAPpad3remain
        ctx.save();
        ctx.beginPath();
        ctx.rect(bx, 41, 104, 26);
        ctx.clip();
        const p = ease((sceneT - mT) / 0.2);
        text(pad3(mPrev), W / 2, 54 + p * 18, INK, 1 - p, 20, 800, 'center');
        text(pad3(mNow), W / 2, 54 - (1 - p) * 18, INK, p, 20, 800, 'center');
        ctx.restore();

09ソナーの線がまわってマスの下の数字を現す地雷原ヒーロー

ゲーム・セキュリティサービスのランディングの最初の画面には、ソナーの線が通ったマスだけ数字が浮かぶ暗い盤が合います。lagOf が線がマスの中心の向きを過ぎた角度を求めると、マスの明るさと数字の不透明度は exp(−lag / FADE_ANGLE) で、地雷の点滅は exp(−lag / BLINK_ANGLE) で決まります。OMEGA が1.15なので線の一周は5.46秒で、一盤の LAP_S は二周ぶんの10.93秒です。

ソナーの線がまわってマスの下の数字を現す地雷原ヒーロー
新しいタブで開く

09 · ソナーの線がまわってマスの下の数字を現す地雷原ヒーロー — 計測 累積面積 38.867% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

OMEGAFADE_ANGLElagOf
            const lag = lagOf(i, a);
            let k = Math.exp(-lag / FADE_ANGLE);
            const pa = sceneT - pingT[i];
            if (pa >= 0 && pa < 2) {
                k = Math.max(k, Math.exp(-pa / 0.7));
            }
            if (k < 0.04) {
                k = 0;
            }
            if (k > 0.3) {
                lit += 1;
            }

マインスイーパの仕組みをコードに移すと、マスはいくつの状態をもてばよいのでしょうか?

マスがもつ値は三つの状態と四つの時刻です

マス一つは地雷かどうかと隣の地雷数、閉じる・開く・旗の三つの状態、そして起きた時刻四つに分かれます。時刻を状態と別に置くと、広がりのように開くのが距離ぶん遅れる場面でも、状態はすぐ替えて画面だけを遅らせられます。

値 意味 変える関数
mine 地雷かどうか placeMines
num 隣の地雷数 calcNums
st 0・1・2 閉じる・開く・旗 revealFrom・applyHint
openT・exT・flT・xT 開いた・爆ぜた・旗を立てた・×印がついた時刻 revealFrom・startChain・applyHint・doChord

マインスイーパのルールを学習ページに載せるとき

マインスイーパー ルールを解説するページに入れるなら、05の findHint(b) は盤を一つ受け取ってヒントを一つ返す関数なので、ボタン一つにつなげます。盤のロジックは画面を知らないので、キャンバスをDOMのマスに替えても同じ関数をそのまま使えます。

プログラミングの課題に出すとき

授業の課題なら、03の calcNums を一つめ、01の revealFrom を二つめに分けると、隣を数えることと広げて開くことが別の問題として解けます。マインスイーパー 法則を説明する記事を書くときも、05の部分集合の比較と06の確率マップがそのまま図になります。

盤を配列にし、最初のクリックのあとで地雷を置き、隣を数え、空きマスから広げて開くまで — マインスイーパ一盤ができる順番です

盤を作り、開き、ヒントを数える順番

05の build がこの順番を一度に見せます。新しい盤を作り、中央のマスを安全なマスとして placeMines し、revealFrom で広げさせ、盤の複製で findHint と applyHint を六回まわしてヒントが続くかを見てから、条件に合う種を選びます。

            B = newBoard(COLS, ROWS, MINES);
            placeMines(B, base + k, center);
            revealFrom(B, [{ i: center, d: 0 }], -9, 0);
            const c = cloneBoard(B);
            let pairs = 0;
            let mineHints = 0;
            let total = 0;
            for (let s = 0; s < 6; s += 1) {
                const h = findHint(c);
                if (!h) {
                    break;
                }
                total += 1;
                if (h.b >= 0) {
                    pairs += 1;
                }
                if (h.kind === 1) {
                    mineHints += 1;
                }
                applyHint(c, h, -9, 0);
            }
            if (total >= 6 && pairs >= 4 && mineHints >= 1 && countSt(B, 1) >= 30) {
                break;
            }
        }

実演に合う盤を選ぶ探索

種がよい盤をくれないときに備えて、場面ごとに候補を探します。01は SEED + k で floodSize が MIN_FLOOD の44以上になる盤を、05は上の条件に合う盤を120回まで、06は開いたマスが28〜64マスの盤を600回まで探します。

マインスイーパが壊れるのは、最初のクリックが地雷の盤・広がりが盤の端を越えて読むマス・旗の数が残りの地雷数とずれるメーターです

最初のクリックが地雷の盤

02は地雷を先に置いた盤と最初のクリックのあとで組んだ盤を並べて見せますが、時間表の爆発は1.0秒で、最初の盤が始まる2.2秒より前です。そのため失敗した盤との対比は二つめの盤から見え、最初の盤では安全な区域が表示される段階のほうが先に目に入ります。

広がりが盤の端を越えて読むマス

広がりは隣の八マスを読みますが、端のマスが i + 1 を足すだけで隣を探すと、右端のマスの隣が次の行の左端のマスになります。この編は境界の検査を nbrs の一か所にだけ置いたので、数える処理も広がりもヒントも同じ隣の一覧を使います。

    function nbrs(b, i) {
        const x = i % b.cols;
        const y = Math.floor(i / b.cols);
        const out = [];
        for (let dy = -1; dy <= 1; dy += 1) {
            for (let dx = -1; dx <= 1; dx += 1) {
                if (dx === 0 && dy === 0) {
                    continue;
                }
                const nx = x + dx;
                const ny = y + dy;
                if (nx >= 0 && nx < b.cols && ny >= 0 && ny < b.rows) {
                    out.push(ny * b.cols + nx);
                }
            }
        }
        return out;
    }

旗の数が残りの地雷数とずれるメーター

08は旗を地雷数の18個より多い20本まで立ててメーターを−2まで下げます。pad3 が符号をつけて三桁にそろえるので−2は -002 と表示され、旗の数は正解かどうかを知らない値なので、間違った旗6本は確認の段階の×印でだけ明らかになります。

確実なマスがない盤はまれです

06は中央のマスを開いて広がった盤600個のうち、閉じたマスに確率0%と100%が一つもない盤が19個しかなく、どれも開いたマスは9〜18マスでした。そのため solveOut で確実なマスをすべて処理した、開いたマスが28〜64マスの盤を使います。

九つの場面の完成版ソースと編集用ファイルをまとめたzipは下から受け取れて、圧縮パスワードはvh7z44k3です。

盤の大きさと地雷の密度を変えると推理ヒントが届くマスはどれだけ変わるのでしょうか — 列・行・地雷数・シードを変えて比べます

名前 変えた値 感じ
盤を大きく 01 COLS×ROWS 16×8 → 30×16 (MINES 16 → 60) 密度12.5%を保つと最初の広がりは57マスから121マスに増えますが、いちばん遠いマスの距離は14から16なので広がる時間は1.4秒から1.6秒です。マスの大きさが28pxから15pxに縮んで数字の文字は17pxから9pxになります
地雷を密に 02 MINES 22 → 40 密度が17%から31%になると移す地雷が3個から4個に増え、安全な初手のあとに開くマスは77マスから9マスに減ります。区域の周りの隣に地雷が接して数字0が真ん中の一マスだけなので、広がりが距離1で止まります
広がりをゆっくり 01 RIP_DELAY 0.1 → 0.25 いちばん遠いマスが開く時刻が1.4秒から3.5秒に遅れて広がりは4.0秒に終わり、3.9秒の二回目のクリックと少し重なります。マスが沈む時間の0.28秒は同じなので、マス一つの動きは変わりません

値を変える前に見ること

推理ヒントは開いた数字のマスからしか出ないので、開いたマスが77から9に減れば、ヒントの材料もそのぶん減ります。盤を大きくするとマスが28pxから15pxに縮んで押し分けにくくなるため、タッチ画面では盤の大きさよりマスの大きさを先に決めるほうがよいです。

動きを減らす設定では開いた盤が一枚です — マスの状態を文で読み上げ、キーボードでマスを移るアクセシビリティです

矢印キーでマスを移して開き、旗を立てます

九つの場面すべてで、舞台の箱が role="application" で、フォーカスを置いたまま矢印キーを押すと自動の実演が止まって自分で操作する状態に移ります。Enter・Spaceはそのマスを開き(開いた数字ならコード開け)、Fは旗、Pは一時停止、Nは時間表の次のこと一つ、Rはやり直しです。

        if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
            e.preventDefault();
            takeOver();
            moveCur(k === 'ArrowLeft' ? -1 : (k === 'ArrowRight' ? 1 : 0), k === 'ArrowUp' ? -1 : (k === 'ArrowDown' ? 1 : 0));
            showNow();
        }
        else if (k === 'Enter' || k === ' ') {
            e.preventDefault();
            takeOver();
            onAct(ky * gCols + kx);
            showNow();
        }
        else if (k === 'f' || k === 'F') {
            e.preventDefault();
            takeOver();
            onFlag(ky * gCols + kx);
            showNow();
        }

状態の行は実演中は静かです

状態の行は実演中は aria-live="off" なので出来事のたびに読まれることはなく、自分で操作する瞬間に polite に替わります。一盤が終わる0.6秒前には、role="status" の隠した段落にいまの状態の文を一度だけ書きます。

    // 一盤の実演が終わったことをスクリーンリーダーに一度だけ知らせます。盤が繰り返されても読み直しません
    let doneSaid = false;
    function checkDone() {
        if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
            doneSaid = true;
            doneP.textContent = statusText();
        }
    }

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

prefers-reduced-motion が有効なら、ループなしで showRest が REST_S 秒の地点まで時間表をまわして開いた盤を一枚だけ描きます。タブが隠れるとループを止め、戻ったときの最初のフレームの dt を捨てるので、隠れていた時間が一度に流れ込むことはありません。

    function showRest() {
        stopLoop();
        lap = 0;
        sceneT = 0;
        evAt = 0;
        script = [];
        build();
        runTo(REST_S);
        showNow();

数字の色8種はクリーム #fcf9f4 の地の上でコントラストが5.7:1から14.3:1のあいだです。

マインスイーパ 作り方のよくある質問 — ライブラリなしで作る方法・数字の色の選び方・確実なマスがない盤の扱い

マインスイーパをライブラリなしで作れますか

キャンバス2D一つで足りて、九つの場面のどれもライブラリ・WebGL・CDNを使いません。乱数は種を固定した生成器で作るので同じ入力は同じ盤になり、盤ロジックの関数はDOMを知らないので、切り出してNodeで動かして確かめられます。

数字の色はどう選べば読めますか

数字ごとに色が違うと分布がひと目で読めますが、色だけで区別してはいけないので、この編は数字の文字も一緒に描き、8種すべてで地との対比を5.7:1以上にそろえました。地の色が変わったら、数字の色のコントラストも測り直します。

確実なマスがないときのマインスイーパのコツはありますか

そのときは06のように確率を計算して、いちばんうすいマスを選べばよいです。この編のラウンド3ではいちばんうすいマスが0%なので、開くと確実に安全なマスが生まれます。

参考として、MDNのrequestAnimationFrameのページとCanvas 2Dのページ、動きを減らす設定はprefers-reduced-motionのページとライブ領域のページが基準です。パズルを組む決まりはジグソーパズルの編を、マスを合わせる決まりは2048ゲームの編を、格子の上で道を探す場面は迷路ジェネレーターの編をあわせて読めます。

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

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