Как стать автором
Поиск
Написать публикацию
Обновить

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

Уровень сложностиСредний
Время на прочтение9 мин
Количество просмотров2.1K

Предыдущие части: Часть 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:

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

Теги:
Хабы:
Всего голосов 3: ↑3 и ↓0+3
Комментарии0

Публикации

Ближайшие события