A couple days ago I posted a little level editor that I made using Javascript and the <canvas> tag. Today I’m going to discuss how I did that. I’m basically discussing my learning experience here. I’m not really sure who is going to find this useful, to be honest, but I’m going for it anyway.

So first we obviously have to set up our canvas. I decided to make mine 256x256 pixels, and divide it into eight tiles per side. This makes the tiles 32x32 pixels.

<canvas width="256" height="256" id="can">you don't support canvas</canvas>

This creates a blank 256x256 canvas with a transparent background whose ID is “can.” The text inside the tag is what shows up to people whose browsers don’t support the tag.

Next, I drew my 32x32 tiles. It’s not really important what to do here, except make sure they actually tile. Here are my tiles for reference:

sky dirt grass flag spike

I named these by ascending number — the sky is named 0.png, the dirt is 1.png, etc.

Next, let’s make it so that the canvas can draw the level on it. Let’s name the function that redraws it draw(). That seems reasonable.

var imgs = [];
var level = [];
function draw() {
    var canvas = document.getElementById('can');
    if (canvas.getContext) {
        var ctx = canvas.getContext("2d");
        for (i = 0; i < 8; i++) {
            imgs[i] = [];
            for (j = 0; j < 8; j++) {
                imgs[i][j] = new Image();
                imgs[i][j].src = '/path/to/images/' + level[i][j] + '.png';
                ctx.drawImage(imgs[i][j], j*32, i*32);
            }
        }
    }
}

Let’s unpack this. The first line of interest is the fourth line. This should be familiar to people who know Javascript — we’re just using the document.getElementById function to get the canvas tag as an object in our script.

However, this object only represents the <canvas> tag; it doesn’t actually let you do stuff to the canvas. In order to do useful things like draw on the canvas, we need to get its “context.” We do this by the canvas.getContext function. Note that we are getting the “2d” context. I have no idea if there is a 3d context or other types of context. I’m playing this by ear here.

Then we fill up the imgs array with a bunch of objects representing our tiles. We construct an image by creating new Image() and then changing its src attribute to the location of the image. Then, we draw the image with the ctx.drawImage(image, x, y) function. (Note how we draw at the coordinates (j*32, i*32), not the other way around.)

Now, if you actually run the snippet above, you’ll notice it won’t actually work. We need to actually define the level first, or else it will fail when it tries to set imgs[i][j].src and looks for level[i][j]. So change the second line to this:

var level = [[0,0,0,0,0,0,0,0],
             [0,0,0,0,0,0,0,0],
             [0,3,0,0,0,0,3,0],
             [2,2,2,0,0,2,2,2],
             [1,1,1,0,0,1,1,1],
             [1,1,1,4,4,1,1,1],
             [1,1,1,1,1,1,1,1],
             [1,1,1,1,1,1,1,1]];

Feel free to alter this line, obviously, because your tile numbering might be different, or you might want to put a different default level. I don’t know your life.

Now we just need to call draw() somewhere at the beginning. I am using jQuery here, so I did it that way. You can do this in pure JS with document.onload = function() { ... }. I added this to the beginning of my script:

$(function() {
    draw();
});

If you did everything right, it should draw your default level in the canvas, wherever you put it. Here’s what it looks like for me:

you don’t support canvas, why are you viewing this article?

Well, this is all fine and dandy, but we are building a level editor, not a level displayer! We need some way of making the level change when we click on it.

Apparently, detecting where the user clicked on an element is a complicated process with many different ways of doing it. The best way I found appears to be this snippet, courtesy of patriques on StackOverflow:

function(event) {
    var rect = canvas.getBoundingClientRect();
    var x = event.clientX - rect.left;
    var y = event.clientY - rect.top;
}

We then need to convert these coordinates into tile coordinates. These would be (Math.floor(x/32), Math.floor(y/32)), since our tiles are 32 pixels wide.

We can add the click function as an event listener on the canvas element, so whenever it is clicked the function gets called. Let’s change the document-loading section to this:

$(function() {
    draw();
    var canvas = document.getElementById('can');
    canvas.addEventListener('click', function(event) {
        var rect = canvas.getBoundingClientRect();
        var x = event.clientX - rect.left;
        var y = event.clientY - rect.top;
        level[Math.floor(y/32)][Math.floor(x/32)] = 0;
        draw();
    });
});

Currently, it just changes the clicked tile to ID 0, because it doesn’t know what tile is selected yet. Then it calls draw() again, so that the canvas will redraw itself to show your changes.

Changing everything to ID 0 is pretty boring, but fortunately it’s not too hard to let the user select their own tile to place. Let’s make a global variable called, oh, I don’t know, tile_to_place which represents the tile to place.

var tile_to_place = 0;
$(function() {
    draw();
    var canvas = document.getElementById('can');
    canvas.addEventListener('click', function(event) {
        var rect = canvas.getBoundingClientRect();
        var x = event.clientX - rect.left;
        var y = event.clientY - rect.top;
        level[Math.floor(y/32)][Math.floor(x/32)] = tile_to_place; // <--
        draw();
    });
});

Now how can we let the user select which tile to place? My first thought was the fancy Bootstrap button-group:


However, if you want to do it with regular buttons to avoid using Bootstrap, that’s okay as well. These instructions should be fairly easy to adapt to something else. I added a button-group below the canvas using the Bootstrap classes:

<div class="btn-group" data-toggle="buttons" id="tile-selector">
    <label class="btn btn-primary active" id="0">
        <input type="radio" name="tile-select" checked />
        <img src="/path/to/images/0.png" alt="Sky" />
    </label>
    <label class="btn btn-primary" id="1">
        <input type="radio" name="tile-select" />
        <img src="/path/to/images/1.png" alt="Dirt" />
    </label>
    <label class="btn btn-primary" id="2">
        <input type="radio" name="tile-select" />
        <img src="/path/to/images/2.png" alt="Grass" />
    </label>
    <label class="btn btn-primary" id="3">
        <input type="radio" name="tile-select" />
        <img src="/path/to/images/3.png" alt="Flag" />
    </label>
    <label class="btn btn-primary" id="4">
        <input type="radio" name="tile-select" />
        <img src="/path/to/images/4.png" alt="Spike" />
    </label>
</div>

The important thing to note here is that I gave their containing <div> a unique ID, and gave them (the <label>s, not the <input>s — the click events go to the labels) IDs that match their image names. This is important, because I used their IDs to determine which image to switch to in the script. I added the following lines to the on-document-load section:

    $("#tile-selector label").click(function() {
        tile_to_place = $(this)[0].id;
    });

The first line selects all <label> elements inside the element with ID tile-selector, and registers a function for all of them to call when clicked. The function changes the current tile to their ID. (This is given by $(this)[0].id rather than $(this).id because $(this) returns an array for some reason. Haven’t figured that out yet.)

And that’s pretty much it! Wrap the whole thing in a centered div so it doesn’t look ugly. Here’s what the finished script should look like.

<!--HTML-->
<div style="text-align:center;">
    <canvas width="256" height="256" id="can">you don't support canvas</canvas><br />
    <div class="btn-group" data-toggle="buttons" id="tile-selector">
        <label class="btn btn-primary active" id="0">
            <input type="radio" name="tile-select" checked />
            <img src="/path/to/images/0.png" alt="Sky" />
        </label>
        <label class="btn btn-primary" id="1">
            <input type="radio" name="tile-select" />
            <img src="/path/to/images/1.png" alt="Dirt" />
        </label>
        <label class="btn btn-primary" id="2">
            <input type="radio" name="tile-select" />
            <img src="/path/to/images/2.png" alt="Grass" />
        </label>
        <label class="btn btn-primary" id="3">
            <input type="radio" name="tile-select" />
            <img src="/path/to/images/3.png" alt="Flag" />
        </label>
        <label class="btn btn-primary" id="4">
            <input type="radio" name="tile-select" />
            <img src="/path/to/images/4.png" alt="Spike" />
        </label>
    </div>
</div>
// Javascript
var tile_to_place = 0;
var imgs = [];
var level = [[0,0,0,0,0,0,0,0],
             [0,0,0,0,0,0,0,0],
             [0,3,0,0,0,0,3,0],
             [2,2,2,0,0,2,2,2],
             [1,1,1,0,0,1,1,1],
             [1,1,1,4,4,1,1,1],
             [1,1,1,1,1,1,1,1],
             [1,1,1,1,1,1,1,1]];

$(function() {
    draw();
    var canvas = document.getElementById('can');
    canvas.addEventListener('click', function(event) {
        var rect = canvas.getBoundingClientRect();
        var x = event.clientX - rect.left;
        var y = event.clientY - rect.top;
        level[Math.floor(y/32)][Math.floor(x/32)] = tile_to_place;
        draw();
    });
    $("#tile-selector label").click(function() {
        tile_to_place = $(this)[0].id;
    });
});

function draw() {
    var canvas = document.getElementById('can');
    if (canvas.getContext) {
        var ctx = canvas.getContext("2d");
        for (i = 0; i < 8; i++) {
            imgs[i] = [];
            for (j = 0; j < 8; j++) {
                imgs[i][j] = new Image();
                imgs[i][j].src = '/path/to/images/' + level[i][j] + '.png';
                ctx.drawImage(imgs[i][j], j*32, i*32);
            }
        }
    }
}

And here’s the finished product:

you don’t support canvas

Implementing save/load functionality is left as an exercise for the reader. Or maybe for me, when I need something to do. :)