Canvas Drag and Drop with Collision Detection

In this post I’ll cover the basics of implementing drag and drop with collision detection using the Canvas element and JavaScript. The end result will produce the following:

Drag and Drag shapes on the canvas

Each item on the canvas is represented by a rectangle.  When clicking inside the bounds of an rectangle the inner circle’s colour changes to grey to indicate the item is selected.  Release the button results in a mouse up event .  Between the mouse down and up events the item can be dragged and thus moved around the canvas.  When two objects collide they will be coloured red. This functionality has been implemented as follows.

Each item is represented in JavaScript as an object called ‘Thing’.  This object contain the following code:

   function Thing(x, y) {

        this.x = x;
        this.y = y;

        this.width = 60;
        this.height = 106;

        this.isSelected = false;
        this.isOverlapping = false;

        this.draw = function (ctx) {

            ctx.beginPath();
            ctx.lineWidth=3;
            ctx.strokeStyle = "red";

            ctx.moveTo(this.x, this.y + 20);
            ctx.bezierCurveTo(this.x, this.y, this.x + this.width, this.y, this.x + this.width, this.y + 20);

            ctx.moveTo(this.x, this.y + 20);
            ctx.lineTo(this.x, this.y + 90);

            ctx.bezierCurveTo(this.x, this.y + 110,this.x + 60,this.y + 110,this.x + 60,this.y + 90);

            ctx.lineTo(this.x + 60,this.y + 20);

            if (this.isOverlapping) {
                ctx.fillStyle = "rgba(255, 00, 00, 0.2)";
                ctx.fill();
            }

            ctx.stroke();

            this.drawBoundingBox(ctx);
            this.drawDragZone(ctx);
            this.drawCenter(ctx);
        };

        this.drawBoundingBox = function (ctx) {
            ctx.beginPath();
            ctx.lineWidth = 1;
            ctx.strokeStyle= "rgb(55, 55, 55)";
            ctx.rect(this.x, this.y, this.width, this.height);
            ctx.stroke();
        };

        this.getBoundingBoxPoints = function () {
            return ([{ x: this.x, y: this.y },
                     { x: this.x + this.width, y: this.y },
                     { x: this.x + this.width, y: this.y + this.height },
                     { x: this.x,  y: this.y + this.height }
                    ]);
        };

        this.drawDragZone = function (ctx) {

            var radius = this.width / 2;

            ctx.beginPath();
            ctx.lineWidth = 1;
            ctx.strokeStyle= "rgb(cc, cc, cc)";
            ctx.arc(this.getCenterX(), this.getCenterY(), radius, 0, 2 * Math.PI, false);

            if (this.isSelected) {
                ctx.fillStyle = "rgba(32, 45, 21, 0.3)";
                ctx.fill();
            }

            ctx.stroke();
        };

        this.drawCenter = function (ctx) {
            ctx.beginPath();
            ctx.lineWidth = 1;

            if (this.isSelected) {
                ctx.strokeStyle= "white";
                ctx.fillStyle = "white";
            } else {
                ctx.strokeStyle= "rgb(55, 55, 55)";
                ctx.fillStyle = "rgb(55, 55, 55)";
            }
            ctx.fillRect(this.getCenterX() - 5, this.getCenterY() - 5, 10, 10);
            ctx.stroke();
        };

        this.setCenterX = function (newX) {
            this.x = newX - (this.width / 2);
        };

        this.setCenterY = function (newY) {
            this.y = newY - (this.height / 2);
        };

        this.setX = function (newX) {
            this.x = newX;
        };

        this.setY = function (newY) {
            this.y = newY;
        };

        this.getX = function () {
            return this.x;
        };

        this.getY = function () {
            return this.y;
        };

        this.getLeft = function () {
            return this.getX();
        };

        this.getTop = function () {
            return this.getY();
        };

        this.getRight = function () {
            return this.getX() + this.width;
        };

        this.getBottom = function () {
            return this.getY() + this.height;
        };

        this.getCenterX = function () {
            return this.x + (this.width / 2);
        };

        this.getCenterY = function () {
            return this.y + (this.height / 2);
        };

        this.getRadius = function () {
            return this.width / 2;
        };

        this.setSelected = function (selected) {
            this.isSelected = selected;
        };

        this.setOverlapping = function (overlap) {
            this.isOverlapping = overlap;
        };
    }

The majority of the above is fairly self-explanatory. The object consists of properties representing the position e.g. x, y.  The  cartesian points are used within several functions to draw the rectangle and its inner shapes.

To draw a ‘Thing’ object we first need to create one. This is performed within the ‘init’ function:

    function init () {

        // resize the canvas to fill browser window dynamically
        window.addEventListener('resize', resizeCanvas, false);

        canvas = document.getElementById('canvas');
        ctx = canvas.getContext('2d');
        mouseHelper = new MouseHelper(document, canvas, collisionCheckCallback);

        //start with only the mousedown event attached
        canvas.addEventListener("mousedown", mouseHelper.mousedown);

        resizeCanvas();

        for (var spawnCounter = 0, itemsCount = 5 + generateRandom(10); spawnCounter < itemsCount; spawnCounter++) {
            renderItems.push(new Thing(generateRandom(canvas.width - 100), generateRandom(canvas.height - 100), mouseHelper));
            collisionCheckCallback();
        }

        resizeCanvas();
    }

The ‘init’ function performs the following tasks:

  • Add a listener to the windows resize event
  • Creates a handle to the canvas and its context
  • Create an instance of the mouse helper object to handle mouse events
  • Creates a random number of ‘Thing’ objects
  • Draw the items to the canvas

The first item in the above list, allows us to resize the canvas whenever the window size changes. The function which is called following a window resize event simply resizes the canvas to fill the Browser’s windows.

The second item simply uses the document object to obtain a handle to the canvas and its context. The context gives us access to the drawing features the canvas posses.

The third item creates an instance of the ‘MouseHelper’ object. This object handles the mouse down, move and up events. This consist of the following code:

    function MouseHelper(doc, canvas, collisionCheckCallback) {

        this.doc = doc;
        this.canvas = canvas;
        this.collisionCheckCallback = collisionCheckCallback;

        this.update = function (handle) {
            this.doc.body.style.cursor = handle;
        };

        this.mousedown = function (e) {

            var mouseX = e.layerX - mouseHelper.canvas.offsetLeft,
                mouseY = e.layerY - mouseHelper.canvas.offsetTop;

            for (var i = 0, l = renderItems.length; i < l; i++) {

                dx = mouseX - renderItems[i].getCenterX();
                dy = mouseY - renderItems[i].getCenterY();

                if (Math.sqrt((dx*dx) + (dy*dy)) < renderItems[i].getRadius()) {

                    dragIdx = i;
                    dragOffsetX = dx;
                    dragOffsetY = dy;
                    renderItems[dragIdx].setSelected(true);

                    canvas.addEventListener("mousemove", mouseHelper.mousemove);
                    canvas.addEventListener("mouseup", mouseHelper.mouseup);

                    drawStuff();
                    mouseHelper.update('move');

                    return;
                }
            }
        };

        this.mousemove = function (e) {

            var mouseX = e.layerX - mouseHelper.canvas.offsetLeft,
                mouseY = e.layerY - mouseHelper.canvas.offsetTop;

            renderItems[dragIdx].setCenterX(mouseX - dragOffsetX);
            renderItems[dragIdx].setCenterY(mouseY - dragOffsetY);

            drawStuff(); 
        };

        this.mouseup = function (e) {

            var mouseX = e.layerX - mouseHelper.canvas.offsetLeft,
                mouseY = e.layerY - mouseHelper.canvas.offsetTop;

            canvas.removeEventListener("mousemove", mouseHelper.mousemove);
            canvas.removeEventListener("mouseup", mouseHelper.mouseup);

            renderItems[dragIdx].setCenterX(mouseX - dragOffsetX);
            renderItems[dragIdx].setCenterY(mouseY - dragOffsetY);

            renderItems[dragIdx].setSelected(false);
            mouseHelper.update('default');

            collisionCheckCallback();
            drawStuff(); 

            dragIdx = -1;
        };

The above object handles the mouse events. The mouse down function uses basics maths to work out whether the mouse pointer is within any of the ‘Thing’ objects. When a match is found the index of the object is stored. This index is used in the mouse move event to re-position the selected object as the mouse moves. Finally the mouse up function triggers the call to the collision check callback function which is passed into the objects constructor. Each event also calls the main draw function.

The main draw function is first called as part of item four in the aforementioned list. The function performs the bulk of the work; drawing the objects in turn. The code for this is:

    function drawStuff() {

        clear();

        for (var i = 0, l = renderItems.length; i < l; i++) {
            renderItems[i].draw(ctx);
        }
    }

The above function is really short and clean. It simply iterates through each ‘Thing’ and calls its draw function. Before drawing the object it first clears the canvas by calling the function of the same name. To fully understand how this works we next need to look into the draw function of the ‘Thing’ object:


    this.draw = function (ctx) {

            ctx.beginPath();
            ctx.lineWidth=3;
            ctx.strokeStyle = "red";

            ctx.moveTo(this.x, this.y + 20);
            ctx.bezierCurveTo(this.x, this.y, this.x + this.width, this.y, this.x + this.width, this.y + 20);

            ctx.moveTo(this.x, this.y + 20);
            ctx.lineTo(this.x, this.y + 90);

            ctx.bezierCurveTo(this.x, this.y + 110,this.x + 60,this.y + 110,this.x + 60,this.y + 90);

            ctx.lineTo(this.x + 60,this.y + 20);

            if (this.isOverlapping) {
                ctx.fillStyle = "rgba(255, 00, 00, 0.2)";
                ctx.fill();
            }

            ctx.stroke();

            this.drawBoundingBox(ctx);
            this.drawDragZone(ctx);
            this.drawCenter(ctx);
        };

The above function uses various native function calls to the canvas’ context to draw the rectangle and bezier curves that make up the shape. The final part of the code we need to cover is the collision callback function:

   var collisionCheckCallback = function () {
        var intersect = new intersectHelper();

        for (var indexCounter = 0, outer = renderItems.length; indexCounter < outer; indexCounter++) {

            var item = renderItems[indexCounter];
            renderItems[indexCounter].setOverlapping(false);

            for (var renderCounter = 0, inner = renderItems.length; renderCounter < inner; renderCounter++) {

                if (indexCounter !== renderCounter) {

                    var comparitor = renderItems[renderCounter];

                    if (intersect.check(item, comparitor) === true) {
                        renderItems[renderCounter].setOverlapping(true);
                        renderItems[indexCounter].setOverlapping(true);
                    }
                }
            }
        }
    };

This function uses the ‘IntersectHelper’ object to determine whether any of the ‘Thing’ objects collide. When two objects overlap the over-lapping flag is set which is the trigger to colour the object red.

That concludes the article.  The full source code is available at my git hub account. A fully working version can be viewed here. Please feel free to use the code for any project or comment on way to improve the above.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s