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.

image

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:

image

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

image

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

image

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: grass
If the average colour is mostly blue (that is, B > R and B > G) then insert a water tile: image
If the average colour is mostly dark brown (R >= G and R >= B and B <= 90) then insert a rock tile: rock
If the average colour is mostly light brown (R >= G and R >= B and B > 90) then insert a dirt/sand tile: dirt
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: flowers

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,
            System.Drawing.Imaging.ImageLockMode.ReadOnly,
            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:

image
0331
image
0331
image
1
image
image
01
image
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?:

image

Adding a Player Sprite

imageTo 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">
<head>
<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
    window.addEventListener('keydown', handleKeyDown, false);
    window.addEventListener('keyup', handleKeyUp, false);
  }  
</script>
</head>
<body onload="GetMap();">
  <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 🙂

  7. adamhill42 says:

    And now Google Maps has 8-bit map tiles.

    “View 8-bit landmarks
    Be a hero, explore the world
    Find hidden monsters
    8-bit Quest Maps is our Beta Maps technology and has certain system requirements. Your system may not meet the minumum requirements for 8-bit computations.”

  8. Pingback: Google’s April Fools’ Day 8-bit Map | Alastair Aitchison

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s