How to write a game on Monogame without attracting the attention of orderlies. Part 5, open the kingdom of multicellular

Previous parts: Part 0, Part 1, Part 2, Part 3, Part 4

4.7 We make a forest out of trees

Last time we stopped on the fact that with an increase in the number of objects, more and more time is spent on their calculation, which, of course, is logical. In our case, the sidewall segments are the most loading moment, which is why it was not possible to make a really long track. So, let’s solve the problem with the side walls. It is a bad idea to build a path border from “bricks”, each of which has its own collider, since there will be too many of them anyway, and no optimization will help here.

It is possible to set the border of the map, which you cannot go beyond, but then it will be difficult to draw tracks of a more complex shape, for example, expanding or narrowing, so I settled on the decision to register the possibility of creating long blocks – it is easier to calculate one 10×1 object than 10 1×1 objects. First, let’s check if this helps us, and create a new class – Border. Since the following code is a test one and I will remove it later, I will show this piece as screenshots.

Now we will remove the generation of the side walls in the initialization in order to remove the brakes:

And right below this cycle we make a generator of long walls.

The sprite number -1 is not in the dictionary, so there will be an error during rendering. Let’s make it so that instead of this, rendering simply does not occur in the View:

_spriteBatch.Begin();
foreach (var o in _objects.Values)
{
    if (o.ImageId == -1)
        continue;
    _spriteBatch.Draw(_textures[o.ImageId], o.Pos - _visualShift, Color.White);
}
_spriteBatch.End();

If we run it, we will see that long invisible walls stop us:

Let’s make the work a little easier for ourselves and the computer – we create a dictionary that will store only solid objects:

<.........................................................>
public Dictionary<int, ISolid> SolidObjects { get; set; }

public void Initialize()
{    
    Objects = new Dictionary<int, IObject>();
    SolidObjects = new Dictionary<int, ISolid>();
<..........................................................>

And we slightly change the generation algorithm, taking into account the fact that we now have this dictionary:

Now we have a check for the “hardness” of the object, as a result of which the object is added to the new dictionary, which means that there is no need to cast types in the calculation of collisions – you can immediately access the dictionary, since the keys in the dictionary of hard objects correspond to the keys of objects in general dictionary:

private void CalculateObstacleCollision(
  (Vector2 initPos, int Id) obj1, 
  (Vector2 initPos, int Id) obj2
)
{    
    bool isCollided = false;
    Vector2 oppositeDirection = new Vector2 (0, 0);
    while (RectangleCollider.IsCollided
          SolidObjects[obj1.Id].Collider, 
          SolidObjects[obj1.Id].Collider))
    {
        isCollided = true;
        if (obj1.initPos != Objects[obj1.Id].Pos)
        {
            oppositeDirection = Objects[obj1.Id].Pos - obj1.initPos;
            oppositeDirection.Normalize();
            Objects[obj1.Id].Move(Objects[obj1.Id].Pos - oppositeDirection);
        }
        if (obj2.initPos != Objects[obj2.Id].Pos)
        {
            oppositeDirection = Objects[obj2.Id].Pos - obj2.initPos;
            oppositeDirection.Normalize();
            Objects[obj2.Id].Move(Objects[obj2.Id].Pos - oppositeDirection);
        }  
    } 
    if (isCollided)
    {
        Objects.[obj1.Id].Speed = new Vector2(0, 0);
        Objects.[obj2.Id].Speed = new Vector2(0, 0);
    }
}

We change the Update method so that only objects from the list of hard objects get into the list of colliding objects:

public void Update()
{
    Vector2 playerInitPos = ObjectSecurity[PlayerId].Pos;
    Dictionary<int, Vector2> collisionObjects = new Dictionary<int, Vector2>();
    foreach (var i in Objects.Keys)
    {
        Vector2 initPos = Objects[i].Pos;
        Objects[i].Update();
        if (SolidObjects.ContainsKey(i))
           collisionObjects.Add(i, initPos);
    }
  <............................................>

We launch, and we see amazing performance, since now not 1004 objects are calculated, but only 6 – three cars, two boundary walls and one wall on the track.

Now, before we forget, let’s make sure that pairs of objects are not counted twice:

public void Update()
{
    Vector2 playerInitPos = ObjectSecurity[PlayerId].Pos;
    Dictionary<int, Vector2> collisionObjects = new Dictionary<int, Vector2>();
    foreach (var i in Objects.Keys)
    {
        Vector2 initPos = Objects[i].Pos;
        Objects[i].Update();
        if (SolidObjects.ContainsKey(i))
           collisionObjects.Add(i, initPos);
    }
    List <(int, int)> processedObjects = new List<(int, int)>();
    foreach (var i in collisionObjects.Keys)
    {
        foreach (var j in collisionObjects.Keys)
        {
            if (i == j || processedObjects.Contains((j, i)))
              continue;  
            CalculateObstacleCollision(
              (collisionObjects[i],i), 
              (collisionObjects[j],j)
            );
            processedObjects.Add((i, j));
        }
    }
    Vector2 playerShift = Objects[PlayerId].Pos - playerInitPos;
    Updated.Invoke(this, new GameplayEventArgs 
                 { 
                   Objects = Objects, 
                     POVShift = playerShift
                 });
}

There is another way – to prescribe map obstacles through tiles. That is, check where our player is according to the array and, if there is a wall, do not let him in. I don’t like it, because it violates the integrity of the structure that I created, so I will not resort to it unnecessarily. At least for now.

4.8 Making the forest visible

We realized that the walls in the form of a single object work as they should, but now the problem arose of how to generate them adequately – firstly, they are now invisible, and even if we give them a sprite, it will be shorter than necessary, firstly secondly, we need to somehow shove the above hardcode into our generator.

Let’s start with the first problem. The walls of the track can be of arbitrary length, and our sprites can only be fixed. And this problem, theoretically, may appear later – an object may have more than one sprite. Let’s change the structure so that the object can consist of several pictures, replacing the int variable of the sprite number in the object’s interface with a dictionary of such numbers:

public interface IObject
{       
    // Вместо одного спрайта будет список спрайтов
    List<(int ImageId, Vector2 ImagePos)> Sprites { get; set; }
    Vector2 Pos { get;}
    Vector2 Speed { get; set; }       
    void Update();
    void Move (Vector2 pos);
}

This list stores tuples – the number of the sprite and its position relative to the position of the object. In this way, we can place many sprites that belong to the same object in different places. We make a similar replacement in classes that implement IObject – in fields and constructors:

<................................................................>
public List<(int ImageId, Vector2 ImagePos)> Sprites { get; set; }
public Car(Vector2 position)
{
    Pos = position;
    IsLive = true;
    Sprites = new List<(int ImageId, Vector2 ImagePos)>();
    Collider = new RectangleCollider((int)Pos.X, (int)Pos.Y, 77, 100);  
}

In the Initialize method of the game loop, for now, comment the code for generating the boundaries of the track so that they do not interfere, and also change the methods for generating the car and the wall in the game loop:

private Car CreateCar (
  float x, float y, int spriteId, Vector2 speed)
{
  Car c = new Car();
  c.Sprites.Add(((byte)spriteId, Vector.Zero));
  c.Pos = new Vector2(x, y);
  c.Speed = speed;
  return c;
}

private Wall CreateWall(
  float x, float y, int spriteId)
{
  Wall w = new Wall();
  c.Sprites.Add(((byte)spriteId, Vector.Zero));
  w.Pos = new Vector2(x, y);
  w.ImageId = spriteId;
  return w;
}

Change the drawing method in the View accordingly:

protected override void Draw(GameTime gameTime)
{
    GraphicsDevice.Clear(Color.DarkSeaGreen);
    base.Draw(gameTime);
    _spriteBatch.Begin();

    foreach (var o in _objects.Values)
    {
      // Перебираем все спрайты в списке и рисуем каждый
      foreach (var sprite in o.Sprites)
        {
          if (sprite.ImageId == -1)
            continue;
          _spriteBatch.Draw(          
          _textures[sprite.ImageId],
          // Добавляем еще и смещение спрайта относительно позиции объекта
          o.Pos - _visualShift + sprite.ImagePos,
          Color.White
          );
        }        
    }
    _spriteBatch.End();
}

Now let’s make our walls have an arbitrary length and draw normally. To begin with, we will return the property of hardness to them, otherwise we will forget later:

Add the length and width properties to the wall (sorry for the screenshot, but it’s clearer):

And now let’s change the generation method in the game loop so that our sprites are multiplied in accordance with the size of the collider:

private Wall CreateWall (float x, float y, ObjectTypes spriteId)
{
  int width = 24;
  int length = 2000;
  Wall w = new Wall (new Vector2(x,y), width, length);  
  for (int i = 0; i < width; i+=24)
      for (int j = 0; j < length; j+=100)
      {
        w.Sprites.Add(((byte)spriteId, new Vector2(i,j)));
      }  
  return w;
}

Now let’s place the walls on the map:

public void Initialize()
{
  Objects = new Dictionary<int, IObject>();
  SolidObjects = new Dictionary<int, ISolid>();

  _map[5, 7] = 'P';
  _map[4, 4] = 'C';
  _map[6, 2] = 'C';
  _map[0, 0] = 'W';
  _map[_map.GetLength(0)-1, 0] = 'W';
}

With this implementation, we place the upper left corner of the wall, and everything else is built relative to it:

The Border class is no longer needed – you can remove it.

The next step is to write down the size of the wall so that it is not hardcoded, but normally set through the map array. First, let’s make an overloaded GenerateObject method specifically for those cases where an object can have arbitrary sizes:

private IObject GenerateObject(char sign, 
                            int xInitTile, int yInitTile, 
                            int xEndTile, int yEndTile)
{
    float xInit = xInitTile * _tileSize;
    float yInit = yInitTile * _tileSize;
    float xEnd = xEndTile * _tileSize;
    float yEnd = yEndTile * _tileSize;
    IObject generatedObject = null;
    if (sign == 'W')
    {
        generatedObject = CreateWall (xInit + _tileSize / 2, yInit + _tileSize / 2,
                                     xEnd + _tileSize / 2, yEnd + _tileSize / 2,
                                     spriteId: ObjectTypes.wall);
    }
    return generatedObject;
}

Wall generation without hardcode would look like this:

private Wall CreateWall (float xInit, float yInit, float xEnd, float yEnd,
                         ObjectTypes spriteId)
{
  int width = Math.Abs(xEnd - xInit) == 0 ? 24 : (int)Math.Abs(xEnd - xInit);
  int length = Math.Abs(yEnd - yInit) == 0 ? 100 : (int)Math.Abs(yEnd - yInit);
  Wall w = new Wall (new Vector2(xInit, yInit), width, length);  
  for (int i = 0; i < width; i+=24)
      for (int j = 0; j < length; j+=100)
      {
        w.Sprites.Add(((byte)spriteId, new Vector2(i,j)));
      }  
  return w;
}

Now we need to change the map array handler so that the wall is correctly generated:

<................................................................>
for (int y = 0; y < _map.GetLength(1); y++)
  for (int x = 0; x < _map.GetLength(0); x++)
  {
      if (_map.GameField[x, y] != '\0')
      {
          IObject generatedObject = null;
          if (int.TryParse(_map[x, y].ToString(), out int corner1))
          {             
              for (int yCorner = 0; yCorner < _map.GetLength(1); yCorner++)
                  for (int xCorner = 0; xCorner < _map.GetLength(0); xCorner++)
                  {
                      if (int.TryParse
                          (
                        _map[xCorner, yCorner].ToString(), 
                                       out int corner2)
                         )
                      {
                          if (corner1==corner2)
                          {
                              generatedObject = 
                                GenerateObject('W', x, y, xCorner, yCorner); 
                              _map[x, y] = '\0';
                              _map[xCorner, yCorner] = '\0';
                          }
                      }
                  }
          }        
          else
          {
              generatedObject = GenerateObject(_map[x, y], x, y);
          }
<................................................................>

The principle of operation is as follows – if we want to place a wall, then we write a number in the array cell and then look for the second one inside the array. Then the first digit will set the coordinate of the upper left corner, and the second – the lower right. Thus, it is easy to obtain the geometric dimensions of our wall, which can be submitted to the generation method. After this operation, we remove the numbers from the array so that they do not interfere.

Let’s create borders in our array and run the program:

public void Initialize()
{
  Objects = new Dictionary<int, IObject>();
  SolidObjects = new Dictionary<int, ISolid>();

  _map[5, 7] = 'P';
  _map[4, 4] = 'C';
  _map[6, 2] = 'C';
  _map[0, 0] = '1';
  _map[0, 10] = '1';
  _map[_map.GetLength(0)-1, 0] = '2';
  _map[_map.GetLength(0)-1, 10] = '2';
}

Works =)

You can even make the walls thick:

public void Initialize()
{
  Objects = new Dictionary<int, IObject>();
  SolidObjects = new Dictionary<int, ISolid>();

  _map[5, 7] = 'P';
  _map[4, 4] = 'C';
  _map[6, 2] = 'C';
  _map[0, 1] = '1';
  _map[1, 10] = '1';
  _map[_map.GetLength(0)-1, 1] = '2';
  _map[_map.GetLength(0)-1, 10] = '2';
  _map[0, 0] = '3';
  _map[_map.GetLength(0)-1, 0] = '3';
}

The disadvantage here is that we cannot specify the types of walls, since we have a charm array. But, I think, within the framework of the game being developed, this is not scary.

A moment of refactoring

Let’s now get the elephant out of the room and make sure our collider sizes don’t get hardcoded. Hardcode will still be, but where it does not infuriate.

We create a static class called Factory (it has nothing to do with patterns), where we transfer our methods for generating cars and walls. In addition, we transfer the enum here, where the sprite numbers are stored:

public static class Factory
{
    private static Dictionary<string, (byte type, int width, int height)> _objects =
      new Dictionary<string, (byte, int, int)>();
    {
        {"classicCar", ((byte)ObjectTypes.car, 77, 100)},
        {"wall", ((byte)ObjectTypes.wall, 24, 100)},
    };

  public static Car CreateClassicCar (float x, float y, Vector2 speed)
  {
      Car c = new Car (new Vector2 (x, y));
      c.Sprites.Add((_objects["classicCar"].type, Vector2.Zero));
      c.Speed = speed;
      return c;
  }
  public static Wall CreateWall (float xInit, float yInit, float xEnd, float yEnd)
  {
    int segmentWidth = _objects["wall"].width;
    int segmentHeight = _objects["wall"].height;
    int width = Math.Abs(xEnd - xInit) == 0 ? segmentWidth : (int)Math.Abs(xEnd - xInit);
    int length = Math.Abs(yEnd - yInit) == 0 ? segmentHeight : (int)Math.Abs(yEnd - yInit);
    Wall w = new Wall (new Vector2(xInit, yInit), width, length);  
    for (int i = 0; i < width; i+=24)
        for (int j = 0; j < length; j+=100)
        {
          w.Sprites.Add((_objects["wall"].type, new Vector2(i,j)));
        }  
    return w;
  }
  public enum ObjectTypes : byte
  {
      car,
      wall
  }
}

We create the _objects dictionary, which will contain the sprite number and collider parameters of the corresponding object. The bottom line is that we will generate any object through the methods of the Factory class and all the ugly hardcode will be stored here.

It remains to change our GameCycle for the new class:

private IObject GenerateObject(char sign, 
                            int xTile, int yTile)
{
    float x = xTile * _tileSize;
    float y = yTile * _tileSize;    
    IObject generatedObject = null;
    if (sign == 'P' || sign == 'C')
    {
        generatedObject = Factory.CreateClassicCar (
          x + _tileSize / 2, 
          y + _tileSize / 2, 
          speed: new Vector2 (0, 0));
    }
    return generatedObject;
}

private IObject GenerateObject(char sign, 
                            int xInitTile, int yInitTile, 
                            int xEndTile, int yEndTile)
{
    float xInit = xInitTile * _tileSize;
    float yInit = yInitTile * _tileSize;
    float xEnd = xEndTile * _tileSize;
    float yEnd = yEndTile * _tileSize;
    IObject generatedObject = null;
    if (sign == 'W')
    {
        generatedObject = Factory.CreateWall (xInit + _tileSize / 2, yInit + _tileSize / 2,
                                     xEnd + _tileSize / 2, yEnd + _tileSize / 2,
                                     spriteId: ObjectTypes.wall);
    }
    return generatedObject;
}

Note that in this class, we now only specify where to generate the object and the speed for the machine. All technical insides are set by the Factory according to a rigidly set plan.

And don’t forget to change the link to the sprite numbers in the View:

And that’s all for today. Next time, finally, we can already do the gameplay!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *