[Babylon.js #11] 3DスマートボールをCanvas 2Dへ移植 — アーチ壁・サブステップ・カップ判定の実装

はじめに

前回、Babylon.jsでスマートボールを制作しましたが、久しぶりにCanvas 2Dをやってみようと3D → 2D 移植をしてみました。

タイトルは、Babylon.js #11 になってますが、コードは2Dなので、番外編です。

前回の記事:

ちなみに、昨年、ブロック崩しゲーム制作を1か月ぐらい継続開発をしていて、やや実装経験はあります。

軽い実装で終わらせるつもりでしたが、ゲームとして作りこんで行くと思いのほか大変で、最低限納得が出来る形までやった結果、2日がかりの実装になりました…。

昨日は、AM 10時までしか時間が無く、未完成のまま終了…。
翌日の今朝、続きを制作し、なんとか完成したので記事にまとめてみます。

動画(Youtube):

動画(PC):

1. なぜ Canvas 2D に移植したか

Babylon.js 版は「3Dでそれっぽく動かす」までが速い一方で、ゲームとして気持ちよくする段階に入ると、調整コストが急に跳ね上がる。今回 Canvas 2D に移した理由は、見た目を削ってでも ロジックの反復速度を最優先したかったから。

実装・検証サイクルを速くする

Canvas 2D では物理も当たり判定も自前実装になる。いきなり大変そうに見えるが、スマートボール程度の要件なら「必要な衝突だけ」を実装すればよく、逆に言うと ゲーム性に効くパラメータへ一直線に触れる。

  • ピンの反発係数、微小ノイズ、摩擦
  • ポケットの「吸い込み感」(カップ形状の当たり方)
  • dt が荒れた時のすり抜け(サブステップ)
  • 発射パワー曲線(長押し→速度変換)

この辺はエンジンに任せても結局“調整”が必要で、しかもエンジン側のブラックボックス(解像度、安定化、反復回数)の影響を受ける。Canvas 側に寄せると、挙動の原因がコード上で追えるので、修正が速い。

動作安定性 / 負荷 / 環境差

3D は「動いているように見える」までが早い代わりに、環境差(GPU/ドライバ/ブラウザ)や描画負荷の影響を受けやすい。特に試作段階だと、

  • 重い時に物理 dt が荒れて、当たり判定が不安定になる
  • 表現(ポストプロセス、ライト、影)を足すほど、挙動検証が邪魔される

という状況になりやすい。Canvas 2D は描画コストが軽く、フレームが安定しやすい=物理が安定しやすい。ゲームの触感を詰めるフェーズでは、こっちの方が都合がいい。

3Dの難所を一旦切り離して、ゲーム体験に集中する

3Dで苦労する層(カメラ、ライト、シェーダ、リソース管理)を一旦切り離すと、「盤面として面白いか?」を判断するのが早くなる。

  • 盤面形状(上部アーチ)でどう跳ねるか
  • ピン密度と配置で運要素がどれだけ出るか
  • ポケットの入りやすさと報酬バランス
  • “もう1回”やりたくなるテンポ(チャージ、リトライ、演出)

Canvas 2D でロジックが固まったら、Babylon.js 側へ逆輸入して演出を盛る。今回の移植は、そのための ロジック抽出でもある。

2. 座標系:world → screen 変換を設計の中心に置く

Canvas 2D移植で最初に固めるべきは、座標系の責務分離。 「ゲームの都合(物理・当たり判定)」と「画面の都合(解像度・レイアウト)」を混ぜると、あとから確実に破綻する。

方針はシンプルで、

  • 盤面ロジックは world座標(ゲーム都合の単位)で統一
  • 描画だけ screen(px) に変換する

という二層構造にする。

world 座標を“基準”にする理由

Canvas は px が全てに見えるけど、px を基準に物理を書き始めると、解像度変更・UI調整・拡大縮小のたびに全部の定数が崩れる。 逆に world を基準にしておけば、

  • 盤面の幅・高さ・ピン間隔・ポケット半径などが「設計値」として安定する
  • 画面サイズを変えても、最後に scale を変えるだけで同じゲームが動く

という状態になる。移植作業ではここが最重要。

実装:world → screen の変換を1箇所に閉じ込める

今回の w2s() は、平行移動(ox, oy)とスケール(sx, sy)をまとめた変換関数になっている。

function w2s(wx: number, wy: number): Vec2 {
  return {
    x: VIEW.ox + wx * VIEW.sx,
    y: VIEW.oy + (2.4 - wy) * VIEW.sy, // y反転込み
  };
}

ここでやっていることは大きく3つ:

  1. 原点の移動(VIEW.ox / VIEW.oy) 盤面をキャンバス中央〜上寄りに配置するためのオフセット。

  2. スケール(VIEW.sx / VIEW.sy) world の 1.0 を何pxとして描くか。縦横別スケールにして、盤面の見た目を調整できるようにしている。

  3. y軸の反転(2.4 - wy) Canvas の y は下が正。ゲームの y は上が正にしたい。 反転を変換側に押し込むことで、物理・当たり判定では「上に投げる」「上に登る」が直感通りになる。

ポイント(運用ルール)

  • 当たり判定は world のまま ピン衝突、壁反射、ポケット判定は全部 world。 これにより、解像度を変えても物理が破綻しにくい。

  • 描画だけが screen(px) 円を描く、線を引く、UIを置く、といった「見せ方」の処理だけが px を触る。

  • 半径変換も分ける(地味に重要)

    • worldToPxRadius() / pxToWorldRadius() を用意して、太さやサイズの“単位の混乱”を避ける
    • 特にカップの線幅や判定用厚み(px→world換算)は、この分離が効く

座標系をここで固定しておくと、以降の難所(サブステップ、アーチ衝突、カップ形状判定)を「世界の中の話」として扱えるようになる。これが Canvas 2D移植の土台。

3. 物理:フレーム依存を消す(サブステップ)

Canvas版で一番の罠は、フレーム落ち=dt増大で挙動が壊れること。 dt が大きくなると、1フレームの間にボールがピンや壁を“飛び越えて”しまい、衝突が検出されない(いわゆる トンネリング)が発生する。

3D物理エンジンは内部でこの手の対策(固定ステップや反復解法)を持っているが、Canvas で自前実装するなら こちらで面倒を見る必要がある。

対策:1フレームを分割して回す(サブステップ)

基本は「dt を上限で刻む」だけ。

const steps = Math.max(1, Math.ceil(dt / maxStep));
const subDt = dt / steps;
for (let s = 0; s < steps; s++) {
  // 速度更新 → 位置更新 → 衝突
}
  • timeStepMax を小さくするほど安定する(ただし計算量は増える)
  • “重い瞬間だけ”分割数が増えるので、常時固定ステップより無駄が少ない

今回だと timeStepMax = 1/240 にしていて、通常 60fps なら 4分割程度になる想定。 これで「当たり判定の信頼性」を底上げできる。

何が効くのか(体験としての効果)

サブステップを入れると、次の2点が明確に改善する。

  • ピンや壁のすり抜けが減る 特にピンのような小さな円形障害物は、dtが大きいと簡単に飛び越える。分割して「途中の位置」でも当たり判定を取れるようにするのが効く。

  • 高パワー時に破綻しにくい 発射速度を上げた時ほど移動量が増えるのでトンネリングが顕在化する。 つまりサブステップは「難易度上昇(速度上昇)」に耐えるための土台。

実装上の注意点(最低限)

  • 衝突処理はサブステップ内で毎回行う 位置更新だけ分割して、衝突を最後に1回だけやる…は意味が薄い。 「速度→位置→衝突」をサブDtごとに回して初めて効く。

  • dtは上限を掛ける(既にやってる)

    const dt = Math.min(0.033, (now - lastTime) / 1000);
    

    ここで極端なdt(タブ復帰など)を切っておくと暴走しにくい。

サブステップは地味だけど、Canvas物理を“ゲームとして成立させる”ための必須装備。これがないと、後からピン配置やカップ判定をどれだけ詰めても、根本的に不安定な挙動が残る。

4. 壁:上部アーチ(半円)の衝突処理が難所

左右の直線壁は「x をクランプして vx を反転」で終わる。問題は上部。 ここは盤面形状がアーチなので、衝突判定も 曲面になる。Canvas 2D では当然エンジンがないので、自前で「曲面の法線」を作って反射させる必要がある。

今回の割り切り:円として扱う(見た目は楕円でも)

描画上は VIEW.sx / VIEW.sy が違うのでアーチは楕円っぽく見えるが、物理は安定性を優先して world空間では円として扱う。

  • 半径は right(=1.95) をそのまま使う(中心x=0、左右壁と整合)
  • 中心は (0, archCy)(アーチの中心Y)
  • 判定は「中心からの距離 dist が半径を超えたか」

この割り切りは重要で、楕円衝突を正確にやろうとすると急に面倒になる(最短距離・法線計算が一段難しくなる)。ゲームとしては「それっぽく滑ればOK」なので、円近似で十分。

衝突判定:距離とめり込みで解く

基本は円 vs 円(壁の円弧 vs ボール)と同じ考え方。

const dx = ball.x;
const dy = ball.y - archCy;
const dist = Math.hypot(dx, dy);

if (dist + ball.r > archR) {
  const overlap = dist + ball.r - archR;

  // 法線(中心→ボール方向)
  const nx = dx / (dist || 1e-6);
  const ny = dy / (dist || 1e-6);

  // 1) めり込み解消:法線方向へ押し戻す
  ball.x -= nx * overlap;
  ball.y -= ny * overlap;

  // 2) 速度反射:法線方向成分だけ反転(反発係数で調整)
  const vn = ball.vx * nx + ball.vy * ny;
  if (vn > 0) {
    const restitution = 0.1; // アーチは“滑る”寄り
    ball.vx *= 0.98;         // 速度を軽く減衰させる
    ball.vy *= 0.98;
    ball.vx -= (1 + restitution) * vn * nx;
    ball.vy -= (1 + restitution) * vn * ny;
  }
}

ポイントは2段構えになっていること:

  1. 押し戻し(位置修正) これがないと、壁の内側に食い込んだまま振動したり、次のフレームで暴発する。

  2. 反射(速度修正) vn(速度の法線成分)だけを処理する。接線方向の速度は残るので、“滑り”が出る。

調整の勘所:アーチは「跳ねない」方が気持ちいい

直線壁と同じ反発にすると、上部でボールがバインバイン跳ねてストレスになりやすい。アーチの役割は「跳ね返す」より「誘導する」に近いので、

  • restitution(反発)を低め(0.05〜0.2)
  • 速度に軽い減衰(0.97〜0.99)
  • 反射は“必要な時だけ”(vn > 0 条件)

この3つが効く。

特に vn > 0 は重要で、押し戻した直後に内向き速度まで反射すると、逆に不自然な挙動になる。 「壁に向かって食い込んでいた時だけ反射」させるのが安定する。

なぜここが難所なのか

  • 曲面は 法線が位置依存なので、毎回計算が必要
  • サブステップを入れても、曲面は“滑り”が出るぶん、反発が強いと暴れやすい
  • 見た目と物理(楕円 vs 円)の差が出やすい領域なので、割り切りが必要

結果として、上部アーチは「正確さ」よりも「気持ちよさ」でパラメータを決める場所になった。

5. ピン衝突:反射+微小ノイズでハマりを防ぐ

ピンは数が多いので、衝突を凝り始めるとコストが爆発する。ここは割り切って 円 vs 円の最小構成にしている。 重要なのは「正確さ」より、“それっぽく跳ねて、気持ちよく散る”こと。

ロジックは円同士の衝突だけにする

各ピンは固定円、ボールも円。距離だけ見れば判定できる。

  • d < ball.r + pin.r なら衝突
  • 法線(pin→ball)を取る
  • めり込み分だけ押し戻す
  • 速度の法線成分を反射(restitution)

このパターンは、いわゆる “impulse 的な解き方” で、軽くて実装も短い。

実装の核:押し戻し → 反射

ピン衝突の本体はこの流れ。

  • 押し戻しがないと、めり込みが残って次のサブステップで連続ヒットし、速度が暴れる
  • 反射は法線方向成分だけを処理し、接線方向は残るので自然な滑りが出る

(コードとしては collidePins() の構造そのまま)

微小ノイズを入れる理由:完全対称は“詰む”

デジタルの衝突は、完全に対称な配置・速度だと 同じ軌道を永遠に繰り返すことがある。 ピンが格子配置だと特に起きやすく、最悪の場合「同じピン列で揺れ続ける」みたいなハマり方になる。

そこで、反射した直後に 極小の乱数を足して対称性を壊す。

ball.vx += randRange(-0.03, 0.03);
ball.vy += randRange(-0.03, 0.03);

この値は大きすぎると操作感が壊れるので、“見た目には分からないけど結果は変わる”程度に留めるのがポイント。

ここはゲームの「運」を作る場所

スマートボールは、プレイヤーが完全に制御できないから面白い。 ピン衝突の微小ノイズは、その「運」を設計として入れている部分でもある。

  • ノイズなし:スキルゲーム寄り、でもハマりが出やすい
  • ノイズあり:運が乗って“入る時は入る”、リプレイ性が上がる

結果として、ピン衝突は「物理の正しさ」より ゲームとしての確率分布(散り方)を作る役割が強い。

6. ポケット:見た目はカップ、判定は2段構え

今回の一番“設計してる感”が出るのがここ。 ポケットは単なる円判定にすると「触れた瞬間に入賞」になってしまい、スマートボール特有の “縁に乗る/弾かれる/最後に落ちる” が表現できない。

そこで、判定を 2段構えにしている。

  • (A) 中心円に落ちたら確定入賞
  • (B) カップ形状(弧)で物理的に受け止める

この2つを組み合わせることで、「カップに吸い込まれる」挙動が作れる。


(A) 中心円への完全IN(確定)

まずはシンプルに、中心に落ちたら確定。

if (d < p.r * 0.82) {
  awardPocket(p);
  return true;
}

ここは“ゲーム都合の判定”なので、見た目の円より少し小さくしている(0.82)。 これを大きくすると「縁に触れただけで入賞」になって雑になる。小さくしすぎると「入ったのに入賞しない」感が出るので、気持ちよさ優先で調整する領域。


(B) カップ形状(弧)との衝突:線分近似で解く

見た目のカップは「黒い弧」だが、判定も弧として扱いたい。ただし、真面目に曲線 vs 円の最近距離を解くのは面倒。 そこで、弧を 線分に分割(segments)して直線近似する。

手順は次の通り:

  1. カップ弧(楕円)をパラメトリックにサンプル
  2. 隣接点同士を結んで線分にする(segments 本)
  3. 各線分に対して「ボール中心からの最近点」を取る
  4. 最近点との距離がしきい値未満なら衝突
const cp = closestPointOnSegment(ball.x, ball.y, ax, ay, bx, by);
const dx = ball.x - cp.x;
const dy = ball.y - cp.y;
const dist = Math.hypot(dx, dy);

if (dist < hitDist) {
  // 押し戻し + 反射
}

この方式の利点:

  • 実装が単純(最近点+距離だけ)
  • segments を増やせば精度が上がる(28分割くらいで十分それっぽい)
  • “カップに沿って滑る”挙動が自然に出る

さらに、衝突法線を線分の法線から作って、押し戻しと反射を入れることで、ボールがカップの縁に乗る。


“乗っただけ即入賞”を防ぐ:段階的に award する

線分近似でカップ弧に乗るようになると、次の問題が出る。

  • 縁に当たって「カップ上に乗った」だけなのに、中心判定が先に走ると即入賞してしまう

そこで、「カップに乗った」フェーズと「中心に落ちた」フェーズを分ける。 具体的には、カップ弧に衝突した場合は その場では award しない。 次のフレームで、ボールが中心側へ落ち込んだ(=条件を満たした)瞬間に award する。

実装では概ね、

  • カップ弧に当たっている(縁の上)
  • かつ、中心側に十分寄った(落ちた) → このタイミングで awardPocket()

という流れにしている。

結果として:

  • 「縁に跳ね返される」こともある
  • 「縁に乗って、少し滑って、最後に落ちる」が起きる
  • 見た目が“吸い込まれる”ように感じられる

ここが設計ポイント

  • 1段判定だと“即入賞”で単調
  • 2段構えにすると「期待→落ちる」のドラマが生まれる
  • 線分近似は精度よりも、滑り方(気持ちよさ)に効く

7. 発射:Space長押しチャージ → 発射速度へ

UIは最小でも「触感」は作れる。スマートボールの場合、触感の核は 発射の“溜め”で、これがあるだけでプレイがゲームになる。

今回の入力設計は、Space の長押しをチャージに割り当てて、

  • keydown でチャージ開始
  • update() 側でチャージ量 0..1 を積算
  • keyup でその値を発射速度に変換

という流れ。

なぜ update 側で積算するのか

長押し時間を keyup - keydown の差分だけで取る設計もできるが、実際には

  • フレーム落ち
  • キーイベントの発火タイミングの揺れ
  • ブラウザやOSの差

で“体感”がズレやすい。 そこで、チャージ量は ゲームループの中で毎フレーム更新するようにして、入力イベントは「開始/終了の合図」だけにしている。

if (game.charging) {
  const heldMs = performance.now() - game.chargeStartTime;
  game.chargeAmount = clamp((heldMs - 80) / 700, 0, 1);
}

ここでやっている調整:

  • heldMs - 80:押してすぐ最大にならないように、デッドゾーンを入れる (軽く触れただけで暴発しない)
  • / 700:最大チャージまでの時間を決める (短いほどシビア、長いほどゆったり)
  • clamp(..., 0, 1):正規化して、後段の処理を単純にする

発射速度への変換

keyup 側では、積算された chargeAmount を読み取って発射するだけ。

const power = game.chargeAmount;
launchBall(power * 2.2);
game.chargeAmount = 0;

発射処理側ではさらに

  • baseShotSpeed
  • extraShotSpeed

を使って最終速度を作っているので、“チャージ曲線”と“物理速度”が分離できる。 この分離があると、触感調整がやりやすい(UIだけ変える/物理だけ変えるが可能)。

この方式の利点

  • 入力イベントのタイミング誤差を吸収できる 長押しの蓄積が update に乗るので、体感が安定する。
  • 描画(ゲージ表示)と完全に同期する chargeAmount がそのまま描画に使えるため、ゲージの見た目と実際の発射がズレない。
  • 調整パラメータが少なく、チューニングが速い 80ms / 700ms / 2.2 を触るだけで「押し心地」が大きく変わる。

8. 演出:背景 + ボケ + ビネットで“ゲーム画面”にする

Canvas 2D は軽い代わりに、何もしないと「ただ描いただけ」になりやすい。 そこで今回は、3Dのライティングやポストプロセスの代わりに、薄いレイヤを重ねて画面密度を作る方針にした。

狙いは1つだけで、盤面が主役に見える“ゲーム画面”っぽさを最小コストで出す。

レイヤ構成(上から順に効く)

採用したのは次の4つ。

  • 背景グラデ(画面の基調)
  • 背後ライト(盤面の存在感を持ち上げる)
  • bokeh(空気感・奥行きの擬似)
  • vignette(視線誘導と締まり)

これらを順に重ねると、Canvasでも“それっぽい画面”になる。


背景グラデ:まず黒一色をやめる

単色背景は素材感がなく、盤面が浮かない。縦グラデで「上が暗く下が少し明るい」だけでも画面が落ち着く。

  • UIやスコアが読みやすい
  • 盤面の色が映える

盤面背後ライト(大きい radial):主役を持ち上げる

盤面中心に、縦長のやわらかいライトを敷く。 これは3Dでいうところの「環境光+局所ライト」を2Dで雑に再現する手。

  • 盤面を背景から分離できる
  • 中央に視線が集まりやすい
  • 派手な加工なしで“ゲームっぽい”密度が出る

実装は drawBackLight() のように、巨大な radial gradient を1発置くだけなので軽い。


bokeh:点 + sin で “動いてる空気” を作る

bokeh は雰囲気を作るのにコスパがいい。 やっていることは単純で、

  • ランダムに配置した円(ぼかし)
  • sin() で alpha を揺らす

だけ。粒を少しだけ動かしてもいいが、今回の揺らぎだけでも「静止画感」が減る。

効果としては:

  • 背景に奥行きが出る
  • 盤面が前に出る
  • 画面が“止まって見えない”

vignette(multiply):最後に締めて視線誘導

画面の四隅を暗く落とす。 これは完全に「視線誘導」と「締まり」のためで、盤面周辺の余白が引き締まる。

  • 中央の盤面が相対的に明るく感じる
  • UIの見やすさが上がる
  • 全体が“完成した画面”っぽくなる

globalCompositeOperation = "multiply" を使うと、元の色味を壊しにくい。


まとめ:2Dでも“レイヤの積み上げ”で画面は作れる

Canvas 2D はシンプルな分、演出は「描き込み」ではなく レイヤ設計が効く。 今回の4点はどれも処理が軽く、しかも効果が大きい。

  • 重くない
  • 調整が速い(数値を触るだけ)
  • 盤面が主役に見える

3Dの演出を全部捨てても、この程度のレイヤを積むだけで「ゲーム画面」として十分成立する。