
Об игре
Если коротко, то Chronicles of cyberpunk — это игра об Большом Брате, который с помощью суперкомпьютера контролирует жизнь людей в городе. Но однажды суперкомпьютер получает способность мыслить и главный герой должен остановить его, пока еще не слишком поздно. Геймплей включает в себя разговоры с основными и второстепенными персонажами, поиск кодов и предметов, а так же мини-битвы с боссами. Главный упор сделан на атмосферу и сюжет.
Разработка идет с 4 января 2015, а релиз намечен на 27 декабря 2017. Были использованы инструменты: Unity3d, Visual Studio, GitHub, Wrike, Blender.










Технические сложности
У игрока может сложиться впечатление, что в игре все механики примитивные. И он скорее всего будет прав. Но, как ни странно, их здесь много и над некоторыми пришлось сидеть по несколько дней, недель, месяцев. Некоторые задачи были настолько сложными и специфичными, что я уже отчаивался их решить, а на форумах никто не мог помочь… вообще никто! Это очень угнетало. Вот некоторые из них:
- при выстреле ракеты вылетали из центра экрана, а не из орудия. А если вылетали из орудия, то летели не в центр, а пролетали сквозь него;
- если игрок ехал на лифте и нажимал кнопку в лифте, то лифт начинал ехать в обратную сторону, а игрок проваливался сквозь пол;
- если лифт еще не опустился на первый этаж, а игрок уже выпрыгнул из него и забежал под пол лифта, то игрок проваливался сквозь пол;
- если игрок сохранился на втором этаже, то после загрузки лифт тоже должен быть на втором этаже;
- если подойти близко к стене, то оружие проходило сквозь стену;
- если сохраниться там, где изначально стоял подвижный объект (машина), то после загрузки игрок проваливался сквозь пол;
- были проблемы, связанные с очень специфическими особенностями движка, с которыми помогали разобраться на форумах.
Но это все цветочки. Самым хардкорным было сделать игровой цикл и систему сохранений. Сразу скажу, что я осознаю то, насколько код ниже ужасен, поэтому прошу помидорами в меня не кидать)) Буду рад любым предложениям, как можно было сделать его лучше.
1. Игровой цикл
В игре 9 сцен + 3 дополнительные сцены (титры, главное меню, самая первая PRELOAD-сцена). Когда игрок перемещается между сценами, вся информация стирается и если в одной сцене игрок взял ключ от двери, находящейся на другой сцене, то на этой другой сцене нужно эту информацию как-то получить. А как обратиться к компонентам той сцены, все данные об которой уже стерты?

Я использую скрипт GameManager, который содержит функцию DontDestroyOnLoad(), которая инициализируется на самой первой сцене Preload. Инициализация этой функции — единственное назначение сцены Preload. После нее сразу автоматически загружается следующая. Объект, который содержит скрипт, помечен тегом Acts, поэтому, когда загружается новая сцена и инициализируются ее объекты, движок быстро находит нужный объект по тегу, после чего с ним можно работать. Такой скрипт существует на протяжении всей игры на всех сценах в единственном экземпляре. В нем я и храню все нужные состояния: номер сцены, на которой мы находимся, номер текущей миссии и т.п.
public string nameOfLastLoadedScene;
private GameObject player;
public int currentActNumber { get; set; } // инкремент в конце каждого акта
private Act_0 act0;
private Act_1 act1;
private Act_2 act2;
//..
private Act_2 act28;
void Start ()
{
DontDestroyOnLoad(this);
SceneManager.LoadScene("Home");
}
void OnLevelWasLoaded()
{
player = GameObject.FindGameObjectWithTag("PlayerOnMainScene");
PlacingPlayerNearHouse();
}
// При загрузке главной сцены размещаем игрока рядом с домом, откуда он выходил
void PlacingPlayerNearHouse()
{
switch (nameOfLastLoadedScene)
{
case "": player.transform.position = new Vector3(-4.42f, 0.65f, 49.26f); break;
case "": player.transform.position = new Vector3(-14.8f, 0.65f, -7.2f); break;
case "": player.transform.position = new Vector3( 0.44f, 0.65f, 6.89f); break;
case "": player.transform.position = new Vector3(36.63f, 0.65f, 6.9f); break;
case "": player.transform.position = new Vector3( 9.45f, 0.65f,-36.73f); break;
case "": player.transform.position = new Vector3(-0.32f, 0.65f, -9.34f); break;
default: break;
}
}
В зависимости от номера текущего акта загружаем определенное поведение
void Update()
{
ActManager();
}
// Запускать определенный акт в зависимости от currentActNumber
void ActManager()
{
Debug.Log("currentActNumber: " + currentActNumber);
if (SceneManager.GetActiveScene().name != "PRELOAD" &&
SceneManager.GetActiveScene().name != "STARTSCREEN")
{
GameObject objWithActScripts = GameObject.FindGameObjectWithTag("Acts");
switch (currentActNumber)
{
case 0: act0 = objWithActScripts.GetComponent<Act_0>();
act0.StartAct(); break;
case 1: act1 = objWithActScripts.GetComponent<Act_1>();
act1.StartAct(); break;
//..
}
}
}
И такая схема позволяет программировать последовательность шагов для каждого акта отдельно
public void StartAct1(MonoBehaviour mb)
{
switch (stepNumber)
{
case 0:
// проигрываем анимацию открытия глаз
mb.StartCoroutine(OpenCloseEyesAnimation());
break;
case 1:
// отображаем подсказку "нажмите любую клавишу, чтобы проснуться"
ShowTip(contentToPrint.tipsTasks[0]);
stepNumber++;
break;
case 2:
// при нажатии на любую кнопку очищаем текст подсказки,
// анимация закрытия глаз, аудио зевания
if (Input.anyKey)
{
audioYawn.Play();
ShowTip("");
mb.StartCoroutine(OpenCloseEyesAnimation());
}
break;
case 3:
// отключаем игрока в кровати и включаем основного игрока,
// анимация открытия глаз
PlayerInBedDisable();
mb.StartCoroutine(OpenCloseEyesAnimation());
break;
case 4:
// блокируем перемещение, отображаем диалог с дроном
ShowUIAndPrintMessage(0, 0);
stepNumber++;
break;
//..
}
}
2. Сохранение/загрузка
Какие стояли задачи: загружать нужную сцену, номер акта, номер шага, позицию игрока и т.п. Сначала использовал функции PlayerPrefs.SetInt(), PlayerPrefs.GetInt(), но после загрузки игры в стим (она еще недоступна) столкнулся с проблемой, что если загрузить обновление, то все сохранения затираются. Поэтому решил сохранять в файл в папку AddData:
// файлSaves.cs
[System.Serializable]
public class Saves
{
public string dateTime;
public float transformPositionX;
public float transformPositionY;
public float transformPositionZ;
public int latestSaveSlot;
public int actNumber;
public string sceneName;
public int slotImage;
public int currentActiveSlot;
public int stepNumber;
}
// файл SaveLoad.cs
public Saves saves;
public void ButtonSave()
{
SetSlotImage();
saves.dateTime = System.DateTime.Now.ToString("yyyy/MM/dd hh:mm:ss") + " ";
saves.transformPositionX = playerTransform.position.x;
saves.transformPositionY = playerTransform.position.y;
saves.transformPositionZ = playerTransform.position.z;
saves.actNumber = gameManagerScript.currentActNumber;
SaveStepNumber();
saves.sceneName = SceneManager.GetActiveScene().name;
saves.slotImage = imageNumberForCurrentSlot;
dateText[currentActiveSlot].text = saves.dateTime;
var serializedSave = JsonUtility.ToJson(saves);
var saveFileName = Application.persistentDataPath + "/Save_" + PlayerPrefs.GetInt("currentActiveSlot") + ".save";
File.WriteAllText(saveFileName, serializedSave);
//==================================
savesForAllSlots.latestSaveSlotForAll = currentActiveSlot;
var serializedSaveForAll = JsonUtility.ToJson(savesForAllSlots);
var saveFileNameForAll = Application.persistentDataPath + "/Save_" + "ForAll" + ".save";
File.WriteAllText(saveFileNameForAll, serializedSaveForAll);
ButtonsActivation();
}
public void ButtonLoad()
{
loadSaveGameImage.SetActive(true);
CheckForContinueButton();
isLoadButtonPressed = true;
if (!isItContinueButton)
LoadJSON();
SceneManager.LoadScene(saves.sceneName);
gameManagerScript.currentActNumber = saves.actNumber;
LoadStepNumber();
}
private void LoadJSON()
{
if(File.Exists(Application.persistentDataPath + "/Save_" + PlayerPrefs.GetInt("currentActiveSlot") + ".save"))
{
var saveFileName = Application.persistentDataPath + "/Save_" + PlayerPrefs.GetInt("currentActiveSlot") + ".save";
string saveFilecontent = File.ReadAllText(saveFileName);
Saves deSerializedSave = JsonUtility.FromJson<Saves>(saveFilecontent);
saves = deSerializedSave;
}
}
Так, функция сохранения начала работать. Но была еще одна проблема. Для каждого акта есть цепочка шагов, которые игрок проходит один за другим. На акте №2 шагов 22 и если мы на шаге №8 подойдем к док-станции дрона, введем пароль, то дрон улетит на кухню (шаг №11). Если потом начать новую игру и загрузить номер шага №11, то получится, что дрон будет в док-станции, а должен быть на кухне. То есть номер шага мы загружаем корректно, а анимацию перемещения дрона не проигрываем. И таких мелочей куча, хранить их все в переменных (координаты и состояния всех объектов для каждого шага), а потом загружать довольно сложно. Поэтому я создал еще один метод, который является копией игрового цикла, но убрал оттуда строки, где ожидается активность со стороны игрока. И при загрузке игры мы просто в ускоренном темпе прогоняем все шаги от нулевого до требуемого, устанавливая актуальные состояния для всех объектов.
// Быстро перебираем все шаги для загрузки сохранения
public void QuickAct(int lastStepNumber)
{
Time.timeScale = 5;
for (int i = 0; i < lastStepNumber; i++)
switch (i)
{
case 0:
stepNumber++;
break;
case 1: // Текстовое интро
startTextCanvas.SetActive(true);
PrintStartText();
break;
case 2: // Инициализация объектов акта
openCloseEyesCanvas.SetActive(true);
startTextCanvas.SetActive(false);
elders.SetActive(true);
break;
//..
case 22: // Выходим на улицу
break;
}
Time.timeScale = 1;
}
Вот как это работает:

Понимаю, что это очень криво, но на момент написания скриптов (да и сейчас тоже) всеми моими знаниями в разработке игр были крупицы информации с форумов, видеоуроков и документации.
Сообщество
В определенный момент разработки мне захотелось узнать, что люди думают об игре. Поэтому зарегистрировался на игровых форумах и начал постить результаты работы. Постепенно появлялись люди, которые делились впечатлениями об игре. Вообще это одна из самых классных вещей — получать обратную связь от людей, которые хотят поиграть/поиграли в то, что ты сделал. Может быть кто-то тоже захочет создать сообщество вокруг своей игры, вот список сайтов:
Что было сделано
В игре все (кроме музыки) сделано с нуля. Не были использованы никакие шаблоны и готовые ассеты:
- 519 анимаций;
- 862 модели, включая 112 моделей людей, у каждой из которых своя уникальная анимация и каждую нужно настроить (добавить материалы, коллайдеры, скрипты). Сделал так, что в каждом акте, где действие происходит на улице или в казино, люди стоят на новых местах и у всех каждый раз новые реплики, чтобы интереснее было исследовать мир;
- 9 сцен + сцена с титрами, главное меню и самая первая PRELOAD-сцена;
- 313 скриптов. Эта цифра сама по себе ничего не значит, но все-таки;
- текст на 13.319 слов (44 листа) + английская локализация;
- вручную нарисовано 17 иконок персонажей, каждая по три раза, чтобы можно было создать 3х кадровую анимацию + еще 259 разных иллюстраций;
- 103 звуковых эффекта, включая 21 музыкальный трек. Все скачал с freesound.org



И все это сосредоточено в одной единственной игре. Это очень круто — видеть, как придуманный тобой мир обретает форму и оживает, а все механики правильно работают. Вообще разработка игр — это лучший способ самовыражения, который я только знаю. Ты можешь создать все, что крутится у тебя в голове — мир, героев, сюжет. И самое крутое, что чем больше ты учишься, то тем более крутые штуки сможешь делать.
Сюжет
Я с самого начала не придумывал сюжет, а просто записывал разные интересные идеи. А потом садился и думал, как это все можно соединить. Было очень интересно узнать, что в итоге получится. Самые интересные идеи приходили совершенно неожиданно. В результате, как мне кажется, получился интересный и запутанный сюжет с большим количеством связей между объектами. В какой то момент даже решил сделать энциклопедию по игре (осторожно, спойлеры) и начать работать над своей игровой вселенной. Вся игра будет состоять из трех частей, а мои остальные игры тоже будут происходить в этой вселенной.

Мотивация
Я эту штуку подсмотрел здесь же, на хабре. Суть в том, чтобы делать коммит каждый день. Если не сделал сегодня, то завтра придется делать за вчера задним числом и за сегодня. Это заставляет тебя хоть немного, но работать каждый день, при чем не заниматься планированием, а реально что-то делать, что можно закоммитить. В общем, всем советую попробовать

Итог
Заметил, что чем больше всего я в игру добавляю, то тем больше она мне нравится. И так может длиться до бесконечности, поэтому определился с датой релиза — 27.12.2017. До тех пор буду доводить все до совершенства.
Может быть это не самая крутая игра и может в нее никто не будет играть, но мне все равно. Главное, что я постарался, сделал все так хорошо, как смог, и мне нравится финальный результат, а сразу после релиза начну работать над второй частью.
Всем спасибо за внимание.
P.S. Кому интересно, у игры уже есть страница в Steam. Ссылку не выкладываю, чтобы не нарушать правила Хабра.
Комментарии (14)
Ravaldini
15.11.2017 13:11+1Посмотрел ролик. Для разработки в одиночку очень круто! И вообще настрой статьи очень положительный. Может даже скачаю. Удачи!
domix32
15.11.2017 14:37+1switch (currentActNumber) { case 0: act0 = objWithActScripts.GetComponent<Act_0>(); act0.StartAct(); break; case 1: ...
В C# есть перечисления. Осмысленные названия понятнее магических чисел.
Так держать.
Legion21
15.11.2017 14:49+1Вот это я и называю искусство! Мб это не очень элегантно в плане кода, мб тут нет супер крутых математических алгоритмов, но это продукт, который автор, как художник перенес из своей головы в реальность… С такими людьми я бы и хотел работать. Желаю успехов и дальнейшего развития!
kashtan404
15.11.2017 15:58Отличная работа! Люблю читать статьи про разработку игр, так как сам недавно вплотную подсел на разработку в UE4.
Скажите пожалуйста, почему выбрали Unity? Интересны критерии по которым выбирали движок.dimaCyberpunk Автор
15.11.2017 18:39Спасибо! Я вообще выбирал между Unity и UE, но у меня с анриалом проблемы были, связанные с Visual Studio. Уже точно не помню, какую-то ошибку выдавало при запуске. И еще он более требовательный к ресурсам, а меня ноут слабенький. Но следующую часть буду на анриале делать, хочу C++ подтянуть и посмотреть, как мои модели будут на нем смотреться)
Ugrum
15.11.2017 16:08Отличная работа! И хорошая, как уже ранее отметили, позитивная статья. А вот это:
«если игрок ехал на лифте и нажимал кнопку в лифте, то лифт начинал ехать в обратную сторону, а игрок проваливался сквозь пол,» это же не баг, а неожиданный поворот сюжета :)).
Dywar
15.11.2017 19:55Ого, вот это упорство.
У меня 3 игры лежит в архивах, почти готовые :)
Определенно успеха добьетесь с таким подходом.
Проблемы описанные выше думаю уже решены, но их решения я видел или в обучающих уроках или в документации (когда этим интересовался).
Для сохранения состояния игры очень хорошо может подойти паттерн Memento, а так желательно их выучить наизусть — metanit.com/sharp/patterns. +SOLID + GRASP
Оптимизировать память — пул объектов, с запросом через фабрику, на хабре писали уже.
thegreedylizard
16.11.2017 11:39+1Мне тоже всегда хотелось заняться разработкой собственной игры, и главным фактором, который постоянно останавливал на этом пути — недостаток мотивации. У автора, как видим, с этим всё в порядке. Желаю дальнейших успехов в разработке!
LeonThundeR
16.11.2017 20:32+1Впечатляющий объем работы для одного человека. Напарника программиста бы тебе ещё и все было бы намного лучше и быстрее.
AbstractGaze
Интересно узнать по поводу зарисовок и моделирования. До того как решили заняться этим проектом опыт уже был? Или все изучалось по мере необходимости?
dimaCyberpunk Автор
Все учил по ходу дела. Сначала модели использовал скачанные, но потом узнал, что их нельзя использовать из-за нарушения авторских прав, пришлось все удалять, учить моделирование и практически с нуля переделывать. Вот мои прошлые проекты, они все убогие)) kozyr.org/games