diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a34e95..3315bad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +0.6.0 / 2014-12-09 + +[NEW] Coquette is using a quad tree for collision detection now. +[NEW] Support for own shapes has been added. Your own shape needs a function called `isIntersecting` which takes another Shape and returns true or false. + +[BREAKING CHANGE] To enable collision detection for an entity the `boundingBox` property has to return an instance of a Shape, either `Collider.Shape.Rectangle` or `Collider.Shape.Circle`. Both constructors take the entity as a parameter. + 0.5.2 / 2014-12-01 [FIX] Fix Left Right Space to destroy ALL asteroids in group when one member hit with bullet. diff --git a/coquette-min.js b/coquette-min.js index d289900..527fa41 100644 --- a/coquette-min.js +++ b/coquette-min.js @@ -1 +1 @@ -(function(exports){var Coquette=function(game,canvasId,width,height,backgroundColor,autoFocus){var canvas=document.getElementById(canvasId);this.renderer=new Coquette.Renderer(this,game,canvas,width,height,backgroundColor);this.inputter=new Coquette.Inputter(this,canvas,autoFocus);this.entities=new Coquette.Entities(this,game);this.runner=new Coquette.Runner(this);this.collider=new Coquette.Collider(this);var self=this;this.ticker=new Coquette.Ticker(this,function(interval){self.collider.update(interval);self.runner.update(interval);if(game.update!==undefined){game.update(interval)}self.entities.update(interval);self.renderer.update(interval);self.inputter.update()})};exports.Coquette=Coquette})(this);(function(exports){var Collider=function(coquette){this.c=coquette};var isSetupForCollisions=function(obj){return obj.center!==undefined&&obj.size!==undefined};Collider.prototype={_currentCollisionPairs:[],update:function(){this._currentCollisionPairs=[];var ent=this.c.entities.all();for(var i=0,len=ent.length;i0){var pair=this._currentCollisionPairs.shift();if(this.isColliding(pair[0],pair[1])){this.collision(pair[0],pair[1])}}},collision:function(entity1,entity2){notifyEntityOfCollision(entity1,entity2);notifyEntityOfCollision(entity2,entity1)},createEntity:function(entity){var ent=this.c.entities.all();for(var i=0,len=ent.length;i=0;i--){if(this._currentCollisionPairs[i][0]===entity||this._currentCollisionPairs[i][1]===entity){this._currentCollisionPairs.splice(i,1)}}},isColliding:function(obj1,obj2){return obj1!==obj2&&isSetupForCollisions(obj1)&&isSetupForCollisions(obj2)&&this.isIntersecting(obj1,obj2)},isIntersecting:function(obj1,obj2){var obj1BoundingBox=getBoundingBox(obj1);var obj2BoundingBox=getBoundingBox(obj2);if(obj1BoundingBox===this.RECTANGLE&&obj2BoundingBox===this.RECTANGLE){return Maths.rectanglesIntersecting(obj1,obj2)}else if(obj1BoundingBox===this.CIRCLE&&obj2BoundingBox===this.RECTANGLE){return Maths.circleAndRectangleIntersecting(obj1,obj2)}else if(obj1BoundingBox===this.RECTANGLE&&obj2BoundingBox===this.CIRCLE){return Maths.circleAndRectangleIntersecting(obj2,obj1)}else if(obj1BoundingBox===this.CIRCLE&&obj2BoundingBox===this.CIRCLE){return Maths.circlesIntersecting(obj1,obj2)}else{throw"Objects being collision tested have unsupported bounding box types."}},RECTANGLE:0,CIRCLE:1};var getBoundingBox=function(obj){return obj.boundingBox||Collider.prototype.RECTANGLE};var notifyEntityOfCollision=function(entity,other){if(entity.collision!==undefined){entity.collision(other)}};var rotated=function(obj){return obj.angle!==undefined&&obj.angle!==0};var getAngle=function(obj){return obj.angle===undefined?0:obj.angle};var Maths={circlesIntersecting:function(obj1,obj2){return Maths.distance(obj1.center,obj2.center)rectangleObj.center.x+rectangleObj.size.x/2){closest.x=rectangleObj.center.x+rectangleObj.size.x/2}else{closest.x=unrotatedCircleCenter.x}if(unrotatedCircleCenter.yrectangleObj.center.y+rectangleObj.size.y/2){closest.y=rectangleObj.center.y+rectangleObj.size.y/2}else{closest.y=unrotatedCircleCenter.y}return this.distance(unrotatedCircleCenter,closest)obj2.center.x+obj2.size.x/2){return false}else if(obj1.center.y-obj1.size.y/2>obj2.center.y+obj2.size.y/2){return false}else if(obj1.center.y+obj1.size.y/2current){min=current}if(current>max){max=current}}return{min:min,max:max}},rectangleCorners:function(obj){var corners=[{x:obj.center.x-obj.size.x/2,y:obj.center.y-obj.size.y/2},{x:obj.center.x+obj.size.x/2,y:obj.center.y-obj.size.y/2},{x:obj.center.x+obj.size.x/2,y:obj.center.y+obj.size.y/2},{x:obj.center.x-obj.size.x/2,y:obj.center.y+obj.size.y/2}];var angle=getAngle(obj)*Maths.RADIANS_TO_DEGREES;for(var i=0;i0){var run=this._runs.shift();run.fn(run.obj)}},add:function(obj,fn){this._runs.push({obj:obj,fn:fn})}};exports.Runner=Runner})(typeof exports==="undefined"?this.Coquette:exports);(function(exports){var interval=16;function Ticker(coquette,gameLoop){setupRequestAnimationFrame();var nextTickFn;this.stop=function(){nextTickFn=function(){}};this.start=function(){var prev=(new Date).getTime();var tick=function(){var now=(new Date).getTime();var interval=now-prev;prev=now;gameLoop(interval);requestAnimationFrame(nextTickFn)};nextTickFn=tick;requestAnimationFrame(nextTickFn)};this.start()}var setupRequestAnimationFrame=function(){var lastTime=0;var vendors=["ms","moz","webkit","o"];for(var x=0;x0){var pair=this._currentCollisionPairs.shift();this.collision(pair[0],pair[1])}},collision:function(entity1,entity2){notifyEntityOfCollision(entity1,entity2);notifyEntityOfCollision(entity2,entity1)},createEntity:function(entity){var ent=this.c.entities.all();for(var i=0,len=ent.length;i=0;i--){if(this._currentCollisionPairs[i][0]===entity||this._currentCollisionPairs[i][1]===entity){this._currentCollisionPairs.splice(i,1)}}},isIntersecting:function(obj1,obj2){var shape1=getBoundingBox(obj1);var shape2=getBoundingBox(obj2);var result;if((result=shape1.isIntersecting(obj1,obj2))!==undefined){return result}else if((result=shape2.isIntersecting(obj1,obj2))!==undefined){return result}else{throw new Error("Unsupported bounding box shapes for collision detection.")}},isColliding:function(obj1,obj2){return obj1!==obj2&&isSetupForCollisions(obj1)&&isSetupForCollisions(obj2)&&this.isIntersecting(obj1,obj2)},RECTANGLE:new RectangleShape,CIRCLE:new CircleShape};var getDimensions=function(entities){var maxx,minx,maxy,miny;entities.forEach(function(entity){if(isSetupForCollisions(entity)){if(maxx===undefined||entity.center.x>maxx){maxx=entity.center.x}if(minx===undefined||entity.center.xmaxy){maxy=entity.center.y}if(miny===undefined||entity.center.yrectangleObj.center.x+rectangleObj.size.x/2){closest.x=rectangleObj.center.x+rectangleObj.size.x/2}else{closest.x=unrotatedCircleCenter.x}if(unrotatedCircleCenter.yrectangleObj.center.y+rectangleObj.size.y/2){closest.y=rectangleObj.center.y+rectangleObj.size.y/2}else{closest.y=unrotatedCircleCenter.y}return this.distance(unrotatedCircleCenter,closest)obj2.center.x+obj2.size.x/2){return false}else if(obj1.center.y-obj1.size.y/2>obj2.center.y+obj2.size.y/2){return false}else if(obj1.center.y+obj1.size.y/2current){min=current}if(current>max){max=current}}return{min:min,max:max}},rectangleCorners:function(obj){var corners=[{x:obj.center.x-obj.size.x/2,y:obj.center.y-obj.size.y/2},{x:obj.center.x+obj.size.x/2,y:obj.center.y-obj.size.y/2},{x:obj.center.x+obj.size.x/2,y:obj.center.y+obj.size.y/2},{x:obj.center.x-obj.size.x/2,y:obj.center.y+obj.size.y/2}];var angle=getAngle(obj)*Maths.RADIANS_TO_DEGREES;for(var i=0;i0){var run=this._runs.shift();run.fn(run.obj)}},add:function(obj,fn){this._runs.push({obj:obj,fn:fn})}};exports.Runner=Runner})(typeof exports==="undefined"?this.Coquette:exports);(function(exports){var interval=16;function Ticker(coquette,gameLoop){setupRequestAnimationFrame();var nextTickFn;this.stop=function(){nextTickFn=function(){}};this.start=function(){var prev=(new Date).getTime();var tick=function(){var now=(new Date).getTime();var interval=now-prev;prev=now;gameLoop(interval);requestAnimationFrame(nextTickFn)};nextTickFn=tick;requestAnimationFrame(nextTickFn)};this.start()}var setupRequestAnimationFrame=function(){var lastTime=0;var vendors=["ms","moz","webkit","o"];for(var x=0;x 0) { var pair = this._currentCollisionPairs.shift(); - if (this.isColliding(pair[0], pair[1])) { - this.collision(pair[0], pair[1]); - } + this.collision(pair[0], pair[1]); } }, @@ -80,6 +98,21 @@ } }, + isIntersecting: function(obj1, obj2) { + var shape1 = getBoundingBox(obj1); + var shape2 = getBoundingBox(obj2); + + var result; + if((result = shape1.isIntersecting(obj1, obj2)) !== undefined) { + return result; + } else + if((result = shape2.isIntersecting(obj1, obj2)) !== undefined) { + return result; + } else { + throw new Error("Unsupported bounding box shapes for collision detection."); + } + }, + isColliding: function(obj1, obj2) { return obj1 !== obj2 && isSetupForCollisions(obj1) && @@ -87,25 +120,75 @@ this.isIntersecting(obj1, obj2); }, - isIntersecting: function(obj1, obj2) { - var obj1BoundingBox = getBoundingBox(obj1); - var obj2BoundingBox = getBoundingBox(obj2); - - if (obj1BoundingBox === this.RECTANGLE && obj2BoundingBox === this.RECTANGLE) { - return Maths.rectanglesIntersecting(obj1, obj2); - } else if (obj1BoundingBox === this.CIRCLE && obj2BoundingBox === this.RECTANGLE) { - return Maths.circleAndRectangleIntersecting(obj1, obj2); - } else if (obj1BoundingBox === this.RECTANGLE && obj2BoundingBox === this.CIRCLE) { - return Maths.circleAndRectangleIntersecting(obj2, obj1); - } else if (obj1BoundingBox === this.CIRCLE && obj2BoundingBox === this.CIRCLE) { - return Maths.circlesIntersecting(obj1, obj2); - } else { - throw "Objects being collision tested have unsupported bounding box types." + RECTANGLE: new RectangleShape(), + CIRCLE: new CircleShape() + }; + + var getDimensions = function(entities) { + var maxx, minx, maxy, miny; + + entities.forEach(function(entity) { + if(isSetupForCollisions(entity)) { + if(maxx === undefined || entity.center.x > maxx) { + maxx = entity.center.x; + } + if(minx === undefined || entity.center.x < minx) { + minx = entity.center.x; + } + if(maxy === undefined || entity.center.y > maxy) { + maxy = entity.center.y; + } + if(miny === undefined || entity.center.y < miny) { + miny = entity.center.y; + } } - }, + }); - RECTANGLE: 0, - CIRCLE: 1 + var width = maxx - minx; + var height = maxy - miny; + + return {size: {x: width, y: height }, center: {x: minx + width/2, y: miny + height/2}} + }; + + var quadTreeCollisionPairs = function(entities) { + var dimensions = getDimensions(entities); + + var p1 = {x: dimensions.center.x - dimensions.size.x/2, + y: dimensions.center.y - dimensions.size.y/2}; + var p2 = {x: dimensions.center.x + dimensions.size.x/2, + y: dimensions.center.y + dimensions.size.y/2}; + + this.quadTree = new Quadtree(p1, p2, { + maxObj: Math.max(Math.round(entities.length/4), 1), + maxLevel: 5 + }); + var quadTree = this.quadTree; + entities.forEach(function(entity) { + quadTree.insert(entity); + }); + + quadTree.allCollisionPairs = allCollisionPairs.bind(this); + return quadTree.collisions(); + }; + + var allCollisionPairs = function(ent) { + var potentialCollisionPairs = []; + + // get all entity pairs to test for collision + for (var i = 0, len = ent.length; i < len; i++) { + for (var j = i + 1; j < len; j++) { + potentialCollisionPairs.push([ent[i], ent[j]]); + } + } + + var collisionPairs = []; + potentialCollisionPairs.forEach(function(pair) { + if(this.isColliding(pair[0], pair[1])) { + collisionPairs.push(pair); + } + }.bind(this)); + + return collisionPairs; }; var getBoundingBox = function(obj) { @@ -351,6 +434,122 @@ RADIANS_TO_DEGREES: 0.01745 }; + function Quadtree(p1, p2, settings, level) { + this.p1 = p1; + this.p2 = p2; + + var width = this.p2.x-this.p1.x; + var height = this.p2.y-this.p1.y; + + this.objects = []; + this.nodes = []; + this.rectangles = []; + this.leaf = true; + this.settings = settings; + + this.level = level || 1; + } + + Quadtree.prototype.insert = function(object) { + var x = object.center.x; + var y = object.center.y; + if(isNaN(x) || isNaN(y)) return; + + if(this.leaf) { + if(this.objects.length this.settings.duration) { + this.start = undefined; + if(!this.quad) { + this.time.all = this.timer.avg(); + this.timer = new Timer(); + this.quad = true; + } else { + this.time.quad = this.timer.avg(); + this.quad = false; + testSuite.nextTest(); + } + } + } + + var TestSuite = function() { + this.tests = []; + this.current = 0; + + this.tbodyEl; + } + + TestSuite.prototype = { + addTest: function(test) { + this.tests.push(test); + }, + currentTest: function() { + return this.tests[this.current]; + }, + nextTest: function() { + this.logTest(); + this.current = ((this.current+1) % this.tests.length); + return this.currentTest(); + }, + hasNextTest: function() { + return this.tests.lengththis.current) { + this.tbodyEl.deleteRow(this.current); + } + var row = this.tbodyEl.insertRow(this.current); + + var cell1 = row.insertCell(0); + var cell2 = row.insertCell(1); + var cell3 = row.insertCell(2); + + // Add some text to the new cells: + cell1.innerHTML = this.currentTest().settings.entities; + if(this.currentTest().time.all < this.currentTest().time.quad) { + cell2.className = "faster"; + } else { + cell3.className = "faster"; + } + cell2.innerHTML = this.currentTest().time.all + "ms"; + cell3.innerHTML = this.currentTest().time.quad + "ms"; + } + }; + + var testSuite = new TestSuite(); + [50, 100, 250, 500].forEach(function(count) { + testSuite.addTest(new Test({entities: count, duration: 5000, quad: false})); + }); + + var Collisions = function() { + var autoFocus = true; + var c = new Coquette(this, "collisions-canvas", + width, height, "white", autoFocus); + this.c = c; + + var collider = this.c.collider; + var colliderUpdate = collider.update; + collider.update = function() { + testSuite.currentTest().onStartCollisionDetection(this); + if(testSuite.currentTest().quad) { + colliderUpdate.apply(collider); + } else { + var ent = this.c.entities.all(); + collider._currentCollisionPairs = allCollisionPairs.apply(collider, [ent]); + while (collider._currentCollisionPairs.length > 0) { + var pair = collider._currentCollisionPairs.shift(); + collider.collision(pair[0], pair[1]); + } + } + testSuite.currentTest().onEndCollisionDetection(this); + }; + + var rendererUpdate = this.c.renderer.update; + this.c.renderer.update = function(intercal) { + rendererUpdate.apply(this); + var ctx = this.getCtx(); + + // draw quad tree + if(testSuite.currentTest().quad && collider.quadTree) { + drawQuad(collider.quadTree, ctx); + } + + } + + }; + + Collisions.prototype = { + entityEl: undefined, + + update: function() { + var viewSize = this.c.renderer.getViewSize(); + var viewCenter = this.c.renderer.getViewCenter(); + + var x1 = viewCenter.x - viewSize.x/2; + var x2 = viewCenter.x + viewSize.x/2; + var y1 = viewCenter.y - viewSize.y/2; + var y2 = viewCenter.y + viewSize.y/2; + + var entities = this.c.entities.all(); + if(!this.entityEl) { + this.entityEl = document.getElementById("entities"); + } + this.entityEl.innerHTML = entities.length; + + var currentTestSettings = testSuite.currentTest().settings; + // Create if too less + for(var i=0; i<(currentTestSettings.entities-entities.length); i++) { + var Shape = Math.random() > 0.5 ? Rectangle : Circle; + this.c.entities.create(Shape, { // make one + center: randomPosition(x1, y1, x2, y2), + vec: randomVec() + }); + } + + // Destroy if too many + for(var i=0; i<(entities.length-currentTestSettings.entities); i++) { + this.c.entities.destroy(entities[i]); + } + + // Wrap it! + this.c.entities.all().forEach(function(entity) { + if(entity.center.x > x2) entity.center.x = x1; + if(entity.center.x < x1) entity.center.x = x2; + if(entity.center.y > y2) entity.center.y = y1; + if(entity.center.y < y1) entity.center.y = y2; + }); + }, + }; + + var Rectangle = function(game, settings) { + this.c = game.c; + this.boundingBox = this.c.collider.RECTANGLE; + this.angle = Math.random() * 360; + this.center = settings.center; + this.size = { x: shapeSize*2, y: shapeSize}; + this.vec = settings.vec; + this.turnSpeed = 2 * Math.random() - 1; + + mixin(makeCurrentCollidersCountable, this); + }; + + Rectangle.prototype = { + update: function() { + // move + this.center.x += this.vec.x; + this.center.y += this.vec.y; + + this.angle += this.turnSpeed; // turn + this.lineWidth = this.colliderCount>0 ? 2 : 1; + this.colliderCount = 0; + }, + + draw: function(ctx) { + ctx.strokeStyle = "black"; + ctx.lineWidth = this.lineWidth; + ctx.strokeRect(this.center.x - this.size.x / 2, this.center.y - this.size.y / 2, + this.size.x, this.size.y); + }, + + }; + + var Circle = function(game, settings) { + this.c = game.c; + this.boundingBox = this.c.collider.CIRCLE; + this.center = settings.center; + this.size = { x: shapeSize, y: shapeSize }; + this.vec = settings.vec; + + mixin(makeCurrentCollidersCountable, this); + }; + + Circle.prototype = { + update: function() { + // move + this.center.x += this.vec.x; + this.center.y += this.vec.y; + + this.lineWidth = this.colliderCount>0 ? 2 : 1; + this.colliderCount = 0; + }, + + draw: function(ctx) { + ctx.beginPath(); + ctx.lineWidth = this.lineWidth; + ctx.arc(this.center.x, this.center.y, this.size.x / 2, 0, Math.PI * 2, true); + ctx.closePath(); + ctx.strokeStyle = "black"; + ctx.stroke(); + }, + + }; + + var levelToColor = ['green', 'red', 'orange', 'yellow', 'brown', 'purple', 'blue']; + var drawQuad = function(quadtree, ctx) { + ctx.lineWidth = 1; + ctx.strokeStyle = levelToColor[quadtree.level-1]; + var x1 = quadtree.p1.x; + var y1 = quadtree.p1.y; + var x2 = quadtree.p2.x; + var y2 = quadtree.p2.y; + ctx.strokeRect(x1, y1, x2-x1, y2-y1); + quadtree.nodes.forEach(function(node) { + drawQuad(node, ctx); + }); + } + + var randomPosition = function(x1, y1, x2, y2) { + var randx = Math.round(Math.random() * (x2-x1) + x1); + var randy = Math.round(Math.random() * (y2-y1) + y1); + return { x: randx, y: randy }; + } + + var randomVec = function() { + var randx = Math.round(Math.random() * 5 - 2.5); + var randy = Math.round(Math.random() * 5 - 2.5); + return { x: randx, y: randy }; + } + + var mixin = function(from, to) { + for (var i in from) { + to[i] = from[i]; + } + }; + + var makeCurrentCollidersCountable = { + colliderCount: 0, // number of other shapes currently touching this shape + + collision: function(_, type) { + this.colliderCount++; + }, + + }; + + var allCollisionPairs = function(ent) { + var potentialCollisionPairs = []; + + // get all entity pairs to test for collision + for (var i = 0, len = ent.length; i < len; i++) { + for (var j = i + 1; j < len; j++) { + potentialCollisionPairs.push([ent[i], ent[j]]); + } + } + + var collisionPairs = []; + potentialCollisionPairs.forEach(function(pair) { + if(this.isColliding(pair[0], pair[1])) { + collisionPairs.push(pair); + } + }.bind(this)); + + return collisionPairs; + }; + + exports.Collisions = Collisions; +})(this); diff --git a/demos/collisions/index.html b/demos/collisions/index.html new file mode 100644 index 0000000..46c10cc --- /dev/null +++ b/demos/collisions/index.html @@ -0,0 +1,41 @@ + + + + + + + + + + +

0ms for testing 0 entities.

+
+ + + +
EntitiesWithout quadtreeWith quadtree
.........
+
+ + diff --git a/demos/leftrightspace/bullet.js b/demos/leftrightspace/bullet.js index b18416f..1ae9f27 100644 --- a/demos/leftrightspace/bullet.js +++ b/demos/leftrightspace/bullet.js @@ -1,7 +1,7 @@ ;(function(exports) { var Bullet = function(game, settings) { this.game = game; - this.boundingBox = game.c.collider.CIRCLE; + this.boundingBox = this.game.c.collider.CIRCLE; this.center = settings.center; this.vel = settings.vector; }; diff --git a/demos/leftrightspace/player.js b/demos/leftrightspace/player.js index b5b0e98..a839313 100644 --- a/demos/leftrightspace/player.js +++ b/demos/leftrightspace/player.js @@ -1,7 +1,7 @@ ;(function(exports) { var Player = function(game, settings) { this.game = game; - this.boundingBox = game.c.collider.CIRCLE; + this.boundingBox = this.game.c.collider.CIRCLE; this.center = settings.center; this.vel = { x:0, y:0 }; // bullshit this.pathInset = this.game.c.renderer.getViewSize().x / 2; diff --git a/package.json b/package.json index f50f197..03495fa 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "coquette", "description": "A micro framework for JavaScript games.", "author": "Mary Rose Cook (http://maryrosecook.com/)", - "version": "0.5.4", + "version": "0.6.0", "repository": { "type": "git", "url": "https://github.com/maryrosecook/coquette.git" diff --git a/spec/collider.spec.js b/spec/collider.spec.js index df0f21b..d6cd82c 100644 --- a/spec/collider.spec.js +++ b/spec/collider.spec.js @@ -25,6 +25,7 @@ describe('collider', function() { var MockCoquette = function() { this.entities = new Entities(this); this.collider = new Collider(this); + this.renderer = {}; }; var Thing = function(__, settings) { @@ -37,13 +38,20 @@ describe('collider', function() { describe('create', function() { it('should not add coll pair for new entity with itself', function() { var c = new MockCoquette(); - var unmock = mock(c.collider, "isColliding", function(a, b) { + var unmockCollider = mock(c.collider, "isColliding", function(a, b) { return true; }); + var unmockRenderer = mock(c.renderer, "getViewSize", function() { + return {x: 500, y: 500}; + }) var haveCreatedEntity = false; var others = []; - c.entities.create(Thing, { id: 0, collision: function() { + var center = {x: 0, y: 0}; + var size = {x: 0, y: 0}; + c.entities.create(Thing, { + id: 0, center: center, size: size, + collision: function() { if (!haveCreatedEntity) { haveCreatedEntity = true; c.entities.create(Thing, { id: 2, collision: function(other) { @@ -51,13 +59,14 @@ describe('collider', function() { }}); } }}); - c.entities.create(Thing, { id: 1 }); + c.entities.create(Thing, { id: 1, center: center, size: size }); c.collider.update(); expect(others[0].id).toEqual(0); expect(others[1].id).toEqual(1); expect(others.length).toEqual(2); - unmock(); + unmockCollider(); + unmockRenderer(); }); it('should do collisions for new entity during current round of coll detection', function() { @@ -68,15 +77,25 @@ describe('collider', function() { var haveCreatedEntity = false; var createdEntityCollisions = 0; - c.entities.create(Thing, { id: 0, collision: function() { - if (!haveCreatedEntity) { - haveCreatedEntity = true; - c.entities.create(Thing, { id: 2, collision: function(other) { - createdEntityCollisions++; - }}); + var center = {x: 0, y: 0}; + var size = {x: 0, y: 0}; + c.entities.create(Thing, { + id: 0, + center: center, size: size, + collision: function() { + if (!haveCreatedEntity) { + haveCreatedEntity = true; + c.entities.create(Thing, { + id: 2, + center: center, size: size, + collision: function(other) { + createdEntityCollisions++; + } + }); + } } - }}); - c.entities.create(Thing, { id: 1 }); + }); + c.entities.create(Thing, { id: 1, center: center, size: size}); c.collider.update(); expect(createdEntityCollisions).toEqual(2); @@ -98,21 +117,30 @@ describe('collider', function() { } }; + var center = {x: 0, y: 0}; + var size = {x: 0, y: 0}; // 0 should collide with 1, 2 and 3 - c.entities.create(Thing, { id: 0 }); - c.entities.create(Thing, { id: 1, collision: collisionCounterFn }); - c.entities.create(Thing, { id: 2, collision: collisionCounterFn }); - c.entities.create(Thing, { id: 3, collision: collisionCounterFn }); + c.entities.create(Thing, { id: 0, center: center, size: size }); + c.entities.create(Thing, { id: 1, center: center, size: size, + collision: collisionCounterFn }); + c.entities.create(Thing, { id: 2, center: center, size: size, + collision: collisionCounterFn }); + c.entities.create(Thing, { id: 3, center: center, size: size, + collision: collisionCounterFn }); c.collider.update(); expect(collisionCounter).toEqual(3); // should only count coll for 0 and 1, not 2 and 3: collisionCounter = 0; c.entities._entities = []; - c.entities.create(Thing, { id: 0, collision: function() { c.entities.destroy(this); } }); - c.entities.create(Thing, { id: 1, collision: collisionCounterFn }); - c.entities.create(Thing, { id: 2, collision: collisionCounterFn }); - c.entities.create(Thing, { id: 3, collision: collisionCounterFn }); + c.entities.create(Thing, { id: 0, center: center, size: size, + collision: function() { c.entities.destroy(this); } }); + c.entities.create(Thing, { id: 1, center: center, size: size, + collision: collisionCounterFn }); + c.entities.create(Thing, { id: 2, center: center, size: size, + collision: collisionCounterFn }); + c.entities.create(Thing, { id: 3, center: center, size: size, + collision: collisionCounterFn }); c.collider.update(); expect(collisionCounter).toEqual(1); @@ -125,14 +153,21 @@ describe('collider', function() { return true; }); - var rmEnt = c.entities.create(Thing, { id: 0 }); + var center = {x: 0, y: 0}; + var size = {x: 0, y: 0}; + + var rmEnt = c.entities.create(Thing, { id: 0, center: center, size: size}); var collisions = 0; for (var i = 1; i < 4; i++) { - c.entities.create(Thing, { id: i, collision: function() { collisions++; }}); + c.entities.create(Thing, { + id: i, center: center, size: size, + collision: function() { collisions++; }}); } - c.entities.create(Thing, { id: i, collision: function() { c.entities.destroy(rmEnt); }}); + c.entities.create(Thing, { + id: i, center: center, size: size, + collision: function() { c.entities.destroy(rmEnt); }}); c.collider.update(); expect(collisions).toEqual(12); unmock(); @@ -144,11 +179,19 @@ describe('collider', function() { return true; }); - var rmEnt = c.entities.create(Thing, { id: 0, collision: function() { c.entities.destroy(this); } }); + var center = {x: 0, y: 0}; + var size = {x: 0, y: 0}; + var rmEnt = c.entities.create(Thing, { + id: 0, collision: function() { c.entities.destroy(this); }, + center: center, size: size + }); var collisions = 0; for (var i = 1; i < 4; i++) { - c.entities.create(Thing, { id: i, collision: function() { collisions++; }}); + c.entities.create(Thing, { + id: i, collision: function() { collisions++; }, + center: center, size: size + }); } c.collider.update(); @@ -162,14 +205,16 @@ describe('collider', function() { it('should test all entities against all other entities once', function() { var c = new MockCoquette(); var comparisons = []; - var unmock = mock(c.collider, "isColliding", function(a, b) { + var unmock = mock(c.collider, "collision", function(a, b) { comparisons.push([a.id, b.id]); }); - c.entities.create(Thing, { id: 0 }); - c.entities.create(Thing, { id: 1 }); - c.entities.create(Thing, { id: 2 }); - c.entities.create(Thing, { id: 3 }); + var center = {x: 0, y: 0}; + var size = {x: 0, y: 0}; + c.entities.create(Thing, { id: 0, center: center, size: size }); + c.entities.create(Thing, { id: 1, center: center, size: size }); + c.entities.create(Thing, { id: 2, center: center, size: size }); + c.entities.create(Thing, { id: 3, center: center, size: size }); c.collider.update(); expect(comparisons.length).toEqual(6); expect(comparisons[0][0] === 0 && comparisons[0][1] === 1).toEqual(true); @@ -183,11 +228,14 @@ describe('collider', function() { it('should do no comparisons when only one entity', function() { var c = new MockCoquette(); + var unmock = mock(c.collider, "isColliding", function(a, b) { throw "arg"; }); - c.entities.create(Thing, { id: 0 }); + c.entities.create(Thing, { + id: 0, center: {x: 0, y: 0}, size: {x: 0, y: 0} + }); c.collider.update(); unmock(); }); @@ -200,11 +248,14 @@ describe('collider', function() { var unmock = mock(c.collider, "isColliding", function() { return true }); var collisions = 0; c.entities.create(Thing, { + center: {x: 0, y: 0}, size: {x: 0, y: 0}, collision: function() { collisions++; } }); - c.entities.create(Thing); + c.entities.create(Thing, { + center: {x: 0, y: 0}, size: {x: 0, y: 0}, + }); c.collider.update(); c.collider.update(); c.collider.update(); @@ -302,7 +353,7 @@ describe('collider', function() { it('should return true: rotated top right just touching rotated top left', function() { expect(new Collider().isIntersecting(mockObj(207, 222, 70, 43, Collider.prototype.RECTANGLE, 325), - mockObj(280, 235, 70, 43, Collider.prototype.RECTANGLE, -484))).toEqual(true); + mockObj(280, 235, 70, 43, Collider.Rectangle, -484))).toEqual(true); }); it('should return true: rotated bottom right just touching rotated top left', function() { @@ -384,8 +435,8 @@ describe('collider', function() { describe('collisions', function() { it('should return true for circ+rect that are colliding', function() { var collider = new Collider(); - var obj1 = mockObj(10, 10, 10, 10, collider.CIRCLE); - var obj2 = mockObj(14, 14, 10, 10, collider.RECTANGLE); + var obj1 = mockObj(10, 10, 10, 10, Collider.prototype.CIRCLE); + var obj2 = mockObj(14, 14, 10, 10, Collider.prototype.RECTANGLE); expect(collider.isIntersecting(obj1, obj2)).toEqual(true); }); @@ -408,8 +459,8 @@ describe('collider', function() { describe('non-collisions', function() { it('should return false for circ+rect that are not colliding', function() { var collider = new Collider(); - var obj1 = mockObj(10, 10, 10, 10, collider.CIRCLE); - var obj2 = mockObj(19, 19, 10, 10, collider.RECTANGLE); + var obj1 = mockObj(10, 10, 10, 10, Collider.prototype.CIRCLE); + var obj2 = mockObj(19, 19, 10, 10, Collider.prototype.RECTANGLE); var intersecting = collider.isIntersecting(obj1, obj2); expect(intersecting).toEqual(false); }); @@ -434,15 +485,15 @@ describe('collider', function() { it('should throw when either obj has invalid bounding box', function() { var collider = new Collider(); - var obj1 = mockObj(5, 5, 1, 1, "la"); - var obj2 = mockObj(0, 0, 10, 10, collider.CIRCLE); expect(function() { + var obj1 = mockObj(5, 5, 1, 1, "la"); + var obj2 = mockObj(0, 0, 10, 10, Collider.prototype.CIRCLE); collider.isIntersecting(obj1, obj2); }).toThrow(); - var obj1 = mockObj(5, 5, 1, 1, Collider.CIRCLE); - var obj2 = mockObj(0, 0, 10, 10, "la"); expect(function() { + var obj1 = mockObj(5, 5, 1, 1, Collider.prototype.CIRCLE); + var obj2 = mockObj(0, 0, 10, 10, "la"); collider.isIntersecting(obj1, obj2); }).toThrow(); }); @@ -451,13 +502,13 @@ describe('collider', function() { it('should only return true when circle+rect in right order to collide', function() { var collider = new Collider(); - var obj1 = mockObj(38, 38, 10, 10, collider.CIRCLE); - var obj2 = mockObj(20, 20, 30, 30, collider.RECTANGLE); + var obj1 = mockObj(38, 38, 10, 10, Collider.prototype.CIRCLE); + var obj2 = mockObj(20, 20, 30, 30, Collider.prototype.RECTANGLE); expect(collider.isIntersecting(obj1, obj2)).toEqual(true); // same dimensions, swap shape type and get no collision - var obj1 = mockObj(38, 38, 10, 10, collider.RECTANGLE); - var obj2 = mockObj(20, 20, 30, 30, collider.CIRCLE); + var obj1 = mockObj(38, 38, 10, 10, Collider.prototype.RECTANGLE); + var obj2 = mockObj(20, 20, 30, 30, Collider.prototype.CIRCLE); expect(collider.isIntersecting(obj1, obj2)).toEqual(false); }); }); diff --git a/src/collider.js b/src/collider.js index af7b7a0..d711d04 100644 --- a/src/collider.js +++ b/src/collider.js @@ -7,26 +7,44 @@ return obj.center !== undefined && obj.size !== undefined; }; + var Shape = { + isIntersecting: function(e1, e2) { + var s1 = getBoundingBox(e1); + var s2 = getBoundingBox(e2); + + var circle = Collider.prototype.CIRCLE; + var rectangle = Collider.prototype.RECTANGLE; + + if (s1 === rectangle && s2 === rectangle) { + return Maths.rectanglesIntersecting(e1, e2); + } else + if (s1 === circle && s2 === rectangle) { + return Maths.circleAndRectangleIntersecting(e1, e2); + } else + if (s1 === rectangle && s2 === circle) { + return Maths.circleAndRectangleIntersecting(e2, e1); + } else + if (s1 === circle && s2 === circle) { + return Maths.circlesIntersecting(e1, e2); + } else return undefined; + } + } + + var RectangleShape = function() {} + RectangleShape.prototype = Shape; + + var CircleShape = function() {} + CircleShape.prototype = Shape; + Collider.prototype = { _currentCollisionPairs: [], update: function() { - this._currentCollisionPairs = []; - - // get all entity pairs to test for collision - var ent = this.c.entities.all(); - for (var i = 0, len = ent.length; i < len; i++) { - for (var j = i + 1; j < len; j++) { - this._currentCollisionPairs.push([ent[i], ent[j]]); - } - } + this._currentCollisionPairs = quadTreeCollisionPairs.apply(this, [this.c.entities.all()]); - // test collisions while (this._currentCollisionPairs.length > 0) { var pair = this._currentCollisionPairs.shift(); - if (this.isColliding(pair[0], pair[1])) { - this.collision(pair[0], pair[1]); - } + this.collision(pair[0], pair[1]); } }, @@ -54,6 +72,21 @@ } }, + isIntersecting: function(obj1, obj2) { + var shape1 = getBoundingBox(obj1); + var shape2 = getBoundingBox(obj2); + + var result; + if((result = shape1.isIntersecting(obj1, obj2)) !== undefined) { + return result; + } else + if((result = shape2.isIntersecting(obj1, obj2)) !== undefined) { + return result; + } else { + throw new Error("Unsupported bounding box shapes for collision detection."); + } + }, + isColliding: function(obj1, obj2) { return obj1 !== obj2 && isSetupForCollisions(obj1) && @@ -61,25 +94,75 @@ this.isIntersecting(obj1, obj2); }, - isIntersecting: function(obj1, obj2) { - var obj1BoundingBox = getBoundingBox(obj1); - var obj2BoundingBox = getBoundingBox(obj2); - - if (obj1BoundingBox === this.RECTANGLE && obj2BoundingBox === this.RECTANGLE) { - return Maths.rectanglesIntersecting(obj1, obj2); - } else if (obj1BoundingBox === this.CIRCLE && obj2BoundingBox === this.RECTANGLE) { - return Maths.circleAndRectangleIntersecting(obj1, obj2); - } else if (obj1BoundingBox === this.RECTANGLE && obj2BoundingBox === this.CIRCLE) { - return Maths.circleAndRectangleIntersecting(obj2, obj1); - } else if (obj1BoundingBox === this.CIRCLE && obj2BoundingBox === this.CIRCLE) { - return Maths.circlesIntersecting(obj1, obj2); - } else { - throw "Objects being collision tested have unsupported bounding box types." + RECTANGLE: new RectangleShape(), + CIRCLE: new CircleShape() + }; + + var getDimensions = function(entities) { + var maxx, minx, maxy, miny; + + entities.forEach(function(entity) { + if(isSetupForCollisions(entity)) { + if(maxx === undefined || entity.center.x > maxx) { + maxx = entity.center.x; + } + if(minx === undefined || entity.center.x < minx) { + minx = entity.center.x; + } + if(maxy === undefined || entity.center.y > maxy) { + maxy = entity.center.y; + } + if(miny === undefined || entity.center.y < miny) { + miny = entity.center.y; + } } - }, + }); + + var width = maxx - minx; + var height = maxy - miny; + + return {size: {x: width, y: height }, center: {x: minx + width/2, y: miny + height/2}} + }; + + var quadTreeCollisionPairs = function(entities) { + var dimensions = getDimensions(entities); + + var p1 = {x: dimensions.center.x - dimensions.size.x/2, + y: dimensions.center.y - dimensions.size.y/2}; + var p2 = {x: dimensions.center.x + dimensions.size.x/2, + y: dimensions.center.y + dimensions.size.y/2}; + + this.quadTree = new Quadtree(p1, p2, { + maxObj: Math.max(Math.round(entities.length/4), 1), + maxLevel: 5 + }); + var quadTree = this.quadTree; + entities.forEach(function(entity) { + quadTree.insert(entity); + }); + + quadTree.allCollisionPairs = allCollisionPairs.bind(this); + return quadTree.collisions(); + }; + + var allCollisionPairs = function(ent) { + var potentialCollisionPairs = []; + + // get all entity pairs to test for collision + for (var i = 0, len = ent.length; i < len; i++) { + for (var j = i + 1; j < len; j++) { + potentialCollisionPairs.push([ent[i], ent[j]]); + } + } + + var collisionPairs = []; + potentialCollisionPairs.forEach(function(pair) { + if(this.isColliding(pair[0], pair[1])) { + collisionPairs.push(pair); + } + }.bind(this)); - RECTANGLE: 0, - CIRCLE: 1 + return collisionPairs; }; var getBoundingBox = function(obj) { @@ -325,6 +408,122 @@ RADIANS_TO_DEGREES: 0.01745 }; + function Quadtree(p1, p2, settings, level) { + this.p1 = p1; + this.p2 = p2; + + var width = this.p2.x-this.p1.x; + var height = this.p2.y-this.p1.y; + + this.objects = []; + this.nodes = []; + this.rectangles = []; + this.leaf = true; + this.settings = settings; + + this.level = level || 1; + } + + Quadtree.prototype.insert = function(object) { + var x = object.center.x; + var y = object.center.y; + if(isNaN(x) || isNaN(y)) return; + + if(this.leaf) { + if(this.objects.length