Part 7 - Isometric Picking


Note: For this tutorial, we will pick up with the Isometric Engine as it exists at the end of Part 4 of the series. The code can be downloaded here.

Note 2: If you are working with Hex maps, the entire technique is very similar... See the link below to the gamedev.net article for the variations needed for a hex map.

It will, at some point, become important to know the relationship between an individual pixel in the game world and the map cells in our isometric map. On a square map, this is very simple: divide the X coordinate by the tile width and the Y coordinate by the tile height, and you have the grid location of the map cell you are interested in.

With an isometric map, this becomes somewhat more complicated, as our individual tiles are no longer squares or rectangles. Any given tile-sized rectangular area on our map will contain pixels from multiple map tiles.

I'm sure there is a complicated way to use mathematics to figure out exactly what pixels map back to what map cells, but I never could get any of them to work correctly... What can I say... They made my head hurt. So, based on this classic article we can save a whole bunch of time and cheat.

Here is a simplified map section with the X,Y coordinates of the represented map cells labeled:



If we superimpose a grid that is one actual-tile in size (note, not the full 64x64 tile size, but the visual size of a single tile, which is 64x32 pixels) we get something that looks like this:



If we divide our world pixel X coordinate by 64 and our Y coordinate by 32, we will get the super-imposed square that contains the pixel we are interested in. The only problem now is that we need to figure out which of the potentially 5 tiles that pixel might belong to.

Take a look at the following image (in fact, go ahead and add it to the TileSets folder in your Content Project):



If you have read the Offscreen Color Keyed Map Tutorial you might recognize these large areas of easily distinguishable colors. We are going to use the same technique to identify the map cell that we want.



Lets assume that the coordinates we are looking for are within the rectangle centered on map cell 11,26 in the diagram above. The remainder of the division that yeilded the map cell itself will provide the X and Y coordinate within the cell that we are interested in, and therefor the X and Y coordinate within the color-coded image. We just need to work out what the colors mean, which is fairly simple based on the diagram above:

ColorX OffsetY OffsetResult
White0011,26
Red-1-110,25
Green-1+110,27
Yellow0-111,25
Blue0+111,27


Updating the Camera


Before we get to that, lets lay some groundwork by updating the Camera class to be a bit more robust. We will also be making some changes to the way we draw the map to use the new functions of the updated Camera class.

Open up the Camera.cs file. Right now, the class only contains a single public Vector2 object, so lets modify that object by changing it's name from Location to location. (Note: Don't right click on the variable and select Refactor/Rename... We don't wan Visual Studio to automatically rename references to our Location variable as we are going to replace it with a Location property to maintain its existing functionality).

To begin with, lets add some properties that will control the range of motion of our camera:

        public static int ViewWidth { get; set; }
        public static int ViewHeight { get; set; }
        public static int WorldWidth { get; set; }
        public static int WorldHeight { get; set; }

        public static Vector2 DisplayOffset { get; set; }


The ViewWidth and ViewHeight properties will let the camera know how big the display area it is covering is, while the WorldWidth and WorldHeight properties set the overall size of the map, determining how var the camera can scroll in the X and Y directions.

By default, our camera would assume a draw position of 0,0, so we have included the DisplayOffset property to allow us to build in the screen location we will be drawing at into our camera. Remember that for our isometric maps we are drawing the map starting 32 pixels to the left and 64 pixels above the top of the screen. We will set our DisplayOffset to -32,-64 later on to duplicate this effect.

Now that we have the properties we need to constrain the movement of the camera, lets add the new Location property to the class:

    public static Vector2 Location
        {
            get
            {
                return location;
            }
            set
            {
                location = new Vector2(
                    MathHelper.Clamp(value.X, 0f, WorldWidth - ViewWidth),
                    MathHelper.Clamp(value.Y, 0f, WorldHeight - ViewHeight));
            }
        }


When reading the location, we simply return the location (lower case) variable. When setting, however, we user MathHelper.Clamp to constrain the values to a valid world range. The reason we subtract the ViewWidth and ViewHeight from the WorldWidth and Height values is to ensure that there will always be a "full screen of the world" visible. In other words, the camera can't get closer to the edge of the world that one full display screen away. Otherwise, we would end up not drawing in the lower right portion of the map display.

Lets wrap up out Camera work by adding three new methods to the Camera class:

        public static Vector2 WorldToScreen(Vector2 worldPosition)
        {
            return worldPosition - Location + DisplayOffset;
        }

        public static Vector2 ScreenToWorld(Vector2 screenPosition)
        {
            return screenPosition + Location - DisplayOffset;
        }

        public static void Move(Vector2 offset)
        {
            Location += offset;
        }


The WorldToScreen and ScreenToWorld methods take a Vector2 and translate it to the appropriate coordinate reference. This way, given a point in the world we can identify where on screen it should appear. The same is true of the reverse. Given a position on screen (say, the location of the mouse cursor) we can translate that into a world-based coordinate, taking the position of the camera into account.

Finally, we add a simple method to move the camera by passing a Vector indicating how far we want to move. This is simply added to the Location property (which will impose the movement constraints on the camera described above).

In order to utilize our new Camera updates, we need to do some work in the Game1.cs file. Start off in the LoadContent() method, where we will initialize the camera's properties.

            Camera.ViewWidth = this.graphics.PreferredBackBufferWidth;
            Camera.ViewHeight = this.graphics.PreferredBackBufferHeight;
            Camera.WorldWidth = ((myMap.MapWidth-2) * Tile.TileStepX);
            Camera.WorldHeight = ((myMap.MapHeight-2) * Tile.TileStepY);
            Camera.DisplayOffset = new Vector2(baseOffsetX, baseOffsetY);


Here we are simply supplying values for all of the properties we created for the camera before we use it for the first time. We pass the size of our display window as the ViewWidth and ViewHeight, and calculate the size of the World based on the tile steps. We use the baseOffsetX and baseOffsetY variables we had already established to set the DisplayOffset property.

Next, we need to update now we move the camera in the Update() method. We will be replacing all of this code in Part 8 when we add a character to the map, so for time time being, replace all of the if (ks.IsKeyDown... entries with the following:

            if (ks.IsKeyDown(Keys.Left))
            {
                Camera.Move(new Vector2(-2, 0));
            }

            if (ks.IsKeyDown(Keys.Right))
            {
                Camera.Move(new Vector2(2, 0));
            }

            if (ks.IsKeyDown(Keys.Up))
            {
                Camera.Move(new Vector2(0, -2));
            }

            if (ks.IsKeyDown(Keys.Down))
            {
                Camera.Move(new Vector2(0, 2));
            }


Here, we are simply replacing the old direct modifications of the Location variable with calls to the Move method. We need to do this because we can no longer directly modify the X and Y components of the Location, since it is now a property instead of a variable.

Now we can replace the SpriteBatch.Draw() calls in the Draw() method. Remove three existing foreach loops that draw the tiles and replace them with:

                    if ((mapx >= myMap.MapWidth) || (mapy >= myMap.MapHeight))
                        continue;
                    foreach (int tileID in myMap.Rows[mapy].Columns[mapx].BaseTiles)
                    {
                        spriteBatch.Draw(

                            Tile.TileSetTexture,
                            Camera.WorldToScreen( 
new Vector2((mapx * Tile.TileStepX) + rowOffset, mapy * Tile.TileStepY)), Tile.GetSourceRectangle(tileID), Color.White, 0.0f, Vector2.Zero, 1.0f, SpriteEffects.None, 1.0f); } int heightRow = 0; foreach (int tileID in myMap.Rows[mapy].Columns[mapx].HeightTiles) { spriteBatch.Draw( Tile.TileSetTexture, Camera.WorldToScreen( new Vector2( (mapx * Tile.TileStepX) + rowOffset, mapy * Tile.TileStepY - (heightRow * Tile.HeightTileOffset))), Tile.GetSourceRectangle(tileID), Color.White, 0.0f, Vector2.Zero, 1.0f, SpriteEffects.None, depthOffset - ((float)heightRow * heightRowDepthMod)); heightRow++; } foreach (int tileID in myMap.Rows[y + firstY].Columns[x + firstX].TopperTiles) { spriteBatch.Draw( Tile.TileSetTexture, Camera.WorldToScreen(
new Vector2((mapx * Tile.TileStepX) + rowOffset, mapy * Tile.TileStepY)), Tile.GetSourceRectangle(tileID), Color.White, 0.0f, Vector2.Zero, 1.0f, SpriteEffects.None, depthOffset - ((float)heightRow * heightRowDepthMod)); }


There are not really many changes here... We start out by adding a simple check to make sure that we don't try to draw a tile that does not exist. If either mapx or mapy are located outside the map, we simply exit the loop and continue on.

The main change in each of the draw calls is that we are using a Vector2 instead of a rectangle to indicate our drawing position. Because of this we need to specify the Scale parameter of the SpriteBatch.Draw() call (the 1.0f right after Vector2.Zero in the calls above). To build the display vector, we use the Camera.WorldToScreen() method. We use the mapx and mapy values that we already calculated to determine the placement of the map tiles on the screen, taking the oddities of the isometric tile system into account.

Go ahead and run your project. You should see no difference at all at this point, but we have laid the groundwork for things we will need in this and the next installment of the series.

Implementing the Picking Code


In order to incorporate the mouse map discussed above into our engine in such a way that we can build on it for what we will be doing in Part 8, we will modify the definition of the TileMap class so it can manage its own mouse map. In the TileMap.cs file, add the following using declarations to the top of the file:

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;


Next, add the following member variable declaration to the TileMap class:

private Texture2D mouseMap;


Modify the declaration of the TileMap constructor to include a way to pass in the MouseMap:

public TileMap(Texture2D mouseMap)


Finally, set the local variable in the first line of the constructor code:

this.mouseMap = mouseMap;


We need to go back into Game1.cs and modify our creation of the TileMap to account for this change to the constructor.

In the declarations area of the Game1.cs file, modify the declaration of the myMap variable to remove the construction. We can't construct it at this point because we need to pass it a Texture2D, which won't be available until the LoadContent() method:

TileMap myMap;


In the LoadContent() method of the Game1.cs file, add a call to instantiate the myMap variable with:

myMap = new TileMap(Content.Load<Texture2D>(@"Textures\TileSets\mousemap"));


While we are here, lets go ahead and add the following image to our content project:



We will use this image to hilight the tile currently under the mouse cursor. Add a declaration for the Texture2D that will hold this image in the declarations area of the Game1.cs file:

Texture2D hilight;


and load the image in the LoadContent() method:

hilight = Content.Load<Texture2D>(@"Textures\TileSets\hilight");


Next, we'll go back to the TileMap.cs file and add a method to convert a pixel-based location on the map into a map cell refernce. This method looks big, but mostly it is just accounting for the different colors on the mousemap:

        public Point WorldToMapCell(Point worldPoint, out Point localPoint)
        {
            Point mapCell = new Point(
               (int)(worldPoint.X / mouseMap.Width),
               ((int)(worldPoint.Y / mouseMap.Height)) * 2
               );

            int localPointX  = worldPoint.X % mouseMap.Width;
            int localPointY = worldPoint.Y % mouseMap.Height;

            int dx = 0;
            int dy = 0;

            uint[] myUint = new uint[1];

            if (new Rectangle(0, 0, mouseMap.Width, mouseMap.Height).Contains(localPointX, localPointY))
            {
                mouseMap.GetData(0, new Rectangle(localPointX, localPointY, 1, 1), myUint, 0, 1);

                if (myUint[0] == 0xFF0000FF) // Red
                {
                    dx = -1;
                    dy = -1;
                    localPointX = localPointX + (mouseMap.Width / 2);
                    localPointY = localPointY + (mouseMap.Height / 2);
                }

                if (myUint[0] == 0xFF00FF00) // Green
                {
                    dx = -1;
                    localPointX = localPointX + (mouseMap.Width / 2);
                    dy = 1;
                    localPointY = localPointY - (mouseMap.Height / 2);
                }

                if (myUint[0] == 0xFF00FFFF) // Yellow
                {
                    dy = -1;
                    localPointX = localPointX - (mouseMap.Width / 2);
                    localPointY = localPointY + (mouseMap.Height / 2);
                }

                if (myUint[0] == 0xFFFF0000) // Blue
                {
                    dy = +1;
                    localPointX = localPointX - (mouseMap.Width / 2);
                    localPointY = localPointY - (mouseMap.Height / 2);
                }
            }

            mapCell.X += dx;
            mapCell.Y += dy - 2;

            localPoint = new Point(localPointX, localPointY);

            return mapCell;
        }


In this method, we pass in a point (worldPoint) which refers to a pixel specific location on the overall map image. The point value that we return represents the X and Y map cell coordinates on the map.

We do this by first converting the point to a rectangle reference as we described above when we overlaid a grid of rectangles on top of our isometric map. Unless this this value (stored in the mapCell variable) is changed, this is the map cell reference we will return at the end of the function.

Next, we determine where within the mouse map the pixel in question falls. The X and Y coordinates are stored in localPointX and localPointY. We set out our "delta" values (dx, and dy), defaulting them both to zero.

By using the GetData() method of the Texture2D class on the mouse map, we extract a single pixel of map data based on the localPointX and localPointY positions and store the result in a one-entry uint array. All we have to do now is examine this value and set the dx and dy values according to the chart above based on the color of the pixel.

One this to note here is that while we are accustomed to specifying colors in R, G, B, A order, the unit values returned are in AABBGGRR order. After we have done the color matching, we simply offset the mapCell point by adding dx to the X coordinate and dy to the Y coordinate. We also subtract two from the Y coordinate because the top two "rows" of our map actually don't contain real map information. They are taken up by the padding at the top of each individual tile (remember that the actual tile graphics are aligned along the bottom of the tile image).

You may also have noticed that we are using the "out" notation in the method declaration to return a second value from this function. including "out" before a parameter in a method list lets you modify the value of the parameter in the method, and it will be updated in the calling scope. In practical terms for the way we are using it, this lets us return more than one value from a method.

This will be important for Part 8 of the series, but for what we are doing in this part, we don't actually need this variable, so lets create an overload that simply returns the point:

        public Point WorldToMapCell(Point worldPoint)
        {
            Point dummy;
            return WorldToMapCell(worldPoint, out dummy);
        }


Lets go ahead and incorporate this into our actual display. Back in the Game1.cs file, add the following code to the Initialize() method:

this.IsMouseVisible = true;


This will make the mouse cursor visible inside the game window.

In the Draw() method, right before the call to spriteBatch.End(), add the following:

            Vector2 hilightLoc = Camera.ScreenToWorld(new Vector2(Mouse.GetState().X, Mouse.GetState().Y));
            Point hilightPoint = myMap.WorldToMapCell(new Point((int)hilightLoc.X, (int)hilightLoc.Y));

            int hilightrowOffset = 0;
            if ((hilightPoint.Y) % 2 == 1)
                hilightrowOffset = Tile.OddRowXOffset;

            spriteBatch.Draw(
                            hilight,
                            Camera.WorldToScreen( 
new Vector2(
(hilightPoint.X * Tile.TileStepX) + hilightrowOffset,
(hilightPoint.Y + 2) * Tile.TileStepY)), new Rectangle(0, 0, 64, 32), Color.White * 0.3f, 0.0f, Vector2.Zero, 1.0f, SpriteEffects.None, 0.0f);


We grab the mouse coordinates and translate them from Screen Space into World Space. We then use WorldToMapCell() to retrieve the map cell reference that this world point corresponds to. After that, we draw the hilight "tile" in exactly the same way we draw our regular map tiles. This time around, we need to add two to the Y coordinate because we are drawing with a hilight image that is only half the size of our actual tiles.

Go ahead and run your project, and you should be able to mouse around on the map with a tile-shaped hilight following your mouse cursor.



While useful on its own, this technique is really building the framework for what we will need in Part 8 of the series, were we will place an animated, player controlled character onto the map.




































 

 
 
Site Contents Copyright 2006 Full Revolution, Inc. All rights reserved.
This site is in no way affiliated with Microsoft or any other company.
All logos and trademarks are copyright their respective companies.
RSS FEED