История о разработке Космосима на Unity

в 12:20, , рубрики: C#, game development, Gamedev, unity3d

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

История о разработке Космосима на Unity - 1
Название — та часть игры, работа над которой была отложена на самый последний момент. В итоге ничего стоящего придумать, к сожалению, не удалось.

Жанр, сеттинг и сюжет

Все началось довольно стандартно, конкурс, тема — «Выбор» (трактуй как хочешь, называется), срок — две недели. Я страдаю легким неприятием 2D-игр, поэтому вопрос 2D/3D не стоял совсем. С жанром и сеттингом было уже сложнее, решено было отталкиваться от двух любимых игр, за которыми была проведена не одна сотня часов — «Космические рейнджеры» и «Механоиды». Так я определился с жанром — игра, построенная на механике Elite, конечно, сильно урезанной. Логичным сеттингом для такого жанра был Sci-fi, место действия — космос, потому что за две недели создать минимально достойные «наземные уровни» весьма проблематично.

Для минимизации требуемого количества контента было решено отказаться от генерируемых планетных систем, и от какой-либо масштабности в целом. Вся игра происходит в пределах одного астероидного пояса, поделенного на несколько секторов, количество точек интереса — минимальное. Но такое решение оставляло время на остальные аспекты игры, если углубиться в проработку мира, на все остальное времени не останется совсем.

История о разработке Космосима на Unity - 2
В первый же день родился набросок интерфейса и набор фич, которые реализовывались в последующие 14 дней.

С сюжетом же я поступил еще более варварски (зря, как оказалось впоследствии) — за основу была взята первая попавшая в голову идея о таинственных захватчиках, посягнувших на астероидный пояс, в котором находилась база ГГ. Продумывание деталей было оставлено на потом, равно как и придумывание сюжетных заданий.

Впереди было самое важное — фичи.

Возможности

Очень хотелось выложиться по-максимуму, и реализовать как можно большее количество разнообразных фишек, многие из которых имели лишь одну цель — радовать тем, что они вообще реализованы в игре. Одной из таких фишек были самонаводящиеся ракеты. Кажется, несмотря на их способность взрывать мелких врагов одним попаданием, ими никто из игравших в игру так толком и не пользовался. Вместе с тем, времени на их создание ушло прилично.

В целом, примерный список основных возможностей выглядел примерно так:

  • Управляемый игроком космический корабль, с возможностью кастомизации вооружения
  • Несколько видов оружия, которые можно подвешивать на разные точки подвески
  • Возможность подбирать обломки врагов и сдавать их на базах, получая за это деньги
  • Несколько секторов, перемещение между которыми производится гиперпрыжком
  • Управляемые и неуправляемые виды оружия
  • Несколько видов противников, отличающихся характеристиками
  • Десяток-другой сюжетных квестов, минимум две концовки
  • Босс, и важный элемент игры — схватка с боссом

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

Начало: Первые модели и передвижение в пустоте

После того, как с жанром и набором фич было решено, в 3d редакторе были быстро набросаны две модели — космический корабль главного героя и один из противников.

История о разработке Космосима на Unity - 3
Примитивность и кубичность кораблей врагов имеет под собой сюжетное обоснование.

И вот теперь, наконец, можно было приступать к программированию.

Первым делом было решено реализовать перемещения космического корабля и камеры.

Перемещение реализовано достаточно просто, никакой инерции и прочих реалистичных вещей. Корабль игрока представляет собой модель с Rigidbody, которому каждый тик задаются угловые и поступательные скорости. Для получения угловой скорости с мыши берется вектор смещения от центра, с каждым тиком к смещению прибавляется текущая дельта перемещения мыши, а сам вектор умножается на коэффициент, меньший 1. Получается достаточно плавное управление, с иллюзией небольшой инерции корабля.

Для поступательного движения все еще проще — корабль двигается в направлении локального «вперед», скорость плавно увеличивается или уменьшается при нажатии клавиш W/S.

С Камерой все получилось тоже довольно просто. В лоб привязать камеру к кораблю — плохая идея, поэтому камера и корабль не связаны, но для камеры есть целевая точка сзади-сверху корабля, в которую она стремится, и целевые углы поворота. Текущая и целевая точки интерполируются, тем самым получается эффект «плавной следящей камеры».

Чтобы придать ощущение скорости, с разгоном корабля поле зрения камеры немного увеличивается, при замедлении соответственно замедляется.


Видно, как корабль неприятно дергается. Это было вылечено включением интерполяции в настройках Rigidbody корабля.

Наполняем пустоту

Перемещаться в пустом черном космосе как-то неинтересно. Поэтому следующим шагом было наполнение космоса объектами.
Так как по сеттингу местом действия игры был астероидный пояс, задача решалась очень просто — в пространстве в несколько десятков кубических километров случайным образом разбрасывалась пара сотен астероидов покрупнее. Seed для случайной генерации сохранялся заранее, поэтому можно было не беспокоиться о том, что база или квестовый предмет окажется внутри астероида.

История о разработке Космосима на Unity - 4
Примерно так это выглядит издали.

Для большей «живости» и динамичности, помимо крупных астероидов были добавлены небольшие, и «звездная пыль». Эти мелочи уже не генерировались по всей локации, а создавались непосредственно вокруг движущегося корабля.

Код генератора астероидной мелочи

public class MiniAsteroids : MonoBehaviour 
{
    private Transform tx;
    private GameObject[] asteroids = new GameObject[155];
    public GameObject[] prefabs = new GameObject[2];
    public int asteroidsMax = 100;
    public float asteroidSize = 1;
    public float asteroidDistance = 10;
    public float asteroidClipDistance = 1;
    private float asteroidDistanceSqr;
    private float asteroidClipDistanceSqr;
    private ParticleSystem pSystem;

    private int updateEvery = 5;
    private int counter = 0;

    GameObject root;

    void Start()
    {
        root = new GameObject();
        root.transform.position = Vector3.zero;
        root.name = "Small Asteroid Field";

        tx = transform;
        asteroidDistanceSqr = asteroidDistance * asteroidDistance;
        asteroidClipDistanceSqr = asteroidClipDistance * asteroidClipDistance;
        pSystem = GetComponent<ParticleSystem>();
    }


    private void CreateStars()
    {
        for (int i = 0; i < asteroidsMax; i++)
        {
            asteroids[i] = Instantiate(prefabs[Random.Range(0, prefabs.Length - 1)]) as GameObject;
            asteroids[i].transform.position = Random.insideUnitSphere * asteroidDistance + tx.position;
            asteroids[i].transform.parent = root.transform;
            asteroids[i].GetComponent<Rigidbody>().angularVelocity = Random.insideUnitSphere * 2f;
        }

    }

    void Update()
    {
        counter++;
        if (asteroids[0] == null) CreateStars();

        if (counter == updateEvery)
        {
            counter = 0;

            for (int i = 0; i < asteroidsMax; i++)
            {

                if ((asteroids[i].transform.position - tx.position).sqrMagnitude > asteroidDistanceSqr)
                {
                    asteroids[i].transform.position = Random.insideUnitSphere.normalized * asteroidDistance + tx.position;
                    asteroids[i].GetComponent<Rigidbody>().velocity = Vector3.zero;
                }

            }
        }

    }
}

Вооружаем корабль

Кораблю главного героя было решено дать возможность нести оружие на шести точках подвески (две или четыре точки это слишком несолидно для тяжеловооруженного истребителя). Места крепления вооружения задавались с помощью GameObject'ов-пустышек, количество точек для подвески было захардкожено (да, каюсь). При этом точки подвески делились на два вида — основное оружие и вспомогательное. Отличие было по сути одно — оружие на основных слотах активировалось на ЛКМ, а на дополнительных — на пробел. С кораблем оружие взаимодействовало довольно ограниченно — через интерфейс, реализующий доступ к основным функциям, что позволяло достаточно легко добавлять новые виды вооружения. Помимо этого, в него было по мере разработки добавлено еще несколько функций, отвечающих за его вид в интерфейсе магазина и ангара.

Интерфейс

interface IWeapon
{
    void Shoot(Transform target);
    void Refill();
    int GetAmmoNum();
    void SetAmmoNum(int n);
    Vector3 GetJointOffset();
    string GetWeaponName();
    string GetWeaponTitle();
    string GetWeaponDesc();
    int GetWeaponPrice();
    Vector3 GetGUIRotation();
    Vector3 GetGUIScale();
}

История о разработке Космосима на Unity - 5
Один из первых скриншотов корабля с подвешенным оружием

Всего было сделано пять видов оружия: Двуствольная пушка, Лазер, неуправляемые ракеты, тяжелая самонаводящаяся торпеда, самонаводящаяся ракета.

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

Код скрипта ракеты с наведением

public class GuidedRocket : MonoBehaviour 
{
    public float selfDestructTime = 5f;
    public float safetyDelay = 0.5f;
    public float damageRadius = 6f;
    public int damage = 120;
    public float acceleration;
    public float steerCoef = 0.09f;
    float maxSpeed = 10f;
    public Collider col;
    bool armed = false;
    public Transform smoke;
    public GameObject explosion;
    public GameObject target;

    Vector3 estimatedPosition;
    Vector3 targetVelocity;
    Rigidbody targetBody;
    public Vector3 fwd = -Vector3.up;
    public Vector3 fixAngle = new Vector3(-90, 0, 0);

    float startSpeed;
    bool first = true;
    Rigidbody body;

    public LayerMask hitMask;

    void Start()
    {
        Invoke("SelfDestruct", selfDestructTime);
        Invoke("Arm", safetyDelay);
        body = GetComponent<Rigidbody>();
    }

    void FixedUpdate()
    {
        if (first)
        {
            first = false;
            startSpeed = transform.InverseTransformDirection(GetComponent<Rigidbody>().velocity).magnitude;
            maxSpeed = startSpeed * 2;
            if (target != null)
            {
                targetBody = target.GetComponent<Rigidbody>();
            }
        }
        Vector3 fwdDir = transform.TransformDirection(fwd);

        if (armed)
        {
            startSpeed += Time.deltaTime * acceleration;

            if (target != null)
            {
                Vector3 enemyPos = target.transform.position;
                Vector3 dir = enemyPos - transform.position;

                if (targetBody != null)
                {
                    float distance = dir.magnitude;
                    Vector3 tgVel = targetBody.velocity;
                    estimatedPosition = target.transform.position + (distance / startSpeed) * tgVel; //примерная точка упреждения
                    dir = estimatedPosition - transform.position;
                }
                else
                {

                }

                Quaternion targetRotation = Quaternion.LookRotation(dir) * Quaternion.Euler(fixAngle);
                body.MoveRotation(Quaternion.Lerp(transform.rotation, targetRotation, steerCoef));
            }  
        }
      
        body.velocity = Vector3.Lerp(body.velocity,fwdDir * startSpeed , 0.5f);
    }

    void SelfDestruct()
    {
        smoke.parent = null;
        smoke.gameObject.AddComponent<Autodestruction>().Set(5f);
        smoke.gameObject.GetComponent<ParticleSystem>().enableEmission = false;

        explosion.transform.parent = null;
        explosion.SetActive(true);
        Destroy(this.gameObject);
    }

    void Arm()
    {
        armed = true;
        col.enabled = true;
    }

    void OnTriggerEnter(Collider col)
    {
        if (armed)
        {
            Debug.Log(col.gameObject.name);
            foreach (Collider c in Physics.OverlapSphere(transform.position, damageRadius))
            {
                if (c.GetComponent<Rigidbody>())
                {
                    c.GetComponent<Rigidbody>().AddExplosionForce(20, this.transform.position, damageRadius);
                    c.GetComponent<Rigidbody>().AddTorque(new Vector3(Random.Range(-30f, 30f), Random.Range(-30f, 30f), Random.Range(-30f, 30f)));
                }

                if (c.gameObject.GetComponent<IDamageReciever>() != null)
                {
                    c.gameObject.GetComponent<IDamageReciever>().DoDamage(damage);
                }
            }

            CancelInvoke("SelfDestruct");
            SelfDestruct();
        }
    }
}

Ниже видео работы двух видов оружия. Также на видео виден эффект разрушения противника, но так как он довольно прост, останавливаться на нем отдельно не буду.

Интерфейс

Затраты времени на интерфейс неожиданно оказались весьма значительными, на разработку интерфейса ушло не меньше трех дней работы. Использовалась система UI, появившаяся в Unity 4.6, на мой взгляд очень удобная и достаточно простая в изучении.

Самой сложной частью работы над интерфейсом был интерфейс базы, необходимо было реализовать довольно большой функционал. Диалоги, покупка/продажа оружия, продажа вещей из трюма, ремонт. В качестве иконок груза/оружия использовались их модели в ортогональной проекции, потому что времени на отрисовку иконок банально не хватало. К каждому предмету было составлено небольшое описание, сообщающее игроку, что это такое перед ним.

История о разработке Космосима на Unity - 6
Первый вариант интерфейса, кривой, без вкладки заданий

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

История о разработке Космосима на Unity - 7
Финальный вариант, суть та же, но приятней глазу.

Полетный интерфейс дался проще. Игрок с ним по сути никак не взаимодействует, да и сам по себе он довльно минималистичен. Для прогресс-баров использовался обычный спрайт, разрезанный средствами Unity на 9 частей и растягиваемый по горизонтали, очень просто и достаточно симпатично. С изогнутыми барами сбоку от прицела все сложнее. Просто растягивать спрайт по одной из осей уже не выйдет, нужно использовать более сложное решение. Был написан простой шейдер с одним параметром — высота отсечения. Она задавалась из скрипта, и в соответствии с ней спрайт рисовался с нужным заполнением.

Код шейдера

Shader "Custom/CrosshairShader" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_Angle ("Angle (Float)", Float) = 0
_Color ("Tint", Color) = (1.0, 0.6, 0.6, 1.0)
}

SubShader {
Tags{"Queue" = "Transparent" }
Pass {
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert_img
#pragma fragment frag

#include "UnityCG.cginc"

uniform sampler2D _MainTex;
uniform float _Angle;
uniform fixed4 _Color;

float4 frag(v2f_img i) : COLOR
{
float4 result = tex2D(_MainTex, i.uv);
float angle = i.uv.y;

if(angle > _Angle)
{
result.a = 0;
}

return result*_Color;
}
ENDCG
}
}
}

Скриншот полетного интерфейса:

История о разработке Космосима на Unity - 8
На скриншоте видно наполовину заполненный показатель здоровья в центре экрана

Противники

По моему мнению, одно из слабых мест игры. Тактика у них только одна — лететь за игроком, если он в их поле зрения, и приблизившись на расстояния открытия огня, начать стрельбу. Несмотря на это, в большом количестве даже такие враги представляют серьезную опасность.

Конечно, помимо названного выше поведения у них были и вспомогательные функции. Например, потеряв игрока из виду, они летели в последнюю точку, где они его видели, а не обнаружив там его, начинали случайно блуждать или возвращались к стартовой точке. А еще, они иногда избегали столкновения с астероидами, но только если летели не слишком быстро. Полноценный конечный автомат для ИИ противников так реализован не был, скрипт ИИ запускал проход каждые 0.1 секунды, в котором решал, что противник будет делать в следующие 0.1с. Из-за этого, чтобы добиться более-менее сносного поведения, пришлось заводить множество дополнительных переменных, описывающих состояние ИИ.

История о разработке Космосима на Unity - 9
Главный босс и вовсе был беззащитен, и полагался лишь на огромное количество своих миньонов.

Квесты

До этого я ни разу не делал систему квестов, поэтому даже не имел представления с чего начинать. Но времени рассуждать или искать уроки не было — надо было действовать, ведь шла вторая неделя! В итоге родилась весьма уродливая система с захардкоженным списком квестов (для добавления нового квеста надо было прописать его название в переменных Главного Квестового Скрипта, увеличить количество квестов на единицу, и прописать к какой базе квест относится), с фиксированным количеством вариантов ответа в диалогах (три штуки), и с прочими признаками прыжков на граблях.

Диалоги сохранялись в xml, для их редактирования был даже сделан простенький редактор там же, в Unity.

История о разработке Космосима на Unity - 10
Редактор, пожалуй лучшая часть системы квестов

Сами же квесты были сделаны префабами, которые инстансились при активации квеста и удалялись после получения награды. Это криво, но работало. На систему квестов, написание диалогов и на сами квесты ушло еще три дня. Срок подходил к концу.

Прочие мелочи

Хочется здесь перечислить разные фишки, которые недостойны отдельного раздела, но о которых тем не менее хочется рассказать.

Начну пожалуй с эффекта гиперпрыжка. Как он выглядит, можно посмотреть ниже.

Реализован он очень просто. Сначала мы запускаем звук, начинаем трясти камеру, показываем поверх основной модели немного увеличенную модель того же корабля, но с текстурой «энергетического поля», затем анимируем ее с помощью Animation Curves, а перед самым прыжком запускаем систему частиц-вспышку, и прячем основную модель. Мне кажется, для «низкобюджетного» эффекта вышло неплохо.

Еще хочется рассказать пару слов про систему сохранений. Как ни странно, она присутствует в игре. Xml-файл с именем игрока сохраняется в папке с игрой, само имя игрока сохраняется в PlayerPrefs (в реестре). Теоретически, можно даже пользоваться несколькими сейвами, меняя имя игрока в главном меню. Сохранение автоматическое, после каждого выполненного квеста.

Реализовано оно крайне просто: был создан класс SaveInfo, куда сохраняются все необходимые параметры состояния игры, в классе реализованы два метода: Load() и Save(), которые сериализуют/десериализуют данные в xml-файл стандартными средствами C#.

Load и Save


    public void Save(string path)
    {
        var serializer = new XmlSerializer(typeof(SaveInfo));
        using (var stream = new FileStream(path, FileMode.Create))
        {
            serializer.Serialize(stream, this);
            stream.Close();
        }
    }

    public static SaveInfo Load(string path)
    {
        var serializer = new XmlSerializer(typeof(SaveInfo));
        using (var stream = new FileStream(path, FileMode.Open))
        {
            return serializer.Deserialize(stream) as SaveInfo;
        }
    }

Все, больше ничего делать не надо, остается только разобрать поля этого класса.

Файл сохранения

<?xml version="1.0" encoding="windows-1251"?>
<SaveInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <name>Mingebag</name>
  <money>150</money>
  <health>460</health>
  <maxHealth>500</maxHealth>
  <maxcargo>2500</maxcargo>
  <maxSpeed>25</maxSpeed>
  <acceleration>3</acceleration>
  <yawPitchFactor>1.99</yawPitchFactor>
  <rollFactor>1.98</rollFactor>
  <wS1Name>Machinegun</wS1Name>
  <wS1Num>0</wS1Num>
  <wS2Name>Machinegun</wS2Name>
  <wS2Num>0</wS2Num>
  <auxName>
    <string>GuidedLauncher</string>
    <string>NursLauncher</string>
    <string>NursLauncher</string>
    <string>GuidedLauncher</string>
  </auxName>
  <auxNum>
    <int>6</int>
    <int>18</int>
    <int>18</int>
    <int>9</int>
  </auxNum>
  <spawnInNextScene>false</spawnInNextScene>
  <spawnPositionIndex>0</spawnPositionIndex>
  <spawned>true</spawned>
  <hasActiveQuest>false</hasActiveQuest>
  <base1QuestNum>0</base1QuestNum>
  <base2QuestNum>0</base2QuestNum>
  <InventoryItems>
    <Item>
      <name>Equipment3</name>
      <title>Двигатель Кубоидов</title>
      <desc>Компактный плазменный двигатель, практически аналогичен человеческому.</desc>
      <weight>300</weight>
      <quantity>1</quantity>
      <price>100</price>
    </Item>
    <Item>
      <name>Equipment2</name>
      <title>Граббер Кубоидов</title>
      <desc>Маломощный эмиттер гравитонов. Используется кубоидами как захват</desc>
      <weight>150</weight>
      <quantity>1</quantity>
      <price>100</price>
    </Item>
    <Item>
      <name>Debris</name>
      <title>Кусок обшивки</title>
      <desc>Обломки космического корабля. Содержит ценные конструкционные материалы</desc>
      <weight>50</weight>
      <quantity>1</quantity>
      <price>10</price>
    </Item>
  </InventoryItems>
</SaveInfo>

Так что, если лень проходить игру, можно поколдовать с сохранениями и ускорить прогресс, или поставить оружие помощнее.

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

Итоги

С момента окончания работ над игрой прошло уже более полугода, и самое время подвести итоги.

Призового места я с этой игрой не занял, видимо не попал в тренд пиксельарт-адвенчур. Зато за время работы над игрой было получено огромное количество бесценного опыта и навыков, а шишки от настигнутых граблей еще долго будут мне напоминать, что некоторые вещи делать не стоит.

Выводы:

  • Объять необъятное конечно можно, но если у тебя сжатые сроки, то лучше не стоит, ничего хорошего не выйдет
  • Сюжет в игре — не самая последняя вещь, и над ним стоит поработать заранее
  • Лучше меньшее количество реализованных фич, чем множество глючных, до которых игрок не обязательно доберется
  • Глупые противники хороши для слешера, для игры с меньшим их количеством лучше сделать их поумней
  • Баланс тоже важен, и на него тоже стоит отвести время
  • Оптимизация — дело важное, и о ней стоит подумать заранее

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

Напоследок, хочу показать вам геймплейное видео из игры. По нему можно примерно оценить что получилось в итоге.

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

Ссылка на билд:
yadi.sk/d/j-v8WoyCiaNuQ

Если кому-нибудь будет интересно, могу залить проект целиком на GitHub.

Автор: TxN

Источник

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


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