【JavaScript】 球と球の衝突反応【ゲーム制作】

最も簡単な衝突反応

球と球が衝突した時の動き(衝突反応)をJavaScriptで実現してみる。ビリヤードの球の動きが典型例で、昔からあるおはじきやビー玉遊びも含まれるだろう。ビデオゲームではスマホアプリの「モンスターストライク」などが代表例。

物理的とは言えないが、最も簡単でそれらしく見せる方法は、衝突したオブジェクト同士の速度を入れ替えるやり方。

別ページで表示

まず、ボールとなる真円を描画するCircleクラス。内部にX、Y軸上の速度や質量を表す変数を実装。

/**
 * 真円を描画(circle.js)
 */
export class Circle {
	constructor(parent = undefined, x = 0, y = 0, radius = 15) {
		if (parent !== undefined) {
			this._parent = parent;
		}
		this._x = x;
		this._y = y;
		this._radius = radius;
		this._vx = 0;
		this._vy = 0;
		this._mass = 1;
		this._rotation = 0;
		this._color = "#ff0000";
		
		this._init();
	}

	//////////////////////////////////
	// Private and protected
	//////////////////////////////////

	_init() {
		if (this._parent !== undefined) {
			this._ctx = this._parent;
		}
		this.draw();
	}

	//////////////////////////////////
	// Public
	//////////////////////////////////

	draw() {
		this._ctx.save();
		this._ctx.fillStyle = this._color;
		this._ctx.beginPath();
		this._ctx.arc(this._x, this._y, this._radius, 0, (Math.PI * 2), true);
		this._ctx.closePath();
		this._ctx.fill();
		this._ctx.restore();
	}

	//////////////////////////////////
	// Getters/Setters
	//////////////////////////////////

	get x() {
		return this._x;
	}
	set x(x) {
		this._x = x;
	}

	get y() {
		return this._y;
	}
	set y(y) {
		this._y = y;
	}

	get radius() {
		return this._radius;
	}
	set radius(radius) {
		this._radius = radius;
	}

	get vx() {
		return this._vx;
	}
	set vx(vx) {
		this._vx = vx;
	}
	get vy() {
		return this._vy;
	}
	set vy(vy) {
		this._vy = vy;
	}

	get mass() {
		return this._mass;
	}
	set mass(mass) {
		this._mass = mass;
	}

	get rotation() {
		return this._rotation;
	}
	set rotation(rotation) {
		this._rotation = rotation;
	}

	set color(color) {
		this._color = color;
		this.draw();
	}
}

次に、衝突反応の処理を担当するCollisionクラス。

import { Circle } from "./circle.js";

/**
 * 簡単な衝突反応の処理(Collision1.js)
 */
export class Collision {
	constructor() {
		this._cvs = document.getElementById('canvas');
		this._ctx = this._cvs.getContext('2d');
		this._blueCir;
		this._redCir;
		this._bounce = -1.0;
		// タイマー関連
		this._animID;
		this._isAnim = 0;
		this._FPS = 60;
		this._frame = 0;
		this._startTime;
		this._nowTime;
		
		this._init();
	}

	//////////////////////////////////
	// Private and protected
	//////////////////////////////////

	_init() {
		this._cvs.style.backgroundColor = "#eeeeee";
		// ①大小2つのボールを作成
		this._blueCir = new Circle(this._ctx, 50, 200, 30);
		this._blueCir.vx = Math.random() * 5 + 1;
		this._blueCir.vy = Math.random() * 5 + 1;
		this._blueCir.color = "#0000ff";
		this._redCir = new Circle(this._ctx, 350, 200, 50);
		this._redCir.vx = Math.random() * 5 + 1;
		this._redCir.vy = Math.random() * 5 + 1;

		this._startTime = performance.now();
		this._mainLoop();
	}

	_checkWalls(cir) {
		if (cir.x + cir.radius > this._cvs.width) {
			cir.x = this._cvs.width - cir.radius;
			cir.vx *= this._bounce;
		} else if (cir.x - cir.radius < 0) {
			cir.x = cir.radius;
			cir.vx *= this._bounce;
		}
		if (cir.y + cir.radius > this._cvs.height) {
			cir.y = this._cvs.height - cir.radius;
			cir.vy *= this._bounce;
		} else if (cir.y - cir.radius < 0) {
			cir.y = cir.radius;
			cir.vy *= this._bounce;
		}
	}

	_mainLoop() {
		this._nowTime = performance.now();
		let elapsedTime = this._nowTime - this._startTime;
		let idealTime = this._frame * (1000 / this._FPS);
		if (idealTime < elapsedTime) {
			this._ctx.clearRect(0, 0, this._cvs.width, this._cvs.height);

			this._blueCir.x += this._blueCir.vx;
			this._blueCir.y += this._blueCir.vy;
			this._redCir.x += this._redCir.vx;
			this._redCir.y += this._redCir.vy;
			let dist = Math.sqrt((this._blueCir.x - this._redCir.x) ** 2 + (this._blueCir.y - this._redCir.y) ** 2);
			// ボールの当たり判定
			if (dist < this._blueCir.radius + this._redCir.radius) { 
				// ②2つのボールの速度(vx,vy)を入れ替える
				let tmpvx = this._blueCir.vx;
				let tmpvy = this._blueCir.vy;
				this._blueCir.vx = this._redCir.vx;
				this._blueCir.vy = this._redCir.vy;
				this._redCir.vx = tmpvx;
				this._redCir.vy = tmpvy;

				// ③2つの円が喰い付かないよう離す
				let absV = Math.abs(this._blueCir.vx) + Math.abs(this._redCir.vx);
				let overlap = (this._blueCir.radius + this._redCir.radius)
					- Math.abs(this._blueCir.x - this._redCir.x);
				this._blueCir.x += this._blueCir.vx / absV * overlap;
				this._redCir.x += this._redCir.vx / absV * overlap;
			}
			// ④各ボールの壁(キャンバスの境界)の当たり判定
			this._checkWalls(this._blueCir);
			this._checkWalls(this._redCir);

			this._blueCir.draw();
			this._redCir.draw();

			this._frame++;
			
			if (elapsedTime >= 1000) {
				this._startTime = this._nowTime;
				this._frame = 0;
			}
		}
	
		this._animID = requestAnimationFrame(this._mainLoop.bind(this));
	}
}

①大小2つのボールを宣言。速度を1〜5の間でランダム生成。

②ここで衝突した2つのボールの、互いの速度を入れ替えている。

③最後にボール同士が喰い付かないよう処理。

this._blueCir.x += this._blueCir.vx;
this._redCir.x += this._redCir.vx;

例えば、上記コードのように、最後にもう一度X軸の速度をボールの位置に加える方法は、スピードが速くボールが込み入っていると喰い付きが発生する。

実際のコードでは、

  1. 2つのボールの速度を絶対値で取得し、足した値を変数に保存(absV)
  2. ボール同士がどれだけ重なっているかを、ボールの半径の合計値を求め、そこからボールの距離を引いて取得(overlap)
  3. ボールの速度を、2つのボールの速度の絶対値(absV)で割り割合を求める。その割合に重なり分(overlap)を掛けて比例する分だけ、ボールを移動

④各ボールが壁(キャンバスの境界)に当たったか判断。2回行うため関数化している。当たっていれば跳ね返す。

この手法の利点は非常に簡単でありながら、2軸上の衝突反応を表現できる点。ゲームであれば、これで用が足りる場合も多いと思う。

片方のボールが停止していた場合、ぶつかりにいったボールは衝突後、停止してしまう。対処法として、違和感のない範囲の速度値をランダムに取得し設定すればよい。

requestAnimationFrameを使ったゲームループの実装は、「【JavaScript】requestAnimationFrameでゲームループを作る」を参照。

運動エネルギーの公式を使った衝突反応

運動量保存の法則

まず、前提として速度、質量、運動量を確認しておく。

速度(v)とは、方向と大きさ(スピード)のこと。ベクトルは方向と大きさを持つもののことだから、速度はベクトルということもできる。

質量(m)とは、専門的に言えば、物体が速度変化にどれだけ抵抗するかを表す大きさ。大きくなるほどその物体を移動させるのは難しくなり、小さくなるほど簡単になる。非専門的に噛み砕けば、その物体が地球上でどれだけ重いかということ。重さは質量に比例し、質量と重さはほとんど同じものと扱ってよい。

運動量(p)とは、物体の質量(m)と速度(v)をかけた値で表される。速度はベクトルなので、運動量もまたベクトル。運動量の方向は速度と同じ。

p = m * v

衝突前のオブジェクトの質量と速度(角度とスピード)が分かれば、衝突後にどこへ、どんな速さで移動するか計算できる。

運動量保存の法則は、ある系に外力が働かない限り(閉鎖系)、その系の運動量の総和(全運動量)は不変であるという物理法則(保存則)である。運動量保存の法則ともいう(Wikipedia参照)。つまり、外から何かの力が働かない限りにおいて、そこにある運動量(エネルギー)は物体の衝突などで変化することはあっても、全体の運動量の和に変化はないということ。

今回のプログラミング的に言えば、オブジェクトAとオブジェクトBが衝突し、互いの運動量に変化が生じても、その合計値は衝突前(の合計値)と衝突後(の合計値)は等しいとなる。

(Aの質量 * 衝突前Aの速度) + (Bの質量 * 衝突前Bの速度) = (Aの質量 * 衝突後Aの速度) + (Bの質量 * 衝突後Bの速度)

衝突後にオブジェクトA、Bの動きをシミュレートしたいわけだから、知りたいのは衝突後Aの速度、衝突後Bの速度だ。これは運動エネルギー(k:Kinetic Energy)の公式で求まる。

k = 0.5 * m * v²

つまり、先程の式は以下の意味になる。

kA + kB = 衝突後kA + 衝突後kB

詳細にすると以下。

(0.5 * Aの質量 * 衝突前Aの速度²) + (0.5 * Bの質量 * 衝突前Bの速度²) = (0.5 * Aの質量 * 衝突後Aの速度²) + (0.5 * Bの質量 * 衝突後Bの速度²)

ここから、知りたい衝突後Aの速度、衝突後Bの速度を求める式が以下。

衝突後Aの速度 = ((Aの質量 – Bの質量) * 衝突前Aの速度 + 2 * Bの質量 * 衝突前Bの速度) / (Aの質量 + Bの質量)

衝突後Bの速度 = ((Bの質量 – Aの質量) * 衝突前Bの速度 + 2 * Aの質量 * 衝突前Aの速度) / (Aの質量 + Bの質量)

1軸上の衝突反応

以下は、1軸上(X軸)で2つの大小のボールを衝突させるスクリプト。大きいボールの質量は小さい方の2.5倍に設定。

別ページで表示

衝突反応の処理を行うCollision3クラス。

import { Circle } from "./circle.js";

/**
 * 運動量保存の法則を使った1軸上の動き(Collision3.js)
 */
export class Collision3 {
	constructor() {
		this._cvs = document.getElementById('canvas');
		this._ctx = this._cvs.getContext('2d');
		this._blueCir;
		this._redCir;
		// タイマー関連
		this._animID;
		this._isAnim = 0;
		this._FPS = 60;
		this._frame = 0;
		this._startTime;
		this._nowTime;
		
		this._init();
	}

	//////////////////////////////////
	// Private and protected
	//////////////////////////////////

	_init() {
		this._cvs.style.backgroundColor = "#eeeeee";
		// 大小2つのボールを作成
		this._blueCir = new Circle(this._ctx, 50, 200, 20);
		this._blueCir.vx = 1;
		this._blueCir.mass = 1;
		this._blueCir.color = "#0000ff";
		this._redCir = new Circle(this._ctx, 350, 200, 50);
		this._redCir.vx = -1;
		this._redCir.mass = 2.5;

		this._startTime = performance.now();
		this._mainLoop();
	}

	_mainLoop() {
		this._nowTime = performance.now();
		let elapsedTime = this._nowTime - this._startTime;
		let idealTime = this._frame * (1000 / this._FPS);
		if (idealTime < elapsedTime) {
			this._ctx.clearRect(0, 0, this._cvs.width, this._cvs.height);

			this._blueCir.x += this._blueCir.vx;
			this._redCir.x += this._redCir.vx;
			let dist = this._blueCir.x - this._redCir.x;
			// ボールの当たり判定
			if (Math.abs(dist) < this._blueCir.radius + this._redCir.radius) {
				// ①運動エネルギーの公式を使い衝突後の速度を計算
				let afterBlueVx = ((this._blueCir.mass - this._redCir.mass) * this._blueCir.vx + 2 * this._redCir.mass * this._redCir.vx)
					/ (this._blueCir.mass + this._redCir.mass);
				let afterRedVx = ((this._redCir.mass - this._blueCir.mass) * this._redCir.vx + 2 * this._blueCir.mass * this._blueCir.vx)
					/ (this._blueCir.mass + this._redCir.mass);

				this._blueCir.vx = afterBlueVx;
				this._redCir.vx = afterRedVx;

				// ②2つの円が喰い付かないよう離す
				let absV = Math.abs(this._blueCir.vx) + Math.abs(this._redCir.vx);
				let overlap = (this._blueCir.radius + this._redCir.radius)
					- Math.abs(this._blueCir.x - this._redCir.x);
				this._blueCir.x += this._blueCir.vx / absV * overlap;
				this._redCir.x += this._redCir.vx / absV * overlap;
			}

			this._blueCir.draw();
			this._redCir.draw();

			this._frame++;
			
			if (elapsedTime >= 1000) {
				this._startTime = this._nowTime;
				this._frame = 0;
			}
		}
	
		this._animID = requestAnimationFrame(this._mainLoop.bind(this));
	}
}

①運動エネルギーの公式を使い、2つのボールの衝突後の速度を求めている。

②衝突後、2つのボールを明確に離している。重なり続けて離れなくなる可能性があるため。

2軸上の衝突反応

上の1軸上の衝突では、2つのボールは同じX軸上に移動していたため、単純に公式に当てはめるだけで正しく計算できた。

2軸上の衝突というのは、衝突するボールが異なる方向を向いているということ。上の2軸上の衝突の図を、1軸上の図と同じにするために回転させる。ここでは「【JavaScript】角度のついた跳ね返り」で使った回転手法を用いる。

システムの座標自体を回転させて、2つのボールの速度(ベクトル)のX軸を合わせる。

これで1軸上の衝突と同じ処理で跳ね返りの結果を計算できる。必要なのはX軸の計算のみ、Y軸(vy)は変更しない。

最後にもう一度、逆回転させて元に戻す。

別ページで表示

衝突反応の処理を行うCollision4クラス。

import { Circle } from "./circle.js";

/**
 * 運動量保存の法則を使った2軸上の動き(Collision4.js)
 */
export class Collision4 {
	constructor() {
		this._cvs = document.getElementById('canvas');
		this._ctx = this._cvs.getContext('2d');
		this._blueCir;
		this._redCir;
		this._bounce = -1.0;
		// タイマー関連
		this._animID;
		this._isAnim = 0;
		this._FPS = 60;
		this._frame = 0;
		this._startTime;
		this._nowTime;
		
		this._init();
	}

	//////////////////////////////////
	// Private and protected
	//////////////////////////////////

	_init() {
		this._cvs.style.backgroundColor = "#eeeeee";
		// ①大小2つのボールを作成
		this._blueCir = new Circle(this._ctx, 50, 200, 20);
		this._blueCir.vx = Math.random() * 5 + 1;
		this._blueCir.vy = Math.random() * 5 + 1;
		this._blueCir.mass = 1;
		this._blueCir.color = "#0000ff";
		this._redCir = new Circle(this._ctx, 350, 200, 50);
		this._redCir.vx = Math.random() * 5 + 1;
		this._redCir.vy = Math.random() * 5 + 1;
		this._redCir.mass = 5;

		this._startTime = performance.now();
		this._mainLoop();
	}

	_checkCollision(cir1, cir2) {
		let dx = cir2.x - cir1.x;
		let dy = cir2.y - cir1.y;
		let dist = Math.sqrt(dx ** 2 + dy ** 2);
		// ボールの当たり判定
		if (dist < cir1.radius + cir2.radius) {
			// 角度を取得し、サインとコサインを計算
			let angle = Math.atan2(dy, dx);
			let sin = Math.sin(angle);
			let cos = Math.cos(angle);

			// cir1の位置を回転
			let cir1x = 0;
			let cir1y = 0;

			// cir2の位置を、cir1を基準に回転
			let cir2x = dx * cos + dy * sin;
			let cir2y = dy * cos - dx * sin;

			// cir1の速度を回転
			let cir1vx = cir1.vx * cos + cir1.vy * sin;
			let cir1vy = cir1.vy * cos - cir1.vx * sin;

			// cir2の速度を回転
			let cir2vx = cir2.vx * cos + cir2.vy * sin;
			let cir2vy = cir2.vy * cos - cir2.vx * sin;

			// 1軸上の衝突処理を実行
			let afterCir1vx = ((cir1.mass - cir2.mass) * cir1vx + 2 * cir2.mass * cir2vx)
				/ (cir1.mass + cir2.mass);
			let afterCir2vx = ((cir2.mass - cir1.mass) * cir2vx + 2 * cir1.mass * cir1vx)
				/ (cir1.mass + cir2.mass);

			cir1vx = afterCir1vx;
			cir2vx = afterCir2vx;

			//2つの円が喰い付かないよう離す
			let absV = Math.abs(cir1vx) + Math.abs(cir2vx);
			let overlap = (cir1.radius + cir2.radius)
				- Math.abs(cir1x - cir2x);
			console.log("absV:" + absV);
			console.log("overlap:" + overlap);
			cir1x += cir1vx / absV * overlap;
			cir2x += cir2vx / absV * overlap;

			// 位置と速度の更新が済んだため、逆回転させる
			let cir1xFin = cir1x * cos - cir1y * sin;
			let cir1yFin = cir1y * cos + cir1x * sin;
			let cir2xFin = cir2x * cos - cir2y * sin;
			let cir2yFin = cir2y * cos + cir2x * sin;

			// 今までの計算はcir1の位置を基準に計算したため、
			// 全ての値にcir1の位置を加え、最終的な移動位置(描画)を取得
			cir2.x = cir1.x + cir2xFin;
			cir2.y = cir1.y + cir2yFin;
			cir1.x = cir1.x + cir1xFin;
			cir1.y = cir1.y + cir1yFin;

			// 速度も逆回転させる
			cir1.vx = cir1vx * cos - cir1vy * sin;
			cir1.vy = cir1vy * cos + cir1vx * sin;
			cir2.vx = cir2vx * cos - cir2vy * sin;
			cir2.vy = cir2vy * cos + cir2vx * sin;
		}
	}

	_checkWalls(cir) {
		if (cir.x + cir.radius > this._cvs.width) {
			cir.x = this._cvs.width - cir.radius;
			cir.vx *= this._bounce;
		} else if (cir.x - cir.radius < 0) {
			cir.x = cir.radius;
			cir.vx *= this._bounce;
		}
		if (cir.y + cir.radius > this._cvs.height) {
			cir.y = this._cvs.height - cir.radius;
			cir.vy *= this._bounce;
		} else if (cir.y - cir.radius < 0) {
			cir.y = cir.radius;
			cir.vy *= this._bounce;
		}
	}

	_mainLoop() {
		this._nowTime = performance.now();
		let elapsedTime = this._nowTime - this._startTime;
		let idealTime = this._frame * (1000 / this._FPS);
		if (idealTime < elapsedTime) {
			this._ctx.clearRect(0, 0, this._cvs.width, this._cvs.height);

			// ボールの移動処理
			this._blueCir.x += this._blueCir.vx;
			this._blueCir.y += this._blueCir.vy;
			this._redCir.x += this._redCir.vx;
			this._redCir.y += this._redCir.vy;
			// ②ボール同士の当たり判定
			this._checkCollision(this._blueCir, this._redCir);
			// ③各ボールの壁(キャンバスの境界)の当たり判定
			this._checkWalls(this._blueCir);
			this._checkWalls(this._redCir);

			this._blueCir.draw();
			this._redCir.draw();

			this._frame++;
			
			if (elapsedTime >= 1000) {
				this._startTime = this._nowTime;
				this._frame = 0;
			}
		}
	
		this._animID = requestAnimationFrame(this._mainLoop.bind(this));
	}
}

①速度を1〜5の間でランダム生成。質量は分かりやすいよう1:5に設定。

②衝突処理は長くなるため関数化。_checkCollision()の処理の流れ。

  1. 最初にボール同士の当たり判定を実行
  2. 2つのボールの中心点を結ぶ角度を取得し、サインとコサインを取得
  3. 1つ目のボールの位置を回転(1つ目のボールを回転の中心にするため座標は0,0)
  4. 2つ目のボールの位置を、1つ目のボールを基準に回転(この回転手法は「【JavaScript】角度のついた跳ね返り」参照)
  5. 1つ目のボールの速度を回転
  6. 2つ目のボールの速度を回転
  7. 1軸上の衝突処理を実行
  8. 新しいx速度をx位置に代入
  9. 両方のボールを逆回転させて、最終的なX、Y位置を取得
  10. 今までの計算は1つ目のボール位置を基準に計算したため、全ての値に1つ目のボール位置を加え、最終的な移動位置を取得
  11. 両方のボールの速度も逆回転させる

③各ボールが壁(キャンバスの境界)に当たったか判断。これも関数化。当たっていれば跳ね返す。

参考図書

コメント

タイトルとURLをコピーしました