Создаем 2D игру "Сокобан" в Unity
() translation by (you can also view the original English article)



В этом учебном пособии мы будем изучать подход к созданию игры "Сокобан" или, игры в которой игрок передвигает ящики, с использованием логики на основе тайловой графики и двумерного массива для хранения данных уровня. Мы используем движок Unity для разработки и C# в качестве языка сценариев. Пожалуйста, загрузите исходные файлы, поставляемые вместе с этим руководством.
1. Игра Сокобан
Некоторые из нас, возможно, не играли в вариант игры "Сокобан". Оригинальная версия может быть даже старше вас. Для получения подробной информации посетите страницу википедии. По существу, у нас есть игрок или элемент, управляемый пользователем, который должен выталкивать ящики или подобные элементы на свой конечный тайл.
Уровень состоит из квадратной или прямоугольной сетки тайлов, где тайл может быть блокирующим или неблокирующим. Мы можем ходить по неблокирующим тайлам и толкать на них ящики. Специальные проходимые тайлы будут помечаться как конечные точки назначения, в которые ящик должен в конце концов переместиться, чтобы завершился уровень. Игроком обычно управляют с помощью клавиатуры. Когда все ящики достигли конечной точки назначения, уровень завершен.
Разработка на основе тайловой графики по существу означает, что наша игра состоит из множества тайлов (клеток), распределенных заранее. Элемент данных уровня будет представлять, как должны быть распределены тайлы для создания нашего уровня. В нашем случае мы будем использовать квадратную, тайловую сетку. Вы можете прочитать больше про игры созданные на основе тайловой (плиточной) графики здесь, на Envato Tuts+.
2. Подготовка Unity проекта
Посмотрим, как мы организовали наш проект Unity для этого урока.
Арт игры
Для этого учебного проекта мы не используем никаких внешних арт ассетов, но будем использовать примитивные спрайты, созданные с помощью последней версии Unity 2017.1. На следующем рисунке показано, как мы можем создавать разные формы спрайтов в Unity.



Для уровня мы будем использовать спрайт 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
.
Завершение уровня
Уровень завершен, когда все мячи находятся в их пунктах назначения.



После каждого успешного движения мы вызываем метод 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
, чтобы указать на новый текстовый файл.
Текущая реализация использует клавиатуру для управления игроком. Я предлагаю вам попробовать изменить элемент управления на касания, чтобы мы могли поддерживать сенсорные устройства. Для этого придется найти некоторый двумерный путь, а также, если вы хотите использовать нажатие на любой тайл для того, чтобы провести туда игрока.
Появится дополнительное руководство, в котором мы рассмотрим, как можно использовать текущий проект для создания изометрических и шестиугольных версий игры сокобан с минимальными изменениями.