JavaScript ライブラリ tmlib.js で円同士の衝突プログラムを作ってみた

phiary に引っ越しました. 毎日プログラミングやWebに関する情報を発信しています! RSS 登録してたまに覗いたり, tweet やハテブして拡散してもらえると幸いです.

Pocket

※このエントリーで使われてる tmlib.js はバージョンが古いので, 急遽新しくエントリーを書きました(2012 11/25) こちらを見て頂けると幸いです.

自作ライブラリ tmlib.jsを使って, 円同士の衝突プログラムを作ってみました.

やっているのは,

  • 円同士の衝突判定
  • めり込みの補正
  • 衝突後の反発

です. ちゃんと計算するなら, 衝突時間を調べてその分ベクトルの長さを調整して… ってやんないといけないのですが, 速度的に問題があるってのと難易度が上がってしまう(ホントはめんどくさい)のでちょっと省略しています.

でもそれっぽく動いていると思います. 色々とパラメータをいじって遊んでみてください.

Sample and Download

今回制作したサンプルはこちら.

円同士の衝突プログラムです. マウスもしくはタッチでボールを掴んで投げることができます.

また, 右上のパラメータエディタで反発係数, 摩擦係数, 重力の方向を変更することができます.

ダウンロードはこちらから出来ます.

Code

今回制作したサンプルの全体コードです.

<!DOCTYPE html> 
<html lang="ja"> 
    <head> 
        <meta charset="utf-8"> 
        <meta name="viewport" content="width=device-width, user-scalable=no" />
        <meta name="apple-mobile-web-app-capable" content="yes" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <title>TM Lib</title>
        <script type="text/javascript" src="./../../src/tmlib.js"></script>
        <script>
            
            // 物理パラメータ
            var PHYSICS_PARAM = {};
            
            // グローバル変数
            var app = null;             // アプリケーション
            var visibleTrace = true;    // 軌跡表示
            var Circle = null;          // サークルクラス
            
            // スクリプトをロード
            TM.loadScript("app");
            TM.loadScript("app", "sound");
            
            // dat-giu をロード
            TM.addNamespace("dat-gui", "../../plugins/dat-gui");
            TM.loadDatGUI();
            
            // stats をロード
            TM.addNamespace("stats", "../../plugins/Stats");
            TM.loadStats();
            
            // メイン処理
            TM.main(function(){
                initParam();    // パラメータ初期化
                initGame()      // ゲーム初期化
                run();          // 実行
                
                TM.inform();    // インフォメーション
                
                // GUI
                if (window["dat"]) {
                    var gui = new dat.GUI();
                    
                    gui.add(window, "explode");
                    gui.add(window, "visibleTrace").setValue(true).onChange(function(){
                        app.scene.background = (visibleTrace) ? "rgba(0, 0, 0, 0.25)" : "rgba(0, 0, 0, 1)";
                    });
                    gui.add(PHYSICS_PARAM, "bounciness", 0, 1, 0.1);
                    gui.add(PHYSICS_PARAM, "friction", 0, 1, 0.01);
                    
                    // gravity フォルダ
                    var gradFolder = gui.addFolder('gravity');
                    gradFolder.add(PHYSICS_PARAM.gravity, "x", -2, 2, 0.1);
                    gradFolder.add(PHYSICS_PARAM.gravity, "y", -2, 2, 0.1);
                    gradFolder.open();
                }
            });
            
            /**
             * パラメータ初期化
             */
            var initParam = function()
            {
                // 物理パラメータ初期化
                PHYSICS_PARAM["bounciness"]    = 0.8;                          // 反発係数
                PHYSICS_PARAM["friction"]      = 0.99;                         // 摩擦係数
                PHYSICS_PARAM["gravity"]       = TM.Geom.Vector2(0, 0.8);      // 重力
                
                // 宇宙
                /*
                PHYSICS_PARAM["bounciness"]    = 1.0;                          // 反発係数
                PHYSICS_PARAM["friction"]      = 1.0;                          // 摩擦係数
                PHYSICS_PARAM["gravity"]       = TM.Math.Vector2(0, 0.0);      // 重力
                */
                
                // サウンド読み込み
                TM.$SoundManager.add("hit", "puu89." + TM.App.Sound.SUPPORT_EXT);
                
                // サークルクラス定義
                Circle = createCircleClass();
            };
            
            /**
             * ゲーム初期化
             */
            var initGame = function()
            {
                // アプリケーション生成
                app = TM.global.app = TM.App.CanvasApp();
                app.scene.background = "rgba(0, 0, 0, 0.25)";
                app.enableStats();
                
                for (var i=0; i<16; ++i) {
                    app.scene.addChild(Circle());
                }
                
                for (var i=0; i<app.scene.children.length-1; ++i) {
                    var target = app.scene.children&#91;i&#93;;
                    for (var j=i+1; j<app.scene.children.length; ++j) {
                        var other = app.scene.children&#91;j&#93;;
                        target.collisionList.push(other);
                    }
                }

            };
            
            /**
             * 実行
             */
            var run = function()
            {
                app.run();
            };
            
            /**
             * クラッシュ
             */
            var explode = function()
            {
                var app = TM.global.app;
                
                var children = app.scene.children;
                for (var i=0; i<children.length; ++i) {
                    var target = children&#91;i&#93;;
                    target.explode();
                }
            };
            
            /**
             * サークルクラスを定義
             */
            var createCircleClass = function()
            {
                var Circle = TM.createClass({
                    superClass: TM.App.InteractiveGameElement,  // インタラクティブゲームエレメントを継承
                    
                    /**
                     * 初期化
                     */
                    init: function() {
                        this.superInit(TM.App.InteractiveGameElement);
                        
                        // 位置をセット
                        this.x = Math.floor(Math.random()*(window.innerWidth-100));
                        this.y = Math.floor(Math.random()*(window.innerHeight-100));
                        // 向きをセット
                        this.direction = TM.Geom.Vector2.random(0, 360, 25);
                        
                        // パラメータをセット
                        this.width  = 100;
                        this.height = 100;
                        this.radius = TM.isMobile ? TM.random(10, 15) : TM.random(25, 50);
                        this.m      = Math.PI*this.radius*this.radius;  // 面積 = 重さにする
                        
                        // 表示色設定
                        this.strokeStyle= "white";
                        this.colorAngle = TM.random(0, 360);
                        var g = TM.Graphics.Graphics();
                        var grad = g.context.createRadialGradient(0, 0, 0, 0, 0, this.radius);
                        grad.addColorStop(0.0, "hsla({color}, 65%, 50%, 0.0)".format({color: this.colorAngle}));
                        grad.addColorStop(0.95, "hsla({color}, 65%, 50%, 1.0)".format({color: this.colorAngle}));
                        grad.addColorStop(1.0, "hsla({color}, 65%, 50%, 0.0)".format({color: this.colorAngle}));
                        this.fillStyle  = grad;
                    },
                    
                    /**
                     * 描画
                     */
                    draw: function(graphics) {
                        graphics.setColorStyle(this.strokeStyle, this.fillStyle);
                        graphics.globalCompositeOperation = "lighter";
                        graphics.fillCircle(0, 0, this.radius);
                    },
                    
                    /**
                     * インタラクティブオブジェクト用更新関数
                     */
                    onEnterFrame: function() {
                        var app = TM.global.app;
                        
                        if (Circle.target == this) {
                            this.x = app.pointing.x;
                            this.y = app.pointing.y;
                            this.direction.x = app.pointing.dX;
                            this.direction.y = app.pointing.dY;
                            return ;
                        }
                        this.direction.mul( PHYSICS_PARAM.friction );
                        this.direction.add( PHYSICS_PARAM.gravity );
                        this.position.add(this.direction);
                        
                        var left    = this.radius;
                        var right   = window.innerWidth-this.radius;
                        var top     = this.radius;
                        var bottom  = window.innerHeight-this.radius;
                        if (this.x < left)  { this.x = left;    this.direction.x*=-1; }
                        if (this.x > right) { this.x = right;   this.direction.x*=-1; }
                        if (this.y < top)   { this.y = top;     this.direction.y*=-1; }
                        if (this.y > bottom){ this.y = bottom;  this.direction.y*=-1; }
                    },
                    
                    /**
                     * 衝突イベント時関数
                     */
                    onCollision: function(other)
                    {
                        // めり込んだ分を押し出す
                        var abVec = TM.Geom.Vector2.sub(other.position, this.position); // 自分から相手へのベクトル
                        var len = abVec.length();
                        if (len == 0) return ;
                        abVec.normalize();
                        
                        var distance = (this.radius + other.radius)-len;    // 自分と相手の距離
                        var sinkVec  = TM.Geom.Vector2.mul(abVec, distance/2);
                        this.position.sub(sinkVec);
                        other.position.add(sinkVec);
                        
                        // 向きベクトルを調整する
                        var V = TM.Geom.Vector2;
                        var m0 = this.m;
                        var m1 = other.m;
                        var e = PHYSICS_PARAM.bounciness;
                        
                        var ma = ( (m1 / (m0+m1))*(1+e) ) * V.dot(V.sub(other.direction,  this.direction), abVec);
                        var mb = ( (m0 / (m0+m1))*(1+e) ) * V.dot(V.sub( this.direction, other.direction), abVec);
                        
                        this.direction.add( V.mul(abVec, ma) );
                        other.direction.add( V.mul(abVec, mb) );
                        
                        // SE 再生
                        var vSubLen = this.direction.lengthSquared() + other.direction.lengthSquared();
                        if (TM.isMobile == false && vSubLen > 10*10) {
                            var hitSound = TM.$SoundManager.get("hit");
                            hitSound.volume = Math.min(1.0, vSubLen / 4000);
                            hitSound.play();
                        }
                    },
                    
                    onMouseOver: function() {
                        // this.fillStyle = "red";
                    },
                    
                    onMouseOut: function() {
                        // this.fillStyle = "white";
                    },
                    
                    onMouseDown: function() {
                        if (Circle.target == null) { Circle.target = this; }
                        this.direction.set(0, 0, 0);
                    },
                    onMouseUp: function() {
                        Circle.target = null;
                    },
                    
                    explode: function()
                    {
                        this.direction = TM.Geom.Vector2.random(0, 360, 50);
                    }
                });
                
                return Circle;
            };
            
        </script>

    </head>
    
    <body>
    </body>
</html>

Tips

円同士の衝突判定

円同士の衝突判定です. 処理は非常にシンプルです. tmlib 内では下記のように定義されています.

TM.Collision.testCircleCircle = function(circle0, circle1) {
    // circle0 と circle1 の中心座標の距離の二乗
    var distanceSquared = TM.Geom.Vector2.distanceSquared(circle0, circle1);
    // 中心座標の距離が circle0 と circle1 の半径の合計の2乗以下だった場合衝突とする
    return distanceSquared <= Math.pow(circle0.radius + circle1.radius, 2);
};
&#91;/code&#93;</pre>
    </section>
    
    <section>
        <h3>めり込みの補正</h3>
        <p>
            めり込んだ距離の半分の長さだけそれぞれの円を移動させることでめり込んだ分を補正しています.
        </p>
        <p>
            ベクトルの計算には自作の <a href="http://tmlife.net/lib/tmlib/doc/symbols/TM.Geom.Vector3.html">Vector3</a> クラスを使用しています.
            使い方は C++ ゲームライブラリや C# の XNA にあるような Vector3 と基本同じです.
        </p>
        <pre class="prettyprint">
// めり込んだ分を押し出す
var abVec = TM.Geom.Vector2.sub(other.position, this.position); // 自分から相手へのベクトル
var len = abVec.length();
if (len == 0) return ;
abVec.normalize();

var distance = (this.radius + other.radius)-len;    // 自分と相手の距離
var sinkVec  = TM.Geom.Vector2.mul(abVec, distance/2);
this.position.sub(sinkVec);
other.position.add(sinkVec);

衝突後の反発

互いの向きベクトル, 質量を考慮して衝突後の向きベクトルを計算しています.

// 向きベクトルを調整する
var V = TM.Geom.Vector2;
var m0 = this.m;
var m1 = other.m;
var e = PHYSICS_PARAM.bounciness;

var ma = ( (m1 / (m0+m1))*(1+e) ) * V.dot(V.sub(other.direction,  this.direction), abVec);
var mb = ( (m0 / (m0+m1))*(1+e) ) * V.dot(V.sub( this.direction, other.direction), abVec);

this.direction.add( V.mul(abVec, ma) );
other.direction.add( V.mul(abVec, mb) );

Sound クラスと SoundManager クラスを使って衝突時に SE を鳴らそう

tmlib には Sound クラスと SoundManager クラスが定義されています.

これらのクラスは tm.sound.js で定義されているので, 下記のコードで動的に読み込んでいます.

TM.loadScript("app", "sound");

使い方

Sound クラスの基本的な使い方は音楽ファイル名(mp3 や wavなど)を 引数として生成し, play を実行するだけです.

// 生成
var sound = TM.App.Sound("ファイル名");
// 再生
sound.play();

これで音が再生されます.

また, 遅延対策として SoundManager というクラスを定義しています.

これは内部的に同じファイルをキャッシュすることで, 同名のファイルが同時再生されても遅延が起きないようになっています.

// キーとファイル名を指定して sound をキャッシュ
TM.$SoundManager.add("キー", "ファイル名", "キャッシュ数(省略可)");
// キーを指定してキャッシュされている Sound の中からフリーなインスタンスを取得
var hitSound = TM.$SoundManager.get("hit");
// 再生
hitSound.play();

蛇足

ホントはこの記事, 半年ほど前に書いていたのですが chrome のバグで SE を鳴らしまくると調子がおかしくなるという問題があり見送っていました.

結局, しばらく直りそうにないので chrome のときのみ SE をオフっています.

それでも chrome で SE を鳴らしたい場合は, ダウンロードしたファイルの tm.sound.js の 59 行目あたりにある

if (TM.browser == "Chrome") { return ; }

を削除してください. chrome でも再生されるようになると思います. 急に鳴らなくなるけど...

Reference

参考になりそうなサイト

次回は, この円同士の衝突プログラムの 3D 版, つまり球同士の衝突プログラムを gl.enchant.js で作ったのでそれについてまとめます.

TRACK BACK URL

POST COMMENT

メールアドレスが公開されることはありません。

COMMENT