2D порталы в платформере

Основная идея создания порталов в двухмерном платформере в том, чтобы создать иллюзию прохождения объекта через портал. Визуально это должно выглядеть следующим образом, например, персонаж входит в портал и как только половина пути пройдена, происходит телепортация на другой портал. Сложность вопроса в том, что в месте портала, коллайдер должен быть проходимым. Допустим, у нас есть стена на который коллайдер, игрок не может пройти через стену само собой, но, если в любом месте стены мы ставим портал, через него проходить можно, а вся остальная стена должна остаться непроходимой. Иначе говоря, нам надо «вырезать» часть коллайдера.


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

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

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

Теперь надо сделать пулю или снаряд, который будет вылетать из портальной пушки:

2D порталы в платформере


using System.Collections;
using UnityEngine;

public class Portal2DBullet : MonoBehaviour {

	public Rigidbody2D body;
	public Portal2D portal { get; set; }
	public int targetMask { get; set; }
	public float timeout { get; set; }
	public Collider2D last { get; set; }
	public Collider2D original { get; set; }
	public string targetTag { get; set; }
	public bool isLock { get; set; }
	private Vector2 min, max, pMin, pMax, point;
	private float maskOffset = .1f; // сдвиг маски, чтобы она не обрезала с края портала
	private int id;

	void Update()
	{
		RaycastHit2D hit = Physics2D.Raycast(transform.position, transform.right, 100f, 1 << targetMask);

		if(hit.collider != null)
		{
			Debug.DrawLine(transform.position, hit.point, Color.yellow);
			portal.normal = hit.normal;
			point = hit.point;
			id = hit.collider.GetInstanceID();
		}

		timeout += Time.deltaTime;
		if(timeout > 10)
		{
			gameObject.SetActive(false);
		}
	}

	void OnTriggerEnter2D(Collider2D coll)
	{
		if(!coll.isTrigger)
		{
			if(id == coll.GetInstanceID())
			{
				BuildPortal(coll);
			}

			gameObject.SetActive(false);
		}
	}

	Vector3 RotatePointAroundPivot(Vector3 point, Vector3 pivot, Vector3 angles)
	{
		Vector3 dir = point - pivot;
		dir = Quaternion.Euler(angles) * dir;
		return dir + pivot;
	}

	Vector3[] BoxCollider2DWorldCorners(BoxCollider2D box) // находим углы бокс коллайдера в мировых координатах
	{
		Vector3[] corners = new Vector3[4];
		Transform bcTransform = box.transform;
		Vector3 worldPosition = bcTransform.TransformPoint(0, 0, 0);
		Vector2 size = new Vector2(box.size.x * bcTransform.localScale.x * .5f, box.size.y * bcTransform.localScale.y * .5f);
		corners[0] = new Vector2(-size.x, -size.y);
		corners[1] = new Vector2(-size.x, size.y);
		corners[2] = new Vector2(size.x, -size.y);
		corners[3] = new Vector2(size.x, size.y);
		corners[0] = RotatePointAroundPivot(corners[0], Vector3.zero, bcTransform.eulerAngles);
		corners[1] = RotatePointAroundPivot(corners[1], Vector3.zero, bcTransform.eulerAngles);
		corners[2] = RotatePointAroundPivot(corners[2], Vector3.zero, bcTransform.eulerAngles);
		corners[3] = RotatePointAroundPivot(corners[3], Vector3.zero, bcTransform.eulerAngles);
		corners[0] = worldPosition + corners[0];
		corners[1] = worldPosition + corners[1];
		corners[2] = worldPosition + corners[2];
		corners[3] = worldPosition + corners[3];
		return corners;
	}

	bool FindPortalTrigger(Vector2 start, Vector2 end) // поиск портала, чтобы определить сторону коллайдера, на который он находится
	{
		if(Physics2D.LinecastAll(start, end, 1 << targetMask).Length > 0) return true;
		return false;
	}

	public void BuildPortal(Collider2D coll) // создание портала
	{
		BoxCollider2D box = coll as BoxCollider2D; // работаем только с BoxCollider2D

		if(box == null) return;

		if(coll.transform.parent.GetComponent<Portal2D>())
		{
			isLock = false;
		}
		else
		{
			original = coll;
			isLock = true;
		}

		portal.colliderA.enabled = false;
		portal.colliderB.enabled = false;
		portal.colliderA.gameObject.layer = coll.gameObject.layer;
		portal.colliderB.gameObject.layer = coll.gameObject.layer;
		Vector3[] worldCorners = BoxCollider2DWorldCorners(box);
		coll.enabled = false;
		last = coll;
		portal.transform.position = point;
		portal.transform.right = portal.normal;
		portal.gameObject.SetActive(true);
		float delta = 0;

		if(FindPortalTrigger(worldCorners[0], worldCorners[1]))
		{
			// запоминаем первый и второй угол коллайдера, между которыми нашелся портал
			min = worldCorners[0];
			max = worldCorners[1];

			// вычисляем глубину коллайдера
			delta = Vector2.Distance(worldCorners[0], worldCorners[2]);
		}
		else if(FindPortalTrigger(worldCorners[2], worldCorners[3]))
		{
			min = worldCorners[2];
			max = worldCorners[3];
			delta = Vector2.Distance(worldCorners[0], worldCorners[2]);
		}
		else if(FindPortalTrigger(worldCorners[0], worldCorners[2]))
		{
			min = worldCorners[0];
			max = worldCorners[2];
			delta = Vector2.Distance(worldCorners[0], worldCorners[1]);
		}
		else if(FindPortalTrigger(worldCorners[1], worldCorners[3]))
		{
			min = worldCorners[1];
			max = worldCorners[3];
			delta = Vector2.Distance(worldCorners[0], worldCorners[1]);
		}

		// находим точки от центра портала
		pMin = point + (min - point).normalized * portal.radius;
		pMax = point + (max - point).normalized * portal.radius;

		coll.enabled = true;

		if(Vector2.Distance(max, min) < portal.radius * 2f + .25f) // проверка, возможно ли на этой плоскости разместить портал
		{
			portal.gameObject.SetActive(false);
			return;
		}

		// проверка, выходят ли края портала за приделы коллайдера
		if(!coll.OverlapPoint(pMin + portal.normal * -.1f))
		{
			// сдвигаем центр портала от края
			point = min + (point - min).normalized * portal.radius;
			portal.transform.position = point;
		}
		else if(!coll.OverlapPoint(pMax + portal.normal * -.1f))
		{
			point = max + (point - max).normalized * portal.radius;
			portal.transform.position = point;
		}

		// заново вычисляем точки портала
		pMin = point + (min - point).normalized * portal.radius;
		pMax = point + (max - point).normalized * portal.radius;

		// на основе полученных точек портала и углов стены
		// вычисляем размеры и позиции триггера, маски и коллайдеров (заменяющих стену)
		// таким образом делаем "дыру" в стене
		portal.mask.localScale = new Vector3(delta - maskOffset * 2f, Vector2.Distance(max, min) - maskOffset, 1);
		portal.mask.position = (min + max)/2f + portal.normal * (delta/-2f - maskOffset);
		portal.locker.transform.position = (pMax + pMin)/2f + portal.normal * delta/-2f;
		portal.locker.transform.localScale = new Vector3(delta, Vector2.Distance(pMax, pMin), 1);
		portal.colliderA.size = new Vector2(delta, Vector2.Distance(pMin, min));
		portal.colliderB.size = new Vector2(delta, Vector2.Distance(pMax, max));
		portal.colliderB.transform.position = (pMax + max)/2f + portal.normal * delta/-2f;
		portal.colliderA.transform.position = (pMin + min)/2f + portal.normal * delta/-2f;
		portal.colliderA.sharedMaterial = coll.sharedMaterial;
		portal.colliderB.sharedMaterial = coll.sharedMaterial;
		portal.colliderA.gameObject.SetActive(true);
		portal.colliderB.gameObject.SetActive(true);
		portal.colliderA.enabled = (portal.colliderA.size.y < .1f) ? false : true;
		portal.colliderB.enabled = (portal.colliderB.size.y < .1f) ? false : true;

		// если оба портала активны, разрешаем проход между ними
		if(portal.portalTarget.gameObject.activeSelf)
		{
			portal.portalTarget.locker.isTrigger = true;
			portal.locker.isTrigger = true;
		}

		coll.enabled = false;
	}
}

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

Следующий шаг, префаб порталов:


Как видим на скриншоте, иерархия объекта состоит из - маски, двух спрайтов портала, двух коллайдеров и одного триггера.

Разберем по порядку, дочерние объекты:

Маска портала Mask:


Здесь у нас только Sprite Masks, у которого в качестве спрайта, нужно указать спрайт размером 100х100 пикселей. Должен быть именно такой размер изображения (обычный квадрат) чтобы правильно рассчитывать размеры маски.

Следующие объекты это спрайты портала portal_template_1 и portal_template_2, нечего особенного в них нет, но нужно правильно настроить Sprite Renderer обоих картинок, в одном случаи параметр Mask Interaction ставим на Visable Iside Mask, у второго изображения на Visable Outside Mask. Таким образом будет создаваться иллюзия вхождения в портал. (при этом на всех (!) спрайтах игрока, нужно ставить Visable Outside Mask)

Итак, следующим идут коллайдеры Collider-1 и Collider-1, они будут воссоздавать поверхность вокруг портала.

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

Рассмотрим родительский объект Portal2D_Primary:


using System.Collections;
using UnityEngine;

public class Portal2D : MonoBehaviour {

	public float radius = 1; // визуально настраиваемый радиус портала (где ориентируемся по его графике)
	public Transform mask; // объект Sprite Masks
	public BoxCollider2D locker; // триггер, который открывает проход, если установлен второй портал, либо наоборот

	// пара обычных бокс коллайдеров, которые заменяют собой стену, где портал, но оставляют проход
	public BoxCollider2D colliderA;
	public BoxCollider2D colliderB;

	public Portal2D portalTarget { get; set; }
	public Rigidbody2D player { get; set; }
	public Vector2 normal { get; set; }
	public Portal2DBullet bullet { get; set; }

	void LateUpdate()
	{
		// если игрок проваливается в триггер портала, перекидываем его на другой
		if(locker.OverlapPoint(player.position))
		{
			var speed = player.velocity.magnitude;
			player.velocity = Vector2.zero;
			player.transform.position = portalTarget.transform.position + (Vector3)portalTarget.normal * .15f;
			player.velocity = portalTarget.normal * speed;
			Character2DPortal.isPortal = true;
		}
	}

	void OnDrawGizmosSelected()
	{
		Gizmos.color = Color.cyan;
		Gizmos.DrawWireSphere(transform.position, radius);
	}
}

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

Вот так выглядит портал, нужно создать еще один, но просто с другим цветом.

Далее, скрипт портальной пушки:

using System.Collections;
using UnityEngine;

public class Portal2DWeapon : MonoBehaviour {

	[Header("Настройки префабов:")]
	[SerializeField] private Portal2D primaryPortal; // первый префаб портала
	[SerializeField] private Portal2D secondaryPortal; // второй префаб портала
	[SerializeField] private Portal2DBullet bulletPrefab; // префаб пули/снаряда
	[Header("Общие параметры:")]
	[SerializeField] private Rigidbody2D player;
	[SerializeField] private float bulletSpeed = 5; // скорость префаба пули
	[SerializeField] private int targetLayer = 4; // маска объекта, на который можно установить портал
	[SerializeField] private Transform shootPoint; // точка оружия, откуда должны вылетать пули
	[SerializeField] private Transform zRotate; // объект вращения, например, само оружие
	[SerializeField] private float minAngle = -40; // ограничение по углам
	[SerializeField] private float maxAngle = 40;
	[SerializeField] private Transform flipParent; // родитель для этого оружия (персонаж)
	private Portal2DBullet primaryBullet, secondaryBullet;
	private float invert;
	private Vector3 mouse;

	void Start()
	{
		primaryBullet = InstantiatePortal(primaryPortal);
		secondaryBullet = InstantiatePortal(secondaryPortal);
		primaryBullet.portal.portalTarget = secondaryBullet.portal;
		secondaryBullet.portal.portalTarget = primaryBullet.portal;
		primaryBullet.portal.player = player;
		secondaryBullet.portal.player = player;
	}

	void Flip()
	{
		Vector3 theScale = flipParent.localScale;
		theScale.x *= -1;
		invert *= -1;
		flipParent.localScale = theScale;
	}

	void LookAtMouse() // следим за мышкой
	{
		Vector3 mousePosMain = Input.mousePosition;
		mousePosMain.z = Camera.main.transform.position.z; 
		mouse = Camera.main.ScreenToWorldPoint(mousePosMain);
		mouse.z = 0;
		Vector3 lookPos = zRotate.position;
		lookPos.z = 0;
		lookPos = mouse - lookPos;
		float angle  = Mathf.Atan2(lookPos.y, lookPos.x * invert) * Mathf.Rad2Deg;
		angle = Mathf.Clamp(angle, minAngle, maxAngle);
		zRotate.rotation = Quaternion.AngleAxis(angle * invert, Vector3.forward);
	}

	void LateUpdate()
	{
		invert = Mathf.Sign(flipParent.localScale.x);

		if(zRotate != null) LookAtMouse();

		if(mouse.x < flipParent.position.x && invert == 1) Flip();
		else if(mouse.x > flipParent.position.x && invert == -1) Flip();

		if(Input.GetMouseButtonDown(0))
		{
			Shoot(primaryBullet);
		}
		else if(Input.GetMouseButtonDown(1))
		{
			Shoot(secondaryBullet);
		}
	}

	void Shoot(Portal2DBullet bullet)
	{
		bullet.timeout = 0;
		if(bullet.last != null) bullet.last.enabled = true;
		bullet.portal.gameObject.SetActive(false);
		bullet.portal.colliderA.gameObject.SetActive(false);
		bullet.portal.colliderB.gameObject.SetActive(false);
		bullet.portal.portalTarget.locker.isTrigger = false;
		bullet.portal.locker.isTrigger = false;

		// перед тем как убрать портал
		// проверка, был ли он установлен на текущую стену раньше, чем второй
		// так же проверяем, был второй портал установлен на эту же стену
		// убираем первый и даем команду второму пересборки, чтобы восстановить целостность стены
		if(bullet.original != null && bullet.portal.portalTarget.gameObject.activeSelf)
		{
			if(!bullet.portal.portalTarget.bullet.isLock) 
			{
				bullet.portal.portalTarget.bullet.BuildPortal(bullet.original);
			}

			bullet.original = null;
		}

		// если пуля уже летит, убираем ее (отмена)
		if(bullet.gameObject.activeSelf) 
		{
			bullet.gameObject.SetActive(false);
			return;
		}

		bullet.transform.position = shootPoint.position;
		bullet.gameObject.SetActive(true);
		Vector3 direction = shootPoint.right * invert;
		bullet.body.velocity = direction * bulletSpeed;
		bullet.transform.right = direction;
	}

	Portal2DBullet InstantiatePortal(Portal2D portal)
	{
		Portal2DBullet clone = Instantiate(bulletPrefab) as Portal2DBullet;
		clone.gameObject.layer = 2;
		clone.portal = Instantiate(portal) as Portal2D;
		clone.portal.bullet = clone;
		clone.portal.gameObject.layer = targetLayer;
		clone.portal.locker.gameObject.layer = 2;
		clone.portal.colliderA.tag = "Untagged";
		clone.portal.colliderB.tag = "Untagged";
		clone.portal.gameObject.SetActive(false);
		clone.targetMask = targetLayer;
		clone.gameObject.SetActive(false);
		return clone;
	}
}

Это своего рода менеджер порталов, скрипт можно повесить на персонажа.

Последний штрих, это скрипт самого персонажа:

using System.Collections;
using UnityEngine;

public class Character2DPortal : MonoBehaviour {

	public static bool isPortal { get; set; }

	[SerializeField] private float speed = 1.5f; // скорость движения
	[SerializeField] private float acceleration = 100; // ускорение
	[SerializeField] private float jumpForce = 5; // сила прыжка
	[SerializeField] private float jumpDistance = 0.75f; // расстояние от центра объекта, до поверхности (определяется вручную в зависимости от размеров спрайта)
	[SerializeField] private KeyCode jumpButton = KeyCode.Space; // клавиша для прыжка
	[SerializeField] private float portalSpeed = 5; // скорость, когда происходит перемещение между порталами
	[SerializeField] private float portalSpeedLimit = 10; // лимит ускорения тела

	private Vector3 direction;
	private int layerMask;
	private Rigidbody2D body;

	void Awake() 
	{
		body = GetComponent<Rigidbody2D>();
		body.freezeRotation = true;
		layerMask = 1 << gameObject.layer | 1 << 2;
		layerMask = ~layerMask;
	}

	bool GetJump() // проверяем, есть ли коллайдер под ногами
	{
		RaycastHit2D hit = Physics2D.Raycast(transform.position, Vector3.down, jumpDistance, layerMask);
		if(hit.collider) return true;
		return false;
	}

	void FixedUpdate()
	{
		if(isPortal)
		{
			body.AddForce(direction * body.mass * portalSpeed);

			if(Mathf.Abs(body.velocity.x) > portalSpeedLimit)
			{
				body.velocity = new Vector2(Mathf.Sign(body.velocity.x) * portalSpeedLimit, body.velocity.y);
			}
		}
		else
		{
			body.AddForce(direction * body.mass * speed * acceleration);

			if(Mathf.Abs(body.velocity.x) > speed)
			{
				body.velocity = new Vector2(Mathf.Sign(body.velocity.x) * speed, body.velocity.y);
			}
		}

		if(Mathf.Abs(body.velocity.y) > portalSpeedLimit)
		{
			body.velocity = new Vector2(body.velocity.x, Mathf.Sign(body.velocity.y) * portalSpeedLimit);
		}
	}

	void OnCollisionEnter2D(Collision2D coll)
	{
		// если происходит касание земли, стены и т.п. - то переходим в режим обычного движения
		isPortal = false;
	}

	void OnDrawGizmosSelected()
	{
		Gizmos.color = Color.red;
		Gizmos.DrawRay(transform.position, Vector3.down * jumpDistance);
	}

	void Update() 
	{
		if(Input.GetKeyDown(jumpButton) && GetJump())
		{
			body.velocity = new Vector2(0, jumpForce);
		}

		float h = Input.GetAxis("Horizontal");
		direction = new Vector2(h, 0); 
	}
}

Не обязательно, чтобы скрипт движения персонажа был именно таким, у вас может быть свой класс. Важно тут отметить пару обязательных моментов. Переменная isPortal которая переключается порталами и внутри скрипта движения функцией OnCollisionEnter2D это нужно для того, чтобы сообщать скрипту, когда игрок находится в процессе перемещения или в стандартном режиме движения FixedUpdate.

Скачать демо проект:
https://www.patreon.com/posts/2d-portaly-v-23168498
Тестировалось на: Unity 2017.3.1

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

Офлайн
TeDj 11 апреля 2018
Классно! Считай, готовая игра!
Офлайн
Atanashi 14 апреля 2018
Здравствуйте. Можете написать скрипт в котором будет возможность реализовать добавление в canvas 3d модели, так что бы озвещение реагировало на модель добавленое в canvas?
Информация
Посетители, находящиеся в группе Гости, не могут оставлять комментарии к данной публикации.
  • Яндекс.Метрика