Предыдущие части: Часть 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:
А на сегодня все. В следующий раз, наконец, сможем уже заняться геймплеем!