Опыт разработки первой игры на Unity, часть 3
Ошибка планирования
Возникла внезапная проблема: пусть во время битвы герои и получают опыт, повышают уровень — но этот прогресс должен сохраниться только при успешном завершении уровня. А смена уровня у меня идет следующим пунктом плана работ!
Так что в этой части будет смена уровня вместо прокачки героев.
Взаимоисключающие цели
Вот какая штука. По моей задумке хочу сделать следующее:
1) Сделать выбор следующей битвы так же, как в AFK Arena сделан мистический лабиринт.
Краткое описание
Игрок может выбрать только ближайшую к себе точку. Например, он выбрал клетку с Мистиком. Тогда следующим шагом он может выбрать либо правую точку (карету), либо центральную. Крайний левый флаг будет для него недоступен
2) Сделать выбор следующей битвы максимально простым.
Загвоздка в том, что вариант с лабиринтом сложен для восприятия — а я хочу, чтобы моя игра была максимально однокнопочной.
Плюсы же лабиринта очевидны — игрок сам решает, что ему будет комфортнее на следующем шаге, у игрока есть выбор, что делать дальше. Плюс игрок видит точки интереса на всей карте и может планировать свой маршрут, исходя из этого.
Грамотно озвученная проблема — часть решения
Хмм, в лабиринте игрок принимает решение… Делает выбор… Планирует маршрут… Стоило только озвучить проблему — тут же пришло в голову решение. Сочетает и простоту восприятия, и предоставляет игроку выбор, игрок ощущает, что он принимает решение. Встречайте!
Тут игрок сражается с противником, после чего выбирает, что ему делать дальше: пойти в следующую битву или пойти в… Эээ, пока не анонсированную, но уже придуманную сущность. О ней будет одна из следующих статей ;)
Решение не идеально — не хватает третьего компонента. Игрок должен понимать, какое решение сделает его игру проще / сложнее, и выбирать наиболее комфортный для него путь. Привет, теория потока. Пока что это просто голый выбор из двух вариантов — я не считаю, что этого будет достаточно. Об этом подумаю где-то между статьями
Объекты или UI?
В юнити есть как игровые объекты (по сути, просто болванки, на которые можно повесить всякое), так и готовые кнопки интерфейса. И на то, и на другое можно нажать (но с объектами нужно извращаться для этого).
Выбор следующей битвы представляю в виде условных островков, соединённых линиями. И вот эти островки могут выглядеть по разному: например, с замком на нем. И вот мне почему-то кажется, что GameObject больше подходит для такого..
С другой стороны готовые кнопки уже имеют важную для дальнейшего реализацию — всякие красивости для нажатого состояния, отпускаемого, ну и так далее. Итак, выбираю! Следующие уровни будут отображаться в виде UI кнопок — как раз потому что они уже имеют нужную мне визуализацию нажатий.
Завершение биты — переход на экран выбора — начало битвы
Есть два варианта
Битва и экран карты— две отдельные сцены.
Используется только одна сцена — но при «переходе» активируются / деактивируются соответствующие объекты.
Пока что выбрал второй вариант. С ним игра банально переходит между этими состояниями быстрее. Минус пока один — игра вроде как кушает больше ресурсов. Сделаю — проверю.
Переделка архитектуры объектов на сцене
Суть вот в чем. Будет примерно следующее
private void DEBUG_SWITCH()
{
if (_spawner.activeSelf)
{
_spawner.SetActive(false);
_uiPanelSkills.SetActive(false);
_uiPanelMap.SetActive(true);
}
else if (_uiObject.activeSelf)
{
_uiPanelSkills.SetActive(false);
_spawner.SetActive(true);
}
}
При «переходе» на экран карты отключаются все объекты, относящиеся к битве, и включаются объекты, относящиеся к карте. При выборе уровня все происходит ровно наоборот.
Но сейчас у меня при старте на сцене куча объектов безо всякого порядка — в основном из-за того, как сделан спавн героев игрока и противника
private void SpawnPlayerHeroes()
{
GetComponent<GenerateSkillUI>().GetBtnPanen(); // костыль. Из Start скрипта почему-то вызывается после этой функции
for (int i = 0; i < maxHeroes; i++)
{
GameObject go = Instantiate(respawnPlayer, transform.GetChild(0).GetChild(i).position, respawnPlayer.transform.rotation);
AddHeroesArray(go, i);
go.GetComponent<Characteristics>().StatsInitiate
("hero_00" + (i + 1).ToString(), "naming_hero_00" + (i + 1).ToString(), "stats_hero_00" + (i + 1).ToString(), "skills_hero_00" + (i + 1).ToString());
GetComponent<GenerateSkillUI>().GenerateButtons(i, go);
}
}
Решаю просто: перед вызовом спавна героев создаю два дополнительных объекта — для героев игрока и героев противника соответственно
private void SpawnParentsForHeroes()
{
GameObject go = new GameObject();
go.name = "ParentForPlayer";
go.transform.SetParent(gameObject.transform);
go.transform.position = transform.position;
go.transform.rotation = transform.rotation;
_goParentForPlayer = go;
GameObject go2 = new GameObject();
go2.name = "ParentForEnemy";
go2.transform.SetParent(gameObject.transform);
go2.transform.position = transform.position;
go2.transform.rotation = transform.rotation;
_goParentForEnemys = go2;
}
Какое-то адское извращение, но работает же!
После этого при спавне героев достаточно указать, что соответствующий _goParent… их родитель. Это так мило — теперь у них есть родитель.
Главное, что мне это дало — как только spawner отключается, пропадают все герои и панель с их скиллом. И появляются при его включении.
На данный момент этого достаточно, но чуть позже в рамках этого этапа нужно сделать так, чтобы при повторной активации все вновь появлялись на своих первоначальных местах — ну и чтобы сбрасывались их параметры.
А дальше что?
Ах да — забыл. Теперь архитектура объектов на сцене выглядит вот так
Дальше нужно добавить саму структуру кнопок / битв. Располагаться они будут как на одной из картинок выше.
Хммм, а как это сделать кодом?
Конечно же, первым делом полез в интернет! Наверняка уже кто-то такое делал) Только ничего подобного не нашел. Хорошо, придется думать.
У меня есть повторяющийся паттерн поведения: 1 — 2 — 1 — 2 и так далее. Значит, мне нужно сделать так, чтобы на экране появлялась комбинация "1 — 2". Хотя стоп — а вдруг я захочу сделать 1 — 2 — 2 — 2 — 1 или что-то такое.
Между делом наткнулся на новую для себя в юнити штуку — Grid. Судя по всему, там как раз можно задать любой шаблон — в том числе такой, какой мне нужен. Но как-то не удалось заставить его работать.
В итоге придумал какую-то абсолютно хитровыдуманную конструкцию. Что-то мне подсказывает, что я перемудрил
Ну и что я натворил
private void SpawnMap(int indexLevelNumber, int howMany)
{
Vector3 directionY = new Vector3(0, _sizeBtnY, 0);
Vector3 directionX = new Vector3(_sizeBtnX, 0, 0);
int howManyConverter (int a) // если howMany будет = levelNumber
{
if (a % 2 == 0)
return 2;
else return 1;
}
int IsEven(int a)
{
if (a % 2 == 0)
return 1;
else return -1;
}
if (howManyConverter(howMany) == 1)
{
GameObject go = Instantiate(_mapBtn,
new Vector3(_startPoint.x, _startPoint.y + directionY.y, _startPoint.z),
_panelMapContent.transform.rotation, _panelMapContent.transform);
}
else if (howManyConverter(howMany) == 2)
{
int indexLevelNumberPlusOne = indexLevelNumber;
for (int i = 0; i < howManyConverter(howMany); i++)
{
GameObject go = Instantiate(_mapBtn,
new Vector3(_startPoint.x + directionX.x * IsEven(indexLevelNumberPlusOne), _startPoint.y + directionY.y, _startPoint.z),
_panelMapContent.transform.rotation, _panelMapContent.transform);
indexLevelNumberPlusOne++;
}
}
_startPoint += directionY;
}
Вызывается так
for (int i = 0; i < _maxLvl; i++)
{
SpawnMap(i, i);
}
Ура! Чем нравится это решение: вместо второй «i» могу подставить 1 или 2. Например, мне нужна последовательность 1 — 1 — 2 — 1 — 2 — 2 — 2.
Тогда я делаю что-то типа такого:
Вызов ее через for, но вместо _maxLvl будет 1 («SpawnMap(i, 1);»).
Вызов ее через for, но вместо _maxLvl будет 2 («SpawnMap(i, i);»).
Вызов ее через for, но вместо _maxLvl будет 2 («SpawnMap(i, 2);»).
И да, в первом случае for не нужен, но это для меня. Иначе могу запутаться.
Разделение битв и особой сущности
По задумке с одной стороны будут битвы, с другой — будет находиться эта самая сущность. Но тут вообще просто — создаю функцию SetBtnType(int type)
private void SetBtnType(GameObject btnObject, int type)
{
// type == 1. Тогда кнопка вызывает битву
// type == 2. Тогда первая кнопка вызывает битву, вторая - особую сущность
btnObject.GetComponent<ClickMapBtn>().Btn.onClick.AddListener(() => btnObject.GetComponent<ClickMapBtn>().StartleLevel(type));
}
И добавляю ее вызов в SpawnMap() куда-нибудь в конец
SetBtnType(howManyConverter(howMany));
_startPoint += directionY;
Упс, накладка
Тут я понимаю, что забыл повесить какой-нибудь скрипт на кнопку карты. Так что делаю новый скрипт ClickMapBtn и вешаю на префаб кнопки карты. Щас будет полное извращение, но в этом скрипте у меня будет в том числе храниться индекс этой кнопки 0_о
Конечно, и в самом спавнере еще нужно добавить массив с кнопками
Перед тем, как сделать переключением «состояния» рабочим, нужно сделать еще одну вещь.
Сброс параметров всего, что отключается
Помните DEBUG_SWITCH, которая просто отключает / включает объекты? Забудем о ней! Теперь на каждом объекте есть скрипт, который не просто отключает объекты, а еще делает что-нибудь с их параметрами, если это нужно.
И эта свитч вызывает нужную функцию из скрипта каждого такого. По сути, у моих объектов появился контроллер, отслеживающий их состояние. Круто же!
Какие типовые сущности у меня есть? Герои, кнопки их скиллов и кнопки карты.
Сходу наткнулся на интересное архитектурное решение. По какой-то причине контроль ползунков на кнопке использовании скила у меня находится… В скрипте Characteristics. Это гениально, не иначе. Значит, переношу всю эту систему туда, где ей самое место — в скрипт кнопки. А Characteristics будет туда только передавать актуальные данные.
Заодно исправил незамеченный баг с постоянным ускорением наполнения маны. И добавил шкалы здоровья с маной над всеми героями — ничем не отличается от аналогичных шкал на кнопках.
Вот такая штука теперь у меня переключает «активные сцены»
private void ChangeActiveState(int activeState)
{
switch (activeState)
{
case 0:
for (int i = 0; i < _spawnerScript.playerHeroes.Length; i++)
{
ResetHero(_spawnerScript.playerHeroes[i]);
}
ResetBtnUseSkill();
for (int i = 0; i < _spawnerScript.enemyHeroes.Length; i++)
{
ResetHero(_spawnerScript.enemyHeroes[i]);
}
WakeUpMap();
break;
case 1:
ResetMap();
for (int i = 0; i < _spawnerScript.playerHeroes.Length; i++)
{
WakeUpHero(_spawnerScript.playerHeroes[i]);
}
WakeUpBtnUseSkills();
for (int i = 0; i < _spawnerScript.enemyHeroes.Length; i++)
{
WakeUpHero(_spawnerScript.enemyHeroes[i]);
}
for (int i = 0; i < _spawnerScript.playerHeroes.Length; i++)
{
UpdateHeroTarget(_spawnerScript.playerHeroes[i]);
}
WakeUpBtnUseSkills();
for (int i = 0; i < _spawnerScript.enemyHeroes.Length; i++)
{
UpdateHeroTarget(_spawnerScript.enemyHeroes[i]);
}
break;
default:
break;
}
}
Честно — понятия не имею, как ее сократить. Первый for проходится по всем героям игрока, следующий — по героям противника. Если попытаться сразу обновить цель для героя в первом цикле, то герои игрока не найдут противника, увы.
А всякие WakeUp вообще простейшие
private void ResetHero(GameObject heroArray)
{
heroArray.GetComponent<HeroReset>().ResetHero();
}
private void ResetBtnUseSkill()
{
_uiPanelSkills.SetActive(false);
}
private void WakeUpBtnUseSkills()
{
_uiPanelSkills.SetActive(true);
}
private void ResetMap()
{
_uiPanelMap.SetActive(false);
}
private void WakeUpHero(GameObject heroArray)
{
heroArray.GetComponent<HeroReset>().WakeUpHero();
_activeState = 0;
}
private void UpdateHeroTarget(GameObject heroArray)
{
heroArray.GetComponent<HeroReset>().UpdateTarget();
}
private void WakeUpMap()
{
_uiPanelMap.SetActive(true);
_activeState = 1;
}
В итоге получилось вот так
При респавне герои начинают с начальными характеристиками.
Следующая задача — поменять сущность имеющихся спавнеров. Теперь их задачей будет что-то типа инициализации объектов: создание, выдача параметров, но не появления на экране. За появление отныне отвечает SwitchActiveState, в котором у меня и происходит переключение. Это ответственная задача, но я верю в него.
Но это еще не все!
Игра умеет менять состояние "битва / карта", теперь нужно сделать так, чтобы кнопки на карте запускали битву — причем разную в зависимости от кнопки!
Для этого вернусь в скрипт, спавнящий на карте кнопки боев и добавлю что-то типа такого
private void SetLevelNumber(GameObject go, int type)
{
if (type == 1)
{
go.GetComponent<ClickMapBtn>().LevelNumber = _lvlNumber;
_lvlNumber++;
}
}
Гляньте, как классно все получается! Номер уровня увеличивается только у кнопок по центру и крайних левых
Подготовка завершена! Теперь, наконец, можно запускать битву, выбрав нужную кнопку на карте.
В бой!
В скрипте смены состояния делаю небольшие перестановки, вынеся кучу текста по разным функциям
public void SetActiveState(int activeState)
{
_activeState = activeState;
ChangeActiveState(activeState);
}
/// <summary>
/// Set Active State. 0: map, 1: battle
/// </summary>
/// <param name="activeState">0: map, 1: battle</param>
private void ChangeActiveState(int activeState)
{
switch (activeState)
{
case 0:
ChooseMap();
break;
case 1:
ChooseBattle();
break;
default:
break;
}
}
И происходит магия!
Тут можно увидеть, что включаются разные уровни в зависимости от нажатой кнопки. Еще можно заметить, что правые кнопки ничего не включают — это нормально пока что. Там будет кое-что другое.
В дальнейшем уровни будет отличаться — противниками и сложностью. Но сделать это смогу только когда разберусь с серверной частью.
Результат
Игра научилась запускать нужную битву и выходить обратно на экран карты. Считаю, это успех! Остался только маленький штрих
private void SetBtnName()
{
_lvlName = transform.GetChild(0).GetComponent<TextMeshProUGUI>();
if (_typeOfLevel == 0)
_lvlName.text = "Secret";
else if (_typeOfLevel == 1)
_lvlName.text = "battle " + _levelNumber;
}
Уф, теперь можно заняться прокачкой героев. Вернусь, когда с ней закончу!