30分で作るブロック崩し:反射の計算を最小で済ませる
遊びをつくる執筆にClaudeを使用しています。

ブロック崩しを作るとき、最初につまずきやすいのが「ボールがブロックのどの面に当たったか」の判定です。円と四角形の厳密な交差を計算し始めると、法線ベクトルや最近接点の話になり、30分では収まりません。 そこでこの記事では、「どこまで計算を減らしても、ブロック崩しとして成立するか」を問いにします。使うのは3つだけです。壁は速度の符号を反転する、ボールは x と y を別々に動かして判定する、ブロックは格子の添字計算で当たり先を引く。この3つで、反射と壊れるブロックが動く最小版を作ります。 最終的なコードは約40行、外部ライブラリなしの単独HTMLです。手順は5分、10分、5分、10分の配分で進め、最後に改造と確認の項目を示します。動作の結果はここでは書きません。各手順の確認項目を自分の画面で見て、判断してください。
考え方:反射の計算を3つに絞る
最小版の方針を先に整理します。
- 壁の反射は符号反転だけで済ませます。 左右の壁に触れたら vx = -vx、天井に触れたら vy = -vy です。入射角を計算する必要はありません。軸に平行な面での反射は、その軸の速度の符号を変えるだけで正しく表せます。
- x と y を別々に動かして判定します。 x だけ動かして当たったら vx を反転し、次に y だけ動かして当たったら vy を反転します。横から当たったのか上下から当たったのかを、動かした軸そのもので区別できます。前フレームの位置との比較や、めり込み量の比較は不要です。
- ブロックは「点」と「格子」で判定します。 ボールの円全体を調べず、進行方向の先端の一点だけを調べます。ブロックは同じ大きさで規則正しく並べるので、点の座標を幅と高さで割って切り捨てれば、列と行が出ます。ブロック全部をループで調べる必要はありません。
この3つの代償は、見た目の精度です。角に当たった瞬間の挙動は厳密ではなく、ボールが少しだけブロックにめり込んで見えることがあります。ただ、遊びの手触りに大きく影響する部分かどうかは、手順2で自分の目で確かめられます。
手順1:全体を貼って動かす(5分)
新しい空のファイルを作り、index.html という名前で保存して、次のコードを丸ごと貼ります。ブラウザで開くと、画面上部に8列5行のブロックが並び、下にパドルとボールが現れる構成です。
<!doctype html>
<meta charset="utf-8">
<canvas id="c" width="480" height="320" style="background:#111;touch-action:none"></canvas>
<script>
const c=document.getElementById("c"),g=c.getContext("2d");
const W=480,H=320,R=6,COLS=8,ROWS=5,BW=60,BH=18,OY=40,PW=70,PY=H-20;
let bricks,x,y,vx,vy,px=205,lives=3;
function serve(){x=240;y=220;vx=2.4;vy=-3;}
function reset(){bricks=Array(COLS*ROWS).fill(1);lives=3;serve();}
function hit(a,b){
const col=Math.floor(a/BW),row=Math.floor((b-OY)/BH),i=row*COLS+col;
if(col<0||col>=COLS||row<0||row>=ROWS||!bricks[i])return false;
bricks[i]=0;return true;
}
c.addEventListener("pointermove",e=>{
const r=c.getBoundingClientRect();
px=Math.max(0,Math.min(W-PW,(e.clientX-r.left)*W/r.width-PW/2));
});
function step(){
x+=vx;
if(x<R&&vx<0||x>W-R&&vx>0||hit(x+Math.sign(vx)*R,y))vx=-vx;
y+=vy;
if(y<R&&vy<0||hit(x,y+Math.sign(vy)*R))vy=-vy;
if(vy>0&&y>PY-R&&y<PY&&x>px-R&&x<px+PW+R){
vy=-vy;vx=(x-(px+PW/2))/(PW/2)*4;
}
if(y>H+R){lives--;if(lives<=0)reset();else serve();}
if(bricks.every(b=>!b))reset();
}
function draw(){
g.clearRect(0,0,W,H);
bricks.forEach((b,i)=>{if(b){g.fillStyle="hsl("+(Math.floor(i/COLS)*50)+",70%,60%)";
g.fillRect(i%COLS*BW+1,OY+Math.floor(i/COLS)*BH+1,BW-2,BH-2);}});
g.fillStyle="#eee";g.fillRect(px,PY,PW,8);
g.beginPath();g.arc(x,y,R,0,7);g.fill();
g.fillText("残機 "+lives,8,16);
}
function loop(){step();draw();requestAnimationFrame(loop);}
reset();loop();
</script>操作は、マウスをキャンバス上で動かす、またはタッチでドラッグすると、パドルが横に動きます。キャンバスが拡大縮小されても位置がずれないよう、表示幅に対する比率で座標を換算しています。
確認項目は次の三つです。
- ボールが壁と天井で跳ね返るか。
- ブロックに当たると、そのブロックが消えるか。
- ボールが下に落ちると残機が減り、0になると全体が最初に戻るか。
どれかが動かない場合は、貼り付けの欠けを疑い、ブラウザの開発者ツールのコンソールにエラーが出ていないか確認します。
手順2:反射の部分を読む(10分)
動いたら、step 関数の4行を読みます。ここが記事の中心です。
x+=vx;
if(x<R&&vx<0||x>W-R&&vx>0||hit(x+Math.sign(vx)*R,y))vx=-vx;
y+=vy;
if(y<R&&vy<0||hit(x,y+Math.sign(vy)*R))vy=-vy;壁の条件に速度の向きを足す理由
x<R だけでなく &&vx<0(左へ進んでいる)を付けています。これは、反転した直後に壁の内側へ戻りきれなかった場合に、次のフレームでもう一度反転してしまうのを防ぐためです。向きの条件がなければ、壁の中で毎フレーム符号が入れ替わり、ボールが壁際で震えるように見えることがあります。
自分で確かめるなら、&&vx<0 を消して動かし、壁際でのふるまいを見ます。変化が見られれば、向きの条件の役割が確認できたことになります。変化が見られない速度設定でも、速度を上げると差が出る場合があります。
先端の一点で調べる理由
x+Math.sign(vx)*R は、右に進んでいれば円の右端、左に進んでいれば左端の x 座標です。y を動かす側も同じ考え方で、上へ進んでいれば円の上端、下なら下端を使います。進む方向の先端だけを調べるので、反対側がブロックに触れて誤判定することはありません。
軸を分ける効果の確かめ方
軸を分ける効果は、比較すると分かります。
- 現在のコードを別名で保存しておきます。
- ブロックの判定を x 側から外します(||hit(x+Math.sign(vx)*R,y) を消します)。
- 横からブロックの側面に当たるような角度で動かし、反射の向きを見ます。
x 側の判定がなければ、横から入ったボールは側面で反射せず、y 側の判定で初めて反応します。その場合の跳ね返り方が自然かどうかは、自分の目で判断してください。さらに y 側も vy=-vy だけの判定に置き換えたとき、どの場面で不自然さが出るかを探すと、軸を分ける価値が実感できます。
格子の添字計算
hit 関数は、点の座標 (a, b) から次のように行と列を出します。
- 列:Math.floor(a / BW)
- 行:Math.floor((b - OY) / BH)
- 配列の位置:row * COLS + col
ブロックの状態は 0 か 1 の一次元配列 bricks に持たせています。生きていれば 1、壊れていれば 0 です。この配列の添字と画面上の位置が、割り算だけで行き来できる点が省略の核です。範囲外の添字を配列に渡さないよう、列と行の範囲チェックを先に置いています。条件の並び順を変えると、存在しない添字を読んでしまう場合があるので、順序は保ってください。
手順3:パドルで角度を付ける(5分)
パドルの判定は、ボールが下向きに進んでいて、パドルの上面付近にあり、横位置がパドルの幅に収まっているとき、の三つを満たす場合です。
if(vy>0&&y>PY-R&&y<PY&&x>px-R&&x<px+PW+R){
vy=-vy;vx=(x-(px+PW/2))/(PW/2)*4;
}vy>0 の条件は、ボールがパドルの内部で上向きに動いている間に、繰り返し反転して張り付くのを防ぎます。y>PY-R&&y<PY は縦方向の帯で、その幅は R(6)です。ボールの1フレームあたりの縦移動量(3)より広いので、帯を飛び越えません。
角度の付け方も一行です。パドルの中心からのずれを、半幅で割って -1〜1 に直し、4 を掛けます。中心に当たれば vx は 0 に近づき、端に当たるほど横向きの速度が大きくなります。三角関数は使いません。
ここで調整できる項目を挙げます。
- 掛け算の 4 を変えると、端に当たったときの横の勢いが変わります。
- vy の符号反転だけでは速さは変わりません。速度を上げたいときは、ブロックを壊した数に応じて vy の大きさを少しずつ増やす方法があります。
- 横を完全な垂直往復にしたくない場合は、vx に最低値を持たせます。
起こりやすい誤りと確認項目(5分)
最小の判定は、前提が崩れると破綻します。守る条件を先に確認します。
- 1フレームの移動量がブロックの薄い辺(18)より小さい、破れたときに起こりうること:ブロックを飛び越える、確認の仕方:vy を段階的に大きくして、貫通が起きる値を探す
- 判定を調べる点がブロックの内部に入る、破れたときに起こりうること:同じ列の隣接ブロックを続けて壊す、確認の仕方:角に当たる場面で、消えるブロックの数を数える
- 壁の判定に速度の向きを付ける、破れたときに起こりうること:壁際で震える、確認の仕方:手順2の比較で確認する
- 配列の範囲チェックを先に置く、破れたときに起こりうること:範囲外の添字を参照する、確認の仕方:画面の端と上方向に飛ばして挙動を見る
特に、ボールが2つのブロックの角へ向かうとき、x 側と y 側の両方で別々のブロックに当たり、同じフレームで2個消えることがあります。軸を分けた判定の副作用です。これを許容できるなら、そのままで構いません。1フレームに壊すのは1個までにしたいなら、hit が true を返したら、同じフレームの残りの判定を飛ばす変数を一つ足します。
もう一つ確認したいのは、ボールがパドルの横から当たった場合です。ボールの高さがパドルの帯(PY-R から PY)を過ぎていると、反射せずそのまま落ちます。これは仕様として残してあります。横からも跳ね返したい場合は、判定を矩形に近づける別の設計が必要になり、30分の範囲を超えます。
次の行動
コードを動かしたら、まず 4 と vy の値を変えて、自分の好みの手触りを探します。そのうえで、ブロックの段数や色、壊れたときの音など、一つだけ足してみてください。
公開する場合は、アソバースで HTML ファイルを直接アップロードできます。入口はファイル直下の index.html で、素材を足すなら相対パスで指定します。このコードは外部通信もサーバー用コードも使わないので、そのままの構成で投稿に向きます。投稿手順や制限の細部は、投稿画面の現在の案内で確認してください。

