Saving and loading a large HTML5 canvas with Phaser

In addition to spending a lot of time on the server-side stuff last weekend, I was able to completely finish the save/load functionality. It's actually quite a bit different from what I originally intended - and different in a good way. For one saving/loading runs completely from the client-side, which is a contributing factor in how fast it is.  

When you want to save a map in the map editor, you simply click the "Save Map" button under the "Settings" tab. You'll then start to download a JSON file to your downloads folder. The JSON file is self-containing, without external references. There's two reasons why I made it self-containing:

  1. You can load it up offline and it will still work. 
  2. If there were external references to images in the map file and those images were moved or deleted, the map file wouldn't work. 

The biggest disadvantage of not using external references is file size. If I had used external references, map files would be mere kilobytes. But because I'm storing edited areas of the canvas with the base64 encoding scheme, the files have the potential to be 10-15MB+. File size will vary greatly, in fact, and I'll explain why below. For every 1000 x 1000 square-pixel area of the map you edit with the brush tool, an additional 300KB is added to the file size. 

Because of this, I'll likely add an option to save a light-weight version of the map file, with external references to images stored on Amazon's S3. 

How it works - you can stop reading now if you have no interest in programming

In my game, there are two aspects of the HTML5 Canvas that need to be saved: game objects and the texture layer. The game objects consist of everything you can interact with in the game: units, buildings, trees, etc. In my game, those game objects are Phaser.Images and Phaser.Sprites. Saving those is easy and I won't go into detail on that here. The basic idea is that you loop through all of your game objects, store their x/y coordinates, image keys and any relevant data into the object that will go into the file the client will download.

The texture layer is the hard part. In Feudal wars, the 'texture layer' is the landscape created by the map editor's brush tool which blends textures on top of an orthogonally-tiled base texture (grass is the default). Although the tiles in the texture layer are images and bitmap data objects themselves, they differ from regular game objects because their pixels are altered by the brush tool. This is why I need to 'take a picture' of them. I can't regenerate them from their base textures because their pixels are altered from their original state.

Normally, with these kinds of games, saving and loading doesn't need to be all that sophisticated. But in my particular game, saving and loading is complicated by two factors:

  1. Browsers aren't designed to save/load content from a user's local machine. They're designed to upload/download content from a web server.
  2. My texture-placement system is completely unorthodox. In typical 2d map editors, textures are placed using a grid-based system. My texture placement system isn't constrained to a grid. The reason why they are typically constrained to a grid is because of one simple fact: if the placement of textures can be recorded with simple x/y coordinates, you don't need to save the textures in the map file. You only need to save the x/y coordinates of the textures that are placed and then you can regenerate the map procedurally from within the game. 

Problem #1 was solved easily thanks to the File API which works surprisingly well in all modern browsers. Problem #2 required a bit of thinking. It's one thing to save the visible portion of the canvas as a dataURL and write it to a file - any chump can do that. But to save the entire canvas in a manageable file size, including the invisible parts not being rendered, when it's 15,000 pixels in width/height and in webgl mode**, now that's tricky. If you were to save a 15,000-square-pixel map as a PNG for example, your file size would be in the gigabites. And that isn't even an option because, with memory constraints, browsers aren't capable of processing that much data anyway. 

My original plan was to rely on server-side processing. If I could send the canvas data piece meal to the server, I could have the server process it, compress it and send it back as a single file in a manageable format, such as a zip.

So I set about slicing up the data into small bits that could be sent and processed by the server. What I ended up figuring out was that I could slice everything in such a way that I didn't even need server-processing. Instead of taking a picture of the entire canvas and putting it into string format, I took pictures of only the parts of the canvas I couldn't procedurally regenerate. The only parts of the canvas I couldn't procedurally regenerate from the base tiles were the parts affected by the brush tool. So it was only a matter of identifying those affected parts and extracting their base64s. Here's what I did to accomplish that. 

1. Create a grid representing the entire game world. The node sizes in the grid are 1000 x 1000 pixels. Inside each node, place a Phaser.Rectangle at the appropriate coordinates. You can use a nested for loop to determine the coordinates:

function createCustomizedTilesGrid() {
    customizedTilesGrid = {
    };
    
    customizedTilesGridNodeSize = 1000;
    
    customizedTilesGrid.width = fnRound(game.world.width / customizedTilesGridNodeSize) + 1;
    customizedTilesGrid.height = fnRound(game.world.height / customizedTilesGridNodeSize) + 1;
    
    for(var i = 0; i < customizedTilesGrid.height; i++) {
        customizedTilesGrid[i] = [];
        for(var q = 0; q < customizedTilesGrid.width; q++) {
            
            var topX = (q * customizedTilesGridNodeSize);
            var topY = (i * customizedTilesGridNodeSize);
            var rect = new Phaser.Rectangle(topX, topY, customizedTilesGridNodeSize, customizedTilesGridNodeSize);
            customizedTilesGrid[i][q] = {
                'customized' : false,
                'rect' : rect,
                'debugColor' : 'blue'
            };
            
        }
        
    }
    
}

2. Whenever an area of the canvas is altered, mark it in your grid. To determine which areas are marked, I’m checking for intersections with the brush tool and the Phaser.Rectangle in my grid, then setting a property called "customized". I'm debugging by coloring in the grid nodes as red or blue, customized and not customized, respectively.

function markCustomizedNode(brushCircle) {
	
	var brushBounds = brushCircle.getBounds();
	
	if(customizedTilesGrid != undefined) {
		
		for(var i = 0; i < customizedTilesGrid.height; i++) {
			
			for(var q = 0; q < customizedTilesGrid.width; q++) {
				
				var rect = customizedTilesGrid[i][q].rect;
				
				if(Phaser.Rectangle.intersects(brushBounds, rect)) {
					customizedTilesGrid[i][q].customized = true;
					customizedTilesGrid[i][q].debugColor = 'red';
					
				}
				
			}
			
		}
		
	}
	
}

function debugMarkedNodes() {

if (customizedTilesGrid != undefined) {

            for (var i = 0; i < customizedTilesGrid.height; i++) {

                for (var q = 0; q < customizedTilesGrid.width; q++) {

                    game.debug.geom(customizedTilesGrid[i][q].rect, customizedTilesGrid[i][q].debugColor, false);
                }

            }

        }

}

3. When a user clicks "save map," extract the base 64s from the marked nodes and their x/y coordinates. To accomplish this, I’m drawing the entire texture layer into a bitmap data object using drawGroup. Then I copy the area of my texture layer (topLayerBMD) which corresponds with a marked node and extract the base64 from its canvas property:

function getCustomizedBase64s() {
    var customizedTiles = {
    };
    var topLayerBMD = game.make.bitmapData(game.world.width, game.world.height);
    
    topLayerBMD.drawGroup(topLayer); // if the map size is too big, webgl cannot do this on some browsers
    var tileNum = 0;
    for(var i = 0; i < customizedTilesGrid.height; i++) {
        
        for(var q = 0; q < customizedTilesGrid.width; q++) {
            
            if(customizedTilesGrid[i][q].customized) {
                var rect = customizedTilesGrid[i][q].rect;
                var bmd = game.make.bitmapData(rect.width, rect.height);
                
                bmd.copy(topLayerBMD, rect.x, rect.y, rect.width, rect.height, 0, 0);
                var base64 = bmd.canvas.toDataURL('image/jpeg', 0.60);
                base64 = {
                    'base64' : base64,
                    'x' : rect.x,
                    'y' : rect.y
                };
                
                customizedTiles[tileNum] = base64;
                tileNum++;
            }
            
        }
        
    }
    
    return customizedTiles;
}

You've then got all the image data you need in a nice, neatly packed array (the return value of getCustomizedBase64s)

And that's pretty much it. From here, it's just a matter of saving your base64s and x/y values, saving the data (ideally in a JSON) and reloading with the File API. I won't go over saving/loading data using the File API in this tutorial, but here's an idea of what your code should look like when adding the base64s back to the game world. "mapFileContents" is the object containing data from the loaded file. Note that I'm using jQuery here to loop through the customizedTiles object. I tend to use jQuery a lot as a utility. $.each provides a much less verbose way to loop through objects/arrays than the native javascript for loop. 

function addCustomizedTiles() {
    var customizedTiles = mapFileContents.customizedTiles;
    
    $.each(customizedTiles, function(key, object) {
            
            var tileX = object.x;
            var tileY = object.y;
            
            var image = game.add.image(tileX, tileY, 'customizedTile' + key);
            
            topLayer.addChild(image);
            
        });
}

** Normally when you attempt .toDataURL on a webgl canvas, your dataURL doesn't have anything except black pixels. To get around this, I set preserveDrawingBuffer on the canvas's webgl context. This has possible performance implications, but it will save the canvas the same way it would in a 2D context. If it is feasible, a better option would be to draw the entire canvas into a second canvas with a 2D context, then extract your strings from there. 

Tags: