Bouncy2.js

It’s the return of the “Oh wait, no way, you’re kidding, he didn’t just say what I think he did, did he?”

This is the long awaited sequel to the classic game Bouncy.js.

Improvements include now rendering all animation on the canvas, as well as refined physics and collision detection. Mobile friendly with accelerometers as the primary user input.

An instance of the game is first created with Bouncy( bouncy_ID ) and followed by calling Bouncy.initinit_config, init_ball ) to start the game.

Live Demo

To initialize the demo, click or touch the blank screen. The demo may take a few seconds to load. Control with arrow keys on a keyboard or accelerometers on a mobile device.

// Bouncy 2 : the House of Bounce, by Vlad Turda
var Bouncy = function( bouncy_div ) {

    // Bouncy : get PlayArea element : private
    var PlayArea = document.getElementById( bouncy_div );

    // Bouncy : game objects : private
    var Ball = new this.BouncyBall();
    var VP = new this.ViewPort();
    var Level = new this.Map();

    // Bouncy : game properties : private
    var animation_frame;
    var wake_lock;

    var config = {
        map_name: 'map',
        fps: 60,
        gravity: { x: 0, y: 1 },
        sweep_degrees: 5,
        haptics: false
    };

    //+ Bouncy : initialization method : public
    this.init = function( init_config, init_ball ) {
        // Initialize config object
        config.map_name = init_config.map_name || config.map_name;
        config.fps = init_config.fps || config.fps;
        config.gravity = init_config.gravity || config.gravity;
        config.sweep_degrees = init_config.sweep_degrees || config.sweep_degrees;
        config.haptics = init_config.haptics || config.haptics;

        // Set ViewPort dimensions
        VP.setDimensions( PlayArea.clientWidth, PlayArea.clientHeight );

        // Add ViewPort canvas to PlayArea DIV
        PlayArea.appendChild( VP.getCanvas() );

        // Initialize the Ball
        Ball.pos = { x: 20, y: 20 };
        Ball.vel = { x: 0, y: 0, rot: 0 };
        Ball.acc = { x: 0, y: 0, jump: init_ball.jump_acc };
        Ball.diameter = init_ball.diameter;
        Ball.bounce = init_ball.bounce;
        Ball.friction = init_ball.friction;
        Ball.setImage( 'img/ball.png' );

        // Set some event handlers
        document.addEventListener( 'keydown', keyHandle );
        document.addEventListener( 'keyup', keyHandle );
        document.addEventListener( "fullscreenchange", fullScreenChange );
        document.addEventListener( "webkitfullscreenchange", fullScreenChange );
        document.addEventListener( "mozfullscreenchange", fullScreenChange );

        // Start animation on mouse click
        PlayArea.addEventListener( 'mousedown', requestStart );

        // Mobile event handlers
        if( /Mobi/.test( navigator.userAgent ) ) {
            config.haptics = true;
            window.addEventListener( 'devicemotion', deviceMotionHandle );
            PlayArea.addEventListener( 'touchstart', requestFullScreen );
            PlayArea.addEventListener( 'touchstart', requestWakeLock );
            PlayArea.addEventListener( 'touchstart', requestStart );
        }
    };

    //+ Bouncy : request full screen method : private 
    var requestFullScreen = function() {
        // Remove this touch listener
        PlayArea.removeEventListener( 'touchstart', requestFullScreen );
        // Request full screen from ViewPort
        VP.requestFullScreen();
        // Set landscape orientation lock if possible
        if( screen.orientation ) {
            screen.orientation.lock( 'landscape-primary' );
            window.screen.mozlockOrientation( 'landscape-primary' );
        }
    };

    //+ Bouncy : request NoSleep "wake lock" method : private
    var requestWakeLock = function() {
        // Remove this listener
        PlayArea.removeEventListener( 'touchstart', requestWakeLock );
        // Request wake lock from NoSleep.js
        wake_lock = new NoSleep();
        wake_lock.enable();
    };

    //+ Bouncy : start game method : private
    var requestStart = function() {
        // Remove this listener
        PlayArea.removeEventListener( 'mousedown', requestStart );
        PlayArea.removeEventListener( 'touchstart', requestStart );
        // Load map with startAnimation as a callback
        Level.loadMap( config.map_name, startAnimation );
    };

    //+ Bouncy : "fullScreenChange" event handler method : private
    var fullScreenChange = function() {
        // Reset the ViewPort dimensions
        VP.setDimensions( PlayArea.clientWidth, PlayArea.clientHeight );
    };

    //+ Bouncy : start animation method : private
    var startAnimation = function() {
        // Get animation frame if it doesn't already exist
        if( !animation_frame ) {
            animation_frame = requestAnimationFrame( gameLoop );
        }
    };

    //+ Bouncy : game loop method : private
    var gameLoop = function() {
        // Resolve physics and check collision
        resolvePhysics();
        checkCollision();
        // Call haptics function if enabled and there was a collision
        if( config.haptics == true && Ball.final.collision.total > 0 ) {
            haptics( Ball.final.collision.coincidence.magnitude );
        }
        // Draw ViewPort
        drawViewPort();
        // Call for next animation frame
        requestAnimationFrame( gameLoop );
    };

    //+ Bouncy : physics method : private
    var resolvePhysics = function() {
        // Add acceleration and gravity to velocity
        Ball.vel.y += Ball.acc.y + config.gravity.y;
        Ball.vel.x += Ball.acc.x + config.gravity.x;
        // Calculate velocity magnitude and angle
        Ball.vel.magnitude = calcMagnitude( Ball.vel.x, Ball.vel.y );
        Ball.vel.angle = calcAngle( Ball.vel.x, Ball.vel.y );
        // Calculate the velocity unit vector
        Ball.vel.unit_vector = {
            x: ( ( Ball.vel.magnitude != 0 ) ? Ball.vel.x / Ball.vel.magnitude : 0 ),
            y: ( ( Ball.vel.magnitude != 0 ) ? Ball.vel.y / Ball.vel.magnitude : 0 ) };
        // Calculate rotational velocity
        Ball.vel.rot = 0;
        if( Ball.vel.x != 0 ) {
            Ball.vel.rot += ( Ball.vel.x / Ball.diameter * 2 ) * config.gravity.y;
        }
        if( Ball.vel.y != 0 ) {
            Ball.vel.rot -= ( Ball.vel.y / Ball.diameter * 2 ) * config.gravity.x;
        }
        // Call Ball to redraw its identity canvas
        Ball.rotate();
    };

    //+ Bouncy : collision check method : private
    var checkCollision = function() {
        // Initialize Ball's collision data
        Ball.final = { x: Ball.pos.x, y: Ball.pos.y };
        Ball.final.collision = { x: 0, y: 0, angle: 0, total: 0 };
        Ball.final.reflection = { x: 0, y: 0 };
        Ball.final.surface_normal = { x: 0, y: 0 };

        // Calc variables
        var check_angle;
        var circle = { x: 0, y: 0 };
        var check = { x: 0, y: 0 };
        var vf_i = 0;

        // Check velocity vector for collision, one unit vector at a time
        while( vf_i <= Math.ceil( Ball.vel.magnitude ) && Ball.final.collision.total == 0 ) {
            // First advance the the sweep coordinates by 1 velocity unit
            if( vf_i > 0 ) {
                Ball.final.x += Ball.vel.unit_vector.x;
                Ball.final.y += Ball.vel.unit_vector.y;
            }

            // Sweep collision edge normal to velocity vector
            for( var degrees = -90; degrees <= 90; degrees += config.sweep_degrees ) {
                // Get relative circle coordinates
                check_angle = Ball.vel.angle + degrees;
                circle = getCircleCoordAtAngle( check_angle, Ball.diameter / 2, 1, 1 );
                // Get absolute coordinates
                check = {
                    x: Math.round( Ball.final.x + ( Ball.diameter / 2 ) + circle.x ),
                    y: Math.round( Ball.final.y + ( Ball.diameter / 2 ) + circle.y ) };
                // Check absolute coordinate in collision array for 255
                if( Level.getLayer( 'collision' ).array[check.y][check.x] == '255' ) {
                    Ball.final.collision.angle += check_angle;
                    Ball.final.collision.total++;
                }
            } // End of sweep

            // Reflect velocity vector if collisions were detected
            if( Ball.final.collision.total > 0 ) {
                // Calculate indcident reflection vector
                Ball.final.collision.angle = Ball.final.collision.angle / Ball.final.collision.total;
                Ball.final.collision.surface_normal = getCircleCoordAtAngle( Ball.final.collision.angle, Ball.diameter / 2, 1, 1 );
                Ball.final.collision.reflection = calcReflection( Ball.vel.unit_vector, Ball.final.collision.surface_normal );

                // Calculate reflected velocity vector
                Ball.vel.x = Ball.final.collision.reflection.x * Ball.vel.magnitude;
                Ball.vel.y = Ball.final.collision.reflection.y * Ball.vel.magnitude;
                Ball.final.collision.surface_normal.magnitude = calcMagnitude( Ball.final.collision.surface_normal.x, Ball.final.collision.surface_normal.y );
                Ball.vel.x -= ( ( 1 - Ball.bounce ) * Ball.vel.x ) * Math.abs( Ball.final.collision.surface_normal.x / Ball.final.collision.surface_normal.magnitude );
                Ball.vel.y -= ( ( 1 - Ball.bounce ) * Ball.vel.y ) * Math.abs( Ball.final.collision.surface_normal.y / Ball.final.collision.surface_normal.magnitude );

                // Subtract 1 velocity unit back from the current coordinates
                Ball.final.x -= Ball.vel.unit_vector.x;
                Ball.final.y -= Ball.vel.unit_vector.y;

                // Add back left over velocity as reflected vector
                if( vf_i < Ball.vel.magnitude ) {
                    Ball.vel.magnitude = calcMagnitude( Ball.vel.x, Ball.vel.y );
                    Ball.final.x += ( Ball.final.collision.reflection.x * ( Ball.vel.magnitude - vf_i ) );
                    Ball.final.y += ( Ball.final.collision.reflection.y * ( Ball.vel.magnitude - vf_i ) );
                }

                // Calculate reflection to surface coincidence vector and magnitude
                Ball.final.collision.coincidence = {
                    x: ( Ball.final.collision.surface_normal.x / Ball.final.collision.surface_normal.magnitude * Ball.vel.x ),
                    y: ( Ball.final.collision.surface_normal.y / Ball.final.collision.surface_normal.magnitude * Ball.vel.y ) };
                Ball.final.collision.coincidence.magnitude = calcMagnitude( Ball.final.collision.coincidence.x, Ball.final.collision.coincidence.y );
            }

            vf_i++; // Increment velocity factor itterator\
        } // End of velocity check, while

        // Set final positions
        Ball.pos.x = Math.round( Ball.final.x );
        Ball.pos.y = Math.round( Ball.final.y );
    };

    //+ Bouncy : haptics method : private
    var haptics = function( vib_strength ) {
        if( vib_strength > 5 ) {
            navigator.vibrate( parseInt( vib_strength * 1.5 ) );
        }
    };

    //+ Bouncy : calculate ViewPort offset method : private
    var calculateViewportOffset = function() {
        // Check left edge
        if( ( Ball.pos.x - VP.offset.x < 100 ) && ( Ball.pos.x > 100 ) ) {
            VP.offset.x = parseInt( Ball.pos.x - 100 );
        }

        // Check right edge
        if( ( Ball.pos.x + Ball.diameter - VP.offset.x > PlayArea.clientWidth - 100 )
                && ( Ball.pos.x + Ball.diameter < Level.getLayer( 'collision' ).canvas.width - 100 ) ) {
            VP.offset.x = parseInt( Ball.pos.x + Ball.diameter - ( PlayArea.clientWidth - 100 ) );
        }

        // Check top edge
        if( ( Ball.pos.y - VP.offset.y < 100 ) && ( Ball.pos.y > 100 ) ) {
            VP.offset.y = parseInt( Ball.pos.y - 100 );
        }

        // Check bottom edge
        if( ( Ball.pos.y + Ball.diameter - VP.offset.y > PlayArea.clientHeight - 100 )
                && ( Ball.pos.y + Ball.diameter < Level.getLayer( 'collision' ).canvas.height - 100 ) ) {
            VP.offset.y = parseInt( ( Ball.pos.y + Ball.diameter ) - ( PlayArea.clientHeight - 100 ) );
        }
    };

    //+ Bouncy : draw to buffer and update ViewPort method : private
    var drawViewPort = function() {
        calculateViewportOffset();
        VP.clearBuffer();
        VP.bufferImage( Level.getLayer( 'background' ).canvas, VP.offset.x / 3, 0 );
        VP.bufferImage( Level.getLayer( 'midground' ).canvas, 0, 0 );
        VP.bufferImage( Ball.getCanvas(), Ball.pos.x, Ball.pos.y );
        VP.update();
    };

    //+ Bouncy : mobile motion event handler method : private
    var deviceMotionHandle = function( e ) {
        // Calculate gravity vector
        var device_gravity = {
            x: ( e.acceleration.x - e.accelerationIncludingGravity.x ),
            y: ( e.acceleration.y - e.accelerationIncludingGravity.y ) };

        // Calculate gravity magnitude
        var device_gravity_magnitude = calcMagnitude( device_gravity.x, device_gravity.y );

        // Calculate gravity unit vector
        var device_gravity_unit_vector = {
            x: device_gravity.x / device_gravity_magnitude,
            y: device_gravity.y / device_gravity_magnitude };

        // Set global gravity vector
        config.gravity.y = -device_gravity_unit_vector.x * 1;
        config.gravity.x = -device_gravity_unit_vector.y * 1;

        // Set Ball's acceleration
        Ball.acc.x = ( Math.abs( Ball.acc.x ) < Math.abs( e.acceleration.y ) ) ? e.acceleration.y * 0.5 : 0;
        Ball.acc.y = ( Math.abs( Ball.acc.y ) < Math.abs( e.acceleration.x ) ) ? e.acceleration.x * 0.5 : 0;
    };

    //+ Bouncy : key handler for desktop method : private
    var keyHandle = function( e ) {
        switch( e.type ) {
            case 'keydown':
                if( e.keyCode == 37 ) {
                    Ball.acc.x = -1;
                }
                if( e.keyCode == 39 ) {
                    Ball.acc.x = 1;
                }
                if( e.keyCode == 38 ) {
                    Ball.vel.y -= Ball.acc.jump;
                }
                break;
            case 'keyup':
                if( e.keyCode == 37 ) {
                    Ball.acc.x = 0;
                }
                if( e.keyCode == 39 ) {
                    Ball.acc.x = 0;
                }
                break;
        }
        if( e.keyCode == 33 ) {
            Ball.diameter += 5;
        }
        if( e.keyCode == 34 ) {
            Ball.diameter -= 5;
        }
    };

    //+ Bouncy : console data log method : private
    var dataOut = function() {
        console.clear();
        console.log( 'Ball Pos X: ' + Ball.pos.x );
        console.log( 'Ball Pos Y: ' + Ball.pos.y );
        console.log( 'Ball Vel X: ' + Ball.vel.x );
        console.log( 'Ball Vel Y: ' + Ball.vel.y );
        console.log( 'Velocity Angle : ' + calcAngle( Ball.vel.x, Ball.vel.y ) );
    };
};

//+ Bouncy : prototype : Ball object
Bouncy.prototype.BouncyBall = function() {
    this.pos = { x: 0, y: 0 };
    this.vel = { x: 0, y: 0, rot: 0 };
    this.acc = { x: 0, y: 0, jump: 0 };
    this.diameter;
    this.bounce;
    this.friction;
    this.final = {
        x: this.pos.x,
        y: this.pos.y,
        collision: { x: 0, y: 0, angle: 0, total: 0 },
        reflection: { x: 0, y: 0 },
        surface_normal: { x: 0, y: 0 }
    };

    var image = new Image();
    var canvas = document.createElement( 'canvas' );
    var ctx;

    this.setImage = function( image_path ) {
        canvas.width = this.diameter;
        canvas.height = this.diameter;
        image.src = image_path;
        image.onload = function() {
            ctx = canvas.getContext( '2d' );
            ctx.drawImage( this, 0, 0, canvas.width, canvas.height );
        };
    };

    this.rotate = function() {
        if( this.vel.rot != 0 ) {
            ctx.translate( this.diameter / 2, this.diameter / 2 );
            ctx.rotate( this.vel.rot );
            ctx.translate( -this.diameter / 2, -this.diameter / 2 );
            ctx.drawImage( image, 0, 0, this.diameter, this.diameter );
        }
    };

    this.drawTo = function( target_ctx, x_offset, y_offset ) {
        target_ctx.drawImage( canvas, ( this.pos.x - x_offset ), ( this.pos.y - y_offset ), this.diameter, this.diameter );
    };

    this.getCanvas = function() {
        return canvas;
    };

    this.getCanvasCTX = function() {
        return ctx;
    };

    this.getImageData = function() {
        return ctx.getImageData( 0, 0, this.diameter, this.diameter );
    };
};

//+ Bouncy : prototype : ViewPort object
Bouncy.prototype.ViewPort = function() {
    var canvas = document.createElement( 'canvas' );
    var buffer = document.createElement( 'canvas' );
    canvas.style.width = '100%';
    canvas.style.height = '100%';

    var ctx = canvas.getContext( '2d' );
    var buffer_ctx = buffer.getContext( '2d' );

    this.offset = { x: 0, y: 0 };

    this.setDimensions = function( d_x, d_y ) {
        canvas.width = d_x;
        canvas.height = d_y;
        buffer.width = d_x;
        buffer.height = d_y;
    };
    this.clear = function() {
        ctx.clearRect( 0, 0, canvas.width, canvas.height );
    };
    this.clearBuffer = function() {
        buffer_ctx.clearRect( 0, 0, buffer.width, buffer.height );
    };
    this.whiteBuffer = function() {
        buffer_ctx.fillStyle = 'white';
        buffer_ctx.fillRect( 0, 0, buffer.width, buffer.height );
    };
    this.bufferImageData = function( image_data, x, y ) {
        buffer_ctx.putImageData( image_data, x - this.offset.x, y - this.offset.y );
    };
    this.bufferImage = function( image, x, y ) {
        buffer_ctx.drawImage( image, x - this.offset.x, y - this.offset.y );
    };
    this.update = function() {
        this.clear();
        ctx.drawImage( buffer, 0, 0 );
    }
    this.requestFullScreen = function() {
        if( canvas.requestFullscreen ) {
            canvas.requestFullscreen();
        }
        else if( canvas.msRequestFullscreen ) {
            canvas.msRequestFullscreen();
        }
        else if( canvas.mozRequestFullScreen ) {
            canvas.mozRequestFullScreen();
        }
        else if( canvas.webkitRequestFullscreen ) {
            canvas.webkitRequestFullscreen();
        }
    }

    this.getCanvas = function() {
        return canvas;
    };
};

//+ Bouncy : prototype : Map object
Bouncy.prototype.Map = function( layer_array ) {
    // These are the available layers
    var layer_list = layer_array || [ 'collision', 'background', 'mid_background', 'midground', 'mid_foreground', 'foreground' ];
    var layers = [ ];
    layers['total'] = 0;

    // We'll store the map layers into these elements
    layer_list.forEach( function( layer ) {
        layers[layer] = {
            canvas: document.createElement( 'canvas' ),
            image: new Image()
        };
    } );

    //+ Map : load map method : method, public
    this.loadMap = function( map_name, callback ) {
        layer_list.forEach( function( layer ) {
            // Successfull image load handler
            layers[layer].image.onload = function() {
                layers[ layer ].canvas.width = this.width;
                layers[ layer ].canvas.height = this.height;
                layers[ layer ].ctx = layers[ layer ].canvas.getContext( '2d' );
                layers[ layer ].ctx.drawImage( this, 0, 0 );
                // If this is the collision layer, generate imageData array
                if( layer == 'collision' ) {
                    layers[ layer ].imageData = layers[ layer ].ctx.getImageData( 0, 0, this.width, this.height );
                    layers[ layer ].array = [ ];
                    for( var i_y = 0; i_y < this.height; i_y++ ) {
                        layers[ layer ].array[i_y] = [ ];
                        for( var i_x = 0; i_x < this.width; i_x++ ) {
                            layers[ layer ].array[ i_y ][ i_x ] = layers[ layer ].imageData.data[ ( ( i_y * this.width + i_x ) * 4 ) + 3 ];
                        }
                    }
                }
                layers[ 'total' ]++;
            };

            // Unsuccessfull image load handler
            layers[ layer ].image.onerror = function() {
                layers[ layer ].canvas = undefined;
                layers[ 'total' ]++;
            };

            // Set each layer's image source
            layers[ layer ].image.src = 'maps/' + map_name + '/' + layer + '.png';
        } );

        // This function will call its self until all layers are processed
        var check_load = function() {
            if( layers[ 'total' ] == layer_list.length ) {
                callback();
            }
            else {
                requestAnimationFrame( check_load );
            }
        };

        // Call checkLoad method until all images have loaded or failed loading
        requestAnimationFrame( check_load );
    };

    //+ Map : return a single layer method : public
    this.getLayer = function( layer ) {
        return layers[layer];
    };
};