Історія про розробку Космосима на Unity

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


Назва — та частина гри, робота над якою була відкладена на самий останній момент. У підсумку нічого путнього придумати, на жаль, не вдалося.

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

Для мінімізації необхідної кількості контенту було вирішено відмовитися від генеруються планетних систем, і від будь-якої масштабності в цілому. Вся гра відбувається в межах одного астероїдного поясу, поділеного на кілька секторів, кількість точок інтересу — мінімальне. Але таке рішення залишала час на інші аспекти гри, якщо заглибитися в опрацювання світу, на все інше часу не залишиться зовсім.


У перший же день народився начерк інтерфейсу і набір функцій, які реалізовувалися в наступні 14 днів.

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

Попереду було найважливіше — фічі.

Можливості
Дуже хотілося викластися по максимуму, і реалізувати якомога більшу кількість різноманітних фішок, багато з яких мали лише одну мету — тішити тим, що вони взагалі реалізовані в грі. Однією з таких фішок були самонавідні ракети. Здається, незважаючи на їх здатність підривати дрібних ворогів одним попаданням, ними ніхто із грали в гру так толком і не користувався. Разом з тим, часу на їх створення пішло пристойно.

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

  • Керований гравцем космічний корабель, з можливістю кастомізації озброєння
  • Кілька видів зброї, які можна підвішувати на різні точки підвіски
  • Можливість підбирати уламки ворогів і здавати їх на базах, отримуючи за це гроші
  • Кілька секторів, перехід між якими здійснюється гиперпрыжком
  • Керовані і некеровані види зброї
  • Кілька видів супротивників, які відрізняються характеристиками
  • Десяток-інший сюжетних квестів, мінімум дві кінцівки
  • Бос, і важливий елемент гри — сутичка з босом
Список вийшов досить великий, але так чи інакше, всі ці можливості вдалося реалізувати в грі з різним ступенем опрацьовування.

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


Примітивність і кубичность кораблів ворогів має під собою сюжетне обгрунтування.

І ось тепер, нарешті, можна було приступати до програмування.

Насамперед було вирішено реалізувати переміщення космічного корабля і камери.

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

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

З Камерою все вийшло теж досить просто. У лоб прив'язати камеру до корабля — погана ідея, тому камера і корабель не пов'язані, але для камери є цільова точка ззаду-зверху корабля, в яку вона прагне, і цільові кути повороту. Поточна та цільова точки інтерполюються, тим самим виходить ефект «плавної слідкуючої камери».

Щоб додати відчуття швидкості, з розгоном корабля поле зору камери трохи збільшується, при уповільненні відповідно сповільнюється.

Видно, як корабель неприємно смикається. Це було вилікувано включенням інтерполяції у налаштуваннях Rigidbody корабля.

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


Приблизно так це виглядає здалеку.

Для більшої «жвавості» і динамічності, крім великих астероїдів були додані невеликі, і «зоряний пил». Ці дрібниці вже не генерувалися по всій локації, а створювалися безпосередньо рухається навколо корабля.

Код генератора астероїдної дрібниці
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();
}



Один з перших скріншотів корабля з підвішеним зброєю

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

Самонавідна ракета для більшої точності розраховувала точку попередження, з припущенням, що мета буде рухатися в тому ж напрямку з тією ж швидкістю. Як правило, цього було достатньо, щоб ракета потрапляла в рухому ціль з досить високою ймовірністю.

Код скрипта ракети з наведенням
public class GuidedRocket : MonoBehaviour 
{
public float selfDestructTime = 5f;
public float safetyDelay = 0.5 f;
public float damageRadius = 6f;
public int damage = 120;
public float acceleration;
public float steerCoef = 0.09 f;
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
{

}

Кватерніонів targetRotation = Кватерніонів.LookRotation(dir) * Кватерніонів.Euler(fixAngle);
body.MoveRotation(Кватерніонів.Lerp(transform.rotation, targetRotation, steerCoef));
} 
}

body.velocity = Vector3.Lerp(body.velocity,fwdDir * startSpeed , 0.5 f);
}

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 на 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
 
}
 
}
 
}
 


Скріншот польотного інтерфейсу:


На скріншоті видно наполовину заповнений показник здоров'я в центрі екрану

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

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


Головний бос і зовсім був беззахисний, і покладався лише на величезну кількість своїх поплічників.

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

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


Редактор, мабуть краща частина системи квестів

Самі ж квести були зроблені префабами, які инстансились при активації квесту і видаляється після отримання нагороди. Це криво, але працювало. На систему квестів, написання діалогів і на самі квести пішло ще три дні. Термін підходив до кінця.

Інші дрібниці
Хочеться тут перелічити різні фішки, які гідні окремого розділу, але про яких, тим не менш хочеться розповісти.

Почну мабуть з ефекту гіперприжка. Як він виглядає, можна подивитися нижче.



Реалізований він дуже просто. Спочатку ми запускаємо звук, починаємо трясти камеру, показуємо поверх основної моделі трохи збільшену модель того ж корабля, але з текстурою «енергетичного поля», потім анимируем її з допомогою 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.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.