Search code examples
javascripthtmlcanvascollisiondetection

HTML Canvas and JavaScript rotating objects with collision detection


I'm creating a game with JavaScript and HTML Canvas. It's a multiplayer 2D game with tanks that try to hit each other. The tanks can move as well as rotate. How can you figure out collision detection with rotating rectangular objects? I know, I could make them square and use circular detection, but it looks very messy when a tank runs into a wall. Thanks for all who try to help :)


Solution

  • Move hit point to local space

    First an alternative

    There are many ways you can do it. The simplest way. When you calculate the cross product between a point and a line it will be negative if the point is right of the line and positive if left. If you then do each of the four sides in turn and they are all the same sign the point must be inside.

    To get the cross product of a line and a point

    //x1,y1,x2,y2   is a line
    // px,py is a point
    // first move line and point relative to the origin
    // so that the line and point is a vector
    px -= x1;
    py -= y1;
    x2 -= x1;
    y2 -= y1;
    var cross = x2 * py - y2 * px; 
    if(cross < 0){ 
         // point left of line
    }else if(cross > 0) {
        // point right of line
    }else {
        // point on the line
    }
    

    A Quicker way.

    But that is a lot of math for each object and each bullet.

    The best way is to transform the bullet into the tanks local coordinate system then its just a simple matter of testing the bounds, left, right, top, bottom.

    To do that you need to invert the tanks transformation matrix. Unfortunately the easy way to do that is currently still behind browser flags/prefixes so you need to create and manipulate the transformations in javascript. (Should not be too long till ctx.getTransform() is implemented across the board and fill a very needed performance hole in the canvas 2d API)

    If ctx.getTransform is available

    So you have a tank at x,y and rotated r and you draw it with

    ctx.translate(x,y);
    ctx.rotate(r);
    // render the tank
    ctx.fillRect(-20,-10,40,20); // rotated about center
    

    The transform hold everything we need to do the calcs, all we need to do is invert it and then multiply the bullet with the inverted matrix

    var tankInvMatrix = ctx.getTransform().invertSelf(); // get the inverted matrix
    

    The bullet is at bx,by so create a DOMPoint

    var bullet = new DOMPoint(bx,by);
    

    Then for each tank transform the bullet with DOMMatrix.transformPoint

    var relBullet = tankInvMatrix.transformPoint(bullet); // transform the point 
                                                          // returning the bullet 
                                                          // relative to the tank
    

    Now just do the test in the tanks local coord space

    if(relBullet.x > -20 && relBullet.x < 20 && relBullet.x > -10 && relBullet.x < 10){
          /// bullet has hit the tank
    }
    

    The Javascript way

    Well until the becomes the norm you have to do it the long way. Using the same x,y,r for tank, bx,by for bullet.

    // create a vector aligned to the tanks direction
    var xdx = Math.cos(r);
    var xdy = Math.sin(r);
    
    // set the 2D API to the tank location and rotation
    ctx.setTransform(xdx,xdy,-xdy,xdx,x,y);  // create the transform for the tank
    
    // draw the tank
    ctx.fillRect(-20,-10,40,20); // rotated about center
    
    // create inverted matrix for the tank 
    // Only invert the tank matrix once per frame
    
    var d =  xdx * xdx - xdy * -xdy;
    var xIx  = xdx / d;
    var xIy  = -xdy / d;
    // I am skipping c,d of the matrix as it is perpendicular to a,b
    // thus c = -b and d = a
    var ix = (-xdy * y - xdx * x) / d;
    var iy = -(xdx * y - xdy * x) / d;
    
    // For each bullet per tank
    // multiply the bullet with the inverted tank matrix
    // bullet local x & y
    var blx = bx * xIx - by * xIy + ix;
    var bly = bx * xIy + by * xIx + iy;
    
    // and you are done.
    if(blx > -20 && blx < 20 && bly > -10 && bly < 10){
          // tank and bullet are one Kaaboommmm 
    }
    

    Test to make sure it works

    Too many negatives, xdx,xdy etc etc for me to be able to see if I got it correct (Turned out I put the wrong sign in the determinant) so here is a quick demo to show it in action and working.

    Use the mouse to move over the tank body and it will show that it is hit in red. You could extend it easily to also hit the tank moving parts. You just need the inverse transform of the turret to get the bullet in local space to do the test.

    UPDATE

    Add code to stop tank's visually popping in and out as the crossed canvas edge. This is done by subtracting an OFFSET from each tank when displayed. This offset must be factored in when doing the hit test by adding OFFSET to the test coordinates.

    const TANK_LEN = 40;
    const TANK_WIDTH = 20;
    const GUN_SIZE = 0.8; // As fraction of tank length
    // offset is to ensure tanks dont pop in and out as the cross screen edge
    const OFFSET = Math.sqrt(TANK_LEN * TANK_LEN + TANK_WIDTH * TANK_WIDTH ) + TANK_LEN * 0.8;
    // some tanks
    var tanks = {
        tanks : [], // array of tanks
        drawTank(){  // draw tank function
            this.r += this.dr;
            this.tr += this.tdr;
            if(Math.random() < 0.01){
                this.dr = Math.random() * 0.02 - 0.01;
            }
            if(Math.random() < 0.01){
                this.tdr = Math.random() * 0.02 - 0.01;
            }
            if(Math.random() < 0.01){
                this.speed = Math.random() * 2 - 0.4;
            }
            var xdx = Math.cos(this.r) * this.scale;
            var xdy = Math.sin(this.r) * this.scale;
            
            // move the tank forward
            this.x += xdx * this.speed;
            this.y += xdy * this.speed;
    
            this.x = ((this.x + canvas.width + OFFSET * 2) % (canvas.width + OFFSET * 2));
            this.y = ((this.y + canvas.height + OFFSET * 2) % (canvas.height + OFFSET * 2)) ;
    
    
            ctx.setTransform(xdx, xdy, -xdy, xdx,this.x - OFFSET, this.y - OFFSET);
            ctx.lineWidth = 2;
    
            
            ctx.beginPath();
            if(this.hit){
                ctx.fillStyle = "#F00";
                ctx.strokeStyle = "#800";
                this.hit = false;
            }else{
                ctx.fillStyle = "#0A0";
                ctx.strokeStyle = "#080";
            }
            ctx.rect(-this.w / 2, -this.h / 2, this.w, this.h);
            ctx.fill();
            ctx.stroke();
            ctx.translate(-this.w /4, 0)
            ctx.rotate(this.tr);
            ctx.fillStyle = "#6D0";
            ctx.beginPath();
            ctx.rect(-8, - 8, 16, 16);
    
            ctx.rect(this.w / 4, - 2, this.w * GUN_SIZE, 4);
            ctx.fill()
            ctx.stroke()
            // invert the tank matrix
            var d =  xdx * xdx - xdy * -xdy;
            this.invMat[0] = xdx / d;
            this.invMat[1] = -xdy / d;
            // I am skipping c,d of the matrix as it is perpendicular to a,b
            // thus c = -b and d = a
            this.invMat[2] = (-xdy * this.y - xdx * this.x) / d;
            this.invMat[3] = -(xdx * this.y - xdy * this.x) / d;        
        },
        hitTest(x,y){ // test tank against x,y
            x += OFFSET;
            y += OFFSET;
            var blx = x * this.invMat[0] - y * this.invMat[1] + this.invMat[2];
            var bly = x * this.invMat[1] + y * this.invMat[0] + this.invMat[3];
            if(blx > -this.w / 2 && blx < this.w / 2 && bly > -this.h / 2 && bly < this.h / 2){
                this.hit = true;
            }        
        },
        eachT(callback){ // iterator
            for(var i = 0; i < this.tanks.length; i ++){ callback(this.tanks[i],i); }
        },
        addTank(x,y,r){  // guess what this does????
            this.tanks.push({
                x,y,r,
                scale: 1,
                dr : 0,  // turn rate
                tr : 0,  // gun direction
                tdr : 0, // gun turn rate
                speed : 0, // speed
                w : TANK_LEN,
                h : TANK_WIDTH,
                invMat : [0,0,0,0],
                hit : false,
                hitTest : this.hitTest,
                draw : this.drawTank,
            })
        },
        drawTanks(){ this.eachT(tank => tank.draw()); },
        testHit(x,y){ // test if point x,y has hit a tank
            this.eachT(tank => tank.hitTest(x,y));
        }
    }
    
    
    // this function is called from a requestAnimationFrame call back
    function display() { 
        if(tanks.tanks.length === 0){
            // create some random tanks
            for(var i = 0; i < 100; i ++){
                tanks.addTank(
                    Math.random() * canvas.width,
                    Math.random() * canvas.height,
                    Math.random() * Math.PI * 2
                );
            }
        }
        
        ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform
        ctx.globalAlpha = 1; // reset alpha
        ctx.clearRect(0, 0, w, h);
        
        // draw the mouse
        ctx.fillStyle = "red";
        ctx.strokeStyle = "#F80";
        ctx.beginPath();
        ctx.arc(mouse.x,mouse.y,3,0,Math.PI * 2);
        ctx.fill();
        ctx.stroke();
    
    
        // draw the tanks    
        tanks.drawTanks();
        // test for a hit (Note there should be a update, then test hit, then draw as is the tank is hit visually one frame late)
        tanks.testHit(mouse.x,mouse.y);
    }
    
    
    
    //====================================================================================================
    // Boilerplate code not part of answer ignore all code from here down
    //====================================================================================================
    
    var w, h, cw, ch, canvas, ctx, mouse, globalTime = 0, firstRun = true;
    ;(function(){
        const RESIZE_DEBOUNCE_TIME = 100;
        var  createCanvas, resizeCanvas, setGlobals, resizeCount = 0;
        createCanvas = function () {
            var c,cs;
            cs = (c = document.createElement("canvas")).style;
            cs.position = "absolute";
            cs.top = cs.left = "0px";
            cs.zIndex = 1000;
            document.body.appendChild(c);
            return c;
        }
        resizeCanvas = function () {
            if (canvas === undefined) {
                canvas = createCanvas();
            }
            canvas.width = innerWidth;
            canvas.height = innerHeight;
            ctx = canvas.getContext("2d");
            if (typeof setGlobals === "function") {
                setGlobals();
            }
            if (typeof onResize === "function") {
                if(firstRun){
                    onResize();
                    firstRun = false;
                }else{
                    resizeCount += 1;
                    setTimeout(debounceResize, RESIZE_DEBOUNCE_TIME);
                }
            }
        }
        function debounceResize() {
            resizeCount -= 1;
            if (resizeCount <= 0) {
                onResize();
            }
        }
        setGlobals = function () {
            cw = (w = canvas.width) / 2;
            ch = (h = canvas.height) / 2;
        }
        mouse = (function () {
            function preventDefault(e) {
                e.preventDefault();
            }
            var mouse = {
                x : 0,y : 0,w : 0,
                alt : false,
                shift : false,
                ctrl : false,
                buttonRaw : 0,
                over : false,
                bm : [1, 2, 4, 6, 5, 3],
                active : false,
                bounds : null,
                crashRecover : null,
                mouseEvents : "mousemove,mousedown,mouseup,mouseout,mouseover,mousewheel,DOMMouseScroll".split(",")
            };
            var m = mouse;
            function mouseMove(e) {
                var t = e.type;
                m.bounds = m.element.getBoundingClientRect();
                m.x = e.pageX - m.bounds.left + scrollX;
                m.y = e.pageY - m.bounds.top + scrollY;
                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 (m.callbacks) {
                    m.callbacks.forEach(c => c(e));
                }
                if ((m.buttonRaw & 2) && m.crashRecover !== null) {
                    if (typeof m.crashRecover === "function") {
                        setTimeout(m.crashRecover, 0);
                    }
                }
                e.preventDefault();
            }
            m.addCallback = function (callback) {
                if (typeof callback === "function") {
                    if (m.callbacks === undefined) {
                        m.callbacks = [callback];
                    } else {
                        m.callbacks.push(callback);
                    }
                }
            }
            m.start = function (element) {
                if (m.element !== undefined) {
                    m.removeMouse();
                }
                m.element = element === undefined ? document : element;
                m.mouseEvents.forEach(n => {
                    m.element.addEventListener(n, mouseMove);
                });
                m.element.addEventListener("contextmenu", preventDefault, false);
                m.active = true;
            }
            m.remove = function () {
                if (m.element !== undefined) {
                    m.mouseEvents.forEach(n => {
                        m.element.removeEventListener(n, mouseMove);
                    });
                    m.element.removeEventListener("contextmenu", preventDefault);
                    m.element = m.callbacks = undefined;
                    m.active = false;
                }
            }
            return mouse;
        })();
        // Clean up. Used where the IDE is on the same page.
        var done = function () {
            removeEventListener("resize", resizeCanvas)
            mouse && mouse.remove();
            document.body.removeChild(canvas);
            canvas = ctx = mouse = undefined;
        }
        function update(timer) { // Main update loop
            if(ctx === undefined){
                return;
            }
            globalTime = timer;
            display(); // call demo code
            requestAnimationFrame(update);
        }
        setTimeout(function(){
            resizeCanvas();
            mouse.start(canvas, true);
            mouse.crashRecover = done;
            addEventListener("resize", resizeCanvas);
            requestAnimationFrame(update);
        },0);
    })();