Advertisement
  1. Game Development
  2. Tile-Based Games

Создаем 2D игру "Сокобан" в Unity

Scroll to top
Read Time: 12 min

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

Final product imageFinal product imageFinal product image
What You'll Be Creating

В этом учебном пособии мы будем изучать подход к созданию игры "Сокобан" или, игры в которой игрок передвигает ящики, с использованием логики на основе тайловой графики и двумерного массива для хранения данных уровня. Мы используем движок Unity для разработки и C# в качестве языка сценариев. Пожалуйста, загрузите исходные файлы, поставляемые вместе с этим руководством.

1. Игра Сокобан

Некоторые из нас, возможно, не играли в вариант игры "Сокобан". Оригинальная версия может быть даже старше вас. Для получения подробной информации посетите страницу википедии. По существу, у нас есть игрок или элемент, управляемый пользователем, который должен выталкивать ящики или подобные элементы на свой конечный тайл.

Уровень состоит из квадратной или прямоугольной сетки тайлов, где тайл может быть блокирующим или неблокирующим. Мы можем ходить по неблокирующим тайлам и толкать на них ящики. Специальные проходимые тайлы будут помечаться как конечные точки назначения, в которые ящик должен в конце концов переместиться, чтобы завершился уровень. Игроком обычно управляют с помощью клавиатуры. Когда все ящики достигли конечной точки назначения, уровень завершен.

Разработка на основе тайловой графики по существу означает, что наша игра состоит из множества тайлов (клеток), распределенных заранее. Элемент данных уровня будет представлять, как должны быть распределены тайлы для создания нашего уровня. В нашем случае мы будем использовать квадратную, тайловую сетку. Вы можете прочитать больше про игры созданные на основе тайловой (плиточной) графики здесь, на Envato Tuts+.

2. Подготовка Unity проекта 

Посмотрим, как мы организовали наш проект Unity для этого урока.

Арт игры

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

How to create sprites within United 20171How to create sprites within United 20171How to create sprites within United 20171

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

Данные уровня

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

1
1,1,1,1,1,1,1
2
1,3,1,-1,1,0,1
3
-1,0,1,2,1,1,-1
4
1,1,1,3,1,3,1
5
1,1,0,-1,1,1,1

Уровень состоит из семи столбцов и пяти строк. Значение 1 означает, что у нас есть основной тайл в этом положении. Значение -1 означает, что это блокирующий тайл, тогда как значение 0 означает, что это конечный целевой тайл. Значение 2 определяет нашего игрока, а значение 3 — перемещающийся мяч. Просто глядя на данные уровня, мы можем визуализировать, как будет выглядеть наш уровень.

3. Создание игрового уровня Сокобан

Чтобы все было просто, и поскольку это не очень сложная логика, у нас есть только один файл сценария для проекта Sokoban.cs, и он прикреплен к камере сцены. Сохраните его в своем редакторе, пока вы будете выполнять остальную часть урока.

Специальные данные уровня

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

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

Парсинг текстового файла уровня

Первый шаг - загрузить наши данные уровня в двумерный массив из внешнего текстового файла. Мы используем метод ParseLevel для загрузки строкового значения string и разбиваем его на заполнение нашего двумерного массива данными уровня levelData.

1
void ParseLevel(){
2
    TextAsset textFile = Resources.Load (levelName) as TextAsset;
3
  string[] lines = textFile.text.Split (new[] { '\r', '\n' }, System.StringSplitOptions.RemoveEmptyEntries);//split by new line, return

4
	string[] nums = lines[0].Split(new[] { ',' });//split by ,

5
	rows=lines.Length;//number of rows

6
	cols=nums.Length;//number of columns

7
	levelData = new int[rows, cols];
8
    for (int i = 0; i < rows; i++) {
9
		string st = lines[i];
10
        nums = st.Split(new[] { ',' });
11
		for (int j = 0; j < cols; j++) {
12
            int val;
13
            if (int.TryParse (nums[j], out val)){
14
                levelData[i,j] = val;
15
			}
16
            else{
17
                levelData[i,j] = invalidTile;
18
			}
19
        }
20
    }
21
}

Во время парсинга мы определяем количество строк и столбцов нашего уровня, когда мы заполняем наш levelData.

Прорисовка уровня

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

1
void CreateLevel(){
2
    //calculate the offset to align whole level to scene middle

3
	middleOffset.x=cols*tileSize*0.5f-tileSize*0.5f;
4
	middleOffset.y=rows*tileSize*0.5f-tileSize*0.5f;;
5
	GameObject tile;
6
	SpriteRenderer sr;
7
	GameObject ball;
8
	int destinationCount=0;
9
	for (int i = 0; i < rows; i++) {
10
		for (int j = 0; j < cols; j++) {
11
            int val=levelData[i,j];
12
			if(val!=invalidTile){//a valid tile

13
				tile = new GameObject("tile"+i.ToString()+"_"+j.ToString());//create new tile

14
				tile.transform.localScale=Vector2.one*(tileSize-1);//set tile size

15
				sr = tile.AddComponent<SpriteRenderer>();//add a sprite renderer

16
				sr.sprite=tileSprite;//assign tile sprite

17
				tile.transform.position=GetScreenPointFromLevelIndices(i,j);//place in scene based on level indices

18
				if(val==destinationTile){//if it is a destination tile, give different color

19
					sr.color=destinationColor;
20
					destinationCount++;//count destinations

21
				}else{
22
					if(val==heroTile){//the hero tile

23
						hero = new GameObject("hero");
24
						hero.transform.localScale=Vector2.one*(tileSize-1);
25
						sr = hero.AddComponent<SpriteRenderer>();
26
						sr.sprite=heroSprite;
27
						sr.sortingOrder=1;//hero needs to be over the ground tile

28
						sr.color=Color.red;
29
						hero.transform.position=GetScreenPointFromLevelIndices(i,j);
30
						occupants.Add(hero, new Vector2(i,j));//store the level indices of hero in dict

31
					}else if(val==ballTile){//ball tile

32
						ballCount++;//increment number of balls in level

33
						ball = new GameObject("ball"+ballCount.ToString());
34
						ball.transform.localScale=Vector2.one*(tileSize-1);
35
						sr = ball.AddComponent<SpriteRenderer>();
36
						sr.sprite=ballSprite;
37
						sr.sortingOrder=1;//ball needs to be over the ground tile

38
						sr.color=Color.black;
39
						ball.transform.position=GetScreenPointFromLevelIndices(i,j);
40
						occupants.Add(ball, new Vector2(i,j));//store the level indices of ball in dict

41
					}
42
				}
43
			} 
44
        }
45
    }
46
	if(ballCount>destinationCount)Debug.LogError("there are more balls than destinations");
47
}

Для нашего уровня мы установили значение tileSize 50, которое является длиной стороны одного квадратного тайла в нашей сетке уровней. Мы циклически проходим по нашему двумерному массиву и определяем значение, хранящееся в каждом из индексов i и j массива. Если это значение не равно invalidTile (-1), мы создаем новый GameObject с именем tile. Мы присоединяем компонент SpriteRenderer к тайлу tile и присваиваем соответствующий спрайт Sprite или цвет Color в зависимости от значения в индексе массива.

При размещении игрока hero или мяча ball нам нужно сначала создать тайл основание, а затем создать остальные тайлы. Поскольку игрок и мяч должны быть наложены на основной тайл, мы даем их компоненту SpriteRenderer более высокий sortingOrder. Всем тайлам присваивается значение localScale для tileSize, поэтому они 50x50 на нашей сцене.

Мы следим за количеством мячей в нашей сцене с помощью переменной ballCount, и должно быть то же самое или большее количество конечных тайлов на нашем уровне, чтобы сделать возможным завершение уровня. Магия происходит в одной строке кода, где мы определяем положение каждого тайла, используя метод GetScreenPointFromLevelIndices (int row, int col).

1
//...

2
tile.transform.position=GetScreenPointFromLevelIndices(i,j);//place in scene based on level indices

3
//...

4
5
Vector2 GetScreenPointFromLevelIndices(int row,int col){
6
    //converting indices to position values, col determines x & row determine y

7
	return new Vector2(col*tileSize-middleOffset.x,row*-tileSize+middleOffset.y);
8
}

Положение в игровом мире плитки определяется путем умножения индексов уровня на значение tileSize. Переменная middleOffset используется для выравнивания уровня в середине экрана. Обратите внимание, что значение row умножается на отрицательное значение для поддержки инвертированной оси y в Unity.

4. Логика игры Сокобан

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

  • Есть ли в сцене тайл на этой позиции или он находится за пределами нашей сетки?
  • Является ли tileK неблокирующим тайлом?
  • Занята ли tileK мячом?

Если позиция tileK находится вне сетки, нам не нужно ничего делать. Если tileK доступна и проходима, тогда нам нужно переместить игрока hero в эту позицию и обновить наш массив levelData. Если в tileK мяч, то нам нужно подумать о следующем соседе в том же направлении, скажем, tileL.

  • Тайл tileL за пределами сетки?
  • Тайл tileL неблокирующий?
  • Занят ли tileL мячом?

Только в том случае, если tileL - это неблокирующий, незанятый тайл, мы должны переместить игрока hero и мяч в tileK на tileK и tileL соответственно. После успешного перемещения нам нужно обновить массив levelData.

Вспомогательные функции

Вышеупомянутая логика означает, что нам нужно знать, какой тайл принадлежит нашему игроку hero. Мы также должны определить, имеет ли конкретный тайл мяч и должен ли иметься доступ к этому мячу.

Для облегчения этого мы используем класс Dictionary именуемый occupants, в котором хранится компонент GameObject в качестве ключа и индексы массива, хранящиеся как Vector2 в качестве значения. В методе CreateLevel мы заполняем occupants, когда создаем игрока hero или мяч. После заполнения dictionary мы можем использовать GetOccupantAtPosition, чтобы вернуть GameObject по заданному индексу массива.

1
Dictionary<GameObject,Vector2> occupants;//reference to balls & hero

2
3
//..

4
occupants.Add(hero, new Vector2(i,j));//store the level indices of hero in dict

5
//..

6
occupants.Add(ball, new Vector2(i,j));//store the level indices of ball in dict

7
//..

8
9
private GameObject GetOccupantAtPosition(Vector2 heroPos)
10
{//loop through the occupants to find the ball at given position

11
    GameObject ball;
12
    foreach (KeyValuePair<GameObject, Vector2> pair in occupants)
13
	{
14
		if (pair.Value == heroPos)
15
		{
16
			ball = pair.Key;
17
			return ball;
18
		}
19
	}
20
	return null;
21
}

Метод IsOccupied определяет, является ли значение levelData по предоставленным индексам мячом.

1
private bool IsOccupied(Vector2 objPos)
2
{//check if there is a ball at given array position

3
    return (levelData[(int)objPos.x,(int)objPos.y]==ballTile || levelData[(int)objPos.x,(int)objPos.y]==ballOnDestinationTile);
4
}

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

1
private bool IsValidPosition(Vector2 objPos)
2
{//check if the given indices fall within the array dimensions

3
    if(objPos.x>-1&&objPos.x<rows&&objPos.y>-1&&objPos.y<cols){
4
    	return levelData[(int)objPos.x,(int)objPos.y]!=invalidTile;
5
	}else return false;
6
}

Ответ на вводимые пользователем данные

В методе обновления Update нашего игрового скрипта мы проверяем события пользователя KeyUp и сравниваем с нашими клавишами ввода, хранящимися в массиве userInputKeys. Как только будет определено требуемое направление движения, мы вызываем метод TryMoveHero с направлением в качестве параметра.

1
void Update(){
2
    if(gameOver)return;
3
	ApplyUserInput();//check & use user input to move hero and balls

4
}
5
6
private void ApplyUserInput()
7
{
8
    if(Input.GetKeyUp(userInputKeys[0])){
9
		TryMoveHero(0);//up

10
	}else if(Input.GetKeyUp(userInputKeys[1])){
11
		TryMoveHero(1);//right

12
	}else if(Input.GetKeyUp(userInputKeys[2])){
13
		TryMoveHero(2);//down

14
	}else if(Input.GetKeyUp(userInputKeys[3])){
15
		TryMoveHero(3);//left

16
	}
17
}

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

1
private void TryMoveHero(int direction)
2
{
3
    Vector2 heroPos;
4
    Vector2 oldHeroPos;
5
	Vector2 nextPos;
6
	occupants.TryGetValue(hero,out oldHeroPos);
7
	heroPos=GetNextPositionAlong(oldHeroPos,direction);//find the next array position in given direction

8
		
9
	if(IsValidPosition(heroPos)){//check if it is a valid position & falls inside the level array

10
		if(!IsOccupied(heroPos)){//check if it is occupied by a ball

11
			//move hero

12
			RemoveOccupant(oldHeroPos);//reset old level data at old position

13
			hero.transform.position=GetScreenPointFromLevelIndices((int)heroPos.x,(int)heroPos.y);
14
			occupants[hero]=heroPos;
15
			if(levelData[(int)heroPos.x,(int)heroPos.y]==groundTile){//moving onto a ground tile

16
				levelData[(int)heroPos.x,(int)heroPos.y]=heroTile;
17
			}else if(levelData[(int)heroPos.x,(int)heroPos.y]==destinationTile){//moving onto a destination tile

18
				levelData[(int)heroPos.x,(int)heroPos.y]=heroOnDestinationTile;
19
			}
20
		}else{
21
			//we have a ball next to hero, check if it is empty on the other side of the ball

22
			nextPos=GetNextPositionAlong(heroPos,direction);
23
			if(IsValidPosition(nextPos)){
24
				if(!IsOccupied(nextPos)){//we found empty neighbor, so we need to move both ball & hero

25
					GameObject ball=GetOccupantAtPosition(heroPos);//find the ball at this position

26
					if(ball==null)Debug.Log("no ball");
27
					RemoveOccupant(heroPos);//ball should be moved first before moving the hero

28
					ball.transform.position=GetScreenPointFromLevelIndices((int)nextPos.x,(int)nextPos.y);
29
					occupants[ball]=nextPos;
30
					if(levelData[(int)nextPos.x,(int)nextPos.y]==groundTile){
31
						levelData[(int)nextPos.x,(int)nextPos.y]=ballTile;
32
					}else if(levelData[(int)nextPos.x,(int)nextPos.y]==destinationTile){
33
						levelData[(int)nextPos.x,(int)nextPos.y]=ballOnDestinationTile;
34
					}
35
					RemoveOccupant(oldHeroPos);//now move hero

36
					hero.transform.position=GetScreenPointFromLevelIndices((int)heroPos.x,(int)heroPos.y);
37
					occupants[hero]=heroPos;
38
					if(levelData[(int)heroPos.x,(int)heroPos.y]==groundTile){
39
						levelData[(int)heroPos.x,(int)heroPos.y]=heroTile;
40
					}else if(levelData[(int)heroPos.x,(int)heroPos.y]==destinationTile){
41
						levelData[(int)heroPos.x,(int)heroPos.y]=heroOnDestinationTile;
42
					}
43
				}
44
			}
45
		}
46
		CheckCompletion();//check if all balls have reached destinations

47
	}
48
}

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

1
private Vector2 GetNextPositionAlong(Vector2 objPos, int direction)
2
{
3
    switch(direction){
4
    	case 0:
5
		objPos.x-=1;//up

6
		break;
7
		case 1:
8
		objPos.y+=1;//right

9
		break;
10
		case 2:
11
		objPos.x+=1;//down

12
		break;
13
		case 3:
14
		objPos.y-=1;//left

15
		break;
16
	}
17
	return objPos;
18
}

Перед перемещением игрока или мяча нам нужно очистить занимаемую ими в настоящее время позицию в массиве levelData. Это делается с помощью метода RemoveOccupant.

1
private void RemoveOccupant(Vector2 objPos)
2
{
3
    if(levelData[(int)objPos.x,(int)objPos.y]==heroTile||levelData[(int)objPos.x,(int)objPos.y]==ballTile){
4
    	levelData[(int)objPos.x,(int)objPos.y]=groundTile;//ball moving from ground tile

5
	}else if(levelData[(int)objPos.x,(int)objPos.y]==heroOnDestinationTile){
6
		levelData[(int)objPos.x,(int)objPos.y]=destinationTile;//hero moving from destination tile

7
	}else if(levelData[(int)objPos.x,(int)objPos.y]==ballOnDestinationTile){
8
		levelData[(int)objPos.x,(int)objPos.y]=destinationTile;//ball moving from destination tile

9
	}
10
}

Если мы найдем heroTile или ballTile по данному индексу, нам нужно установить его в groundTile. Если мы найдем heroOnDestinationTile или ballOnDestinationTile, нам нужно установить его в destinationTile.

Завершение уровня

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

A Completed LevelA Completed LevelA Completed Level

После каждого успешного движения мы вызываем метод CheckCompletion, чтобы узнать, завершен ли уровень. Мы циклически проходим по нашему массиву levelData и подсчитываем количество событий ballOnDestinationTile. Если это число равно нашему общему числу мячей, определяемых переменной ballCount, уровень завершен.

1
private void CheckCompletion()
2
{
3
    int ballsOnDestination=0;
4
    for (int i = 0; i < rows; i++) {
5
		for (int j = 0; j < cols; j++) {
6
            if(levelData[i,j]==ballOnDestinationTile){
7
				ballsOnDestination++;
8
			}
9
		}
10
	}
11
	if(ballsOnDestination==ballCount){
12
		Debug.Log("level complete");
13
		gameOver=true;
14
	}
15
}

Заключение

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

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

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

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 Game Development 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.