How to create a bubble shooter game using html and javascript
In this post we learn about how to create a bubble shooter game using html and javascript step by step
HTML Code
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Tech World 4U</title>
<script type='text/javascript' src='bubbleshotter.js'></script>
</head>
<body style="background-color: gray;">
<canvas style="margin-left: 30%;" id="viewport" width="628" height="628"></canvas></div>
</body>
</html>
JavaScript Code
window.onload = function() {
var canvas = document.getElementById("viewport");
var context = canvas.getContext("2d");
var lastframe = 0;
var fpstime = 0;
var framecount = 0;
var fps = 0;
var initialized = false;
var level = {
x: 4, // X position
y: 83, // Y position
width: 0, // Width, gets calculated
height: 0, // Height, gets calculated
columns: 15, // Number of tile columns
rows: 14, // Number of tile rows
tilewidth: 40, // Visual width of a tile
tileheight: 40, // Visual height of a tile
rowheight: 34, // Height of a row
radius: 20, // Bubble collision radius
tiles: [] // The two-dimensional tile array
};
// Define a tile class
var Tile = function(x, y, type, shift) {
this.x = x;
this.y = y;
this.type = type;
this.removed = false;
this.shift = shift;
this.velocity = 0;
this.alpha = 1;
this.processed = false;
};
// Player
var player = {
x: 0,
y: 0,
angle: 0,
tiletype: 0,
bubble: {
x: 0,
y: 0,
angle: 0,
speed: 1000,
dropspeed: 900,
tiletype: 0,
visible: false
},
nextbubble: {
x: 0,
y: 0,
tiletype: 0
}
};
// Neighbor offset table
var neighborsoffsets = [[[1, 0], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1]], // Even row tiles
[[1, 0], [1, 1], [0, 1], [-1, 0], [0, -1], [1, -1]]]; // Odd row tiles
// Number of different colors
var bubblecolors = 7;
// Game states
var gamestates = { init: 0, ready: 1, shootbubble: 2, removecluster: 3, gameover: 4 };
var gamestate = gamestates.init;
// Score
var score = 0;
var turncounter = 0;
var rowoffset = 0;
// Animation variables
var animationstate = 0;
var animationtime = 0;
// Clusters
var showcluster = false;
var cluster = [];
var floatingclusters = [];
// Images
var images = [];
var bubbleimage;
// Image loading global variables
var loadcount = 0;
var loadtotal = 0;
var preloaded = false;
// Load images
function loadImages(imagefiles) {
// Initialize variables
loadcount = 0;
loadtotal = imagefiles.length;
preloaded = false;
// Load the images
var loadedimages = [];
for (var i=0; i<imagefiles.length; i++) {
// Create the image object
var image = new Image();
// Add onload event handler
image.onload = function () {
loadcount++;
if (loadcount == loadtotal) {
// Done loading
preloaded = true;
}
};
// Set the source url of the image
image.src = imagefiles[i];
// Save to the image array
loadedimages[i] = image;
}
// Return an array of images
return loadedimages;
}
// Initialize the game
function init() {
// Load images
images = loadImages(["bubble-sprites.png"]);
bubbleimage = images[0];
// Add mouse events
canvas.addEventListener("mousemove", onMouseMove);
canvas.addEventListener("mousedown", onMouseDown);
// Initialize the two-dimensional tile array
for (var i=0; i<level.columns; i++) {
level.tiles[i] = [];
for (var j=0; j<level.rows; j++) {
// Define a tile type and a shift parameter for animation
level.tiles[i][j] = new Tile(i, j, 0, 0);
}
}
level.width = level.columns * level.tilewidth + level.tilewidth/2;
level.height = (level.rows-1) * level.rowheight + level.tileheight;
// Init the player
player.x = level.x + level.width/2 - level.tilewidth/2;
player.y = level.y + level.height;
player.angle = 90;
player.tiletype = 0;
player.nextbubble.x = player.x - 2 * level.tilewidth;
player.nextbubble.y = player.y;
// New game
newGame();
// Enter main loop
main(0);
}
// Main loop
function main(tframe) {
// Request animation frames
window.requestAnimationFrame(main);
if (!initialized) {
// Preloader
// Clear the canvas
context.clearRect(0, 0, canvas.width, canvas.height);
// Draw the frame
drawFrame();
// Draw a progress bar
var loadpercentage = loadcount/loadtotal;
context.strokeStyle = "#ff8080";
context.lineWidth=3;
context.strokeRect(18.5, 0.5 + canvas.height - 51, canvas.width-37, 32);
context.fillStyle = "#ff8080";
context.fillRect(18.5, 0.5 + canvas.height - 51, loadpercentage*(canvas.width-37), 32);
// Draw the progress text
var loadtext = "Loaded " + loadcount + "/" + loadtotal + " images";
context.fillStyle = "#000000";
context.font = "16px Verdana";
context.fillText(loadtext, 18, 0.5 + canvas.height - 63);
if (preloaded) {
// Add a delay for demonstration purposes
setTimeout(function(){initialized = true;}, 1000);
}
} else {
// Update and render the game
update(tframe);
render();
}
}
// Update the game state
function update(tframe) {
var dt = (tframe - lastframe) / 1000;
lastframe = tframe;
// Update the fps counter
updateFps(dt);
if (gamestate == gamestates.ready) {
// Game is ready for player input
} else if (gamestate == gamestates.shootbubble) {
// Bubble is moving
stateShootBubble(dt);
} else if (gamestate == gamestates.removecluster) {
// Remove cluster and drop tiles
stateRemoveCluster(dt);
}
}
function setGameState(newgamestate) {
gamestate = newgamestate;
animationstate = 0;
animationtime = 0;
}
function stateShootBubble(dt) {
// Bubble is moving
// Move the bubble in the direction of the mouse
player.bubble.x += dt * player.bubble.speed * Math.cos(degToRad(player.bubble.angle));
player.bubble.y += dt * player.bubble.speed * -1*Math.sin(degToRad(player.bubble.angle));
// Handle left and right collisions with the level
if (player.bubble.x <= level.x) {
// Left edge
player.bubble.angle = 180 - player.bubble.angle;
player.bubble.x = level.x;
} else if (player.bubble.x + level.tilewidth >= level.x + level.width) {
// Right edge
player.bubble.angle = 180 - player.bubble.angle;
player.bubble.x = level.x + level.width - level.tilewidth;
}
// Collisions with the top of the level
if (player.bubble.y <= level.y) {
// Top collision
player.bubble.y = level.y;
snapBubble();
return;
}
// Collisions with other tiles
for (var i=0; i<level.columns; i++) {
for (var j=0; j<level.rows; j++) {
var tile = level.tiles[i][j];
// Skip empty tiles
if (tile.type < 0) {
continue;
}
// Check for intersections
var coord = getTileCoordinate(i, j);
if (circleIntersection(player.bubble.x + level.tilewidth/2,
player.bubble.y + level.tileheight/2,
level.radius,
coord.tilex + level.tilewidth/2,
coord.tiley + level.tileheight/2,
level.radius)) {
// Intersection with a level bubble
snapBubble();
return;
}
}
}
}
function stateRemoveCluster(dt) {
if (animationstate == 0) {
resetRemoved();
// Mark the tiles as removed
for (var i=0; i<cluster.length; i++) {
// Set the removed flag
cluster[i].removed = true;
}
// Add cluster score
score += cluster.length * 100;
// Find floating clusters
floatingclusters = findFloatingClusters();
if (floatingclusters.length > 0) {
// Setup drop animation
for (var i=0; i<floatingclusters.length; i++) {
for (var j=0; j<floatingclusters[i].length; j++) {
var tile = floatingclusters[i][j];
tile.shift = 0;
tile.shift = 1;
tile.velocity = player.bubble.dropspeed;
score += 100;
}
}
}
animationstate = 1;
}
if (animationstate == 1) {
// Pop bubbles
var tilesleft = false;
for (var i=0; i<cluster.length; i++) {
var tile = cluster[i];
if (tile.type >= 0) {
tilesleft = true;
// Alpha animation
tile.alpha -= dt * 15;
if (tile.alpha < 0) {
tile.alpha = 0;
}
if (tile.alpha == 0) {
tile.type = -1;
tile.alpha = 1;
}
}
}
// Drop bubbles
for (var i=0; i<floatingclusters.length; i++) {
for (var j=0; j<floatingclusters[i].length; j++) {
var tile = floatingclusters[i][j];
if (tile.type >= 0) {
tilesleft = true;
// Accelerate dropped tiles
tile.velocity += dt * 700;
tile.shift += dt * tile.velocity;
// Alpha animation
tile.alpha -= dt * 8;
if (tile.alpha < 0) {
tile.alpha = 0;
}
// Check if the bubbles are past the bottom of the level
if (tile.alpha == 0 || (tile.y * level.rowheight + tile.shift > (level.rows - 1) * level.rowheight + level.tileheight)) {
tile.type = -1;
tile.shift = 0;
tile.alpha = 1;
}
}
}
}
if (!tilesleft) {
// Next bubble
nextBubble();
// Check for game over
var tilefound = false
for (var i=0; i<level.columns; i++) {
for (var j=0; j<level.rows; j++) {
if (level.tiles[i][j].type != -1) {
tilefound = true;
break;
}
}
}
if (tilefound) {
setGameState(gamestates.ready);
} else {
// No tiles left, game over
setGameState(gamestates.gameover);
}
}
}
}
// Snap bubble to the grid
function snapBubble() {
// Get the grid position
var centerx = player.bubble.x + level.tilewidth/2;
var centery = player.bubble.y + level.tileheight/2;
var gridpos = getGridPosition(centerx, centery);
// Make sure the grid position is valid
if (gridpos.x < 0) {
gridpos.x = 0;
}
if (gridpos.x >= level.columns) {
gridpos.x = level.columns - 1;
}
if (gridpos.y < 0) {
gridpos.y = 0;
}
if (gridpos.y >= level.rows) {
gridpos.y = level.rows - 1;
}
// Check if the tile is empty
var addtile = false;
if (level.tiles[gridpos.x][gridpos.y].type != -1) {
// Tile is not empty, shift the new tile downwards
for (var newrow=gridpos.y+1; newrow<level.rows; newrow++) {
if (level.tiles[gridpos.x][newrow].type == -1) {
gridpos.y = newrow;
addtile = true;
break;
}
}
} else {
addtile = true;
}
// Add the tile to the grid
if (addtile) {
// Hide the player bubble
player.bubble.visible = false;
// Set the tile
level.tiles[gridpos.x][gridpos.y].type = player.bubble.tiletype;
// Check for game over
if (checkGameOver()) {
return;
}
// Find clusters
cluster = findCluster(gridpos.x, gridpos.y, true, true, false);
if (cluster.length >= 3) {
// Remove the cluster
setGameState(gamestates.removecluster);
return;
}
}
// No clusters found
turncounter++;
if (turncounter >= 5) {
// Add a row of bubbles
addBubbles();
turncounter = 0;
rowoffset = (rowoffset + 1) % 2;
if (checkGameOver()) {
return;
}
}
// Next bubble
nextBubble();
setGameState(gamestates.ready);
}
function checkGameOver() {
// Check for game over
for (var i=0; i<level.columns; i++) {
// Check if there are bubbles in the bottom row
if (level.tiles[i][level.rows-1].type != -1) {
// Game over
nextBubble();
setGameState(gamestates.gameover);
return true;
}
}
return false;
}
function addBubbles() {
// Move the rows downwards
for (var i=0; i<level.columns; i++) {
for (var j=0; j<level.rows-1; j++) {
level.tiles[i][level.rows-1-j].type = level.tiles[i][level.rows-1-j-1].type;
}
}
// Add a new row of bubbles at the top
for (var i=0; i<level.columns; i++) {
// Add random, existing, colors
level.tiles[i][0].type = getExistingColor();
}
}
// Find the remaining colors
function findColors() {
var foundcolors = [];
var colortable = [];
for (var i=0; i<bubblecolors; i++) {
colortable.push(false);
}
// Check all tiles
for (var i=0; i<level.columns; i++) {
for (var j=0; j<level.rows; j++) {
var tile = level.tiles[i][j];
if (tile.type >= 0) {
if (!colortable[tile.type]) {
colortable[tile.type] = true;
foundcolors.push(tile.type);
}
}
}
}
return foundcolors;
}
// Find cluster at the specified tile location
function findCluster(tx, ty, matchtype, reset, skipremoved) {
// Reset the processed flags
if (reset) {
resetProcessed();
}
// Get the target tile. Tile coord must be valid.
var targettile = level.tiles[tx][ty];
// Initialize the toprocess array with the specified tile
var toprocess = [targettile];
targettile.processed = true;
var foundcluster = [];
while (toprocess.length > 0) {
// Pop the last element from the array
var currenttile = toprocess.pop();
// Skip processed and empty tiles
if (currenttile.type == -1) {
continue;
}
// Skip tiles with the removed flag
if (skipremoved && currenttile.removed) {
continue;
}
// Check if current tile has the right type, if matchtype is true
if (!matchtype || (currenttile.type == targettile.type)) {
// Add current tile to the cluster
foundcluster.push(currenttile);
// Get the neighbors of the current tile
var neighbors = getNeighbors(currenttile);
// Check the type of each neighbor
for (var i=0; i<neighbors.length; i++) {
if (!neighbors[i].processed) {
// Add the neighbor to the toprocess array
toprocess.push(neighbors[i]);
neighbors[i].processed = true;
}
}
}
}
// Return the found cluster
return foundcluster;
}
// Find floating clusters
function findFloatingClusters() {
// Reset the processed flags
resetProcessed();
var foundclusters = [];
// Check all tiles
for (var i=0; i<level.columns; i++) {
for (var j=0; j<level.rows; j++) {
var tile = level.tiles[i][j];
if (!tile.processed) {
// Find all attached tiles
var foundcluster = findCluster(i, j, false, false, true);
// There must be a tile in the cluster
if (foundcluster.length <= 0) {
continue;
}
// Check if the cluster is floating
var floating = true;
for (var k=0; k<foundcluster.length; k++) {
if (foundcluster[k].y == 0) {
// Tile is attached to the roof
floating = false;
break;
}
}
if (floating) {
// Found a floating cluster
foundclusters.push(foundcluster);
}
}
}
}
return foundclusters;
}
// Reset the processed flags
function resetProcessed() {
for (var i=0; i<level.columns; i++) {
for (var j=0; j<level.rows; j++) {
level.tiles[i][j].processed = false;
}
}
}
// Reset the removed flags
function resetRemoved() {
for (var i=0; i<level.columns; i++) {
for (var j=0; j<level.rows; j++) {
level.tiles[i][j].removed = false;
}
}
}
// Get the neighbors of the specified tile
function getNeighbors(tile) {
var tilerow = (tile.y + rowoffset) % 2; // Even or odd row
var neighbors = [];
// Get the neighbor offsets for the specified tile
var n = neighborsoffsets[tilerow];
// Get the neighbors
for (var i=0; i<n.length; i++) {
// Neighbor coordinate
var nx = tile.x + n[i][0];
var ny = tile.y + n[i][1];
// Make sure the tile is valid
if (nx >= 0 && nx < level.columns && ny >= 0 && ny < level.rows) {
neighbors.push(level.tiles[nx][ny]);
}
}
return neighbors;
}
function updateFps(dt) {
if (fpstime > 0.25) {
// Calculate fps
fps = Math.round(framecount / fpstime);
// Reset time and framecount
fpstime = 0;
framecount = 0;
}
// Increase time and framecount
fpstime += dt;
framecount++;
}
// Draw text that is centered
function drawCenterText(text, x, y, width) {
var textdim = context.measureText(text);
context.fillText(text, x + (width-textdim.width)/2, y);
}
// Render the game
function render() {
// Draw the frame around the game
drawFrame();
var yoffset = level.tileheight/2;
// Draw level background
context.fillStyle = "#8c8c8c";
context.fillRect(level.x - 4, level.y - 4, level.width + 8, level.height + 4 - yoffset);
// Render tiles
renderTiles();
// Draw level bottom
context.fillStyle = "#656565";
context.fillRect(level.x - 4, level.y - 4 + level.height + 4 - yoffset, level.width + 8, 2*level.tileheight + 3);
// Draw score
context.fillStyle = "#ffffff";
context.font = "18px Verdana";
var scorex = level.x + level.width - 150;
var scorey = level.y+level.height + level.tileheight - yoffset - 8;
drawCenterText("Score:", scorex, scorey, 150);
context.font = "24px Verdana";
drawCenterText(score, scorex, scorey+30, 150);
// Render cluster
if (showcluster) {
renderCluster(cluster, 255, 128, 128);
for (var i=0; i<floatingclusters.length; i++) {
var col = Math.floor(100 + 100 * i / floatingclusters.length);
renderCluster(floatingclusters[i], col, col, col);
}
}
// Render player bubble
renderPlayer();
// Game Over overlay
if (gamestate == gamestates.gameover) {
context.fillStyle = "rgba(0, 0, 0, 0.8)";
context.fillRect(level.x - 4, level.y - 4, level.width + 8, level.height + 2 * level.tileheight + 8 - yoffset);
context.fillStyle = "#ffffff";
context.font = "24px Verdana";
drawCenterText("Game Over!", level.x, level.y + level.height / 2 + 10, level.width);
drawCenterText("Click to start", level.x, level.y + level.height / 2 + 40, level.width);
}
}
// Draw a frame around the game
function drawFrame() {
// Draw background
context.fillStyle = "#e8eaec";
context.fillRect(0, 0, canvas.width, canvas.height);
// Draw header
context.fillStyle = "#303030";
context.fillRect(0, 0, canvas.width, 79);
// Draw title
context.fillStyle = "#ffffff";
context.font = "24px Verdana";
context.fillText("Bubble Shooter Game - TechWorld4u09.com", 10, 37);
// Display fps
context.fillStyle = "#ffffff";
context.font = "12px Verdana";
context.fillText("Fps: " + fps, 13, 57);
}
// Render tiles
function renderTiles() {
// Top to bottom
for (var j=0; j<level.rows; j++) {
for (var i=0; i<level.columns; i++) {
// Get the tile
var tile = level.tiles[i][j];
// Get the shift of the tile for animation
var shift = tile.shift;
// Calculate the tile coordinates
var coord = getTileCoordinate(i, j);
// Check if there is a tile present
if (tile.type >= 0) {
// Support transparency
context.save();
context.globalAlpha = tile.alpha;
// Draw the tile using the color
drawBubble(coord.tilex, coord.tiley + shift, tile.type);
context.restore();
}
}
}
}
// Render cluster
function renderCluster(cluster, r, g, b) {
for (var i=0; i<cluster.length; i++) {
// Calculate the tile coordinates
var coord = getTileCoordinate(cluster[i].x, cluster[i].y);
// Draw the tile using the color
context.fillStyle = "rgb(" + r + "," + g + "," + b + ")";
context.fillRect(coord.tilex+level.tilewidth/4, coord.tiley+level.tileheight/4, level.tilewidth/2, level.tileheight/2);
}
}
// Render the player bubble
function renderPlayer() {
var centerx = player.x + level.tilewidth/2;
var centery = player.y + level.tileheight/2;
// Draw player background circle
context.fillStyle = "#7a7a7a";
context.beginPath();
context.arc(centerx, centery, level.radius+12, 0, 2*Math.PI, false);
context.fill();
context.lineWidth = 2;
context.strokeStyle = "#8c8c8c";
context.stroke();
// Draw the angle
context.lineWidth = 2;
context.strokeStyle = "#0000ff";
context.beginPath();
context.moveTo(centerx, centery);
context.lineTo(centerx + 1.5*level.tilewidth * Math.cos(degToRad(player.angle)), centery - 1.5*level.tileheight * Math.sin(degToRad(player.angle)));
context.stroke();
// Draw the next bubble
drawBubble(player.nextbubble.x, player.nextbubble.y, player.nextbubble.tiletype);
// Draw the bubble
if (player.bubble.visible) {
drawBubble(player.bubble.x, player.bubble.y, player.bubble.tiletype);
}
}
// Get the tile coordinate
function getTileCoordinate(column, row) {
var tilex = level.x + column * level.tilewidth;
// X offset for odd or even rows
if ((row + rowoffset) % 2) {
tilex += level.tilewidth/2;
}
var tiley = level.y + row * level.rowheight;
return { tilex: tilex, tiley: tiley };
}
// Get the closest grid position
function getGridPosition(x, y) {
var gridy = Math.floor((y - level.y) / level.rowheight);
// Check for offset
var xoffset = 0;
if ((gridy + rowoffset) % 2) {
xoffset = level.tilewidth / 2;
}
var gridx = Math.floor(((x - xoffset) - level.x) / level.tilewidth);
return { x: gridx, y: gridy };
}
// Draw the bubble
function drawBubble(x, y, index) {
if (index < 0 || index >= bubblecolors)
return;
// Draw the bubble sprite
context.drawImage(bubbleimage, index * 40, 0, 40, 40, x, y, level.tilewidth, level.tileheight);
}
// Start a new game
function newGame() {
// Reset score
score = 0;
turncounter = 0;
rowoffset = 0;
// Set the gamestate to ready
setGameState(gamestates.ready);
// Create the level
createLevel();
// Init the next bubble and set the current bubble
nextBubble();
nextBubble();
}
// Create a random level
function createLevel() {
// Create a level with random tiles
for (var j=0; j<level.rows; j++) {
var randomtile = randRange(0, bubblecolors-1);
var count = 0;
for (var i=0; i<level.columns; i++) {
if (count >= 2) {
// Change the random tile
var newtile = randRange(0, bubblecolors-1);
// Make sure the new tile is different from the previous tile
if (newtile == randomtile) {
newtile = (newtile + 1) % bubblecolors;
}
randomtile = newtile;
count = 0;
}
count++;
if (j < level.rows/2) {
level.tiles[i][j].type = randomtile;
} else {
level.tiles[i][j].type = -1;
}
}
}
}
// Create a random bubble for the player
function nextBubble() {
// Set the current bubble
player.tiletype = player.nextbubble.tiletype;
player.bubble.tiletype = player.nextbubble.tiletype;
player.bubble.x = player.x;
player.bubble.y = player.y;
player.bubble.visible = true;
// Get a random type from the existing colors
var nextcolor = getExistingColor();
// Set the next bubble
player.nextbubble.tiletype = nextcolor;
}
// Get a random existing color
function getExistingColor() {
existingcolors = findColors();
var bubbletype = 0;
if (existingcolors.length > 0) {
bubbletype = existingcolors[randRange(0, existingcolors.length-1)];
}
return bubbletype;
}
// Get a random int between low and high, inclusive
function randRange(low, high) {
return Math.floor(low + Math.random()*(high-low+1));
}
// Shoot the bubble
function shootBubble() {
// Shoot the bubble in the direction of the mouse
player.bubble.x = player.x;
player.bubble.y = player.y;
player.bubble.angle = player.angle;
player.bubble.tiletype = player.tiletype;
// Set the gamestate
setGameState(gamestates.shootbubble);
}
// Check if two circles intersect
function circleIntersection(x1, y1, r1, x2, y2, r2) {
// Calculate the distance between the centers
var dx = x1 - x2;
var dy = y1 - y2;
var len = Math.sqrt(dx * dx + dy * dy);
if (len < r1 + r2) {
// Circles intersect
return true;
}
return false;
}
// Convert radians to degrees
function radToDeg(angle) {
return angle * (180 / Math.PI);
}
// Convert degrees to radians
function degToRad(angle) {
return angle * (Math.PI / 180);
}
// On mouse movement
function onMouseMove(e) {
// Get the mouse position
var pos = getMousePos(canvas, e);
// Get the mouse angle
var mouseangle = radToDeg(Math.atan2((player.y+level.tileheight/2) - pos.y, pos.x - (player.x+level.tilewidth/2)));
// Convert range to 0, 360 degrees
if (mouseangle < 0) {
mouseangle = 180 + (180 + mouseangle);
}
// Restrict angle to 8, 172 degrees
var lbound = 8;
var ubound = 172;
if (mouseangle > 90 && mouseangle < 270) {
// Left
if (mouseangle > ubound) {
mouseangle = ubound;
}
} else {
// Right
if (mouseangle < lbound || mouseangle >= 270) {
mouseangle = lbound;
}
}
player.angle = mouseangle;
}
function onMouseDown(e) {
var pos = getMousePos(canvas, e);
if (gamestate == gamestates.ready) {
shootBubble();
} else if (gamestate == gamestates.gameover) {
newGame();
}
}
function getMousePos(canvas, e) {
var rect = canvas.getBoundingClientRect();
return {
x: Math.round((e.clientX - rect.left)/(rect.right - rect.left)*canvas.width),
y: Math.round((e.clientY - rect.top)/(rect.bottom - rect.top)*canvas.height)
};
}
// Call init to start the game
init();
};