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

4.5 Создаем вселенную

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

Начнем с того, что расчертим наш мир на квадраты (тайлы) внутри которых будет происходить что-то интересное, например, находиться машинка. Для этого внутри нашего GameCycle создадим новый двухмерный массив и дадим ему тестовые размеры 10х8 ячеек типа char. Идея следующая — если в ячейке стоит определенный символ, например, 'P', то в заданном квадрате игра сгенерирует машинку игрока. Если не нравится возиться с символьными типами, то можно сделать любой другой массив (кроме булевого) и ввести свои обозначения. Я раньше так и делал, пока не подсмотрел идею с чаровыми массивами для карты на канале StandaloneCoder и не забрал себе — мне так удобнее работать . Далее вводим параметр геометрического размера нашего квадратика — _tileSize. Задать его лучше так, чтобы объекты помещались внутри него. За кадром я уменьшил спрайт машинки (теперь он 77х100 пикселей), поэтому размеры тайла у меня будут равны 100.

После этого в методе Initialize прописываем генерацию объектов (их пока два — машинка игрока и машинка не игрока) в соответствии со значениями в ячейках — 'P' — игрок, 'C' — другая машинка.

//<......................>
private int _currentId;

private char[,] _map = new char [10, 8];
private int _tileSize = 100;

public int PlayerId { get; set; }
public Dictionary<int, IObject> Object { get; set; }

public void Initialize()
{
  Objects = new Dictionary<int, IObject>();
  _map[5, 6] = 'P';
  _map[4, 4] = 'C';
  _map[6, 6] = 'C';
  _currentId = 1;
  bool isPlacedPlayer = false;//Проверяем, не разместили ли мы уже игрока
  for (int y = 0; y < _map.GetLength(1); y++)
    for (int x = 0; x < _map.GetLength(0); x++)
    {
      if (_map[x, y] == 'P' && !isPlacedPlayer)
      {
        Car player = new Car();
        player.ImageId = 1;
    		player.Pos = new Vector2(x*_tileSize + _tileSize/2 - 38,
                                y*_tileSize + _tileSize/2 - 50);    		
    		player.Speed = new Vector2(0, 0); 
    		PlayerId = _currentId;
        Objects.Add(_currentId, player);
        isPlacedPlayer = true;
    		_currentId++;
      }
  		else if (_map[x, y] == 'C')
      {
        Car anotherCar = new Car();
  			anotherCar.Pos = new Vector2(x*_tileSize + _tileSize/2 - 38,
                                y*_tileSize + _tileSize/2 - 50);
    		anotherCar.ImageId = 1;
    		anotherCar.Speed = new Vector2(0, 0);
  			Objects.Add(_currentId, anotherCar);
  			_currentId++;
      }   
    }       
}

Итак, вместо жестко заданной генерации наш метод проходит циклом по нашей карте _map, проверяет значение в ячейках и размещает объект с учетом размера тайла. Смещения позиции я подобрал так, чтобы машинка размещалась по центру тайла: мы смещаем относительно верхнего левого угла тайла левый верхний угол машинки сначала на половину его размера по X и Y — тогда левый верхний угол машинки будет в центре тайла. После этого смещаем его уже вверх и влево на половину размеров спрайта машинки.

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

Примечание: Здесь невооруженным взглядом заметен довольно уродливый дубляж кода, но пока беспокоиться не нужно — мы все поправим.

Пока я создал в ячейках (4;4) и (6;4) машинки NPC, а в ячейке (5;6) — игрока. Всего же размер массива сейчас 10х8 при размере тайла 100 — то есть, наша карта занимает примерно один экран, машинка игрока должна сгенерироваться по центру и снизу, а NPC — на 100 пикселей выше слева и справа. Запустим и проверим.

Генерация трех машинок согласно карте
Генерация трех машинок согласно карте

Все работает, можно идти дальше. Добавим второй основной объект, который должен в этой игре присутствовать помимо машинок — стенки трассы. Создаем класс Wall, который реализует интерфейс IObject, чтобы он мог корректно работать внутри нашей системы:

class Wall : IObject
{
	public int ImageId { get; set; }
  public Vector2 Pos { get; set; }
  
  public void Update()
  {
  
  }
}

Класс очень простой — у стенки нет ничего, кроме позиции и номера спрайта — пока у нее никакого апдейта не предполагается. Добавим в код игрового цикла генерацию стенки, а в массиве будем обозначать ее как 'W':

public void Initialize()
{
  Objects = new Dictionary<int, IObject>();
  _map[5, 6] = 'P';
  _map[4, 4] = 'C';
  _map[6, 6] = 'C';
  //Генерируем стенки по краям трассы
  for (int y = 0; y < _map.GetLength(1); y++)
  {
    _map[0, y] ='W';
    _map[_map.GetLength(0)-1, y] ='W';
  }
  _currentId = 1;
  bool isPlacedPlayer = false;
  for (int y = 0; y < _map.GetLength(1); y++)
    for (int x = 0; x < _map.GetLength(0); x++)
    {
      //Напоминаю, что таким знаком я обозначаю куски кода, 
      //который не менялся
      //<........................>
      else if (_map[x, y] == 'W')
      {
        Wall w = new Wall();
        w.Pos = new Vector2(x*_tileSize + _tileSize/2 - 12,
                                y*_tileSize + _tileSize/2 - 50);
        w.ImageId = 2;
        Objects.Add(_currentId, w);
        _currentId++;
      } 
    }  
}

Спрайт для стенки я сделал заранее и уже добавил в словарь спрайтов — повторяться не буду.

Теперь это, наконец, стало похоже на бессмертную классику моего детства:

Бессмертная классика моего детства
Бессмертная классика моего детства

Настало время задуматься вот, о чем: мы сделали смещение камеры вместе с машинкой, но игра изначально не фокусируется на ней. Это станет заметно, если мы попробуем поместить машинку в другие координаты. Устраним это досадное недоразумение. Для этого достаточно в методе Initialize в конце вызвать событие Updated так же, как и в методе Update:

Updated.Invoke(this, new GameplayEventArgs()
{
    Objects = Objects,
    POVShift = Objects[PlayerId].Pos    
});

Запускаем:

Итак, мы видим, что смещение произошло, но не туда, куда надо, так как я поставил значение POVShift равным координате левого верхнего угла спрайта машинки. Чтобы это исправить, нужно теперь из начального POVShift вычесть значения X и Y позиции, на которой машинка должна появиться на экране, то есть, на сколько сместить машинку относительно верхнего левого угла экрана:

Updated.Invoke(this, new GameplayEventArgs()
{
    Objects = Objects,
    POVShift = new Vector2(
      Objects[PlayerId].Pos.X - 512 + 38,
      Objects[PlayerId].Pos.Y - 512 + 50)
});

Я поставил ее по центру по оси OX и сместил на такое же значение 512 по OY вниз, чтобы машинка оказалась в нижней части экрана:

Кажется, что машинка находится не по центру, но на самом деле криво отцентрирована трасса — если смотреть на машинку и края экрана, то она ровно посередине. Сделаем трассу более протяженной и разместим машинку в ее начале (в самом низу, так как ось OY направлена вниз):

//<.......................>
//Длина трассы теперь 500 тайлов
private char[,] _map = new char [10, 500];
private int _tileSize = 100;

public int PlayerId { get; set; }
public Dictionary<int, IObject> Object { get; set; }

public void Initialize()
{
  Objects = new Dictionary<int, IObject>();
  _map[5, 498] = 'P'; //Помещаем игрока в начало трассы
  _map[4, 4] = 'C';
  _map[6, 6] = 'C';
  _currentId = 1;
  bool isPlacedPlayer = false;
  //<.......................>  
}

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

public class Car : IObject
{  
		public int ImageId { get; set; }
    public Vector2 Pos { get; set; } 
    public void Update()
    {
        Pos += Speed;
        Speed = new Vector2(0, Speed.Y);        
    }
}

Теперь при нажатии клавиш влево-вправо боковая скорость изменится на 1, машинка сдвинется при вызове метода Update, после чего сбросит скорость. Однако, 1 пиксель маловато, сделаем поворот чуть более резким. Для этого в GameCycle изменим две строчки в методе MovePlayer:

case IGameplayModel.Direction.right:
{
    p.Speed += new Vector2(5, 0);
    break;
}
case IGameplayModel.Direction.left:
{
    p.Speed += new Vector2(-5, 0);
    break;
}

Уже получилось нечто осязаемое, но прежде, чем идти дальше, нужно привести код в порядок.

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

Самый разросшийся метод в нашей программе — Initalized в Модели. В нем наблюдается нехорошая тенденция, когда один метод делает все. Прежде чем ситуация стала необратимой разграничим зоны ответственности. Инициализация не должна заниматься непосредственной генерацией, она должна работать на более высоком уровне. Поэтому создаем методы генерации объектов в Модели — выведем генератор для каждого типа в отдельный метод. Так работать будет намного удобнее (спойлер: потом для генерации вообще будет сделан отдельный класс, но пока в этом нет необходимости).

private Car CreateCar (
  float x, float y, int spriteId, Vector2 speed)
{
  Car c = new Car();
  c.ImageId = spriteId;
  c.Pos = new Vector2(x, y);
  c.Speed = speed;
  return c;
}

private Wall CreateWall(
  float x, float y, int spriteId)
{
  Wall w = new Wall();
  w.Pos = new Vector2(x, y);
  w.ImageId = spriteId;
  return w;
}

Заодно избавились от дубляжа - игрок и машинка генерируется одним и тем же методом. Вызываться нужные методы генерации тоже будут не в Initialize, сделаем для этого отдельный метод GenerateObject:

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 = CreateCar(
      x + _tileSize/2 - 38, y + _tileSize/2 - 50,
    spriteId: 1, speed: new Vector2(0,0));
  }
  else if (sign == 'W')
  {
    generatedObject = CreateWall(
      x + _tileSize/2 - 12, y + _tileSize/2 - 50,
    spriteId: 2);
  }
  return generatedObject;
}

Теперь достаточно просто вызвать этот метод внутри Initialize, и у нас будет нужный готовый объект:

//<.....................>
_currentId = 1;
bool isPlacedPlayer = false;
for (int y = 0; y < _map.GetLength(1); y++)
    for (int x = 0; x < _map.GetLength(0); x++)
    {
      if (_map[x, y] != '\0')
      {
        IObject generatedObject = GenerateObject(
          _map[x, y], x, y);
        Objects.Add(_currentId, generatedObject);
        _currentId++;
      } 
    }
PlayerId = 1;
Updated.Invoke(this, new GameplayEventArgs()
{
    Objects = Objects,
    POVShift = new Vector2(
      Objects[PlayerId].Pos.X - 512 + 38,
      Objects[PlayerId].Pos.Y - 512 + 50)
});
//<.....................>

Однако, теперь непонятно, что делать с айдишником игрока, так как он не всегда равен 1. Например, в текущей генерации на первый айдишник у нас стенка:

Новый спрайт игрока
Новый спрайт игрока

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

//<.....................>
_currentId = 1;
bool isPlacedPlayer = false;
for (int y = 0; y < _map.GetLength(1); y++)
    for (int x = 0; x < _map.GetLength(0); x++)
    {
      if (_map[x, y] != '\0')
      {
        IObject generatedObject = GenerateObject(
          _map[x, y], x, y);
        if (_map[x, y] == 'P'&&!isPlacedPlayer)
        {
          PlayerId = _currentId;
          isPlacedPlayer = true;
        }        
        Objects.Add(_currentId, generatedObject);
        _currentId++;
      } 
    }
//Строку с PlayerId = 1 убрали
Updated.Invoke(this, new GameplayEventArgs()
{
    Objects = Objects,
    POVShift = new Vector2(
      Objects[PlayerId].Pos.X - 512 + 38,
      Objects[PlayerId].Pos.Y - 512 + 50)
});
//<.....................>

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

Я долго (минуты 3) думал над этим, размышлял, пытался передавать параметры в модель по еще одному событию, но потом подумал — а зачем? Пусть этим занимается View! Убираем все это из модели и переносим во View. Оставляем пока только центрирование по тайлу, но потом и от него нужно будет избавиться:

//<.....................>
//Кусок метода Update Модели
Updated.Invoke(this, new GameplayEventArgs()
{
  //Убрали указание на положение на экране
    Objects = Objects,
    POVShift = new Vector2(
      Objects[PlayerId].Pos.X,
      Objects[PlayerId].Pos.Y)
});
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 = CreateCar(
      x + _tileSize/2, y + _tileSize/2,
    spriteId: 1, speed: new Vector2(0,0));
  }
  else if (sign == 'W')
  {
    generatedObject = CreateWall(
      x + _tileSize/2, y + _tileSize/2,
    spriteId: 2);
  }
  return generatedObject;
}

POVShift теперь только передает коодинаты машины игрока, а в генерации мы оставили только привязку к центру тайла. Учет разрешения экрана пишем во View в методе инициализации класса:

protected override void Initalize()
{
  base.Initialize();
  _graphics.IsFullScreen = false;
  _graphics.PreferredBackBufferWidth = 1024;
  _graphics.PreferredBackBufferHeight = 768;
  _graphics.ApplyChanges();
  _visualShift.X -= _graphics.PreferredBackBufferWidth/2;
  _visualShift.Y -= _graphics.PreferredBackBufferHeight*0.8f;
}

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

Комментарий: отрисовку относительно центра спрайта (дополнительные смещения на 38, 12 и 50 пикселей у стенки и машинки) я убрал, поэтому положение отрисовки задается от левого верхнего угла спрайта. Когда я начинал статью, то эта отрисовка была, но затем я обнаружил, что ошибся, из-за чего наблюдался один неприятный баг, из-за которого криво обсчитывалось столкновение между объектами. Код исправлен (обращения к размерам спрайтам вообще выброшены), но на следующих скриншотах и гифках вы увидите красивое центрирование, когда баг я еще не нашел. Если вы используете предлагаемый мной код, то у вас все будет сдвинуто вправо на половину ширины машинки.

Теперь нужно что-то решать с айдишниками спрайтов, которые мы присваиваем в модели. Я долго думал и решил просто добавить их номера перечислением в класс GameCycle:

public enum ObjectTypes : byte
{
    car,
    wall,  
}

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

Прошу прощения за скриншот — в данном случае так легче показать, что поменялось.

Осталось сделать так, чтобы View обращался к тем же номерам:

protected override void LoadContent()
{
    _spriteBatch = new SpriteBatch(GraphicsDevice);
    _textures.Add((byte)GameCycle.ObjectTypes.car, Content.Load<Texture2D>("Base_car"));
    _textures.Add((byte)GameCycle.ObjectTypes.wall, Content.Load<Texture2D>("Wall"));
}

Это немного нарушает паттерн MVP, потому что теперь View знает о модели, но, я думаю, это не так страшно. View мы теперь не можем использовать независимо от модели, но модель с другим View - вполне. На этом заканчиваем очередную минутку рефакторинга — можно двигаться дальше.

В следующий раз сделаем наши машинки и стенки материальными объектами!

Комментарии (0)