【JavaScript】角度のついた跳ね返り【ゲーム制作】

基本の角度のついた跳ね返り

水平面や垂直面に対する跳ね返りは、単に速度を反転させればよく簡単だが(【JavaScript】ボールを壁で跳ね返させる)、反射面に角度がついている場合は難しい。そこで、ボールが反射面に当たった時、システムの座標を変換して反射面を水平にし、跳ね返り処理を行った後、システムの座標を元に戻す方法を採用する。

下のスクリプトはCanvasに反射面である線を引き、上から落としたボールを跳ね返らせている。

別ページで表示

まず、ボールに見立てた円を描画するCircleクラス。

/**
 * 真円を描画(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._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;
	}

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

次に、線を描画するLineクラス。

/**
 * 反射面を描画(line.js)
 * @param parent 親のコンテキスト
 * @param x1 描き始めのX座標
 * @param y1 描き始めのY座標
 * @param x2 描き終わりのX座標
 * @param y2 描き終わりのY座標	
 */
export class Line {
	constructor(parent = undefined, x1 = 0, y1 = 0, x2 = 0, y2 = 0) {
		if (parent !== undefined) {
			this._parent = parent;
		}
		this._x = 0;
		this._y = 0;
		this._x1 = x1;
		this._y1 = y1;
		this._x2 = x2;
		this._y2 = y2;
		this._rotation = 0;
		this._lineWidth = 1;
		
		this._init();
	}

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

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

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

	draw() {
		this._ctx.save();
		this._ctx.translate(this._x, this._y);
		this._ctx.rotate(this._rotation);

		this._ctx.lineWidth = this._lineWidth;
		this._ctx.beginPath();
		this._ctx.moveTo(this._x1, this._y1);
		this._ctx.lineTo(this._x2, this._y2);
		this._ctx.closePath();
		this._ctx.stroke();
		this._ctx.restore();
	}

	/**
	 * 線の描画領域(矩形)を返す
	 * @returns オブジェクトでx、y、幅、高さ
	 */
	getBounds() {
		// 線の角度が0度(水平)なら実行
		if (this._rotation === 0) {
			let minX = Math.min(this._x1, this._x2);
			let minY = Math.min(this._y1, this._y2);
			let maxX = Math.max(this._x1, this._x2);
			let maxY = Math.max(this._y1, this._y2);

			return {
				x: this._x + minX,
				y: this._y + minY,
				width: maxX - minX,
				height: maxY - minY
			}
		// 線の角度が0以外なら実行
		} else {
			let sin = Math.sin(this._rotation);
			let cos = Math.cos(this._rotation);
			let x1r = cos * this._x1 + sin * this._y1;
			let x2r = cos * this._x2 + sin * this._y2;
			let y1r = cos * this._y1 + sin * this._x1;
			let y2r = cos * this._y2 + sin * this._x2;

			return {
				x: this._x + Math.min(x1r, x2r),
				y: this._y + Math.min(y1r, y2r),
				width: Math.max(x1r, x2r) - Math.min(x1r, x2r),
				height: Math.max(y1r, y2r) - Math.min(y1r, y2r)
			}
		}
	}

	//////////////////////////////////
	// 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 rotation() {
		return this._rotation;
	}
	set rotation(rotation) {
		this._rotation = rotation;
	}
}

最後に、角度のついた跳ね返りを処理するAngleBounceクラス。

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

/**
 * 角度のついた跳ね返り運動(anglebounce.js)
 */
export class AngleBounce {
	constructor() {
		this._cvs = document.getElementById('canvas');
		this._ctx = this._cvs.getContext('2d');
		this._line;
		this._circle;
		this._gravity = 0.2;
		this._bounce = -0.6;
		this._vx = 0;
		this._vy = 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";
		// ①角度のついた反射面を描画
		this._line = new Line(this._ctx, 0, 0, 250, 0);
		this._line.x = 50;
		this._line.y = 200;
		// ②角度をラジアンに変換して渡す
		this._line.rotation = 15 * Math.PI / 180;
		this._circle = new Circle(this._ctx, 100, 100, 30);

		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);

			// ③ボールに速度を設定.Y軸に重力値を適用
			this._vy += this._gravity;
			this._circle.x += this._vx;
			this._circle.y += this._vy;
			// ④角度のサインとコサインを取得
			let cos = Math.cos(this._line.rotation);
			let sin = Math.sin(this._line.rotation);
			// ⑤斜線を基準にしたボールの位置の取得
			let x1 = this._circle.x - this._line.x;
			let y1 = this._circle.y - this._line.y;
			// ⑥座標の回転
			let x2 = cos * x1 + sin * y1;
			let y2 = cos * y1 - sin * x1;
			// ⑦速度の回転
			let vx1 = cos * this._vx + sin * this._vy;
			let vy1 = cos * this._vy - sin * this._vx;
			// ⑧ボールが斜線に当たったか判定
			if (y2 > 0 - this._circle.radius) {
				// ⑨回転させた値を使った跳ね返りの実行
				y2 = -this._circle.radius;
				vy1 *= this._bounce;
			}
			// ⑩全てを回転させて元状態に戻す
			x1 = cos * x2 - sin * y2;
			y1 = cos * y2 + sin * x2;
			this._vx = cos * vx1 - sin * vy1;
			this._vy = cos * vy1 + sin * vx1;
			this._circle.x = this._line.x + x1;
			this._circle.y = this._line.y + y1;
			
			this._circle.draw();
			this._line.draw();

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

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

①まず反射面である線をX座標50、Y座標200の地点に描画。②角度は15度でラジアンに変換している。

③最初に重力値をボールのY軸に加算。さらに速度をボールに適用。

④線の角度を使いサインとコサインを取得。後に回転する時に使用。

⑤線の位置を基準にしたボールのX、Y座標を、ボールの位置から線の位置を引くことで求める。これが回転の中心点となる。

⑥座標の回転。回転のやり方は「【JavaScript】オブジェクトを円運動させる【JavaScript】オブジェクトを円運動させる」の後半で解説している。③で線の角度を元にサインとコサインを出しているため、同じ角度量回転して水平になる。

⑦速度も同じように回転させる。速度は角度と大きさ(長さ)を持つベクトルのため、角度が分かれば回転可能。

⑧ボールが跳ねる下の境界は0となる線自体のため、y2が0からボールの半径を引いた値よりも大きいかどうかで、当たり判定を行っている。

⑨線に上に来るようボール位置を調整し、跳ね返りの値を適用して跳ね返させる。

⑩全てを回転させて元状態に戻す。逆回転させるため下4行の+と-を逆にしている。元の座標を変数で保存しておくよりも手軽。

線から落ちるようにする

このままでは、線上を通り過ぎても転がり続けるため、ボールが線上にあるか判定を行う。判定処理を追加したのがAngleBounce2クラス。

AngleBounceクラスの③箇所の上に、

let bounds = this._line.getBounds();

を追加。線の描画領域を矩形情報(X座標、Y座標、幅、高さ)を取得。

さらに、④〜⑩までを以下のif文で囲っただけ。

if (this._circle.x + this._circle.radius > bounds.x
  && this._circle.x - this._circle.radius < bounds.x + bounds.width) {
      ...
}

スクリプトとコードは以下。

別ページで表示

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

/**
 * 角度のついた跳ね返り運動2(anglebounce2.js)
 */
export class AngleBounce2 {
	constructor() {
		this._cvs = document.getElementById('canvas');
		this._ctx = this._cvs.getContext('2d');
		this._line;
		this._circle;
		this._gravity = 0.2;
		this._bounce = -0.6;
		this._vx = 0;
		this._vy = 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";
		// 角度のついた反射面を描画
		this._line = new Line(this._ctx, 0, 0, 250, 0);
		this._line.x = 50;
		this._line.y = 200;
		// 角度をラジアンに変換して渡す
		this._line.rotation = 10 * Math.PI / 180;
		this._circle = new Circle(this._ctx, 100, 100, 30);

		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);

			// ①線の矩形領域をオブジェクト(x, y, 幅, 高さ)で取得
			let bounds = this._line.getBounds();
			// ボールに速度を設定.Y軸に重力値を適用
			this._vy += this._gravity;
			this._circle.x += this._vx;
			this._circle.y += this._vy;
			// ②ボールが線上にあるか判定
			if (this._circle.x + this._circle.radius > bounds.x
				&& this._circle.x - this._circle.radius < bounds.x + bounds.width) {
				// 角度のサインとコサインを取得
				let cos = Math.cos(this._line.rotation);
				let sin = Math.sin(this._line.rotation);
				// 線を基準にしたボールの位置の取得
				let x1 = this._circle.x - this._line.x;
				let y1 = this._circle.y - this._line.y;
				// 座標の回転
				let x2 = cos * x1 + sin * y1;
				let y2 = cos * y1 - sin * x1;
				// 速度の回転
				let vx1 = cos * this._vx + sin * this._vy;
				let vy1 = cos * this._vy - sin * this._vx;
				// ボールが線に当たったか判定
				if (y2 > 0 - this._circle.radius) {
					// 回転させた値を使った跳ね返りの実行
					y2 = -this._circle.radius;
					vy1 *= this._bounce;
				}
				// 全てを回転させて元状態に戻す
				x1 = cos * x2 - sin * y2;
				y1 = cos * y2 + sin * x2;
				this._vx = cos * vx1 - sin * vy1;
				this._vy = cos * vy1 + sin * vx1;
				this._circle.x = this._line.x + x1;
				this._circle.y = this._line.y + y1;
			}
			this._circle.draw();
			this._line.draw();

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

複数の反射面を跳ね返らせる

最後に反射面を4つに増やしてみる。

別ページで表示

4つの反射面を跳ね返り落下していく処理を担当するAngleBounce3クラス。

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

/**
 * 角度のついた跳ね返り運動3(anglebounce3.js)
 */
export class AngleBounce3 {
	constructor() {
		this._cvs = document.getElementById('canvas');
		this._ctx = this._cvs.getContext('2d');
		// ①反射面の線を4本分配列で用意
		this._lines = [];
		this._NUM_LINES = 4;
		this._circle;
		this._gravity = 0.2;
		this._bounce = -0.6;
		this._vx = 0;
		this._vy = 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";
		// ②角度のついた反射面を描画
		let lx = [50, 200, 50, 180];
		let ly = [100, 150, 200, 280];
		let lr = [10, -30, 10, -15];
		for (let i = 0; i < this._NUM_LINES; i++) {
			this._lines[i] = new Line(this._ctx, 0, 0, 100, 0);
			this._lines[i].x = lx[i];
			this._lines[i].y = ly[i];
			this._lines[i].rotation = lr[i] * Math.PI / 180;
		}
		this._circle = new Circle(this._ctx, 100, 0, 30);

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

	_checkLine(line) {
		// 線の矩形領域をオブジェクト(x, y, 幅, 高さ)で取得
		let bounds = line.getBounds();
		// ボールが線上にあるか判定
		if (this._circle.x + this._circle.radius > bounds.x
			&& this._circle.x - this._circle.radius < bounds.x + bounds.width) {
			// 角度のサインとコサインを取得
			let cos = Math.cos(line.rotation);
			let sin = Math.sin(line.rotation);
			// 線を基準にしたボールの位置の取得
			let x1 = this._circle.x - line.x;
			let y1 = this._circle.y - line.y;
			// 座標の回転
			let x2 = cos * x1 + sin * y1;
			let y2 = cos * y1 - sin * x1;
			// 速度の回転
			let vx1 = cos * this._vx + sin * this._vy;
			let vy1 = cos * this._vy - sin * this._vx;
			// ⑤ボールが線に当たったか判定
			if (y2 > 0 - this._circle.radius && y2 < vy1) {
				// 回転させた値を使った跳ね返りの実行
				y2 = -this._circle.radius;
				vy1 *= this._bounce;
			}
			// 全てを回転させて元状態に戻す
			x1 = cos * x2 - sin * y2;
			y1 = cos * y2 + sin * x2;
			this._vx = cos * vx1 - sin * vy1;
			this._vy = cos * vy1 + sin * vx1;
			this._circle.x = line.x + x1;
			this._circle.y = line.y + y1;
		}
		line.draw();
	}

	_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);

			// ③ボールに速度を設定.Y軸に重力値を適用
			this._vy += this._gravity;
			this._circle.x += this._vx;
			this._circle.y += this._vy;

			// ④ボールと各線の跳ね返り処理を線の数だけ実行
			for (let i = 0; i < this._NUM_LINES; i++) {
				if (this._checkLine(this._lines[i])) {
					break;
				}
			}
			this._circle.draw();

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

①反射面を4つにするため、配列で管理。

②4つの反射面をfor文で作成。

③ボールは今までと同じ1つのため、ボールに対する重力や速度の計算、適用はメインループで1度だけ行う。

④反射面となる線とボールの跳ね返り処理は、線が4つあるため独立した関数にまとめた。その_checkLine()をfor文で各線分呼び出す。

⑤if文の条件式の後半に「&& y2 < vy1」を追加している。これがないと跳ね返ったボールが上の線を超えると、超えた線に登ってしまう。

参考図書

コメント

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