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

javascript ピンボール 9種 — 抽選盤・フリッパー・バンパー・ビー玉レース

javascript ピンボールは、名前を付けたボールをピンや棒やバンパーの間に転がして当選を決める、キャンバスの抽選盤です。ボールを同じ所から落として枠ごとに名前を付けると、なぜ真ん中の名前ばかり当たるのでしょうか。

抽選盤からミニテーブルまで — ボールが転がる九つの舞台

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

何も触らなければ、どの枠でも同じシードの盤がひとりでに動きます。終わりのある01・07・08は結果を1,600ms見せてから同じシードで最初から転がし直し、02・09のフリッパーは、ボールが棒の上面の区域に入ると押して240ms(09は220ms)後に離す仮想の入力が代わりに打ちます。05・09の発射バネもボールが上がってくると自動で引いて離し、09は実演の1,300msに一度台を揺らしてから3,600msごとに揺らし直します。ボタンを初めて押した瞬間に実演は止まり、そこからは押したとおりに動きます。01・07・08の「もう一度抽選」と03・04・06の「もう一度転がす」は、シードを一つ上げて新しい盤を始めます。

並べ方は、ボールが受ける力が一種類ずつ増えるようにしました。01は止まったピンにぶつかるだけで、02は動く棒が自分の速さをボールに乗せ、03は触れた瞬間に決まった衝撃を、04は斜辺で上乗せの力を、05は引いた長さに比例した力を与えます。06は休まず回る棒、07は坂と転がり摩擦で順位を分け、08はボール一つではなく60個が積もる形で確率を見せ、09は02・03・05を一台にまとめます。

javascript ピンボール 9種 — 名前ボールの抽選盤・フリッパー・バンパー・三角キッカー・発射バネ・風車の棒・ビー玉レース・分布盤・ミニピンボール台

九つの舞台は、キャンバス2DとJavaScriptだけで動いています。物理エンジン・ライブラリ・WebGL・CDN・画像ファイルは一つもなく、乱数もMath.randomではなく32ビット整数をかき混ぜるmakeRngの一か所からしか出てきません。画面の更新の合図(requestAnimationFrame の解説)は16msの計算ティックに置き換えて積み、舞台ごとにその1ティックをさらに4〜6歩(SUBSTEPS)に刻んで衝突を測ります。

480×300の窓で撮った24枚と、隣り合う23回の差分

各舞台を480×300の窓で2秒レンダーし、直前の1枚と比べて一度でも色が変わった画素が舞台に占めた広さを累積面積、23回の比較のうち画面が変わった回数を動いたフレームとして記録しました。

舞台 累積面積 動いたフレーム 強度の平均
01 名前ボールの抽選盤 19.1% 23/23 99.9
02 フリッパー 12.5% 23/23 98.5
03 バンパー 21.5% 23/23 83.1
04 三角キッカー 16.8% 23/23 97.3
05 発射バネ 8.5% 23/23 98.7
06 風車の棒 28.8% 23/23 103.9
07 ビー玉レース 14.2% 23/23 72.4
08 分布盤 8.8% 23/23 82.7
09 ミニピンボール台 14.3% 23/23 85.5

九つとも、23回の比較のたびに画面のどこかが変わっていました。06が28.8%でいちばん広いのは、羽根三枚の棒二本が休まず回って7個のビー玉を舞台のあちこちへ打ち出すためと考えられ、05が8.5%でいちばん狭いのは、バネを引いて離すあいだ舞台の大半が止まっているためと考えられます。

配信の視聴者抽選からチーム発表の順番決めまで、置き場所

配信画面に視聴者抽選を出すなら、01がいちばん近い形です。ゴール線を先に越えたボールを当選として読むので、見ている人の誰もが結果を目で追え、いまA〜Hの文字が入っている8個のボールの名札を参加者の名前に替えれば使えます。一人だけを手早く決める場面なら、web ルーレットの回転盤のほうが単純で済むかもしれません。

集まりで発表の順番を決めるなら07が向いています。一人だけが残る01と違い、順位表が6行すべてをゴールした順とタイムで残すからです。08は結果より過程を見せたいときに使い、真ん中へ集まる偏りを先に描いてから名前を混ぜて割り当てるので、景品抽選の案内ページで「枠の位置で有利にはならない」という説明を図が代わりに引き受けます。ひとりで遊べる「ピンボール ブラウザゲーム」をページに一台置きたいなら、フリッパー・バンパー・発射バネを一台に組んだ09がそのまま土台になります。

実際の抽選に移すとき、替えるべきものが一つあります。デモはシード固定なので結果が再現されます — 実際の抽選に使うときは、シードを外部の入力から受け取る必要があります。シード4761の盤は何度開き直してもAが1,040msで当選し、「もう一度抽選」もseed += 1のあとでrestart()を呼ぶだけなので、4761の次の盤は4762と決まっています。

01名前のボールがピンの間を落ちる抽選盤

A〜Hの名札を付けた8個のボールが66本のピンの間を落ち、ピンと重なった深さの分だけ押し出され、近づいていた法線方向の速度だけが0.5倍で裏返って跳ねます。ボール全体がゴール線(y 238)を先に越えたものが当選なので、配信の視聴者抽選画面では誰が先に入ったかをそのまま結果に使えます。シード4761の盤ではAが1,040msで当選し、ピンに127回触れたあと5,616msで8個すべてが枠に収まります。

名前のボールがピンの間を落ちる抽選盤
新しいタブで開く

01 · 名前のボールがピンの間を落ちる抽選盤 — 計測 累積面積 19.085% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PEG_ROWSRESTITUTIONSUBSTEPS
    function hitCircle(b, cx, cy, cr, e) {
        const dx = b.x - cx;
        const dy = b.y - cy;
        const d = Math.hypot(dx, dy);
        const overlap = b.r + cr - d;
        if (overlap <= 0) {
            return -1;
        }
        // 中心がちょうど重なると向きが決まらない — 上へ押し出す
        const nx = d > 1e-9 ? dx / d : 0;
        const ny = d > 1e-9 ? dy / d : -1;
        b.x += nx * overlap;
        b.y += ny * overlap;
        contact.nx = nx;
        contact.ny = ny;
        contact.px = cx + nx * cr;
        contact.py = cy + ny * cr;
        const vn = b.vx * nx + b.vy * ny;
        if (vn >= 0) {
            return 0;
        }
        b.vx -= (1 + e) * vn * nx;
        b.vy -= (1 + e) * vn * ny;
        return -vn;
    }

02回る速さをボールに乗せて打ち出すフリッパー

棒の上でボールにいちばん近い点を探し、軸からその点までのベクトルを90°回して角速度を掛けた値を接点の速度として渡し、跳ね返りを測ります。先端の接点は12ラジアン/秒 × 86pxで1,032px/sになり、一振りに77msかかるので、棒の上面に載せたボールは軸から長さの0.3の位置なら815px/s、先端なら1,496px/sで飛びます。ブラウザミニゲームのイベントページでは、上面の区域に入ったら押すという実演の規則を人の手に置き換えることになり、この規則は時刻表ではなく区域で決まっているので、シードが同じなら同じ瞬間に打ちます。

回る速さをボールに乗せて打ち出すフリッパー
新しいタブで開く

02 · 回る速さをボールに乗せて打ち出すフリッパー — 計測 累積面積 12.542% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLIPPER_SPEEDFLIPPER_LENGTHFLIPPER_REST_ANGLE
    // 1歩(h 秒) — 目標の角度までの残りを speed × h 以内に切って回す。端に着くと w は 0
    function moveFlipper(f, h) {
        const target = f.pressed ? f.up : f.rest;
        const d = clamp(target - f.angle, -f.speed * h, f.speed * h);
        f.angle += d;
        f.w = d / h;
    }
    // フリッパーとボール — 棒の上でいちばん近い点を探し、その点が回る速さ(角速度 × 軸までの距離)を相対速度に乗せる。
    // 軸から遠い先端で打つほど接点が速く、ボールが強く飛ぶ
    function hitFlipper(b, f, e) {
        const tip = flipperTip(f);
        const c = closestOnSegment(b.x, b.y, f.px, f.py, tip[0], tip[1]);
        const rx = c[0] - f.px;
        const ry = c[1] - f.py;
        // 回転で生じる接点の速度 — 軸→接点のベクトルを 90° 回して角速度を掛ける
        const svx = -f.w * ry;
        const svy = f.w * rx;
        const vn = hitSegment(b, f.px, f.py, tip[0], tip[1], f.half, e, svx, svy, 0);
        if (vn > 0 && Math.abs(f.w) > 0.5) {
            f.hitR = Math.hypot(rx, ry);
            f.hitSurf = Math.abs(f.w) * f.hitR;
        }
        return vn;
    }

03触れた瞬間に決まった強さで押し返すバンパー

ボールを円どうしの衝突で0.5倍に跳ね返したあと、触れた法線の向きへBUMPER_KICKの320px/sを足すので、真上から100・300・600px/sでまっすぐ入ったボールは370・470・620px/sで出ていきます。バンパーの半径は絵の中だけでばねとして動き、当たったあと11.8%ふくらんでから−6.8%まで行き過ぎて縮み、480msで±1%以内に落ち着きますが、当たり判定は半径24のままです。アプリ公開記念のイベントバナーのようにボールが何個か休まず跳ね続けてほしい場所に合い、実演の10秒間で4つのバンパーは合わせて17回当たりました。

触れた瞬間に決まった強さで押し返すバンパー
新しいタブで開く

03 · 触れた瞬間に決まった強さで押し返すバンパー — 計測 累積面積 21.528% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BUMPER_KICKBUMPER_RADIUSBUMPER_STIFFNESS
    // ボールとバンパー — 円と円の衝突で重なった分を押し出して跳ね返したあと、入ってきた速さに関係なく同じ強さ kick を法線の向きへ足す。
    // 当たり判定は絵のふくらんだ半径ではなく r のまま(ふくらみは絵だけ)
    function hitBumper(b, m, e) {
        const vn = hitCircle(b, m.x, m.y, m.r, e);
        if (vn <= 0) {
            return vn;
        }
        b.vx += contact.nx * m.kick;
        b.vy += contact.ny * m.kick;
        m.dv += m.pop;
        m.flash = 1;
        m.hits += 1;
        return vn;
    }
    // 半径のばね — ふくらんだ分だけ引き戻す力(stiff × d)と速さに比例する抵抗(damp × dv)。抵抗が小さいので一度行き過ぎてから戻る
    function springBumper(m, h) {
        m.dv += (-m.stiff * m.d - m.damp * m.dv) * h;
        m.d += m.dv * h;
        m.flash *= Math.exp(-h * 7);
    }

04ゴムのようにしなって弾く三角キッカー

三角形の斜辺を太さの半分が2の線分として衝突させ、近づく速さが40px/sを超え、触れた法線が斜辺の外側と同じ向きのときだけ法線 × 360px/sを上乗せします。斜辺の真ん中は絵の中でだけしなり、正面から150px/sで当たると−2.24pxしなってから+1.27px跳ね返り、292msで±0.3px以内にまっすぐ戻って、ボールは435px/sで出ていきます。ゲームサイトのトップ画面の飾りとして置くと、真ん中のすき間から落ちたボールが上からまた落ちてくるので、実演の10秒で24回弾きます。

ゴムのようにしなって弾く三角キッカー
新しいタブで開く

04 · ゴムのようにしなって弾く三角キッカー — 計測 累積面積 16.78% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SLING_BOOSTSLING_BEND_MAXSLING_DAMPING
    // 斜辺 — 線分の衝突で跳ね返したあと、法線が斜辺の外を向き十分速く当たったなら法線方向の上乗せを加え、斜辺を内側へ押す
    function hitSling(b, sl) {
        const vn = hitSegment(b, sl.a[0], sl.a[1], sl.c[0], sl.c[1], 2, RESTITUTION, 0, 0, 0);
        if (vn <= SLING_MIN) {
            return;
        }
        if (contact.nx * sl.nx + contact.ny * sl.ny < 0.5) {
            return;
        }
        b.vx += sl.nx * SLING_BOOST;
        b.vy += sl.ny * SLING_BOOST;
        sl.bendV -= vn * SLING_PRESS;
        sl.flash = 1;
        sl.hits += 1;
    }
    // しなり — 戻す力(stiff × bend)と抵抗(damp × bendV)で減衰振動、深さは SLING_BEND_MAX 以内に切る
    function bendSling(sl, h) {
        sl.bendV += (-SLING_STIFFNESS * sl.bend - SLING_DAMPING * sl.bendV) * h;
        sl.bend = clamp(sl.bend + sl.bendV * h, -SLING_BEND_MAX, SLING_BEND_MAX);
        sl.flash *= Math.exp(-h * 6);
    }

05引いた分だけ力がたまる発射バネ

押している間はバネが60px/sで最大40pxまで引かれ、離すと引いた長さ × 900の加速度で戻っていき、初めて0を通る瞬間にバネの速さの0.85倍をボールの上向きの速度として渡します。引き10・20・30・40pxは255・510・765・1,020px/sの発射になり、ボールは離してから64ms後に飛び出します。ゲーム風の会員登録お祝い画面なら、ボタンを押している長さがそのまま強さになるので説明がなくても手になじみ、ボールを渡したバネは休む位置より24px上まで行き過ぎてから352msで±1px以内に止まります。

引いた分だけ力がたまる発射バネ
新しいタブで開く

05 · 引いた分だけ力がたまる発射バネ — 計測 累積面積 8.451% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PLUNGER_STIFFNESSPLUNGER_MAX_PULLLAUNCH_SCALE
            const before = p.pull;
            const a = -p.stiff * p.pull - (p.free ? 0 : p.damp * p.v);
            // 速度を先に更新する積分
            p.v += a * h;
            p.pull += p.v * h;
            if (p.free && before > 0 && p.pull <= 0) {
                p.free = false;
                if (p.ball) {
                    const speed = Math.abs(p.v) * p.scale;
                    p.ball.vx = 0;
                    p.ball.vy = -speed;
                    p.lastPull = p.releasedPull;
                    p.lastLaunch = speed;
                    p.ball = null;
                }
            }
            // ボールなしで離したり、ほんの少し引いて離したりしたときは、そのまま揺れて止まる
            if (p.free && p.releasedPull <= 0) {
                p.free = false;
            }

06回る風車の棒に巻き込まれて弾かれるビー玉

羽根三枚の棒二本が6ラジアン/秒で互いに逆向きに回り、ビー玉が当たった羽根の上の点の回転速度がそのまま接点の速度に入ります。止めたビー玉を軸から15・30・45・56pxの位置に置いて羽根で打つと170・295・423・514px/sで弾み、先に当たるほど遠くへ飛びます。グループゲームの順番決め画面に置くと、7個のビー玉が当たった位置ごとに違う勢いで弾かれ、実演の30秒間で軸から25px以内に当たった15回は平均260px/s、45pxより外に当たった79回は平均496px/sでした。

回る風車の棒に巻き込まれて弾かれるビー玉
新しいタブで開く

06 · 回る風車の棒に巻き込まれて弾かれるビー玉 — 計測 累積面積 28.824% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ROTOR_SPEEDROTOR_ARMSROTOR_LENGTH
    // 羽根とボール — 羽根の上でいちばん近い点の回転速度を接点の速度に
    function hitArm(b, r, k) {
        const w = r.dir * ROTOR_SPEED;
        const tip = armTip(r, k);
        const c = closestOnSegment(b.x, b.y, r.x, r.y, tip[0], tip[1]);
        const rx = c[0] - r.x;
        const ry = c[1] - r.y;
        const vn = hitSegment(b, r.x, r.y, tip[0], tip[1], ROTOR_HALF, RESTITUTION, -w * ry, w * rx, 0);
        if (vn > 0) {
            lastR = Math.hypot(rx, ry);
            lastV = Math.hypot(b.vx, b.vy);
            r.glow[k] = 1;
        }
    }

07坂を転がった着順で順位が決まるビー玉レース

6個のビー玉がジグザグの坂4本を転がり下り、坂に触れた一歩ごとに接線方向の速さから 1 − e^(−0.4 × 倍率 × h) の割合を差し引きますが、その倍率がビー玉ごとにシードで0.7〜1.3なので速さが分かれます。シード4767の盤はE・B・A・C・D・Fの順に4.51〜5.18秒でゴールし、摩擦を0にすると同じシードで順位がB・E・C・A・F・Dに変わります。チーム発表の順番抽選ページに使えば、順位表は走っている間はコースの進み具合で並び替わり、ゴール線を越えた順とタイムで確定します。

坂を転がった着順で順位が決まるビー玉レース
新しいタブで開く

07 · 坂を転がった着順で順位が決まるビー玉レース — 計測 累積面積 14.235% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RAMP_FRICTIONRAMP_SLOPEMARBLE_COUNT
    // コースの進み具合 — 上で通り過ぎた坂の数 × 1000 + いまの坂で転がった距離。ゴールしたビー玉はゴールした順が先
    function progressOf(b) {
        if (b.finishMs >= 0) {
            return 1e7 - b.finishMs;
        }
        let k = 0;
        for (let i = 0; i < ramps.length; i += 1) {
            const r = ramps[i];
            const x = clamp(b.x, Math.min(r[0], r[2]), Math.max(r[0], r[2]));
            if (rampY(r, x) < b.y) {
                k = i + 1;
            }
        }
        const on = ramps[Math.min(k, ramps.length - 1)];
        const along = Math.abs(clamp(b.x, Math.min(on[0], on[2]), Math.max(on[0], on[2])) - on[0]);
        return k * 1000 + along;
    }
    function standings() {
        const idx = [];
        for (let i = 0; i < balls.length; i += 1) {
            idx.push(i);
        }
        return idx.sort(function (a, b) { return progressOf(balls[b]) - progressOf(balls[a]); });
    }

08落ちた枠を積み上げて釣り鐘の形が現れる分布盤

60個のボールを70ms間隔で落として9つの枠に入った数を数え、棒の高さはティックごとに残りの差の0.14だけ追いかけて伸び、その上にbinomShareで出した期待曲線が重なります。シード4768の実演では60個のうち16個(26.7%)が真ん中の枠に入り、期待の割合27.3%に近い値でした。落ちきるとシードで混ぜた名札が700msかけて自分の枠へ滑っていき、レモン色のボールが入った7番の枠のHが当選になるので、公平な景品抽選の案内ページで偏りと混ぜ方を一画面で見せられます。

落ちた枠を積み上げて釣り鐘の形が現れる分布盤
新しいタブで開く

08 · 落ちた枠を積み上げて釣り鐘の形が現れる分布盤 — 計測 累積面積 8.804% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DROP_INTERVAL_MSSLOT_COUNTSHUFFLE_SLOTS
    // 二項分布の期待の割合 — 段ごとに半々に分かれると k 番目の枠に入る割合 = (ROWS 個から k 個を選ぶ数) / 2^ROWS
    function binomShare(k) {
        let c = 1;
        for (let i = 0; i < k; i += 1) {
            c = c * (ROWS - i) / (i + 1);
        }
        return c / Math.pow(2, ROWS);
    }

09フリッパー・バンパー・スコア表示をまとめたミニピンボール台

02と同じ関数で回るフリッパー二本(長さ70)、03のバンパー三つ(半径19、300px/s)、05の発射バネを一台に載せ、バンパー100点・フリッパーで打つと10点を、600msかけて始めと終わりがゆっくりな曲線で数字を転がして足します。「揺らす」ボタンは台の上のボールに横へ160px/s、上へ56px/sを足し、画面にはばねの速さだけを与えるので、画面が6.39px押されて−3.79px跳ね返り592msで±0.5px以内に止まる間も物理の座標はそのままです。ゲーム発売ランディングのトップ画面に置けば、実演の20秒でスコア1,580点と揺らし6回がひとりでに積み上がります。

フリッパー・バンパー・スコア表示をまとめたミニピンボール台
新しいタブで開く

09 · フリッパー・バンパー・スコア表示をまとめたミニピンボール台 — 計測 累積面積 14.274% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TABLE_GRAVITYNUDGE_IMPULSESCORE_ROLL_MS
    // 揺らす — 台の上のボールに同じ衝撃を足し、画面にはばねの速さを与える(画面の揺れは絵だけ、ボールは衝撃だけを受ける)
    function nudge() {
        if (!plunger.ball && !ball.out) {
            ball.vx += nudgeDir * NUDGE_IMPULSE;
            ball.vy -= NUDGE_IMPULSE * 0.35;
        }
        shakeV += nudgeDir * SHAKE_KICK;
        nudgeDir = -nudgeDir;
        nudges += 1;
    }

重なった分だけ押し戻し、触れた向きへ跳ね返す — 円と線分の衝突と、回るフリッパーの接点の速さの計算

重なった深さと法線方向の速度だけを見る円の衝突

「ピンボール 物理」と聞くと難しそうですが、土台になるのはこの円の衝突ひとつです。hitCircleはボールの中心とピンの中心の距離dを測り、半径の和より短ければ、その差である重なりの深さの分だけ、二つの中心を結ぶ向きにボールを押し出します。続いて速度をその向きの成分vnと残りに分け、vnが近づく側のときだけ(1 + e) × vnを引いて、法線成分を−e倍に裏返します。横に滑る成分には手を付けないので、ボールはピンの側面をかすめて斜めに抜け、01のe 0.5では近づいていた速さの半分が跳ね返りとして残ります。文字を箱ひとつとして見てぶつける計算は文字 物理 演算で扱いましたが、ここではボールもピンも円なので、角を考えずに中心線一本で片づきます。

線分はいちばん近い一点で円になる

02・09のフリッパー、04の斜辺、06の羽根、07の坂はどれも線分です。closestOnSegmentがボールの中心から線分に下ろした垂線の足を0〜1の区間で切って求めると、その点を半径が線の太さの半分の円とみなし、同じ押し出しと跳ね返りをかけます。円と違うのは接点が動けることだけなので、hitSegmentはボールの速度から接点の速度(svx, svy)を引いた相対速度で跳ね返りを測ります。07で坂の向きの力を別に計算しなくてもビー玉が転がり下りるのもこのおかげで、衝突が坂に垂直な成分だけを消すため、重力のうち坂に沿う分だけが残ります。

回る棒の上の一点の速さは角速度 × 軸までの距離

軸から接点へのベクトル(rx, ry)を90°回すとその点が回る向きになり、角速度wを掛けた(−w·ry, w·rx)が接点の速度です。大きさはw × rなので、02の先端(86px)は12 × 86 = 1,032px/s、06の先端(58px)は6 × 58 = 348px/sになります。javascript ブロック崩しのパドルは当たった位置で反射角を選びましたが、フリッパーは当たった位置がそのままその点の速さなので、角度と強さが一緒に変わります。02で軸から0.6〜0.7の位置の結果が1,269・1,057px/sとばらついたのは、振っている途中で接点が棒に沿って滑るためと考えられます。

引き40pxが秒速1,020pxになる道筋

05のバネは引いた長さxに対して900 × xの押し戻す加速度を受けます。この力でためたエネルギーをすべて返してもらうと、0を通る瞬間の速さは√900 × x = 30xで、これにLAUNCH_SCALEの0.85を掛けた25.5xがボールに渡ります。そのため40pxは1,020px/s、10pxは255px/sですが、10pxで打ったボールはレーンを登りきれず、いちばん高くてy 204まで上がってからバネの上へ落ちてきます。離してから最初に0を通るまで抵抗を0にしているのは引いた分を少しも削らずに返すためで、抵抗24はボールを渡したあとで初めて効きます。

ボール 抽選 ゲームは、なぜ真ん中の枠ばかり当たるのか?

8段で半々に分かれたボールが集まる場所

08のボールは8段のピンを通り、段ごとに左か右へ分かれます。右へ行った回数がそのボールの入る枠の番号になり、8回のうち4回右へ行く道は70通り、8回とも右へ行く道は1通りしかありません。256通りの道のうち真ん中の枠が70/256 = 27.3%、両端の枠が1/256 = 0.4%を受け持つのはこのためで、ボールを3,000個実際に落とすと真ん中の枠は28.7%、両端は0.3%・0.2%でした。この形は、ピンに触れたとき横の速さを0.45倍に落とし、シードの乱数で±55px/sを混ぜて初めて出てきます。揺れを0にして横の速さをそのまま残すと、枠ごとの割合が7.9〜13.0%とほぼ均等に広がり、釣り鐘の形は消えました。

名前を枠に混ぜて付けると均等に戻ってくる当選

枠が真ん中に偏るということは、枠に名前を固定しておくとその偏りがそのまま人に移るということです。シード1000〜1299の300盤を混ぜずに回すと、真ん中のD・E・Fがそれぞれ51〜52回、端のAは3回、Iは4回当選しました。SHUFFLE_SLOTSを有効にすると盤ごとに名札をシードの乱数で後ろから一つずつ入れ替えて枠と名前の結びつきを切り、同じ300盤で名前ごとに27〜40回になりました。均等に分ければ一つの名前に約33回なので、その周りに集まったことになります。ただし、この300盤が数えたのは盤ごとに60個のあとで落ちるレモン色のボール(GOLD_R 5)一つが入った枠で、前の節の3,000個の分布は普通のボール(BALL_R 3.5)を数えたものです。混ぜずに回した300盤では真ん中の枠に入ったのが52回(17.3%)でしたが、GOLD_Rを3.5に変えると91回(30.3%)に上がり、レモン色のボールが大きいぶん分布が平らになっていたことがわかります。

        // 名前の割り当て — 混ぜるならシードの乱数で後ろから一つずつ入れ替える
        names = LABELS.slice(0, SLOT_COUNT);
        if (SHUFFLE_SLOTS) {
            for (let i = names.length - 1; i > 0; i -= 1) {
                const j = Math.floor(rng() * (i + 1));
                const t = names[i];
                names[i] = names[j];
                names[j] = t;
            }
        }

枠は相変わらず真ん中に集まりますが、どの名前が真ん中の枠に座るかが盤ごとに変わります。偏りを消すのではなく、偏りが誰に向かうかを混ぜるやり方です。

もっと高く跳ね、もっと速く打ち、もっと強く押す — 値を一つずつ変えた変形

名前 変えた値 感じ
ボールをもっと高く跳ねさせる 01 RESTITUTION 0.5 → 0.8 ピンに当たる回数が127回から151回に増えてぽんぽん跳ね、当選はA 1,040msからE 1,456msに、最後のボールは3,360msから5,744msに、すべて止まるまでは5,616msから9,712msに延びます
フリッパーをもっと速く 02 FLIPPER_SPEED 12 → 20 先端の接点が1,032px/sから1,720px/sに、一振りが77msから46msに縮み、実演20秒間の打った直後のボールの速さは683〜1,815px/sから1,041〜2,623px/sに上がります(打った回数17回 → 20回)
バンパーをもっと強く 03 BUMPER_KICK 320 → 640 上から300px/sで入ったボールが470px/sではなく790px/sで弾き出され、10秒間にバンパーに当たった回数が17回から53回に増えます

三つとも画面が忙しくなる方向ですが、引き換えになるものはそれぞれ違います。01は結果が出るまでの待ち時間が4秒以上延び、当選のボールもAからEに替わるので、跳ね返り一つが同じシードの当選者を変えたことになります。02を20に上げたまま1ティックを1歩で計算すると、棒をすり抜ける位置が1か所から5か所に増えるので次の節と合わせて読む必要があり、03は当たった回数が3倍を超え、ボールがバンパーの間を長く行き来するようになったと考えられます。

速いボールが薄いフリッパーをすり抜けるとき・ビー玉がピンの間に挟まるとき・舞台の幅が変わるとき — ピンボールの物理が壊れるところ

一歩で16pxを超えて進むと棒を飛び越える

02の棒の先端は1ティック(16ms)の間に1,032 × 0.016 = 16.51px進みます。ボールが棒に触れたと判定される距離は半径10と太さの半分6を足した16pxなので、1ティックを1歩(SUBSTEPS 1)で計算すると、先端がボールを一度に越えてしまうことがあります。ボールを棒の上面の8か所(軸から長さの0.3〜1.0)に載せて押してみると、SUBSTEPS 1では先端の1か所でボールが棒を突き抜けて下へ落ち、2・3・6では1か所もありませんでした。FLIPPER_SPEEDを20に上げると一歩が27.52pxになり、SUBSTEPS 1で0.6〜1.0の5か所が抜け、2歩(13.76px)からはまた0か所でした。デモが使う6歩では一歩の移動が2.75pxで、触れる距離の16pxにはまるで届きません。01のピン盤も同じ形で、ピンに食い込んだ最大の深さはSUBSTEPS 1・4・8で3.38・0.87・0.58pxと減り、ピンとボールの半径の和が12pxなので抜けることはありませんでした。

衝突の関数と九つの舞台のindex.html、同じ計算をReactの部品に移した版はひとつの圧縮ファイルで受け取れ、開くパスワードは z3hydw63 です。

ピンのてっぺんの真ん中に載ったボール

左右がまったく同じ位置に速度0で載ったボールは、押し出しが上にしか働かないので、横へ転がり落ちる理由がありません。ボールを一つ真ん中のピン(240, 74)のてっぺんに置いてSTUCK_KICKを0で回すと、300ティック(4.8秒)たってもx 240.00のまま動きませんでした。90なら、ゴール線より上で12px/s未満のまま500msとどまったボールを挟まったものとみなして512msで一度弾き、768msでピン半マス(22px)の外へ出て一番下の枠まで落ちていきます。参加者の多い配信の抽選でボールが一つゴール線の上で止まってしまうと盤が終わらないので、この一度の軽い一押しが盤を終わらせる仕掛けになります。

画面のpxで物理を回すと同じシードで当選が変わる

物理は表示の大きさに関係なく480×300の論理座標で動き、fitCanvasが描くときだけ表示幅 / 480の倍率を掛けます。480の窓の表示幅418.6px、320の窓の294px、端末の倍率3で同じシードを最後まで回すと、当選A・1,040msと8個のボールの最後のx座標が小数第3位まで一致し、違ったのはキャンバスの画素数419・294・837だけでした。逆に物理を画面のpxで回したとすると、同じ760px/s²が論理座標では480/294倍強く効くことになり、320の窓では同じシードの当選がC・1,376msに変わりました。

    function fitCanvas() {
        // 倍率は 2 で切る — 倍率 3 の端末で画素が 2.25 倍に増え、フレームごとの仕事がその分大きくならないように
        const dpr = Math.min(window.devicePixelRatio || 1, 2);
        const cssW = cv.getBoundingClientRect().width || W;
        viewScale = cssW / W;
        const k = viewScale * dpr;
        cv.width = Math.max(1, Math.round(W * k));
        cv.height = Math.max(1, Math.round(H * k));
        // 描くときだけ論理座標 → 画素の倍率を掛ける
        ctx.setTransform(k, 0, 0, k, 0, 0);
    }

当選結果は文字でも知らせる — 結果の読み上げ領域と動きを減らす設定のアクセシビリティ

状態の行が結果を読み上げる

九つの舞台はどれも、キャンバスの下の<p data-role="status">の状態の行に結果を文字で書きます。実演の盤が終わると01は「当選: A」、07は「順位: E, B, A, C, D, F」、08は「当選の枠 7 → H」の形で、キャンバスはaria-hidden="true"なので読み上げはこの行が受け持ちます。実演が回っている間aria-liveはoffで、繰り返される実演の結果は読まず、ボタンを初めて押すとpoliteに切り替わります(aria-live の解説)。文字は変わったときだけ書き換え、05のように引いている間に値が変わり続ける状態は5px・10px/s単位で区切って書きます。

動きを減らす設定では終わった盤を一枚

prefers-reduced-motion: reduceのときは九つともアニメーションのループを始めず、同じシードの盤をsettle()で最後まで計算した最後の場面一枚と結果の文字だけを描きます(prefers-reduced-motion の解説)。終わりのある01・07・08はそれぞれ1,200・1,300・900ティック以内で結果が出た瞬間に止まり、終わりのない02〜06・09は80〜240ティックまで計算した場面を見せます。この状態でボタンを押すとその入力の結果まですぐ計算して一度だけ描くので、05は押すと引いた画面、離すと発射後の場面になります。途中で設定を変えてもchangeイベントですぐに追いかけます。

押し続けるボタンもキーボードで

ボタンは九つとも本物の<button type="button">です。02・05のボタンと09のフリッパー二つは押している間だけ働き、ポインターを押す・離すイベントと一緒にSpace・Enterのkeydown・keyupを受けるので、キーを押している間フリッパーは上がったまま、バネは引かれたままになります。フォーカスが外れると(blur)離したものとみなし、フォーカスの外枠は:focus-visibleの2pxの実線で、09の「◀ フリッパー」「フリッパー ▶」はaria-labelで「左のフリッパー」「右のフリッパー」と読まれます。

抽選盤やピンボール台をサイトに載せる前のよくある質問

この抽選盤を本物のイベント抽選にそのまま使えますか?

物理の計算は同じ入力に同じ結果を返すので、公平さはシードをどこから受け取るかで決まります。デモのように4761を書き込んでおくと誰でも結果を前もって回して確かめられるため、抽選の直前に参加者の前で決めた数や、サーバーが出した値をシードとして渡すほうが安心です。枠で選ぶ08なら、名前を混ぜる設定を有効にしておかないと、真ん中の枠の偏りが特定の名前に集まります。

「ピンボール ゲーム 作り方」を初めて調べたら、どの舞台から読めばいいですか?

01のように止まった円にぶつかるところからが入りやすいです。hitCircle一つと重力、固定のティックがあればボールはピンの間を抜けていき、線分の衝突を足すと04の斜辺と07の坂に、接点の速度を足すと02のフリッパーと06の風車になります。09はその三つを合わせた台なので最後に開くのが順番に合い、同じゲームの分野で格子の上の道探しが気になるなら迷路 ゲームが続けて読みやすいはずです。

ボールが壁をすり抜けたり、画面の外へ消えたりするのはどう防げますか?

まず一歩で進む距離を見ます。いちばん速い物体が一歩で進む距離が、ボールの半径と壁の太さの半分の和より短くなければならず、02は1ティックを6歩に分けてフリッパーの先端の移動を2.75pxに抑えています。タブを長く離れてから戻ると画面の更新の間隔が大きく開きますが、九つとも1フレームで追いつく時間を100ms、計算ティックを6回までに切っているので、ボールが一度に遠くへ飛ぶことはありません。

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

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