雑食性雑感雑記

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

【JavaScript & Canvas】等速度運動のボールを弾ませてみる

先日にボールを壁に反射させるのを作った。
コレ、ボールが反射するときに弾ませてみたら面白いかなーと思い、弾ませる方法を考えてみた。

作成物

内容は前と一緒。Canvas エリアにマウスカーソルを持っていくと、mousemove イベントによりボールが生成されます。
ボールは壁に反射する際、ちょっと弾んでから反射します。

技術要素

Canvas の楕円形

楕円は――と思ったら、ellipse() で描けるんですね。便利。
短辺と長辺を設定すれば描けるとのことでちょっと実験。

短辺 = 20、長辺 = 40 とした楕円と、
両辺 20 で楕円を描いたもの、同じく両辺 40 で楕円を描いたものを並べてみました。配置を見ると、細かい調整無く使えそう。

context.beginPath();
context.fillStyle = 'rgb(213, 83, 182)';
context.ellipse(200, 200, 30, 60, 0, 0, 2 * Math.PI, false);
context.fill();
context.beginPath();
context.fillStyle = 'rgb(213, 83, 182)';
context.arc(200, 400, 30, 0, 2 * Math.PI, false);
context.fill();
context.beginPath();
context.fillStyle = 'rgb(213, 83, 182)';
context.arc(400, 200, 60, 0, 2 * Math.PI, false);
context.fill();

弾ませる

弾ませるための定義。以下のように決めました。

『反射しなければいけない』内部の閾値および、『まだ進めるけどだんだん形が歪む』外側の閾値を定め、
・壁との距離が内側閾値以下 → 反射。
・壁との距離が外側閾値以下 → そのまま進むが、ボールを歪ませる (楕円形にする)。

あとは割合を計算するだけ。

実装においての問題とか

現在の実装では、射出方向によって、閾値を大きく超えてしまうことがあります。
初速の設定でマージンを大きくとるとか、反射したときに描画に矛盾が出ない位置まで移動するとかあるのでしょうが、今回はとりあえずこのままで。。

この「閾値大きく超える」問題により、ボールがベタっと反射するイメージ以上につぶれてしまうことになります。
せめてこの問題だけ軽減したく、
今回は X 方向、Y 方向の外側閾値に対して、45°位置を 1:1 として、マージンを付けることにしました。
例えば、20°方向に射出する場合、X方向の方が強くぶつかるので、X方向の弾みを閾値により少なくする形式です。

これを踏まえて、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.outer_size = frac * 20 + 10; // 10 ~ 30
        this.outer_size_x = this.outer_size;
        this.outer_size_y = this.outer_size;

        // 内側サイズ ... 壁の反射
        this.inner_size = this.outer_size * 3.0 / 4.0; // 外側サイズの 3/4

        // 初速
        // 大きさに反比例
        this.v0 = (1 - frac) * 0.3 + 0.3; // 0.3 ~ 0.6

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

        // 速度によって貫通してしまうので、縦・横でそれぞれ弾む許容量を設定
        const outer_inner_diff = Math.abs(this.outer_size - this.inner_size);
        this.outer_size_base_x = this.inner_size + ((Math.cos(this.direction) + 1) / 2.0) * outer_inner_diff;
        this.outer_size_base_y = this.inner_size + ((Math.sin(this.direction) + 1) / 2.0) * outer_inner_diff;

        // 反射
        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;

        // 中心から壁までの距離を算出
        const wall_dist_x = Math.min(Math.max(0, this.x), Math.abs(WIDTH - this.x));
        const wall_dist_y = Math.min(Math.max(0, this.y), Math.abs(HEIGHT - this.y));
        const wall_dist = Math.min(wall_dist_x, wall_dist_y);

        // 中心から壁までの距離が内側サイズより小さかったら反射
        if (wall_dist_x < this.inner_size) {this.ref_x *= -1; this.ref_count++;}
        if (wall_dist_y < this.inner_size) {this.ref_y *= -1; this.ref_count++;}

        // 外側サイズより小さかったら、その分だけ歪ませる
        // 短い方を採用
        if (wall_dist < this.outer_size) {
            if (wall_dist_x < wall_dist_y) {
                const frac = wall_dist_x / this.outer_size_base_x;
                this.outer_size_x = wall_dist_x;
                this.outer_size_y = this.outer_size / frac;            
            }
            else {
                const frac = wall_dist_y / this.outer_size_base_y;
                this.outer_size_x = this.outer_size / frac;
                this.outer_size_y = wall_dist_y;
            }
        }
        else {
            this.outer_size_x = this.outer_size;
            this.outer_size_y = this.outer_size;
        }

        // 一定数跳ね返ったら無効化
        if (this.ref_count >= 50) this.enabled = false;
    }    
}

コード全文

以下に貼っておきます。

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

<script>

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

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

////////////////////
//
// 描画する点
// 物理法則に従って落下させる
// 
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.outer_size = frac * 20 + 10; // 10 ~ 30
        this.outer_size_x = this.outer_size;
        this.outer_size_y = this.outer_size;

        // 内側サイズ ... 壁の反射
        this.inner_size = this.outer_size * 3.0 / 4.0; // 外側サイズの 3/4

        // 初速
        // 大きさに反比例
        this.v0 = (1 - frac) * 0.3 + 0.3; // 0.3 ~ 0.6

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

        // 速度によって貫通してしまうので、縦・横でそれぞれ弾む許容量を設定
        const outer_inner_diff = Math.abs(this.outer_size - this.inner_size);
        this.outer_size_base_x = this.inner_size + ((Math.cos(this.direction) + 1) / 2.0) * outer_inner_diff;
        this.outer_size_base_y = this.inner_size + ((Math.sin(this.direction) + 1) / 2.0) * outer_inner_diff;

        // 反射
        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;

        // 中心から壁までの距離を算出
        const wall_dist_x = Math.min(Math.max(0, this.x), Math.abs(WIDTH - this.x));
        const wall_dist_y = Math.min(Math.max(0, this.y), Math.abs(HEIGHT - this.y));
        const wall_dist = Math.min(wall_dist_x, wall_dist_y);

        // 中心から壁までの距離が内側サイズより小さかったら反射
        if (wall_dist_x < this.inner_size) {this.ref_x *= -1; this.ref_count++;}
        if (wall_dist_y < this.inner_size) {this.ref_y *= -1; this.ref_count++;}

        // 外側サイズより小さかったら、その分だけ歪ませる
        // 短い方を採用
        if (wall_dist < this.outer_size) {
            if (wall_dist_x < wall_dist_y) {
                const frac = wall_dist_x / this.outer_size_base_x;
                this.outer_size_x = wall_dist_x;
                this.outer_size_y = this.outer_size / frac;            
            }
            else {
                const frac = wall_dist_y / this.outer_size_base_y;
                this.outer_size_x = this.outer_size / frac;
                this.outer_size_y = wall_dist_y;
            }
        }
        else {
            this.outer_size_x = this.outer_size;
            this.outer_size_y = this.outer_size;
        }

        // 一定数跳ね返ったら無効化
        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.ellipse(element.x, element.y, element.outer_size_x, element.outer_size_y, 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>


アイキャッチ用