В прошлом году я написал пост о своей игре Chronicles of cyberpunk, находящейся в разработке. С тех пор было сделано немало и игра наконец-то готова. Хочу рассказать, что сделал и с какими трудностями столкнулся.
Об игре
Если коротко, то 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. Ссылку не выкладываю, чтобы не нарушать правила Хабра.
Автор: dimaCyberpunk