├── screenshot.png ├── bubble-sprites.png ├── bubble-shooter.html ├── README.md ├── LICENSE └── bubble-shooter-example.js /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rembound/Bubble-Shooter-HTML5/HEAD/screenshot.png -------------------------------------------------------------------------------- /bubble-sprites.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rembound/Bubble-Shooter-HTML5/HEAD/bubble-sprites.png -------------------------------------------------------------------------------- /bubble-shooter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Rembound.com Example 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bubble Shooter HTML5 2 | A Bubble Shooter game like Bust-A-Move or Puzzle Bobble with HTML5 Canvas and JavaScript. 3 | 4 | This is a code example that belongs to the article: [Bubble Shooter Game Tutorial With HTML5 And JavaScript](http://rembound.com/articles/bubble-shooter-game-tutorial-with-html5-and-javascript) 5 | 6 | [![Bubble Shooter Game Tutorial With HTML5 And JavaScript](screenshot.png?raw=true)](http://rembound.com/articles/bubble-shooter-game-tutorial-with-html5-and-javascript) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Rembound.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /bubble-shooter-example.js: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------------ 2 | // Bubble Shooter Game Tutorial With HTML5 And JavaScript 3 | // Copyright (c) 2015 Rembound.com 4 | // 5 | // This program is free software: you can redistribute it and/or modify 6 | // it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or 8 | // (at your option) any later version. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see http://www.gnu.org/licenses/. 17 | // 18 | // http://rembound.com/articles/bubble-shooter-game-tutorial-with-html5-and-javascript 19 | // ------------------------------------------------------------------------ 20 | 21 | // The function gets called when the window is fully loaded 22 | window.onload = function() { 23 | // Get the canvas and context 24 | var canvas = document.getElementById("viewport"); 25 | var context = canvas.getContext("2d"); 26 | 27 | // Timing and frames per second 28 | var lastframe = 0; 29 | var fpstime = 0; 30 | var framecount = 0; 31 | var fps = 0; 32 | 33 | var initialized = false; 34 | 35 | // Level 36 | var level = { 37 | x: 4, // X position 38 | y: 83, // Y position 39 | width: 0, // Width, gets calculated 40 | height: 0, // Height, gets calculated 41 | columns: 15, // Number of tile columns 42 | rows: 14, // Number of tile rows 43 | tilewidth: 40, // Visual width of a tile 44 | tileheight: 40, // Visual height of a tile 45 | rowheight: 34, // Height of a row 46 | radius: 20, // Bubble collision radius 47 | tiles: [] // The two-dimensional tile array 48 | }; 49 | 50 | // Define a tile class 51 | var Tile = function(x, y, type, shift) { 52 | this.x = x; 53 | this.y = y; 54 | this.type = type; 55 | this.removed = false; 56 | this.shift = shift; 57 | this.velocity = 0; 58 | this.alpha = 1; 59 | this.processed = false; 60 | }; 61 | 62 | // Player 63 | var player = { 64 | x: 0, 65 | y: 0, 66 | angle: 0, 67 | tiletype: 0, 68 | bubble: { 69 | x: 0, 70 | y: 0, 71 | angle: 0, 72 | speed: 1000, 73 | dropspeed: 900, 74 | tiletype: 0, 75 | visible: false 76 | }, 77 | nextbubble: { 78 | x: 0, 79 | y: 0, 80 | tiletype: 0 81 | } 82 | }; 83 | 84 | // Neighbor offset table 85 | var neighborsoffsets = [[[1, 0], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1]], // Even row tiles 86 | [[1, 0], [1, 1], [0, 1], [-1, 0], [0, -1], [1, -1]]]; // Odd row tiles 87 | 88 | // Number of different colors 89 | var bubblecolors = 7; 90 | 91 | // Game states 92 | var gamestates = { init: 0, ready: 1, shootbubble: 2, removecluster: 3, gameover: 4 }; 93 | var gamestate = gamestates.init; 94 | 95 | // Score 96 | var score = 0; 97 | 98 | var turncounter = 0; 99 | var rowoffset = 0; 100 | 101 | // Animation variables 102 | var animationstate = 0; 103 | var animationtime = 0; 104 | 105 | // Clusters 106 | var showcluster = false; 107 | var cluster = []; 108 | var floatingclusters = []; 109 | 110 | // Images 111 | var images = []; 112 | var bubbleimage; 113 | 114 | // Image loading global variables 115 | var loadcount = 0; 116 | var loadtotal = 0; 117 | var preloaded = false; 118 | 119 | // Load images 120 | function loadImages(imagefiles) { 121 | // Initialize variables 122 | loadcount = 0; 123 | loadtotal = imagefiles.length; 124 | preloaded = false; 125 | 126 | // Load the images 127 | var loadedimages = []; 128 | for (var i=0; i= level.x + level.width) { 268 | // Right edge 269 | player.bubble.angle = 180 - player.bubble.angle; 270 | player.bubble.x = level.x + level.width - level.tilewidth; 271 | } 272 | 273 | // Collisions with the top of the level 274 | if (player.bubble.y <= level.y) { 275 | // Top collision 276 | player.bubble.y = level.y; 277 | snapBubble(); 278 | return; 279 | } 280 | 281 | // Collisions with other tiles 282 | for (var i=0; i 0) { 325 | // Setup drop animation 326 | for (var i=0; i= 0) { 348 | tilesleft = true; 349 | 350 | // Alpha animation 351 | tile.alpha -= dt * 15; 352 | if (tile.alpha < 0) { 353 | tile.alpha = 0; 354 | } 355 | 356 | if (tile.alpha == 0) { 357 | tile.type = -1; 358 | tile.alpha = 1; 359 | } 360 | } 361 | } 362 | 363 | // Drop bubbles 364 | for (var i=0; i= 0) { 369 | tilesleft = true; 370 | 371 | // Accelerate dropped tiles 372 | tile.velocity += dt * 700; 373 | tile.shift += dt * tile.velocity; 374 | 375 | // Alpha animation 376 | tile.alpha -= dt * 8; 377 | if (tile.alpha < 0) { 378 | tile.alpha = 0; 379 | } 380 | 381 | // Check if the bubbles are past the bottom of the level 382 | if (tile.alpha == 0 || (tile.y * level.rowheight + tile.shift > (level.rows - 1) * level.rowheight + level.tileheight)) { 383 | tile.type = -1; 384 | tile.shift = 0; 385 | tile.alpha = 1; 386 | } 387 | } 388 | 389 | } 390 | } 391 | 392 | if (!tilesleft) { 393 | // Next bubble 394 | nextBubble(); 395 | 396 | // Check for game over 397 | var tilefound = false 398 | for (var i=0; i= level.columns) { 430 | gridpos.x = level.columns - 1; 431 | } 432 | 433 | if (gridpos.y < 0) { 434 | gridpos.y = 0; 435 | } 436 | 437 | if (gridpos.y >= level.rows) { 438 | gridpos.y = level.rows - 1; 439 | } 440 | 441 | // Check if the tile is empty 442 | var addtile = false; 443 | if (level.tiles[gridpos.x][gridpos.y].type != -1) { 444 | // Tile is not empty, shift the new tile downwards 445 | for (var newrow=gridpos.y+1; newrow= 3) { 473 | // Remove the cluster 474 | setGameState(gamestates.removecluster); 475 | return; 476 | } 477 | } 478 | 479 | // No clusters found 480 | turncounter++; 481 | if (turncounter >= 5) { 482 | // Add a row of bubbles 483 | addBubbles(); 484 | turncounter = 0; 485 | rowoffset = (rowoffset + 1) % 2; 486 | 487 | if (checkGameOver()) { 488 | return; 489 | } 490 | } 491 | 492 | // Next bubble 493 | nextBubble(); 494 | setGameState(gamestates.ready); 495 | } 496 | 497 | function checkGameOver() { 498 | // Check for game over 499 | for (var i=0; i= 0) { 540 | if (!colortable[tile.type]) { 541 | colortable[tile.type] = true; 542 | foundcolors.push(tile.type); 543 | } 544 | } 545 | } 546 | } 547 | 548 | return foundcolors; 549 | } 550 | 551 | // Find cluster at the specified tile location 552 | function findCluster(tx, ty, matchtype, reset, skipremoved) { 553 | // Reset the processed flags 554 | if (reset) { 555 | resetProcessed(); 556 | } 557 | 558 | // Get the target tile. Tile coord must be valid. 559 | var targettile = level.tiles[tx][ty]; 560 | 561 | // Initialize the toprocess array with the specified tile 562 | var toprocess = [targettile]; 563 | targettile.processed = true; 564 | var foundcluster = []; 565 | 566 | while (toprocess.length > 0) { 567 | // Pop the last element from the array 568 | var currenttile = toprocess.pop(); 569 | 570 | // Skip processed and empty tiles 571 | if (currenttile.type == -1) { 572 | continue; 573 | } 574 | 575 | // Skip tiles with the removed flag 576 | if (skipremoved && currenttile.removed) { 577 | continue; 578 | } 579 | 580 | // Check if current tile has the right type, if matchtype is true 581 | if (!matchtype || (currenttile.type == targettile.type)) { 582 | // Add current tile to the cluster 583 | foundcluster.push(currenttile); 584 | 585 | // Get the neighbors of the current tile 586 | var neighbors = getNeighbors(currenttile); 587 | 588 | // Check the type of each neighbor 589 | for (var i=0; i= 0 && nx < level.columns && ny >= 0 && ny < level.rows) { 678 | neighbors.push(level.tiles[nx][ny]); 679 | } 680 | } 681 | 682 | return neighbors; 683 | } 684 | 685 | function updateFps(dt) { 686 | if (fpstime > 0.25) { 687 | // Calculate fps 688 | fps = Math.round(framecount / fpstime); 689 | 690 | // Reset time and framecount 691 | fpstime = 0; 692 | framecount = 0; 693 | } 694 | 695 | // Increase time and framecount 696 | fpstime += dt; 697 | framecount++; 698 | } 699 | 700 | // Draw text that is centered 701 | function drawCenterText(text, x, y, width) { 702 | var textdim = context.measureText(text); 703 | context.fillText(text, x + (width-textdim.width)/2, y); 704 | } 705 | 706 | // Render the game 707 | function render() { 708 | // Draw the frame around the game 709 | drawFrame(); 710 | 711 | var yoffset = level.tileheight/2; 712 | 713 | // Draw level background 714 | context.fillStyle = "#8c8c8c"; 715 | context.fillRect(level.x - 4, level.y - 4, level.width + 8, level.height + 4 - yoffset); 716 | 717 | // Render tiles 718 | renderTiles(); 719 | 720 | // Draw level bottom 721 | context.fillStyle = "#656565"; 722 | context.fillRect(level.x - 4, level.y - 4 + level.height + 4 - yoffset, level.width + 8, 2*level.tileheight + 3); 723 | 724 | // Draw score 725 | context.fillStyle = "#ffffff"; 726 | context.font = "18px Verdana"; 727 | var scorex = level.x + level.width - 150; 728 | var scorey = level.y+level.height + level.tileheight - yoffset - 8; 729 | drawCenterText("Score:", scorex, scorey, 150); 730 | context.font = "24px Verdana"; 731 | drawCenterText(score, scorex, scorey+30, 150); 732 | 733 | // Render cluster 734 | if (showcluster) { 735 | renderCluster(cluster, 255, 128, 128); 736 | 737 | for (var i=0; i= 0) { 796 | // Support transparency 797 | context.save(); 798 | context.globalAlpha = tile.alpha; 799 | 800 | // Draw the tile using the color 801 | drawBubble(coord.tilex, coord.tiley + shift, tile.type); 802 | 803 | context.restore(); 804 | } 805 | } 806 | } 807 | } 808 | 809 | // Render cluster 810 | function renderCluster(cluster, r, g, b) { 811 | for (var i=0; i= bubblecolors) 884 | return; 885 | 886 | // Draw the bubble sprite 887 | context.drawImage(bubbleimage, index * 40, 0, 40, 40, x, y, level.tilewidth, level.tileheight); 888 | } 889 | 890 | // Start a new game 891 | function newGame() { 892 | // Reset score 893 | score = 0; 894 | 895 | turncounter = 0; 896 | rowoffset = 0; 897 | 898 | // Set the gamestate to ready 899 | setGameState(gamestates.ready); 900 | 901 | // Create the level 902 | createLevel(); 903 | 904 | // Init the next bubble and set the current bubble 905 | nextBubble(); 906 | nextBubble(); 907 | } 908 | 909 | // Create a random level 910 | function createLevel() { 911 | // Create a level with random tiles 912 | for (var j=0; j= 2) { 917 | // Change the random tile 918 | var newtile = randRange(0, bubblecolors-1); 919 | 920 | // Make sure the new tile is different from the previous tile 921 | if (newtile == randomtile) { 922 | newtile = (newtile + 1) % bubblecolors; 923 | } 924 | randomtile = newtile; 925 | count = 0; 926 | } 927 | count++; 928 | 929 | if (j < level.rows/2) { 930 | level.tiles[i][j].type = randomtile; 931 | } else { 932 | level.tiles[i][j].type = -1; 933 | } 934 | } 935 | } 936 | } 937 | 938 | // Create a random bubble for the player 939 | function nextBubble() { 940 | // Set the current bubble 941 | player.tiletype = player.nextbubble.tiletype; 942 | player.bubble.tiletype = player.nextbubble.tiletype; 943 | player.bubble.x = player.x; 944 | player.bubble.y = player.y; 945 | player.bubble.visible = true; 946 | 947 | // Get a random type from the existing colors 948 | var nextcolor = getExistingColor(); 949 | 950 | // Set the next bubble 951 | player.nextbubble.tiletype = nextcolor; 952 | } 953 | 954 | // Get a random existing color 955 | function getExistingColor() { 956 | existingcolors = findColors(); 957 | 958 | var bubbletype = 0; 959 | if (existingcolors.length > 0) { 960 | bubbletype = existingcolors[randRange(0, existingcolors.length-1)]; 961 | } 962 | 963 | return bubbletype; 964 | } 965 | 966 | // Get a random int between low and high, inclusive 967 | function randRange(low, high) { 968 | return Math.floor(low + Math.random()*(high-low+1)); 969 | } 970 | 971 | // Shoot the bubble 972 | function shootBubble() { 973 | // Shoot the bubble in the direction of the mouse 974 | player.bubble.x = player.x; 975 | player.bubble.y = player.y; 976 | player.bubble.angle = player.angle; 977 | player.bubble.tiletype = player.tiletype; 978 | 979 | // Set the gamestate 980 | setGameState(gamestates.shootbubble); 981 | } 982 | 983 | // Check if two circles intersect 984 | function circleIntersection(x1, y1, r1, x2, y2, r2) { 985 | // Calculate the distance between the centers 986 | var dx = x1 - x2; 987 | var dy = y1 - y2; 988 | var len = Math.sqrt(dx * dx + dy * dy); 989 | 990 | if (len < r1 + r2) { 991 | // Circles intersect 992 | return true; 993 | } 994 | 995 | return false; 996 | } 997 | 998 | // Convert radians to degrees 999 | function radToDeg(angle) { 1000 | return angle * (180 / Math.PI); 1001 | } 1002 | 1003 | // Convert degrees to radians 1004 | function degToRad(angle) { 1005 | return angle * (Math.PI / 180); 1006 | } 1007 | 1008 | // On mouse movement 1009 | function onMouseMove(e) { 1010 | // Get the mouse position 1011 | var pos = getMousePos(canvas, e); 1012 | 1013 | // Get the mouse angle 1014 | var mouseangle = radToDeg(Math.atan2((player.y+level.tileheight/2) - pos.y, pos.x - (player.x+level.tilewidth/2))); 1015 | 1016 | // Convert range to 0, 360 degrees 1017 | if (mouseangle < 0) { 1018 | mouseangle = 180 + (180 + mouseangle); 1019 | } 1020 | 1021 | // Restrict angle to 8, 172 degrees 1022 | var lbound = 8; 1023 | var ubound = 172; 1024 | if (mouseangle > 90 && mouseangle < 270) { 1025 | // Left 1026 | if (mouseangle > ubound) { 1027 | mouseangle = ubound; 1028 | } 1029 | } else { 1030 | // Right 1031 | if (mouseangle < lbound || mouseangle >= 270) { 1032 | mouseangle = lbound; 1033 | } 1034 | } 1035 | 1036 | // Set the player angle 1037 | player.angle = mouseangle; 1038 | } 1039 | 1040 | // On mouse button click 1041 | function onMouseDown(e) { 1042 | // Get the mouse position 1043 | var pos = getMousePos(canvas, e); 1044 | 1045 | if (gamestate == gamestates.ready) { 1046 | shootBubble(); 1047 | } else if (gamestate == gamestates.gameover) { 1048 | newGame(); 1049 | } 1050 | } 1051 | 1052 | // Get the mouse position 1053 | function getMousePos(canvas, e) { 1054 | var rect = canvas.getBoundingClientRect(); 1055 | return { 1056 | x: Math.round((e.clientX - rect.left)/(rect.right - rect.left)*canvas.width), 1057 | y: Math.round((e.clientY - rect.top)/(rect.bottom - rect.top)*canvas.height) 1058 | }; 1059 | } 1060 | 1061 | // Call init to start the game 1062 | init(); 1063 | }; --------------------------------------------------------------------------------