Creating “The Legend of Zelda” map tiles from Bing Maps aerial imagery

Time for something light-hearted, I feel. Following on from last year’s Bing Maps tribute to Grand Theft Auto, I thought I’d do another videogame-themed map hack inspired by perhaps my most-loved game of all time – the Legend of Zelda.

Almost the entire year of my 10-year old life was spent playing the Legend of Zelda on the Nintendo Entertainment System. In those days, games rarely had their own in-game maps, and it was long before GameFAQs or Prima strategy guides existed, so the only way to keep track of where you’d been in an adventure game was to create your own maps. My mum would bring me home sheets of graph paper from work, and I would spend hours laboriously drawing the layout of each room in every dungeon, and how each of the screens in the overworld connected to each other. (If you’ve ever tried to do this yourself, you’ll appreciate the ingenuity of the “lost woods”, which are very hard to map on paper!).

As a result, I’ve always personally found the overworld maps of Zelda games to be a thing of beauty. Here’s the world map for the original Legend Of Zelda:

This one’s from Zelda II: The Adventure of Link:

and here’s The Legend of Zelda: A Link to the Past:

The world of Hyrule is undeniably beautiful – its deserts, rivers, and forests becoming ever more intricate in each successive game in the Zelda series. But wouldn’t it be cool to create an 8bit-style tileset of the real world that you navigate around?

Now, I’m no graphic artist and, even if I was, the thought of drawing the 275 billion tiles required to cover the world at zoom level 19 would probably put me off the idea anyway. So, I need some way to automatically determine whether a given area should be represented as a water tile, or a grass tile, say. And I wondered if it was possible to do this based on a simple image analysis of the corresponding aerial tile from Bing Maps.

Converting Bing Maps Aerial Imagery to 8bit tiles

So, here’s the plan: I build a Bing Maps application that uses a custom tile layer. The handler for the tile layer requests aerial imagery from Bing Maps. But, instead of returning the tile image direct to the client, it converts the features on that tile into appropriate 8bit sprites and returns a new tile composed of those sprites to the client instead.

To choose which tile images to use, I’ll divide each 256px x 256 tile retrieved from Bing Maps into smaller 32px x 32px subtiles. I’ll then do a simple RGB analysis to determine the “average” colour of each subtile.

 If the average colour of that subtile is mostly green (that is, when comparing the RGB component values, G > B > R) then I’ll insert a grass tile: If the average colour is mostly blue (that is, B > R and B > G) then insert a water tile: If the average colour is mostly dark brown (R >= G and R >= B and B <= 90) then insert a rock tile: If the average colour is mostly light brown (R >= G and R >= B and B > 90) then insert a dirt/sand tile: To add a bit of variety, I’ll also make it so that every grass tile has a 1/50 chance of being replaced with a flower tile:

You can obviously tweak these, or add more rules to fit the tileset as you see fit.

Here’s the code to implement these rules in a C# handler:

```<%@ WebHandler Language="C#" Class="Handler" %>

using System;
using System.Web;
using System.Drawing;
using System.Data.SQLite;
using System.IO;
using System.Drawing.Imaging;

public class Handler : IHttpHandler
{

public void ProcessRequest(HttpContext context)
{
string quadKey = context.Request.QueryString["q"];

// Create a random number generator to vary grass tiles
Random random = new Random();

// Retrieve the original aerial tile image
System.Net.WebRequest request = System.Net.WebRequest.Create("http://ecn.t" + quadKey[quadKey.Length - 1] + ".tiles.virtualearth.net/tiles/a" + quadKey + "?g=774&mkt=en-gb&lbl=l1&stl=h&n=z");
System.Net.WebResponse response = request.GetResponse();
System.IO.Stream responseStream = response.GetResponseStream();
Bitmap mapImage = new Bitmap(responseStream);

// Set up a graphics class based on the tile bitmap
using (Graphics graphics = System.Drawing.Graphics.FromImage(mapImage))
{
int tileWidth = 32, tileHeight=32;
int numRows = 8, numCols = 8;

// Loop through the aerial tile in 16x16 squares
for (int row = 0; row < numRows; row++)
{
for (int col = 0; col < numCols; col++)
{
// Get the bitmap data for this square
Rectangle tileRect = new Rectangle(col * tileWidth, row * tileHeight, tileWidth, tileHeight);
System.Drawing.Imaging.BitmapData bmData = mapImage.LockBits(
tileRect,
mapImage.PixelFormat);

// Get the average R,G,B values of pixels in this square
long[] totals = new long[] { 0, 0, 0 };
unsafe
{
// 24bit image so 3 bytes per pixel (PNG + transparency would be 4)
int PixelSize = 3;
for (int y = 0; y < tileHeight; y++)
{
byte* p = (byte*)bmData.Scan0 + (y * bmData.Stride);
for (int x = 0; x < tileWidth; x++)
{
totals[0] += p[x * PixelSize]; // Blue
totals[1] += p[x * PixelSize + 1]; // Green
totals[2] += p[x * PixelSize + 2]; // Red
}
}
}
mapImage.UnlockBits(bmData);

// Work out the average RGB colour value
int avgB = (int)(totals[0] / (bmData.Width * bmData.Height));
int avgG = (int)(totals[1] / (bmData.Width * bmData.Height));
int avgR = (int)(totals[2] / (bmData.Width * bmData.Height));

// Determine average colour of this tile
Color fillColour = Color.FromArgb(avgR, avgG, avgB);

// Snow
if (fillColour.G >= 225 && fillColour.B >= 225 && fillColour.R >= 225)
{
graphics.FillRectangle(Brushes.White, tileRect);
}

// Grass
else if (fillColour.G >= fillColour.B && fillColour.G >= fillColour.R)
{
// Random chance of being a flower tile
if (random.Next(0, 50) < 1)
{
Bitmap grassImage = (Bitmap)Image.FromFile(@"C:\Users\Alastair\Documents\Visual Studio 2008\WebSites\BingMaps\v7\ZeldaMap\flowers.png");
graphics.DrawImage(grassImage, tileRect);
}
else
{
Bitmap grassImage = (Bitmap)Image.FromFile(@"C:\Users\Alastair\Documents\Visual Studio 2008\WebSites\BingMaps\v7\ZeldaMap\grass.png");
graphics.DrawImage(grassImage, tileRect);
}
}

// Water
else if (fillColour.B >= fillColour.G && fillColour.B >= fillColour.R)
{
graphics.FillRectangle(new SolidBrush(Color.FromArgb(255,48,89,166)), tileRect);
}

// Dirt/Sand
else if (fillColour.R >= fillColour.G && fillColour.R >= fillColour.B && fillColour.B <= 90)
{
Bitmap dirtImage = (Bitmap)Image.FromFile(@"C:\Users\Alastair\Documents\Visual Studio 2008\WebSites\BingMaps\v7\ZeldaMap\rock.png");
graphics.DrawImage(dirtImage, tileRect);
}

// Rock
else if (fillColour.R >= fillColour.G && fillColour.R >= fillColour.B && fillColour.B > 90)
{
Bitmap rockImage = (Bitmap)Image.FromFile(@"C:\Users\Alastair\Documents\Visual Studio 2008\WebSites\BingMaps\v7\ZeldaMap\dirt.png");
graphics.DrawImage(rockImage, tileRect);
}

// Default - just fill tile in single average colour
else {
graphics.FillRectangle(new SolidBrush(fillColour), tileRect);
}
}
}
}

// Send the resulting image back to the client
context.Response.ContentType = "image/png";
System.IO.MemoryStream memStream = new System.IO.MemoryStream();
mapImage.Save(memStream, System.Drawing.Imaging.ImageFormat.Png);
memStream.WriteTo(context.Response.OutputStream);
}

public bool IsReusable
{
get
{
return false;
}
}

}

```

(Note that, because I’m accessing aerial tiles directly from the Bing Maps tile servers rather than through the API, this method is unsupported and probably not allowed in a production environment. But then, why on earth would you ever want to do this in a production environment? It’s just for fun!)

Based on the rules above, let’s see how certain tiles are rendered:

 0331 0331 1 01 01

That’s pretty much the effect I was after, and here’s what it looks like then you create a custom tile layer based on the above handler displayed on a Bing Maps control at zoom level 1. Pretty neat, huh?:

Adding a Player Sprite

To finish this example off, I want to add a player to the world as well. He’ll stay in the middle of the map, and the tiles will scroll under his feet as you press the cursor keys for him to navigate around. I’ll create a few simple frames of animation to show him walking in different directions, as shown in the sprite strip to the left.

The player sprite will appear in a div placed in the centre of the map, and I’ll use javascript to change the background of the div to the appropriate frame depending on whether the player is moving up, right, down, or left. Since there are three frames of animation for each direction, I’ll create a simple frame matrix that will loop through the relevant frames.

var frameMatrix = [1, 2, 0, 4, 5, 3, 7, 8, 6, 10, 11, 9];

In addition to animating the background of the player sprite div, pressing a cursor key will also cause the map to scroll in the appropriate direction. For this, I’ve added a custom Pan() method that extends the Microsoft.Maps.Map prototype.

Here’s the code listing for the HTML page:

```<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html  xmlns="http://www.w3.org/1999/xhtml">
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<script type="text/javascript" src="http://ecn.dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=7.0"></script>
<script type="text/javascript">

// Define some globals for handling key input
var keys = { UP: 38, DOWN: 40, LEFT: 37, RIGHT: 39 };
var keysPressed = [];
// These used to track player animation frames
var frameMatrix = [1, 2, 0, 4, 5, 3, 7, 8, 6, 10, 11, 9];
var currentFrame = 0;
// The map. Obviously.
var map = null;
// Add a convenient method to pan the map
Microsoft.Maps.Map.prototype.Pan = function(x, y) {
var pos = this.tryPixelToLocation(new Microsoft.Maps.Point(this.getWidth() / 2 + x, this.getHeight() / 2 - y), Microsoft.Maps.PixelReference.control);
this.setView({ center: pos });
};
// Set the frame rate of how often the map moves/player animates
var int = window.setInterval("animate()", 150);

// This function gets called once per animation loop
function animate() {
// Only look at the first key pressed
switch (keysPressed[0]) {
case keys.UP:
// If we've reached the end of the "up" animation frames, start again
if (currentFrame >= 2) { currentFrame = 0; }
// otherwise, go on to the next "up" animation frame
else { currentFrame++ };
// Pan the map up
map.Pan(0, 10);
break;
case keys.DOWN:
// If we've reached the end of the "down" animation frames, start again
if (currentFrame < 3 || currentFrame >= 5) { currentFrame = 3; }
// otherwise, go on to the next "down" animation frame
else { currentFrame++ };
// Pan the map down
map.Pan(0, -10);
break;
case keys.LEFT:
// If we've reached the end of the "left" animation frames, start again
if (currentFrame < 6 || currentFrame >= 8) { currentFrame = 6; }
// otherwise, go on to the next "left" animation frame
else { currentFrame++ };
// Pan the map left
map.Pan(-10, 0);
break;
case keys.RIGHT:
// If we've reached the end of the "right" animation frames, start again
if (currentFrame < 9 || currentFrame >= 11) { currentFrame = 9; }
// otherwise, go on to the next "right" animation frame
else { currentFrame++ };
// Pan the map right
map.Pan(10, 0);
break;
}

// Update the offset of the background of the player div
document.getElementById("player").style.backgroundPosition = "0 " + -currentFrame * 32 + "px";
}

// This function keeps track of the key(s) currently being pressed.
// Uses an array so that, for example, can use diagonal movement when
// two keys are pressed.
function handleKeyDown(e) {
switch (e.keyCode) {
case keys.UP:
if (!inArray(keys.UP, keysPressed) && !inArray(keys.DOWN, keysPressed)) {
keysPressed.push(keys.UP);
}
break;
case keys.DOWN:
if (!inArray(keys.DOWN, keysPressed) && !inArray(keys.UP, keysPressed)) {
keysPressed.push(keys.DOWN);
}
break;
case keys.LEFT:
if (!inArray(keys.LEFT, keysPressed) && !inArray(keys.RIGHT, keysPressed)) {
keysPressed.push(keys.LEFT);
}
break;
case keys.RIGHT: // Right:
if (!inArray(keys.RIGHT, keysPressed) && !inArray(keys.LEFT, keysPressed)) {
keysPressed.push(keys.RIGHT);
}
break;
}
e.handled = true;
}

// Remove key from the array when they are released
function handleKeyUp(e) {
keysPressed = removeFromArray(e.keyCode, keysPressed);
}

// Utility methods
function inArray(element, arr) {
for (var i = 0; i < arr.length; i++) {
if (arr[i] === element) {
return true;
}
}
return false;
}
function removeFromArray(element, arr) {
for (var i = 0; i < arr.length; i++) {
if (arr[i] == element)
arr.splice(i, 1);
}
return arr;
}

// Create the map
function GetMap() {
map = new Microsoft.Maps.Map(document.getElementById("mapDiv"),
{ credentials: "GETYEROWNBINGMAPSKEY!",
center: new Microsoft.Maps.Location(50, 0),
mapTypeId: Microsoft.Maps.MapTypeId.mercator,
zoom: 5,
inertiaIntensity: 0.00001,
tileBuffer:3,
disableKeyboardInput: true
});

// Create a custom tile source that points to the .NET handler
var tileSource = new Microsoft.Maps.TileSource({ uriConstructor: location.href.substring(0, location.href.lastIndexOf('/')) + "/" + "handler.ashx?q={quadkey}" });

// Construct the layer using the tile source
var tilelayer = new Microsoft.Maps.TileLayer({ mercator: tileSource, opacity: 1 });

// Push the tile layer to the map
map.entities.push(tilelayer);

// Position the player sprite in the middle of the map
document.getElementById("player").style.backgroundImage = "url('player.png')";
document.getElementById("player").style.left = map.getWidth() / 2 + "px";
document.getElementById("player").style.top = map.getHeight() / 2 + "px";

// Attach event handlers
}
</script>
<div id='mapDiv' style="position:relative; width:640px; height:480px;"></div>
<div id='player' style="position: absolute; z-index:10; width:20px; height:32px; background-repeat:no-repeat;"></div>
</body>
</html>

```

Finally, here’s a video of the finished product. Watch me walk from somewhere near Paris to Valencia and then onto Naples, all in glorious dynamically-generated 8-bit graphics!

This entry was posted in Bing Maps and tagged , , . Bookmark the permalink.

17 Responses to Creating “The Legend of Zelda” map tiles from Bing Maps aerial imagery

1. Xavier says:

Great work and lots of fun. Congratulations once again Alastair.
One thing : the code post for the httpHandler is incomplete. There are missing parts between the unsafe code and the foreach (line 50).
I would love running it locally and show the results to NES lovers (with an Alastair credit, no thieving stuff here…)

• alastaira says:

Well spotted! WordPress has munged my code paste somewhere – I’ll try to correct that now. And you’re welcome to show/share/amend this code to anybody.

• alastaira says:

Please try again now – code listings hopefully should be fixed.

• Xavier says:

Thanks Alastair, handler works ok now (I have used WPF Bing Maps control).
Another mashup idea : why not creating a fractal tile layer (ie Sierpiński triangle, Von Koch snowflake) where you can zoom in like a fool ? Lots of fun ahead…

2. rbrundritt says:

Awesome. Cool application.

3. Pedro Sousa says:

Brilliant stuff. Great idea and event better implementation.
I love these “just for fun” posts 😀

• alastaira says:

Thankyou Pedro, and I’m glad you like it.
I really enjoy writing these kind of posts “just for fun”, but hopefully this example might also help show someone how to use a tile layer, or how to animate a sprite, or just make them think of a new idea for a mapping application, so it’s also sort of educational!

And if it makes you smile as well, that’s even better 🙂

4. Excellent, innovative an creative…….like it 🙂

5. Nicolas Boonaert says:

Excellent idea! And love the simple implementation 😉
Oh btw, the original legend of zelda map makes me think about the old arabic/oriental maps which represents the world with local features and in a completely different conception of the world and the representation that was made.. like those:

http://farm4.staticflickr.com/3364/3283867809_cfc500287f_z.jpg?zz=1
I have one in my books that really looks like the first from Zelda.. I will try to catch it in this ‘Atlas des Atlas’.
Thanks for sharing your fun stuff 🙂

6. earthware says:

lol again you surpass yourself! Want some real work to do 😉

• alastaira says:

Thanks for the offer Brian, but no – I’ve decided that real work sucks, and I shan’t be doing any more of it…

Fiddling around with pointless stuff like this is more aligned with my skill set, motivation, and resource availability 🙂

• Brian says:

Fair enough, can I work for u instead then 🙂