Looking for a code/desgin review of continous map implemention

Started by
14 comments, last by slayemin 11 years, 5 months ago
Hello, incoming huge post! I am creating a 2D RPG where the entire world is open and continous. That is, there are no instances (for now). I am looking for any and all criticism over my design and code. I also hope this will help future developers. I will explain my ideas and walk through the design. At the bottom you can find my entire implemention.

The world is divided up into tiles where each tile is one image. Each tile is named "X_Y" where x and y are the coordinates with respect to the entire world. Suppose our playable character starts in 3_3, and the tile the character is actively in is called the center tile. The character renders to the center of the screen always (think Diablo), so we keep track of the map tile's (x,y) position and also the character's (x,y) position relative to the map. The difference in distance between the two is called the xOffSet and yOffSet.

There are two main steps in the Update method. (1) Checks to see if we need to load nearby tile and (2) see if we are no longer in the center tile.

(1) The center tile has an interboundry rectangle, so that if this boundary is crossed, we must load a nearby tile to render. Suppose we are in 3_3 moving east towards the edge so the 4_3 must be loaded. The code to check for this is here:

[source lang="csharp"]if (Math.Abs(mapPosition.X) > interBoundaryWidth)
{
if (!bEast)
{
Load(EAST, 1, 0);
bEast = true;
}
}
else if (bEast)
{
Unload(EAST);
bEast = false;
}[/source]

bEast is a boolean that says if the map to the east of the center tile is loaded. We see if our map's position has crossed the inter boundary. If so, we check if the eastern tile is loaded already so that we only load it once. We call the Load function telling it the direction relative to the center tile and that the coordinates going to added are 1 in the x direction and 0 in the y. Since we are in 3_3, this will load 4_3. bEast is set to true so we dont load it again. Backing up to the beginning, suppose the map's position is still within the interboundry. Then, if the Eastern map is loaded, we want to unload it and set the boolean to false. This step is needed if we traveled east, passed the interboundary, loaded 4_3, but then turned back west far enough that we don't need to render 4_3 anymore.

Similar sections of code are used for all possible directions including the need to load NE, NW, SE, and SW maps.

(2) This section sees if our character has moved out of the center tile and into another. Continuing with the east example, suppose our character moves from being in 3_3 into 4_3.

[source lang="csharp"]if (Math.Abs(mapPosition.X) + (GraphicsDeviceManager.DefaultBackBufferWidth / 2) > tileWidth)
{
foreach (MapTile bt in mapTiles)
{
if (bt.direction == CENTER)
bt.direction = WEST;
else if (bt.direction == NORTH)
bt.direction = NW;
else if (bt.direction == SOUTH)
bt.direction = SW;
}

foreach (MapTile bt in mapTiles)
{
if (bt.direction == EAST)
{
bt.direction = CENTER;
mapName = bt.name;
}
else if (bt.direction == NE)
bt.direction = NORTH;
else if (bt.direction == SE)
bt.direction = SOUTH;
}
worldManager.setCharacterPosition(new Vector2(characterPosition.X + tileWidth, characterPosition.Y));[/source]

The first if statement sees if we have crossed outside of the map tile's width and thus into a new map tile. If we have, we want to update all the tiles to reflect the direction on the new center tile. Consider this crude drawing that maps each possible tile into the new direction. Note that only the N/NE or S/SE sections could possibly be loaded at the same time since the north and south tiles cannot be loaded at the same time because the maps are bigger than our screen. "C" is for "Center" which is where our character is located.

--------- -----------
| N | NE | | NW | N |
---------- ------------
| C | E | ---> | W | C |
---------- ------------
|S | SE | | SW | S |
---------- ------------

So our center tile becomes the west tile, and the west tile becomes the center tile, and so on for the other tiles. The order of changing the directions is important, which is why I have two foreach loops. Lastly, we update the character's position to that it starts on the far left side of the new center tile.

Finally, the rendering is based on the tile's direction which can be seen in my code below. If you have read this far, please leave a comment! I'm open to improvements and hopefully would like to help others tackling this problem. Thanks for reading!

[source lang="csharp"]
namespace Arkenstone
{
// The Map class contains and manages up to four total MapTiles.
class Map
{
String mapName; // This will be the name of the map the playable character is in.

List<MapTile> mapTiles; // Contains up to 4 loaded tiles.

int mapListSize = 3; // Size limit for backgroundTiles.

float xOffSet = (GraphicsDeviceManager.DefaultBackBufferWidth - 48) / 2; // = 376, 48 = mainCharacter's spirte width
float yOffSet = (GraphicsDeviceManager.DefaultBackBufferHeight - 72) / 2; // = 264, 72 = mainCharacter's sprite height

int tileHeight; // All map tiles are the same height.
int tileWidth; // All map tiles are the same width.
int interBoundaryWidth; // Inside width boundary of a map tile used to know when to load other maps.
int interBoundaryHeight; // Inside height boundary of a map tile used to know when to load other maps.

bool bEast, bWest, bNorth, bSouth; // Directional booleans set to true when a map is loaded.
bool bNE, bSE, bSW, bNW; // Directional booleans set to true when a map is loaded.

// Directional strings.
private const string CENTER = "CENTER";
private const string NORTH = "NORTH";
private const string SOUTH = "SOUTH";
private const string EAST = "EAST";
private const string WEST = "WEST";
private const string NE = "NE";
private const string SE = "SE";
private const string NW = "NW";
private const string SW = "SW";

Vector2 characterPosition; // Character's position relative to the map. That is, with the offsets.

ContentManager contentManager; // Keep a copy of the content manager since it is used so often.

Vector2 mapPosition; //top x,y for the map tile used for drawing.

public Map()
{
// Only have 4 maps total, ever.
mapTiles = new List<MapTile>(mapListSize);

// Create new tiles.
MapTile mapTile1 = new MapTile();
MapTile mapTile2 = new MapTile();
MapTile mapTile3 = new MapTile();
MapTile mapTile4 = new MapTile();

// Fill the list.
mapTiles.Add(mapTile1);
mapTiles.Add(mapTile2);
mapTiles.Add(mapTile3);
mapTiles.Add(mapTile4);

}

// Loads map the first map "x_y"
public void LoadContent(int x, int y, ContentManager contentManager)
{
try
{
this.contentManager = contentManager;
mapName = (x).ToString() + "_" + (y).ToString();
mapTiles[0].LoadContent(this.contentManager, mapName, CENTER);

tileHeight = mapTiles[0].Source.Height;
tileWidth = mapTiles[0].Source.Width;

interBoundaryHeight = mapTiles[0].innerBoundary.Height;
interBoundaryWidth = mapTiles[0].innerBoundary.Width;

// When the character is first loaded, no other map tiles are in view.
bWest = bNorth = bEast = bSouth = false;
bNE = bSE = bSW = bNW = false;
}
catch (Exception e)
{
Console.WriteLine(mapName + " does not exist.");
}
}

public void Update(WorldManager worldManager)
{
// Get the character's position relitive to the map.
characterPosition = worldManager.mainCharacter.mapPosition;

// Set the top x,y for the map
mapPosition = new Vector2(-xOffSet + characterPosition.X, -yOffSet + characterPosition.Y);

// If we are outside the inter boundaries of the center tile, load the next tile so that it can be rendered.
#region Map loading
// East
if (Math.Abs(mapPosition.X) > interBoundaryWidth)
{
if (!bEast)
{
Load(EAST, 1, 0);
bEast = true;
}
}
else if (bEast)
{
Unload(EAST);
bEast = false;
}

// South
if (Math.Abs(mapPosition.Y) > interBoundaryHeight)
{
if (!bSouth)
{
Load(SOUTH, 0, 1);
bSouth = true;
}
}
else if (bSouth)
{
Unload(SOUTH);
bSouth = false;
}

// West
if (mapPosition.X > 0)
{
if (!bWest)
{
Load(WEST, -1, 0);
bWest = true;
}
}
else if (bWest)
{
Unload(WEST);
bWest = false;
}

// North
if (mapPosition.Y > 0)
{
if (!bNorth)
{
Load(NORTH, 0, -1);
bNorth = true;
}
}
else if (bNorth)
{
Unload(NORTH);
bNorth = false;
}

// NE
if (mapPosition.Y > 0 && Math.Abs(mapPosition.X) > interBoundaryWidth)
{
if (!bNE)
{
Load(NE, 1, -1);
bNE = true;
}
}
else if (bNE)
{
Unload(NE);
bNE = false;
}

// NW
if (mapPosition.Y > 0 && mapPosition.X > 0)
{
if (!bNW)
{
Load(NW, -1, -1);
bNW = true;
}
}
else if (bNW)
{
Unload(NW);
bNW = false;
}

// SE
if (Math.Abs(mapPosition.Y) > interBoundaryHeight && Math.Abs(mapPosition.X) > interBoundaryWidth)
{
if (!bSE)
{
Load(SE, 1, 1);
bSE = true;
}
}
else if (bSE)
{
Unload(SE);
bSE = false;
}

// SW
if (Math.Abs(mapPosition.Y) > interBoundaryHeight && mapPosition.X > 0)
{
if (!bSW)
{
Load(SW, -1, 1);
bSW = true;
}
}
else if (bSW)
{
Unload(SW);
bSW = false;
}

#endregion

// This region checks if the character has moved out of the center tile, and updates accordingly so we are back in the center tile.
#region Moved In Positions
// Moved to the west square
if (mapPosition.X > (GraphicsDeviceManager.DefaultBackBufferWidth / 2))
{
// Moving from (x,y) to (x-1, y)
foreach (MapTile bt in mapTiles)
{
if (bt.direction == CENTER)
bt.direction = EAST;
else if (bt.direction == NORTH)
bt.direction = NE;
else if (bt.direction == SOUTH)
bt.direction = SE;
}

foreach (MapTile bt in mapTiles)
{
if (bt.direction == WEST)
{
bt.direction = CENTER;
mapName = bt.name; // Updates the name of the map our character is in.
}
else if (bt.direction == NW)
bt.direction = NORTH;
else if (bt.direction == SW)
bt.direction = SOUTH;
}

// Update the character's position.
worldManager.setCharacterPosition(new Vector2 (characterPosition.X - tileWidth, characterPosition.Y));
}


// Moved to the east tile.
else if (Math.Abs(mapPosition.X) + (GraphicsDeviceManager.DefaultBackBufferWidth / 2) > tileWidth)
{
foreach (MapTile bt in mapTiles)
{
if (bt.direction == CENTER)
bt.direction = WEST;
else if (bt.direction == NORTH)
bt.direction = NW;
else if (bt.direction == SOUTH)
bt.direction = SW;
}

foreach (MapTile bt in mapTiles)
{
if (bt.direction == EAST)
{
bt.direction = CENTER;
mapName = bt.name;
}
else if (bt.direction == NE)
bt.direction = NORTH;
else if (bt.direction == SE)
bt.direction = SOUTH;
}
worldManager.setCharacterPosition(new Vector2(characterPosition.X + tileWidth, characterPosition.Y));

}

// Moved to the north tile
else if (mapPosition.Y > (GraphicsDeviceManager.DefaultBackBufferHeight / 2))
{
foreach (MapTile bt in mapTiles)
{
if (bt.direction == CENTER)
bt.direction = SOUTH;
else if (bt.direction == WEST)
bt.direction = SW;
else if (bt.direction == EAST)
bt.direction = SE;
}

foreach (MapTile bt in mapTiles)
{
if (bt.direction == NORTH)
{
bt.direction = CENTER;
mapName = bt.name;
}
else if (bt.direction == NE)
bt.direction = EAST;
else if (bt.direction == NW)
bt.direction = WEST;
}
worldManager.setCharacterPosition(new Vector2(characterPosition.X, characterPosition.Y - tileHeight));

}

// Moved to the south tile.
else if (Math.Abs(mapPosition.Y) + (GraphicsDeviceManager.DefaultBackBufferHeight / 2) > tileHeight)
{
foreach (MapTile bt in mapTiles)
{
if (bt.direction == CENTER)
bt.direction = NORTH;
else if (bt.direction == WEST)
bt.direction = NW;
else if (bt.direction == EAST)
bt.direction = NE;
}

foreach (MapTile bt in mapTiles)
{
if (bt.direction == SOUTH)
{
bt.direction = CENTER;
mapName = bt.name;
}
else if (bt.direction == SE)
bt.direction = EAST;
else if (bt.direction == SW)
bt.direction = WEST;
}
worldManager.setCharacterPosition(new Vector2(characterPosition.X, characterPosition.Y + tileHeight));
}
#endregion
}

// Loads a BackgroundTile named "a_b".
public void Load(string direction, int a, int b)
{
// Search for an empty slot in the list to load a map tile.
foreach (MapTile backgroundTile in mapTiles)
{
// This if statement is for safety so we don't accidently try to load a tile that is already loaded.
if (backgroundTile.direction == direction)
{
// Map is already loaded.
break;
}
if (!backgroundTile.isLoaded)
{
// Deconstruct the name to get the grid number
String[] coordinates = mapName.Split('_');
int x = Convert.ToInt32(coordinates[0]);
int y = Convert.ToInt32(coordinates[1]);
// Example: we are in "4_5" and moving east into the view of the next eastern square.
// We then want to load map "5_5". The a and b parameters have the required offset.
// That is, a=1 and b=0.
string newMapName = (x + a).ToString() + "_" + (y + b).ToString();
backgroundTile.LoadContent(this.contentManager, newMapName, direction);
break;
}
}
}

// Unloads the BackgroundTile in the given direction.
public void Unload(string direction)
{
foreach (MapTile backgroundTile in mapTiles)
{
if (backgroundTile.direction == direction)
{
backgroundTile.UnloadContent(contentManager);
break;
}
}
}

public void Draw(SpriteBatch spriteBatch, WorldManager worldManager)
{
// Character's position does not change between the world map's update and the world map's draw, but
// keeping this for safety in case I change the order in the future. Could be deleted later.
characterPosition = worldManager.mainCharacter.mapPosition;

// Update top x,y for the map
mapPosition = new Vector2(-xOffSet + characterPosition.X, -yOffSet + characterPosition.Y);

// Draws squares to the screen
foreach (MapTile backgroundTile in mapTiles)
{
if (backgroundTile.isLoaded)
{
// Draw the tiles based on their directional position.
switch (backgroundTile.direction)
{
case CENTER:
backgroundTile.Draw(spriteBatch, mapPosition);
break;
case EAST:
backgroundTile.Draw(spriteBatch, new Vector2(Math.Abs(tileWidth) - Math.Abs(mapPosition.X), mapPosition.Y));
break;
case WEST:
backgroundTile.Draw(spriteBatch, new Vector2(-Math.Abs(tileWidth) + Math.Abs(mapPosition.X), mapPosition.Y));
break;
case NORTH:
backgroundTile.Draw(spriteBatch, new Vector2(mapPosition.X, -Math.Abs(tileHeight) + Math.Abs(mapPosition.Y)));
break;
case SOUTH:
backgroundTile.Draw(spriteBatch, new Vector2(mapPosition.X, Math.Abs(tileHeight) - Math.Abs(mapPosition.Y)));
break;
case NW:
backgroundTile.Draw(spriteBatch, new Vector2(-Math.Abs(tileWidth) + Math.Abs(mapPosition.X), -Math.Abs(tileHeight) + Math.Abs(mapPosition.Y)));
break;
case NE:
backgroundTile.Draw(spriteBatch, new Vector2(Math.Abs(tileWidth) - Math.Abs(mapPosition.X), -Math.Abs(tileHeight) + Math.Abs(mapPosition.Y)));
break;
case SE:
backgroundTile.Draw(spriteBatch, new Vector2(Math.Abs(tileWidth) - Math.Abs(mapPosition.X), Math.Abs(tileHeight) - Math.Abs(mapPosition.Y)));
break;
case SW:
backgroundTile.Draw(spriteBatch, new Vector2(-Math.Abs(tileWidth) + Math.Abs(mapPosition.X), Math.Abs(tileHeight) - Math.Abs(mapPosition.Y)));
break;
}
}
}
}

}
}[/source]
Advertisement
For some reason there are no spaces between the boxes! It is a matrix transformed into another matrix. Written out:

[ N, NE, C, E, S, SE] ---> [ NW, N, W, C, SW, S]

Match this with what I have in the above post. Hope that makes sense.
I'm not sure I understand the need for all the code about directions (but maybe I'm missing something).

Seems like on each update cycle you could just figure out which tiles need to be loaded based on the player's current position in the map, and the size of the screen. Then compare that to a list of currently loaded tiles, and unload and load as necessary.

I'm not sure I understand the need for all the code about directions ... you could just figure out which tiles need to be loaded based on the player's current position in the map


Figuring out which tile to load is what the direction code is about. It's a book keeping system to see which one to load based on your current tile and also helps to let the program know where to render that tile on the screen.
I'm not a coder so I have no real idea if this will be helpful BUT...

I have worked on the largest MMo map in history for about 10 years and the biggest head ache we've had was workign with world origin offsets. The problem is that our world size and need for placement precision of active and passive entities meant we had to use relative locations.

Why exactly is this an issue? I'm not the tech guy I just produce but I can tell you it is worth thinking about given the sheer amount of complaining about the system that my guys have given me over the years.

Hope it's woth a penny.

[quote name='phil_t' timestamp='1351483689' post='4994936']
I'm not sure I understand the need for all the code about directions ... you could just figure out which tiles need to be loaded based on the player's current position in the map


Figuring out which tile to load is what the direction code is about. It's a book keeping system to see which one to load based on your current tile and also helps to let the program know where to render that tile on the screen.
[/quote]

I assume you're loading tiles in the direction the player is moving? We tried that early on (long time ago so may be better now) but it was easier just to assume that in our world a player could move to any of the 8 surrounding tiles and that any of them could be drawn at any time so load all of them and to reduce any load stutters to thread the loading of the next 16 edge tiles as well.

[quote name='phil_t' timestamp='1351483689' post='4994936']
I'm not sure I understand the need for all the code about directions ... you could just figure out which tiles need to be loaded based on the player's current position in the map


Figuring out which tile to load is what the direction code is about. It's a book keeping system to see which one to load based on your current tile and also helps to let the program know where to render that tile on the screen.
[/quote]

I guess I'm just not sure why the direction the player is moving is important. This is an open world game, so why are the tiles in the direction he is facing more important than those behind him? All that should matter is the player's position and the "radius" around him that you want loaded (which also would simplify the code a lot).
I believe (though I'm not sure) that Gopher is referring to keeping a buffer of pre-loaded tiles around your character so they never have to wait for the game to load an area.

Depending on how fast your load/unload code is (which will depend on how much information is contained within each game location, among other things) there could be a non-negligible time from the load call to when the area is actually available in memory. Thus, you want to actually start loading the area a safe amount of time before the player would see it. Gopher seems to be implying a predictive model based on travel direction.

In a current design project, I'm toying with another solution; basically it boils down to loading a larger area. Instead of loading only the immediate surrounding area and possibly having to reload every time the player crosses a certain boundary (imagine a player moving back and forth across that domain boundary, incurring tile loads each time) I add another buffer.

Loaded area (try to imagine it as a square):
XXXXX
X___X
X_P_X
X___X
XXXXX

At any given time the game has a 5x5 supertile area loaded into memory. The player's view is substantially smaller than this. The Player (P) can move to any of the tiles denoted by underscores (within the X boundary, also loaded in memory) without incurring a subsequent load of any new space. When he does move into an X boundary, the loaded area moves so that the X tile moved into becomes the center of the new area - thus the player can return in the direction he came for a ways without the game needing to load again.

Of course, this basically trades larger load volume for lower load frequency, so it may not be optimal for all games and situations. If loading is fast enough, it might not be that big of a deal to just do a lot of it.

I Create Games to Help Tell Stories

Hold on, I'm not quite sure I understand how you're describing your maps...

Is it a tile based system, where you have a 100x100 grid of tiles, all rendered on the screen at once?
Or is it a system where each map grid is an area which fills up the entire screen (like old school Zelda) and you move in the cardinal directions to go to the next area?
Hello Valgor

I am working on the same thing as you, except I call my "tiles" "cells" instead, because the word "tile" is reserved for little blue and brown bricks that make up the world. What I do is I keep a 3x3 2d array. array[1][1] is the center cell, and the others are the neighbor cells. Every time the player changes to a new cell, all the surrounding 8 cells are loaded too.

Each character has a position that is in pixel units, and each cell has a position that is in cell units. To calculate which cell a character should be in, I do this:

cell_x = character.pos.x / CELL_WIDTH
cell_y = character.pos.y / CELL_HEIGHT

This topic is closed to new replies.

Advertisement