Игра «Сокобан» / Sokoban [2D]

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


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

Создадим папку Resources, а в ней папку Prefabs - где будем хранить префабы, игровой карты.

Теперь, нужно нарисовать страйты для наших префабов. Сразу определитесь с размерами, так как все спрайты должны быть одинаковыми квадратами. Лучше всего использовать размеры 100х100 или 200х200 и т.д. Внимание! Все спрайты о которых пойдет речь дальше, должны быть одинаковые по ширине и высоте.

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

Игровые префабы:

Player - без коллайдера, со стандартным тегом Player.

Ground и Wall и Box - с 2D коллайдероми.

Target - с 2D триггером (размер триггера должен быть в два раза меньше) и Rigidbody2D в режиме Kinematic:

Игра «Сокобан» / Sokoban [2D]

Дополнительно на объект Target цепляем скрипт:

using System.Collections;
using UnityEngine;

public class SokobanTrigger : MonoBehaviour {

	void OnTriggerEnter2D(Collider2D other)
	{
		if(other.transform.name.CompareTo("Box") == 0) Sokoban.target++;
	}

	void OnTriggerExit2D(Collider2D other)
	{
		if(other.transform.name.CompareTo("Box") == 0) Sokoban.target--;
	}
}

Задача скрипта, отслеживать, есть контакт с ящиком или нет.

Итак, все эти префабы мы кидаем в папку Prefabs и двигаемся дальше.

Добавим на сцену наш небольшой редактор:

using System.Collections;
using UnityEngine;

public class SokobanEditorSetup : MonoBehaviour {

	[Header("Настройки меню")]
	public Vector2 position = new Vector2(10, 10);
	public float width = 400;
	public float height = 60;
	[Header("Папка с префабами в Resources")]
	public string prefabsPath = "Prefabs";
	[Header("Настройки сетки")]
	public Sprite cellSprite;
	public Color cellColor = Color.white;
	public int cellWidth = 10;
	public int cellHeight = 10;
	public float cellSize = 1;
	[Header("Родительский объект карты")]
	public Transform map;

	// данные переменные используются менюшкой
	[HideInInspector] public Transform[] prefabs;
	[HideInInspector] public string[] prefabsNames;
	[HideInInspector] public int index;
	[HideInInspector] public bool showButton, project2D;
	[HideInInspector] public string tagField;
	[HideInInspector] public LayerMask layerMask;

	void Awake()
	{
		gameObject.SetActive(false);
	}

	public void ClearMap()
	{
		GetClear(map);
	}

	void GetClear(Transform tr)
	{
		GameObject[] obj = new GameObject[tr.childCount];
		for(int i = 0; i < tr.childCount; i++)
		{
			obj[i] = tr.GetChild(i).gameObject;
		}
		foreach(GameObject t in obj)
		{
			DestroyImmediate(t);
		}
	}

	public void Create()
	{
		GetClear(transform);
		Transform sample = new GameObject().transform;
		sample.gameObject.tag = "EditorOnly";
		sample.gameObject.AddComponent<SpriteRenderer>().sprite = cellSprite;
		sample.gameObject.GetComponent<SpriteRenderer>().color = cellColor;
		sample.gameObject.AddComponent<BoxCollider2D>();
		float posX = -cellSize * cellWidth/2 - cellSize/2;
		float posY = cellSize * cellHeight/2 + cellSize/2;
		float Xreset = posX;
		int z = 0;
		for(int y = 0; y < cellHeight; y++)
		{
			posY -= cellSize;
			for(int x = 0; x < cellWidth; x++)
			{
				posX += cellSize;
				Transform tr = Instantiate(sample) as Transform;
				tr.SetParent(transform);
				tr.localScale = Vector3.one;
				tr.position = new Vector2(posX, posY);
				tr.name = "Cell_" + z;
				z++;
			}
			posX = Xreset;
		}
		DestroyImmediate(sample.gameObject);
	}

	public void SetPrefab(GameObject obj)
	{
		if(prefabs.Length == 0) return;
		Transform clone = Instantiate(prefabs[index], obj.transform.position - Vector3.forward * 0.05f, Quaternion.identity) as Transform;
		clone.gameObject.name = prefabs[index].name;
		clone.parent = map;
	}

	public void LoadResources()
	{
		prefabs = Resources.LoadAll<Transform>(prefabsPath);

		prefabsNames = new string[prefabs.Length];
		for(int i = 0; i < prefabs.Length; i++)
		{
			prefabsNames[i] = prefabs[i].name;
		}

		index = 0;
	}
}

Здесь лишь часть функционала. Поэтому в папку со скриптами кидаем:

#if UNITY_EDITOR
using System.Collections;
using UnityEngine;
using UnityEditor;
[CustomEditor(typeof(SokobanEditorSetup))]
public class SokobanEditor : Editor {

	public override void OnInspectorGUI()
	{
		DrawDefaultInspector();
		SokobanEditorSetup t = (SokobanEditorSetup)target;
		GUILayout.Label("Управление:", EditorStyles.boldLabel);
		GUILayout.BeginHorizontal();
		if(GUILayout.Button("Создать / Обновить сетку")) t.Create();
		if(GUILayout.Button("Очистить карту")) t.ClearMap();
		GUILayout.EndHorizontal();
	}

	void OnSceneGUI()
	{
		SokobanEditorSetup t = (SokobanEditorSetup)target;

		HandleUtility.AddDefaultControl(GUIUtility.GetControlID(FocusType.Passive)); // отмена выбора объекта ЛКМ в окне редактора
		
		if(Event.current.button == 0 && Event.current.type == EventType.mouseDown || Event.current.button == 0 && Event.current.type == EventType.mouseDrag)
		{
			RaycastHit2D hit = Physics2D.Raycast(SceneView.currentDrawingSceneView.camera.ScreenToWorldPoint(new Vector2(Event.current.mousePosition.x, 
				SceneView.currentDrawingSceneView.camera.pixelHeight - Event.current.mousePosition.y)), Vector2.zero);
			
			if(hit.collider != null)
			{
				if(!Event.current.shift)
				{
					if(hit.collider.name.CompareTo(t.prefabsNames[t.index]) != 0) t.SetPrefab(hit.transform.gameObject);
				}
				else
				{
					if(hit.collider.tag.CompareTo("EditorOnly") != 0) DestroyImmediate(hit.transform.gameObject);
				}
			}
		}

		Handles.BeginGUI();
		GUILayout.BeginArea(new Rect(t.position.x, t.position.y, t.width, t.height), EditorStyles.helpBox);

		if(GUILayout.Button("Загрузить / Обновить префабы")) t.LoadResources();

		GUILayout.TextArea("Справка: установить выбранный объект ЛКМ, убрать объект Shift+ЛКМ.");

		GUILayout.BeginHorizontal();
		GUILayout.TextField("Выбор префаба: ");
		t.index = EditorGUILayout.Popup(t.index, t.prefabsNames);
		GUILayout.EndHorizontal();

		GUILayout.EndArea();
		Handles.EndGUI();
	}
}
#endif

Теперь, выбрав объект сцены, на котором у нас весит класс SokobanEditorSetup, мы можем создавать и редактировать игровую карту, в окне редактора Юнити.

В заключении, остается контроль игры, добавим на сцену:

using System.Collections;
using UnityEngine;

public class Sokoban : MonoBehaviour {

	[SerializeField] private int targetCount = 1; // сколько точек для ящиков на карте
	[SerializeField] private float zOffset = -1; // смещение, чтобы персонаж и ящики, были выше остальных объектов
	[SerializeField] private float step = 1; // шаг движения, должен быть такой же, как и размер клетки
	[SerializeField] private float speed = 0.05f; // скорость движения
	private Transform player, moved; 
	public static int target { get; set; }
	private Vector3 direction, targetPos;
	private bool isMove;

	void Awake()
	{
		player = GameObject.FindGameObjectWithTag("Player").transform;
		target = 0;
		if(player != null)
		{
			player.position = new Vector3(player.position.x, player.position.y, zOffset);
			targetPos = player.position;
		}
	}

	void Complete()
	{
		Debug.Log("! You Win !");
	}

	void Update()
	{
		if(player != null)
		{
			Control();
		}
	}

	Transform GetTransform(Vector2 point)
	{
		RaycastHit2D hit = Physics2D.Raycast(point, Vector2.zero);

		if(hit.collider != null)
		{
			return hit.transform;
		}

		return null;
	}

	bool CanMove()
	{
		moved = null;

		// поиск объектов в направлении движения, на два хода вперед
		Transform t1 = GetTransform(new Vector2(player.position.x + step * direction.x, player.position.y + step * direction.y));
		Transform t2 = GetTransform(new Vector2(player.position.x + step * direction.x * 2f, player.position.y + step * direction.y * 2f));

		// ищем ящик
		if(t1 != null && t1.name.CompareTo("Box") == 0) moved = t1;

		// условия при которых, движение невозможно
		if(t1 == null || t1.name.CompareTo("Wall") == 0 || moved != null && t2 != null && t2.name.CompareTo("Box") == 0 || 
			moved != null && t2 != null && t2.name.CompareTo("Wall") == 0) return false;

		isMove = true;
		if(moved != null) moved.position = new Vector3(moved.position.x, moved.position.y, zOffset);

		return true;
	}

	void Move()
	{
		if(!CanMove()) return;

		// определяем точку назначения
		targetPos = new Vector3(player.position.x + step * direction.x, player.position.y + step * direction.y, player.position.z);
	}

	Vector3 GetRoundPos(Vector3 val) // обрезаем хвосты
	{
		val.x = Mathf.Round(val.x * 100f)/100f;
		val.y = Mathf.Round(val.y * 100f)/100f;
		val.z = Mathf.Round(val.z * 100f)/100f;
		return val;
	}

	void Control()
	{
		if(isMove)
		{
			// движение персонажа и ящика, если он есть
			player.position = Vector3.MoveTowards(player.position, targetPos, speed);
			if(moved != null) moved.position = Vector3.MoveTowards(moved.position, targetPos + direction * step, speed);

			if(targetPos == GetRoundPos(player.position))
			{
				isMove = false;

				// выравнивание позиции
				player.position = GetRoundPos(player.position);
				if(moved != null) moved.position = GetRoundPos(moved.position);

				if(target == targetCount)
				{
					Complete();
					enabled = false;
				}
			}

			return;
		}

		if(Input.GetKey(KeyCode.D))
		{
			direction = Vector3.right;
		}
		else if(Input.GetKey(KeyCode.A))
		{
			direction = Vector3.left;
		}
		else if(Input.GetKey(KeyCode.W))
		{
			direction = Vector3.up;
		}
		else if(Input.GetKey(KeyCode.S))
		{
			direction = Vector3.down;
		}
		else
		{
			direction = Vector3.zero;
		}

		if(direction.magnitude != 0)
		{
			Move();
		}
	}
}

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

Переменные cellSize и step должны иметь одинаковое значение, а определяются они исходя из размера одной из сторон квадрата любого перфаба (так как они одинаковые у нас). Просто посмотрите размер коллайдера Х или У, это и будет нужное значение.

Скачать игру:

Внимание! Посетители, находящиеся в группе Гости, не могут скачивать файлы.
Тестировалось на: Unity 5.6.1

Комментариев 1

Офлайн
Вот - это ностальгия relieved satisfied !
Информация
Посетители, находящиеся в группе Гости, не могут оставлять комментарии к данной публикации.
  • Яндекс.Метрика