【p2.js】正規分布のおもちゃ galton board をつくった【pixi.js】

#はじめに
二項分布が正規分布で近似できることを直感的に理解できる「<b>galton board</b>」というおもちゃがある。
これを作ってみた。

![AGDRec_20201209_173047.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163605/ce60c4ce-a2f7-4846-12f6-04eda5490fe4.gif)


本家（?）はこんな感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163605/ea04b424-8280-9538-7e8c-ab842171f047.png)


#説明
描画はpixi.jsを使っています。
物理演算はp2.jsを使っています。
玉は500個にしています。球が増えると処理落ちするので余裕を見てこれぐらいにしています。
下の入れ物から左右へはみ出た場合、また上からやり直しさせます。

追記: 時々、釘に玉が引っかかるのでcanvasをクリックしたときに釘を震わせるようにしました。
追記: 全ての玉を動いていないと判定したときに、釘に引っかかってる場合は自動で揺らすようにした。(積極的には揺らさない)

#意外と正規分布にならない
玉の大きさ、釘の位置、大きさを適切にしないと意外と正規分布っぽくなりません。
玉と釘の摩擦を大きくしたら、いい感じになった。
公開しているプログラムは適当に調整しております。

#参考
こちら(↓)にわかりやすい説明があります。
https://blog.applibot.co.jp/2017/09/06/p2-js/

#プログラム
以下をhtmlに貼り付ければ動きます。

```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>galton board (p2.js, pixi.js)</title>
<style>
html, body {
  margin: 0;
  padding: 0;
  width:100%;
  height:100%;
  overflow: hidden;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.4.5/pixi.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p2.js/0.7.1/p2.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {

    // 定数
    const _BACKGROUND_COLOR = 0x000000,     // 背景色
        _PIXI_JS_ZOOM_LEVEL = 100,          // pixi.js用の係数
        _FIXED_TIME_STEP = 1 / 60,          // 1フレームの時間
        _RENDERER_WIDTH = 600,              // レンダラの幅
        _RENDERER_HEIGHT = 1000,            // レンダラの高さ
        _RENDERER_DOM_WIDTH = _RENDERER_WIDTH / 2,      // レンダラのDOMにおける幅
        _RENDERER_DOM_HEIGHT = _RENDERER_HEIGHT / 2,    // レンダラのDOMにおける高さ
        // 玉(パチンコでいうところのパチンコ玉)
        _BALL_COLOR = 0xff0000,             // 玉の色
        _BALL_RADIUS = 7.8,                 // 玉の半径
        _BALL_MAX_COUNT = 500,              // 最大の玉数
        // 固定玉(パチンコでいうところの釘)
        _NAIL_COLOR = 0x00ff00,             // 固定玉の色
        _NAIL_RADIUS = 10.5,                // 固定玉
        _NAIL_BASE_Y = 320,                 // 固定玉ベースとなる高さ
        _NAIL_STEP_Y = 30,                  // 固定玉のステップ高さ
        // 階級(下の階級ごとの入れ物)
        _RANK_WIDTH = 5,                    // 階級の仕切りの幅
        _RANK_HEIGHT = 420,                 // 階級の仕切りの高さ
        // 摩擦
        _WALL_BALL_FRACTION = 0.2,          // 壁と玉の摩擦
        _NAIL_BALL_FRICTION = 0.6,          // 釘と玉の摩擦
        _BALL_BALL_FRICTION = 0.2,          // 玉と玉の摩擦       
        // 他
        _WALL_COLOR = 0xffffff,             // 壁の色
        _STOPEED_THRESHOLD = 3.5,               // 玉が止まったかどうかの閾値
        _RESTART_INTERVAL = 1000,           // 再スタート時のインターバル
        _NORMAL_COLOR = 'yellow',           // 正規分布の色
        _NORMAL_LINE_WIDTH = 10,            // 正規分布の線の太さ      
        _MAX_STOPPED_FRAME_COUNT = 180,     // 何フレーム動かなかったらリセットするか    
        _MAX_MOVE_FRAME_COUNT = 30,         // 何フレーム釘を動かすか    
        _CAPTURE_CANVAS = false;            // リセット時にキャプチャして、ダウンロードするか

    let _world, _stage, _renderer,
        _divides = 9,   // 3以上11以下の奇数に対応する予定であったが、9で固定
        _stoppedFrames = 0,
        _materialWall, _materialNail, _materialBall,
        _sumXBar = 0, _sumSigma2 = 0,
        _times = 1,
        _moveNailFrames = -1,
        _ballList = [], _nailList = []; // 釘を動かすため釘のリストを記憶する

    init();
    animate();

    function init() {

        // 初期化(p2.js)
        _world = new p2.World();

        initFriction();        

        // レンダラとステージの初期化
        _renderer = PIXI.autoDetectRenderer(_RENDERER_WIDTH, _RENDERER_HEIGHT, {preserveDrawingBuffer: true});
        _renderer.backgroundColor = _BACKGROUND_COLOR;
        _stage = new PIXI.Stage();  

        // DOMにpixiのcanvasを追加
        _renderer.view.style.width = _RENDERER_DOM_WIDTH + 'px';
        _renderer.view.style.height = _RENDERER_DOM_HEIGHT + 'px';
        document.body.appendChild(_renderer.view);

        // 玉の入れ物
        createWall(_RENDERER_WIDTH / 2 - 155, 130, 290, 20, Math.PI / 6);
        createWall(_RENDERER_WIDTH / 2 + 155, 130, 290, 20, -Math.PI / 6);
        createWall(_RENDERER_WIDTH / 2 - 35, 225, 60, 20, Math.PI * 0.5);
        createWall(_RENDERER_WIDTH / 2 + 35, 225, 60, 20, Math.PI * 0.5);

        // 釘
        const stepX = (_RENDERER_WIDTH - (_divides + 1) * _RANK_WIDTH) / _divides + _RANK_WIDTH;
        for(let i = 0; i < _divides - 1; i += 1) {
            let beginX;
            beginX = _RENDERER_WIDTH / 2 - stepX * (i / 2);
            for(j = -2; j <= i + 2; j += 1) {
                createNail(beginX + stepX * j, _NAIL_BASE_Y + _NAIL_STEP_Y * i);
            }
        }

        // 下側の階級の仕切り
        const rankStepX = (_RENDERER_WIDTH - (_divides + 1) * _RANK_WIDTH) / _divides;
        for(let i = 0; i <= _divides; i += 1) {
            createWall(_RANK_WIDTH / 2 + (rankStepX + _RANK_WIDTH) * i, _RENDERER_HEIGHT - _RANK_HEIGHT / 2, _RANK_HEIGHT, 5, Math.PI / 2);
        }

        // 下側の壁
        createWall(_RENDERER_WIDTH / 2, _RENDERER_HEIGHT + 25, _RENDERER_WIDTH, 50, 0);

        // 正規分布のcanvasを作成
        createNormalCanvas();

        // canvasクリック時に釘を動かす
        document.getElementById('normal-canvas').addEventListener('click', e => { 
            if(_moveNailFrames < 0) {
                _moveNailFrames = 0;
            }
        });
    }

    // 位置を覚える
    function saveNailList() {
        const list = _nailList.map(graphic => { 
            return { x: graphic.x, y: graphic.y };
        });
        return list;
    }

    function restoreNailList(list) {
        _nailList.forEach((graphic, i) => {
            const saved = list[i];
            graphic.x = saved.x;
            graphic.y = saved.y;
            graphic.body.position[0] = pixiToP2X(saved.x);
            graphic.body.previousPosition[0] = pixiToP2X(saved.x);
            graphic.body.position[1] = pixiToP2Y(saved.y);
            graphic.body.previousPosition[1] = pixiToP2Y(saved.y);
        });
    }

    function moveNails(pixiX, pixiY) {
        _nailList.forEach(graphic => {
            graphic.x += pixiX;
            graphic.y += pixiY;
            graphic.body.position[0] += pixiToP2X(pixiX);
            graphic.body.previousPosition[0] += pixiToP2X(pixiX);
            graphic.body.position[1] += pixiToP2Y(pixiY);
            graphic.body.previousPosition[1] += pixiToP2Y(pixiY);
        });
    }

    // 摩擦の初期化
    function initFriction() {
        _materialWall = new p2.Material();
        _materialNail = new p2.Material();
        _materialBall = new p2.Material();

        const wbContactMaterial = new p2.ContactMaterial(_materialWall, _materialBall, {
            friction: _WALL_BALL_FRACTION
        });
        _world.addContactMaterial(wbContactMaterial);

        const nbContactMaterial = new p2.ContactMaterial(_materialNail, _materialBall, {
            friction: _NAIL_BALL_FRICTION
        });
        _world.addContactMaterial(nbContactMaterial);

        const bbContactMaterial = new p2.ContactMaterial(_materialNail, _materialBall, {
            friction: _BALL_BALL_FRICTION
        });
        _world.addContactMaterial(bbContactMaterial);
    }

    function createWall(x, y, w, h, rotation) {
        const p2X = pixiToP2X(x);
        const p2Y = pixiToP2Y(y);
        const p2W = pixiToP2Value(w);
        const p2H = pixiToP2Value(h);
        const shape = new p2.Box({
            width:p2W, 
            height:p2H,
        });
        shape.material = _materialWall;
        const body = new p2.Body({
            mass:0,
            position:[p2X, p2Y],
            angle:-rotation
        });
        body.addShape(shape);
        _world.addBody(body);

        const graphic = new PIXI.Graphics();
        graphic.beginFill(_WALL_COLOR);
        graphic.drawRect(-w/2, -h/2, w, h);
        graphic.endFill();
        graphic.pivot.set(0.5, 0.5);
        graphic.x = x;
        graphic.y = y;
        graphic.rotation = rotation;
        _stage.addChild(graphic);
        graphic.body = body;
        graphic.shape = shape;
    }

    function createNail(x, y) {
        const xx = pixiToP2X(x);
        const yy = pixiToP2Y(y);
        const shape = new p2.Circle({radius:pixiToP2Value(_NAIL_RADIUS)});
        shape.material = _materialNail;
        const body = new p2.Body({
            mass: 0, 
            position: [xx, yy]
        });
        const color = _NAIL_COLOR;
        body.color = color;
        body.addShape(shape);
        _world.addBody(body);

        const graphic = new PIXI.Graphics();
        graphic.beginFill(color);
        graphic.drawCircle(0, 0, _NAIL_RADIUS);
        graphic.endFill();
        graphic.pivot.set(0.5, 0.5);
        graphic.x = x;
        graphic.y = y;
        _stage.addChild(graphic);
        graphic.body = body;
        graphic.shape = shape;
        _nailList.push(graphic);
    }

    function createBall() {
        const randX = _RENDERER_WIDTH / 2 + (Math.random() * 200 - 100);
        const randY = Math.random() * 50 - 50;
        const xx = pixiToP2X(randX);
        const yy = pixiToP2Y(randY);
        const shape = new p2.Circle({radius:pixiToP2Value(_BALL_RADIUS)});
        shape.material = _materialBall;
        const body = new p2.Body({
            mass:1.0, 
            position: [xx, yy]
        });
        const color = _BALL_COLOR;
        body.color = color;
        body.addShape(shape);
        _world.addBody(body);

        const graphic = new PIXI.Graphics();
        graphic.beginFill(color);
        graphic.drawCircle(0, 0, _BALL_RADIUS);
        graphic.endFill();
        graphic.pivot.set(0.5, 0.5);
        graphic.x = randX;
        graphic.y = randY;
        _stage.addChild(graphic);
        graphic.body = body;
        graphic.shape = shape;
        _ballList.push(graphic);
    }   

    // アニメーションメソッド
    function animate() {
        if(_ballList.length === _BALL_MAX_COUNT) {
            // 全て止まっているか調べる
            const allStop = _ballList.every(graphic => {
                const pixiPrevX = p2ToPixiX(graphic.body.previousPosition[0]),
                    pixiX = p2ToPixiX(graphic.body.position[0]),
                    pixiPrevY = p2ToPixiY(graphic.body.previousPosition[1]),
                    pixiY = p2ToPixiY(graphic.body.position[1]),
                    deltaX = Math.abs(pixiX - pixiPrevX),
                    deltaY = Math.abs(pixiY - pixiPrevY);
                return deltaX <= _STOPEED_THRESHOLD && deltaY <= _STOPEED_THRESHOLD;
            });
            if(allStop) {
                // 止まっている玉が下の階級の入れ物より上にある場合、揺らす
                if(_moveNailFrames < 0) {
                    const outOfRank = _ballList.filter(graphic => {
                        const pixiY = p2ToPixiY(graphic.body.position[1]);
                        // Yが釘の範囲にあるか調べる
                        const topY = _NAIL_BASE_Y;
                        const bottomY = _NAIL_BASE_Y + _NAIL_STEP_Y * (_divides - 2);
                        return topY <= pixiY && pixiY <= bottomY;
                    });
                    let dist = Number.MAX_VALUE;
                    for(let i = 0; i < outOfRank.length; i += 1) {
                    	const iGraphic = outOfRank[i],
                    		iPixiX = p2ToPixiX(iGraphic.body.position[0]),
                    		iPixiY = p2ToPixiY(iGraphic.body.position[1]);
                    	for(let j = i + 1; j < outOfRank.length; j += 1) {
                    		const jGraphic = outOfRank[j],
                    		jPixiX = p2ToPixiX(jGraphic.body.position[0]),
                    		jPixiY = p2ToPixiY(jGraphic.body.position[1]);
                    		const tmpDist = Math.sqrt((iPixiX - jPixiX) * (iPixiX - jPixiX) 
                    			+ (iPixiY - jPixiY) * (iPixiY - jPixiY));
                    		if(tmpDist < dist) {
                    			dist = tmpDist;
                    		}
                    	}
                    }
                    if(dist < _BALL_RADIUS * 2 + _BALL_RADIUS * 0.5) {// 距離が近いものがある => 引っかかっているとみなす
                        _moveNailFrames = 0;
                        _stoppedFrames = 0;
                        requestAnimationFrame(animate);
                        return;
                    }
                }
                _stoppedFrames++;
                if(_stoppedFrames >= _MAX_STOPPED_FRAME_COUNT) {
                    // 標本平均と標本分散を計算
                    let xBar = _ballList.reduce((p, g) => p + p2ToPixiX(g.body.position[0]), 0);
                    xBar /= _ballList.length;
                    let sigma2 = _ballList.reduce((p, g) => {
                        const x = p2ToPixiX(g.body.position[0]);
                        return p + (x - xBar) * (x - xBar);
                    }, 0);
                    sigma2 /= _ballList.length;
                    console.log(`E(${xBar.toFixed(2)}), V(${sigma2.toFixed(2)})`);
                    _sumXBar += xBar;
                    _sumSigma2 += sigma2;
                    const aveXBar = _sumXBar / _times,
                        aveSigma2 = _sumSigma2 / _times;
                    console.log(`aveE(${aveXBar.toFixed(2)}), aveV(${aveSigma2.toFixed(2)})`);

                    // キャプチャ処理
                    if(_CAPTURE_CANVAS) {
                        captureCanvas(xBar, sigma2);
                    }

                    // ボールを削除する
                    for(let i = _ballList.length - 1; i >= 0; i -= 1) {
                        const graphic = _ballList[i];
                        _world.removeBody(graphic.body);
                        _stage.removeChild(graphic);
                        _ballList.splice(i, 1);
                        graphic.destroy();
                    }
                    _times++;
                    _moveNailFrames = -1;
                    _stoppedFrames = 0;
                    requestAnimationFrame(animate);
                    return;
                }
            } else {
                _stoppedFrames = 0;
            }
        } else {
            _stoppedFrames = 0;
        }
        requestAnimationFrame(animate);

        let moveNailFlag = false,
            savedList;
        if(_moveNailFrames >= 0) {
            _moveNailFrames++;
            moveNailFlag = true;
            savedList = saveNailList();
            if(_moveNailFrames >= _MAX_MOVE_FRAME_COUNT) {
                _moveNailFrames = -1;
            }
            const x = Math.random() * 4 - 2,
                y = Math.random() * 4 - 2;
            moveNails(x, y);
        }        

        // Move physics bodies forward in time
        _world.step(_FIXED_TIME_STEP);

        // ボール作成
        if(_ballList.length < _BALL_MAX_COUNT) {
            createBall();
        }        

        for(let i = _ballList.length - 1; i >= 0; i--) {
            const graphic = _ballList[i];
            if (graphic.body.world && graphic.shape.type == p2.Shape.CIRCLE) {
                const x = p2ToPixiX(graphic.body.position[0]);
                const y = p2ToPixiY(graphic.body.position[1]);
                if (x < 0 - _BALL_RADIUS || x > _RENDERER_WIDTH + _BALL_RADIUS) {// 外に出た
                   _world.removeBody(graphic.body);
                     _stage.removeChild(graphic);
                     _ballList.splice(i, 1);
                     graphic.destroy();
                } else {
                    graphic.x = x;
                    graphic.y = y;
                }
            }
        }

        // 描画
        _renderer.render(_stage);

        if(moveNailFlag) {
            restoreNailList(savedList);
        }
    }

    // 正規分布を作成する
    function createNormalCanvas() {
        const pixiCanvas = document.getElementsByTagName('canvas')[0],
            rect = pixiCanvas.getBoundingClientRect();

        const canvas = document.createElement('canvas');
        canvas.width = _RENDERER_WIDTH;
        canvas.height = _RENDERER_HEIGHT;
        canvas.id = 'normal-canvas';
        canvas.style.position = 'absolute';
        canvas.style.zIndex = 100;
        canvas.style.left = rect.left + 'px';
        canvas.style.top = rect.top + 'px';
        canvas.style.width = _RENDERER_DOM_WIDTH + 'px';
        canvas.style.height = _RENDERER_DOM_HEIGHT + 'px';
        document.body.appendChild(canvas);

        drawNormalDistribution();
    }

    // 正規分布を描画する
    function drawNormalDistribution() {
        const canvas = document.getElementById('normal-canvas'),
            ctx = canvas.getContext('2d');

        // クリアする
        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

        // 正規分布を描画する
        ctx.globalAlpha = 0.5;
        ctx.strokeStyle = _NORMAL_COLOR;
        ctx.lineWidth = _NORMAL_LINE_WIDTH;
        for(let x = 0; x <= _RENDERER_WIDTH; x += 4) {
            const stepX = (_RENDERER_WIDTH - (_divides + 1) * _RANK_WIDTH) / _divides + _RANK_WIDTH;

            const p = 0.5,
                normal_mu = _divides * p,
                normal_sigma2 = _divides * p * (1 - p),
                mu = _RENDERER_WIDTH / 2,
                sigma2 = normal_sigma2 * stepX * stepX,
                rate = 97000; // rateは正規分布をフィットさせるための係数(適当)

            let y = rate * (1 / Math.sqrt(2 * Math.PI * sigma2)) 
            * Math.exp(-(x - mu) * (x- mu) / (2 * sigma2));
            y = _RENDERER_HEIGHT - y;

            if(x === 0) {
                ctx.beginPath();
                ctx.moveTo(x, y);
            } else {
                ctx.lineTo(x, y);
            }
        }

        ctx.stroke();
    }

    // キャプチャしてダウンロードする
    function captureCanvas(xBar = 0.0, sigma2 = 0.0) {
        const tempCanvas = document.createElement('canvas'),
            tempCtx = tempCanvas.getContext('2d');

        tempCanvas.width = _RENDERER_WIDTH;
        tempCanvas.height = _RENDERER_HEIGHT;

        // canvasを貼り付ける
        const canvases = document.getElementsByTagName('canvas');
        Array.from(canvases).forEach(canvas => {
            tempCtx.drawImage(canvas, 0, 0);
        });

        // ダウンロード       
        let link = document.createElement('a');
        link.href = tempCanvas.toDataURL('image/png');
        const date = new Date();
        const strDate = date.getFullYear() + '_' + ('0' + (date.getMonth() + 1)).slice(-2) + '_' +('0' + date.getDate()).slice(-2) + ' ' +  ('0' + date.getHours()).slice(-2) + '_' + ('0' + date.getMinutes()).slice(-2) + '_' + ('0' + date.getSeconds()).slice(-2);
        link.download = `${strDate}_E(${xBar.toFixed(2)})_V(${sigma2.toFixed(2)}).png`;
        link.click();
    }

     // p2 to pixi
    function p2ToPixiX(p2X) {
        return p2X * _PIXI_JS_ZOOM_LEVEL;
    }
    function p2ToPixiY(p2Y) {
        return -(p2Y * _PIXI_JS_ZOOM_LEVEL);
    }
    function p2ToPixiValue(p2Value) {
        return p2Value * _PIXI_JS_ZOOM_LEVEL;
    }

    // pixi to p2
    function pixiToP2X(pixiX) {
        return pixiX / _PIXI_JS_ZOOM_LEVEL;
    }
    function pixiToP2Y(pixiY) {
        return -(pixiY / _PIXI_JS_ZOOM_LEVEL);
    }
    function pixiToP2Value(pixiValue) {
        return pixiValue / _PIXI_JS_ZOOM_LEVEL;
    }

});     
</script>
</head>
<body>
</body>
</html>
```

#最後に
パチンコの釘の調整する人はすごいなーって思った。