Как написать игру на Monogame, не привлекая внимания санитаров. Часть 5, открываем царство многоклеточных

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Предыдущие части: Часть 0, Часть 1, Часть 2, Часть 3, Часть 4

4.7 Делаем из деревьев лес

В прошлый раз мы остановились на том, что с увеличением количества объектов все больше времени уходит на их обсчет, что, безусловно, логично. В нашем случае самым нагружающим моментом являются сегменты боковых стенок, из-за чего по-настоящему длинную трассу сделать не получалось. Итак, решим проблему с боковыми стенками. Строить границу трассы из «кирпичиков», каждый из которых обладает своим коллайдером - плохая идея, так как их в любом случае будет слишком много, и никакая оптимизация тут не поможет.

Можно прописать границу карты, за которую выходить нельзя, но тогда будет тяжело рисовать трассы более сложной формы, например, расширяющуюся или сужающуюся, поэтому я остановился на решении прописать возможность создания длинных блоков – легче обсчитывать один объект размерами 10х1, чем 10 объектов 1х1. Сначала проверим, поможет ли нам это, и создадим новый класс — Граница. Так как нижеследующий код является тестовым и потом я его уберу, то этот кусок буду показывать скриншотами.

Теперь уберем в инициализации генерацию боковых стенок, чтобы убрать тормоза:

И прямо под этим циклом делаем генератор длинных стенок.

Номера спрайта -1 в словаре нет, поэтому при отрисовке будет ошибка. Сделаем так, чтобы вместо этого отрисовки просто не происходило во 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();

Если запустим, то увидим, что длинные невидимые стенки нас останавливают:

Немного облегчим себе и компьютеру работу — создаем словарь, в котором будут храниться только твердые объекты:

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

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

И немного меняем алгоритм генерации с учетом того, что у нас теперь есть этот словарь:

Теперь у нас есть проверка на «твердость» объекта, в результате которой объект добавляется в новый словарь, а, значит, нет необходимости приводить типы в обсчете коллизий — можно сразу обращаться к словарю, так как ключи в словаре твердых объектов соответствуют ключам объектов в общем словаре:

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);
    }
}

Метод Update меняем так, чтобы в список сталкивающихся объектов попадали только объекты из списка твердых:

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);
    }
  <............................................>

Запускаем, и видим потрясающую производительность, так как теперь обсчитывается не 1004 объекта, а всего 6 – три машинки, две пограничные стенки и одна стенка на трассе.

Теперь, пока не забыли, сделаем так, чтобы пары объектов не считались дважды:

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
                 });
}

Есть еще один способ – прописывать препятствия карты через тайлы. То есть, проверять, где наш игрок согласно массиву и, если там стенка – не пускать его. Мне он не нравится, потому что он нарушает целостность структуры, которую я создал, поэтому прибегать к нему без необходимости я не буду. По крайней мере, пока.

4.8 Делаем лес видимым

Мы поняли, что стенки в виде единого объекта работают как надо, но теперь возникла проблема того, как их сгенерировать адекватно — во-первых, они сейчас невидимые, а даже если мы дадим им спрайт, то он будет короче, чем нужно, во-вторых, нужно как-то впихнуть приведенный выше хардкод в наш генератор.

Начнем с первой проблемы. Стенки трассы могут быть произвольной длины, а спрайты у нас могут быть только фиксированными. И эта проблема, теоретически, может появиться и потом – у объекта может быть не один спрайт, а больше. Поменяем структуру так, чтобы объект мог состоять из нескольких картинок, заменяя в интерфейсе объекта интовую переменную номера спрайта на словарь таких номеров:

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

Этот список хранит кортежи — номер спрайта и его позицию относительно позиции объекта. Таких образом, мы сможем размещать много спрайтов, которые относятся к одному объекту, в разных местах. Делаем аналогичную замену в классах, которые реализуют IObject - в полях и конструкторах:

<................................................................>
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);  
}

В методе Initialize игрового цикла пока комментим код генерации границ трассы, чтобы не мешались, а также меняем методы генерации машины и стенки в игровом цикле:

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;
}

Соответствующим образом меняем метод отрисовки во View:

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();
}

Теперь сделаем так, чтобы наши стенки имели произвольную длину и нормально отрисовывались. Для начала вернем им свойство твердости, а то потом забудем:

Добавляем стенке свойства длины и ширины (прошу прощения за скриншот, но так нагляднее):

А теперь поменяем метод генерации в игровом цикле так, чтобы наши спрайты множились в соответствии с размерам коллайдера:

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;
}

Теперь разместим стенки на карте:

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';
}

При такой реализации мы размещаем левый верхний угол стены, а все остальное строится уже относительно него:

Класс Border теперь не нужен — можно его удалить.

Следующим этапом пропишем, чтобы размер стенки не хардкодился, а нормально задавался через массив карты. Для начала сделаем перегруженный метод GenerateObject специально для тех случаев, когда объект может иметь произвольные размеры:

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;
}

Генерация стенки без хардкода будет выглядеть следующим образом:

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;
}

Теперь нужно изменить обработчик массива карты так, чтобы стенка корректно генерировалась:

<................................................................>
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);
          }
<................................................................>

Принцип работы следующий — если мы хотим разместить стенку, то в ячейке массива записываем цифру и дальше ищем внутри массива вторую такую же. Тогда первая цифра будет задавать координату левого верхнего угла, а вторая — правого нижнего. Таким образом легко получить геометрические размеры нашей стенки, которые можно подать на метод генерации. После этой операции цифры удаляем из массива, чтобы они не мешались.

Создадим в нашем массиве границы и запустим программу:

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';
}

Работает =)

Можно даже сделать стенки толстыми:

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';
}

Недостатком здесь является то, что типы стенок мы указывать не можем, так как массив у нас чаровый. Но, думаю, в рамках разрабатываемой игры это не страшно.

Минутка рефакторинга

Давайте, теперь уберем слона из комнаты и сделаем так, чтобы размеры наших коллайдеров не хардкодились. Хардкод все равно будет, но там, где это не бесит.

Создаем статический класс с названием Фабрика (к паттернам не имеет отношения), куда переносим наши методы генерации машинки и стены. Кроме того, переносим сюда enum, где хранятся номера спрайтов:

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
  }
}

Создаем словарь _objects, который как раз и будет содержать номер спрайта и параметры коллайдера соответствующего объекта. Суть в том, что генерировать любой объект мы будем через методы класса Factory и весь некрасивый хардкод будет храниться здесь.

Остается поменять под новый класс наш GameCycle:

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;
}

Обратите внимание, что в этом классе мы теперь только указываем, где сгенерировать объект и скорость для машины. Все технические внутренности задает Фабрика по жестко заданному плану.

И не забудем поменять ссылку на номера спрайтов во View:

А на сегодня все. В следующий раз, наконец, сможем уже заняться геймплеем!

Источник: https://habr.com/ru/articles/700938/


Интересные статьи

Интересные статьи

Меня зовут Максим Кульгин, моя компания (xmldatafeed) занимается парсингом сайтов в России порядка четырёх лет. Ежедневно мы парсим более 500 крупнейших интернет-магазинов в России. Теперь д...
НЛМК- большая компания, производственные активы которой располагаются в разных регионах России и за рубежом. Перед нами стояла задача спроектировать и внедрить новую интеграционную платформу, которая ...
Эта текст покрывает ответы на некоторые совсем базовые вопросы и вместе с тем сразу погружает в проблематику получения ответа на вопрос: "как работать лучше? однопоточно, многопоточно или многопоточно...
Привет! Извиняюсь за долгий выпуск продолжения нашего "экшн-сериала", после The Standoff 2020 я (@clevergod) и все ребята "ушли в работу с головой", и новость о весеннем ...
В предыдущей статье мы начали освещать тему эффективности применения методологии TDD для микроконтроллеров (далее – МК) на примере разработки прошивки для STM32. Мы выполнили следующее: Опре...