company_banner

Про создание платформера на Unity. Часть третья, долгожданная

  • Tutorial
Привет, Хабр!

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



Осторожно: объемы гифок под катом становятся просто нечеловеческими!


Предыдущие части были опубликованы почти две недели назад, поэтому советую немного освежить знания :)

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

Начнем с конца, добавив максимально простую физическую штуку — коробку. Нам нужны спрайт коробки, rigidbody2D и boxcollider2D. Приступим!



Как видите, все еще ничего сложного. Приготовим из получившейся коробки префаб и перенесем еще раз на форму. Следите за руками: у одной из коробок поставим галку isKinematic у rigidbody2D и запустим начинающееся веселье:



Коробка с установленным rigidbody2D.isKinematic = true не двигается. Ее нельзя сдвинуть с места применением сил или коллизий, так что эта коробка будет выполнять роль неподвижного препятствия. Идем дальше.

В лучших традициях super meat boy, добавим в нашу пока_еще_не игру врага-пилу. Пила будет крутиться и… всё.



Невероятной сложности скрипт для этого действия выглядит так:

using UnityEngine;
using System.Collections;

public class sawScriptNew : MonoBehaviour {

	// Use this for initialization
	void Start () {
	
	}
	
	// Update is called once per frame
	void Update () {
		transform.Rotate (new Vector3(0f,0f,-3f));
	}
}


Поясню: transform.rotate вращает спрайт на заданные в vector3 углы вокруг, соответственно, осей x, y и z. В случае с 2D игрой первые две оси мало применимы для нашей задачи, и вот почему:

transform.Rotate (new Vector3(-3f,0f,0f));


transform.Rotate (new Vector3(0f,-3f,0f));


К слову, о 2D режиме. Внимательный читатель заметил, что пила некрасиво торчит перед платформой и загораживает весь вид. Для того, чтобы это исправить, достаточно знать простую вещь — в 2D режиме объекты на экране упорядочиваются (вот тут не очень уверен насчет терминологии, поправьте) по z-уровню. Вот вам наглядная гифка о том, как это работает.



Теперь сделаем так, чтобы герой умирал при взаимодействии с пилой. Исправим characterController.cs следующим образом:

void OnTriggerEnter2D(Collider2D col){
		if ((col.gameObject.name == "dieCollider")||(col.gameObject.name == "saw"))
						Application.LoadLevel (Application.loadedLevel);


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



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



Обучайте игрока максимально эффективно: не стоит растягивать введение на несколько уровней. Вспомните классический мир 1-1 из Super Mario Brothers. Его (как правило) достаточно, чтобы познакомиться с основными механиками игры и самыми часто встречаемыми врагами.


Идем дальше. Когда есть один уровень, нужен и второй. Сохраним сцену как scene1.unity и создадим новую. Построим, аналогично примеру выше, еще один уровень и сохраним (уже догадались? молодцы) как scene2. Теперь идем в File -> Build Settings и ставим галки на обеих сценах. Примерно так:



Рядом с названием сцены отображается номер — по нему мы можем обращаться к сцене для ее загрузки. Или по имени, кому как удобнее. Создадим пустой объект и запрограммируем его так, чтобы после сбора всех звезд и столкновения с ним (объектом) игрок переходил на вторую сцену. Для этого в методе OnTriggerEnter будем проверять, есть ли еще звезды на сцене.

if (col.gameObject.name == "endLevel") {
			if (!(GameObject.Find("star"))) Application.LoadLevel ("scene2");
				}


Вот так он будет выглядеть на сцене. При желании, можно добавить спрайт с помощью компонента sprite renderer.


Как выглядит переключение уровней (gif, 3.7 MB)



На этом основная часть статьи заканчивается и начинается бонус-трек по вашим заявкам.



Соответственно, немного про веселье и про физику! Добавим пару спрайтов — платформу и файрбол из Super Mario, и попробуем сделать из этого машину для разрушения кучи коробок.



В этом нам поможет первый представитель семейства Джойнтов, с которым мы познакомимся — Distance Joint, который, по сути, позволяет превратить что угодно в игре в математический маятник. Добавляем его на объект, выбираем, что будет с другой стороны маятника (здесь пригодится объект с rigidbody2d.isKinematic = true), какое расстояние между ними и в итоге получится то, что получилось выше. Коробки, кстати, все те же, что использовались в нашем платформере. Смотрите!



Объект, к которому прицеплен наш разрушающий шар, имеет компонент rigidbody2D со включенным атрибутом isKinematic. Это нужно для того, чтобы он не падал сразу после запуска игры (и вообще никогда).

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

Предыдущие серии:
Часть первая, в которой обаятельный носач учится ходить
Часть вторая, в которой проигрывать — весело.

p.s. Больше веселья с физикой (GIF, 3.1 MB)
Microsoft
410,00
Microsoft — мировой лидер в области ПО и ИТ-услуг
Поделиться публикацией

Комментарии 25

    +2
    Достаточно хорошая статья, но позволю себе одно небольшое замечание — z-сортировку предпочтительней делать с помощью Sorting Layers и Order in Layer, а не z координатой, поскольку это менее «интуитивно». В вашем случае можно сделать три слоя — для ловушек (самый «низкий»), для земли (средний) и для объектов переднего плана.
    В этом случае не придется менять Z координату у объектов, они по умолчанию будут находиться в нужном месте с нужной «глубиной».
      +3
      Спасибо за замечание! Обязательно изучу вопрос и расскажу об этом в следующих частях :)
      +1
      А как же кешировать transform? Мне за такое в комментариях хотели руки пересадить куда надо :)
        +3
        Сборник вредных советов.
            void Update () {
                transform.Rotate (new Vector3(0f,0f,-3f));
            }
        

        Необходимо умножать вектор на Time.deltaTime, иначе вращение будет неравномерным и зависящим от FPS.
        if ((col.gameObject.name == "dieCollider")||(col.gameObject.name == "saw"))
        

        Определение типа объекта по его строковому имени? На костер. Хотя бы теги уже для этого использовали — они для этого предназначены.
         if (!(GameObject.Find("star")))
        

        Вообще шикарно. Вы же представляете, насколько феерически медленно работает этот метод? ИМХО его если и стоит юзать, то разве что при загрузке сцены.

        И да, кешировать transform не помешало бы.
          0
          Спасибо за сборник хороших советов :)

          Про Time.deltaTime совершенно вылетело из головы — питерская осень жестокая штука.

          Насчет поиска всех «съеденных» звезд — предложите быструю альтернативу?
            +1
            Насчет поиска всех «съеденных» звезд — предложите быструю альтернативу?
            Не искать звезды вовсе :) Заводим некий «игровой контроллер» (для простоты можно сделать его синглотоном, а для гурманов — можно заюзать какой-нибудь IoC-контейнер). Создаем также компонент для звезд, который при создании и при смерти будет кидать игровому контроллеру некое событие. Таким образом, игровой контроллер всегда будет точно знать, сколько сейчас звезд на карте, а игрок может его запрашивать без лишних расходов.
              +2
              Отлично!) Надо будет в следующий раз на этом примере показать межскриптовое взаимодействие.

              Еще раз спасибо!)
            0
            Кстати, вместо использования тегов можно повесить скрипт «убивания» на саму пилу или «компонент убивания» и настроить слои столкновений таким образом, чтобы столкновения проверялись только с персонажем.

            При обнаружении столкновения на стороне пил или места падения ни тэг, ни имя проверять не придется, просто запускаем логику «убийства» персонажа
              0
              Что прикажете делать с триггером конца уровня и со звездами? В любом случае нужно определить, с чем именно столкнулись.
                0
                Ну и эти вещи можно по тому же принципу делать, разбить логику по разным объектам, заставлять их влиять как-то на персонажа. Скажем, если игроку надо собрать звезду, то при столкновении с игроком каждая звезда уменьшает в менеджере звезд какую-нибудь переменную. Идентификатор конца уровня, например, может проверить текущее значение этой переменной и не загрузить следующий уровень. В данном случае проверять по имени или тегу ничего не придется, компоненты тащить с гейм объекта, с которым столкнулись, тоже.
                Это конечно все очень абстрактно, но в целом позволяет отойти от антипаттерна «God Object», который содержит в себе огромное количество логики, которую теоретически можно (и часто желательно) растащить по разным компонентам.

                Но это все очень субъективно, я совершенно не претендую на догматичные «истины»
                  0
                  За что я люблю подобные топики — в комментах много умных мыслей, которые могут натолкнуть на развитие. Я вот почитал разные комментарии, погуглил другие топики и написал приблизительно такой рабочий код для приложения из этих трех статтей

                  Общие классы, не вешаются:

                  Scene
                  using UnityEngine;
                  using System.Collections;
                  
                  public class Scene {
                  
                  	private static Scene sceneInstance = null;
                  
                  	public static Scene GetInstance () {
                  		if (sceneInstance == null) {
                  			sceneInstance = new Scene();
                  		}
                  
                  		return sceneInstance;
                  	}
                  
                  	public Character player = null;
                  
                  	public BonusManager bonuses = new BonusManager();
                  
                  	public void EndLevel () {
                  		Application.LoadLevel("scene2");
                  	}
                  
                  	public void ReloadLevel () {
                  		Application.LoadLevel(Application.loadedLevel);
                  	}
                  
                  }
                  
                  BonusManager
                  using UnityEngine;
                  using System.Collections;
                  
                  public class BonusManager {
                  
                  	private int createdBonuses = 0;
                  	private int takkenBonuses = 0;
                  	
                  	public void CreateBonus (Bonus bonus) {
                  		createdBonuses += bonus.value;
                  	}
                  	
                  	public void TakeBonus (Bonus bonus) {
                  		if (!AllCollected()) {	
                  			takkenBonuses  += bonus.value;
                  			createdBonuses -= bonus.value;
                  		}
                  	}
                  
                  	public int GetScore () {
                  		return takkenBonuses;
                  	}
                  	
                  	public bool AllCollected () {
                  		return createdBonuses <= 0;
                  	}
                  }
                  
                  ExtendedMonoBehaviour
                  using UnityEngine;
                  using System.Collections;
                  
                  public class ExtendedMonoBehaviour : MonoBehaviour {
                  	
                  	private Transform cachedTransform = null;
                  	
                  	public new Transform transform {
                  		get {
                  			if (cachedTransform == null) {
                  				cachedTransform = base.transform;
                  			}
                  			return cachedTransform;
                  		}
                  		protected set {
                  			cachedTransform = value;
                  		}
                  	}
                  
                  	protected Scene scene {
                  		get {
                  			return Scene.GetInstance();
                  		}
                  	}
                  
                  
                  
                  	protected bool isPlayerCol (Collider2D col) {
                  		return col.gameObject == scene.player.gameObject;
                  	}
                  }
                  

                  Классы для объектов игры (на пилу навешивается Saw и Terminator, на DieCollider только Terminator)

                  Bonus
                  using UnityEngine;
                  using System.Collections;
                  
                  public class Bonus : ExtendedMonoBehaviour {
                  
                  	public int value = 1;
                  
                  	public void OnEnable() {
                  		scene.bonuses.CreateBonus(this);
                  	}
                  
                  	public void OnTriggerEnter2D (Collider2D col) {
                  		if (isPlayerCol(col)) {
                  			scene.bonuses.TakeBonus(this);
                  			Destroy(this.gameObject);
                  		}
                  	}
                  }
                  
                  Camera
                  using UnityEngine;
                  using System.Collections;
                  
                  public class Camera : ExtendedMonoBehaviour {
                  	public Transform target;
                  	public float dampTime = 0.15f;
                  
                  	private Vector3 velocity = Vector3.zero;
                  	
                  	// Update is called once per frame
                  	void Update () {
                  		if (!target) return;
                  
                  		Vector3 point = camera.WorldToViewportPoint(target.position);
                  		Vector3 delta = target.position - camera.ViewportToWorldPoint(new Vector3(0.5f, 0.5f, point.z));
                  		Vector3 destination = transform.position + delta;
                  		 
                  		transform.position = Vector3.SmoothDamp(transform.position, destination, ref velocity, dampTime);
                  	}
                  }
                  
                  Character
                  using UnityEngine;
                  using System.Collections;
                  
                  public class Character : ExtendedMonoBehaviour {
                  
                  	public float maxSpeed = 20f;
                  	public float jumpForce = 1000f;
                  
                  	private bool facingRight = true;
                  
                  	public void Start () {
                  		scene.player = this;
                  	}
                  	
                  	public void Update(){
                  		float move = Input.GetAxis("Horizontal");
                  
                  		if (Input.GetKeyDown(KeyCode.W) || Input.GetKeyDown(KeyCode.UpArrow)) {
                  			rigidbody2D.AddForce( new Vector2(0f, jumpForce) );
                  		}
                  
                  		rigidbody2D.velocity = new Vector2(move * maxSpeed, rigidbody2D.velocity.y);
                  
                  		if (move != 0) {
                  			CheckDirection(move > 0);
                  		}
                  		
                  		if (Input.GetKey(KeyCode.Escape)) {
                  			Application.Quit();
                  		}
                  		
                  		if (Input.GetKey(KeyCode.R)) {
                  			Application.LoadLevel(Application.loadedLevel);
                  		}
                  	}
                  	
                  	public void CheckDirection(bool right){
                  		if (facingRight != right) {
                  			facingRight = right;
                  			Vector3 scale = transform.localScale;
                  			scale.x *= -1;
                  			transform.localScale = scale;
                  		}
                  	}  
                  
                  	public void Die() {
                  		scene.ReloadLevel();
                  	}
                  	
                  	public void OnGUI(){
                  		GUI.Box( new Rect(5, 5, 80, 22), "Stars: " + scene.bonuses.GetScore() );
                  	}
                  }
                  
                  Exit
                  using UnityEngine;
                  using System.Collections;
                  
                  public class Exit : ExtendedMonoBehaviour {
                  	
                  	public void OnTriggerEnter2D (Collider2D col) {
                  		if (isPlayerCol(col) && scene.bonuses.AllCollected()) {
                  			scene.EndLevel();
                  		}
                  	}
                  	
                  }
                  
                  Saw
                  using UnityEngine;
                  using System.Collections;
                  
                  public class Saw : ExtendedMonoBehaviour {
                  
                  	public float speed = 180f;
                  
                  	public void Update () {
                  		transform.Rotate( new Vector3(0f, 0f, speed * Time.deltaTime) );
                  	}
                  
                  }
                  
                  Terminator
                  using UnityEngine;
                  using System.Collections;
                  
                  public class Terminator : ExtendedMonoBehaviour {
                  
                  	public void OnTriggerEnter2D (Collider2D col) {
                  		if (isPlayerCol(col)) {
                  			scene.player.Die();
                  		}
                  	}
                  	
                  }
                  


                  Оно не совсем корректно работает, но более интересно пообсуждать сам подход.
                  1. Мне не очень понравилась идея с Синглтоном, но я не совсем понял как создать инстанс для одной сцены и иметь к нему доступ?
                  2. Как ту реализуется DI?
                  3. Как, допустим, вынести ГУИ в отдельный обработчик с методом OnGui?
                  4. Можно конечно было бы повесить его на камеру или героя, но это как-то странно.
                  5. Как создать «Синглтон только для одной сцены»? Неплохо было бы, чтобы он автоматически уничтожался во время выгрузки сцены, дополнительный контроль утечек не помешает.
                  6. Или его вручную удалять после выгрузки сцены?
                  7. Как повесится на события выгрузки и загрузки сцены (абстрактные, а не привязанные к объекту)?

                  Я Юнити сегодня впервые вижу. Думаю, большинство вопросов уйдет, но, как я уже сказал, интересно не так прямые ответы на эти пункты, как общие рассуждения по поводу подхода. Даже если не уверены в правильности. Очень интересно такое читать и из сукупности идей делать своё мнение.
                    0
                            get {
                                if (cachedTransform == null) {
                                    cachedTransform = base.transform;
                                }
                                return cachedTransform;
                            }
                    

                    Нюанс — такого лучше избегать, так как оператор == для наследников UnityEngine.Object (коим является и Transform) перегружен и «под капотом» вызывает нативный код движка. В итоге такой код для «кеширования» не даст практически никаких преимуществ по скорости.
                      0
                      а null == cachedTransform?
                        0
                        То же самое будет.
                        Полурешение — использовать ReferenceEquals, он проверяет именно ссылку. Как-то так:
                                get {
                                    if (ReferenceEquals(cachedTransform, null)) {
                                        cachedTransform = base.transform;
                                    }
                                    return cachedTransform;
                                }
                        

                        Но это будет работать правильно, только если компонента не была уничтожена после первого вызова. Иначе останется «пустой» объект в managed-коде, который на ReferenceEquals(obj, null) возвращает false, но нативный объект, оберткой над которым он является, уже будет уничтожен, и попытка это заюзать закончится все тем же NullReferenceException. Так что, к сожалению, 100% надежного способа тут нет, разве что всегда помнить, какой объект должен существовать в каждый момент времени.
                        P.S. В Unity 5 все это дело очень сильно заоптимизировали, так что там можно уже не переживать по этому поводу, и подобные костыли просто не нужны.
                          0
                          я так понимаю, можно сохранять
                          bool transformCached = true
                          if(!trasformCached) set
                          

                          а по остальному что скажете?
                            0
                            Да, я именно так и делаю. По остальному чуть позже отвечу
                              +1
                              Я тормоз :( Уже, наверное, неактуально, но…

                              1. Ну… Все тем же синглтоном, в простейшем случае. Вешаете скрипт на объект в сцене. Скрипт тогда может выглядеть примерно так:
                              using UnityEngine;
                              using System.Collections;
                              
                              public class FooManager : MonoBehaviour {
                              
                                  private static FooManager instance = null;
                              
                                  public static FooManager Instance () {
                                      get {
                                          return instance;
                                      }
                                  }
                                  private void Awake() {
                                      instance = this;
                                  }
                              
                              }
                              

                              2. Для DI сначала нужен DI-контейнер иметь, коих достаточно много для Юнити существует. Ну и можете почитать, например:
                              blogs.unity3d.com/2014/05/07/dependency-injection-and-abstractions/
                              habrahabr.ru/post/188438/
                              3-4. Мм, а в чем проблема? Делаете отдельный скрипт для GUI, даете ему любым удобным способом ссылки на нужные объекты. Скрипт берет публичные свойства нужных вам объектов и рисует.
                              5-6. Просто повесьте скрипт на объект в этой самой сцене. Таким образом, он уже будет создан при загрузке сцены, и при смене сцены будет автоматически выгружен вместе со всеми остальными объектами.
                              Ну и еще помимо вещей, которые имеют смысл только в пределах сцены, есть глобальные штуки, которые должны существовать практически с самого старта приложения (например, хранение настроек). Для таких я завожу отдельную сцену, которая только прогружает их заранее, и продолжает грузить уже, скажем, меню игры. И да, все эти объекты помечены DontDestroyOnLoad, чтобы переживать все смены сцен.
                              7. А нет таких событий. Можно нагородить костылей, но проще банально избегать ситуаций, когда такие события нужны… Так-то за загрузку сцены можно считать Awake, а за выгрузку — OnDestroy, лишь бы никто этот объект руками не трогал.
                                0
                                Почему? Актуально, спасибо)
              –5
              Платформер? Не хотите в конкурсе на gamedev.ru поучаствовать?

                0
                Спасибо! Буду ждать продолжения! Особенно об анимации, я голосовал за неё.
                  0
                  Спасибо за статьи. Меня смущает странньій баг. Если герой упирается в колайдер боком, то прилипает к нему, пока не отпустить направление движения. Можно как-то обойти такую особенность?

                    +1
                    О, это предмет бурных обсуждений на официальных ресурсах, там можно Санта Барбару снимать. Гуглится по запросу
                    unity3d 2d controller sticks to walls

                    Вкратце — большинство решает проблему закидывая на стены физический материал с нулевым трением, но в данном случае это не прокатит, потому что платформа и есть стена. Некоторые проверяют, находится ли сейчас персонаж в воздухе, и если да, то накидывают физический материал с нулевым трением на него. Кто-то не дает двигаться в сторону возможного «застревания» (кто-то рейкастом, кто-то свиптестом). Советую погуглить по приведенному выше запросу (для достижения наибольшего объема найденных тем запрос можно немного поменять).
                      +1
                      Можно еще на платформу слева и справа прилепить коллайдеры, на которые кинуть материал с нулевым трением. В случае с префабом особой мороки вызвать не должно.
                        0
                        Почему бы в нашем примере не повесить нуль-материал на Бокс-Колайдер героя? Скольжение при беге будет корректным из-за Циркл-Колайдера, а об стены он будет скользить. Какие подводные камни этого решения?
                          0
                          Возможно резкое ускорение героя при скольжении по откосам. Чем ближе угол откоса к 90 градусам, тем выше скорость.

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

                  Самое читаемое