In the Gemstone Hunter game, the player is a treasure-hunting archaeologist roaming the zombie-infested wilderness. Unlike the other games presented in this book, Gemstone Hunter will not automatically generate levels. For this reason, the game can be viewed more along the lines of a project starter to explore topics, such as combining Windows Forms and XNA, to produce a level editor, and an introduction to the basics of building a platform-style game.
The XNA Creator's Club website provides a number of Starter Kits that contain sample code and images to get you started on developing specific types of games. The Platform Starter Kit was introduced with XNA 3.1 and included the XNA Game Studio distribution. We are going to make use of some of the graphics from this Starter Kit in Gemstone Hunter. We will not use the actual starter kit itself, however, for a couple of reasons. First, all of the code in the Starter Kit is in C#, and second, we want to build on some of the code that we have established in our prior games and focus on key aspects of the platform genre.
In this chapter, we will begin the development of Gemstone Hunter, by building a map editor for the game. In order to do so, we will cover:
Expanding our existing tile map engine
Adding multiple projects to a Visual Studio solution
Adding a Windows Form to an XNA game project
Communicating between Windows Forms and our game code
Loading and saving map files through serialization
While we may not be directly using XNA's Platform Starter Kit, we will be borrowing the graphical resources for the player's character and enemy monsters from the project. We will begin by creating the project that will eventually house our completed game:
Download 2403_08_GRAPHICPACK.zip
from the book's website, and extract the contents to a temporary folder. Leave this folder open in Windows Explorer.
Inside Visual Studio, select New Project... from the File menu.
Create a new Windows Game (4.0) project called Gemstone Hunter
.
Right-click on the Gemstone Hunter Content project, and add a new folder called Textures
.
Switch back to the Windows Explorer window, and highlight the Sprites
folder, as well as the three .PNG files (Gem.png, PlatformTiles.png
, and TitleScreen.png)
. Right-click on one of the highlighted files and select Copy.
Switch back to the Visual Studio window, right-click on the Textures folder in the content project, and select Paste. This should result in a Sprites folder under Textures folder and all three images being added to your project.
Right-click on the Gemstone HunterContent project, and add a new folder called Fonts
.
Create a new SpriteFont
object called Pericles8
in the Fonts
folder. Set the font name to Pericles
and the size to 8
.
At the top of the Solution Explorer window, right-click on the solution (Solution'Gemstone Hunter') and select Add | New Project....
From the new project window, select the Windows Game Library (4.0) template. Name the project Tile Engine
, and add it to the solution:
We now have three projects in our solution—The Gemstone Hunter game project, the associated content project, and the Tile Engine game library project. The game project itself will be detailed in the next chapter. The Tile Engine project will contain the code for, not surprisingly, the game's tile engine, which will be shared with yet another project, the Level Editor, which we will create shortly.
In Robot Rampage, we built a simple tile engine that displayed a single layer of tiles from a two-dimensional array of integers, which represented the tiles associated with each map square. For Gemstone Hunter, we will construct a new tile engine that handles multiple tile layers, including a layer of tiles that are drawn in the foreground, appearing in front of the player:
Since we need to store more information about an individual map square, we will begin by defining a class that will contain all of the information we need about a particular square.
Add a new class called MapSquare
to the Tile Engine project.
Modify the declaration of the MapSquare
class by adding the<Serializable()>
attribute before the class declaration. The class declaration should read:
<Serializable()> Public Class MapSquare
Add the declarations region to the MapSquare class:
#Region "Declarations" Public LayerTiles(3) As Integer Public CodeValue As String = " Public Passable As Boolean = True #End Region
Add a constructor to the MapSquare
class:
#Region "Constructor" Public Sub New( background As Integer, interactive As Integer, foreground As Integer, code As String, passable As Boolean) LayerTiles(0) = background LayerTiles(1) = interactive LayerTiles(2) = foreground CodeValue = code Me.Passable = passable End Sub #End Region
Add the TogglePassable()
method to the MapSquare
class:
In step 2, we added a new type of information to our code file called an attribute. Attributes indicate to the Visual Basic compiler that the item following it should be treated differently in some way compared to the norm for that item type.
In our case, we want to be able to save the tile map that we will be creating to a file and then load that file in both the editor and the game projects. To do this, we need to indicate to Visual Basic that the object is serializable—that is, the object can be converted into a byte-stream, which can be stored and reloaded at a later point.
Modifying serializable classes
We need to be fairly sure we have covered all of the bases with our MapSquare
class, because once we have used the class to save map files, any changes made (even renaming variables) will cause reloading the saved data to either fail completely, or return corrupted results. This is because the binary serialization method that we will be using essentially grabs the in-memory representation of the data and saves it to disk, reloading it in the same manner. If the definition of the structure changes in any way, the binary format that was output will not match the new in-memory format, resulting in unpredictable problems with our loaded maps.
The LayerTiles
array stores three integer values representing the tile images on the background, interactive, and foreground layers. Depending on the needs of your game, you could create any number of layers. When we draw them, we will use the index of the layer to determine the depth at which the sprite is drawn, ensuring that they appear in the correct order on the screen.
While building our map, we can associate a string value with each individual map square by setting the CodeValue
variable. This variable will allow us to create special features, such as map transitions, traps, and invisible barriers to enemy movement.
Finally, to determine if the map square blocks player movement, we can check the Passable
member variable. If it is false
, the player cannot move into this square. The default for all MapSquares
is to be open to movement (Passable
set to true)
.
Create a new module Camera
in the Tile Engine project.
Modify the declaration of the Camera
module to make it public:
Public Module Camera
Add declarations to the Camera
module:
#Region "Declarations" Private _position As Vector2 = Vector2.Zero Private _viewPortSize As Vector2 = Vector2.Zero #End Region
Add properties to the Camera
module:
#Region "Properties" Public Property WorldRectangle As Rectangle = New Rectangle(0, 0, 0, 0) Public Property ViewPortWidth As Integer Get Return CInt(Int(_viewPortSize.X)) End Get Set(ByVal value As Integer) _viewPortSize.X = value End Set End Property Public Property ViewPortHeight As Integer Get Return CInt(Int(_viewPortSize.Y)) End Get Set(ByVal value As Integer) _viewPortSize.Y = value End Set End Property Public Property Position As Vector2 Get Return _position End Get Set(ByVal value As Vector2) _position = New Vector2( MathHelper.Clamp(value.X, WorldRectangle.X, WorldRectangle.Width - ViewPortWidth), MathHelper.Clamp(value.Y, WorldRectangle.Y, WorldRectangle.Height - ViewPortHeight)) End Set End Property Public ReadOnly Property ViewPort As Rectangle Get Return New Rectangle( CInt(Int(Position.X)), CInt(Int(Position.Y)), ViewPortWidth, ViewPortHeight) End Get End Property #End Region
Add methods to the Camera
module:
#Region "Public Methods" Public Sub Move(offset As Vector2) Position += offset End Sub Public Function ObjectIsVisible(bounds As Rectangle) As Boolean Return ViewPort.Intersects(bounds) End Function Public Function WorldToScreen(worldLocation As Vector2) As Vector2 Return worldLocation - Position End Function Public Function WorldToScreen(worldRect As Rectangle) As Rectangle Return New Rectangle( worldRect.Left - CInt(Int(Position.X)), worldRect.Top - CInt(Int(position.Y)), worldRect.Width, worldRect.Height) End Function Public Function ScreenToWorld(screenLocation As Vector2) As Vector2 advanced tile enginecamera, rebuildingReturn screenLocation + Position End Function Public Function ScreenToWorld(screenRect As Rectangle) As Rectangle Return New Rectangle( screenRect.Left + CInt(Int(Position.X)), screenRect.Top + CInt(Int(Position.Y)), screenRect.Width, screenRect.Height) End Function #End Region
The Transform()
methods from the Robot Rampage Camera have been renamed to WorldToScreen()
, and a new pair of methods called ScreenToWorld()
have been added. We will need to respond to mouse events in the map editor, and the mouse position is reported in screen coordinates. These new methods will assist in determining the map square underneath the mouse cursor.
We saw in Robot Rampage that converting world coordinates to screen coordinates was a simple matter of subtracting the position of the camera from the coordinate. The reverse is also true. To convert from a screen coordinate to a world map coordinate, we add the position of the camera.
The basic concepts behind our original tile-based map engine are unchanged, and indeed most of the code for our new tile engine can be brought over from Robot Rampage. We need to make modifications to the way the map itself is stored, and the way we access (for both reading and setting) map tiles to accommodate the MapSquare
class, instead of a simple array of integers.
In the Tile Engine project, rename the Class1.vb
file that was generated by the Game Library project template to TileMap.vb
by right-clicking on Class1.vb
and selecting Rename. Visual Studio will ask if you wish to rename all references to the class as well. Go ahead and click on Yes. We have not referenced our new class anywhere, so no other code is actually updated.
Double-click on the newly renamed TileMap.vb
file to open it in the editor.
Modify the declaration of the TileMap
class to add two Imports
directives and to change the class into a Public Module
. The entire text of the file should now be:
Imports System.Runtime.Serialization.Formatters.Binary Imports System.IO Public Module TileMap End Module
Add declarations to the TileMap module:
#Region "Declarations" Public Const TileWidth As Integer = 48 Public Const TileHeight As Integer = 48 Public Const MapWidth As Integer = 160 Public Const MapHeight As Integer = 12 Public Const MapLayers As Integer = 3 Private Const skyTile As Integer = 2 Private mapCells(mapWidth,mapHeight) As MapSquare Public EditorMode As Boolean = False Public spriteFont As SpriteFont Private tileSheet As Texture2D #End Region
Add the TilesPerRow
property to the TileMap
module:
Add the Initialize()
method to the TileMap
module:
#Region "Initialization" Public Sub Initialize(tileTexture As Texture2D) tileSheet = tileTexture For x As Integer = 0 To MapWidth - 1 For y As Integer = 0 To MapHeight - 1 For z As Integer = 0 To MapLayers - 1 mapCells(x,y) = New MapSquare(skyTile, 0, 0, "", True) Next Next Next End Sub #End Region
Add the TileSourceRectangle()
function to the TileMap
module:
#Region "Tile Handling Methods" Public Function TileSourceRectangle(index As Integer) As Rectangle Return New Rectangle( (index Mod TilesPerRow) * TileWidth, CInt(Int(index / TilesPerRow)) * TileHeight, TileWidth, TileHeight) End Function #End Region
Before we continue with the implementation of the TileMap
class, let's look at the difference we can see so far from the same class in Robot Rampage.
Right from the start, we have a directive that we have not used before: Imports
. There are many, many namespaces (pre-defined pieces of code) supplied with the .Net Framework, and everything we have used up until this point has been accessible without using the Imports
directive. All Imports
does is tell the Visual Basic compiler that we will be using objects from the specified namespace and lets us avoid typing the entire namespace name whenever we want to use one of its components. The System.Runtime.Serialization.Formatters.Binary
assembly provides a simple way for us to write our level files out to the disk (and read them back in again), by converting the array of MapSquare
objects in memory into a format that can be written to a file.
Importing namespaces
Imports
directives provide us with a shorthand way of using classes in namespaces that we would not normally access. Without it, we must enter the full name (System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
) of the class we wish to use, instead of simply using the class name (BinaryFormatter
). Both approaches will work, but if we are accessing objects from a long namespace more than a couple of times in a code file, the Imports
directive saves quite a bit of typing.
If you examine C# XNA code, you will often see several using
directives at the top of each code file, with namespaces, such as Microsoft.XNA.Framework.Graphics
. We do not need to import these in Visual Basic, as the project templates take care of this for us.
You may notice that both the declaration and initialization regions are quite a bit shorter than they were in Robot Rampage. In Gemstone Hunter, we will define a tile sheet image that contains the tiles we will use in the game. Unlike our previous games, we will not simply store all of our game's graphics on the same sprite sheet, but break it up into multiple sheets. We have a texture dedicated to the tile images for our game, and each type of game object has a texture file of its own:
By defining a single sprite sheet to hold only tiles for the tile map, we can treat the sheet in a special manner and decide that the sheet will be evenly divided into as many tiles as will fit on the image. Our tile sheet image is 480x480
pixels, and with a 48x48
tile size, we have 10 rows of 10 tiles, for 100 total tiles available to our game. We could always increase the size of the image to add more tiles, though we would want to keep it to increments of 48 pixels in each direction, to make the math easier.
We will number the tiles starting with zero in the upper-left corner of the tile sheet and progressing across a row. When we reach the end of the row, we return to the left side of the image and start a new row. The TilesPerRow
property and the TileSourceRectangle
method replace the array of pre-defined tiles, by providing a way to locate the source rectangle for any tile we wish to draw on the tile sheet image.
Our map is still represented by an array, but around this time, it is a two-dimensional array of MapSquare
objects, instead of simple integers. We have also rearranged our terminology to reflect the more complex nature of our tile map. What we referred to as squares in Robot Rampage, we now call cells. Any of our code that dealt with getting or setting information about map tiles in Robot Rampage will need to be updated to handle the entire MapSquare
object in each cell, instead of a simple integer value.
The TileMap
itself will include support for being used in editing mode, which can be toggled by setting the EditorMode
member variable. While in editor mode, we will draw the contents of the CodeValue
member of the MapSquare
class on top of each square, so the TileMap
class needs a SpriteFont
for use with SpriteBatch.DrawString()
.
Our Initialize()
method is greatly simplified by the removal of the tiles array, allowing us to establish all of the mapCells
as MapSquares
, with empty tiles on each layer. Our tile sheet contains a fully transparent tile in the upper-left corner (tile zero) and a blue sky tile in the third position (tile two), so by filling the map with squares containing tile two on the background layer, and tile zero on the other two layers, we end up with an empty map with a blue sky background. This will simply save time when creating a new map with the map editor, by letting us skip drawing the sky on each map.
Add methods dealing with locating map cells to the TileMap
module:
#Region "Information about Map Cells" Public Function GetCellByPixelX(pixelX As Integer) As Integer Return CInt(Int(pixelX / TileWidth)) End Function Public Function GetCellByPixelY(pixelY As Integer) As Integer Return CInt(Int(pixelY / TileHeight)) End Function Public Function GetCellByPixel(pixelLocation As Vector2) As Vector2 Return New Vector2( GetCellByPixelX(CInt(Int(pixelLocation.X))), GetCellByPixelY(CInt(Int(pixelLocation.Y)))) End Function Public Function GetCellCenter( cellX As Integer, cellY As Integer) As Vector2 Return New Vector2( tile engineTileMap moduleCSng((cellX * TileWidth) + (TileWidth / 2)), CSng((cellY * TileHeight) + (TileHeight / 2))) End Function Public Function GetCellCenter(cell As Vector2) As Vector2 Return GetCellCenter( CInt(Int(cell.X)), CInt(Int(cell.Y))) End Function Public Function CellWorldRectangle( cellX As Integer, cellY As Integer) As Rectangle Return New Rectangle( cellX * TileWidth, cellY * TileHeight, TileWidth, TileHeight) End Function Public Function CellWorldRectangle(cell As Vector2) As Rectangle Return CellWorldRectangle( CInt(Int(cell.X)), CInt(Int(cell.Y))) End Function Public Function CellScreenRectangle( cellX As Integer, cellY As Integer) As Rectangle Return Camera.WorldToScreen(CellWorldRectangle(cellX, cellY)) End Function Public Function CellSreenRectangle(cell As Vector2) As Rectangle Return CellScreenRectangle(CInt(Int(cell.X)), CInt(Int(cell.Y))) End Function Public Function CellIsPassable( cellX As Integer, cellY As Integer) As Boolean tile engineTileMap moduleDim square As MapSquare = GetMapSquareAtCell(cellX, cellY) If IsNothing(square) Then Return True Else Return square.Passable End If End Function Public Function CellIsPassable(cell As Vector2) As Boolean Return CellIsPassable(CInt(Int(cell.X)), CInt(Int(cell.Y))) End Function Public Function CellIsPassableByPixel( pixelLocation As Vector2) As Boolean Return CellIsPassable( GetCellByPixelX(CInt(Int(pixelLocation.X))), GetCellByPixelY(CInt(Int(pixelLocation.Y)))) End Function Public Function CellCodeValue( cellX As Integer, cellY As Integer) As String Dim square As MapSquare = GetMapSquareAtCell(cellX, cellY) If IsNothing(square) Then Return "" Else Return square.CodeValue End If End Function Public Function CellCodeValue(cell As Vector2) As String Return CellCodeValue(CInt(Int(cell.X)), CInt(Int(cell.Y))) End Function #End Region
Add methods for manipulating MapSquares
to the TileMap
module:
#Region "Information about MapSquare objects" Public Function GetMapSquareAtCell( tileX As Integer, tileY As Integer) As MapSquare If ((tileX >= 0) And (tileX < MapWidth) And (tileY >= 0) And (tileY < MapHeight)) Then Return mapCells(tileX, tileY) Else Return Nothing End If End Function Public Sub SetMapSquareAtCell( tileX As Integer, tileY As Integer, square As MapSquare) If ((tileX >= 0) And (tileX < MapWidth) And (tileY >= 0) And (tileY < MapHeight)) Then mapCells(tileX, tileY) = square End If End Sub Public Sub SetTileAtCell( tileX As Integer, tileY As Integer, layer As Integer, tileIndex As Integer) If ((tileX >= 0) And (tileX < MapWidth) And (tileY >= 0) And (tileY < MapHeight)) Then mapCells(tileX, tileY).LayerTiles(layer) = tileIndex End If End Sub Public Function GetMapSquareAtPixel( pixelX As Integer, pixelY As Integer) As MapSquare Return GetMapSquareAtCell( GetCellByPixelX(pixelX), GetCellByPixelY(pixelY)) End Function Public Function GetMapSquareAtPixel( pixelLocation As Vector2) As MapSquare Return GetMapSquareAtPixel( CInt(Int(pixelLocation.X)), CInt(Int(pixelLocation.Y))) End Function #End Region
Much of the code we need for dealing with the tile map is unchanged from our simpler tile engine, aside from the changes to accommodate our new cell and MapSquare
terminology. We also use the MapSquare
type when getting and setting the contents of map cells instead of integers.
The SetTileAtCell()
method may seem out of place among the methods dealing with MapSquare
objects. Its purpose is to provide a way to change the tile index of a single layer in a cell, without repackaging the cell's entire MapSquare
object. By passing SetTileAtCell()
a cell location, layer number, and tile index, we can change the content of a single layer—exactly what we will need to do when building the map editor.
Because the game engine will need easy access to the Passable
and CodeValue
members of a cell (without the need to deal with the tile layer values), we have created the shortcut methods CellIsPassable()
and CellCodeValue()
. When the time comes to move the player and enemy objects during game play, we will make extensive use of these methods to determine what map squares are accessible to game entities.
We are now ready to assemble the code necessary to draw the enhanced tile map to the screen. We need to account for all three layers of the map, ensuring that each will be drawn in the proper relationship to the others—the background layer appearing furthest away, the interactive layer drawn above it, and finally the foreground layer drawn nearest to the screen.
Add the Draw()
method to the TileMap
module:
#Region "Drawing" Public Sub Draw(spriteBatch As SpriteBatch) Dim startX As Integer startX = GetCellByPixelX(CInt(Int(Camera.Position.X))) Dim endX As Integer endX = GetCellByPixelX(CInt(Int(Camera.Position.X)) + Camera.ViewPortWidth) Dim startY As Integer startY = GetCellByPixelY(CInt(Int(Camera.Position.Y))) Dim endY As Integer endY = GetCellByPixelY(CInt(Int(Camera.Position.Y)) + Camera.ViewPortHeight) For x As Integer = startX To endX For y As Integer = startY To endY For z As Integer = 0 To MapLayers - 1 If ((x >= 0) And (y >= 0) And (x < MapWidth) And (y < MapHeight)) Then spriteBatch.Draw( tileSheet, CellScreenRectangle(x,y), TileSourceRectangle( mapCells(x,y).LayerTiles(z)), Color.White, 0.0, Vector2.Zero, SpriteEffects.None, 1F - (CSng(z) * 0.1F)) End If Next If EditorMode Then DrawEditModeItems(spriteBatch, x, y) End If Next Next End Sub Public Sub DrawEditModeItems( spriteBatch As SpriteBatch, x As Integer, y As Integer) If ((x < 0) Or (x >= MapWidth) Or (y < 0) Or (y >= MapHeight)) Then Return End If tile mapdrawingIf Not CellIsPassable(x,y) Then spriteBatch.Draw( tileSheet, CellScreenRectangle(x, y), TileSourceRectangle(1), New Color(255, 0, 0) * 0.4F, 0.0, Vector2.Zero, SpriteEffects.None, 0.0) End If If mapCells(x, y).CodeValue <> "" Then Dim screenRect As Rectangle = CellScreenRectangle(x, y) spriteBatch.DrawString( spriteFont, mapCells(x, y).CodeValue, New Vector2(screenRect.X, screenRect.Y), Color.White, 0.0, Vector2.Zero, 1.0, SpriteEffects.None, 0.0) End If End Sub #End Region
Once again, the Draw()
method is very familiar. We still use the position of the camera to determine the range of cells that need to be drawn to the screen, but this time we nest a third loop inside the horizontal and vertical loops, which draws the tiles from each of the three layers.
This SpriteBatch.Draw()
call is unlike any of the others we have made, because this time we are specifying a layer depth as the last parameter of the call. In the past, when we have used the advanced form of the Draw()
method, we have always left this parameter at a value of 0
.
Layer depths
By specifying the layer depth at which to draw the sprite, we can execute the draw calls in any order and allow the graphics card to place them properly according to their sorted depth. In order for this to work, we need to specify some additional parameters to the SpriteBatch.Begin()
method call we will use in the Game1
class' Draw()
method. We need to specify SpriteSortMode.BackToFront
as the first parameter of the call. This tells the SpriteBatch
object to pay attention to the layer depth information, otherwise the order in which the sprites are drawn will still be used for sorting, and the layer depths will be ignored. The FrontToBack
reverses the meaning of the layer depth parameter, making items at 1.0
closer to the camera than items at 0.0
. None of the other modes (Immediate
, Deferred
, and Texture)
will allow us to properly sort sprites, because they either rely on the drawing order (Immediate and Deferred)
or group the drawn sprites in order by the source texture they come from (Texture
).
If our tile engine is currently in editor mode, the DrawEditModeItems()
method is called after each tile is drawn. This uses the white tile at tile index one to draw a semi-transparent red block over any square that is not passable by the player. Additionally, the method uses SpriteBatch.DrawString()
to display the content of the CodeValue
variable associated with each map cell, if it is not empty.
Right-click on the Gemstone Hunter project in Solution Explorer, and click on Add Reference....
Click on the Projects tab in the Add Reference window, and ensure that the Tile Engine project is selected. Click on OK:
Open the Game1.vb
file in the Gemstone Hunter project, and add the following Imports
directive to the top of the file:
Imports Tile_Engine
Add the following to the LoadContent()
method of the Game1
class:
TileMap.Initialize( Content.Load(Of Texture2D)("TexturesPlatformTiles")) TileMap.SetTileAtCell(3, 3, 1, 10) Camera.WorldRectangle = new Rectangle(0, 0, 160 * 48, 12 * 48) Camera.Position = Vector2.Zero Camera.ViewPortWidth = 800 Camera.ViewPortHeight = 600
Replace the current Draw()
method of the Game1
class with the following:
Protected Overrides Sub Draw(gameTime As GameTime) GraphicsDevice.Clear(Color.Black) spriteBatch.Begin( SpriteSortMode.BackToFront, BlendState.AlphaBlend) TileMap.Draw(spriteBatch) spriteBatch.End() MyBase.Draw(gameTime) End Sub
By referencing the Tile Engine project from the Gemstone Hunter project, we can utilize the code from the Tile Engine project, by including an Imports
directive referencing the Tile_Engine
namespace.
During the LoadContent()
method, we initialize the TileMap
and Camera
classes, and add a single tile to the map, so that we will see it when we run the application.
Finally, we see the special form of the SpriteBatch.Begin()
call that we need to make, to display the different tile layers sorted in the proper order. In addition to specifying the sort mode, we also need to specify the way transparent sprites are blended together. The default is BlendState.AlphaBlend
, which is what we want to keep. Since there is no SpriteBatch.Begin()
call that allows us to specify only the sort mode, we must supply the blend mode, even though it is normally the default.
Why go to all of this trouble creating multiple projects and referencing them instead of including the MapSquare, Camera
, and TileMap
code directly into the Gemstone Hunter project? Since we are going to need to display the tile engine in both the game and the level editor, splitting it out into its own project and referencing it allows us to create only one set of source files for the map components. If we were to include the code directly in the game, we would need to make another copy of those three items for our map editor project. Any time we need to update the entities, we would need to remember to update them in both places, increasing the chance of introducing errors and inconsistencies into our project.
With the tile engine in place, we are now ready to begin building the map editor that we will use to create levels for the Gemstone Hunter game. The map editor will combine both an XNA Game and a Windows Forms form to take advantage of the Windows Forms controls (menus, buttons, checkboxes, and so on) to save us the time of recreating all of these controls within XNA.
Since we know that we want to create a Windows Forms application for our level editor, it is tempting to use the Windows Forms application template that is included with Visual Studio. However, it is much easier to add a Windows Forms object to an XNA game project than to work the other way around, and try to incorporate all of the components of an XNA project into the Windows Forms template.
In the Solution Explorer window, right-click on the top-most item that reads Solution 'Gemstone Hunter' (3 Projects), and select Add | New Project....
Select the Windows Game (4.0) project template.
Name the project Level Editor
, and click on OK.
Right-click on the Level Editor project, and select Add Reference....
On the Projects tab of the Add Reference window, select Tile Engine, and click on OK:
Right-click on the Level Editor project again, and select Add Content Reference....
Select the Gemstone HunterContent (Content) project, and click OK.
Right-click on the Level Editor project in the Solution Explorer window, and click on Set as StartUp Project.
Your solution now has five separate projects: the game project (simply called Gemstone Hunter), the game's Content project, the Tile Engine game library, the Level Editor, and the Level EditorContent project. Because we have created a content reference in the Level Editor project to the game's content project, all of the game's content will be available in the level editor as well. We will leave the Level Editor Content project empty, but if we needed content items available in the Level Editor that were not needed by the game, we could place them here.
By setting the Level Editor as the startup project, whenever we execute our code from the development environment, the Level Editor will be the application that starts (as opposed to starting the actual game). This setting is just a convenience for us while we work on the editor:
Just like our Gemstone Hunter project, the Level Editor project contains a reference to the Tile Engine project, allowing it to make use of the tile engine code without duplicating it.
Right-click on the Level Editor project in Solution Explorer, and select Add | Windows Form.
Name the form MapEditor.vb
, and click on the Add button.
The MapEditor form will automatically open in Design mode as a blank window:
In the properties window (right-click on the form and select Properties if you have hidden the properties window), set the Size property to 700, 670
pixels.
On the left edge of the screen, open the Toolbox panel (View | Other Windows | Toolbox if it is hidden) and expand the All Windows Forms section. Locate the MenuStrip
control and drag an instance of it onto the MapEditor
form. Leave the menu items empty for now.
Drag a new PictureBox
control from the Toolbox panel onto the form.
Click on the newly created PictureBox
, and set the following properties in the Properties window:
Name:pctSurface
Anchor:Top, Bottom, Left, Right
Location:184, 27
Modifiers : Public
Size:471, 576
Right-click on the MapEditor.vb
file in Solution Explorer, and select View Code to open the source code for the MapEditor
form.
Add the following Imports
directives to the MapEditor
class file:
Imports System.IO Imports System.Drawing Imports System.Drawing.Imaging Imports System.Windows.Forms Imports Tile_Engine
Add the following declaration to the MapEditor
class:
Public game As Game1
In the Level Editor project, double-click on the Program.vb
file, and replace the Main()
method with the following:
Sub Main(ByVal args As String()) Dim form As MapEditor = New MapEditor() form.Show() form.game = New Game1( form.pctSurface.Handle, form, form.pctSurface) form.game.Run() End Sub
Still in the Level Editor project, open the Game1.vb
class file, and add the following declarations to the declarations area:
Private drawSurface As IntPtr Private parentForm As System.Windows.Forms.Form Private pictureBox As System.Windows.Forms.PictureBox
Replace the Game1
constructor with the following:
Public Sub New(drawSurface As IntPtr, parentForm As System.Windows.Forms.Form, surfacePictureBox As System.Windows.Forms.PictureBox) graphics = New GraphicsDeviceManager(Me) Content.RootDirectory = "Content" Me.drawSurface = drawSurface Me.parentForm = parentForm Me.pictureBox = surfacePictureBox AddHandler graphics.PreparingDeviceSettings, AddressOf graphics_PreparingDeviceSettings Mouse.WindowHandle = drawSurface End Sub
Add the graphics_PreparingDeviceSettings()
event handler to the Game1
class:
Private Sub graphics_PreparingDeviceSettings( sender As object, e As PreparingDeviceSettingsEventArgs) With e.GraphicsDeviceInformation.PresentationParameters .DeviceWindowHandle = drawSurface End With End Sub
Execute the project:
As it turns out, we can add a form to an XNA Game project in the same way we would add a form to a standard Windows application. The form will not show up by default, however. The Program.vb
file is the driver behind the XNA project, and the Main()
method gets executed when the application starts.
Other methods
This is not the only way to integrate Windows Forms and XNA. Check out the Winforms samples at the XNA Creators Club website (http://creators.xna.com/en-US/sample/winforms_series1) for additional information. The Creators Club website has many samples on other topics as well.
For a normal XNA Game, the Main()
method creates an instance of the Game1
class and calls its Run()
method. In order to combine our game with a Windows Form, we have altered this startup process.
Instead of creating an instance of the Game1
class in the Program
module (where Main()
lives), we create an instance of our MapEditor
form class, and then create an instance of Game1
inside the form.
This will simplify addressing components of the Game1
class from the MapEditor
form, allowing us to change properties in the game object in response to user interaction with form controls.
We pass the window handle of the PictureBox
to the Game1
constructor. The window handle uniquely identifies the display area of PictureBox
, allowing our code to redirect XNA's drawing commands from the original game window to the area defined by the PictureBox
.
In order to tell XNA that the graphics we draw should be displayed on the PictureBox
, we add an event handler to the PreparingDeviceSettings
event of the GraphicsDeviceManager
class. In this event handler, we simply set the DeviceWindowHandle
associated with the graphics device to drawSurface
, the value that we passed in when the instance of the Game1
class was created inside Program.vb
.
The last thing the constructor does is tell XNA's Mouse
class that it should report coordinates relative to the PictureBox
that the game will be drawn onto. If we do not make this setting, we will not be able to determine where on our game display the mouse cursor is located.
Alas! We still have a few problems. The most obvious is that there is a big, empty window that shows up on top of our level editor window. This is the window that would normally contain the XNA game. We have moved the output of the XNA drawing commands to a new drawing surface, but the old window still gets created.
The second problem will not be apparent right away, but will cause us trouble when we resize the map editor form while it is running. When the size of the PictureBox
changes, we need to let XNA know that the back-buffer size has changed and let our Camera
module know that the view port on the display has changed as well.
To address both of these problems, we will add additional event handlers to the system events that occur when the visibility of the game's empty window changes and when the picture box is resized.
Event handlers
Just about everything that happens in Windows happens in response to events. When the user clicks on a button, resizes a window, selects an item from a menu, or any number of other actions, Windows notifies everything that might be impacted by that action that the event has taken place. All Windows Forms controls have event handlers that determine how they respond to those events. In Visual Basic, we can easily add an event handler to an object, even if it already has event handlers, using the AddHandler
statement.
Add the following directive to the top of the Level Editor project's Game1
class:
Imports Tile_Engine
Add the following declaration to the declarations area of the Game1
class of the Level Editor project:
Private gameForm As System.Windows.Forms.Control
Add the following code to the constructor of the Game1
class:
gameForm = System.Windows.Forms.Control. FromHandle(Me.Window.Handle) AddHandler gameForm.VisibleChanged, AddressOf gameForm_VisibleChanged AddHandler pictureBox.SizeChanged, AddressOf pictureBox_SizeChanged
Add the two event handlers to the Game1
class:
Private Sub gameForm_VisibleChanged(sender As object, e as EventArgs) If gameForm.Visible Then gameForm.Visible = False End If End Sub event handlersaddingPrivate Sub pictureBox_SizeChanged(sender As object, e As EventArgs) If (parentForm.WindowState <> System.Windows.Forms.FormWindowState.Minimized) Then graphics.PreferredBackBufferWidth = pictureBox.Width graphics.PreferredBackBufferHeight = pictureBox.Height Camera.ViewPortWidth = pictureBox.Width Camera.ViewPortHeight = pictureBox.Height graphics.ApplyChanges() End If End Sub
Execute the application.
To end the application, you will need to return to the Visual Studio interface and select Stop Debugging from the Debug menu.
At first, the gameForm_VisibleChanged()
method may look odd. It may seem that it would prevent the game from ever being displayed, as it sets the gameForm.Visible
property to false
, if it ever ends up as true
.
Remember, though, that the form (or window), which is automatically generated by XNA, will always be empty. Our game's display is now being redirected to the PictureBox
on our MapEditor
form. This means that we really do want to make sure that the game's form is never visible. Whenever its visibility changes, we ensure that Visible
is set to false
, to keep it from appearing.
When the size of the PictureBox
changes—since it is anchored to the sides of the MapEditor
form, resizing the form will resize the PictureBox—we
want to update the game's GraphicsDeviceManager
with the new size of our display area, and pass those updates along to the Camera
module. We need to be careful to check the WindowState
of the parent form when processing the resize event. Because the back-buffer width and height must be greater than zero, if we attempt to set them when the form has been minimized, the application will crash.
Clicking on the window close button or pressing Alt + F4 to end the application will no longer work, because the hidden window that Game1
would normally run in will not close automatically.
Double-click on the MapEditor.vb
file in Solution Explorer to open the MapEditor
form in the design window.
Click on the empty MenuStrip
that you previously added to the form, and add menu entries for the following items (including the&
symbols, which will create keyboard shortcuts for the menu entries):
&File
&Load Map
&Save Map
: A single dash, creating a separator line
E&xit
&Tools
&Clear Map
&Layer
&Background
&Interactive
&Foreground
Double-click on the Exit item under the File menu to have Visual Studio automatically generate an event handler for the Exit menu item.
Enter the following code into the ExitToolStripMenuItem_Click()
event handler:
game.Exit() Application.Exit()
We now have a standard Windows menu attached to our form with a few entries for our level editor. In order to add code to menu items other than the Exit command, we need to make modifications to our Game1
class, so we will come back to them after we have laid out all of the items on our display.
What are all those ampersands?
In step two, each of the menu item entries contains an ampersand (&) character, usually as the first character in the entry. When the MenuStrip
control sees these characters, instead of displaying them in the menu, it causes the next character in the name to be underlined and treated as a shortcut key. By labeling the File menu as &File, the item will be displayed as File, and pressing Alt + F will open the File menu. Items within the File menu can then be accessed by pressing their own shortcut keys (L for Load, S for Save, and so on).
Expand the Textures
folder in the Gemstone Hunter Content project.
Click on the PlatformTiles.png
file. The Properties window below Solution Explorer will update to display the properties of the image file.
Change the Copy to Output Directory property to Copy if newer.
Switch back to the Design mode view of the MapEditor
form.
Add an ImageList
control to the MapEditor
form by double-clicking on the control in the Toolbox window. It will show up in the gray area below the form, as it is a non-visible control. Set the following properties on the ImageList:
Name:imgListTiles
ColorDepth:Depth32Bit
ImageSize: 48, 48
Add a ListView
control to the MapEditor
form, and give it the following properties:
Name:listTiles
HideSelection:False
LargeImageList:imgListTiles
Location:10, 27
MultiSelect:False
Size:173, 315
TileSize:48, 48
View:Tile
Right-click on MapEditor.vb
in Solution Explorer, and select View Code.
Add the following helper method to the MapEditor
class:
Private Sub LoadImageList() Dim filepath As String Filepath = Application.StartupPath + "ContentTexturesPlatformTiles.png" Dim tileSheet As Bitmap = New Bitmap(filepath) Dim tilecount As Integer = 0 Dim y As Integer Dim x As Integer Dim tilesAcross As Integer Dim tilesDown As Integer tilesAcross = CInt(Int(tileSheet.Width / TileMap.TileWidth)) tilesDown = CInt(Int(tileSheet.Height / TileMap.TileHeight)) For y = 0 To tilesDown - 1 For x = 0 To tilesAcross - 1 Dim newBitmap As Bitmap Dim rect As Rectangle Rect = New Rectangle( x * TileMap.TileWidth, y * TileMap.TileHeight, TileMap.TileWidth, TileMap.TileHeight) newBitmap = tileSheet.Clone( rect, System.Drawing.Imaging.PixelFormat.DontCare) imgListTiles.Images.Add(newBitmap) Dim itemName As String = "" If tilecount = 0 Then formtile section controlsitemName = "Empty" End If If tilecount = 1 Then itemName = "White" End If listTiles.Items.Add(new ListViewItem(itemName, tilecount)) tileCount += 1 Next Next End Sub
Double-click on the MapEditor.vb
file to reopen the Design mode view of the form.
Double-click on the title bar for the MapEditor
window, causing Visual Studio to automatically generate an event handler for the MapEditor_Load
event.
Add the following line to the MapEditor_Load()
event handler:
Execute the application:
After executing the project, you should have a scrollable view of the tiles in the tile set in the ListView
control in the upper-left corner of the editor window. The LoadImageList()
helper method reads the PlatformTiles.png
file, and splits it up into tile-sized chunks, which it stores inside the imgListTiles ImageList
control.
Notice that we needed to modify the properties of the PlatformTiles.png
file so that it gets copied to the output directory, because normally the content pipeline would convert the PNG file into an XNB file, which is unreadable by the standard Windows Bitmap class. The XNB file will still be created and copied, but the PNG file will also be placed in the output folder where our MapEditor
code can find it.
As each image is added to the imgListTiles
control, entries are also added to the listTiles ListView
control. The first and second tiles are given special labels in the ListView
(Empty
and White)
, as they will both appear to be empty white squares since the background of the ListView
itself is white.
The MapEditor_Load()
event handler runs when the form is loaded, as its name implies. We will be expanding on this event handler as we add more controls to the form, since it gives us a convenient place to perform initialization.
Double click on the MapEditor.vb
file in Solution Explorer to reopen the map editor in the design view if it is not already open.
In the Toolbox window, double-click on the VScrollBar
control, to add it to the form. Give it the following properties:
Name:VScrollBar1
Anchor:Top, Bottom, Right
LargeChange:48
Location:658, 27
Size:17, 576
In the Toolbox window, double-click on the HScrollBar
control to add it to the form. Give it the following properties:
Name:HScrollBar1
Anchor:Bottom, Left, Right
LargeChange:48
Location:184, 606
Size:474, 17
Switch back to the code view for the MapEditor.vb
file, and add the FixScrollBarScales()
helper method to the MapEditor
class:
Private Sub FixScrollBarScales() Camera.ViewPortWidth = pctSurface.Width Camera.ViewPortHeight = pctSurface.Height Camera.Move(Vector2.Zero) VScrollBar1.Minimum = 0 VScrollBar1.Maximum = Camera.WorldRectangle.Height - Camera.ViewPortHeight HScrollBar1.Minimum = 0 HScrollBar1.Maximum = Camera.WorldRectangle.Width - Camera.ViewPortWidth formscroll barsEnd Sub
Edit the MapEditor_Load()
method to include a call to FixScrollBarScales():
FixScrollBarScales()
Double-click on MapEditor.vb
in Solution Explorer to reopen the Design mode view of the MapEditor
form.
Click on the title bar of the MapEditor
window to select the form as the active control.
In the Properties window, ensure that the drop-down box at the top of the window reads MapEditor System.Windows.Forms.Form.
Still in the Properties widow, click on the yellow lightning bolt button in the toolbar to switch the view from properties to event handlers:
Scroll down to locate the Resize event, and double-click on the empty box to the right of the event name, causing Visual Studio to automatically generate an event handler for the MapEditor_Resize
event.
Add the following to the MapEditor_Resize()
method:
FixScrollBarScales()
Switch back to properties view in the Properties window by going back to the Design mode view of the form and clicking on the small page icon to the left of the lightning bolt icon in the Properties window toolbar.
We now have scroll bars attached to the sides of the game's display area. When the form is initially displayed, and then again whenever it is resized, the scroll bars will be rescaled so that they cover the entire area of the game's tile map.
We will use these scroll bars to move around on the map while editing, though their actual implementation will again be tied to changes to the Game1
class.
Add a GroupBox
to the MapEditor
form, and give it the following properties:
Name:groupBoxRightClick
Location:10, 346
Size:173, 103
Text:Right Click Mode
Add a RadioButton
control inside the groupBoxRightClick GroupBox
by dragging it from the Toolbox window and dropping it inside the Groupbox
. Give it the following properties:
Name:radioPassable
Checked:True
Location:6, 17
Text:Toggle Passable
Add another RadioButton
control inside the groupBoxRightClick
control with the following properties:
Name:radioCode
Location:6, 35
Text:Code
Add a TextBox
control inside the groupBoxRightClick
control, with the following properties:
Name:txtNewCode
Location:62, 36
Size:103, 20
Add a Label
control inside the groupBoxRightClick
, with the following properties:
Name:lblCurrentCode
Location:60, 59
Text:---
Add a ComboBox
control inside the groupBoxRightClick
, with the following properties:
Name:cboCodeValues
DropDownStyle:DropDownList
Location:5, 75
Size:160, 21
Add a Label
control below the group box, with the following properties:
Name:lblMapNumber
Location:12, 452
Text:Map Number
Add a ComboBox
control below the group box, with the following properties:
Name:cboMapNumber
DropDownStyle:DropDownList
Location:81, 452
Size:94, 21
Modify the MapEditor_Load()
method of the MapEditor
class, by adding the following to the existing code:
cboCodeValues.Items.Clear() cboCodeValues.Items.Add("Gemstone") cboCodeValues.Items.Add("Enemy") cboCodeValues.Items.Add("Lethal") cboCodeValues.Items.Add("EnemyBlocking") cboCodeValues.Items.Add("Start") cboCodeValues.Items.Add("Clear") cboCodeValues.Items.Add("Custom") For x As Integer = 0 To 99 cboMapNumber.Items.Add(x.ToString().PadLeft(3, CChar("0"))) Next cboMapNumber.SelectedIndex = 0 TileMap.EditorMode = True
Return to the Design view of the MapEditor
form, and double-click on the cboCodeValues
combo box (the combo box inside the group box). Update the automatically generated event handler to read:
Private Sub cboCodeValues_SelectedIndexChanged( sender As System.Object, e As System.EventArgs ) Handles cboCodeValues.SelectedIndexChanged formfinal controlstxtNewCode.Enabled = False Select Case ( cboCodeValues.Items(cboCodeValues.SelectedIndex).ToString()) Case "Gemstone" txtNewCode.Text = "GEM" Case "Enemy" txtNewCode.Text = "ENEMY" Case "Lethal" txtNewCode.Text = "DEAD" Case "EnemyBlocking" txtNewCode.Text = "BLOCK" Case "Start" txtNewCode.Text = "START" Case "Clear" txtNewCode.Text = "" Case "Custom" txtNewCode.Text = "" txtNewCode.Enabled = True End Select End Sub
Execute the application:
We now have all of the interactive controls that we need for our level editor. The codes drop-down box provides a number of standard code values that we will use during level creation, with the ability to add custom codes, which are entered in the textbox above it.
Double-click on the Game1.vb
file in the Level Editor project to open it in the editor.
Add the following declarations to the Game1
declarations area:
Public DrawLayer As Integer = 0 Public DrawTile As Integer = 0 Public EditingCode As Boolean = False Public CurrentCodeValue As String = "" Public HoverCodeValue As String = "" Public lastMouseState As MouseState Private vscroll As System.Windows.Forms.VScrollBar Private hscroll As System.Windows.Forms.HScrollBar
Add the following lines to the Game1
constructor:
vscroll = CType(parentForm.Controls("VScrollBar1"), System.Windows.Forms.VScrollBar) hscroll =CType(parentForm.Controls("HScrollBar1"), System.Windows.Forms.HScrollBar)
Modify the LoadContent()
method of the Game1
class to read:
Protected Overrides Sub LoadContent() spriteBatch = new SpriteBatch(GraphicsDevice) Camera.ViewPortWidth = pictureBox.Width Camera.ViewPortHeight = pictureBox.Height Camera.WorldRectangle = New Rectangle( 0, 0, TileMap.TileWidth * TileMap.MapWidth, TileMap.TileHeight * TileMap.MapHeight ) TileMap.Initialize( Content.Load(Of Texture2D)("TexturesPlatformTiles")) TileMap.spriteFont = Content.Load(Of SpriteFont)("FontsPericles8") lastMouseState = Mouse.GetState() pictureBox_SizeChanged(Nothing, Nothing) End Sub
Modify the Draw()
method of the Game1
class to read:
Protected Overrides Sub Draw(gameTime As GameTime) GraphicsDevice.Clear(Color.Black) spriteBatch.Begin( SpriteSortMode.BackToFront, BlendState.AlphaBlend) TileMap.Draw(spriteBatch) spriteBatch.End() MyBase.Draw(gameTime) End Sub
To simplify communications between the Windows Form and the XNA Game, we have declared a number of public member variables that our Windows Form code will be able to update in response to user-generated events. We have also loaded a SpriteFont
to draw code values with, and a MouseState
variable to hold the state of the mouse between frames.
Finally, we declare two objects that reference the scroll bars on the level editor form. We will use these to sync-up the display of the tile map to the location of the scroll bars.
The LoadContent()
method is fairly standard, setting the size of the tile map, and loading the tile images and sprite font. The TileMap
class spriteFont
member is set to the font we loaded, and the lastMouseState
member is initialized. Right before exiting, the LoadContent()
method calls the pictureBox_SizeChanged()
method, to make sure that the graphics device has the proper dimensions for the display window.
In our Draw()
method, we have again used the expanded form of the SpriteBatch.Begin()
call in order to specify the SpriteSortMode.BackToFront
parameter.
Replace the current Update()
method in the Game1
class with the following:
Protected Overrides Sub Update(gameTime As GameTime) Camera.Position = new Vector2(hscroll.Value, vscroll.Value) Dim ms As MouseState = Mouse.GetState() if ((ms.X > 0) And (ms.Y > 0) And (ms.X < Camera.ViewPortWidth) And (ms.Y < Camera.ViewPortHeight)) Then Dim mouseLoc As Vector2 = Camera.ScreenToWorld( new Vector2(ms.X, ms.Y)) If Camera.WorldRectangle.Contains( CInt(mouseLoc.X), CInt(mouseLoc.Y)) Then If ms.LeftButton = ButtonState.Pressed Then TileMap.SetTileAtCell( TileMap.GetCellByPixelX(CInt(mouseLoc.X)), TileMap.GetCellByPixelY(CInt(mouseLoc.Y)), DrawLayer, DrawTile) End If If (ms.RightButton = ButtonState.Pressed) And (lastMouseState.RightButton = ButtonState.Released) Then If EditingCode Then TileMap.GetMapSquareAtCell( TileMap.GetCellByPixelX(CInt(mouseLoc.X)), TileMap.GetCellByPixelY(CInt(mouseLoc.Y)) ).CodeValue = CurrentCodeValue Else TileMap.GetMapSquareAtCell( TileMap.GetCellByPixelX(CInt(mouseLoc.X)), TileMap.GetCellByPixelY(CInt(mouseLoc.Y)) ).TogglePassable() End If End If HoverCodeValue = TileMap.GetMapSquareAtCell( TileMap.GetCellByPixelX( CInt(mouseLoc.X)), TileMap.GetCellByPixelY( CInt(mouseLoc.Y))).CodeValue End If End If lastMouseState = ms MyBase.Update(gameTime) End Sub
Execute the application. Attempting to draw tiles at this point results in black holes being punched in the all-blue map:
The first thing our Update()
method does is to set the game's camera position, based on the current values of the horizontal and vertical scroll bars on the level editor form. When we scroll the scroll bars, the game map will scroll as well.
Next, we verify that the mouse coordinates are within the view port of the camera and thus within the PictureBox
control on the editor form. If they are, we determine the world-based coordinates of the mouse cursor.
If the left mouse button has been pressed, we update the tile under the cursor on the current DrawLayer
with the current DrawTile
. The right mouse button is a bit more complicated.
We will use the right mouse button to set two different types of information—either toggling on and off the Passable
property of the MapSquare
, or setting its CodeValue
property, depending on the mode selected on the MapEditor
form.
In either case, we will only make a change to the underlying tile if the right mouse button is pressed during this frame and was not pressed during the previous frame. This eliminates updating the same square multiple times on a single button press (which would make toggling passability on and off difficult!) During the first frame after the button is pressed, the MapSquare
will be updated. Until the button is released, no other updates will occur.
The HoverCodeValue
member is set to the CodeValue
of the MapSquare
under the mouse cursor. This value will be used by the MapEditor
form and displayed on a label on the screen to provide an alternative method of viewing the code for an individual square.
Finally, lastMouseState
is updated to the current mouse state, and the Update()
method is completed.
As we saw at the end of the last section, attempting to draw tiles to the map at this point only leaves empty spaces. This is because the DrawLayer
and DrawTile
integers both default to zero, so we are drawing a fully-transparent tile onto the background layer.
Now that we have made the necessary updates to the XNA side of the level editor, we need to flesh out the event handlers for the controls we placed on the MapEditor
form to update the control variables inside the Game1
class.
Open the MapEditor
form in Design mode.
Double-click on the listTiles ListView
control to automatically generate an event handler for the SelectedIndexChanged
event. Update the event code to read:
Private Sub listTiles_SelectedIndexChanged( sender As System.Object, e As System.EventArgs) Handles listTiles.SelectedIndexChanged If listTiles.SelectedIndices.Count > 0 Then game.DrawTile = listTiles.SelectedIndices(0) End If End Sub
Return to the Design view of the MapEditor
form, and double-click on the radio button labeled Toggle Passable to generate a handler for the CheckChanged
event. Update the event handler to read:
Private Sub radioPassable_CheckedChanged( sender As System.Object, e As System.EventArgs) Handles radioPassable.CheckedChanged If Not IsNothing(game) Then If radioPassable.Checked Then game.EditingCode = False Else game.EditingCode = True End If End If End Sub
Return to the Design view, and double-click on the Code radio button. Update the CheckChanged
event handler to read:
Private Sub radioCode_CheckedChanged( sender As System.Object, e As System.EventArgs) Handles radioCode.CheckedChanged If Not IsNothing(game) Then If radioPassable.Checked Then game.EditingCode = False Else game.EditingCode = True End If End If End Sub
Return to the Design view, and double-click on the txtNewCode
textbox. Update the TextChanged
event handler to read:
Private Sub txtNewCode_TextChanged( sender As System.Object, e As System.EventArgs) Handles txtNewCode.TextChanged game.CurrentCodeValue = txtNewCode.Text End Sub
Return to the Design view, and double-click on the Background menu item under the Layer menu. Update the Click
event handler to read:
Private Sub BackgroundToolStripMenuItem_Click( sender As System.Object, e As System.EventArgs) Handles BackgroundToolStripMenuItem.Click game.DrawLayer = 0 backgroundToolStripMenuItem.Checked = True interactiveToolStripMenuItem.Checked = False foregroundToolStripMenuItem.Checked = False End Sub
Generate a Click
event handler for the Interactive layer menu item, and update it to read:
Private Sub InteractiveToolStripMenuItem_Click( sender As System.Object, e As System.EventArgs) Handles InteractiveToolStripMenuItem.Click game.DrawLayer = 1 backgroundToolStripMenuItem.Checked = False interactiveToolStripMenuItem.Checked = True foregroundToolStripMenuItem.Checked = False End Sub
Generate a Click
event handler for the Foreground layer menu item, and update it to read:
Private Sub ForegroundToolStripMenuItem_Click( sender As System.Object, e As System.EventArgs) Handles ForegroundToolStripMenuItem.Click game.DrawLayer = 2 backgroundToolStripMenuItem.Checked = False interactiveToolStripMenuItem.Checked = False foregroundToolStripMenuItem.Checked = True End Sub
In the MapEditor_Load()
method, add the following to the end of the method to indicate the starting layer for the editor:
backgroundToolStripMenuItem.Checked = True
Execute the application, and use the editor to draw tiles to the map:
Maximize the display window, and attempt to use the scroll bars to scroll around the map display.
All of our event handlers for the form controls simply pass information along to the appropriate variables in the Game1
class. Selecting a layer from the menu bar updates the DrawLayer
value, while the radio buttons toggle the EditingCode
variable between true
and false
. We need to wrap these statements in "If Not IsNothing(game) Then"
statements, because the first time these events will be executed is before the game object has been created.
Any time the txtNewCode
control's Text
property is changed, the current contents of the TextBox
are copied to the CurrentCodeValue
member of the Game1
class.
In step 11, you probably noticed that the game window does not update the current position when you are scrolling through the scroll bars until you release the mouse button, even though the scroll bar's marker moves the whole time. This happens because the movement of the scroll bar is preventing the game loop from executing while the user is in the process of manipulating the scroll bars.
In addition, the scroll bars do not match up to the map window until we resize the display at least once. This is because the form's Load
event happens prior to the initialization of the Game1
instance (if you go back and look at Program.vb
, the form is created and shown first, and then the game is initialized), so the WorldRectangle
property of the Camera
module is {0, 0, 0, 0}
. This results in FixScrollBarScales()
setting negative numbers for the maximum values of the scroll bars.
We can fix both of these problems by adding a timer to the form that initially calls FixScrollBarScales()
and repeatedly calls the game's Tick()
method.
Double-click on the timer control in the Toolbox window to add a new instance to the MapEditor
form. As with the ImageList
control, the timer is not visible and will appear in the editor as an icon and label below the design window. Give the timer control the following properties:
Name:timerGameUpdate
Enabled:True
Interval:20
Double-click on the timerGameUpdate
control to generate a Tick
event handler, and add the following code to it:
Private Sub timerGameUpdate_Tick( sender As System.Object, e As System.EventArgs) Handles timerGameUpdate.Tick If hScrollBar1.Maximum < 0 Then FixScrollBarScales() End If game.Tick() If game.HoverCodeValue <> lblCurrentCode.Text Then lblCurrentCode.Text = game.HoverCodeValue End If End Sub
Execute the application. Draw a few tiles on the map, and use the scroll bars to verify that they function as expected.
Using the scroll bars does not prevent the timer control from firing its Tick
event, so by executing the game's Tick()
method from within the timerGameUpdate_Tick()
event handler, we can force the game's Update()
and Draw()
methods to run even when they normally would not.
The last item in the timerGameUpdate_Tick()
handler checks to see if the HoverCodeValue
inside the Game1
class has been updated, since it was last copied to the label displaying it on the Windows Form. If it has, the form label is updated as well.
The last thing we need to address to complete the Gemstone Hunter Level Editor is how we will load and save our map files. There are a number of ways we could store our level maps, but we will implement a very simple method that does not require parsing XML or creating a textfile with a special format to store the map.
In the Tile Engine project, open the TileMap.vb
module file.
Add the Loading and Saving Maps region to the TileMap
module:
#Region "Loading and Saving Maps" Public Sub SaveMap(fileToSave As FileStream) Dim formatter As BinaryFormatter = new BinaryFormatter() formatter.Serialize(fileToSave, mapCells) fileToSave.Close() End Sub Public Sub LoadMap(fileToLoad As FileStream) Try Dim formatter As BinaryFormatter = new BinaryFormatter() mapCells = CType(formatter.Deserialize(fileToLoad), MapSquare(,)) fileToLoad.Close() Catch ClearMap() End Try End Sub Public Sub ClearMap() For x As Integer = 0 To MapWidth - 1 For y As Integer = 0 To MapHeight - 1 For z As Integer = 0 To MapLayers - 1 mapCells(x, y) = New MapSquare(2, 0, 0, "", True) Next Next Next End Sub #End Region
Back in the Level Editor project, open the MapEditor
form in Design mode.
Double-click on the Load Map item in the File menu to create the Click
event handler, and update its code to read:
Private Sub LoadMapToolStripMenuItem_Click( sender As System.Object, e As System.EventArgs) Handles LoadMapToolStripMenuItem.Click Try TileMap.LoadMap(New FileStream( Application.StartupPath + "MAP" + cboMapNumber.Items(cboMapNumber.SelectedIndex).ToString() + ".MAP", FileMode.Open)) Catch System.Diagnostics.Debug.Print("Unable to load map file") End Try End Sub
Double-click on the Save Map item in the File menu, and update its Click
handler to read:
Private Sub SaveMapToolStripMenuItem_Click( sender As System.Object, e As System.EventArgs) Handles SaveMapToolStripMenuItem.Click TileMap.SaveMap(New FileStream( Application.StartupPath + "MAP" + cboMapNumber.Items(cboMapNumber.SelectedIndex).ToString() + ".MAP", FileMode.Create)) End Sub
Double-click on the Clear Map item in the Tools menu, and update its Click
handler to read:
Private Sub ClearMapToolStripMenuItem_Click( sender As System.Object, e As System.EventArgs) Handles ClearMapToolStripMenuItem.Click TileMap.ClearMap() End Sub
Execute the application and create a simple map. Save it to disk, update the map, and reload it.
When a map is saved, we create a FileStream
object, which represents our file on disk. We then create an instance of the BinaryFormatter
class and call its Serialize()
method, passing in the stream to serialize the data from the object we wish to serialize. In our case, it is the array containing the MapSquare
objects that represent our game map.
When loading the map, the process is exactly the same, except that we use the Deserialize()
method of the BinaryFormatter
class to reverse the process, converting the binary data on disk back into its in-memory representation. By surrounding the attempt to load the map with a Try...End Try
block, we can take action (clearing the map to an empty blue sky) instead of simply crashing the level editor.
From the Windows Form side of the system, we call the new LoadMap()
and SaveMap()
methods, passing in the FileStream
object that is created based on the cboMapNumber
drop-down list. Our maps will be saved in files named "MAP###.MAP
", with the three-digit number taken from the cboMapNumber
list. While using our maps in the Level Editor, they will be stored in the same directory our Level Editor executable is running from (normally, the Visual Studio 2010ProjectsGemstone HunterLevel EditorLevel Editorinx86debug
folder inside your Documents
folder). In the following screenshot, we have created a sample map using the level editor. This map currently has no passability or code information on it, and it contains only tile information for now:
When building maps, we will use the right mouse button to toggle each individual map square as either passable or impassable. When a square is marked as impassable, it will be tinted red by the editor, indicating that both the monster and the player will treat the square as a solid wall, no matter what visual representation the square has.
Without this information, the player would fall straight through the level and off the map—something we will need to account for in the game engine when we build it in the next chapter.
Each MapSquare
can be assigned a code value that will allow the game to implement special behavior for that square. We have pre-defined a handful of code values including:
Gemstone (GEM
): A gem will be spawned at this location for the player to collect.
Enemy Blocking (BLOCK
): Players can move through these squares, but enemies will treat them as walls. This allows us to confine enemies to an elevated platform, for example.
Start (START
): If no position is set because of a map transition, the player will start the map in this square.
In addition, we will define a special code for map transitions. In the following image, we have a code value of T-001-03-10
on the MapSquare
containing the door into the brick building. At runtime, we will interpret this code value to mean: Transition (T) to map 001, at location 03, 10. In this way, we can link maps together and allow the player to move between them:
This image shows the map from the previous section with both passability and code information filled in. In Editor
mode, the TileEngine
class displays the code values as text blocks on each map square. These codes mean nothing to the editor, so just like passability information, we will need to account for it in the game engine.
Select the form as the current object by clicking on the form's title bar in the Design window.
Switch to Event editing mode in the Properties window by clicking on the lightning bolt button.
Scroll down to the FormClosed
event, and double-click in the empty box to the right of the event name to create the MapEditor_FormClosed()
event handler.
Add the following code to the MapEditor_FormClosed()
event handler:
game.Exit() Application.Exit()
When the form closes, we need to shut down both the XNA game and the overall application, otherwise the system will not release the resources, and the program will still be running invisibly in the background.
The Gemstone Hunter Level Editor project is fairly rough around the edges. It is not exactly a model example of Windows Forms development, but then few purpose-built internal game development tools are.
If you feel like diving further into Windows Forms development, here are a few suggestions for improving on the level editor:
Currently, the level editor does not alert you if you try to load a map after you have made changes to the current map. By adding checks to the Update()
method of the Game1
class, you could flag the map as having changed and issue the appropriate warnings to the user when they try to load a new map.
Marking squares as impassable requires an individual click on each square. You could expand the number of radio buttons to include marking squares as passable and impassable as separate tasks, thus allowing the user to hold down the mouse button and draw large blocks of impassable squares.
On the more game-focused side of things, try creating a few levels! The level editor supports up to 100 levels (000
through 099)
, so there is plenty of room to experiment.
We now have a working—if not pretty—level editor. In this chapter, we:
Added multiple layers and other types of data to the TileMap
class that we built for Robot Rampage
Created a multi-project Visual Studio solution that shares code between projects
Added a Windows Forms form to our XNA Game Studio project and modified the program's startup process to render the form to a PictureBox
control on the form
Implemented methods to allow communication between the Windows Form and the XNA game, including synchronized scroll bars, and updating member variables in the Game1
class in response to Windows Forms controls events
Implemented methods to load and save map files through the BinaryFormatter
class
In the next chapter, we will flesh out the Gemstone Hunter project and cover the basics of building a platform-style game using the maps that we created with the level editor from this chapter.
3.15.149.45