Search code examples
javascripthtmlcanvascollision-detectiongame-physics

Rotating Images & Pixel Collision Detection


I've got this game in this plunker.

When the swords are not rotating, it all works fine (you can check by uncommenting lines 221 and commenting out 222-223). When they are rotating like in the plunker above, the collision doesn't work well.

I guess that's because the "getImageData" remembers the old images, but I gather it's an expensive thing to recalculate over and over again.

Is there a better way to rotate my images and make this work? Or do I have to recalculate their pixel map?

Code of the culprit:

for (var i = 0; i < monsters.length; i++) {
    var monster = monsters[i];
    if (monster.ready) {
        if (imageCompletelyOutsideCanvas(monster, monster.monsterImage)) {
            monster.remove = true;
        }
        //else {
        //ctx.drawImage(monster.monsterImage, monster.x, monster.y);
        drawRotatedImage(monster.monsterImage, monster.x, monster.y, monster);
        monster.rotateCounter += 0.05;
        //}
    }
}

Solution

  • Geometric solution

    To do this via a quicker geometry solution.

    The simplest solution is a line segment with circle intersection algorithm.

    Line segment.

    A line has a start and end described in a variety of ways. In this case we will use the start and end coordinates.

    var line = {
        x1 : ?,
        y1 : ?,
        x2 : ?,
        y2 : ?,
    }
    

    Circle

    The circle is described by its location and radius

    var circle = {
       x : ?,
       y : ?,
       r : ?,
    }
    

    Circle line segment Intersect

    The following describes how I test for the circle line segment collision. I don't know if there is a better way (most likely there is) but this has served me well and is reliable with the caveat that line segments must have length and circles must have area. If you can not guarantee this then you must add checks in the code to ensure you don't get divide by zeros.

    Thus to test if a line intercepts the circle we first find out how far the closest point on the line (Note a line is infinite in size while a line segment has a length, start and end)

    // a quick convertion of vars to make it easier to read.
    var x1 = line.x1;
    var y1 = line.y1;
    var x2 = line.x2;
    var y2 = line.y2;
    
    var cx = circle.x;
    var cy = circle.y;
    var r = circle.r;
    

    The result of the test, will be true if there is a collision.

    var result; // the result of the test
    

    Convert the line to a vector.

    var vx = x2 - x1;  // convert line to vector
    var vy = y2 - y1;
    var d2 = (vx * vx + vy * vy);  // get the length squared
    

    Get the unit distance from the circle of the near point on the line. The unit distance is a number from 0, to 1 (inclusive) and represents the distance along the vector of a point. if the value is less than 0 then the point is before the vector, if greater then 1 the point is past the end.

    I know this by memory and forget the concept. Its the dot product of the line vector and the vector from the start of the line segment to the circle center divided by the line vectors length squared.

    // dot product of two vectors is v1.x * v2.x + v1.y * v2.y over v1 length squared 
    u =  ((cx - x1) * vx + (cy - y1) * vy) / d2;
    

    Now use the unit position to get the actual coordinate of the point on the line closest to the circle by adding to the line segment start position the line vector times the unit distance.

     // get the closest point
    var  xx = x1 + vx * u;
    var  yy = y1 + vy * u;
    

    Now we have a point on the line, we calculate the distance from the circle using pythagoras square root of the sum of the two sides squared.

    // get the distance from the circle center
    var d =  Math.hypot(xx - cx, yy - cy);    
    

    Now if the line (not line segment) intersects the circle the distance will be equal or less than the circle radius. Otherwise the is no intercept.

    if(d > r){ //is the distance greater than the radius
        result = false;  // no intercept
    } else { // else we need some more calculations
    

    To determine if the line segment has intercepted the circle we need to find the two points on the circle's circumference that the line has crossed. We have the radius and the distance the circle is from the line. As the distance from the line is always at right angles we have a right triangle with the hypot being the radius and one side being the distance found.

    Work out the missing length of the triangle. UPDATE see improved version of the code from here at bottom of answer under "update" it uses unit lengths rather than normalise the line vector.

    // ld for line distance is the square root of the hyp subtract side squared
    var ld = Math.sqrt(r * r - d * d);
    

    Now add that distance to the point we found on the line xx, yy to do that normalise the line vector (makes the line vector one unit long) by dividing the line vector by its length, and then to multiply it by the distance found above

    var len = Math.sqrt(d2); // get the line vector length
    var nx = (vx / len) * ld;      
    var ny = (vy / len) * ld;      
    

    Some people may see that I could have used the Unit length and skipped a few calculations. Yes but I can be bothered rewriting the demo so will leave it as is

    Now to get the to intercept points by adding and subtracting the new vector to the point on the line that is closest to the circle

    ix1 = xx + nx; // the point furthest alone the line 
    iy1 = xx + ny;
    ix2 = xx - nx; // the point in the other direction
    iy2 = xx - ny;
    

    Now that we have these two points we can work out if they are in the line segment but calculating the unit distance they are on the original line vector, using the dot product divide the squared distance.

        var u1 =  ((ix1 - x1) * vx + (iy1 - y1) * vy) / d2;
        var u2 =  ((ix2 - x1) * vx + (iy1 - y1) * vy) / d2; 
    

    Now some simple tests to see if the unit postion of these points are on the line segment

        if(u1 < 0){  // is the forward intercept befor the line segment start
            result = false;  // no intercept            
        }else
        if(u2 > 1){ // is the rear intercept after the line end
            result = false;  // no intercept            
        } else {
            // though the line segment may not have intercepted the circle
            // circumference if we have got to here it must meet the conditions
            // of touching some part of the circle.
            result = true;
        }
    }
    

    Demo

    As always here is a demo showing the logic in action. The circle is centered on the mouse. There are a few test lines that will go red if the circle touches them. It will also show the point where the circle circumference does cross the line. The point will be red if in the line segment or green if outside. These points can be use to add effects or what not

    I am lazy today so this is straight from my library. Note I will post the improved math when I get a chance.

    Update

    I have improved the algorithm by using unit length to calculate the circle circumference intersects, eliminating a lot of code. I have added it to the demo as well.

    From the Point where the distance from the line is less than the circle radius

                // get the unit distance to the intercepts
                var ld = Math.sqrt(r * r - d * d) / Math.sqrt(d2);
    
                // get that points unit distance along the line
                var u1 =  u + ld; 
                var u2 =  u - ld; 
                if(u1 < 0){  // is the forward intercept befor the line
                    result = false;  // no intercept
                }else
                if(u2 > 1){  // is the backward intercept past the end of the line
                    result = false;  // no intercept
                }else{
                    result = true;
                }
            }
    

    var demo = function(){
        
        // the function described in the answer with extra stuff for the demo
        // at the bottom you will find the function being used to test circle intercepts.
        
        
        /** GeomDependancies.js begin **/
            
        // for speeding up calculations.
        // usage may vary from descriptions. See function for any special usage notes
        var data = {
            x:0,   // coordinate
            y:0,
            x1:0,   // 2nd coordinate if needed
            y1:0,
            u:0,   // unit length
            i:0,   // index
            d:0,   // distance
            d2:0,  // distance squared
            l:0,   // length
            nx:0,  // normal vector
            ny:0,
            result:false, // boolean result
        }
        // make sure hypot is suported
        if(typeof Math.hypot !== "function"){
            Math.hypot = function(x, y){ return Math.sqrt(x * x + y * y);};
        }
        /** GeomDependancies.js end **/
        
        /** LineSegCircleIntercept.js begin **/
        // use data properties
        // result  // intercept bool for intercept
        // x, y    // forward intercept point on line ** 
        // x1, y1  // backward intercept point on line
        // u       // unit distance of intercept mid point
        // d2      // line seg length squared
        // d       // distance of closest point on line from circle
        // i       // bit 0 on for forward intercept on segment 
        //         // bit 1 on for backward intercept
        // ** x = null id intercept points dont exist
        var lineSegCircleIntercept = function(ret, x1, y1, x2, y2, cx, cy, r){
        var vx, vy, u, u1, u2, d, ld, len, xx, yy;
            vx = x2 - x1;  // convert line to vector
            vy = y2 - y1;
            ret.d2 = (vx * vx + vy * vy);
            
            // get the unit distance of the near point on the line
            ret.u = u =  ((cx - x1) * vx + (cy - y1) * vy) / ret.d2;
            xx = x1 + vx * u; // get the closest point
            yy = y1 + vy * u;
            
            // get the distance from the circle center
            ret.d = d =  Math.hypot(xx - cx, yy - cy);    
            if(d <= r){ // line is inside circle
                // get the distance to the two intercept points
                ld = Math.sqrt(r * r - d * d) / Math.sqrt(ret.d2);
    
                // get that points unit distance along the line
                u1 =  u + ld; 
                if(u1 < 0){  // is the forward intercept befor the line
                    ret.result = false;  // no intercept
                    return ret;
                }
                u2 =  u - ld; 
                if(u2 > 1){  // is the backward intercept past the end of the line
                    ret.result = false;  // no intercept
                    return ret;
                }
                ret.i = 0;
                if(u1 <= 1){
                    ret.i += 1;
                    // get the forward point line intercepts the circle
                    ret.x = x1 + vx * u1;  
                    ret.y = y1 + vy * u1;
                }else{
                    ret.x = x2;
                    ret.y = y2;
                    
                }
                if(u2 >= 0){
                    ret.x1 = x1 + vx * u2;  
                    ret.y1 = y1 + vy * u2;
                    ret.i += 2;
                }else{
                    ret.x1 = x1;
                    ret.y1 = y1;
                }
                
                // tough the points of intercept may not be on the line seg
                // the closest point to the must be on the line segment
                ret.result = true;
                return ret;
                
            }
            ret.x = null; // flag that no intercept found at all;
            ret.result = false;  // no intercept
            return ret;
                
        }
        /** LineSegCircleIntercept.js end **/
        
    
        // mouse and canvas functions for this demo.
    
        /** fullScreenCanvas.js begin **/
        var canvas = (function(){
            var canvas = document.getElementById("canv");
            if(canvas !== null){
                document.body.removeChild(canvas);
            }
            // creates a blank image with 2d context
            canvas = document.createElement("canvas"); 
            canvas.id = "canv";    
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight; 
            canvas.style.position = "absolute";
            canvas.style.top = "0px";
            canvas.style.left = "0px";
            canvas.style.zIndex = 1000;
            canvas.ctx = canvas.getContext("2d"); 
            document.body.appendChild(canvas);
            return canvas;
        })();
        var ctx = canvas.ctx;
        
        /** fullScreenCanvas.js end **/
        /** MouseFull.js begin **/
        
        var canvasMouseCallBack = undefined;  // if needed
        var mouse = (function(){
            var mouse = {
                x : 0, y : 0, w : 0, alt : false, shift : false, ctrl : false,
                interfaceId : 0, buttonLastRaw : 0,  buttonRaw : 0,
                over : false,  // mouse is over the element
                bm : [1, 2, 4, 6, 5, 3], // masks for setting and clearing button raw bits;
                getInterfaceId : function () { return this.interfaceId++; }, // For UI functions
                startMouse:undefined,
            };
            function mouseMove(e) {
                var t = e.type, m = mouse;
                m.x = e.offsetX; m.y = e.offsetY;
                if (m.x === undefined) { m.x = e.clientX; m.y = e.clientY; }
                m.alt = e.altKey;m.shift = e.shiftKey;m.ctrl = e.ctrlKey;
                if (t === "mousedown") { m.buttonRaw |= m.bm[e.which-1];
                } else if (t === "mouseup") { m.buttonRaw &= m.bm[e.which + 2];
                } else if (t === "mouseout") { m.buttonRaw = 0; m.over = false;
                } else if (t === "mouseover") { m.over = true;
                } else if (t === "mousewheel") { m.w = e.wheelDelta;
                } else if (t === "DOMMouseScroll") { m.w = -e.detail;}
                if (canvasMouseCallBack) { canvasMouseCallBack(m.x, m.y); }
                e.preventDefault();
            }
            function startMouse(element){
                if(element === undefined){
                    element = document;
                }
                "mousemove,mousedown,mouseup,mouseout,mouseover,mousewheel,DOMMouseScroll".split(",").forEach(
                function(n){element.addEventListener(n, mouseMove);});
                element.addEventListener("contextmenu", function (e) {e.preventDefault();}, false);
            }
            mouse.mouseStart = startMouse;
            return mouse;
        })();
        if(typeof canvas === "undefined"){
            mouse.mouseStart(canvas);
        }else{
            mouse.mouseStart();
        }
        /** MouseFull.js end **/
        
        // helper function
        function drawCircle(ctx,x,y,r,col,col1,lWidth){
            if(col1){
                ctx.lineWidth = lWidth;
                ctx.strokeStyle = col1;
            }
            if(col){
                ctx.fillStyle = col;
            }
            
            ctx.beginPath();
            ctx.arc( x, y, r, 0, Math.PI*2);
            if(col){
                ctx.fill();
            }
            if(col1){
                ctx.stroke();
            }
        }
        
        // helper function
        function drawLine(ctx,x1,y1,x2,y2,col,lWidth){
            ctx.lineWidth = lWidth;
            ctx.strokeStyle = col;
            ctx.beginPath();
            ctx.moveTo(x1,y1);
            ctx.lineTo(x2,y2);
            ctx.stroke();
        }
        var h = canvas.height;
        var w = canvas.width;
        var unit = Math.ceil(Math.sqrt(Math.hypot(w, h)) / 32);
        const U80 = unit * 80;
        const U60 = unit * 60;
        const U40 = unit * 40;
        const U10 = unit * 10;
        var lines = [
            {x1 : U80, y1 : U80, x2 : w /2, y2 : h - U80},
            {x1 : w - U80, y1 : U80, x2 : w /2, y2 : h - U80},
            {x1 : w / 2 - U10, y1 : h / 2 - U40, x2 : w /2, y2 : h/2 + U10 * 2},
            {x1 : w / 2 + U10, y1 : h / 2 - U40, x2 : w /2, y2 : h/2 + U10 * 2},
        ];
        
        function update(){
            var i, l;
            ctx.clearRect(0, 0, w, h);
            
            drawCircle(ctx, mouse.x, mouse.y, U60, undefined, "black", unit * 3);
            drawCircle(ctx, mouse.x, mouse.y, U60, undefined, "yellow", unit * 2);
            for(i = 0; i < lines.length; i ++){
                l = lines[i]
                drawLine(ctx, l.x1, l.y1, l.x2, l.y2, "black" , unit * 3)
                drawLine(ctx, l.x1, l.y1, l.x2, l.y2, "yellow" , unit * 2)
                
                // test the lineSegment circle
                data = lineSegCircleIntercept(data,  l.x1, l.y1, l.x2, l.y2, mouse.x, mouse.y, U60);
                // if there is a result display the result
                if(data.result){
                    drawLine(ctx, l.x1, l.y1, l.x2, l.y2, "red" , unit * 2)
                    if((data.i & 1) === 1){
                        drawCircle(ctx, data.x, data.y, unit * 4, "white", "red", unit );
                    }else{
                        drawCircle(ctx, data.x, data.y, unit * 2, "white", "green", unit );
                    }
                    if((data.i & 2) === 2){
                        drawCircle(ctx, data.x1, data.y1, unit * 4, "white", "red", unit );
                    }else{
                        drawCircle(ctx, data.x1, data.y1, unit * 2, "white", "green", unit );
                    }
                }
            }
            requestAnimationFrame(update);
        }
        
        update();
    }
    // resize if needed by just starting again
    window.addEventListener("resize",demo);
    
    // start the demo
    demo();