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

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