雑食性雑感雑記

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

【JavaScript & Canvas】 投射と跳ね返り

前回 : 等速度運動
等速度運動やったなら、斜方投射もやっておこうと物理の計算を復習。

作成物

マウスオーバーで円 (= ボール) を作り出すのは前回と同じ。
右方向を0°として、0° ~ 180°の方向にランダムなボールを生成して投射します。
X方向の範囲外部分はループ。
Y方向下側が地面になっていて、衝突するとバウンドします。

技術要素

投射

物理の知識に詳しい記事はいっぱいあるので省略。
コンストラクタで初速を設定し、それを X 方向・Y方向に分割して速度を算出し、更に位置も算出します。

class Point {

    constructor(x, y, ts) {

        // 初期位置および初期タイムスタンプ
        this.first_x = x;
        this.first_y = y;
        this.first_ts = ts;

        // 初速
        const frac = Math.random();
        this.v0 = (1 - frac) * 0.3 + 0.7; // 0.3 ~ 1.0

        // 投射方向
        this.direction = Math.random() * Math.PI; // 0 ~ 180°

        // 加速度
        // NOTE: 重力 = 9.8 を使うと速すぎるので適当な小さい値を設定。
        this.a = 0.001;

        (後略)
    }

    update(ts) {
        // 初期時刻からの差分
        const ts_diff = ts - this.first_ts;
        const ts_diff2 = ts_diff * ts_diff; // 2乗

        // 速度
        // NOTE: x は等速度、y は等加速度で落下。
        this.vx = this.v0 * Math.cos(this.direction);
        this.vy = this.v0 * Math.sin(this.direction) - this.a * ts_diff;

        // 位置を算出
        const dx = this.v0 * Math.cos(this.direction) * ts_diff;
        const dy = this.v0 * Math.sin(this.direction) * ts_diff - 0.5 * this.a * ts_diff2;

        // 初期位置に加算
        // NOTE: canvas 座標系は y 下方向が正方向
        this.x = this.first_x + dx;
        this.y = this.first_y - dy; 

        ( 後略 )
    }
}

ループ & 反射

X方向はループ処理にしました。
範囲を超えたら、その分だけ加算 or 減算します。

        // x方向 ... 壁にぶつかったらループ
        while (this.x < 0) this.x += WIDTH;
        while (this.x > WIDTH) this.x -= WIDTH;

Y方向は反射。
衝突時の反射角度、衝突時速度を求め、衝突した位置を初期位置にして描画することで実現しています。
衝突時速度に反発係数を乗算して、徐々に速度が 0 になるようになっています。

        // y方向 ... 反射角度および衝突時距離を算出し、衝突位置より描画を再開
        if (this.y > HEIGHT) {

            // 衝突時の速度から反射角度を算出
            this.direction = Math.abs(Math.PI / 2.0 - Math.atan(this.vx, this.vy));

            // 衝突時速度
            this.v0 = Math.sqrt(this.vx * this.vx + this.vy * this.vy) * REFLECTION; // 反発係数

            // 激突位置から再開
            this.first_ts = ts
            this.first_y = HEIGHT;
            this.first_x = this.x;

            // 速度が小さくなったら無効化
            if (this.v0 < REFLECTION_VMIN) this.enabled = false;
        }

Canvas の操作とかは前回と一緒なので省略。

コード全体

<div id="canvas_base"></div>

<script>

// Canvas 紐づけ要素 ID
const BASE_ID = "canvas_base";

// 描画エリアサイズ
const WIDTH = 500;
const HEIGHT = 500;

// 反発係数
const REFLECTION = 0.8;

// 反射時に速度が一定以下になったら除外
const REFLECTION_VMIN = 0.2;

////////////////////
//
// 描画する点
// 物理法則に従って落下させる
// 
class Point {

    constructor(x, y, ts) {

        // 初期位置および初期タイムスタンプ
        this.first_x = x;
        this.first_y = y;
        this.first_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.3 + 0.7; // 0.3 ~ 1.0

        // 投射方向
        this.direction = Math.random() * Math.PI; // 0 ~ 180°

        // 加速度
        // NOTE: 重力 = 9.8 を使うと速すぎるので適当な小さい値を設定。
        this.a = 0.001;
    }

    // 現在位置を取得
    update(ts) {
        // 初期時刻からの差分
        const ts_diff = ts - this.first_ts;
        const ts_diff2 = ts_diff * ts_diff; // 2乗

        // 速度
        // NOTE: x は等速度、y は等加速度で落下。
        this.vx = this.v0 * Math.cos(this.direction);
        this.vy = this.v0 * Math.sin(this.direction) - this.a * ts_diff;

        // 位置を算出
        const dx = this.v0 * Math.cos(this.direction) * ts_diff;
        const dy = this.v0 * Math.sin(this.direction) * ts_diff - 0.5 * this.a * ts_diff2;

        // 初期位置に加算
        // NOTE: canvas 座標系は y 下方向が正方向
        this.x = this.first_x + dx;
        this.y = this.first_y - dy; 

        // x方向 ... 壁にぶつかったらループ
        while (this.x < 0) this.x += WIDTH;
        while (this.x > WIDTH) this.x -= WIDTH;

        // y方向 ... 反射角度および衝突時距離を算出し、衝突位置より描画を再開
        if (this.y > HEIGHT) {

            // 衝突時の速度から反射角度を算出
            this.direction = Math.abs(Math.PI / 2.0 - Math.atan(this.vx, this.vy));

            // 衝突時速度
            this.v0 = Math.sqrt(this.vx * this.vx + this.vy * this.vy) * REFLECTION; // 反発係数

            // 激突位置から再開
            this.first_ts = ts
            this.first_y = HEIGHT;
            this.first_x = this.x;

            // 速度が小さくなったら無効化
            if (this.v0 < REFLECTION_VMIN) 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>

アイキャッチ用

前回と一緒っぽいけど……。