雑食性雑感雑記

知識の整理場。ため込んだ知識をブログ記事として再構築します。

【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>

アイキャッチ用