12
\$\begingroup\$

This project is pretty much like a 2D gravity simulator. You click and it creates a circle that attracts other circles. The result of a collision is a bigger circle that is a sum of the masses. If you don't know what I'm talking about, here's a gif:

gif explanation

The game was made using CreateJS, but you don't need to know CreateJS to understand the code.

My biggest concern is performance, but I care a lot for code clarity too.

The scene starts with some pre-made planets, and there's some commented-out code that creates different setups for testing. It may be interesting to review functions inside the Utils.js file, principally those related to the utils.distToSegmentSquared() function, which I've got from StackOverflow and is dreadfully expensive.

Engine.js:

var SDU = SDU || {};
SDU.GameScene = function(canvas) {
    this.canvas = document.getElementById(canvas);
    this.SDUScene_constructor(canvas);
    this.bodies = [];
    this.collisions = [];
    this.garbage = [];
    this.palette = {
        dark: ["#FF4650", "#FFD939", "#97FB32", "#32CEF4", "#FE60D6"],
        light: ["#CF7758", "#9FB6A3", "#A878A6", "#7D8FA6", "#B04C56", "#3D4754", "#3D4754"]
    };
    SDU.GameScene.self = this;

    this.setup();
    this.listen();
};
createjs.extend(SDU.GameScene, SDU.Scene);
SDU.GameScene.prototype.setup = function() {
    /*for (var i1 = 0; i1 < 10; ++i1) {
        for (var i2 = 0; i2 < 10; ++i2) {
            var planet = new SDU.Planet(50 + i1 * 25, 50 + i2 * 25, 5, this.palette.light.getRandomItem(), {
                density: 1,
                attraction: 1
            }, this);
        }
    }*/
    for (var i = 0; i < 500; ++i) {
        var planet = new SDU.Planet(utils.getRandomInt(0, window.innerWidth), utils.getRandomInt(0, window.innerHeight), 5, this.palette.light.getRandomItem(), {}, this);
    }

    //var p1 = new SDU.Planet(50, 50, 5, this.palette.getRandomItem(), {}, this);
    //var p2 = new SDU.Planet(100, 50, 10, this.palette.getRandomItem(), {}, this);
};
SDU.GameScene.prototype.pastTickStart = performance.now();
SDU.GameScene.prototype.gcIterations = 0;
SDU.GameScene.prototype.delta = 0;
SDU.GameScene.prototype.listen = function() {
    var _this = this,
        preview;
    _this.mouse.down = false;
    createjs.Touch.enable(this);
    createjs.Ticker.timingMode = createjs.Ticker.RAF;
    createjs.Ticker.addEventListener("tick", function() {
        _this.delta = performance.now() - _this.pastTickStart;
        _this.pastTickStart = performance.now();
        _this.step();
        _this.update();
        _listenMouseMove();
    });
    _this.addEventListener("stagemousedown", function(e) {
        _this.mouse.down = {
            x: e.stageX,
            y: e.stageY
        };
        preview = new SDU.Planet(_this.mouse.down.x, _this.mouse.down.y, 5, null, {
            movable: false,
            collidable: false,
            attraction: 0
        }, _this);
        preview.setStroke("#3D4754", 2);
        preview.redraw();
    });

    function _listenMouseMove() {
        if (_this.mouse.down) {}
    }
    _this.addEventListener("stagemouseup", function(e) {
        if (preview) _this.garbage.push(preview);
        var diffX = e.stageX - _this.mouse.down.x,
            diffY = e.stageY - _this.mouse.down.y,
            distSquare = diffX * diffX + diffY * diffY,
            dist = Math.sqrt(distSquare),
            angle = Math.atan2(e.stageY - _this.mouse.down.y, e.stageX - _this.mouse.down.x);

        var planet = new SDU.Planet(_this.mouse.down.x, _this.mouse.down.y, 5, _this.palette.light.getRandomItem(), {
            vx: dist * 2 * Math.cos(angle),
            vy: dist * 2 * Math.sin(angle)
        }, _this);
        planet.scaleX = planet.scaleY = 0;
        createjs.Tween.get(planet, {
            override: true
        }).to({
            scaleX: 1,
            scaleY: 1
        }, 1000, createjs.Ease.elasticOut);
        _this.mouse.down = false;
    });
};
SDU.GameScene.prototype.step = function() {
    var obj, obj1, obj2, obj3,
        i, i1, i2, i3, i4;
    for (i = 0; i < this.bodies.length; ++i) {
        obj = this.bodies[i];
        if (obj.movable) {
            obj.px = obj.x;
            obj.py = obj.y;
            obj.x += obj.vx / 500;
            obj.y += obj.vy / 500;
        }
    }
    this.gcIterations = 0;
    for (i1 = 0; i1 < this.bodies.length; ++i1) {
        obj1 = this.bodies[i1];
        for (i2 = i1 + 1; i2 < this.bodies.length; ++i2) {
            obj2 = this.bodies[i2];
            if (obj1 !== obj2) {
                ++this.gcIterations;

                var diffX = obj2.x - obj1.x,
                    diffY = obj2.y - obj1.y,
                    distSquare, dist;

                if (1000 / this.delta < 50)
                    distSquare = diffX * diffX + diffY * diffY;
                else distSquare = Math.min(utils.distToSegmentSquared(obj2, {
                    x: obj1.px,
                    y: obj1.py
                }, obj1), utils.distToSegmentSquared(obj1, {
                    x: obj2.px,
                    y: obj2.py
                }, obj2));

                if (distSquare > utils.sqr(obj1.getRadius() + obj2.getRadius())) {
                    dist = Math.sqrt(distSquare);

                    if (obj1.affectedByGravity) {
                        var force1 = obj2.getMass() / distSquare * obj2.attraction;
                        obj1.vx += force1 * diffX / dist;
                        obj1.vy += force1 * diffY / dist;
                    }
                    if (obj2.affectedByGravity) {
                        var force2 = obj1.getMass() / distSquare * obj1.attraction;
                        obj2.vx -= force2 * diffX / dist;
                        obj2.vy -= force2 * diffY / dist;
                    }
                } else if (obj1.collidable && obj2.collidable) {
                    var alreadyHadCollisions = false,
                        collisionIndex = -1;
                    i3 = this.collisions.length;
                    while (i3--) {
                        var collision = this.collisions[i3],
                            index1 = collision.indexOf(obj1),
                            index2 = collision.indexOf(obj2);

                        if (index1 > -1 && index2 === -1)
                            collision.push(obj2);
                        else if (index1 === -1 && index2 > -1)
                            collision.push(obj1);

                        // Following code is a fix for [[obj1, obj3], [obj2, obj4]].
                        if (alreadyHadCollisions && (index1 > -1 || index2 > -1)) {
                            for (i4 = 0; i4 < this.collisions[collisionIndex].length; ++i4) {
                                obj3 = this.collisions[collisionIndex][i4];
                                if (obj3 !== obj1 && obj3 !== obj2) collision.push(obj3);
                            }
                            this.collisions.splice(collisionIndex, 1);
                        }

                        if (index1 > -1 || index2 > -1) {
                            alreadyHadCollisions = true;
                            collisionIndex = i3;
                        }
                    }
                    if (!alreadyHadCollisions) this.collisions.push([obj1, obj2]);
                }
            }
        }
    }
    for (i1 = 0; i1 < this.collisions.length; ++i1) {
        var targets = this.collisions[i1],
            biggestRadius, scaleFactor;
        obj1 = targets[0];
        biggestRadius = obj1.getRadius();
        for (i2 = 1; i2 < targets.length; ++i2) {
            obj2 = targets[i2];
            var density = Math.max(obj1.density, obj2.density),
                area = obj1.getArea() * (obj1.density / density) + obj2.getArea() * (obj2.density / density);

            obj1.x = (obj1.getMass() * obj1.x + obj2.getMass() * obj2.x) / (obj1.getMass() + obj2.getMass());
            obj1.y = (obj1.getMass() * obj1.y + obj2.getMass() * obj2.y) / (obj1.getMass() + obj2.getMass());
            obj1.vx = (obj1.getMass() * obj1.vx + obj2.getMass() * obj2.vx) / (obj1.getMass() + obj2.getMass());
            obj1.vy = (obj1.getMass() * obj1.vy + obj2.getMass() * obj2.vy) / (obj1.getMass() + obj2.getMass());

            obj1.setArea(area); // Setting obj1's area must happen after calculating velocity!
            biggestRadius = Math.max(obj2.getRadius(), biggestRadius);
            obj1.attraction = Math.max(obj1.attraction, obj2.attraction);

            this.garbage.push(obj2);
        }
        obj1.setColor(this.palette.light.getRandomItem());
        obj1.redraw();
        if (1000 / this.delta >= 30) {
            scaleFactor = biggestRadius / obj1.getRadius();
            obj1.scaleX = obj1.scaleY = scaleFactor;
            createjs.Tween.get(obj1, {
                override: true
            }).to({
                scaleX: 1,
                scaleY: 1
            }, 1000, createjs.Ease.elasticOut);
        }
    }
    this.collisions = [];
    for (i = 0; i < this.garbage.length; ++i) {
        obj = this.garbage[i];

        var index = this.bodies.indexOf(obj);
        if (index > -1)
            this.bodies.splice(index, 1);
        else throw new Error("Tried to dump unexistent object from `bodies` array.");

        if (obj) this.removeChild(obj);
    }
    this.garbage = [];
};
createjs.promote(SDU.GameScene, "SDUScene");

Planet.js:

var SDU = SDU || {};

SDU.Planet = function(x, y, radius, color, properties, parent) {
    this.createjsShape_constructor();
    this.set({
        x: x,
        y: y,
        radius: radius,
        color: color,
        stroke: {
            color: null,
            thickness: 0
        },

        vx: 0,
        vy: 0,
        px: x,
        py: y,

        density: 1,
        attraction: 1,

        movable: true,
        collidable: true,
        affectedByGravity: true,

        redraw: function() {
            var radius = this.radius + this.stroke.thickness;
            this.graphics.s(this.stroke.color).ss(this.stroke.thickness).f(this.color).dc(0, 0, this.radius);
            this.cache(-radius, -radius, radius * 2, radius * 2);
            this.graphics.clear();
        },
        getMass: function() {
            return this.getArea() * this.density;
        },
        setMass: function(newValue, changeArea) {
            if (!changeArea) {
                this.density = newValue / this.getArea();
            } else {
                this.setArea(newValue / this.density);
            }
        },
        getArea: function() {
            return Math.PI * this.radius * this.radius;
        },
        setArea: function(newValue) {
            this.radius = Math.sqrt(newValue / Math.PI);
        },
        getRadius: function() {
            return this.radius;
        },
        setRadius: function(newValue, conformDensity) {
            if (conformDensity)
                this.density = this.radius / newValue;
            this.radius = newValue;
        },
        getColor: function() {
            return this.color;
        },
        setColor: function(newValue) {
            this.color = newValue;
        },
        setStroke: function(color, thickness) {
            this.stroke.color = color;
            this.stroke.thickness = thickness;
        }
    }).set(properties || {});
    this.redraw();
    if (parent) parent.addChild(this);
};
createjs.extend(SDU.Planet, createjs.Shape);
createjs.promote(SDU.Planet, "createjsShape");

Scene.js:

var SDU = SDU || {};
SDU.Scene = function(canvas) {
    this.createjsStage_constructor(canvas);
    this.bodies = [];
    this.mouse = {
        x: 0,
        y: 0
    };
    var _this = this;
    this.addEventListener("stagemousemove", function(e) {
        _this.mouse.x = e.stageX;
        _this.mouse.y = e.stageY;
    });
};
createjs.extend(SDU.Scene, createjs.Stage);
SDU.Scene.prototype.addChild = function(child) {
    this.bodies.push(child);
    this.createjsStage_addChild(child);
};
SDU.Scene.prototype.addChildAt = function(child, index) {
    this.bodies.push(child);
    this.createjsStage_addChildAt(child, index);
};
createjs.promote(SDU.Scene, "createjsStage");

Utils.js:

var utils = utils || {};
utils.merge = function(obj1, obj2) {
    var obj = {};
    for (var p1 in obj1) {
        if (obj1.hasOwnProperty(p1))
            obj[p1] = obj1[p1];
    }
    for (var p2 in obj2) {
        if (obj2.hasOwnProperty(p2))
            obj[p2] = obj2[p2];
    }
    return obj;
};
utils.getRandomInt = function(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
};
utils.getRandomFloat = function(min, max) {
    return Math.random() * (max - min) + min;
};

utils.sqr = function(x) {
    return x * x;
};

utils.dist2 = function(v, w) {
    return utils.sqr(v.x - w.x) + utils.sqr(v.y - w.y);
};

utils.distToSegmentSquared = function(p, v, w) {
    var l2 = utils.dist2(v, w);
    if (l2 === 0) return utils.dist2(p, v);
    var t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2;
    if (t < 0) return utils.dist2(p, v);
    if (t > 1) return utils.dist2(p, w);
    return utils.dist2(p, {
        x: v.x + t * (w.x - v.x),
        y: v.y + t * (w.y - v.y)
    });
};

utils.distToSegment = function(p, v, w) {
    return Math.sqrt(utils.distToSegmentSquared(p, v, w));
};

Array.prototype.getRandomItem = function() {
    var i = utils.getRandomInt(0, this.length - 1);
    return this[i];
};
navigator.sayswho = (function() {
    var ua = navigator.userAgent,
        tem,
        M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
    if (/trident/i.test(M[1])) {
        tem = /\brv[ :]+(\d+)/g.exec(ua) || [];
        return 'IE ' + (tem[1] || '');
    }
    if (M[1] === 'Chrome') {
        tem = ua.match(/\b(OPR|Edge)\/(\d+)/);
        if (tem != null) return tem.slice(1).join(' ').replace('OPR', 'Opera');
    }
    M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, '-?'];
    if ((tem = ua.match(/version\/(\d+)/i)) != null) M.splice(1, 1, tem[1]);
    return M.join(' ');
})();

I know it's a loooooooot of code, so I highly recommend reviewing at the Github repository.

\$\endgroup\$
  • 1
    \$\begingroup\$ Help does say to embed it, but no mention of how long. Plus the Meta seems to agree on "as long as you like". But I'd recommend narrowing it down, and maybe spread it across questions. Long questions tend to ward away potential reviewers. \$\endgroup\$ – Joseph Sep 11 '15 at 2:43
  • \$\begingroup\$ @JosephtheDreamer Ok, edited the question. I'm providing the minimum amount of code as possible, but it is still huge. Should I merge everything on a single code block for better formatting? I'm also excluding some files that don't need to be reviewed anyway. \$\endgroup\$ – Tiago Marinho Sep 11 '15 at 2:51
  • 2
    \$\begingroup\$ some of the almost independent getters recalculate the same values over and over, An example of this is Area, it is based on radius, which I doubt changes very much, but it is evaluated each time it is called. It might be worth storing the value in the Planet class and updating it when either setArea or setRadius is called. Even if you don't do it in the class, in Engine.js in one iteration getMass is called 16 times, and so in that one iteration you work out the area 18 times, the same area 9 times twice, when it only needs to be done once each \$\endgroup\$ – spyr03 Sep 11 '15 at 3:16
  • 1
    \$\begingroup\$ @TiagoMarinho It would, and it would be up to whether you think it was worth the performance, I personally think the code as it is pretty clear and readable. But maybe make use of variables more inside the Engine \$\endgroup\$ – spyr03 Sep 11 '15 at 3:32
  • 1
    \$\begingroup\$ It looks pretty! \$\endgroup\$ – user29120 Sep 11 '15 at 14:01
3
\$\begingroup\$

and there's some commented-out code that creates different setups for testing

Automate this! Or better yet, create a default config that you can tweak instead of adding/removing code.

/*for (var i1 = 0; i1 < 10; ++i1) {
    for (var i2 = 0; i2 < 10; ++i2) {
        var planet = new SDU.Planet(50 + i1 * 25, 50 + i2 * 25, 5, this.palette.light.getRandomItem(), {
            density: 1,
            attraction: 1
        }, this);
    }
}*/
  • As far as I understand, this just runs at setup, which shouldn't be affecting performance. However, you could substitute your nested loop into just one loop that does i1 x i2 iterations.

  • You're comparing with 10. Better move that to a config object or some "constant" (just a variable acting like a constant) for you to easily configure this value.

  • 50 + i1 * 25, 50 + i2 * 25 - they don't seem to mean anything. What is this equation exactly? If at all possible, move this to a function which you can just call. And while you're there, name it according to what it does.


//var p1 = new SDU.Planet(50, 50, 5, this.palette.getRandomItem(), {}, this);
//var p2 = new SDU.Planet(100, 50, 10, this.palette.getRandomItem(), {}, this);
  • Same here, what are these numbers exactly? Move to config or variable that names their purpose.

var _this = this,
    preview;
  • The _ has a special meaning in programming. It implies that the thing with _ is private. Since this is not a private variable, nor are you exposing anything public in the function, then don't use the _. Common names include instance, that, and self. I prefer instance though, as it implies that we're in a method, the function should be used as a method, and that this is an object instance and not some randomly assigned context.

  • I would recommend a var per variable. I looked at stagemousedown and found preview with no var. It was hard to spot where it was defined because it didn't have var when it was declared.


function _listenMouseMove() {
    if (_this.mouse.down) {}
}
  • Dead code. :P

for (i1 = 0; i1 < this.bodies.length; ++i1) {
    obj1 = this.bodies[i1];
    for (i2 = i1 + 1; i2 < this.bodies.length; ++i2) {
        obj2 = this.bodies[i2];
  • Haven't read through your code for the calculations (nose is bleeding with math). But your loop for appears to be O(n^2). Appears to be redundant.

    For instance, [1, 2, 3]. Your loop runs like [[1,1],[1,2],[1,3],[2,1],[2,2],[2,3],[3,1],[3,2],[3,3]]. If you could find a way, remove the redundant comparisons and the loop would be something like [[1,2],[1,3],[2,3]]. It's a great reduction in iterations.

  • I also notice a while inside there. That complicates things. As much as possible, try avoiding nested loops.


this.collisions = [];
  • If you're clearing the collisions array, just set the length to zero (this.collisions.length = 0) instead of assigning a new array. It avoids creating too many objects.

radius * 2
return this.getArea() * this.density;
return Math.PI * this.radius * this.radius;
  • As mentioned in a comment, this appears constant most of the time. Might want to cache these values and just recalculate them when any dependents change. Saves you from computing them all the time.

return Math.floor(Math.random() * (max - min + 1)) + min;
  • You can use <number> | 0 to clip off the decimals from a number, instead of Math.floor. In some engines, bitwise operations are faster than Math operations. But mileage may vary.

Array.prototype.getRandomItem = function() {
    var i = utils.getRandomInt(0, this.length - 1);
    return this[i];
};
  • Don't modify the prototype. In general, don't modify objects you don't own. Create your own function that accepts an array and returns a random item instead.

navigator.sayswho = (function() {
    var ua = navigator.userAgent,
        tem,
        M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
    if (/trident/i.test(M[1])) {
        tem = /\brv[ :]+(\d+)/g.exec(ua) || [];
        return 'IE ' + (tem[1] || '');
    }
    if (M[1] === 'Chrome') {
        tem = ua.match(/\b(OPR|Edge)\/(\d+)/);
        if (tem != null) return tem.slice(1).join(' ').replace('OPR', 'Opera');
    }
    M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, '-?'];
    if ((tem = ua.match(/version\/(\d+)/i)) != null) M.splice(1, 1, tem[1]);
    return M.join(' ');
})();
\$\endgroup\$
  • 1
    \$\begingroup\$ I'll review your proposals as soon as I can. For now, about the O(n^2) loop, it already run as you suggested, because i2 starts as i1 + 1 so when i1 equals 2, i2 will start as 3 and it'll loop as: [[1, 2], [1, 3], [2, 3]]. preview had a var statement, you just didn't noticed it at all, because it was along with _this. It even appears in your third topic. \$\endgroup\$ – Tiago Marinho Sep 11 '15 at 15:20
  • \$\begingroup\$ @TiagoMarinho Ahh. Didn't go through the logic fully (math scares me) :P And yeah, preview was tricky to find. \$\endgroup\$ – Joseph Sep 11 '15 at 16:54
  • \$\begingroup\$ No problem. Other than that, your review was pretty straightforward. Thank you. \$\endgroup\$ – Tiago Marinho Sep 11 '15 at 19:48

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.