Объяснение
Что же такое hitbox и hurtbox? Разве это не одно и то же?
Ответ может зависеть от того, кому вы зададите вопрос, но в статье мы будем придерживаться мнения, что hitbox и hurtbox — это два различных понятия с разным применением, как это бывает в любой достойной игре-файтинге.
Hitbox — это невидимый прямоугольник (или сфера), определяющий, куда попадает атака.
Hurtbox — это тоже невидимый прямоугольник (или сфера), но определяющий место, в которое игрок или объект может ударить с помощью Hitbox.
На этом изображении из Street Fighter IV красный прямоугольник — это hitbox, а зелёный — hurtbox
Стоит заметить, что размер и позиция hitbox-ов и hurtbox-ов зависит от воспроизводимого кадра анимации:
Gif из Killer Instinct. Заметьте, что hitbox-ы появляются только в кадрах удара и движутся вместе с мечом
В примере из Killer Instinct мы также видим ещё один тип прямоугольников, а именно Pushbox (жёлтый прямоугольник; Hurtbox — это пустые зелёные прямоугольники). Pushbox — это область, обозначающая пространство, физически занимаемое персонажем, не позволяющее им накладываться друг на друга.
В большинстве игр жанра fighting и beat 'em up существует ещё два типа областей, которые мы для простоты не будем рассматривать:
Grab или throw box определяет область, за которую персонажа можно схватить или бросить, а block box определяет область, в которой атакуемый игрок, нажимающий кнопку «назад», начинает блокировать атаку вместо движения назад.
С точки зрения дизайна все эти области очень важны. Hitbox-ы и Hurtbox-ы атак определяют не только количество кадров, в которых атака наносит урон, но и слепые зоны этой атаки, а также уязвимые места игрока.
Хорошее объяснение этого на примере Street Fighter представлено в видео.
Итак, разобравшись с терминологией, давайте приступать к работе.
Чего мы хотим
Давайте рассмотрим каждый тип области, с которым мы хотим работать, и скажем, чего мы от них хотим:
Pushbox: нам нужно, чтобы два pushbox-а соприкасались, но не накладывались друг на друга (именно поэтому они называются «областями отталкивания» (pushbox) — они толкают другого персонажа). Pushbox-ы должны взаимодействовать только с другими pushbox-ами.
Hurtbox: он может регистрировать удар, но не должен выполнять коллизии в физическом смысле. Hurtbox-ы должны взаимодействовать только с Hitbox-ами.
Hitbox: мы должны иметь возможность проверки наложения его на Hurtbox в произвольных кадрах. Он должен взаимодействовать только с Hurtbox-ами.
Использование стандартных компонентов Unity
В первую очередь мы можем попробовать привязать разные типы областей к стандартным компонентам Unity. Очевидным выбором будет какой-нибудь Collider.
Pushbox можно непосредственно реализовать как Collider плюс Rigidbody. Он будет вести себя точно так, как нам нужно — реагировать на коллизии с объектами и не накладываться на другие Pushbox-ы.
Единственное, о чём нам нужно беспокоиться (кроме правильной настройки Rigidbody) — о реализации свойства "может иметь коллизии только с другими Pushbox-ами". Если вы знакомы с системой физики Unity, то уже знаете, что решение заключается в использовании слоёв и матрицы коллизий слоёв. Для понятности мы можем создать слой с названием Pushbox, назначить его нашему объекту и настроить матрицу коллизий таким образом, чтобы Pushbox выполнял коллизии только с Pushbox.
Для Hurtbox-ов мы можем взять Collider, использующий isTrigger. Так мы гарантируем, что он не будет выполнять коллизии в физическом смысле и будет только регистрировать другие коллайдеры, попадающие в его область. Для самой регистрации удара нам нужно добавить в тот же объект скрипт, реализующий событие OnTriggerEnter, возможно, с проверкой тэга входящего коллайдера, чтобы убедиться, что вызвавший событие коллайдер является нужным нам, а затем выполнить вычисления урона и здоровья, необходимые игре. Вероятно, вам знаком такой подход.
Также нам понадобится создать слои Hurtbox и Hitbox, и мы снова воспользуемся Layer Collision Matrix, чтобы Hurtbox выполнял коллизии только с Hitbox, и наоборот.
- Заметьте, что нам не нужен Rigidbody, но только потому, что я считаю, что каждый добавляемый нами триггер будет дочерним объектом объекта Pushbox, у которого уже есть Rigidbody. Это важно, потому что Collider-ы без Rigidbody внутри себя или какого-то из своих родительских объектов будут определяться движком Unity как Static и их перемещение не приведёт ни к каким действиям.
- Кроме того, нам, вероятно, потребуется различать Hitbox игрока от Hitbox одного из врагов. То же самое относится и к Hurtbox-ам. Благодаря этому Hitbox игрока сможет ударять только по Hurtbox врагов, а Hitbox-ы врагов смогут бить только по Hurtbox-ам игрока. Если вы хотите разрешить наносить урон союзникам, но здесь следует быть аккуратными, чтобы игрок не мог ударить собственный Hurtbox.
Hitbox-ы реализовать сложнее всего. Чтобы избежать физической коллизии, мы можем использовать Collider с isTrigger, на самом деле у нас нет коллайдеров, «входящих» в Hitbox. Решить эту задачу можно другим способом: Hitbox «входит в» (или проверяет наложение с) Hurtbox-ом. Как бы то ни было, нам нужен Collider, в противном случае Unity никогда не вызовет OnTriggerEnter в нашем Hurtbox.
Для нанесения урона Hurtbox-у нам нужно добавить скрипт в тот же объект, чтобы наш Hurtbox мог использовать GetComponent<T> и получал его, чтобы узнать, сколько урона нужно нанести. Мы можем сделать это также другим способом: вызываемым для обоих Collider-ов OnTriggerEnter. Также нам нужно найти способ делать наш Hitbox активным только тогда, когда мы хотим, а не в каждом кадре и не тогда, когда персонаж не атакует. Для этого мы просто может отключать скрипт, поскольку в соответствии с документацией события Trigger передаются отключенным MonoBehaviours, чтобы позволить включать Behaviours в ответ на коллизии.
Мы можем включать и отключать коллайдер или добавить в скрипт булево значение, сообщающее, должен ли он ударять, или нет.
Проблемы
- Иерархия: нам нужно иметь скрипт в каждом объекте с Collider-ом, чтобы иметь возможность реагировать на OnTriggerEnter. Если вы предпочитаете ради порядка содержать все скрипты в одном месте, то вам потребуется создать скрипт, просто чтобы делегировать вызов всем другим объектам.
- Чрезмерность: при таком подходе у наших Hitbox-ов есть огромный функционал, который нам не потребуется.
- События: наш функционал основывается на OnTriggerEnter. Использование событий Unity может и не быть проблемой. но существуют причины по крайней мере для того, чтобы задуматься об их необходимости. Чтобы знать больше, также стоит изучить это (в разделе «Avoiding expensive calls to the Unity API»).
- Визуальный шум: если для разных атак вы хотите использовать разные Hitbox-ы, то снова проявляются не только упомянутые выше проблемы, но и возникает визуальная зашумлённость в окне редактора.
- Низкая гибкость: при использовании в качестве Hitbox-ов Collider-ов означает, что при изменении формы коллайдера, например, с Box на Sphere, вам придётся вручную удалять все BoxCollider и добавлять SphereCollider (или писать скрипт редактора, делающий эту работу за вас)
Создаём всё сами
Как вы наверно поняли из сказанного выше, Pushbox-ы и Hurtbox-ы достаточно удобно реализуются стандартными компонентами Unity.
У Hurtbox-ов всё ещё есть вышеупомянутые проблемы, и мы решим некоторые из них, но основной сущностью, требующей собственного абстрагирования, является Hitbox.
Если вы создаёте файтинг со множеством атак и комбо, то вероятно хотите, чтобы все атаки были хорошо упорядочены в объекте и имелась возможность создания для каждой из них несколько сочетаний Hitbox-ов. Для этого вам понадобится скрипт, строго делегирующий вызовы OnTriggerEnter активной атаке или выполняющий нечто подобное.
Мы не хотим создавать отдельный объект Hitbox для каждой из атак, и здесь мы можем использовать одни и те же, меняя их размер!
Hitbox-ы
Нам нужно, чтобы новый компонент решал следующие задачи:
- Обладать поведением Hitbox-а: он должен иметь возможность проверять наложение на Hurtbox в произвольных кадрах. Он обязан взаимодействовать только с Hurtbox-ами.
- Иметь визуальное представление в окне Scene.
- Быть настраиваемым и гибким.
- В идеале не зависеть от событий Unity API.
- Быть достаточно независимым, чтобы можно было использовать скрипт для другого объекта.
- Не быть привязанным к конкретной атаке. Hitbox-ы должны быть применимыми к нескольким разным атакам.
Поведение
Во-первых, как нам проверить, что какая-то область накладывается на Collider? Ответ заключается в использовании UnityEngine.Physics.
В Physics есть множество методов, способных выполнить эту задачу. Мы можем указать нужную нам форму (Box, Sphere, Capsule), а также при желании получить Collider-ы, которые мы ударяем (если они есть) в виде массива или передать массив, чтобы заполнить его этими Collider-ами. Пока мы не будем об этом думать, но в первом случае выделяется новый массив, а во втором мы просто заполняем уже существующий.
Давайте начнём с того, что будем проверять, ударяет ли по чему-нибудь прямоугольная область. Для этого мы можем использовать OverlapBox.
Нам нужно задать размеры проверяемого прямоугольника. Для этого нам требуется центр прямоугольника, его половинная величина, поворот и слои, которые он должен ударять. Половинная величина — это половины размеров в каждом из направлений, например, если у нас есть прямоугольник с размером (2, 6, 8) то его половинная величина будет равна (1, 3, 4).
В качестве центра мы можем использовать transform position GameObject-а, а для поворота — transform rotation GameObject-а, или добавить общие переменные для задания конкретных значений.
Половинная величина — это просто Vector3, поэтому мы сделаем его общим и будем использовать.
Для слоёв, которые можно ударять, мы создадим публичное свойство типа LayerMask. Это позволит нам выбирать слои в инспекторе.
Collider[] colliders = Physics.OverlapBox(position, boxSize, rotation, mask);
if (colliders.Length > 0) {
Debug.Log("We hit something");
}
В случае правильной настройки и при наложении проецируемого прямоугольника на Collider в соответствующей маске при вызове мы должны увидеть сообщение в консоли.
Визуальное представление
Всё это здорово… но не слишком функционально. Пока мы не можем увидеть заданный где-то прямоугольник, будет очень сложно указать подходящие размеры и расположение Hitbox-ов.
Так как же нам отрисовать прямоугольник в окне Scene, но не в самой игре? С помощью OnDrawGizmos.
Как сказано в документации, Gizmos предназначены для визуальной отладки или вспомогательных построений в окне scene. Именно то, что нам нужно!
Мы дадим нашему Gizmo цвет и матрицу преобразований. То есть мы просто создаём матрицу с позицией, поворотом и масштабом transform-а.
private void OnDrawGizmos() {
Gizmos.color = Color.red;
Gizmos.matrix = Matrix4x4.TRS(transform.position, transform.rotation, transform.localScale);
Gizmos.DrawCube(Vector3.zero, new Vector3(boxSize.x * 2, boxSize.y * 2, boxSize.z * 2)); // Потому что размер - это половинная величина
}
При желании можно использовать OnDrawGizmosSelected, чтобы отрисовывать прямоугольник только при выборе объекта.
Настраиваемость и гибкость
Настраиваемость — это обширная тема, она во многом зависит от типа создаваемой игры и необходимого функционала.
В нашем случае мы позволим вносить быстрые изменения в форму и цвет хитбокса. Если вы используете Anima2D или какую-то скелетную анимацию, то вам возможно потребуется. чтобы Hitbox масштабировался в соответствии с масштабом костей.
Для изменения формы достаточно всего лишь добавить булево свойство и менять OverlapBox на любую другую форму, например на OverlapSphere. Для настройки сферы необходимо будет добавить публичное свойство радиуса. Не забывайте, что для отрисовки новой формы нужно будет изменить событие OnDrawGizmos (в нашем примере это будет DrawSphere).
Следует учесть, что мы не добавляем новый компонент и ничего не удаляем, а просто создали булево значение, которое будет выбирать накладываемую форму при проверке коллизий. Оно позволяет нам изменять форму хитбокса в зависимости от атаки (или даже для одной и той же атаки).
Что касается цвета, то я хочу, чтобы Hitbox менял цвет при следующих состояниях: он неактивен, он проверяет наличие коллизий или он обнаружил коллизию с чем-то. Позже нам понадобятся эти состояния для логики, так что давайте их добавим.
Мы создадим enum для состояния и три цвета, которые добавим как свойства нашего Hitbox-а.
public enum ColliderState {
Closed,
Open,
Colliding
}
Ваш класс может выглядеть примерно так:
public class Hitbox: MonoBehaviour {
public LayerMask mask;
public bool useSphere = false;
public Vector3 hitboxSize = Vector3.one;
public float radius = 0.5f;
public Color inactiveColor;
public Color collisionOpenColor;
public Color collidingColor;
private ColliderState _state;
/*
здесь методы
*/
}
Теперь мы можем обновлять gizmos, заменив строку
Gizmos.color = Color.red;
на вызов нового метода: private void checkGizmoColor() {
switch(_state) {
case ColliderState.Closed:
Gizmos.color = inactiveColor;
break;
case ColliderState.Open:
Gizmos.color = collisionOpenColor;
break;
case ColliderState.Colliding:
Gizmos.color = collidingColor;
break;
}
}
Так где же мы будем изменять состояние? Нам нужны три элемента:
- способ сказать Hitbox-у начать проверку коллизий
- способ сказать ему прекратить
- способ проверки коллизии
Первые два очевидны:
public void startCheckingCollision() {
_state = ColliderState.Open;
}
public void stopCheckingCollision() {
_state = ColliderState.Closed;
}
Теперь, когда Hitbox активен, мы хотим проверять в каждом кадре, нет ли у него с чем-нибудь коллизий, пока он активен. При этом мы переходим к следующему пункту.
Независимость от Unity Events API
Как вы возможно знаете, для проверки чего-то в каждом кадре мы можем использовать Update (ради упрощения я не добавляю проверку на изменение размера):
private void Update() {
if (_state == ColliderState.Closed) { return; }
Collider[] colliders = Physics.OverlapBox(position, boxSize, rotation, mask);
if (colliders.Length > 0) {
_state = ColliderState.Colliding;
// Здесь мы должны сделать что-то с коллайдерами
} else {
_state = ColliderState.Open;
}
}
Как вы видите, мы выполняем возврат, только если текущее состояние имеет значение «Closed». Это значит, что мы по-прежнему проверяем коллизии, если Hitbox с чем-то сталкивается, что позволяет Hitbox-у ударять несколько объектов одновременно, а не только первый ударенный. В своей игре вы можете выполнять обработку иначе.
Мы используем Update, но не хотим зависеть от Unity Events API! Решение может заключаться в создании собственного публичного метода обновления, который можно назвать hitboxUpdate (содержимое его будет таким же, как и у метода Update), и вызывать только в Hitbox-ах, используемых в текущей атаке.
Очевидно, что нам понадобится вызывать Update() в каких-то объектах выше по иерархии, но нам точно не нужно использовать их в каждом Hitbox-е постоянно просто потому, что они есть.
Использование Hitbox-а скриптом в другом объекте
Помните — проблема использования Collider заключалась в том, что для реализации OnTriggerEnter нам нужен был скрипт в том же GameObject? Так как мы используем собственный скрипт и можем добавлять его к чему угодно, решение достаточно очевидно.
Мы добавим объект в качестве свойства, чтобы можно было вызывать для него какой-то метод при коллизии Hitbox-а.
Для решения этой задачи можно использовать разные подходы:
- Мы можем добавить публичный GameObject и использовать SendMessage (у этого способа очень низкая скорость).
- Можно сделать то же самое с Monobehaviour, использующим метод, который вызывается при коллизии Hitbox-а. У этого способа есть свой недостаток: если мы хотим, чтобы Hitbox-ы использовали разные скрипты, то необходимо будет добавлять все эти свойства или наследовать от базового скрипта, который содержит вызываемый метод
- Можно создать интерфейс с методом, который необходимо вызывать, и реализовать его в каждом классе, который должен использовать Hitbox-ы.
С точки зрения структуры для меня очевидным выбором является interface. Единственная проблема в Unity заключается в том, что редактор по умолчанию не отображает интерфейсы как публичные свойства, поэтому мы не можем назначить его в редакторе. В следующем пункте я расскажу, почему это не является серьёзной проблемой.
Давайте создадим и применим интерфейс:
public interface IHitboxResponder {
void collisionedWith(Collider collider);
}
Добавим его как свойство в наш Hitbox…
public class Hitbox : MonoBehaviour {
...
private IHitboxResponder _responder = null;
...
/*
и оставшаяся часть класса
*/
}
Также при желании можно использовать вместо одного респондента массив.
Давайте используем респондента:
public void hitboxUpdate() {
if (_state == ColliderState.Closed) { return; }
Collider[] colliders = Physics.OverlapBox(position, boxSize, rotation, mask);
for (int i = 0; i < colliders.Length; i++) {
Collider aCollider = colliders[i];
responder?.collisionedWith(aCollider);
}
_state = colliders.Length > 0 ? ColliderState.Colliding : ColliderState.Open;
}
Если вам незнаком оператор "?", то прочитайте это.
Отлично! Но как нам задать свойство
_responder
. Давайте добавим сеттер и рассмотрим его в следующем пункте. public void useResponder(IHitboxResponder responder) {
_responder = responder;
}
Hitbox-ы должны быть применимыми к нескольким разным атакам, а не привязаны к одной
В этом разделе я объясню, почему не важно то, что мы не можем задать HitboxResponders с помощью редактора.
Сначала давайте поговорим об этих «респондентах». В выбранном нами для реализации Hitbox-ов подходе респондентом является любой класс, который должен что-то делать, когда Hitbox находится в коллизии с Collider-ом, то есть реализует IHitboxResponder. Для примера можно взять скрипт атаки: мы хотим, чтобы он наносил урон тому, что мы ударим.
Так как мы хотим, чтобы он не был связан с какой-то конкретной атакой и его можно было использовать многократно, задание респондентов в редакторе ничего бы нам не дало, потому что мы хотим иметь возможность менять респондентов на лету.
Допустим, у нас есть два типа атак — прямой удар и апперкот той же рукой, и у каждого их них есть свой скрипт, сообщающий, в каких кадрах анимации он должен ударять, сколько урона наносить и тому подобное. Так как обе эти атаки выполняются одной конечностью, давайте используем один Hitbox.
public class Attack: Monobehaviour, IHitboxResponder {
...
public int damage;
public Hitbox hitbox;
...
public void attack() {
hitbox.setResponder(this);
// выполняем всё остальное для атаки
}
void collisionedWith(Collider collider) {
Hurtbox hurtbox = collider.GetComponent<Hurtbox>();
hurtbox?.getHitBy(damage);
}
}
Отлично! Мы создали работающие хитбоксы. Как видно из кода выше, мы добавили Hurtbox-ам метод
getHitBy(int damage)
. Давайте посмотрим, сможем ли мы его улучшить.Усовершенствование Hurtbox-ов
В идеале для Hurtbox-ов мы хотим реализовать более-менее те же пункты, что и для Hitbox-ов. Это должно быть проще, потому что у Collider-а есть необходимый нам функционал. Также нам нужно использовать Collider, иначе Physics.Overlap… не будет сообщать об ударе.
Заметьте, что благодаря тому, как мы структурировали код, нам не нужно ни для чего использовать OnTriggerEnter, мы получаем скрипт с помощью GetComponent.
Это даёт нам настраиваемость и гибкость. Для обеспечения такой же гибкости, как у Hitbox-ов, нам нужно добавлять и удалять коллайдеры на лету, а для настраиваемости мы можем рисовать на коллайдере цвет в зависимости от его состояния.
public class Hurtbox : MonoBehaviour {
public Collider collider;
private ColliderState _state = ColliderState.Open;
public bool getHitBy(int damage) {
// Делаем что-то с уроном и состоянием
}
private void OnDrawGizmos() {
// Можно просто снова использовать код из хитбокса,
// но получая размер, поворот и масштаб из коллайдера
}
}
Добавлять и удалять Collider-ы на лету не так просто, как Hitbox-ы. Я не нашёл удовлетворительного способа для выполнения этой задачи. Мы можем добавить к скрипту несколько разных Collider-ов и выбирать нужный с помощью булевого значения, как мы делали это в Hitbox-ах. Проблема здесь заключается в том, что необходимо добавлять в качестве компонента каждый нужный Collider и в результате у нас получится сильная визуальная зашумлённость в редакторе и на объекте.
Ещё одним подходом будет добавление и удаление компонентов через код, но такое решение прибавит кучу ненужного мусора и вероятно будет не таким точным.
Было бы идеально, чтобы Hurtbox наследовал от Collider-а, а вся его логика форм была внутренней и мы могли бы отрисовывать только ту форму, которую сейчас используем, но мне не удалось заставить эту систему работать так, как хотелось.
Что дальше?
Если вы повторяли внимательно следили и повторяли за операциями в посте, то теперь у вас есть реализованные в Unity hitbox-ы, hurtbox-ы и pushbox-ы. Но что более важно, теперь мы знаем эти абстракции, и это сильно упростит работу, если вы будете надстраивать что-то поверх них.
Вероятно, сейчас инспектор скриптов у вас выглядит ужасно, но не волнуйтесь, это мы рассмотрим в следующем посте:
Мы можем превратить это...
Во что-то вроде этого!
Leopotam
Нельзя использовать Physics.OverlapBox() из-за постоянных memory allocation, вместо него следует взять Physics.OverlapBoxNonAlloc().