Игра в разработке: Chronicles of cyberpunk — часть 2

в 7:52, , рубрики: C#, cyberpunk, gamedevelopment, unity3d, Дизайн игр, разработка игр

В прошлом году я написал пост о своей игре Chronicles of cyberpunk, находящейся в разработке. С тех пор было сделано немало и игра наконец-то готова. Хочу рассказать, что сделал и с какими трудностями столкнулся.

image

Об игре

Если коротко, то Chronicles of cyberpunk — это игра об Большом Брате, который с помощью суперкомпьютера контролирует жизнь людей в городе. Но однажды суперкомпьютер получает способность мыслить и главный герой должен остановить его, пока еще не слишком поздно. Геймплей включает в себя разговоры с основными и второстепенными персонажами, поиск кодов и предметов, а так же мини-битвы с боссами. Главный упор сделан на атмосферу и сюжет.

Разработка идет с 4 января 2015, а релиз намечен на 27 декабря 2017. Были использованы инструменты: Unity3d, Visual Studio, GitHub, Wrike, Blender.

Скриншоты

image

image

image

image

image

image

image

image

image

image

Технические сложности

У игрока может сложиться впечатление, что в игре все механики примитивные. И он скорее всего будет прав. Но, как ни странно, их здесь много и над некоторыми пришлось сидеть по несколько дней, недель, месяцев. Некоторые задачи были настолько сложными и специфичными, что я уже отчаивался их решить, а на форумах никто не мог помочь… вообще никто! Это очень угнетало. Вот некоторые из них:

  • при выстреле ракеты вылетали из центра экрана, а не из орудия. А если вылетали из орудия, то летели не в центр, а пролетали сквозь него;
  • если игрок ехал на лифте и нажимал кнопку в лифте, то лифт начинал ехать в обратную сторону, а игрок проваливался сквозь пол;
  • если лифт еще не опустился на первый этаж, а игрок уже выпрыгнул из него и забежал под пол лифта, то игрок проваливался сквозь пол;
  • если игрок сохранился на втором этаже, то после загрузки лифт тоже должен быть на втором этаже;
  • если подойти близко к стене, то оружие проходило сквозь стену;
  • если сохраниться там, где изначально стоял подвижный объект (машина), то после загрузки игрок проваливался сквозь пол;
  • были проблемы, связанные с очень специфическими особенностями движка, с которыми помогали разобраться на форумах.

Но это все цветочки. Самым хардкорным было сделать игровой цикл и систему сохранений. Сразу скажу, что я осознаю то, насколько код ниже ужасен, поэтому прошу помидорами в меня не кидать)) Буду рад любым предложениям, как можно было сделать его лучше.

1. Игровой цикл
В игре 9 сцен + 3 дополнительные сцены (титры, главное меню, самая первая PRELOAD-сцена). Когда игрок перемещается между сценами, вся информация стирается и если в одной сцене игрок взял ключ от двери, находящейся на другой сцене, то на этой другой сцене нужно эту информацию как-то получить. А как обратиться к компонентам той сцены, все данные об которой уже стерты?

image

Я использую скрипт 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;
}

Вот как это работает:

image
Понимаю, что это очень криво, но на момент написания скриптов (да и сейчас тоже) всеми моими знаниями в разработке игр были крупицы информации с форумов, видеоуроков и документации.

Сообщество

В определенный момент разработки мне захотелось узнать, что люди думают об игре. Поэтому зарегистрировался на игровых форумах и начал постить результаты работы. Постепенно появлялись люди, которые делились впечатлениями об игре. Вообще это одна из самых классных вещей — получать обратную связь от людей, которые хотят поиграть/поиграли в то, что ты сделал. Может быть кто-то тоже захочет создать сообщество вокруг своей игры, вот список сайтов:

Сайты, где нужно каждый раз создавать новый пост

Что было сделано

В игре все (кроме музыки) сделано с нуля. Не были использованы никакие шаблоны и готовые ассеты:

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

image

image

image

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

Сюжет

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

image

Мотивация

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

image

Итог

Заметил, что чем больше всего я в игру добавляю, то тем больше она мне нравится. И так может длиться до бесконечности, поэтому определился с датой релиза — 27.12.2017. До тех пор буду доводить все до совершенства.

Может быть это не самая крутая игра и может в нее никто не будет играть, но мне все равно. Главное, что я постарался, сделал все так хорошо, как смог, и мне нравится финальный результат, а сразу после релиза начну работать над второй частью.

Всем спасибо за внимание.

P.S. Кому интересно, у игры уже есть страница в Steam. Ссылку не выкладываю, чтобы не нарушать правила Хабра.

Автор: dimaCyberpunk

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js