Как написать игру на 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *