В первой части я рассуждал для чего прототипирование и в целом с чего начать
— Часть 1
В второй части немножко пробежались по ключевым классам
и архитектуре — Часть 2
И вот третья часть — в ней на самом деле будет немного рассуждений, разберем как действуют модификаторы, и дроп на игровое поле (это не сложно, но есть нюансы). И немножко про архитектуру, постараюсь без занудства.
Скриншоты про ракету в целом унылые, поэтому предлагаю посмотреть видео другого прототипа, который был собран за 2е недели вместе с графикой, и был заброшен в связи с тем что в жанре платформеры и так не протолкнуться. Это кстати одна из ключевых идей вокруг прототипа — собрать, посмотреть, не гавно ли это, нужно ли это? И честно забросить в корзину если вам ответы кажутся недостаточно убедительными. Но! Это не касается творческих проектов — иногда творчество бывает ради творчества ).
Итак видосик, смотреть надо на тексты расставленные по уровню, а не на геймплей ):
В прошлой статье я получил код ревью и благодарен за это, критика помогает развиваться даже если вы с ней не согласны.
Но хочется развернуть за архитектуру и синтаксис касательно прототипов:
И наконец про сам модификатор:
Модификаторами тут и ранее я называю силовые поля, которых касается ракета, и которые влияют на её траекторию, в прототипе их два типа — ускорение и отклонение.
Это очень простой класс задача которого передать два события:
Для чего тут вообще событийная модель? Кому еще может понадобиться этот ивент?
В текущем проекте это не реализовано, но:
В этом протипе суть геймплея — расставлять модифкаторы по игровому полю, корректируя траекторию полёта ракеты, для облетания препятствий и попадания в пункт/на планету назначения. Для этого у нас предусмотрена панель справа, на которой расположены иконки модификаторов.
Упреждая вопросы:
3 и 1 — так называемые магические цифры, которые просто взяты из головы и вставлены в код, такого нужно избегать, но почему они тут? Принцип по которому формируется правая панель так и не был определен, и было принято решение просто на прототип потестить прохождение с именно таким количеством модификаторов.
Как это сделать правильно? — как минимум вынести в сериализуемые поля, и задавать необходимые количества через инспектор. Почему я поленился и стоит ли это делать вам? Тут надо исходить из общей картины, за формирование необходимого количества модификаторов всё равно будут отвечать отдельная сущность и конфиг, поэтому тут я поленился рассчитывая на большой рефакторинг в будущем. Но лучше не ленится! )
О конфигах — когда появились первые лекции о ScriptableObject, мне понравилась идея хранить данные как ассет. Ты получаешь необходимые данные где тебе нужно, без привязки к экземпляру монобеха. Потом появилась лекция о подходе в разработке игр с участием ScriptableObject, где использовали хранение настроек экземпляра. Собственно пресеты/настройки чего либо, сохраненные в ассет и есть конфиг.
Рассмотрим класс конфига:
В чем суть его работы? Он хранит настроенный класс данных про модификатор.
Тип модификатора нужен для идентификации, иконка для интерфейса, геймобж сам игровой объект модификатора, материал тут для того чтобы его можно было менять при настройке. На игровом поле могут быть уже расположены модификаторы, и допустим геймдизайнер меняет его тип, теперь он даёт ускорение, модификатор инится из конфига и обновляет все поля, включая материал, согласно этому типа модификатора.
Работа с конфигом очень проста — обращаемся к конфигу за данными для конкретного типа, получаем эти данные, инитим визуал и возможные настройки из этих данных.
Где профит?
Выгода в очень большой гибкости, например вы хотите поменять материал и иконку на модификаторе ускорения или допустим заменить геймобжект целиком. Вместо переписывания и пробрасывания в поля инспектора, мы просто меняем эти данные в одном конфиге и вуаля — у нас всё обновится, на всех сценах/уровнях/панелях.
А если в конфиге несколько данных для модификатора ускорение?
В прототипе можно легко отследить вручную чтобы данные не дублировались, на рабочем проекте — нужны тесты и валидация данных.
Что тут происходит?
Мы хватаем иконку с панели, под ней создаем геймобж самого модификатора. И собственно кастим координату с клика/тача в игровое пространство, таким образом двигаем модификатор в игровом пространстве вместе с иконкой в UI, кстати советую почитать про RectTransformUtility, это отличный класс хелпер в котором очень много фич для интерфейса.
Допустим мы передумали ставить модификатор и возвращаем его обратно на панель,
Этот кусочек кода даёт нам понять что под кликом. Почему тут также указана проверка на слой? И почему опять магическое число 5? Как мы помним из второй части, мы используем график рейкастер не только для UI, но и для кнопки которая находится в сцене, также если мы добавим функционал удаления уже расставленных модификатов по полю или их перемещение, то они тоже будут попадать под график рейкаст, поэтому здесь также дополнительная проверка на принадлежность к слою UI. Это дефолтный слой, и его порядок не меняется, поэтому цифра 5 тут общем то не магическое число.
В итоге получается что если мы отпустим иконку над панелью — она вернется на панель, если над игровым полем — модификатор останется на поле, иконка удалится.
На код прототипа был потрачен 1 рабочий день. Плюс немного возни на подпиливание и графику. В целом геймплей был признан пригодным, не смотря на тонну вопросов по арту и фишечкам геймдизайна. Mission Complete.
Успешных всем прототипов.
— Часть 1
В второй части немножко пробежались по ключевым классам
и архитектуре — Часть 2
И вот третья часть — в ней на самом деле будет немного рассуждений, разберем как действуют модификаторы, и дроп на игровое поле (это не сложно, но есть нюансы). И немножко про архитектуру, постараюсь без занудства.
Скриншоты про ракету в целом унылые, поэтому предлагаю посмотреть видео другого прототипа, который был собран за 2е недели вместе с графикой, и был заброшен в связи с тем что в жанре платформеры и так не протолкнуться. Это кстати одна из ключевых идей вокруг прототипа — собрать, посмотреть, не гавно ли это, нужно ли это? И честно забросить в корзину если вам ответы кажутся недостаточно убедительными. Но! Это не касается творческих проектов — иногда творчество бывает ради творчества ).
Итак видосик, смотреть надо на тексты расставленные по уровню, а не на геймплей ):
Небольшое отступление
В прошлой статье я получил код ревью и благодарен за это, критика помогает развиваться даже если вы с ней не согласны.
Но хочется развернуть за архитектуру и синтаксис касательно прототипов:
- Какой бы вы крутой не были, нельзя всего предусмотреть, что то будет не заложено, и может быть много заложено того — что не понадобится. Поэтому в любом случае нужен будет рефакторинг или расширение. Если вы не можете описать конкретную выгоду от кода/подхода, лучше не тратить много времени на этот код.
- Почему на мой взгляд ООП/Событийная модель/композиция проще для прототипов чем ECS, Unity COOP, DI FrameWorks, Reactive Frameworks и тд. Меньше писанины, все связи видны в коде, ведь основная задача прототипа ответить на главный вопрос — можно ли в это играть, и на ряд второстепенных — что для геймплея лучше, то или это. Поэтому нужно как можно быстрее реализовать необходимый функционал. Зачем на маленький проект внедрять фреймворк, прописывать всё барахло, чтобы реализовать три игровые сущности? Каждая из которых на самом деле класс на 50-100 строк. Архитектуру стоит продумывать в рамках задач на прототип, и в рамках возможного расширения до альфы, но второе нужно скорее в голове чем в коде, чтобы потом не гореть дописывая код
Про модификаторы:
И наконец про сам модификатор:
Модификаторами тут и ранее я называю силовые поля, которых касается ракета, и которые влияют на её траекторию, в прототипе их два типа — ускорение и отклонение.
Класс модификатора
public class PushSideModificator : MonoBehaviour
{
[SerializeField] TypeOfForce typeOfForce = TypeOfForce.Push;
[SerializeField] private float force;
[SerializeField] DropPanelConfig dropPanelConfig;
private float boundsOfCollider;
private void OnTriggerEnter(Collider other)
{
boundsOfCollider = other.bounds.extents.x;
GlobalEventAggregator.EventAggregator.Invoke(new SpaceForces
{
TypeOfForce = typeOfForce,
Force = force,
ColliderBound = boundsOfCollider,
CenterOfObject = transform.position,
IsAdded = true
});
}
private void OnTriggerExit(Collider other)
{
GlobalEventAggregator.EventAggregator.Invoke(new SpaceForces
{ CenterOfObject = transform.position, IsAdded = false });
}
}
Это очень простой класс задача которого передать два события:
- Попадание игрока в поле(которое на самом деле физический тригер юнити), и весь необходимый контекст — тип модификатора, его позицию, размеры коллайдера, сила воздействия модификатора и тд. В этом плюс ивент агрегатора, он может передать любой контекст заинтересованным лицам. В данном ключе это модель ракеты, которая обрабатывает модификатор.
- Второе событие — игрок покинул поле. Чтобы убрать его влияние на траекторию игрока
Для чего тут вообще событийная модель? Кому еще может понадобиться этот ивент?
В текущем проекте это не реализовано, но:
- Озвучка (получили ивент что кто то вошел в поле — проигрываем соответствующий звук, кто то вышел — аналогично)
- UI маркеры, допустим за каждое поле мы будем снимать немного топлива с ракеты, должны будут появиться всплывающие подсказки что мы вошли в поле и потеряли топливо, ну или начислять очки за каждое попадание в поле, есть много вариантов того что интерфейсу интересен факт попадания игроком в поле.
- Спец. эффекты — при попадание в разный тип поля, могут накладываться разные эффекты, как на саму ракету, так и на пространство вокруг ракеты/поля. Спец. эффектами может заниматься отдельная сущность/контроллер, которая тоже будет подписана на события с модификаторов.
- Ну и это минимум кода, не нужны сервис локаторы, агрегация, зависимости и тд.
Основа геймплея
В этом протипе суть геймплея — расставлять модифкаторы по игровому полю, корректируя траекторию полёта ракеты, для облетания препятствий и попадания в пункт/на планету назначения. Для этого у нас предусмотрена панель справа, на которой расположены иконки модификаторов.
Класс панели
[RequireComponent (typeof(CanvasGroup))]
public class DragAndDropModifiersPanel : MonoBehaviour
{
[SerializeField] private DropModifiersIcon iconPrfb;
[SerializeField] private DropPanelConfig config;
private CanvasGroup canvasGroup;
private void Awake()
{
GlobalEventAggregator.EventAggregator.AddListener<ButtonStartPressed>(this, RocketStarted);
canvasGroup = GetComponent<CanvasGroup>();
}
private void RocketStarted(ButtonStartPressed obj)
{
canvasGroup.DOFade(0, 1);
(canvasGroup.transform as RectTransform).DOAnchorPosX(100, 1);
}
private void Start()
{
for (var x = 0; x< 3; x++)
{
var mod = config.GetModifierByType(TypeOfForce.Push);
var go = Instantiate(iconPrfb, transform);
go.Init(mod);
}
for (var x = 0; x< 1; x++)
{
var mod = config.GetModifierByType(TypeOfForce.AddSpeed);
var go = Instantiate(iconPrfb, transform);
go.Init(mod);
}
}
}
Упреждая вопросы:
for (var x = 0; x< 3; x++)
for (var x = 0; x< 1; x++)
3 и 1 — так называемые магические цифры, которые просто взяты из головы и вставлены в код, такого нужно избегать, но почему они тут? Принцип по которому формируется правая панель так и не был определен, и было принято решение просто на прототип потестить прохождение с именно таким количеством модификаторов.
Как это сделать правильно? — как минимум вынести в сериализуемые поля, и задавать необходимые количества через инспектор. Почему я поленился и стоит ли это делать вам? Тут надо исходить из общей картины, за формирование необходимого количества модификаторов всё равно будут отвечать отдельная сущность и конфиг, поэтому тут я поленился рассчитывая на большой рефакторинг в будущем. Но лучше не ленится! )
О конфигах — когда появились первые лекции о ScriptableObject, мне понравилась идея хранить данные как ассет. Ты получаешь необходимые данные где тебе нужно, без привязки к экземпляру монобеха. Потом появилась лекция о подходе в разработке игр с участием ScriptableObject, где использовали хранение настроек экземпляра. Собственно пресеты/настройки чего либо, сохраненные в ассет и есть конфиг.
Рассмотрим класс конфига:
Класс конфига
[CreateAssetMenu(fileName = "DropModifiersPanel", menuName = "Configs/DropModifier", order = 2)]
public class DropPanelConfig : ScriptableObject
{
[SerializeField] private ModifierBluePrintSimple[] modifierBluePrintSimples;
public DropModifier GetModifierByType(TypeOfForce typeOfModifiers)
{
return modifierBluePrintSimples.FirstOrDefault(x => x.GetValue.TypeOfModifier == typeOfModifiers).GetValue;
}
}
[System.Serializable]
public class DropModifier
{
public TypeOfForce TypeOfModifier;
public Sprite Icon;
public GameObject Modifier;
public Material Material;
}
В чем суть его работы? Он хранит настроенный класс данных про модификатор.
public class DropModifier
{
public TypeOfForce TypeOfModifier;
public Sprite Icon;
public GameObject Modifier;
public Material Material;
}
Тип модификатора нужен для идентификации, иконка для интерфейса, геймобж сам игровой объект модификатора, материал тут для того чтобы его можно было менять при настройке. На игровом поле могут быть уже расположены модификаторы, и допустим геймдизайнер меняет его тип, теперь он даёт ускорение, модификатор инится из конфига и обновляет все поля, включая материал, согласно этому типа модификатора.
Работа с конфигом очень проста — обращаемся к конфигу за данными для конкретного типа, получаем эти данные, инитим визуал и возможные настройки из этих данных.
Где профит?
Выгода в очень большой гибкости, например вы хотите поменять материал и иконку на модификаторе ускорения или допустим заменить геймобжект целиком. Вместо переписывания и пробрасывания в поля инспектора, мы просто меняем эти данные в одном конфиге и вуаля — у нас всё обновится, на всех сценах/уровнях/панелях.
А если в конфиге несколько данных для модификатора ускорение?
В прототипе можно легко отследить вручную чтобы данные не дублировались, на рабочем проекте — нужны тесты и валидация данных.
Из иконки, на игровое поле
Класс иконки модификатора
public class DropModifiersIcon : MonoBehaviour, IDragHandler, IBeginDragHandler, IEndDragHandler
{
[SerializeField] private Image icon;
[Header("сюда пихаем канвас для каста координаты с мышки")]
[SerializeField] private RectTransform canvas;
private CanvasGroup canvasGroup;
private DropModifier currentModifier;
private Vector3 startPoint;
private Vector3 outV3;
private GameObject currentDraggedObj;
private void Start()
{
canvasGroup = GetComponent<CanvasGroup>();
startPoint = transform.position;
canvas = GetComponentInParent<Canvas>().transform as RectTransform;
}
public void Init(DropModifier dropModifier)
{
icon.sprite = dropModifier.Icon;
currentModifier = dropModifier;
}
public void OnBeginDrag(PointerEventData eventData)
{
BlockRaycast(false);
currentDraggedObj = Instantiate(currentModifier.Modifier, WorldSpaceCoord(), Quaternion.identity);
GlobalEventAggregator.EventAggregator.Invoke(new ImOnDragEvent { IsDragging = true });
}
private void BlockRaycast(bool state)
{
canvasGroup.blocksRaycasts = state;
}
public void OnDrag(PointerEventData eventData)
{
Vector2 outV2;
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvas, Input.mousePosition, null, out outV2);
transform.position = canvas.transform.TransformPoint(outV2);
if (currentDraggedObj != null)
currentDraggedObj.transform.position = WorldSpaceCoord();
}
private Vector3 WorldSpaceCoord()
{
RectTransformUtility.ScreenPointToWorldPointInRectangle(canvas, Input.mousePosition, Camera.main, out outV3);
return outV3;
}
public void OnEndDrag(PointerEventData eventData)
{
GlobalEventAggregator.EventAggregator.Invoke(new ImOnDragEvent { IsDragging = false });
if (eventData.pointerCurrentRaycast.gameObject != null && eventData.pointerCurrentRaycast.gameObject.layer == 5)
{
Destroy(currentDraggedObj);
transform.SetAsLastSibling();
canvasGroup.blocksRaycasts = true;
}
else
Destroy(gameObject);
}
}
public struct ImOnDragEvent
{
public bool IsDragging;
}
Что тут происходит?
Мы хватаем иконку с панели, под ней создаем геймобж самого модификатора. И собственно кастим координату с клика/тача в игровое пространство, таким образом двигаем модификатор в игровом пространстве вместе с иконкой в UI, кстати советую почитать про RectTransformUtility, это отличный класс хелпер в котором очень много фич для интерфейса.
Допустим мы передумали ставить модификатор и возвращаем его обратно на панель,
if (eventData.pointerCurrentRaycast.gameObject != null && eventData.pointerCurrentRaycast.gameObject.layer == 5)
Этот кусочек кода даёт нам понять что под кликом. Почему тут также указана проверка на слой? И почему опять магическое число 5? Как мы помним из второй части, мы используем график рейкастер не только для UI, но и для кнопки которая находится в сцене, также если мы добавим функционал удаления уже расставленных модификатов по полю или их перемещение, то они тоже будут попадать под график рейкаст, поэтому здесь также дополнительная проверка на принадлежность к слою UI. Это дефолтный слой, и его порядок не меняется, поэтому цифра 5 тут общем то не магическое число.
В итоге получается что если мы отпустим иконку над панелью — она вернется на панель, если над игровым полем — модификатор останется на поле, иконка удалится.
На код прототипа был потрачен 1 рабочий день. Плюс немного возни на подпиливание и графику. В целом геймплей был признан пригодным, не смотря на тонну вопросов по арту и фишечкам геймдизайна. Mission Complete.
Выводы и рекомендации
- Закладывайте минимальную архитектуру, но тем не менее архитектуру
- Соблюдайте основные принципы, но без фанатизма )
- Выбирайте простые решения
- Между универсальностью и быстротой — для прототипа лучше выбрать скорость
- Для больших/средних проектов, подразумевайте что проект лучше переписать с нуля. Например сейчас тренд в Unity — DOTS, придется писать много компонентов и систем, на коротких забегах это плохо, проигрываете по времени, на длинных забегах — когда все компоненты и системы прописаны, начинается выигрыш по времени. Не думаю что это круто — потратить кучу времени на трендовую архитектуру, и выяснить что прототип гавно
Успешных всем прототипов.