【JavaScript & Canvas】 等速度運動と反射
前回に下地となるクラスを作ったので、アニメーションが活かせる簡単な処理を作ってみます。
『ボールを投げて壁に反射して――』が分かり易そうなのでコレを作ってみよう。
作成物
Canvas エリアにマウスカーソルを持っていくと、mousemove イベントにより球体が生成されます。
球体は何回か壁に反射して消滅します。
マウス動かして大量生成すると、カラフルな感じになります。
技術要素
描画している点を管理する「Point」クラス
Canvas の処理で描画位置等管理するのは大変なので、描画位置やスタイルを管理するための Point クラスを作ります。
コンストラクタで初期位置およびタイムスタンプを入力。カラーや大きさはランダムで設定しておきます。
更新 (update) 時に前回タイムスタンプとの差分を取って、その分だけ等速度に移動させます。
この辺は高校生の物理辺りですかね。詳しいブログいっぱいあるだろうから省略。
反射については、1 or -1 が入る変数 ref_x および ref_y を用意して、壁にぶつかるごとに符号を入れ替えています。
……が、速度によっては通り過ぎてしまい、抜け出せないまま反射最大数を超えることになります。。
反射最大数を超えると有効化フラグが false となります。無効となった point は上位モジュールにより削除します。
Point クラス :
//////////////////// // // 描画する点 // 物理法則に従って落下させる // class Point { constructor(x, y, ts) { // 初期位置およびタイムスタンプ this.x = x this.y = y this.ts = ts // 有効フラグ // 無効になったら上位モジュールより削除 this.enabled = true; // 表示色 this.color = 'rgb(' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ')' // 大きさ const frac = Math.random(); this.size = frac * 20 + 10; // 10 ~ 30 // 初速 // 大きさに反比例 this.v0 = (1 - frac) * 0.7 + 0.5; // 0.5 ~ 1.2 // 投射方向 this.direction = Math.random() * Math.PI * 2; // 0 ~ 2 * Pi // 反射 this.ref_count = 0; this.ref_x = 1; this.ref_y = 1; } // 現在位置を取得 update(ts) { // 初期時刻からの差分 const ts_diff = ts - this.ts; // 速度 this.vx = this.v0 * Math.cos(this.direction); this.vy = this.v0 * Math.sin(this.direction); // 位置を算出 const dx = this.vx * ts_diff; const dy = this.vy * ts_diff; // 初期位置に加算 // NOTE: canvas 座標系は y 下方向が正方向 this.x += this.ref_x * dx this.y += - this.ref_y * dy; // 現在 timestamp 更新 this.ts = ts; // 反射 if (this.x < 0 || this.x > WIDTH) {this.ref_x *= -1; this.ref_count++;} if (this.y < 0 || this.y > HEIGHT) {this.ref_y *= -1; this.ref_count++;} // 一定数跳ね返ったら無効化 if (this.ref_count >= 50) this.enabled = false; } }
描画更新処理
前回、下地クラスを作った時の更新処理で用いた window.requestAnimationFrame() で呼び出される update ですが、第一引数にタイムスタンプが入ってきます。
よって、このタイムスタンプの差分を使うことで、差分だけ移動させることが可能です。
まず、point の生成は、マウスオーバーのイベント発生時にその座標を保持しておき、
// (始めはマウスクリックイベントのつもりだったので名前が onclick。。) onclick(event) { const rect = event.target.getBoundingClientRect(); this.click_x = event.clientX - rect.left; this.click_y = event.clientY - rect.top; }
update 処理内で、位置が設定されていたら点を生成します。
// ( update 内 ) if (this.click_x != null && this.click_y != null) { this.points.push(new Point(this.click_x, this.click_y, timestamp)) this.click_x = null; this.click_y = null; }
JavaScript は forEach 使えるの便利。これを使って描画。
this.points.forEach(element => { element.update(timestamp); this.context_buff.beginPath(); this.context_buff.arc(element.x, element.y, element.size, 0, 2 * Math.PI, false); this.context_buff.fillStyle = element.color; this.context_buff.fill(); });
Point クラスで、「一定数反射したら enabled = false にする」というのを入れているので、
条件を満たした point を filter 使って削除します。
// 範囲外に出たものを除外 this.points = this.points.filter((value, index, array) => value.enabled);
コード全文
貼っておきます。
コード全文 :
<div id="canvas_base"></div> <script> // Canvas 紐づけ要素 ID const BASE_ID = "canvas_base"; // 描画エリアサイズ const WIDTH = 500; const HEIGHT = 500; //////////////////// // // 描画する点 // 物理法則に従って落下させる // class Point { constructor(x, y, ts) { // 初期位置およびタイムスタンプ this.x = x this.y = y this.ts = ts // 有効フラグ // 無効になったら上位モジュールより削除 this.enabled = true; // 表示色 this.color = 'rgb(' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ')' // 大きさ const frac = Math.random(); this.size = frac * 20 + 10; // 10 ~ 30 // 初速 // 大きさに反比例 this.v0 = (1 - frac) * 0.7 + 0.5; // 0.5 ~ 1.2 // 投射方向 this.direction = Math.random() * Math.PI * 2; // 0 ~ 2 * Pi // 反射 this.ref_count = 0; this.ref_x = 1; this.ref_y = 1; } // 現在位置を取得 update(ts) { // 初期時刻からの差分 const ts_diff = ts - this.ts; // 速度 this.vx = this.v0 * Math.cos(this.direction); this.vy = this.v0 * Math.sin(this.direction); // 位置を算出 const dx = this.vx * ts_diff; const dy = this.vy * ts_diff; // 初期位置に加算 // NOTE: canvas 座標系は y 下方向が正方向 this.x += this.ref_x * dx this.y += - this.ref_y * dy; // 現在 timestamp 更新 this.ts = ts; // 反射 if (this.x < 0 || this.x > WIDTH) {this.ref_x *= -1; this.ref_count++;} if (this.y < 0 || this.y > HEIGHT) {this.ref_y *= -1; this.ref_count++;} // 一定数跳ね返ったら無効化 if (this.ref_count >= 50) this.enabled = false; } } //////////////////// // // Canvas 操作クラス // class CanvasOp { constructor(width, height, base_id = null) { this.width = width; this.height = height; // 描画用 Canvas およびダブルバッファリング用 Canvas を作成 this.canvas = document.createElement("canvas"); this.canvas_buff = document.createElement("canvas"); this.canvas_buff.hidden = true; // 情報表示用 this.text = document.createElement("div"); // サイズ設定 this.canvas.width = width; this.canvas.height = height; this.canvas_buff.width = width; this.canvas_buff.height = height; // canvas を親要素に紐づけ if (base_id == null) { // 未指定なら document.body に紐づける document.body.appendChild(this.canvas); document.body.appendChild(this.canvas_buff); document.body.appendChild(this.text); } else { const base = document.getElementById(base_id); base.appendChild(this.canvas); base.appendChild(this.canvas_buff); base.appendChild(this.text); } // 操作用 Context if (this.canvas.getContext && this.canvas_buff.getContext) { this.context = this.canvas.getContext("2d"); this.context_buff = this.canvas_buff.getContext("2d"); } else { this.context = null; this.context_buff = null; console.error("Error: Canvas not supported."); } // イベント追加 // this.canvas.addEventListener('click', this.onclick.bind(this), false); // 「マウス上にあれば常に」版 this.canvas.addEventListener('mousemove', this.onclick.bind(this), false); // アニメーション用カウント this.count = 0; // クリック位置 this.click_x = null; this.click_y = null; // 点群 this.points = [] } // クリック時イベント onclick(event) { const rect = event.target.getBoundingClientRect(); this.click_x = event.clientX - rect.left; this.click_y = event.clientY - rect.top; } // 更新処理 update(timestamp) { // context 空なら停止 if (this.context == null) { return; } // クリックされていたら点を追加 if (this.click_x != null && this.click_y != null) { this.points.push(new Point(this.click_x, this.click_y, timestamp)) this.click_x = null; this.click_y = null; } ////// /// buff に描画 /// // Clear this.context_buff.clearRect(0, 0, this.width, this.height); // 外枠 this.context_buff.strokeRect(0, 0, this.width, this.height); // animation this.points.forEach(element => { element.update(timestamp); this.context_buff.beginPath(); this.context_buff.arc(element.x, element.y, element.size, 0, 2 * Math.PI, false); this.context_buff.fillStyle = element.color; this.context_buff.fill(); }); // 範囲外に出たものを除外 this.points = this.points.filter((value, index, array) => value.enabled); // 現在のボール数 this.text.innerHTML = "Ball count : " + this.points.length; ////// /// Canvas に転送 /// const data = this.context_buff.getImageData(0, 0, this.width, this.height); this.context.putImageData(data, 0, 0); // Update window.requestAnimationFrame((timestamp) => this.update(timestamp)); } } //////////////////// // // onload // window.onload = function() { let canvas = new CanvasOp(WIDTH, HEIGHT, BASE_ID); canvas.update(0); } </script>
アイキャッチ用