Advertisement
  1. Code
  2. Coding Fundamentals
  3. Game Development

Создание жизни: Игра жизни Конвея

Scroll to top
Read Time: 13 min

() translation by (you can also view the original English article)

Иногда даже простой набор основных правил может дать вам очень интересные результаты. В этом уроке мы с нуля создадим основной движок для Игра жизни Конвея.

Примечание: Хотя это руководство написано с использованием C и XNA, вы должны иметь возможность использовать одни и те же приемы и концепции практически в любой среде разработки 2D-игр.


Введение

Игра жизни Конвея - это клеточный автомат, разработанный в 1970-х годах британским математиком по имени Джон Конвей.

Учитывая двумерную сетку ячеек, с некоторыми "вкл" или "живыми" и другими "выключенными" или "мертвыми", и набором правил, которые управляют тем, как они оживают или умирают, мы можем иметь интересную "форму жизни" которая развернется прямо перед нами. Таким образом, просто рисуя несколько шаблонов на нашей сетке, а затем запуская симуляцию, мы можем наблюдать, как основные формы жизни развиваются, распространяются, отмирают и в конечном итоге стабилизируются. Загрузите окончательные исходные файлы или ознакомьтесь с демонстрацией ниже:

Теперь эта "Игра Жизни" не является просто "игрой" - это скорее машина, в основном потому, что в ней нет игрока и нет цели, она просто развивается на основе своих начальных условий. Тем не менее, с ней очень весело играть, и есть много принципов игрового дизайна, которые могут быть применены к ее созданию. Итак, без лишних слов, давайте начнем!

Для этого урока я пошел дальше и построил все в XNA, потому что это то, что мне больше всего нравится. (Здесь есть руководство по началу работы с XNA, если вам интересно.) Однако вы имеете возможность следовать любой среде разработки 2D-игр, с которой вы знакомы.


Создание клеток

Основной элемент в игре Конвея жизни это клетки, которые являются "формами жизни", которые формируют основу всего моделирования. Каждая клетка может находиться в одном из двух состояний: "жив" или "мертв". Для согласованности мы будем придерживаться этих двух названий состояний ячеек до конца этого урока.

Клетки не двигаются, они просто влияют на своих соседей в зависимости от их текущего состояния.

Теперь, с точки зрения программирования их функциональности, мы должны дать им три схемы поведения:

  1. Они должны отслеживать свою позицию, границы и состояние, чтобы их можно было нажать и нарисовать.
  2. Они должны переключаться между живым и мертвым состоянием при нажатии, что позволяет пользователю делать интересные вещи.
  3. Они должны быть нарисованы как белые или черные, если они мертвы или живы, соответственно.

Все вышеперечисленное может быть достигнуто путем создания класса Cell, который будет содержать следующий код:

1
2
class Cell
3
{
4
  public Point Position { get; private set; }
5
	public Rectangle Bounds { get; private set; }
6
7
	public bool IsAlive { get; set; }
8
9
	public Cell(Point position)
10
	{
11
		Position = position;
12
		Bounds = new Rectangle(Position.X * Game1.CellSize, Position.Y * Game1.CellSize, Game1.CellSize, Game1.CellSize);
13
14
		IsAlive = false;
15
	}
16
17
	public void Update(MouseState mouseState)
18
	{
19
		if (Bounds.Contains(new Point(mouseState.X, mouseState.Y)))
20
		{
21
			// Make cells come alive with left-click, or kill them with right-click.

22
			if (mouseState.LeftButton == ButtonState.Pressed)
23
				IsAlive = true;
24
			else if (mouseState.RightButton == ButtonState.Pressed)
25
				IsAlive = false;
26
		}
27
	}
28
29
	public void Draw(SpriteBatch spriteBatch)
30
	{
31
		if (IsAlive)
32
			spriteBatch.Draw(Game1.Pixel, Bounds, Color.Black);
33
34
		// Don't draw anything if it's dead, since the default background color is white.

35
	}
36
}

Сетка и ее правила

Теперь, когда каждая клетка будет вести себя правильно, нам нужно создать сетку, которая будет держать их всех, и реализовать логику, которая сообщает каждому, должна ли она ожить, остаться в живых, умереть или остаться мертвой (без зомби!).

Правила довольно просты:

  1. Любая живая клетка с менее чем двумя живыми соседями умирает.
  2. Любая живая клетка с двумя или тремя живыми соседями доживает до следующего поколения.
  3. Любая живая клетка с более чем тремя живыми соседями умирает, как будто из-за переполненности.
  4. Любая мертвая клетка с ровно тремя живыми соседями становится живой клеткой, как будто путем размножения.

Вот краткое визуальное руководство по этим правилам на изображении ниже. На каждую ячейку, выделенную синей стрелкой, будет действовать соответствующее ей пронумерованное правило выше. Другими словами, клетка 1 умрет, клетка 2 останется в живых, клетка 3 умрет, а клетка 4 оживет.

Таким образом, поскольку игровой симулятор запускает обновление с постоянными временными интервалами, сетка будет проверять каждое из этих правил для всех ячеек в сетке. Это можно сделать, поместив следующий код в новый класс, который я назову Grid:

1
2
class Grid
3
{
4
	public Point Size { get; private set; }
5
6
	private Cell[,] cells;
7
8
	public Grid()
9
	{
10
		Size = new Point(Game1.CellsX, Game1.CellsY);
11
12
		cells = new Cell[Size.X, Size.Y];
13
14
		for (int i = 0; i < Size.X; i++)
15
			for (int j = 0; j < Size.Y; j++)
16
				cells[i, j] = new Cell(new Point(i, j));
17
	}
18
19
	public void Update(GameTime gameTime)
20
	{
21
		(...)
22
23
		// Loop through every cell on the grid.

24
		for (int i = 0; i < Size.X; i++)
25
		{
26
			for (int j = 0; j < Size.Y; j++)
27
			{
28
				// Check the cell's current state, and count its living neighbors.

29
				bool living = cells[i, j].IsAlive;
30
				int count = GetLivingNeighbors(i, j);
31
				bool result = false;
32
33
				// Apply the rules and set the next state.

34
				if (living && count < 2)
35
 					result = false;
36
 				if (living && (count == 2 || count == 3))
37
 					result = true;
38
 				if (living && count > 3)
39
					result = false;
40
				if (!living && count == 3)
41
					result = true;
42
43
				cells[i, j].IsAlive = result;
44
			}
45
		}
46
	}
47
48
	(...)
49
50
}

Единственное, чего нам здесь не хватает, - это магический метод GetLivingNeighbors, который просто подсчитывает, сколько соседей текущей ячейки в данный момент живы. Итак, давайте добавим этот метод в наш класс Grid:

1
2
public int GetLivingNeighbors(int x, int y)
3
{
4
	int count = 0;
5
6
	// Check cell on the right.

7
	if (x != Size.X - 1)
8
		if (cells[x + 1, y].IsAlive)
9
			count++;
10
11
	// Check cell on the bottom right.

12
	if (x != Size.X - 1 && y != Size.Y - 1)
13
		if (cells[x + 1, y + 1].IsAlive)
14
			count++;
15
16
	// Check cell on the bottom.

17
	if (y != Size.Y - 1)
18
		if (cells[x, y + 1].IsAlive)
19
			count++;
20
21
	// Check cell on the bottom left.

22
	if (x != 0 && y != Size.Y - 1)
23
		if (cells[x - 1, y + 1].IsAlive)
24
			count++;
25
26
	// Check cell on the left.

27
	if (x != 0)
28
		if (cells[x - 1, y].IsAlive)
29
			count++;
30
31
	// Check cell on the top left.

32
	if (x != 0 && y != 0)
33
		if (cells[x - 1, y - 1].IsAlive)
34
			count++;
35
36
	// Check cell on the top.

37
	if (y != 0)
38
		if (cells[x, y - 1].IsAlive)
39
			count++;
40
41
	// Check cell on the top right.

42
	if (x != Size.X - 1 && y != 0)
43
		if (cells[x + 1, y - 1].IsAlive)
44
			count++;
45
46
	return count;
47
}

Обратите внимание, что в приведенном выше коде первый оператор if каждой пары просто проверяет, что мы не на краю сетки. Если бы у нас не было этой проверки, мы столкнулись бы с несколькими исключениями, выходящими за пределы массива. Кроме того, так как это приведет к тому, что счетчик никогда не будет увеличиваться при проверке за краями, это означает, что игра "предполагает", что края мертвы, поэтому это эквивалентно наличию постоянной границы белых мертвых ячеек вокруг наших игровых окон.


Обновление сетки в дискретных временных шагах

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

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

К счастью, это действительно довольно легко реализовать в коде. Все, что нам нужно, это иметь вторую сетку ячеек в памяти для следующего состояния ячеек. Каждый раз, когда мы определяем следующее состояние ячейки, мы сохраняем ее во второй сетке для следующего состояния всех ячеек. Затем, когда мы нашли следующее состояние каждой ячейки, мы применяем их все одновременно. Таким образом, мы можем добавить двумерный массив логических значений nextCellStates как частную переменную, а затем добавить этот метод в класс Grid:

1
2
public void SetNextState()
3
{
4
	for (int i = 0; i < Size.X; i++)
5
		for (int j = 0; j < Size.Y; j++)
6
			cells[i, j].IsAlive = nextCellStates[i, j];
7
}

Наконец, не забудьте исправить вышеописанный метод Update, чтобы он присваивал результат следующему состоянию, а не текущему, а затем вызывал SetNextState в самом конце метода Update, сразу после завершения циклов.


Рисование сетки

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

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

1
2
public void Draw(SpriteBatch spriteBatch)
3
{
4
	foreach (Cell cell in cells)
5
		cell.Draw(spriteBatch);
6
7
	// Draw vertical gridlines.

8
	for (int i = 0; i < Size.X; i++)
9
		spriteBatch.Draw(Game1.Pixel, new Rectangle(i * Game1.CellSize - 1, 0, 1, Size.Y * Game1.CellSize), Color.DarkGray);
10
11
	// Draw horizontal gridlines.

12
	for (int j = 0; j < Size.Y; j++)
13
		spriteBatch.Draw(Game1.Pixel, new Rectangle(0, j * Game1.CellSize - 1, Size.X * Game1.CellSize, 1), Color.DarkGray);
14
}

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


Добавление игровой логики высокого уровня

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

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

Теперь, когда у нас все настроено и готово к работе, мы можем просто запустить игру! Но не так быстро, потому что есть проблема: мы ничего не можем сделать, потому что игра всегда запущена. По сути, невозможно нарисовать определенные фигуры, потому что они будут разбиваться на части, когда вы рисуете их, поэтому нам нужно иметь возможность приостановить игру. Было бы также хорошо, если бы мы могли очистить сетку, если она станет беспорядочной, потому что наши творения часто выходят из-под контроля и оставляют беспорядок позади.

Итак, давайте добавим некоторый код для приостановки игры при каждом нажатии пробела и очистим экран, если нажата клавиша Backspace:

1
2
protected override void Update(GameTime gameTime)
3
{
4
	keyboardState = Keyboard.GetState();
5
6
	if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
7
		this.Exit();
8
9
	// Toggle pause when spacebar is pressed.

10
	if (keyboardState.IsKeyDown(Keys.Space) && lastKeyboardState.IsKeyUp(Keys.Space))
11
		Paused = !Paused;
12
13
	// Clear the screen if backspace is pressed.

14
	if (keyboardState.IsKeyDown(Keys.Back) && lastKeyboardState.IsKeyUp(Keys.Back))
15
		grid.Clear();
16
17
	base.Update(gameTime);
18
19
	grid.Update(gameTime);
20
21
	lastKeyboardState = keyboardState;
22
}

Также было бы полезно, если бы мы очень четко дали понять, что игра была приостановлена, поэтому, когда мы пишем наш метод Draw, давайте добавим некоторый код, чтобы фон стал красным, и напишем "Paused" в фоновом режиме:

1
2
protected override void Draw(GameTime gameTime)
3
{
4
	if (Paused)
5
		GraphicsDevice.Clear(Color.Red);
6
	else
7
		GraphicsDevice.Clear(Color.White);
8
9
	spriteBatch.Begin();
10
	if (Paused)
11
	{
12
		string paused = "Paused";
13
		spriteBatch.DrawString(Font, paused, ScreenSize / 2, Color.Gray, 0f, Font.MeasureString(paused) / 2, 1f, SpriteEffects.None, 0f);
14
	}
15
	grid.Draw(spriteBatch);
16
	spriteBatch.End();
17
18
	base.Draw(gameTime);
19
}

Это оно! Теперь все должно работать, так что вы можете сделать это, нарисовать несколько форм жизни и посмотреть, что произойдет! Откройте для себя интересные шаблоны, которые вы можете сделать, снова обратившись к странице Википедии. Вы также можете поиграть с частотой кадров, размером ячейки и размерами сетки, чтобы настроить его по своему вкусу.


Добавление улучшений

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

Начнем с определения частоты обновления и частоты кадров отдельно в основном классе:

1
2
public const int UPS = 20; // Updates per second

3
public const int FPS = 60;

Теперь, при инициализации игры, используйте частоту кадров (FPS), чтобы определить, как быстро она будет считывать ввод с мыши и рисовать, что должно быть как минимум ровным 60 FPS как минимум:

1
2
IsFixedTimeStep = true;
3
TargetElapsedTime = TimeSpan.FromSeconds(1.0 / FPS);

Затем добавьте таймер в ваш класс Grid, чтобы он обновлялся только тогда, когда это необходимо, независимо от частоты кадров:

1
2
public void Update(GameTime gameTime)
3
{
4
	(...)
5
6
	updateTimer += gameTime.ElapsedGameTime;
7
8
	if (updateTimer.TotalMilliseconds > 1000f / Game1.UPS)
9
	{
10
		updateTimer = TimeSpan.Zero;
11
12
		(...) // Update the cells and apply the rules.

13
14
	}
15
16
}

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


Заключение

Теперь у вас в руках гладкая и функциональная Игра Жизни, но если вы захотите изучить ее дальше, вы всегда сможете добавить к ней больше настроек. Например, сетка в настоящее время предполагает, что за ее пределами все мертво. Вы можете изменить его так, чтобы сетка вращалась, так что планер летал бы вечно! В этой популярной игре нет недостатка в вариациях, так что дайте волю своему воображению.

Спасибо за прочтение, надеюсь, вы узнали что-то полезное сегодня!

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.