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

web ルーレット — 弾いて回し摩擦で止まる回転盤キャンバス9種

web ルーレットを指ではじいて遊びたいなら、まず計算の中身を見せます。指ではじいた強さだけ回ります。角速度を積分し摩擦で減衰し、ポインターの下のマスを計算で判定します。重み付き確率・連続抽選・当たりの紙吹雪まで — キャンバス1枚、コードはパスワード付きzipでお渡しします。

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

デモ

9項目とも同じ骨格(ポインター角度→角速度→摩擦積分→当選判定)を使っていますが、盤の見た目・重み付け・鳴る音は項目ごとに違います。実測値は次の表のとおりです。

番号 項目 累積面積 強度平均 動いたフレーム
01 扇形ホイール 23.344% 55.9 23/24
02 はじき角速度 23.217% 52.4 23/24
03 摩擦減衰 9.394% 45.5 23/24
04 重み付き確率 22.052% 56.0 23/24
05 ポインターのカチッ 22.926% 55.8 23/24
06 当選判定 22.293% 53.7 23/24
07 項目エディタ 21.903% 62.0 23/24
08 連続抽選 21.825% 50.6 21/24
09 ヒーローシーン 22.094% 60.1 23/24

03の累積面積だけ9.394%と低く見えますが、これは画面の半分を減速曲線のグラフパネルが占める構造上の値で、回転盤自体が小さいわけではありません。08も21.825%とやや低めですが、脱落記録を並べるサイドパネルが画面幅を分け合っているためで、残り7項目は22〜23%台にそろっています。05は境界を通るたびに音が鳴る項目ですが、drag.onが人の実際の操作中しか立たないため、自動演示とgif・mp4のキャプチャ中は意図的に無音のままです。

9ツールの段階別コード

社内の忘年会や授業の指名に使う抽選 ルーレットなら、04の重み付き確率と08の連続抽選を組み合わせるのが定番の組み方です。9項目とも下のコードはzip内のバニラJavaScript版とReact 18版の両方に対応する形で入っています。

01扇形ホイール — 極座標の弧でマスをつなぐ盤

moveTo(中心) → arc(開始角, 終了角) → closePath でマスを1つ描き、マス数だけ繰り返せば盤の土台が完成です。角度は2π/nで均等分割し、色はパレット配列を循環させて隣り合うマスが同色にならないようにします。累積面積23.344%・強度平均55.9・動いたフレーム23/24です。

扇形ホイール — 極座標の弧でマスをつなぐ盤
新しいタブで開く

01 · 扇形ホイール — 極座標の弧でマスをつなぐ盤 — 計測 累積面積 23.344% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

arcbeginPathfillText

扇形の境界線とマス番号を同時に描く

マスを塗った直後に境界線を1pxのストロークで重ね、扇の中央角度だけ回転させてから番号を右詰めで描くと、盤が回ってもラベルが常に外向きのまま読めます。

for (var k = 0; k < N; k++) {
  var a0 = k * SEG, a1 = a0 + SEG;
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.arc(0, 0, R, a0, a1);
  ctx.closePath();
  ctx.fillStyle = PAL[k];
  ctx.fill();
  ctx.strokeStyle = 'rgba(23,20,26,.22)';
  ctx.lineWidth = 1;
  ctx.stroke();
  ctx.save();
  ctx.rotate(a0 + SEG / 2);
  ctx.fillStyle = INK;
  ctx.font = '700 13px sans-serif';
  ctx.textAlign = 'right';
  ctx.textBaseline = 'middle';
  ctx.fillText(String(k + 1), R - 14, 0);
  ctx.restore();
}

02はじき角速度 — ポインター移動を回転速度に変える

pointermoveごとに中心からの角度をMath.atan2で測り、前回角度との差を経過時間で割ると rad/s の角速度が出ます。指を離した瞬間の値が初期回転速度になり、はじいた強さの分だけ盤が回ります。累積面積23.217%・強度平均52.4・動いたフレーム23/24です。

はじき角速度 — ポインター移動を回転速度に変える
新しいタブで開く

02 · はじき角速度 — ポインター移動を回転速度に変える — 計測 累積面積 23.217% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermovepointerdownMath.atan2

指を離した瞬間の値だけを初期速度にする

pointerdownで角度と時刻を記録し、pointermoveのたびに差分から速度を更新し続け、pointerupの瞬間のdrag.vだけを盤の初期角速度として採用します。

function angleAt(e) {
  var b = field.getBoundingClientRect();
  return Math.atan2(e.clientY - (b.top + b.height / 2), e.clientX - (b.left + b.width / 2));
}
field.addEventListener('pointerdown', function (e) {
  drag.on = true; drag.lastA = angleAt(e); drag.lastT = performance.now(); drag.v = 0;
});
field.addEventListener('pointermove', function (e) {
  if (!drag.on) return;
  var a = angleAt(e), now = performance.now();
  var dA = a - drag.lastA;
  if (dA > Math.PI) dA -= TAU;
  if (dA < -Math.PI) dA += TAU;
  ang += dA;
  var dt = Math.max(1, now - drag.lastT) / 1000;
  drag.v = 0.65 * drag.v + 0.35 * (dA / dt);
  drag.lastA = a; drag.lastT = now;
});

03摩擦減衰 — 毎フレーム積分して自ら止まる盤

rAFループでdtを測り、角度 += 角速度·dt、角速度 *= 摩擦^dt を積分すると、はじいた直後は速く、指数曲線で遅くなります。右側のグラフパネルが画面の半分を占めてその減衰曲線をリアルタイムに描くため累積面積は9.394%と他より小さく出ますが、これは構造上自然な値です。摩擦係数3段階のボタンで同じはじき(16 rad/s)の停止までの時間を比較できます。強度平均45.5・動いたフレーム23/24です。

摩擦減衰 — 毎フレーム積分して自ら止まる盤
新しいタブで開く

03 · 摩擦減衰 — 毎フレーム積分して自ら止まる盤 — 計測 累積面積 9.394% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFrameperformance.now

摩擦係数3段階で同じはじきの減速を比べる

低・中・高の3つのボタンはCOEFS配列の値を切り替えるだけで、はじく速さそのものは固定のまま停止までの秒数だけを変えて見せます。

if (angularVelocity !== 0) {
  ang += angularVelocity * dt;
  var before = Math.abs(angularVelocity);
  angularVelocity *= Math.pow(COEFS[ci], dt);
  t0 += dt;
  if (Math.abs(angularVelocity) < STOP) {
    angularVelocity = 0; settled = true; settleAt = t0;
    secEl.textContent = settleAt.toFixed(1);
    hist.push({ t: t0, v: 0 });
  } else {
    hist.push({ t: t0, v: Math.abs(angularVelocity) });
  }
  if (hist.length > 200) hist.shift();
}

04重み付き確率 — マスの角度を重みで分けて公正に抽選する

すべてのマスを同じ角度にするとマス数分の均等確率になりますが、項目ごとに重みを置くと開始角・終了角を累積比率で決め、角度そのものが確率になります。結果は停止後の角度で判定するので、操作なしで見える確率のまま当選します。累積面積22.052%・強度平均56.0・動いたフレーム23/24です。

重み付き確率 — マスの角度を重みで分けて公正に抽選する
新しいタブで開く

04 · 重み付き確率 — マスの角度を重みで分けて公正に抽選する — 計測 累積面積 22.052% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.randomfillTextarc

累積比率が開始角と終了角を決める

重み配列Wの合計に対する各項目の比率を先に累積しておき、その累積比率を扇形の開始角・終了角の基準にすれば、マス中央に出す確率%の表示と実際の面積が常に一致します。

var W = [5, 3, 2, 1, 3, 2];
var TOT = W.reduce(function (a, b) { return a + b; }, 0);
var bounds = [], acc = 0;
for (var q = 0; q < W.length; q++) { bounds.push(acc / TOT); acc += W[q]; }
bounds.push(1);

function norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
function idxAt(a) {
  var u = norm(-Math.PI / 2 - a);
  for (var k = 0; k < W.length; k++) if (u >= bounds[k] && u < bounds[k + 1]) return k;
  return W.length - 1;
}

05ポインターのカチッ — マス境界を通るたびに弾く音と揺れ

回転角度をマス境界角で割った商が変わるフレームを検出すると、ポインターがピンを通過した瞬間です。そのフレームでOscillatorNodeによる短いティックを鳴らし、ポインターを境界方向へわずかに押して減衰スプリングで戻すと、本物のピンが掛かる手触りが出ます。ただしティックが鳴るのは人が実際にドラッグしている間だけで、自動演示とgif・mp4のキャプチャ中はdrag.onがfalseのまま無音です。累積面積22.926%・強度平均55.8・動いたフレーム23/24です。

ポインターのカチッ — マス境界を通るたびに弾く音と揺れ
新しいタブで開く

05 · ポインターのカチッ — マス境界を通るたびに弾く音と揺れ — 計測 累積面積 22.926% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFrameOscillatorNoderotate

境界を通過したフレームだけティックを鳴らす

境界を跨いだ判定はしきい値付きの商の比較1つで済み、スプリングの復元力a = −pk·x − pc·vが同じフレームでポインターを押し戻すので、音と動きがずれません。

function tickCheck() {
  var q = Math.floor(ang / SEG);
  if (q !== lastQ) {
    var d = q - lastQ;
    ticks += Math.abs(d);
    pV += (d > 0 ? 1 : -1) * Math.abs(vel || drag.v || 6) * 26;
    if (drag.on) tickSound();
    ticksEl.textContent = String(ticks);
    lastQ = q;
  }
}

06当選判定 — 停止角度を扇形インデックスに換算する

ポインターは12時方向に固定なので、停止角度を −π/2 基準で正規化してマス角で割ると当選インデックスが出ます。結果はaria-liveで読み上げ、判定・表示・再挑戦を1つの流れにつなげて、画面をつけたまま何度でも回せます。累積面積22.293%・強度平均53.7・動いたフレーム23/24です。

当選判定 — 停止角度を扇形インデックスに換算する
新しいタブで開く

06 · 当選判定 — 停止角度を扇形インデックスに換算する — 計測 累積面積 22.293% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.atan2aria-livefillText

12時固定のポインターを基準に正規化する

norm(-Math.PI / 2 - ang)は停止角度を「12時から時計回りにどれだけ回ったか」という0〜2πの値に変換するだけの1行で、これをマス角で割った整数部が当選インデックスになります。

function norm(a) { a %= TAU; return a < 0 ? a + TAU : a; }
function idxAt(a) { return Math.floor(norm(-Math.PI / 2 - a) / SEG) % N; }

function judge() {
  var deg = Math.round(norm(ang) * 180 / Math.PI);
  restEl.textContent = String(deg);
  var idx = idxAt(ang);
  lastWin = idx;
  winEl.textContent = String(idx + 1) + '番';
  nameEl.textContent = NAMES[idx];
  liveEl.textContent = (idx + 1) + '番 ' + NAMES[idx];
}

07項目エディタ — 名前欄とマス角度が一緒に再計算される盤

名前を入力するとmeasureTextで幅を測り、マスの弧長に収まるまでフォントサイズを二分探索で縮めます。行の増減のたびに角度分割と配置をすべて再計算し、盤が編集内容にそのまま追従します。累積面積21.903%・強度平均62.0・動いたフレーム23/24です。

項目エディタ — 名前欄とマス角度が一緒に再計算される盤
新しいタブで開く

07 · 項目エディタ — 名前欄とマス角度が一緒に再計算される盤 — 計測 累積面積 21.903% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillTextmeasureTextchange

二分探索でフォントサイズをマスの弧長に収める

上限と下限のフォントサイズを7回だけ2分し、measureTextの幅がマスの弧長(R·seg·0.78)以下になる最大値を選ぶので、名前の長さがどれだけ違っても文字が枠からはみ出しません。

function fitFont(text, arcLen) {
  var lo = 6, hi = 15, best = 6;
  for (var it = 0; it < 7; it++) {
    var mid = (lo + hi) / 2;
    ctx.font = '700 ' + mid + 'px sans-serif';
    if (ctx.measureText(text).width <= arcLen) { best = mid; lo = mid; } else { hi = mid; }
  }
  return best;
}

08連続抽選 — 当選マスを除いて回る脱落モード

抽選された項目を外して残りで角度を再分配すると、発表者抽選やビンゴ抽選になります。当選者はサイドの記録パネルに移り、元に戻すで直前の抽選を取り消せます。サイドパネルが画面幅を分け合う構造のため累積面積は21.825%とやや低めで、動いたフレームも21/24とほかの項目より少なめです。強度平均50.6です。

連続抽選 — 当選マスを除いて回る脱落モード
新しいタブで開く

08 · 連続抽選 — 当選マスを除いて回る脱落モード — 計測 累積面積 21.825% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillRectarcchange

配列のspliceとpushで確定と取り消しを両立する

生存中の項目は番号だけを持つ配列aliveで管理し、確定はspliceで1件抜いてlogへpush、取り消しはlogをpopしてaliveへ戻すだけなので、状態が常に配列の中身だけで説明できます。

function commit() {
  if (previewIdx < 0 || !alive.length) return;
  var winner = alive.splice(previewIdx, 1)[0];
  log.push(winner);
  previewIdx = -1; settle = -1; pulse = -1;
  renderLog();
}
btnEl.addEventListener('click', function () {
  if (!log.length) return;
  var back = log.pop();
  alive.push(back);
  previewIdx = -1; settle = -1; pulse = -1;
  renderLog();
  idle = 0.8;
});

09ヒーローシーン — 紙吹雪が弾け結果を保存できる完成版

停止判定の直後にパーティクルの紙吹雪を弾けさせ、当選名を大きく表示し、盤全体をtoDataURLで書き出します。摩擦・ティック・重み・脱落モードを1画面に載せたヒーローバナー兼実用ツールです。累積面積22.094%・強度平均60.1・動いたフレーム23/24です。

ヒーローシーン — 紙吹雪が弾け結果を保存できる完成版
新しいタブで開く

09 · ヒーローシーン — 紙吹雪が弾け結果を保存できる完成版 — 計測 累積面積 22.094% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramearctoDataURL

停止判定の直後にパーティクルとtoDataURLをつなぐ

mulberry32のシード固定乱数でパーティクルの初速と角度だけを毎回同じ列にしておけば、重力と減衰で自然に落ちる紙吹雪でも自動演示のたびに同じ場面を再現できます。

function mulberry32(seed) {
  var a = seed >>> 0;
  return function () {
    a |= 0; a = (a + 0x6D2B79F5) | 0;
    var t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

はじきがなぜ物理なのか — 角速度の積分と摩擦減衰

ライブラリを使わないjavascript ルーレットの実装では、この一連の積分ループそのものがすべての手触りを決めます。CSSのtransitionで減速イージングをかけるだけでは弾く強さが結果に反映されませんが、pointermoveのたびにMath.atan2で中心角を測り、前回角度との差を経過時間で割って角速度を作り、その角速度を毎フレーム摩擦係数のべき乗で減衰させると、はじく強さごとに回転時間も停止角度も実際に変わります。盤の見た目は違っても、画面ごとに毎フレーム格子や角度を計算し直す点は、以前扱った衝突判定で崩れないテトリスの9ステップと同じ考え方です。

角速度は指数移動平均で滑らかにする

瞬間の角度差をそのまま速度にすると指の震えでガクガクします。drag.v = 0.65 * drag.v + 0.35 * (dA / dt)のように直前の値を65%残す指数移動平均を挟むと、指を離した瞬間の値がなめらかな初期回転速度になります。

摩擦は毎フレームのべき乗で効く

角速度 *= Math.pow(摩擦係数, dt)は時間の経過分だけべき乗するので、フレームレートが変わっても同じ経過時間なら同じだけ減速します。角速度の絶対値がしきい値(STOP)を下回った瞬間にループを終え、その角度を停止角度として確定します。

当たりは角度計算 — ポインター判定と重み

12時方向に固定したポインターを基準に停止角度を正規化する点は9項目すべてで共通ですが、その先の割り算の対象が均等なマス角か累積比率(bounds)かで、実装は2つに分かれます。

均等確率のidxAtは角度をマス角で割った余り

均等確率のマスではnorm(-Math.PI / 2 - ang) / (TAU / n)をMath.floorした値がそのままインデックスになり、全マスの角度がすべて同じなので余りの分布も均等です。

重み付き確率のboundsは累積比率で開始角と終了角を決める

重み付きのマスでは累積比率の配列boundsを先に作っておき、正規化した停止角度がboundsのどの区間に入るかを線形に探すだけで、区間の幅=重みの比率=当選確率になります。

摩擦の積分を端折ってイージングで止めると壊れる落とし穴

作る途中でよく起きるつまずきと、その理由をまとめます。

失敗 なぜ壊れるか
CSSのtransitionだけで減速を真似る イージング曲線は弾く強さを反映できず、角速度を毎フレーム実際に積分してはじめて弾きごとに違う停止角度が出ます
dtをクランプせず使う タブ切り替えなどでdtが一気に大きくなり、盤が1フレームで何周も飛んでしまいます
重み付きマスを均等角度のまま確率だけ表示する 画面上の角度そのものが確率でなければ、操作なしで公正な抽選になりません
人の入力と自動演示を別関数で作る 2つの経路が同じ摩擦積分ループを通らないと、デモと実際の弾きの手触りがずれます
名前を編集しても角度分割を再計算しない 古いマス配置が新しい項目数と合わなくなり、境界とラベルの位置がずれます

CSSのイージングだけで停止を真似ない

減速イージングは見た目が似ていても弾く強さに反応しないため、はじく強さを変えても回転時間と停止角度がまったく変わらず、物理ではなく単なるアニメーションになってしまいます。

dtをクランプしないと数周飛ぶ

Math.min(64, now - last) / 1000のように上限を設けておけば、フレームが途切れた直後でも積分量が一度に膨らまず、盤の位置が飛ばずに済みます。

重み付きマスは角度自体を確率にする

確率%のテキストだけを大きく見せて角度を均等のままにすると、見た目と実際の当選しやすさが食い違う偽の抽選になってしまいます。

自動演示中のティック音は鳴らさない

シード固定の自動ループにまで音を乗せると、無音のはずのgif・mp4キャプチャに音があるふりをすることになるので、drag.onの条件を必ず挟みます。パスワード付きzipには9項目ぶんの完成コードがまとまっていて、本文内のsgvkvgusがその解凍キーです。

摩擦係数ひとつで手触りが変わる3つの変化とアクセシビリティ

同じ盤でも1つの数値を変えるだけで手触りが大きく変わります。3つのバリエーションと、動きを抑えたいユーザーへの対応をまとめます。

項目 変える値 効果
03 摩擦減衰 摩擦係数 0.38 → 0.08 長く回る代わりに待ち時間が伸びる
01 扇形ホイール マス数 8 → 18 ティックが密になるが文字が小さくなる
05 ポインターのカチッ ポインター振幅 4px → 7px 手触りは増すが判定角とずれて見えやすい

摩擦係数0.38から0.08へ — 待ち時間と手応えのトレードオフ

摩擦を弱めるとしきい値を下回るまでの時間が伸びて盤が長く回り続け、逆に強めると背筋が伸びるほどぴたりと止まりますが、はじく楽しさそのものは減ります。

マス数8から18へ — ティックの密度と文字サイズのトレードオフ

マス数を増やすと境界を通る回数が増えてティック音が密になり速度感が増しますが、1マスの弧長が短くなる分だけ表示できる文字が小さくなります。

ポインター振幅4pxから7pxへ — 手触りと判定角のずれのトレードオフ

振幅を大きくすると実物のピンに掛かるような手触りが増しますが、行き過ぎるとポインターの見た目の位置と判定角度がずれて見え、計算が間違っているように感じられます。

prefers-reduced-motionは静止した結果画面を1枚固定する

@media (prefers-reduced-motion: reduce)が真の環境では、rAFループそのものを起動せず、結果がすでに出た状態の盤を1枚描いて固定します。空のキャンバスに戻すのではなく、静止した完成状態を見せる点がポイントです。

発表の順番決めと景品抽選での使い方

社内発表や授業の指名で同じ人が二度当たらないようにしたいときは08連続抽選が向いています。一度当たった名前は配列から外れ、誤操作は取り消しで戻せるので、進行を止めずに済みます。

景品を配るイベントでは09ヒーローシーンのように当選の瞬間に紙吹雪が弾け、結果をtoDataURLで画像保存できる盤がその場の記念にもなります。参加者名簿を当日差し替えるなら07項目エディタが名前の増減に合わせて角度とフォントサイズを自動で計算し直します。

名前抽選や当選確率についてよくある質問

web ルーレットの当選確率は操作で変えられますか

いいえ。当選インデックスは停止角度を−π/2基準で正規化してマス角(または累積比率のbounds)で割った計算結果であり、後から任意のマスを選んで表示する経路はどこにも存在しません。

名前 ルーレットとして使えますか

はい。07項目エディタでは名前を入力するたびにmeasureTextで幅を測ってフォントサイズを二分探索で縮め、行の増減に合わせて角度分割も再計算するので、参加者名を入れ替えるだけで名前抽選盤として使えます。

javascript ルーレットとして実装をそのまま流用できますか

はい。9項目ともライブラリ・CDNを使わないバニラJavaScriptで、zipにはReact 18版も同梱しているので、どちらの環境でも角速度の積分ループをそのままコピーして動かせます。

もっと読む

同じ「毎フレーム計算し直す」考え方は他のキャンバス実装にも共通しています。衝突判定で崩れないテトリスの9ステップや、ピクセル単位で砂の落下を計算するキャンバス9種もあわせてご覧ください。

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

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