Как 3 года делать игру на одном энтузиазме: история Buran-19

Как 3 года делать игру на одном энтузиазме: история Buran-19

Виталий Соловьёв делится нюансами разработки игры Buran-19: переживания, трудности, код, картинки, цифры и «вотэтовсё»
32 минуты18738

В комментах к предыдущей статье я обещал после релиза игры написать её детальный разбор. C конца декабря 2020-го года Buran-19 доступна в Google Play (позже в Apple Store) — теперь официально закрываю гештальт и 236-й тикет. Кстати, на написание этой статьи в моём битбакете закрыт тикет #36.

Об игре

Buran-19 — моя экзистенциальная аллегория, казуальная инди-игра. Сначала игрок наслаждается графикой, музыкой, летает, собирает, совершенствует себя. Но, когда он достигает определённого уровня (19-й уровень — последний), игра меняет логику и начинает его мочить. Она делает это нещадно и в итоге добивается своего. Всегда.

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

Сюжет

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

Игра всячески мотивирует это делать: музыка, звуки, бряканье, тряска экрана, огромные цифры, таблички и тому подобное. Также за игроком охотятся аномалии. Сложность растёт в зависимости от уровня — нужно собирать всё больше космонавтов. 

Как ясно из названия, в игре 19 уровней, которые легко преодолеть. Но по их истечении меняется уровень сложности, игра переворачивается с ног на голову. Она становится агрессивной — включается Death mode. Меняется музыка, космос, начинают тикать часики, возможно, становится меньше ресурсов. В первый раз даже не совсем понятно, что происходит. 

Особенности

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

Инди с головы до ног

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

Атмосфера и эстетика

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

В игре гармонично сочетаются музыка и графика. Атмосфера передана максимально достоверно — прямо так, как было у меня в голове. 

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

Вот такой для эстетики и передачи атмосферы:

Дальше команда (художник, звукорежиссёр) тебя спрашивает, что чувствуешь, что видишь в этом. Какие-то одиночество, непоколебимость, пространство. Объясняешь, что тебе нужно. Например, говоришь звукорежиссёру, что нужна основная музыка, музыка в режиме паузы, в таком-то и таком-то стиле. Даёшь примеры. Буквально словами говоришь: «бдыж», «бдаамс». Или описываешь какую-то ситуацию, мол надо представить, как происходит вот это, с каким звуком бы это произошло. 

Потом работа над ошибками, прослушивание-переслушивание — где-то высокие частоты убрать, где-то уйти в глубину басами. Попробуйте, в общем, поиграть с наушниками. 

Death Mode

В режиме смерти на карте появляется намного больше объектов и они ведут себя иначе. У игрока есть 8 инструментов борьбы с ними. Например, оружие, которое позволяет очищать всю карту разом. Всё лопается, вжикает. Это вызывает приятные ощущения очищения, лёгкости, почти катарсиса. Залипательно. Помните, как в игре, где собираешь в ряд одинаковые шарики и они взрываются, и затем выкатываются новые? Это очень похоже.

Этот режим появился в самом конце разработки. Буквально за месяц до конца проекта. Он пришёл спонтанно, вместе с новым названием (до этого было выбрано название Shuttle). Я читал несколько книг о том, как правильно делать игры, и какие 100 правил стоит соблюдать при разработке, чтобы игра была годной. 

Одно из правил — фан, или получение удовольствия. Если игрок не получает фан — игра скучная. Фан получать сложно, и должна быть какая-то изюминка. И здесь она в том, что сначала всё просто, а потом — всё плохо.

Child mode

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

Возможность создания профайлов

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

Кому игра может зайти

Я не занимался исследованием целевой аудитории и не играл в игры последние 7 лет. Нонсенс. По результату опросника IARC, знаю, что Buran-19 рассчитана на детей от трёх лет. И надеюсь, что она обладает достаточным уровнем эстетики, чтобы понравиться и взрослым.

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

  • Homeworld — тактическая космическая стратегия, в которой ведутся боевые действия. Я в неё, наверное, играл лет в 11. Она меня настолько впечатлила, что, можно сказать, «inspired me». Это было как раз то космические, что я никогда больше в жизни-то и не видел. Я до сих пор помню её!
  • Limbo —  survival horror, где мальчик в мрачной атмосфере пытается найти сестру. Она меня поразила своей механикой и эстетикой, вдохновила меня тем, что использует просто движок Box2D, и на libGDX можно сделать более-менее то же самое.
  • DISTRANT — бродилка в жанре психологического хоррора, где ты решаешь пазлы. Всё сделано в пиксель-арте. Её создал один финн вместе со своей женой. Меня очень воодушевило, что у этой не самой сложной игры на libGDX (если я не ошибся) миллион скачиваний.

Так что, если вам нравится что-то из этих трёх игр, вы любите мелкие инди или продукцию компании Playdead, то вам может зайти и моя игра.

Этапы разработки

Есть два пути в разработке: «беру и делаю» и условно правильный. Сначала я пошёл по первому.

Мой опыт «беру и делаю»

Всё началось с того, что на учебном модуле по разработке игр на Android преподаватель Алексей Кутепов предложил делать аркаду по типу арканоида. Там в нижней части экрана кораблик, на него сверху летит космос, появляются враги и ты стреляешь в них пульками. На этом примере Алексей хотел показать, как изнутри устроены игры и познакомить нас с опенсорсным Java-фреймворком libGDX.

Тогда я только примерно понимал, как делаются игры. Знал, что есть программисты и художники. Есть мультипликация, сменяющиеся кадры. Из-за оптических обманов и несовершенства глаза мы воспринимаем более 24 кадров в минуту как интегральную величину и видим движение. Мне было интересно разобраться, как это всё программировать — курс оказался в нужное время и в нужном месте.

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

В классических арканоидах нужно выжить максимальное количество времени, ускоряясь и набирая очки. У моего же кораблика появилась дополнительная цель — помогать космонавтам, летающим в открытом космосе. Тогда это показалось мне чумовой идей. Я нашёл примерные спрайты в интернете: космонавтов, кораблик, аномалии. И начал писать. События развивались, и игра преобразовывалась. Забавно смотреть, какие изменения были в течение времени.

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

Определение коллизий. Коллизия — столкновение предметов и производство результата столкновения. Там возможна факториальная сложность, потому что каждый объект может взаимодействовать с каждым. Классический пример, для понимания детекции коллизий — заставка на Windows с пузырями, которые меняют цвет от столкновений. Когда есть 1000 пузырей, которые могут взаимодействовать друг с другом, то, по сути, нужно каждый из них проверить на то, а нет ли сейчас взаимодействия с другими 999 пузырями. Для этого есть разные алгоритмы, с которыми мы не знакомились. Наш детектор коллизий — это бесконечный цикл fori по всем объектам :) 

Asset-менеджеры. Есть целые классы, которые позволяют работать с ассетами: звуками, картинками, фонтами. В игре выходит, что всегда виртуально рисуется одна здоровенная картинка, обрезанная в нужных местах. Эту картинку — TextureAtlas, надо формировать из отдельных спрайтов. И тогда в графическом процессоре нет бесконечно переключения контекста, игра не тупит, ресурсы не тратятся. Как это организовать — задача интересная, особенно для 3D игр. В 2D достаточно соблюдать некоторые базовые принципы и их потом проверить в игровом режиме.

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

Обучаясь, я накидывал всё, что в голову взбредёт. Почувствовал на себе минусы разработки без фиксации фич:

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

Мой опыт «как правильно»

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

Как закончился модуль, я уже разрабатывал игру с Сашей Фисуновым. Мы накидывали фичи, в телеге переписывались, мол было бы хорошо добавить ещё и ещё. Потом Саша в один момент сказал: «Cтоп, давай напишем доку». 

Первая документация, когда игра ещё называлась Shuttle, заняла 29 страниц. Накидали за вечер: замечания, игра, игрок, монстры, препятствия, список фичей, дизайн, GUI. Кому интересно — документ можно глянуть. Только он не поддерживался в актуальном состоянии, там первоначальные мысли. 

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

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

Последний диздок, например — астронавты и баффы астронавтов. Что такое астронавт, что в нём должно быть? Их профессии, как они выглядят, что они делают, как взаимодействуют друг с другом, какая логика работы? Потом это всё рождает конкретные техзадания для программиста — допустим, таблицу, которую нужно в программе имплементировать в виде кода или некоторой логики.

Ещё доказательство, что «бюрократия» таки важна

«О, давайте сделаем игру, будет круто!» — не работает. Во время модуля по разработке игр мы группой думали создать свой проект. Собрались всемером в отдельном чатике под названием Project Janus и решили делать игру. 

Было 7 идей для голосования. Победила игра вроде популярной сетевой agar.io, где бактерия собирает другие бактерии и растёт. Решили делать подобную, но с атомом, который присоединяет другие атомы и вырастает до Вселенной. Но мы спеклись — все без опыта, никто не умеет работать в команде. И не было драйвера — только идея. Я предлагал сделать свой «кораблик», но никто не согласился. 

Как сделаю в следующий раз

Если ещё столкнусь с подобным проектом, то сразу буду делать правильно. На мой взгляд, этапы разработки должны быть такие:

  1. Анализ рынка и идея. Что первично, наверное, зависит от типа игры. Если это коммерческая движуха, то сначала маркетинговое исследование, а потом идея. 
  2. Анализ ресурсов. Теперь нужно обсудить плюсы, минусы, корнер-кейсы, сложности идеи и понять сроки реализации в зависимости от ресурсов. Проанализировать себя или команду. Готовы ли все, например, год по 5 часов в день хреначить. Если готовы — идём.
  3. Написать документацию и зафиксировать план работ. Для игр — это высокоуровневый документ, где отражаются основные концептуальные вещи: количество уровней и графических элементов, модули и компоненты, как это будет изображено в виде окон, какие будут элементы управления, персонажи, как они будут с друг другом взаимодействовать, какие будут эффекты, когда они будут появляться. Это нужно, чтобы задачу потом декомпозировать и покрывать документацией каждую маленькую её часть. Без документации непонятно, что делать и с чего начинать.
  4. Разделение на команды.
  5. Реализация и тестирование.

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

Использованные технологии

Java. Почему? Потому что всё началось с моего обучения на факультете Java.

libGDX. Почему? Там находишься недалеко от железа. Изучать нюансы важно не на программе, которая позволяет накидывать высокоуровневые объекты и быстро что-то создавать. Чтобы глубоко разобраться, нужно начинать с самого дна. Может, даже стоит прочитать книгу по OpenGL и понять, как рисуются все эти трапеции, треугольники. Как там происходит с точки зрения математики. OpenGL — чистая математика и работа с полигонами на низком уровне. Хотя для прототипирования такой метод уже не подойдёт. Времени уходит жуть как много.

libGDX — первая прослойка после самого низкого уровня. В нём, конечно, есть классы, которые позволяют инкапсулировать взаимодействие с OpenGL и помогают не задумываться о том, как всё рисуется (есть batcher-классы). Ты, грубо говоря, через классы имеешь доступ ко всем нужным функциям, и у тебя есть различные утильные классы, которые математику считают, рисуют фигуры, ты сразу можешь пользоваться текстурами, как объектом в Java, ну и так далее. Не надо задумываться, что происходит ниже. А если хочешь — идёшь и читаешь код. И это самое ценное.

Но это не фреймворк, в котором ты накидываешь объекты на графическую среду, и не движок, и даже не среда разработки. Движок туда подключается (например, Box2D), но суть в том, что, разрабатывая игру, ты понимаешь, как оно устроено. Есть твой мир с игровыми координатами, в которых ты работаешь. Ты понимаешь, что объект — это картинка, которая помещена в определённой области экрана и может двигаться. Описываешь законы движения.

Особенность libGDX в том, что ты не можешь, как в Unity, использовать готовую среду, куда ты накидываешь ассеты, прописываешь взаимодействия. В libGDX есть зародышевые вещи для отрисовки интерфейсов. Это называется скины (Skin). Есть классы, которые работают со скинами. Есть небольшие утилиты вдобавок к libGDX. SkinComposer, в которой можно набрасывать свои картинки, помогают формировать окошки, полосы прокрутки, всякие чекбоксы и так далее — одна из таких вспомогательных утилит.

Также libGDX — кроссплатформенный фреймворк. Можно писать на Java и запускать продукт на десктопе, на Android, iOS, в браузере. Всё должно работать из коробочки.

Интересные (?) решения

Интересных решений немного, наверное, они больше интересны для меня, потому что я не знал, как это всё делается. В целом мне очень помогли общие подходы в программировании: что надо задачу декомпозировать, использовать шаблоны проектирования, шаблоны программирования, соображать, как работает Java.

Своя система частиц

Система частиц — инструмент, который позволяет делать эффекты. Например, взрыв интересной конфигурации, огонь, какие-то метаморфозы. Как это работает? У тебя в основании графики лежит частица — particle. И суть в том, что она рисуется бесконечное число раз и ты создаёшь законы, по которым её нужно рисовать.

Например, взрыв — одна частица, которой ты говоришь: «Нарисуйся в центре плотно, а потом отходи лучами или по уравнению кривых линий на рандомную величину отступа». Визуально это получается круто. И в итоге тебе в ассетах нужно иметь только одну частицу 16 на 16 пикселей. Либо несколько частиц. С помощью её бесконечного рисования получается красивый эффект. Вот популярный пример из интернета:

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

Вот решения моих задач с помощью системы частиц.

Лучи захвата и лазеры

Эффекты уничтожения аномалий

Огонь из турбин

Кстати, звёздное небо и все пункты выше — это всё одна и та же частица 16х16. И это прекрасно.

Реализация системы частиц состоит из двух классов: сама система, которая беспокоится об объектах, и билдер, который описывает законы рисования.

ParticleEmitter.java

package com.buran.game.effects.particle;
 
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.buran.game.conf.PaintConstants;
import com.buran.game.utils.Assets;
import com.buran.game.utils.pool.AbstractPoolable;
import com.buran.game.utils.pool.ObjectPool;
 
import lombok.Getter;
import lombok.Setter;
 
 
/**
* Created by FlameXander on 02/07/2017.
*/
 
public class ParticleEmitter extends ObjectPool<ParticleEmitter.Particle> {
   private TextureRegion particleTexture;
   private ParticleEffectBuilder builder;
   private Vector2 tmp;
 
   public ParticleEmitter() {
       this(Particle.class);
       this.particleTexture = Assets.getInstance().getAtlas().findRegion(PaintConstants.STAR16_REGION);
       this.builder = new ParticleEffectBuilder(this);
       this.tmp = new Vector2(0, 0);
   }
 
   private ParticleEmitter(Class<? extends Particle> clazz) {
       super(clazz);
   }
 
   @Override
   protected Particle newObject() {
       return new Particle();
   }
 
   public void render(SpriteBatch batch) {
       batch.setBlendFunction(GL20.GL_SRC_ALPHA, GL20.GL_ONE);
       for (int i = 0; i < activeList.size(); i++) {
           Particle particle = activeList.get(i);
           batch.setColor(particle.getCurrentR(), particle.getCurrentG(), particle.getCurrentB(), particle.getCurrentA());
           activeList.get(i).render(batch);
       }
       batch.setBlendFunction(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA);
       batch.setColor(1.0f, 1.0f, 1.0f, 1.0f);
   }
 
   public void setup(float x, float y, float vx, float vy, float duration, float startSize, float endSize, float startR, float startG, float startB, float startA, float endR, float endG, float endB, float endA) {
       getActiveElement().init(x, y, vx, vy, duration, startSize, endSize, startR, startG, startB, startA, startR, startG, startB, startA);
   }
 
   public void setupFromPointToPoint(float x1, float y1, float x2, float y2, float size1, float size2, float r1, float g1, float b1, float a1, float r2, float g2, float b2, float a2) {
       float dst = Vector2.dst(x1, y1, x2, y2) / 10.0f;
       tmp.set(x2, y2);
       tmp.sub(x1, y1);
       tmp.nor();
       tmp.scl(10.0f);
       for (int i = 0; i < (int) dst; i++) {
           float ox = x1 + i * tmp.x;
           float oy = y1 + i * tmp.y;
           getActiveElement().init(ox + MathUtils.random(-10, 10), oy + MathUtils.random(-10, 10), 0, 0, 0.1f, size1, size2, r1, g1, b1, a1, r2, g2, b2, a2);
       }
   }
 
   public void update(float dt) {
       for (int i = 0; i < activeList.size(); i++) {
           activeList.get(i).update(dt);
       }
       checkPool();
   }
 
   public ParticleEffectBuilder getBuilder() {
       return builder;
   }
 
   public class Particle extends AbstractPoolable {
       private float time, duration;
       private float startSize, endSize;
       @Getter
       @Setter
       private float currentSize;
       private float startR, startG, startB, startA;
       @Getter
       @Setter
       private float currentR, currentG, currentB, currentA;
       private float endR, endG, endB, endA;
 
       public Particle() {
           this.position = new Vector2(0, 0);
           this.velocity = new Vector2(0, 0);
           this.startSize = 1.0f;
           this.endSize = 1.0f;
       }
 
       public void init(float x, float y, float vx, float vy, float duration, float startSize, float endSize, float startR, float startG, float startB, float startA, float endR, float endG, float endB, float endA) {
           init(x, y);
           this.velocity.x = vx;
           this.velocity.y = vy;
           this.startR = startR;
           this.startG = startG;
           this.startB = startB;
           this.startA = startA;
           this.endR = endR;
           this.endG = endG;
           this.endB = endB;
           this.endA = endA;
           this.time = 0.0f;
           this.duration = duration;
           this.startSize = startSize;
           this.endSize = endSize;
           this.active = true;
           calculateCurrentValues();
       }
 
       @Override
       public void init(float x, float y) {
           this.position.x = x;
           this.position.y = y;
       }
 
       private void calculateCurrentValues() {
           float percentage = time / duration;
           currentSize = MathUtils.lerp(startSize, endSize, percentage);
           currentR = MathUtils.lerp(startR, endR, percentage);
           currentG = MathUtils.lerp(startG, endG, percentage);
           currentB = MathUtils.lerp(startB, endB, percentage);
           currentA = MathUtils.lerp(startA, endA, percentage);
       }
 
       @Override
       public void basicUpdate(float dt) {
           time += dt;
           calculateCurrentValues();
           position.mulAdd(velocity, dt);
           if (time > duration) {
               destroy();
           }
       }
 
       @Override
       public void update(float dt) {
           basicUpdate(dt);
       }
 
       @Override
       public void destroy() {
           active = false;
       }
 
       @Override
       public Vector2 getPosition() {
           return position;
       }
 
       @Override
       public boolean isActive() {
           return active;
       }
 
       @Override
       public void render(SpriteBatch batch) {
           basicRender(batch);
       }
 
       @Override
       public void basicRender(SpriteBatch batch) {
           batch.draw(
                   particleTexture,
                   this.getPosition().x - 8, this.getPosition().y - 8,
                   8, 8,
                   16, 16,
                   this.getCurrentSize(), this.getCurrentSize(),
                   0
           );
       }
   }
}

ParticleEffectBuilder.java

package com.buran.game.effects.particle;
 
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
 
/**
* @author soloyes on 30/07/18.
*/
 
public class ParticleEffectBuilder {
   private ParticleEmitter particleEmitter;
 
   public ParticleEffectBuilder(ParticleEmitter particleEmitter) {
       this.particleEmitter = particleEmitter;
   }
 
   public void buildDust(float x, float y, float dxy, int count) {
       for (int j = 0; j < count; j++) {
           particleEmitter.setup(x, y, MathUtils.random(-dxy, dxy), MathUtils.random(-dxy, dxy), 1f, 6.0f, 1.0f, 0.17f, 0.17f, 0.17f, 1, 0, 0, 0, 0);
       }
   }
 
   public void buildBuranEngineFire(Vector2 position, float angle, float radius, float level, float factor) {
       particleEmitter.setup(position.x - radius * (float) Math.cos(Math.toRadians(angle)), position.y - radius * (float) Math.sin(Math.toRadians(angle)), MathUtils.random(-90, 90), MathUtils.random(-90, 90), 0.3f + level * 0.1f, 4.0f + level * 1.5f, 0.4f, 1, 0.6f - level * 0.15f, 0, 1, 1, 0, 0, 0.5f);
       if (MathUtils.random(0, 100) < /*FPS hack*/20 / factor/*FPS hack*/) {
           for (int i = 0; i < level + 1; i++) {
               particleEmitter.setup(position.x - radius * (float) Math.cos(Math.toRadians(angle)), position.y - radius * (float) Math.sin(Math.toRadians(angle)), MathUtils.random(-180, 180), MathUtils.random(-180, 180), 0.3f, 8.0f + level * 2, 0.0f, 1, 0.4f, 0, 1, 0, 0, 0, 0.2f);
           }
       }
 
   }
 
   public void buildBuranFrontalTurboFire(Vector2 position, float angle, float radius, float randomPositionNoise) {
       particleEmitter.setup(position.x + radius * (float) Math.cos(Math.toRadians(angle)), position.y + radius * (float) Math.sin(Math.toRadians(angle)), MathUtils.random(-randomPositionNoise, randomPositionNoise), MathUtils.random(-randomPositionNoise, randomPositionNoise), 5.9f, 16.0f, 15.0f, 1, 0.6f, 0, 1, 0, 0, 0, 0.0f);
   }
 
   public void buildBuranLowLevelEngineFire(Vector2 position, float angle, float radius) {
       particleEmitter.setup(position.x - radius * (float) Math.cos(Math.toRadians(angle)), position.y - radius * (float) Math.sin(Math.toRadians(angle)), MathUtils.random(-90, 90), MathUtils.random(-90, 90), 0.2f, 3.0f, 2.4f, 0.2f, 0.2f, 1.0f, 0.4f, 0, 0, 0, 0.2f);
   }
 
   public void buildBuranShield(Vector2 position, float angle, float radius, int halfConeAngle, float r, float g, float b) {
       for (int i = -halfConeAngle; i <= halfConeAngle; i += 10) {
           particleEmitter.setup(position.x + radius * (float) Math.cos(Math.toRadians(angle + i)), position.y + radius * (float) Math.sin(Math.toRadians(angle + i)), MathUtils.random(-5, 5), MathUtils.random(-5, 5), 0.15f, 3.0f, 4.0f, r, g, b, 0.1f, 0, 0, 0, 0.0f);
       }
   }
 
   public void buildFuelDestroyFlame(Vector2 position) {
       for (int j = 0; j < 20; j++) {
           particleEmitter.setup(
                   position.x, position.y,
                   MathUtils.random(-70, 70), MathUtils.random(-100, 100), 3f, 10, 0,
                   0.4f, 0.4f, 0.4f, 0.2f,
                   0.4f, 0.4f, 0.4f, 0.0f
           );
           particleEmitter.setup(
                   position.x, position.y,
                   MathUtils.random(-60, 60), MathUtils.random(-100, 100), 2f, 10, 0,
                   1.0f, 0.4f, 0.0f, 0.7f,
                   1.0f, 0.0f, 0.0f, 0.0f
           );
       }
   }
}

Решения с эффектами

У меня есть отдельный пакет с эффектами — для работы со звуком, текстом, движением (объекты по-разному ведут себя). Есть утильный класс осцилляторов, которые моргают, меняют пропорцию или размеры. 

Есть классы, которые обходят ограничения фреймворка. Допустим, когда собираешь объекты, например, осколки от астероидов, то если берёшь сразу десяток, то получается неприятный звук — всё сливается воедино. Мне пришлось делать классы, которые отслеживают количество собранных объектов и вносят небольшую задержку, чтобы как-то гранулировать звук, который воспроизводит программа.

PrettySound.java

package com.buran.game.effects;
 
import java.util.LinkedList;
 
/**
* @author soloyes on 11/7/18.
*/
 
public class PrettySound {
   private static LinkedList<PrettySound> list = new LinkedList<PrettySound>();
   private BoomBox boomBox;
   private boolean flag;
   private float dt;
   private float delay;
 
   private PrettySound(float delay) {
       this.boomBox = new BoomBox();
       this.delay = delay;
   }
 
   public static PrettySound getPretty(float delay) {
       PrettySound p = new PrettySound(delay);
       list.add(p);
       return p;
   }
 
   public void playSound(String sound) {
       if (flag) {
           boomBox.playSound(sound);
           flag = false;
       }
   }
 
   public static void update(float dt) {
       for (int i = 0; i < list.size(); i++) {
           list.get(i).dt += dt;
           if (list.get(i).dt >= list.get(i).delay) {
               list.get(i).dt = 0.0f;
               list.get(i).flag = true;
           }
       }
   }
}

Есть классы, которые занимаются тряской экрана, и это работа с системой координат OpenGL. Есть классы, которые отвечают за вибрацию телефона.

Использование MVC шаблона проектирования при разработке UI

Я использовал Model-View-Controller, который полностью соответствуют подходам в веб-разработке, при разработке почти всего GUI и для сохранения и работы с результатами и конфигурациями.

У меня все конфигурации и результаты хранятся в JSON, соответственно мои дата-классы работают с JSON сторонними библиотеками. Я использовал популярный Jackson.

Вот как я храню профили и результаты:

[
 {
   "type": "PROFILE",
   "username": "Player",
   "preferences": {
     "notification": true,
     "soundVolume": 1.0,
     "effects": true,
     "effectsVolume": 0.5,
     "music": true,
     "musicVolume": 0.5,
     "shader": true,
     "child": false,
     "level": 2,
     "vibration": 2,
     "joystick": false,
     "joystick_mode": 0,
     "joystickColor": true,
     "runTimes": 26
   },
   "active": false,
   "id": "acf54856-76d7-49da-ab10-c2224fc872de",
   "resultSet": [
     {
       "date": 1607356827393,
       "level": "LOW",
       "current": {
         "effectiveness": 12,
         "score": 0,
         "time": 2,
         "deathModeTime": 0,
         "dmgDone": 0,
         "dmgReceived": 0,
         "enemiesDestroyed": 0,
         "minedAsteroids": 0,
         "pickUpItems": 0,
         "usedItems": 0,
         "astronautsTaken": 0,
         "levelAchieved": 0
       },
       "previous": {
         "effectiveness": 12,
         "score": 0,
         "time": 1,
         "deathModeTime": 0,
         "dmgDone": 0,
         "dmgReceived": 0,
         "enemiesDestroyed": 0,
         "minedAsteroids": 0,
         "pickUpItems": 0,
         "usedItems": 0,
         "astronautsTaken": 0,
         "levelAchieved": 0
       },
       "child": false
     },
     {
       "date": 1607338683914,
       "level": "DEBUG",
       "current": {
         "effectiveness": 28,
         "score": -51930,
         "time": 52,
         "deathModeTime": 0,
         "dmgDone": 18,
         "dmgReceived": 31,
         "enemiesDestroyed": 9,
         "minedAsteroids": 31,
         "pickUpItems": 10,
         "usedItems": 1,
         "astronautsTaken": 7,
         "levelAchieved": 8
       },
       "previous": {
         "effectiveness": 44,
         "score": 792201,
         "time": 304,
         "deathModeTime": 0,
         "dmgDone": 190,
         "dmgReceived": 303,
         "enemiesDestroyed": 123,
         "minedAsteroids": 240,
         "pickUpItems": 92,
         "usedItems": 30,
         "astronautsTaken": 52,
         "levelAchieved": 11
       },
       "child": false
     },
     {
       "date": 1607431352616,
       "level": "LOW",
       "current": {
         "effectiveness": 18,
         "score": 33000,
         "time": 22,
         "deathModeTime": 0,
         "dmgDone": 0,
         "dmgReceived": 0,
         "enemiesDestroyed": 0,
         "minedAsteroids": 0,
         "pickUpItems": 4,
         "usedItems": 0,
         "astronautsTaken": 1,
         "levelAchieved": 2
       },
       "previous": {
         "effectiveness": 12,
         "score": 0,
         "time": 2,
         "deathModeTime": 0,
         "dmgDone": 0,
         "dmgReceived": 0,
         "enemiesDestroyed": 0,
         "minedAsteroids": 0,
         "pickUpItems": 0,
         "usedItems": 0,
         "astronautsTaken": 0,
         "levelAchieved": 0
       },
       "child": false
     },
     {
       "date": 1607432290748,
       "level": "HIGH",
       "current": {
         "effectiveness": 44,
         "score": 208000,
         "time": 70,
         "deathModeTime": 0,
         "dmgDone": 0,
         "dmgReceived": 33,
         "enemiesDestroyed": 29,
         "minedAsteroids": 29,
         "pickUpItems": 13,
         "usedItems": 8,
         "astronautsTaken": 5,
         "levelAchieved": 6
       },
       "previous": {
         "effectiveness": 12,
         "score": 0,
         "time": 3,
         "deathModeTime": 0,
         "dmgDone": 0,
         "dmgReceived": 0,
         "enemiesDestroyed": 0,
         "minedAsteroids": 0,
         "pickUpItems": 0,
         "usedItems": 0,
         "astronautsTaken": 0,
         "levelAchieved": 0
       },
       "child": false
     }
   ]
 },
 {
   "type": "PROFILE",
   "username": "Kid",
   "preferences": {
     "notification": true,
     "soundVolume": 1.0,
     "effects": true,
     "effectsVolume": 0.5,
     "music": true,
     "musicVolume": 0.5,
     "shader": true,
     "child": true,
     "level": 1,
     "vibration": 2,
     "joystick": false,
     "joystick_mode": 0,
     "joystickColor": true,
     "runTimes": 0
   },
   "active": true,
   "id": "7cefba17-9b7a-40f6-b19b-141f73896809",
   "resultSet": []
 },
 {
   "type": "DUMMY",
   "username": "",
   "preferences": null,
   "active": false,
   "id": "4a2d3451-7439-4d34-8edb-67cdb83342bb",
   "resultSet": []
 },
 {
   "type": "DUMMY",
   "username": "",
   "preferences": null,
   "active": false,
   "id": "9416e04a-a0a0-4a2c-811a-3ed99aea0ab4",
   "resultSet": []
 },
 {
   "type": "DUMMY",
   "username": "",
   "preferences": null,
   "active": false,
   "id": "3ebedba2-746d-447a-98ee-7d64bba2e9f3",
   "resultSet": []
 }
]

Торможение кораблика по золотому сечению

Сначала я корпел над изображением ускорения корабля. Там есть специальный объект, ты нажимаешь, и он начинает дико разгоняться — из турбин валит пламя. Но ускорение — полбеды, ещё надо показать торможение. А как? Показать шлейф от кораблика в космосе. 

Я долго думал, а потом вспомнил про принципы золотого сечения. Используя спрайты кораблика, которые блюрятся в различную степень, в зависимости от удаления от настоящего объекта корабля, сделал так, чтобы каждый след в шлейфе отставал от предыдущего на число пикселей, которое равно числу в ряде Фибоначчи. Вот как это выглядит визуально:

А вот как это реализовано. Здесь вычисляются положения теней, углы, альфа-каналы для каждой последующей тени (они исчезают шлейфом):

private void makeShadow(float dt) {
   float v = velocity.len() - Buffs.getPlayerEnginePower();
   if (v > 10) {
       if (v <= shadowsCoordinates.getLast()) {
           if (shadowsCoordinates.size() > 1) {
               shadowsCoordinates.removeLast();
           }
           Vector2 tmp = position.cpy();
           tmp.set(
                   tmp.x - (float) Math.cos(Math.toRadians(angle)) - width / 2,
                   tmp.y - (float) Math.sin(Math.toRadians(angle)) - height / 2
           );
           vector2List.add(tmp);
           angleList.add(angle);
           int i = vector2List.size() - 1;
           alphaList.add(1.0f - (0.7f / (i / 2 + 1)));
       }
   } else {
       for (int i = 0; i < alphaList.size(); i++) {
           float t = alphaList.get(i) - dt;
           if (t <= 0.0f) {
               t = 0.0f;
           }
           alphaList.set(i, t);
           if (alphaList.get(alphaList.size() - 1) == 0.0f) {
               vector2List.clear();
               angleList.clear();
               alphaList.clear();
               setShadowsCoordinates();
           }
       }
   }
}

Конечно, есть ещё методы, которые считают всякое вспомогательное. Их сюда не вставлял, но идея, думаю, понятна.

Борьба с вибрацией

Недавно нашёл неприятную вещь. Когда играешь в Death mode и уничтожаешь более 30 объектов, то на каждое уничтожение телефон вибрирует. Вибрация и обращение libGDX к библиотеке, которая отвечает за вибрацию на телефоне, занимает время. Когда говоришь телефону «возьми в одну секунду повибрируй 30 раз 20 миллисекунд», моя Motorola уходит в раздумья. На 10 миллисекунд задерживает геймплей и сразу следующий кадр показывает. Графический процессор всё проиграл, но из буфера кадра ничего не предоставил на экран. Короче, артефакты возникают, когда появляется много объектов.

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

Такие решения принимаются, когда чуть поиграл и понял, что что-то не нравится. Я предположил, что игрок должен иметь возможность сделать себе удобно сам. Есть телефоны, которые не реагируют на вызов вибрации ниже 30 миллисекунд. Просто вибратор внутри не может меньше чем 50 миллисекунд работать. И я тоже пытался усреднять эти значения.

Эти вещи невозможно понять без 10 телефонов и компании, что тестит игру. Так что я решил это по-своему.

Шейдеры

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

У меня есть шейдер, который делает экран серым во время паузы. Здесь всё просто — говоришь ему «возьми всё из буфера кадра и умножь на такие-то величины, чтобы цвет стал оттенком серого». При умножении любого цвета на коэффициент ты получаешь его в оттенках серого. Этот шейдер это и делает. Была ещё идея фон замылить — попробовали, получилось так себе. 

Есть шейдер, который рисует чёрную дыру. Если в конфигурациях шейдеры отключить, то чёрная дыра — просто спрайт, спиралька с чёрным центром. Когда шейдеры включены, то спиралька не видна, но есть реальная имитация искажения пространства, линзирование. Аномалии, объекты, попадая в неё, искажаются, как в воде. Сначала Саша Фисунов сделал прототип, а потом мы добавили туда разноуровневый блюр и исправили ошибки.

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

Настройка игрового баланса

Игровой баланс — набор параметров, которые помогают игроку испытывать удовольствие. Игровой баланс обычно делается в виде таблицы с формулами, в зависимости от которых выставляются параметры в игре. И в идеале в таблице нужно менять одну цифру и она рассчитывает всё остальное. Баланс может быть статический — как у меня, и динамический — обычно используется в командных играх, сетевых.

У меня несколько статических классов. Есть пакет configs, в нём класс Rules.java и в нём 418 строчек, из них 326 — конфигурации. Есть ещё отдельные классы для конфигурации звуков, картинок, вибрации телефона, цветов, файлов, куда складываются профайлы и результаты.

Есть два класса — Buffs.java и DeathMode.java, которые меняют начальные конфигурации игры. Эти два класса с течением времени меняют начальное состояние игры. То есть скорость аномалий, их сила, их прожорливость меняется при наступлении определённых триггеров.

В балансе отражено всё: количество аномалий, время их респауна, максимальное количество, количество на старте. И всё это есть по всем типам генерируемых объектов. Есть описания игрока, его скорость, описание способностей подбирать лут, радиусы подбора, его максимальная скорость, скорость при ускорении, баффы по очкам здоровья, броне и лазерам. 

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

Если брать именно конфигурации игры, звука, вибрации, картинок и изменения игры при переходе из одного режима в другой, то получается около 500 параметров.

Как я настраивал баланс

Чтобы не задерживать игрока слишком долго, я придумал искусственное ограничение — play session time. Это примерно 20 минут — стандартная поездка в метро. Исходя из этого я должен подавать игроку различное количество объектов, чтобы он успел поиграть за 20 минут от начала и до конца.

Конечно, если не собирать космонавтов, то будешь играть и 100 минут, и 200, пока не умрёшь сам: будешь с астероидами сталкиваться, попадёшь в чёрную дыру, или тебя просто аномалии замочат. Можно играть долго, но я попытался сделать так, чтобы игроку стало понятно, что круто дойти до 19 уровня как можно скорее. И здесь скорее выбор за игроком: заниматься исследованием мира, или добиться результатов.

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

У меня предусмотрено три режима игры — low, middle, high. В зависимости от этого меняется разрешение экрана и «площадь космоса». 

Если рассмотреть, например, вырожденный случай, то чтобы собрать 95 космонавтов в уровне low нужно пролететь 4 экрана вниз и 4 экрана вверх. На это потребуется 12 минут, потому что респаун космонавтов так настроен. Чтобы это время уменьшить я изменяю количество очков за одного космонавта — это раз, какой-то базовый набор пунктов в формуле — два, время респауна космонавтов — три. 

Дальше я могу делать нормализацию: не будет же космонавт всегда появляться в самой крайней нижней точке, и в самой крайней верхней. У меня есть случайное распределение, и я примерно знаю, что космонавт появляется в одном из 16 участков космоса такое-то количество раз. Соответственно я в уме делю 20 минут на 4 (цифры сейчас беру из головы, всё немного сложнее) и понимаю, что в нормальных условиях игрок будет летать не 20 минут, а 5 минут. Это время до перехода в режим Death mode, где он играет примерно столько же, сколько в обычном режиме. Дальше игра его убивает.

Мой принцип балансировки такой — чтобы игрок быстро заработал очки и достиг наивысшего уровня, а потом я его перевожу в другой режим. Всё это должно уложиться максимум в 20 минут. Всё гораздо сложнее, но работает это примерно так.

В итоге у меня есть 2 цифры: play session time — это количество времени, которое игрок потратит, чтобы полностью закончить игру в двух режимах. А с другой стороны — базовый набор очков, которые ему даёт игра. Меняя эти 2 цифры, я могу полностью изменить ход игры. Конечно, надо руками всё остальные параметры подправить.

Плейтестинг

Тут просто надо смотреть, как играют другие люди. Я заметил, как писал выше, что детям заходит графика, сенсорика, без уклона в сюжет. Но взрослые уже настраивают геймплей под себя. Задают вопросы. Здесь важно посмотреть, есть ли удовольствие, трудно ли начать играть, трудно ли дойти до конца игры. И в этот момент ты видишь всякие баги. Баги, баги, баги. После плейтестинга я дописал порядка 300-400 строк кода, учёл некоторые пожелания игроков. 

Я понял, что игрока надо учить играть, без этого никуда. Жалею, что не успел сделать адекватную обучалку — это может стать основной трудностью у игрока. С другой стороны, интерфейс игры позволяет ей наслаждаться и без знания нюансов. Поэтому здесь, скорее всего, будет поле для манёвров и задачи на будущие проекты. 

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

Вот некоторые примеры того, что пришлось исправить и доделать:

  • Написать звуки на «keep going» на каждый важный уровень.
  • Космос всё равно вылез на планшете — видимо, спрайт не цикличен.
  • Звук лазера не выключился в один из моментов игры.
  • Подтверждение при выходе — новое меню.
  • Эффективность n/100, но не непонятное число.
  • DeathMode в одну строчку, а не в две — не читабельно.
  • Изменить стратегию сохранения профиля: каждый символ — добавить описание.
  • Больше больших букв и поощрений.
  • Больше ракет.
  • Больше оружия, чтобы брать и больше мочить. Оружия игрок должен получить вдоволь.
  • Баг с джойстиком.
  • Баг с кругляшом очков.

Было ещё несколько пожеланий: различные сортировки, возможности быстро работать с инвентарём. Но это повлекло бы существенные изменения в коде, а на новые тесты времени уже не оставалось. И как я уже говорил, надо фиксировать фичи, и выпускать проект в релиз.

Трудности

Отсутствие знаний

Продолжительное время не было старшего программиста. Он обычно говорит как правильно. Изредка приходил Саша Фисунов и помогал. Особенно вначале, когда я не мог быстро разрабатывать, его способности очень поддерживали и мотивировали. Я пишу за вечер 200-300 строчек кода, Саша — 1000 за присест.

Мотивация команды и общение с художником

Мы все работали на добровольных началах — никто никому денег не платил. Может быть, поэтому возникали трудности с мотивацией, организацией процесса. Например, художник часто уходил обучаться и меня это сильно дизморалило, но я заботливо ждал. Я хотел, чтобы именно этот человек закончил всё от начала и до конца. Иногда я его пытал, просил, умолял, делал что угодно, чтобы получить от него графику. 

Ведь без красивой графики твоя игра — ничто. И моя изначальная идея была в том, чтобы сделать проект просто красивым. Чтоб там было всё кастомное, чтобы были кастомные звуки, графика. Своя идея, прям всё своё. 

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

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

Почему мотивация важна

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

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

Монетизация

Как я и говорил, сначала я хотел, чтобы игра была просто чем-то красивым, хотел опубликовать её бесплатно и без рекламы. Даже хотел опубликовать код, чтобы люди комментировали, может быть, развивали, помогали. Помню самое первое название — Open Shuttle, мол открытая программа, открытый космос, всё такое.

Но прошло время и я понял, что игра становится играбельной, и вкладывается много сил и энергии. И я понял, что я странный. На текущий момент это почти 3 года работы. Возник вопрос, как эту игру монетизировать.

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

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

Размещение на Google Play

Чтобы игра попала в Google Play, нужно зарегистрировать аккаунт в Google Play Dev Console и заплатить символическую сумму. У меня год назад это составило примерно 25 долларов. Далее вбиваешь данные, прикручиваешь биллинг-аккаунт, домашний адрес и так далее. Надо указать, что ты физлицо, всё весьма интуитивно.

Дальше Google тебя сам ведёт по тому, что нужно заполнить, какой будет тип приложения. Первая интересная вещь, с которой я столкнулся — он предлагает размещение paid (за деньги без рекламы и интеграции) и free (бесплатно и с рекламой). Если у тебя приложение paid, то его изменить на free уже нельзя. Соответственно, если ты назвал своё приложение в проекте как-то красиво и с первой попытки не сделал его в нужном формате, то гугл не даст возможность его удалить и изменить имя на уровне проекта. Можно только скрыть (hide). Если сделал ошибку — нужно создать новое. Поэтому сразу продумывайте названия проектов.

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

Когда я столкнулся с этим, то создал сразу два: Buran-19 и Free Buran-19. Google Play их идентифицирует по названию пакета. Этот пакет указывается в конфигурации в  Gradle скриптах, он должен быть уникальным. Кстати, у меня платное или бесплатное регулируется выставлением одного флага — true или false.

Я думал, что быстренько залью программы без проблем. Скомпилировал. Залил одно, второе, а Google говорит, уже что-то такое есть — попробуйте ещё раз. Начал изучать, как другие люди делают две версии игры. И в Limbo был один пакет — com.limbo.game, а второй — com.limbo.game.free. Ну я сделал точно так же.

Перед аплодом нужно приложения подписать в Android Studio. Потом нужно придумать описание приложения, картинки для разных типов экрана, 8 картинок для разного типа разрешения. Сделать логотипы, картинки, которые Google будет, вероятно, использовать для продвижения твоего приложения в других приложениях. Потом нажимаешь Submit и Google говорит — мы посмотрим, что вы сделали.

С первого раза меня забанили, не дали опубликовать. Они там берут собранную apk, запускают у себя на эмуляторе в саппорте и честно прокликивают все кнопочки. Там есть огромный regulation, и они в его рамках находят ошибки. И у меня оказалась ошибка. Когда дебажил игру, то смотрел, как работают activity для перехода по ссылкам и для тестов выбрал приложение GitHub. Забыл изменить ссылку, и Google сказал, что у вас там вообще референс на что-то левое, не связанное с вашим приложением. Я изменил ссылку, и всё стало хорошо.

Они, кстати, не гарантируют конкретный временной интервал проверки. В итоге я ждал 5 дней. И потом ещё 2. Теперь каждая версия — это 2 дня проверок.

Приложение они залили — и дальше я разбирался с тем, как его опубликовать. У них есть несколько режимов тестирования: internal, альфа, бета и т.д. Ты создаешь список почтовых адресов и добавляешь его в какой-то тип тестирования. Потом загружаешь приложение с новым ID (новую версию) и релиз-нотами, делаешь паблиш тест и оно рассылает всем ссылки, мол «Здравствуйте, вы принимаете участие в тестировании. Готовы или нет?». Но у меня эта функция не заработала без опубликованного production-приложения.

То есть я хотел сначала создать рабочую группу, им через Google распространить свои apk, провести тестирование, а потом опубликовать. Но Google подразумевает, что ты всё протестировал, а уже потом сделал первый релиз. То есть ты можешь тестировать только после второго релиза. Странное ограничение, но, может, они к этому пришли из-за того, что был бы какой-то фрод. Может, снизили нагрузку на сети и дата-центры. Короче, нельзя сделать предварительное тестирование используя магазин приложений Google.

Подключение рекламы

C рекламой тоже интересно получилось. За неё отвечает отдельный сайт — Google AdMob. Этот сервис, который распространяет рекламный контент через баннеры. Там нужно также создать свой аккаунт, тебе выдают id. Надо привязать карточку, куда деньги будут приходить (ха-ха, если будут). Кстати, кажется, деньги поступают на счёт каждый месяц только в том случае, если за этот месяц ты заработал не менее 100 долларов. Ещё 30% оставляешь Google (куда же без него).

Когда ты регистрируешь свой баннер, то выбираешь тип (их там несколько типов, документация прекрасная), выбираешь приложение, к которому его привязываешь. Оно всё связывает между собой, и через время присылает письмо, что ваш баннер зарегистрирован.

Я ожидал, что зарегистрирую свой баннер, открою свою игру, запущу и сразу баннер будет работать, но этого не произошло. Падала ошибка, я долго не мог понять, что это. Нашёл в саппорте Google её описание. Говорилось, что если вы получили её, то, скорее всего, сделали всё правильно. Просто сервис, который вы зарегистрировали, ещё не вышел в продакшн и вам нужно подождать N времени. Кстати, если вы подождёте меньше 24 часов, то нет смысла заводить тикет в саппорт.

И так и вышло. Через 12 часов Google написал, что мой AdMob зарегистрирован — вот айдишник. Я попробовал — не работает. Оказалось, надо, чтобы приложение ещё было выпущено в релиз. А если ты хочешь обойти это, то нужно написать в программе айдишник своего телефона и сказать программе, что эта тестовая трубка (и он в стек-трейсе даже выдаёт кусочек кода). И в этом случае всё заработает. Я сделал, мне показали какие-то средства для ухода за трещинами на коже, я обрадовался и закрыл тикет.

Есть ещё Firebase, который собирает кучу всякой статистики, всё интегрировано со всеми сервисами гугла. Но это отдельная история — до неё я ещё не дошёл.

Команда и благодарности

Прежде всего — моя семья. Серьёзно. Не все могут себе позволить не спать по ночам и сохранять поддержку близких. Моя жена — просто пушка! :)

Саша — художник. Человек с очень незаурядным аналитическим складом ума, всегда пытался уложить всё в чёткие модели. Я у него научился очень многому. Всё, что он сделал — прекрасно. Отвечает тем запросам, которые я предъявлял по проекту. 

Павел Хамхоев — звукорежиссёр. Мой давний друг со студенчества, у него 20 лет опыта работы в индустрии музыки. Мне было достаточно ему поверхностно объяснять, что хочу, и он будто у меня из головы вынимал. Паша — ты огонь!

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

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

Виталик Кротенко — спасибо тебе за качественные переводы по ночам. Я в этом деле такой себе.

Команда плейтестеров: Лёша, Ярик, Жека, Света, Кира, Семён. Ну хоть семь человек таки поиграли. Это уже успех!

Ну и я — в ролях менеджера, гейм-дизайнера и программиста.

Buran-19 в цифрах

  • 2 года 11 месяцев от идеи до релиза

  • 20000 строчек кода

  • 161 класс на Java

  • 78 звуков — 5 полноценных треков, 9 звуковых атмосферных эффектов, 56 звуков игрового процесса, 10 звуков для UI

  • 153 картинки/спрайта

От знакомства с теорией разработки игр до релиза прошло 3 года. За эти три года перерывов, когда ничего не делалось, было суммарно на год с небольшим. И наверное, ещё один год можно выделить на время, которое нужно было, чтобы чему-то учиться. Читать, разбираться, делать тесты, экспериментировать. То есть осознанная работа велась примерно год.

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

Планы

25 ноября 2020 года я встал из-за ноутбука и сказал жене: «Я всё». Мы постояли, пообнимались. Касаемо текущего проекта в планах остались некоторые задачи.

Я купил два домена — buran19.com и buran-19.com, нужно сделать лендинг с Privacy Policy, где указано, что игра не собирает персональные данные. Google требует её, чтобы детям до 13 лет был разрешён доступ к игре. Для этого нужна ссылка.

Нужно расшарить игру в AppStore. Жена говорит, что все платёжеспособные юзеры находятся там. Разные взгляды на жизнь у людей. Уже есть тикет, запихал игру в iOS — нашли пару багов, теперь надо тестировать. Думаю, что в конце января уже будет в AppStore. 

Тестирование на iOS

Разобраться с монетизацией, анализом метрик и сборкой урожая. Изучить Firebase — с помощью которого можно собирать различные данные о приложениях, стектрейсы, user behaviour. Там можно даже удалённо управлять приложением, например, изменять частоту показа рекламы, чтобы юзер не качал новую версию.

Перевести эту статью на английский. Возможно попробовать как-то продвигать, написать блогерам. Не рублю в PR — буду разбираться.

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

Короче, разработать и даже зарелизить игру — дело не последнее.

Если буду делать следующий проект (идеи уже есть), то хочу:

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

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

«Ну штош...»

Для меня самое важное, что разработка этой вещи доведена до конца. Это тест идеи, и, может, игра так себе и никому не будет нужна. Я уже давно это пережил, я не пребываю в иллюзиях. Знаю, вероятность добиться грандиозных успехов незначительна. Но чисто для себя я добился того, что мне хочется.

Buran-19 можно скачать в Google Play. Кроме платной, «чистой» версии игры в магазине есть и бесплатная, но с баннерами.

Буду благодарен за любую конструктивную обратную связь по игре. Вот что ещё интересно:

  • Как люди ищут нишу и есть ли у кого-то опыт исследования этого?
  • Если у кого-то есть опыт успешных релизов, то интересно, из каких составляющих они сложились. Как игра получила 130 тысяч скачиваний, хотя была пакменом? Интересно, был ли проведён анализ результата и стало ли понятно, почему взлетело. Или не взлетело.
  • Хочется сходить на экскурсию в какую-то геймдев-компанию и посмотреть, как это отличается от стандартных айтишных компаний, познакомиться с людьми, проникнуться атмосферой. Идеально, конечно, бы было проникнуть в компанию Valve и поговорить с Гейбом Ньюэллом, но это вряд ли возможно в принципе.

P.S. Harvest and fly! Freely.

программированиеgamesистории успеха
Нашли ошибку в тексте? Напишите нам.
Спасибо,
что читаете наш блог!
Posts popup