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.init( init_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];
};
};