最も簡単な衝突反応
球と球が衝突した時の動き(衝突反応)を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軸の速度をボールの位置に加える方法は、スピードが速くボールが込み入っていると喰い付きが発生する。
実際のコードでは、
- 2つのボールの速度を絶対値で取得し、足した値を変数に保存(absV)
- ボール同士がどれだけ重なっているかを、ボールの半径の合計値を求め、そこからボールの距離を引いて取得(overlap)
- ボールの速度を、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()の処理の流れ。
- 最初にボール同士の当たり判定を実行
- 2つのボールの中心点を結ぶ角度を取得し、サインとコサインを取得
- 1つ目のボールの位置を回転(1つ目のボールを回転の中心にするため座標は0,0)
- 2つ目のボールの位置を、1つ目のボールを基準に回転(この回転手法は「【JavaScript】角度のついた跳ね返り」参照)
- 1つ目のボールの速度を回転
- 2つ目のボールの速度を回転
- 1軸上の衝突処理を実行
- 新しいx速度をx位置に代入
- 両方のボールを逆回転させて、最終的なX、Y位置を取得
- 今までの計算は1つ目のボール位置を基準に計算したため、全ての値に1つ目のボール位置を加え、最終的な移動位置を取得
- 両方のボールの速度も逆回転させる
③各ボールが壁(キャンバスの境界)に当たったか判断。これも関数化。当たっていれば跳ね返す。
参考図書
コメント